Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f449d3600 | |||
| c9ef4464f6 | |||
| ed76820e97 | |||
| e052fb778b | |||
| f05dd1cf74 | |||
| b5962464e8 | |||
| 36ce18a250 | |||
| 58d609e5d2 | |||
| 8e01ae0bf9 | |||
| 7e17df4130 | |||
| 46f68b88e3 | |||
| 1226bc8365 | |||
| 22a1105000 | |||
| b2a528eba1 | |||
| cdd49c6421 | |||
| 775a503d6f | |||
| 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 | |||
| b0482ec157 | |||
| 0aee9d4dd0 | |||
| c6309f0f74 | |||
| 3b90e2f212 | |||
| b2ea6c727c | |||
| 5e706fd003 | |||
| 9c55344e85 | |||
| 4d3b73ae70 | |||
| 5dff56bbb2 | |||
| c734c00742 | |||
| 886e781a4f |
+51
-4
@@ -6,8 +6,17 @@ APP_NAME=shaguabijia-app-server
|
|||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
|
|
||||||
# ===== 数据库 =====
|
# ===== 数据库 =====
|
||||||
# SQLite 本地文件路径。生产环境用 /opt/shaguabijia-app-server/data.db
|
# 本地开发/测试统一用 Docker PostgreSQL:run.bat/run.sh 会自动拉起容器
|
||||||
DATABASE_URL=sqlite:///./data/app.db
|
# (docker-compose.yml + scripts/ensure_pg.py)。详见 docs/database/postgres-migration.md。
|
||||||
|
# 生产用原生 PG,由 scripts/init_postgres.py 写入强随机密码的连接串。
|
||||||
|
# ⚠️ scheme 必须是 postgresql+psycopg://(psycopg3);不要写成 postgresql://(会去找未装的 psycopg2)。
|
||||||
|
DATABASE_URL=postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia
|
||||||
|
# Docker 自动定位:run.bat/run.sh 会自动找 Docker Desktop 并启动——优先从 PATH 上的 docker CLI 反推
|
||||||
|
# 安装目录(装在 D 盘等非默认盘符也能找到),再退到注册表 / 常见目录。仅当你的安装位置极特殊、自动
|
||||||
|
# 探测失败时,才需下面这行显式指到 exe(值可含空格,直接写到行尾即可,无需引号):
|
||||||
|
# DOCKER_DESKTOP_EXE=D:\Program Files\Docker\Docker\Docker Desktop.exe
|
||||||
|
# 实在不想装/启 Docker → 把上面 DATABASE_URL 改成 sqlite 可降级跑(仅救急,PG 专有 SQL/严格性不被验证):
|
||||||
|
# DATABASE_URL=sqlite:///./data/app.db
|
||||||
|
|
||||||
# ===== JWT =====
|
# ===== JWT =====
|
||||||
# 生产部署务必改成随机长字符串,可用:python -c "import secrets; print(secrets.token_urlsafe(64))"
|
# 生产部署务必改成随机长字符串,可用:python -c "import secrets; print(secrets.token_urlsafe(64))"
|
||||||
@@ -33,12 +42,38 @@ HEARTBEAT_TIMEOUT_MINUTES=60
|
|||||||
HEARTBEAT_SCAN_INTERVAL_SEC=60
|
HEARTBEAT_SCAN_INTERVAL_SEC=60
|
||||||
|
|
||||||
# ===== 短信 (mock 模式) =====
|
# ===== 短信 (mock 模式) =====
|
||||||
# mock = true 时,任意 6 位数字均通过,且 /sms/send 不真发短信(只 log)。
|
# mock = true 时,任意 6 位数字均通过,且 /sms/send 不真发短信(只 log)。生产改 false。
|
||||||
# 后续接阿里云/腾讯云短信时,改成 false 并填供应商相关 key。
|
|
||||||
SMS_MOCK=true
|
SMS_MOCK=true
|
||||||
SMS_CODE_TTL_SEC=300
|
SMS_CODE_TTL_SEC=300
|
||||||
SMS_SEND_INTERVAL_SEC=60
|
SMS_SEND_INTERVAL_SEC=60
|
||||||
|
|
||||||
|
# ===== 短信提供商(可切换:jiguang 默认 / aliyun 阿里云号码认证 / chuanglan 创蓝云智)=====
|
||||||
|
# jiguang :本服务生成验证码,极光 REST 只负责下发,本地内存校验(复用上面极光 JG_* 凭证)。
|
||||||
|
# aliyun :阿里云 dypns 号码认证,阿里云生成+下发+校验(Mode A,核验免费);缺凭证时 /sms/* 返 503。
|
||||||
|
# 需在阿里云号码认证控制台开通「融合认证」,并使用系统赠送签名 + 赠送模板。
|
||||||
|
# chuanglan:创蓝云智(253)模板短信,本服务生成码、创蓝只下发、本地校验(Mode B,与极光同);缺凭证 503。
|
||||||
|
# 用 YZM 前缀验证码账号;服务器出网 IP 需在创蓝控制台加白名单(否则 117)。见 docs/integrations/chuanglan/tpl-send.md。
|
||||||
|
SMS_PROVIDER=jiguang
|
||||||
|
ALIYUN_SMS_ACCESS_KEY_ID=
|
||||||
|
ALIYUN_SMS_ACCESS_KEY_SECRET=
|
||||||
|
ALIYUN_SMS_SIGN_NAME=
|
||||||
|
ALIYUN_SMS_TEMPLATE_CODE=
|
||||||
|
# 方案名:留空=默认方案;若填,发码与校验须一致(本服务已共用同一配置项,不会不匹配)。
|
||||||
|
ALIYUN_SMS_SCHEME_NAME=
|
||||||
|
ALIYUN_SMS_ENDPOINT=dypnsapi.aliyuncs.com
|
||||||
|
ALIYUN_SMS_CODE_LENGTH=6
|
||||||
|
ALIYUN_SMS_VALID_TIME_SEC=300
|
||||||
|
ALIYUN_SMS_INTERVAL_SEC=60
|
||||||
|
ALIYUN_SMS_TIMEOUT_SEC=15
|
||||||
|
# --- 创蓝云智(253)---
|
||||||
|
CHUANGLAN_SMS_ACCOUNT=
|
||||||
|
CHUANGLAN_SMS_PASSWORD=
|
||||||
|
CHUANGLAN_SMS_TEMPLATE_ID=1022457679
|
||||||
|
# 短信签名文案【品牌】;模板已关联签名则留空。
|
||||||
|
CHUANGLAN_SMS_SIGNATURE=
|
||||||
|
CHUANGLAN_SMS_ENDPOINT=https://smssh.253.com/msg/sms/v2/tpl/send
|
||||||
|
CHUANGLAN_SMS_TIMEOUT_SEC=10
|
||||||
|
|
||||||
# ===== 测试账号(release 包全流程联调用)=====
|
# ===== 测试账号(release 包全流程联调用)=====
|
||||||
# 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】
|
# 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】
|
||||||
# (real 模式下也跳过校验)、每次登录【都重走新手引导】,并有【每日登录上限】防被人猜到号后脚本刷。
|
# (real 模式下也跳过校验)、每次登录【都重走新手引导】,并有【每日登录上限】防被人猜到号后脚本刷。
|
||||||
@@ -137,3 +172,15 @@ 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
|
||||||
|
|
||||||
|
# ===== 客户端运行日志上报(POST /api/v1/applog/batch)=====
|
||||||
|
# 客户端批量上报的 App 运行日志逐条落到独立滚动文件 logs/app-client.log,供阿里云 Logtail
|
||||||
|
# 采进【独立 SLS logstore】(与服务日志 app-server.log 分开;滚动机制相同,trace_id 可跨层检索)。
|
||||||
|
# 全部有默认值,不填即用默认(定义见 app/core/client_log.py 与 app/api/v1/applog.py)。
|
||||||
|
# CLIENT_LOG_FILE=logs/app-client.log # 落盘路径
|
||||||
|
# CLIENT_LOG_MAX_BYTES=20971520 # 单文件 20MB 滚动
|
||||||
|
# CLIENT_LOG_BACKUP_COUNT=10 # 保留 10 个 → ~200MB 缓冲(给 Logtail 断线留余量)
|
||||||
|
# CLIENT_LOG_SERVICE_NAME=app-client # 输出行 service 字段
|
||||||
|
# APPLOG_MAX_BATCH=500 # 单批最大条数(超 → 422;导入期常量,改需重启)
|
||||||
|
# APPLOG_MAX_BODY_BYTES=2097152 # 请求体上限 2MB(超 → 413;运行期可调)
|
||||||
|
# APPLOG_MAX_MSG_BYTES=8192 # 单条 msg 超此字节数截断
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ secrets/*
|
|||||||
*.log
|
*.log
|
||||||
logs/
|
logs/
|
||||||
|
|
||||||
|
# 本地 admin server(端口 8771)Windows 启动脚本,个人调试用,不入库
|
||||||
|
/run8771.bat
|
||||||
|
|
||||||
# Claude Code 自动持久化的权限 allowlist / 个人本地设置(会话专属,不入库)。
|
# Claude Code 自动持久化的权限 allowlist / 个人本地设置(会话专属,不入库)。
|
||||||
# 需要团队共享的 Claude 配置(commands/ 等)可单独 git add -f,不受此忽略影响。
|
# 需要团队共享的 Claude 配置(commands/ 等)可单独 git add -f,不受此忽略影响。
|
||||||
.claude/settings.json
|
.claude/settings.json
|
||||||
@@ -57,3 +60,6 @@ tests/meituan_coupon_bj.tsv
|
|||||||
tests/meituan_coupon_data.tsv
|
tests/meituan_coupon_data.tsv
|
||||||
tests/meituan_coupon_fz.tsv
|
tests/meituan_coupon_fz.tsv
|
||||||
tests/meituan_coupon_xm.tsv
|
tests/meituan_coupon_xm.tsv
|
||||||
|
|
||||||
|
# git worktrees (superpowers 隔离工作区)
|
||||||
|
.worktrees/
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -76,8 +76,8 @@ Endpoints under `app/api/internal/` are for server-to-server communication (pric
|
|||||||
|
|
||||||
## Database
|
## Database
|
||||||
|
|
||||||
- **Dev**: SQLite (`sqlite:///./data/app.db`), `check_same_thread=False`, no connection pool.
|
- **Dev/Test**: Docker PostgreSQL 16 — `run.sh`/`run.bat` auto-start it via `scripts/ensure_pg.py` + `docker-compose.yml`; `.env.example` ships the PG URL by default; pytest uses the same container's `shaguabijia_test` DB. **Local no longer uses SQLite** (the SQLite branch in `db/session.py` is retained as a fallback only).
|
||||||
- **Prod**: PostgreSQL — just change `DATABASE_URL` in `.env`. Pool size 10 + max overflow 20, pool_recycle 3600.
|
- **Prod**: native PostgreSQL — bootstrap with `scripts/init_postgres.py` (no Docker). 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.
|
- **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`.
|
- **New models**: Define in `app/models/`, import in `app/models/__init__.py`, then run `alembic revision --autogenerate`.
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ All config via `pydantic-settings` in `app/core/config.py`. Single `Settings` cl
|
|||||||
|
|
||||||
## Testing
|
## 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.
|
- `tests/conftest.py`: Sets env vars BEFORE imports, ensures the Docker PG `shaguabijia_test` DB via `scripts/ensure_pg.py`, builds all tables with `Base.metadata.create_all()` (drop+create for a clean start), tears down with `drop_all()`.
|
||||||
- External integrations are monkeypatched in tests (e.g., WeChat Pay, Jiguang, Pangle callbacks) — tests never make real HTTP calls.
|
- 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.
|
- `TestClient` from FastAPI is used for all tests. Rate limiting is disabled globally in tests.
|
||||||
|
|
||||||
|
|||||||
@@ -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,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")
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""add manual high-risk flag and note to user
|
||||||
|
|
||||||
|
Revision ID: user_manual_risk_fields
|
||||||
|
Revises: risk_monitor_generic
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "user_manual_risk_fields"
|
||||||
|
down_revision: str | None = "risk_monitor_generic"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
|
||||||
|
_INVITE_WITHDRAW_PAGE = "invite-withdraws"
|
||||||
|
|
||||||
|
|
||||||
|
def _finance_role() -> sa.TableClause:
|
||||||
|
return sa.table(
|
||||||
|
"admin_role",
|
||||||
|
sa.column("name", sa.String),
|
||||||
|
sa.column("pages", _JSON),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("user") as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column(
|
||||||
|
"is_high_risk",
|
||||||
|
sa.Boolean(),
|
||||||
|
server_default=sa.false(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
batch_op.add_column(sa.Column("high_risk_note", sa.Text(), nullable=True))
|
||||||
|
batch_op.create_index("ix_user_is_high_risk", ["is_high_risk"], unique=False)
|
||||||
|
role = _finance_role()
|
||||||
|
conn = op.get_bind()
|
||||||
|
row = conn.execute(
|
||||||
|
sa.select(role.c.pages).where(role.c.name == "finance")
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if row is not None and _INVITE_WITHDRAW_PAGE not in (row or []):
|
||||||
|
conn.execute(
|
||||||
|
role.update()
|
||||||
|
.where(role.c.name == "finance")
|
||||||
|
.values(pages=[*(row or []), _INVITE_WITHDRAW_PAGE])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
role = _finance_role()
|
||||||
|
conn = op.get_bind()
|
||||||
|
row = conn.execute(
|
||||||
|
sa.select(role.c.pages).where(role.c.name == "finance")
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if row is not None:
|
||||||
|
conn.execute(
|
||||||
|
role.update()
|
||||||
|
.where(role.c.name == "finance")
|
||||||
|
.values(
|
||||||
|
pages=[
|
||||||
|
page for page in (row or []) if page != _INVITE_WITHDRAW_PAGE
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with op.batch_alter_table("user") as batch_op:
|
||||||
|
batch_op.drop_index("ix_user_is_high_risk")
|
||||||
|
batch_op.drop_column("high_risk_note")
|
||||||
|
batch_op.drop_column("is_high_risk")
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -21,22 +21,28 @@ 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": "invite-withdraws", "label": "邀请提现审核"},
|
||||||
|
{"key": "withdraws", "label": "其他提现审核"},
|
||||||
{"key": "price-reports", "label": "低价审核"},
|
{"key": "price-reports", "label": "低价审核"},
|
||||||
{"key": "feedbacks", "label": "用户反馈"},
|
{"key": "feedbacks", "label": "用户反馈"},
|
||||||
]},
|
]},
|
||||||
{"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": "审计日志"},
|
|
||||||
]},
|
]},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -51,13 +57,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", "invite-withdraws", "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",
|
||||||
]},
|
]},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,11 @@
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import 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.ad_ecpm import AdEcpmRecord
|
||||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||||
from app.models.ad_reward import AdRewardRecord
|
from app.models.ad_reward import AdRewardRecord
|
||||||
from app.repositories.ad_feed_reward import FEED_REWARD_UNIT_SECONDS
|
from app.repositories.ad_feed_reward import FEED_REWARD_UNIT_SECONDS
|
||||||
@@ -55,10 +56,21 @@ def _reward_video_rows(
|
|||||||
if user_id is not None:
|
if user_id is not None:
|
||||||
stmt = stmt.where(AdRewardRecord.user_id == user_id)
|
stmt = stmt.where(AdRewardRecord.user_id == user_id)
|
||||||
|
|
||||||
|
records = list(db.execute(stmt).scalars())
|
||||||
|
# S2S 发奖回调不携带实际填充 ADN;用相同用户和 ad_session_id 的展示记录回填。
|
||||||
|
session_ids = {record.ad_session_id for record in records if record.ad_session_id}
|
||||||
|
impression_by_session = {
|
||||||
|
(record.user_id, record.ad_session_id): record
|
||||||
|
for record in db.execute(
|
||||||
|
select(AdEcpmRecord).where(AdEcpmRecord.ad_session_id.in_(session_ids))
|
||||||
|
).scalars()
|
||||||
|
} if session_ids else {}
|
||||||
|
|
||||||
# 用本日之前的累计份数做起点,当日 granted 在其上继续递增 → 与 _granted_cumulative+1 对齐
|
# 用本日之前的累计份数做起点,当日 granted 在其上继续递增 → 与 _granted_cumulative+1 对齐
|
||||||
granted_n: dict[int, int] = _prior_granted_counts(db, date=date, user_id=user_id)
|
granted_n: dict[int, int] = _prior_granted_counts(db, date=date, user_id=user_id)
|
||||||
rows: list[dict] = []
|
rows: list[dict] = []
|
||||||
for rec in db.execute(stmt).scalars():
|
for rec in records:
|
||||||
|
impression = impression_by_session.get((rec.user_id, rec.ad_session_id))
|
||||||
if rec.status == "granted":
|
if rec.status == "granted":
|
||||||
nth = granted_n.get(rec.user_id, 0) + 1
|
nth = granted_n.get(rec.user_id, 0) + 1
|
||||||
granted_n[rec.user_id] = nth
|
granted_n[rec.user_id] = nth
|
||||||
@@ -68,6 +80,8 @@ def _reward_video_rows(
|
|||||||
"record_id": rec.id,
|
"record_id": rec.id,
|
||||||
"user_id": rec.user_id,
|
"user_id": rec.user_id,
|
||||||
"ad_session_id": rec.ad_session_id,
|
"ad_session_id": rec.ad_session_id,
|
||||||
|
"adn": impression.adn if impression is not None else None,
|
||||||
|
"slot_id": impression.slot_id if impression is not None else None,
|
||||||
"app_env": rec.app_env,
|
"app_env": rec.app_env,
|
||||||
"our_code_id": rec.our_code_id,
|
"our_code_id": rec.our_code_id,
|
||||||
"created_at": rec.created_at,
|
"created_at": rec.created_at,
|
||||||
@@ -90,6 +104,8 @@ def _reward_video_rows(
|
|||||||
"record_id": rec.id,
|
"record_id": rec.id,
|
||||||
"user_id": rec.user_id,
|
"user_id": rec.user_id,
|
||||||
"ad_session_id": rec.ad_session_id,
|
"ad_session_id": rec.ad_session_id,
|
||||||
|
"adn": impression.adn if impression is not None else None,
|
||||||
|
"slot_id": impression.slot_id if impression is not None else None,
|
||||||
"app_env": rec.app_env,
|
"app_env": rec.app_env,
|
||||||
"our_code_id": rec.our_code_id,
|
"our_code_id": rec.our_code_id,
|
||||||
"created_at": rec.created_at,
|
"created_at": rec.created_at,
|
||||||
@@ -149,6 +165,78 @@ def _feed_scene_matches(rec: AdFeedRewardRecord, scene: str | None) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _nonblank(value: str | None) -> str | None:
|
||||||
|
value = value.strip() if value else None
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_ad_source(records: list[AdEcpmRecord]) -> tuple[str | None, str | None]:
|
||||||
|
"""仅在候选展示记录指向唯一 ADN 时回填来源,避免错误归因。"""
|
||||||
|
adns = {_nonblank(record.adn) for record in records}
|
||||||
|
adns.discard(None)
|
||||||
|
if len(adns) != 1:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
slots = {_nonblank(record.slot_id) for record in records}
|
||||||
|
slots.discard(None)
|
||||||
|
return next(iter(adns)), next(iter(slots)) if len(slots) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _feed_source_fallbacks(
|
||||||
|
db: Session, records: list[AdFeedRewardRecord]
|
||||||
|
) -> tuple[
|
||||||
|
dict[tuple[int, str], tuple[str | None, str | None]],
|
||||||
|
dict[tuple[int, str, str], tuple[str | None, str | None]],
|
||||||
|
]:
|
||||||
|
"""为旧信息流发奖记录构建安全来源索引。"""
|
||||||
|
session_ids = {record.ad_session_id for record in records if record.ad_session_id}
|
||||||
|
trace_ids = {record.trace_id for record in records if record.trace_id}
|
||||||
|
if not session_ids and not trace_ids:
|
||||||
|
return {}, {}
|
||||||
|
|
||||||
|
filters = []
|
||||||
|
if session_ids:
|
||||||
|
filters.append(AdEcpmRecord.ad_session_id.in_(session_ids))
|
||||||
|
if trace_ids:
|
||||||
|
filters.append(AdEcpmRecord.trace_id.in_(trace_ids))
|
||||||
|
impressions = list(db.execute(select(AdEcpmRecord).where(or_(*filters))).scalars())
|
||||||
|
|
||||||
|
by_session: dict[tuple[int, str], list[AdEcpmRecord]] = {}
|
||||||
|
by_trace_ecpm: dict[tuple[int, str, str], list[AdEcpmRecord]] = {}
|
||||||
|
for impression in impressions:
|
||||||
|
if impression.ad_session_id:
|
||||||
|
by_session.setdefault((impression.user_id, impression.ad_session_id), []).append(impression)
|
||||||
|
if impression.trace_id:
|
||||||
|
by_trace_ecpm.setdefault(
|
||||||
|
(impression.user_id, impression.trace_id, impression.ecpm_raw), []
|
||||||
|
).append(impression)
|
||||||
|
|
||||||
|
return (
|
||||||
|
{key: _unique_ad_source(value) for key, value in by_session.items()},
|
||||||
|
{key: _unique_ad_source(value) for key, value in by_trace_ecpm.items()},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _feed_source(
|
||||||
|
record: AdFeedRewardRecord,
|
||||||
|
*,
|
||||||
|
by_session: dict[tuple[int, str], tuple[str | None, str | None]],
|
||||||
|
by_trace_ecpm: dict[tuple[int, str, str], tuple[str | None, str | None]],
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""返回本条发奖广告的来源;无唯一证据时保留原始空值。"""
|
||||||
|
adn, slot_id = _nonblank(record.adn), _nonblank(record.slot_id)
|
||||||
|
if adn and slot_id:
|
||||||
|
return adn, slot_id
|
||||||
|
|
||||||
|
candidate = by_session.get((record.user_id, record.ad_session_id or ""))
|
||||||
|
if candidate is None and record.trace_id:
|
||||||
|
candidate = by_trace_ecpm.get((record.user_id, record.trace_id, record.ecpm_raw))
|
||||||
|
if candidate is None:
|
||||||
|
return adn, slot_id
|
||||||
|
candidate_adn, candidate_slot_id = candidate
|
||||||
|
return adn or candidate_adn, slot_id or candidate_slot_id
|
||||||
|
|
||||||
|
|
||||||
def _feed_rows(
|
def _feed_rows(
|
||||||
db: Session, *, date: str, user_id: int | None, scene: str | None = None
|
db: Session, *, date: str, user_id: int | None, scene: str | None = None
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
@@ -167,11 +255,17 @@ def _feed_rows(
|
|||||||
if user_id is not None:
|
if user_id is not None:
|
||||||
stmt = stmt.where(AdFeedRewardRecord.user_id == user_id)
|
stmt = stmt.where(AdFeedRewardRecord.user_id == user_id)
|
||||||
|
|
||||||
|
records = list(db.execute(stmt).scalars())
|
||||||
|
by_session, by_trace_ecpm = _feed_source_fallbacks(db, records)
|
||||||
|
|
||||||
# 本日之前的累计**条数**做起点,与发奖侧 granted_unit_total(COUNT granted)对齐
|
# 本日之前的累计**条数**做起点,与发奖侧 granted_unit_total(COUNT granted)对齐
|
||||||
granted_count: dict[int, int] = _feed_prior_granted_count(db, date=date, user_id=user_id)
|
granted_count: dict[int, int] = _feed_prior_granted_count(db, date=date, user_id=user_id)
|
||||||
rows: list[dict] = []
|
rows: list[dict] = []
|
||||||
for rec in db.execute(stmt).scalars():
|
for rec in records:
|
||||||
keep = _feed_scene_matches(rec, scene) # 累计照常推进,这里只决定是否展示本行
|
keep = _feed_scene_matches(rec, scene) # 累计照常推进,这里只决定是否展示本行
|
||||||
|
adn, slot_id = _feed_source(
|
||||||
|
rec, by_session=by_session, by_trace_ecpm=by_trace_ecpm
|
||||||
|
)
|
||||||
if rec.status == "granted":
|
if rec.status == "granted":
|
||||||
# 一条广告 = 1 份(与 grant_feed_reward 同口径:看满一份即发该条满额,不按 unit_count 累加)。
|
# 一条广告 = 1 份(与 grant_feed_reward 同口径:看满一份即发该条满额,不按 unit_count 累加)。
|
||||||
# nth = 账号累计第几**条**(含本日之前),与发奖侧 granted_unit_total+1 对齐;累计照常推进
|
# nth = 账号累计第几**条**(含本日之前),与发奖侧 granted_unit_total+1 对齐;累计照常推进
|
||||||
@@ -188,6 +282,8 @@ def _feed_rows(
|
|||||||
"record_id": rec.id,
|
"record_id": rec.id,
|
||||||
"user_id": rec.user_id,
|
"user_id": rec.user_id,
|
||||||
"ad_session_id": rec.ad_session_id,
|
"ad_session_id": rec.ad_session_id,
|
||||||
|
"adn": adn,
|
||||||
|
"slot_id": slot_id,
|
||||||
"trace_id": rec.trace_id,
|
"trace_id": rec.trace_id,
|
||||||
"app_env": rec.app_env,
|
"app_env": rec.app_env,
|
||||||
"our_code_id": rec.our_code_id,
|
"our_code_id": rec.our_code_id,
|
||||||
@@ -214,6 +310,8 @@ def _feed_rows(
|
|||||||
"record_id": rec.id,
|
"record_id": rec.id,
|
||||||
"user_id": rec.user_id,
|
"user_id": rec.user_id,
|
||||||
"ad_session_id": rec.ad_session_id,
|
"ad_session_id": rec.ad_session_id,
|
||||||
|
"adn": adn,
|
||||||
|
"slot_id": slot_id,
|
||||||
"trace_id": rec.trace_id,
|
"trace_id": rec.trace_id,
|
||||||
"app_env": rec.app_env,
|
"app_env": rec.app_env,
|
||||||
"our_code_id": rec.our_code_id,
|
"our_code_id": rec.our_code_id,
|
||||||
|
|||||||
@@ -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 = (
|
||||||
@@ -70,7 +96,11 @@ _REWARD_DETAIL_KEYS = (
|
|||||||
|
|
||||||
def _reward_detail(row: dict) -> dict:
|
def _reward_detail(row: dict) -> dict:
|
||||||
"""从 audit 行抽出发奖复算明细(给前端展开行渲染因子1/因子2/份数/LT/应发实发)。"""
|
"""从 audit 行抽出发奖复算明细(给前端展开行渲染因子1/因子2/份数/LT/应发实发)。"""
|
||||||
return {k: row[k] for k in _REWARD_DETAIL_KEYS}
|
detail = {key: row[key] for key in _REWARD_DETAIL_KEYS}
|
||||||
|
# 聚合父行可能包含多个 ADN,来源必须保留在每一条发奖明细上。
|
||||||
|
detail["adn"] = row.get("adn")
|
||||||
|
detail["slot_id"] = row.get("slot_id")
|
||||||
|
return detail
|
||||||
|
|
||||||
|
|
||||||
def ad_revenue_report(
|
def ad_revenue_report(
|
||||||
@@ -82,6 +112,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 +210,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({
|
||||||
@@ -212,8 +248,8 @@ def ad_revenue_report(
|
|||||||
"impressions": 0,
|
"impressions": 0,
|
||||||
"ecpm": row["ecpm"],
|
"ecpm": row["ecpm"],
|
||||||
"revenue_yuan": 0.0,
|
"revenue_yuan": 0.0,
|
||||||
"adn": None,
|
"adn": row.get("adn"),
|
||||||
"slot_id": None,
|
"slot_id": row.get("slot_id"),
|
||||||
"has_reward": True,
|
"has_reward": True,
|
||||||
"status": row["status"],
|
"status": row["status"],
|
||||||
"expected_coin": int(row["expected_coin"]),
|
"expected_coin": int(row["expected_coin"]),
|
||||||
@@ -277,14 +313,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 +376,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:
|
||||||
@@ -385,6 +431,38 @@ def ad_revenue_report(
|
|||||||
for k, v in type_map.items()
|
for k, v in type_map.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 经营看板的两类 eCPM 必须按真实展示的 SDK eCPM 加权,不能用发奖状态修正后的
|
||||||
|
# 收益反推。Draw 包含新 draw 与历史 feed;看视频包含福利与提现视频。
|
||||||
|
category_map: dict[str, dict] = {}
|
||||||
|
for event in events:
|
||||||
|
category = (
|
||||||
|
"draw" if event["ad_type"] in {"draw", "feed"}
|
||||||
|
else "video" if event["ad_type"] in {"reward_video", "withdrawal_video"}
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if category is None:
|
||||||
|
continue
|
||||||
|
stat = category_map.setdefault(category, {
|
||||||
|
"impressions": 0,
|
||||||
|
"revenue_yuan": 0.0,
|
||||||
|
"ecpm_fen_sum": 0.0,
|
||||||
|
})
|
||||||
|
impressions = int(event["impressions"])
|
||||||
|
stat["impressions"] += impressions
|
||||||
|
stat["revenue_yuan"] += event["revenue_yuan"]
|
||||||
|
stat["ecpm_fen_sum"] += rewards.parse_ecpm_fen(event["ecpm"]) * impressions
|
||||||
|
category_stats = {
|
||||||
|
key: {
|
||||||
|
"impressions": value["impressions"],
|
||||||
|
"revenue_yuan": round(value["revenue_yuan"], 6),
|
||||||
|
"ecpm_yuan": round(
|
||||||
|
value["ecpm_fen_sum"] / value["impressions"] / 100.0,
|
||||||
|
6,
|
||||||
|
) if value["impressions"] else 0.0,
|
||||||
|
}
|
||||||
|
for key, value in category_map.items()
|
||||||
|
}
|
||||||
|
|
||||||
# 分场景小计(按 feed_scene:展示条数 + 预估收益),同 type_stats 基于全量 events——
|
# 分场景小计(按 feed_scene:展示条数 + 预估收益),同 type_stats 基于全量 events——
|
||||||
# 供数据大盘「领券广告 / 比价广告」卡用。此前大盘是在分页 items 里按 feed_scene 现算,
|
# 供数据大盘「领券广告 / 比价广告」卡用。此前大盘是在分页 items 里按 feed_scene 现算,
|
||||||
# 2026-07-02 起信息流逐条展示行(唯一带收益 + 场景的行)不再进主表 items,现算恒为 0;
|
# 2026-07-02 起信息流逐条展示行(唯一带收益 + 场景的行)不再进主表 items,现算恒为 0;
|
||||||
@@ -439,6 +517,7 @@ def ad_revenue_report(
|
|||||||
"daily": daily,
|
"daily": daily,
|
||||||
"hourly": hourly,
|
"hourly": hourly,
|
||||||
"type_stats": type_stats,
|
"type_stats": type_stats,
|
||||||
|
"category_stats": category_stats,
|
||||||
"scene_stats": scene_stats,
|
"scene_stats": scene_stats,
|
||||||
"dau": dau,
|
"dau": dau,
|
||||||
"items": main_rows[offset:offset + limit],
|
"items": main_rows[offset:offset + limit],
|
||||||
|
|||||||
@@ -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:
|
||||||
resp = meituan.query_order(
|
api_requests += 1
|
||||||
sid=sid, start_time=start_time, end_time=end_time,
|
try:
|
||||||
query_time_type=query_time_type, page=page, limit=100,
|
resp = meituan.query_order(
|
||||||
)
|
sid=sid, start_time=start_time, end_time=end_time,
|
||||||
|
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,30 +439,58 @@ 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:
|
||||||
resp = jd_union.query_order_rows(
|
api_requests += 1
|
||||||
start_time=cur,
|
try:
|
||||||
end_time=win_end,
|
resp = jd_union.query_order_rows(
|
||||||
query_time_type=query_time_type,
|
start_time=cur,
|
||||||
page_index=page,
|
end_time=win_end,
|
||||||
page_size=200,
|
query_time_type=query_time_type,
|
||||||
)
|
page_index=page,
|
||||||
|
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(
|
||||||
|
|||||||
@@ -44,6 +44,25 @@ def set_user_debug_trace(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def set_user_risk(
|
||||||
|
db: Session,
|
||||||
|
user: User,
|
||||||
|
*,
|
||||||
|
is_high_risk: bool,
|
||||||
|
note: str | None,
|
||||||
|
commit: bool = True,
|
||||||
|
) -> User:
|
||||||
|
"""设置人工风险结论和备注,支持与审计日志共用同一事务。"""
|
||||||
|
user.is_high_risk = is_high_risk
|
||||||
|
user.high_risk_note = note.strip() if is_high_risk and note else None
|
||||||
|
if commit:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
else:
|
||||||
|
db.flush()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
def update_feedback_status(
|
def update_feedback_status(
|
||||||
db: Session, feedback: Feedback, *, status: str, commit: bool = True
|
db: Session, feedback: Feedback, *, status: str, commit: bool = True
|
||||||
) -> Feedback:
|
) -> Feedback:
|
||||||
|
|||||||
+526
-261
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||||
|
|||||||
@@ -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"))],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -16,6 +16,7 @@ from app.admin.schemas.user import (
|
|||||||
GrantCashRequest,
|
GrantCashRequest,
|
||||||
GrantCoinsRequest,
|
GrantCoinsRequest,
|
||||||
SetDebugTraceRequest,
|
SetDebugTraceRequest,
|
||||||
|
SetUserRiskRequest,
|
||||||
SetUserStatusRequest,
|
SetUserStatusRequest,
|
||||||
UserCoinRecord,
|
UserCoinRecord,
|
||||||
UserRewardStats,
|
UserRewardStats,
|
||||||
@@ -84,12 +85,21 @@ def get_user_reward_stats(
|
|||||||
db: AdminDb,
|
db: AdminDb,
|
||||||
date_from: Annotated[datetime | None, Query()] = None,
|
date_from: Annotated[datetime | None, Query()] = None,
|
||||||
date_to: Annotated[datetime | None, Query()] = None,
|
date_to: Annotated[datetime | None, Query()] = None,
|
||||||
|
withdraw_source: Annotated[
|
||||||
|
str | None, Query(pattern="^(coin_cash|invite_cash)$")
|
||||||
|
] = None,
|
||||||
) -> UserRewardStats:
|
) -> UserRewardStats:
|
||||||
"""提现详情抽屉「用户统计区」。date_from/date_to 都不传 = 注册至今(全量)。"""
|
"""提现详情抽屉「用户统计区」。date_from/date_to 都不传 = 注册至今(全量)。"""
|
||||||
if user_repo.get_user_by_id(db, user_id) is None:
|
if not user_repo.user_exists(db, user_id):
|
||||||
raise HTTPException(status_code=404, detail="用户不存在")
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
return UserRewardStats(
|
return UserRewardStats(
|
||||||
**queries.user_reward_stats(db, user_id, date_from=date_from, date_to=date_to)
|
**queries.user_reward_stats(
|
||||||
|
db,
|
||||||
|
user_id,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
withdraw_source=withdraw_source,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -138,6 +148,50 @@ def set_user_status(
|
|||||||
return OkResponse()
|
return OkResponse()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{user_id}/risk", response_model=OkResponse, summary="设置人工高风险标记与备注")
|
||||||
|
def set_user_risk(
|
||||||
|
user_id: int,
|
||||||
|
body: SetUserRiskRequest,
|
||||||
|
request: Request,
|
||||||
|
admin: Annotated[
|
||||||
|
AdminUser, Depends(require_role("operator", "finance"))
|
||||||
|
],
|
||||||
|
db: AdminDb,
|
||||||
|
) -> OkResponse:
|
||||||
|
user = user_repo.get_user_by_id(db, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
before = {
|
||||||
|
"is_high_risk": user.is_high_risk,
|
||||||
|
"high_risk_note": user.high_risk_note,
|
||||||
|
}
|
||||||
|
mutations.set_user_risk(
|
||||||
|
db,
|
||||||
|
user,
|
||||||
|
is_high_risk=body.is_high_risk,
|
||||||
|
note=body.note,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
write_audit(
|
||||||
|
db,
|
||||||
|
admin,
|
||||||
|
action="user.risk.set",
|
||||||
|
target_type="user",
|
||||||
|
target_id=user_id,
|
||||||
|
detail={
|
||||||
|
"before": before,
|
||||||
|
"after": {
|
||||||
|
"is_high_risk": user.is_high_risk,
|
||||||
|
"high_risk_note": user.high_risk_note,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ip=get_client_ip(request),
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return OkResponse()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{user_id}/debug-trace", response_model=OkResponse, summary="开关调试链接权限")
|
@router.post("/{user_id}/debug-trace", response_model=OkResponse, summary="开关调试链接权限")
|
||||||
def set_user_debug_trace(
|
def set_user_debug_trace(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""admin 提现:列表(读)+ 单笔重试查单 + 批量对账(写,带审计)。
|
"""admin 提现:列表(读)+ 单笔/批量查单与审核操作(写,带审计)。
|
||||||
|
|
||||||
提现的钱逻辑(查微信/退款/撤单/幂等)全部复用 app.repositories.wallet,admin 只触发 + 记审计。
|
提现的钱逻辑(查微信/退款/撤单/幂等)全部复用 app.repositories.wallet,admin 只触发 + 记审计。
|
||||||
这些 wallet 函数内部各自 commit(涉及微信调用),审计在其后单独 commit:操作本身幂等,
|
这些 wallet 函数内部各自 commit(涉及微信调用),审计在其后单独 commit:操作本身幂等,
|
||||||
@@ -14,17 +14,16 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|||||||
from app.admin.audit import write_audit
|
from app.admin.audit import write_audit
|
||||||
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||||
from app.admin.repositories import queries
|
from app.admin.repositories import queries
|
||||||
from app.admin.schemas.common import CursorPage
|
|
||||||
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.wallet import (
|
from app.admin.schemas.wallet import (
|
||||||
CashTxnOut,
|
CashTxnOut,
|
||||||
ReconcileResult,
|
InviteOverviewOut,
|
||||||
|
WithdrawBulkItemResult,
|
||||||
WithdrawBulkRejectRequest,
|
WithdrawBulkRejectRequest,
|
||||||
WithdrawBulkRequest,
|
WithdrawBulkRequest,
|
||||||
WithdrawBulkResult,
|
WithdrawBulkResult,
|
||||||
WithdrawBulkItemResult,
|
|
||||||
WithdrawDetailOut,
|
WithdrawDetailOut,
|
||||||
WithdrawLedgerCheckOut,
|
|
||||||
WithdrawListItemOut,
|
WithdrawListItemOut,
|
||||||
WithdrawOrderOut,
|
WithdrawOrderOut,
|
||||||
WithdrawRejectRequest,
|
WithdrawRejectRequest,
|
||||||
@@ -83,8 +82,16 @@ def list_withdraws(
|
|||||||
cursor=cursor,
|
cursor=cursor,
|
||||||
)
|
)
|
||||||
# 联表带出手机号/昵称 + 累计成功提现(本页 user_id 批量富化,2 条聚合查询,无 N+1)。
|
# 联表带出手机号/昵称 + 累计成功提现(本页 user_id 批量富化,2 条聚合查询,无 N+1)。
|
||||||
enrichment = queries.withdraw_list_enrichment(db, [o.user_id for o in items])
|
enrichment = queries.withdraw_list_enrichment(
|
||||||
_empty = {"phone": None, "nickname": None, "cumulative_success_cents": 0}
|
db, [o.user_id for o in items], source=source
|
||||||
|
)
|
||||||
|
_empty = {
|
||||||
|
"phone": None,
|
||||||
|
"nickname": None,
|
||||||
|
"is_high_risk": False,
|
||||||
|
"high_risk_note": None,
|
||||||
|
"cumulative_success_cents": 0,
|
||||||
|
}
|
||||||
out_items = [
|
out_items = [
|
||||||
WithdrawListItemOut.model_validate(o).model_copy(
|
WithdrawListItemOut.model_validate(o).model_copy(
|
||||||
update=enrichment.get(o.user_id, _empty)
|
update=enrichment.get(o.user_id, _empty)
|
||||||
@@ -95,8 +102,13 @@ def list_withdraws(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/summary", response_model=WithdrawSummaryOut, summary="提现审核台统计")
|
@router.get("/summary", response_model=WithdrawSummaryOut, summary="提现审核台统计")
|
||||||
def withdraws_summary(db: AdminDb) -> WithdrawSummaryOut:
|
def withdraws_summary(
|
||||||
return WithdrawSummaryOut(**queries.withdraw_summary(db))
|
db: AdminDb,
|
||||||
|
source: Annotated[
|
||||||
|
str | None, Query(pattern="^(coin_cash|invite_cash)$")
|
||||||
|
] = None,
|
||||||
|
) -> WithdrawSummaryOut:
|
||||||
|
return WithdrawSummaryOut(**queries.withdraw_summary(db, source=source))
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -129,13 +141,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,
|
||||||
@@ -155,13 +165,13 @@ def withdraw_health_check(db: AdminDb) -> WxpayHealthCheckOut:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ledger-check", response_model=WithdrawLedgerCheckOut, summary="提现资金账本校验")
|
|
||||||
def withdraw_ledger_check(db: AdminDb) -> WithdrawLedgerCheckOut:
|
|
||||||
return WithdrawLedgerCheckOut(**queries.withdraw_ledger_check(db))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{out_bill_no}", response_model=WithdrawDetailOut, summary="提现单详情")
|
@router.get("/{out_bill_no}", response_model=WithdrawDetailOut, summary="提现单详情")
|
||||||
def withdraw_detail(out_bill_no: str, db: AdminDb) -> WithdrawDetailOut:
|
def withdraw_detail(
|
||||||
|
out_bill_no: str,
|
||||||
|
db: AdminDb,
|
||||||
|
date_from: Annotated[datetime | None, Query()] = None,
|
||||||
|
date_to: Annotated[datetime | None, Query()] = None,
|
||||||
|
) -> WithdrawDetailOut:
|
||||||
order = queries.get_withdraw_by_out_bill_no(db, out_bill_no)
|
order = queries.get_withdraw_by_out_bill_no(db, out_bill_no)
|
||||||
if order is None:
|
if order is None:
|
||||||
raise HTTPException(status_code=404, detail="提现单不存在")
|
raise HTTPException(status_code=404, detail="提现单不存在")
|
||||||
@@ -174,6 +184,8 @@ def withdraw_detail(out_bill_no: str, db: AdminDb) -> WithdrawDetailOut:
|
|||||||
id=user.id,
|
id=user.id,
|
||||||
phone=user.phone,
|
phone=user.phone,
|
||||||
nickname=user.nickname,
|
nickname=user.nickname,
|
||||||
|
is_high_risk=user.is_high_risk,
|
||||||
|
high_risk_note=user.high_risk_note,
|
||||||
status=user.status,
|
status=user.status,
|
||||||
wechat_nickname=user.wechat_nickname,
|
wechat_nickname=user.wechat_nickname,
|
||||||
wechat_avatar_url=user.wechat_avatar_url,
|
wechat_avatar_url=user.wechat_avatar_url,
|
||||||
@@ -197,37 +209,36 @@ def withdraw_detail(out_bill_no: str, db: AdminDb) -> WithdrawDetailOut:
|
|||||||
recent_withdraws,
|
recent_withdraws,
|
||||||
overview["cash_balance_cents"] if overview else 0,
|
overview["cash_balance_cents"] if overview else 0,
|
||||||
)
|
)
|
||||||
|
detail_enrichment = queries.withdraw_list_enrichment(
|
||||||
|
db, [order.user_id], source=order.source
|
||||||
|
).get(order.user_id, {})
|
||||||
|
|
||||||
return WithdrawDetailOut(
|
return WithdrawDetailOut(
|
||||||
order=WithdrawOrderOut.model_validate(order),
|
order=WithdrawOrderOut.model_validate(order),
|
||||||
user=user_snapshot,
|
user=user_snapshot,
|
||||||
|
cumulative_success_cents=int(
|
||||||
|
detail_enrichment.get("cumulative_success_cents", 0)
|
||||||
|
),
|
||||||
risk_flags=risk_flags,
|
risk_flags=risk_flags,
|
||||||
risk_score=risk_score,
|
risk_score=risk_score,
|
||||||
recent_withdraws=[WithdrawOrderOut.model_validate(o) for o in recent_withdraws],
|
recent_withdraws=[WithdrawOrderOut.model_validate(o) for o in recent_withdraws],
|
||||||
recent_cash_transactions=[CashTxnOut.model_validate(t) for t in recent_cash_transactions],
|
recent_cash_transactions=[CashTxnOut.model_validate(t) for t in recent_cash_transactions],
|
||||||
audit_logs=[AdminAuditLogOut.model_validate(log) for log in audit_logs],
|
audit_logs=[AdminAuditLogOut.model_validate(log) for log in audit_logs],
|
||||||
|
invite_overview=(
|
||||||
|
InviteOverviewOut(
|
||||||
|
**queries.invite_overview(
|
||||||
|
db,
|
||||||
|
order.user_id,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if order.source == "invite_cash"
|
||||||
|
else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# 注意:/reconcile 必须在 /{out_bill_no}/refresh 之前声明(静态路径优先于路径参数)
|
|
||||||
@router.post("/reconcile", response_model=ReconcileResult, summary="批量对账(扫超时 pending 单)")
|
|
||||||
def reconcile(
|
|
||||||
request: Request,
|
|
||||||
admin: Annotated[AdminUser, Depends(require_role("finance"))],
|
|
||||||
db: AdminDb,
|
|
||||||
older_than_minutes: Annotated[int, Query(ge=0)] = 15,
|
|
||||||
) -> ReconcileResult:
|
|
||||||
try:
|
|
||||||
result = wallet_repo.reconcile_pending_withdraws(db, older_than_minutes=older_than_minutes)
|
|
||||||
except wxpay.WxPayNotConfiguredError as e:
|
|
||||||
raise HTTPException(status_code=503, detail="微信支付未配置") from e
|
|
||||||
write_audit(
|
|
||||||
db, admin, action="withdraw.reconcile", target_type="withdraw", target_id=None,
|
|
||||||
detail=result, ip=get_client_ip(request), commit=True,
|
|
||||||
)
|
|
||||||
return ReconcileResult(**result)
|
|
||||||
|
|
||||||
|
|
||||||
def _bulk_result(items: list[WithdrawBulkItemResult]) -> WithdrawBulkResult:
|
def _bulk_result(items: list[WithdrawBulkItemResult]) -> WithdrawBulkResult:
|
||||||
success = sum(1 for item in items if item.ok)
|
success = sum(1 for item in items if item.ok)
|
||||||
return WithdrawBulkResult(
|
return WithdrawBulkResult(
|
||||||
|
|||||||
@@ -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 秒份数")
|
||||||
@@ -37,6 +40,8 @@ class AdRevenueRecord(BaseModel):
|
|||||||
expected_coin: int = Field(..., description="按公式复算应发金币")
|
expected_coin: int = Field(..., description="按公式复算应发金币")
|
||||||
actual_coin: int = Field(..., description="实际入账金币")
|
actual_coin: int = Field(..., description="实际入账金币")
|
||||||
matched: bool = Field(..., description="复算与实发是否一致")
|
matched: bool = Field(..., description="复算与实发是否一致")
|
||||||
|
adn: str | None = Field(None, description="本条发奖对应的实际填充 ADN 子渠道")
|
||||||
|
slot_id: str | None = Field(None, description="本条发奖对应的底层 mediation rit")
|
||||||
|
|
||||||
|
|
||||||
class AdRevenueDaily(BaseModel):
|
class AdRevenueDaily(BaseModel):
|
||||||
@@ -44,7 +49,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);非全量视图/无数据为空"
|
||||||
)
|
)
|
||||||
@@ -70,6 +75,7 @@ class AdRevenueTypeStat(BaseModel):
|
|||||||
|
|
||||||
impressions: int = Field(..., description="该类型展示条数合计")
|
impressions: int = Field(..., description="该类型展示条数合计")
|
||||||
revenue_yuan: float = Field(..., description="该类型预估收益合计(元)")
|
revenue_yuan: float = Field(..., description="该类型预估收益合计(元)")
|
||||||
|
ecpm_yuan: float | None = Field(None, description="按真实展示次数加权的 SDK eCPM(元/千次)")
|
||||||
|
|
||||||
|
|
||||||
class AdRevenueRow(BaseModel):
|
class AdRevenueRow(BaseModel):
|
||||||
@@ -93,7 +99,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 折算之和;"
|
||||||
@@ -136,6 +145,10 @@ class AdRevenueReportOut(BaseModel):
|
|||||||
default_factory=dict,
|
default_factory=dict,
|
||||||
description="按广告类型(ad_type)小计 {ad_type: {impressions, revenue_yuan}};前端取 draw / reward_video 做分类大盘",
|
description="按广告类型(ad_type)小计 {ad_type: {impressions, revenue_yuan}};前端取 draw / reward_video 做分类大盘",
|
||||||
)
|
)
|
||||||
|
category_stats: dict[str, AdRevenueTypeStat] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="按经营分类小计:draw=draw+历史 feed,video=reward_video+withdrawal_video",
|
||||||
|
)
|
||||||
scene_stats: dict[str, AdRevenueTypeStat] = Field(
|
scene_stats: dict[str, AdRevenueTypeStat] = Field(
|
||||||
default_factory=dict,
|
default_factory=dict,
|
||||||
description="按信息流场景(feed_scene)小计 {comparison/coupon/welfare: {impressions, revenue_yuan}};"
|
description="按信息流场景(feed_scene)小计 {comparison/coupon/welfare: {impressions, revenue_yuan}};"
|
||||||
@@ -150,7 +163,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)。穿山甲无用户/类型/场景维度,"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -29,6 +29,7 @@ class AdminComparisonListItem(BaseModel):
|
|||||||
source_price_cents: int | None = None
|
source_price_cents: int | None = None
|
||||||
best_price_cents: int | None = None
|
best_price_cents: int | None = None
|
||||||
saved_amount_cents: int | None = None
|
saved_amount_cents: int | None = None
|
||||||
|
ordered: bool = False
|
||||||
# debug 概览
|
# debug 概览
|
||||||
total_ms: int | None = None
|
total_ms: int | None = None
|
||||||
step_count: int | None = None
|
step_count: int | None = None
|
||||||
@@ -36,14 +37,40 @@ 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
|
||||||
|
device_model_name: str | None = None
|
||||||
rom_vendor: str | None = None
|
rom_vendor: str | None = None
|
||||||
rom_name: str | None = None
|
rom_name: str | None = None
|
||||||
|
rom_version: int | 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)。"""
|
||||||
|
|
||||||
@@ -60,7 +87,6 @@ class AdminComparisonDetail(AdminComparisonListItem):
|
|||||||
skipped_dish_names: list = []
|
skipped_dish_names: list = []
|
||||||
# 全量环境
|
# 全量环境
|
||||||
device_manufacturer: str | None = None
|
device_manufacturer: str | None = None
|
||||||
rom_version: int | None = None
|
|
||||||
android_sdk: int | None = None
|
android_sdk: int | None = None
|
||||||
app_version_code: int | None = None
|
app_version_code: int | None = None
|
||||||
source_app_version: str | None = None
|
source_app_version: str | None = None
|
||||||
@@ -71,3 +97,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
|
||||||
|
|
||||||
@@ -32,7 +32,10 @@ class FeedbackOut(BaseModel):
|
|||||||
# 提交端环境快照(feedback 表列):提交版本号 / 机型OS版本;改版前的历史反馈为 None
|
# 提交端环境快照(feedback 表列):提交版本号 / 机型OS版本;改版前的历史反馈为 None
|
||||||
app_version: str | None = None
|
app_version: str | None = None
|
||||||
device_model: str | None = None
|
device_model: str | None = None
|
||||||
|
device_model_name: str | None = None
|
||||||
|
device_manufacturer: str | None = None
|
||||||
rom_name: str | None = None
|
rom_name: str | None = None
|
||||||
|
rom_version: int | None = None
|
||||||
android_version: str | None = None
|
android_version: str | None = None
|
||||||
# 联表瞬态字段(queries._attach_user_info 挂):列表展示完整手机号,点手机号查该用户全部反馈
|
# 联表瞬态字段(queries._attach_user_info 挂):列表展示完整手机号,点手机号查该用户全部反馈
|
||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
@@ -55,6 +58,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
|
||||||
@@ -16,6 +16,8 @@ class AdminUserListItem(BaseModel):
|
|||||||
register_channel: str
|
register_channel: str
|
||||||
status: str
|
status: str
|
||||||
debug_trace_enabled: bool = False
|
debug_trace_enabled: bool = False
|
||||||
|
is_high_risk: bool = False
|
||||||
|
high_risk_note: str | None = None
|
||||||
wechat_openid: str | None = None
|
wechat_openid: str | None = None
|
||||||
wechat_nickname: str | None = None
|
wechat_nickname: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
@@ -118,3 +120,19 @@ class SetUserStatusRequest(BaseModel):
|
|||||||
|
|
||||||
class SetDebugTraceRequest(BaseModel):
|
class SetDebugTraceRequest(BaseModel):
|
||||||
enabled: bool = Field(..., description="是否给该用户开「复制调试链接」权限")
|
enabled: bool = Field(..., description="是否给该用户开「复制调试链接」权限")
|
||||||
|
|
||||||
|
|
||||||
|
class SetUserRiskRequest(BaseModel):
|
||||||
|
is_high_risk: bool = Field(..., description="是否标记为高风险用户")
|
||||||
|
note: str | None = Field(
|
||||||
|
None,
|
||||||
|
max_length=500,
|
||||||
|
description="高风险备注;标记高风险时必填,解除后清空",
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("note")
|
||||||
|
@classmethod
|
||||||
|
def validate_note(cls, value: str | None, info):
|
||||||
|
if info.data.get("is_high_risk") and not (value or "").strip():
|
||||||
|
raise ValueError("标记高风险时必须填写原因")
|
||||||
|
return value.strip() if value and value.strip() else None
|
||||||
|
|||||||
+27
-25
@@ -60,6 +60,8 @@ class WithdrawListItemOut(WithdrawOrderOut):
|
|||||||
|
|
||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
nickname: str | None = None
|
nickname: str | None = None
|
||||||
|
is_high_risk: bool = False
|
||||||
|
high_risk_note: str | None = None
|
||||||
cumulative_success_cents: int = 0 # 累计成功提现 = SUM(amount_cents) WHERE status='success'
|
cumulative_success_cents: int = 0 # 累计成功提现 = SUM(amount_cents) WHERE status='success'
|
||||||
|
|
||||||
|
|
||||||
@@ -67,7 +69,10 @@ class WithdrawSummaryOut(BaseModel):
|
|||||||
reviewing_count: int
|
reviewing_count: int
|
||||||
reviewing_amount_cents: int
|
reviewing_amount_cents: int
|
||||||
pending_count: int
|
pending_count: int
|
||||||
|
success_count: int
|
||||||
|
rejected_count: int
|
||||||
failed_count: int
|
failed_count: int
|
||||||
|
total_count: int
|
||||||
today_success_count: int
|
today_success_count: int
|
||||||
today_success_amount_cents: int
|
today_success_amount_cents: int
|
||||||
today_rejected_count: int
|
today_rejected_count: int
|
||||||
@@ -77,6 +82,8 @@ class WithdrawUserSnapshot(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
phone: str
|
phone: str
|
||||||
nickname: str | None = None
|
nickname: str | None = None
|
||||||
|
is_high_risk: bool = False
|
||||||
|
high_risk_note: str | None = None
|
||||||
status: str
|
status: str
|
||||||
wechat_nickname: str | None = None
|
wechat_nickname: str | None = None
|
||||||
wechat_avatar_url: str | None = None
|
wechat_avatar_url: str | None = None
|
||||||
@@ -87,19 +94,34 @@ class WithdrawUserSnapshot(BaseModel):
|
|||||||
withdraw_success_cents: int
|
withdraw_success_cents: int
|
||||||
|
|
||||||
|
|
||||||
|
class InviteeDetailOut(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
phone: str
|
||||||
|
registered_at: datetime
|
||||||
|
invite_success: bool
|
||||||
|
first_compare_store: str | None = None
|
||||||
|
first_compare_products: str | None = None
|
||||||
|
first_order_store: str | None = None
|
||||||
|
first_order_products: str | None = None
|
||||||
|
first_order_amount_cents: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class InviteOverviewOut(BaseModel):
|
||||||
|
invite_total: int
|
||||||
|
invite_success_total: int
|
||||||
|
items: list[InviteeDetailOut]
|
||||||
|
|
||||||
|
|
||||||
class WithdrawDetailOut(BaseModel):
|
class WithdrawDetailOut(BaseModel):
|
||||||
order: WithdrawOrderOut
|
order: WithdrawOrderOut
|
||||||
user: WithdrawUserSnapshot | None = None
|
user: WithdrawUserSnapshot | None = None
|
||||||
|
cumulative_success_cents: int = 0
|
||||||
risk_flags: list[str]
|
risk_flags: list[str]
|
||||||
risk_score: int
|
risk_score: int
|
||||||
recent_withdraws: list[WithdrawOrderOut]
|
recent_withdraws: list[WithdrawOrderOut]
|
||||||
recent_cash_transactions: list[CashTxnOut]
|
recent_cash_transactions: list[CashTxnOut]
|
||||||
audit_logs: list[AdminAuditLogOut]
|
audit_logs: list[AdminAuditLogOut]
|
||||||
|
invite_overview: InviteOverviewOut | None = None
|
||||||
|
|
||||||
class ReconcileResult(BaseModel):
|
|
||||||
checked: int
|
|
||||||
resolved: int
|
|
||||||
|
|
||||||
|
|
||||||
class WithdrawBulkRequest(BaseModel):
|
class WithdrawBulkRequest(BaseModel):
|
||||||
@@ -128,26 +150,6 @@ class WithdrawBulkResult(BaseModel):
|
|||||||
items: list[WithdrawBulkItemResult]
|
items: list[WithdrawBulkItemResult]
|
||||||
|
|
||||||
|
|
||||||
class WithdrawLedgerCheckOut(BaseModel):
|
|
||||||
ok: bool
|
|
||||||
# 普通现金账(coin_cash:金币兑换的现金)
|
|
||||||
cash_balance_total_cents: int
|
|
||||||
cash_transaction_total_cents: int
|
|
||||||
balance_diff_cents: int
|
|
||||||
missing_withdraw_txn_count: int
|
|
||||||
missing_refund_txn_count: int
|
|
||||||
duplicate_refund_txn_count: int
|
|
||||||
refund_txn_on_non_terminal_count: int
|
|
||||||
# 邀请奖励金账(invite_cash:与普通现金物理隔离,各自对账)。默认 0 向后兼容。
|
|
||||||
invite_cash_balance_total_cents: int = 0
|
|
||||||
invite_cash_transaction_total_cents: int = 0
|
|
||||||
invite_balance_diff_cents: int = 0
|
|
||||||
invite_missing_withdraw_txn_count: int = 0
|
|
||||||
invite_missing_refund_txn_count: int = 0
|
|
||||||
invite_duplicate_refund_txn_count: int = 0
|
|
||||||
invite_refund_txn_on_non_terminal_count: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class WxpayHealthCheckOut(BaseModel):
|
class WxpayHealthCheckOut(BaseModel):
|
||||||
ok: bool
|
ok: bool
|
||||||
wxpay_configured: bool
|
wxpay_configured: bool
|
||||||
|
|||||||
+13
-1
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status
|
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
|
||||||
|
|
||||||
@@ -77,6 +77,18 @@ def get_current_user_optional(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_ip(request: Request) -> str:
|
||||||
|
"""客户端真实 IP:生产经 nginx 反代优先 X-Forwarded-For 首段,否则直连 IP。
|
||||||
|
|
||||||
|
路由层取 IP 的规范实现(analytics / applog 等共用);core 层(ratelimit)因不能
|
||||||
|
反向依赖 app.api,自留一份私有副本。
|
||||||
|
"""
|
||||||
|
xff = request.headers.get("x-forwarded-for")
|
||||||
|
if xff:
|
||||||
|
return xff.split(",")[0].strip()
|
||||||
|
return request.client.host if request.client else ""
|
||||||
|
|
||||||
|
|
||||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
OptionalUser = Annotated[User | None, Depends(get_current_user_optional)]
|
OptionalUser = Annotated[User | None, Depends(get_current_user_optional)]
|
||||||
DbSession = Annotated[Session, Depends(get_db)]
|
DbSession = Annotated[Session, Depends(get_db)]
|
||||||
|
|||||||
+94
-98
@@ -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,50 +179,11 @@ 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:
|
rec = crud_ad.grant_ad_reward(
|
||||||
try:
|
db, user_id, trans_id, ecpm=ecpm, ad_session_id=ad_session_id,
|
||||||
boost, _balance = crud_signin.boost_today_signin(
|
reward_scene=REWARD_SCENE_REWARD_VIDEO, boost_round_id=boost_round_id,
|
||||||
db, user_id, ad_ref_id=trans_id, commit=False
|
reward_name=params.get("reward_name"), raw=raw[:1024],
|
||||||
)
|
)
|
||||||
except crud_signin.NotSignedTodayError:
|
|
||||||
db.rollback()
|
|
||||||
rec = crud_ad.record_external_reward(
|
|
||||||
db, user_id, trans_id, coin=0, reward_scene=reward_scene,
|
|
||||||
ad_session_id=ad_session_id, ecpm=ecpm,
|
|
||||||
reward_name=params.get("reward_name"), raw=raw[:1024],
|
|
||||||
status="not_signed",
|
|
||||||
)
|
|
||||||
except crud_signin.AlreadyBoostedError:
|
|
||||||
db.rollback()
|
|
||||||
rec = crud_ad.record_external_reward(
|
|
||||||
db, user_id, trans_id, coin=0, reward_scene=reward_scene,
|
|
||||||
ad_session_id=ad_session_id, ecpm=ecpm,
|
|
||||||
reward_name=params.get("reward_name"), raw=raw[:1024],
|
|
||||||
status="already_boosted",
|
|
||||||
)
|
|
||||||
except crud_signin.LastCycleDayBoostBlockedError:
|
|
||||||
db.rollback()
|
|
||||||
rec = crud_ad.record_external_reward(
|
|
||||||
db, user_id, trans_id, coin=0, reward_scene=reward_scene,
|
|
||||||
ad_session_id=ad_session_id, ecpm=ecpm,
|
|
||||||
reward_name=params.get("reward_name"), raw=raw[:1024],
|
|
||||||
status="last_day",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
rec = crud_ad.record_external_reward(
|
|
||||||
db, user_id, trans_id, coin=boost.coin_awarded,
|
|
||||||
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm=ecpm,
|
|
||||||
reward_name=params.get("reward_name"), raw=raw[:1024],
|
|
||||||
commit=False,
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(rec)
|
|
||||||
else:
|
|
||||||
rec = crud_ad.grant_ad_reward(
|
|
||||||
db, user_id, trans_id, ecpm=ecpm, ad_session_id=ad_session_id,
|
|
||||||
reward_scene=REWARD_SCENE_REWARD_VIDEO,
|
|
||||||
reward_name=params.get("reward_name"), raw=raw[:1024],
|
|
||||||
)
|
|
||||||
except crud_ad.UnknownUserError:
|
except crud_ad.UnknownUserError:
|
||||||
logger.warning("pangle callback unknown user_id=%d trans_id=%s", user_id, trans_id)
|
logger.warning("pangle callback unknown user_id=%d trans_id=%s", user_id, trans_id)
|
||||||
return PangleCallbackOut(is_verify=False, reason=REASON_UNKNOWN_USER)
|
return PangleCallbackOut(is_verify=False, reason=REASON_UNKNOWN_USER)
|
||||||
@@ -242,6 +213,46 @@ def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/reward-result/{ad_session_id}",
|
||||||
|
response_model=AdRewardResultOut,
|
||||||
|
summary="按 ad_session_id 查本次广告的权威发奖结果",
|
||||||
|
dependencies=[Depends(rate_limit(120, 60, "ad-reward-result"))],
|
||||||
|
)
|
||||||
|
def reward_result(
|
||||||
|
user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
ad_session_id: str = Path(..., min_length=8, max_length=64, description="本次广告会话 id"),
|
||||||
|
) -> AdRewardResultOut:
|
||||||
|
"""客户端看完激励视频后轮询本接口拿**本次实发金币 + 本轮累计**用于弹窗,不再用余额差 /
|
||||||
|
coin_per_ad 估算(修「弹窗数值与真实金币对不上」)。
|
||||||
|
|
||||||
|
round_coin 是「恭喜累计获得奖励」弹窗真正显示的数:本轮(= 客户端的 boost_round_id)所有
|
||||||
|
granted 记录之和。由服务端求和而不是客户端自己累加——客户端进程被杀/重建后本地累计会丢,
|
||||||
|
发奖记录不会。取不到轮 id(pending / 老客户端 / extra 丢失)时为 null,客户端退回显示单条。
|
||||||
|
|
||||||
|
S2S 回调异步:查不到记录 = 回调还没到 → 返 200 + status='pending' 让客户端继续重试,
|
||||||
|
**不返 404**(404 只表示路由不存在)。纯只读:发奖仍只由验签过的 S2S 回调完成,
|
||||||
|
这里不写库、不产生任何奖励,被刷也只是查自己的记录。
|
||||||
|
"""
|
||||||
|
rec = crud_ad.find_by_session(db, user.id, ad_session_id)
|
||||||
|
if rec is None:
|
||||||
|
# 连记录都没有 → 不知道属于哪一轮,round_coin 一并为 null(不是 0,0 会被当成"本轮没赚到")
|
||||||
|
return AdRewardResultOut(
|
||||||
|
ad_session_id=ad_session_id, status="pending", coin=None, round_coin=None,
|
||||||
|
)
|
||||||
|
# 本条不是 granted 时**仍返本轮累计**(这条按 0 计):第 3 条撞每日上限那下,客户端的限额
|
||||||
|
# toast 要显示的是前两条已到账的总额,不是空。
|
||||||
|
round_coin = (
|
||||||
|
crud_ad.round_coin_total(db, user.id, rec.boost_round_id)
|
||||||
|
if rec.boost_round_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return AdRewardResultOut(
|
||||||
|
ad_session_id=ad_session_id, status=rec.status, coin=rec.coin, round_coin=round_coin,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@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,55 +340,27 @@ 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
|
# 优先用客户端按 ad_session_id 上报的真实 eCPM(走与正式发奖相同的公式);
|
||||||
)
|
# 取不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍能验出非零金币。
|
||||||
except crud_signin.NotSignedTodayError:
|
ad_session_id = payload.ad_session_id if payload is not None else None
|
||||||
db.rollback()
|
ecpm_val = "200"
|
||||||
rec = crud_ad.record_external_reward(
|
if ad_session_id:
|
||||||
db, user.id, trans_id, coin=0, reward_scene=reward_scene,
|
ecpm_rec = crud_ecpm.find_by_session(db, user_id=user.id, ad_session_id=ad_session_id)
|
||||||
raw="client debug test-grant signin_boost", status="not_signed",
|
if ecpm_rec is not None and rewards.parse_ecpm_fen(ecpm_rec.ecpm_raw) > 0:
|
||||||
)
|
ecpm_val = ecpm_rec.ecpm_raw
|
||||||
except crud_signin.AlreadyBoostedError:
|
try:
|
||||||
db.rollback()
|
rec = crud_ad.grant_ad_reward(
|
||||||
rec = crud_ad.record_external_reward(
|
db, user.id, trans_id, ecpm=ecpm_val, ad_session_id=ad_session_id,
|
||||||
db, user.id, trans_id, coin=0, reward_scene=reward_scene,
|
boost_round_id=boost_round_id,
|
||||||
raw="client debug test-grant signin_boost", status="already_boosted",
|
reward_name="测试发奖", raw=f"client debug test-grant ecpm={ecpm_val}",
|
||||||
)
|
)
|
||||||
except crud_signin.LastCycleDayBoostBlockedError:
|
except crud_ad.UnknownUserError as e:
|
||||||
db.rollback()
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e
|
||||||
rec = crud_ad.record_external_reward(
|
|
||||||
db, user.id, trans_id, coin=0, reward_scene=reward_scene,
|
|
||||||
raw="client debug test-grant signin_boost", status="last_day",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
rec = crud_ad.record_external_reward(
|
|
||||||
db, user.id, trans_id, coin=boost.coin_awarded,
|
|
||||||
reward_scene=reward_scene, reward_name="测试签到膨胀",
|
|
||||||
raw="client debug test-grant signin_boost", commit=False,
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(rec)
|
|
||||||
else:
|
|
||||||
# 优先用客户端按 ad_session_id 上报的真实 eCPM(走与正式发奖相同的公式);
|
|
||||||
# 取不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍能验出非零金币。
|
|
||||||
ad_session_id = payload.ad_session_id if payload is not None else None
|
|
||||||
ecpm_val = "200"
|
|
||||||
if ad_session_id:
|
|
||||||
ecpm_rec = crud_ecpm.find_by_session(db, user_id=user.id, ad_session_id=ad_session_id)
|
|
||||||
if ecpm_rec is not None and rewards.parse_ecpm_fen(ecpm_rec.ecpm_raw) > 0:
|
|
||||||
ecpm_val = ecpm_rec.ecpm_raw
|
|
||||||
try:
|
|
||||||
rec = crud_ad.grant_ad_reward(
|
|
||||||
db, user.id, trans_id, ecpm=ecpm_val, ad_session_id=ad_session_id,
|
|
||||||
reward_name="测试发奖", raw=f"client debug test-grant ecpm={ecpm_val}",
|
|
||||||
)
|
|
||||||
except crud_ad.UnknownUserError as e:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e
|
|
||||||
|
|
||||||
(used, limit, coin_per, round_count, cooldown_until,
|
(used, limit, coin_per, round_count, cooldown_until,
|
||||||
_watched, _watch_limit) = crud_ad.today_status(db, user.id)
|
_watched, _watch_limit) = crud_ad.today_status(db, user.id)
|
||||||
|
|||||||
+18
-11
@@ -6,26 +6,33 @@ POST /api/v1/analytics/events — 批量接收新手引导(及后续)埋点,appe
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
import logging
|
||||||
|
|
||||||
from app.api.deps import DbSession
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, get_client_ip
|
||||||
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:
|
|
||||||
"""取客户端 IP:生产经 nginx 反代优先 X-Forwarded-For 第一段,否则直连 IP(同 admin get_client_ip)。"""
|
|
||||||
xff = request.headers.get("x-forwarded-for")
|
|
||||||
if xff:
|
|
||||||
return xff.split(",")[0].strip()
|
|
||||||
return request.client.host if request.client else ""
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/events", response_model=AnalyticsIngestOut, summary="批量上报埋点事件")
|
@router.post("/events", response_model=AnalyticsIngestOut, summary="批量上报埋点事件")
|
||||||
def ingest_events(
|
def ingest_events(
|
||||||
batch: AnalyticsBatchIn, request: Request, db: DbSession
|
batch: AnalyticsBatchIn, request: Request, db: DbSession
|
||||||
) -> AnalyticsIngestOut:
|
) -> AnalyticsIngestOut:
|
||||||
n = analytics_repo.record_batch(db, batch, client_ip=_client_ip(request))
|
n = analytics_repo.record_batch(db, batch, client_ip=get_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)
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""客户端运行日志批量上报接口。
|
||||||
|
|
||||||
|
POST /api/v1/applog/batch — 批量接收客户端运行日志,逐条写专用滚动文件 logs/app-client.log
|
||||||
|
(供 Logtail 采进独立 SLS logstore)。鉴权同 analytics(不强制登录,user_id 可选在 body)。
|
||||||
|
fire-and-forget:写失败也不 500(避免客户端重试风暴);超批 422、超体积 413、msg 超限截断。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
|
||||||
|
from app.api.deps import get_client_ip
|
||||||
|
from app.core.client_log import write_records
|
||||||
|
from app.core.ratelimit import rate_limit
|
||||||
|
from app.schemas.applog import AppLogBatchIn, AppLogIngestOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/applog", tags=["applog"])
|
||||||
|
|
||||||
|
|
||||||
|
def _enforce_body_limit(request: Request) -> None:
|
||||||
|
"""依赖:body 声明过大直接 413(在 body 校验前拦截)。缺 Content-Length 由 nginx 兜底。"""
|
||||||
|
max_bytes = int(os.getenv("APPLOG_MAX_BODY_BYTES", str(1024 * 1024 * 2)))
|
||||||
|
cl = request.headers.get("content-length")
|
||||||
|
if cl is not None and cl.isdigit() and int(cl) > max_bytes:
|
||||||
|
raise HTTPException(status_code=413, detail="日志批量过大")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/batch",
|
||||||
|
response_model=AppLogIngestOut,
|
||||||
|
summary="批量上报客户端运行日志",
|
||||||
|
dependencies=[
|
||||||
|
Depends(rate_limit(120, 60, "applog-batch")),
|
||||||
|
Depends(_enforce_body_limit),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def ingest_logs(batch: AppLogBatchIn, request: Request) -> AppLogIngestOut:
|
||||||
|
received, dropped = write_records(
|
||||||
|
batch.logs,
|
||||||
|
meta={
|
||||||
|
"device_id": batch.device_id,
|
||||||
|
"user_id": batch.user_id,
|
||||||
|
"app_ver": batch.app_ver,
|
||||||
|
"platform": batch.platform,
|
||||||
|
"sent_at": batch.sent_at,
|
||||||
|
},
|
||||||
|
client_ip=get_client_ip(request),
|
||||||
|
)
|
||||||
|
return AppLogIngestOut(received=received, dropped=dropped)
|
||||||
+445
-19
@@ -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))
|
||||||
@@ -154,10 +292,39 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit
|
|||||||
detail="登录尝试过于频繁,请稍后再试",
|
detail="登录尝试过于频繁,请稍后再试",
|
||||||
)
|
)
|
||||||
|
|
||||||
if not verify_code(req.phone, req.code):
|
try:
|
||||||
|
ok = verify_code(req.phone, req.code)
|
||||||
|
except SmsError as e: # provider 校验降级(如阿里云接口异常)→ 原样透出其状态码(503),别误报「验证码错误」
|
||||||
|
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
|
||||||
|
if not ok:
|
||||||
|
# 校验码错误才记风控失败事件(provider 降级 503 已在上面提前 raise,不算「验证失败」)
|
||||||
|
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 +333,265 @@ 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="登录尝试过于频繁,请稍后再试",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ok = verify_code(req.phone, req.code)
|
||||||
|
except SmsError as e: # provider 校验降级(如阿里云接口异常)→ 原样透出其状态码(503),别误报「验证码错误」
|
||||||
|
raise HTTPException(status_code=e.status_code, detail=str(e)) from e
|
||||||
|
if not ok:
|
||||||
|
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 对")
|
||||||
|
|||||||
+54
-11
@@ -25,22 +25,34 @@ import uuid
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, HTTPException, Request, status
|
from fastapi import APIRouter, BackgroundTasks, 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
|
||||||
|
from app.services.comparison_llm_backfill import backfill_comparison_llm_cost
|
||||||
|
|
||||||
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 +64,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},
|
||||||
@@ -65,13 +81,17 @@ def _harvest_running_blocking(
|
|||||||
def _harvest_done_blocking(
|
def _harvest_done_blocking(
|
||||||
trace_id: str, user_id: int | None, done_params: dict, business_type: str,
|
trace_id: str, user_id: int | None, done_params: dict, business_type: str,
|
||||||
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:
|
) -> int:
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
rec, newly_success = crud_compare.harvest_done(
|
rec, newly_success = crud_compare.harvest_done(
|
||||||
db, trace_id=trace_id, user_id=user_id, done_params=done_params,
|
db, trace_id=trace_id, user_id=user_id, done_params=done_params,
|
||||||
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,
|
||||||
@@ -83,6 +103,7 @@ def _harvest_done_blocking(
|
|||||||
# 不在此处发邀请奖:#113 已把发奖口径从「比价」移到「实际下单」(order.py),harvest
|
# 不在此处发邀请奖:#113 已把发奖口径从「比价」移到「实际下单」(order.py),harvest
|
||||||
# 只记录比价、不发奖。否则比价先于下单 + try_reward 幂等闸会让奖落在「比价」这步,
|
# 只记录比价、不发奖。否则比价先于下单 + try_reward 幂等闸会让奖落在「比价」这步,
|
||||||
# 架空 #113 的「下单才发奖」防刷意图(newly_success 仅留作日志观测)。
|
# 架空 #113 的「下单才发奖」防刷意图(newly_success 仅留作日志观测)。
|
||||||
|
return rec.id
|
||||||
|
|
||||||
|
|
||||||
def _harvest_abort_blocking(
|
def _harvest_abort_blocking(
|
||||||
@@ -199,25 +220,38 @@ 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,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
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 语义。)
|
||||||
@@ -225,19 +259,25 @@ async def price_step(request: Request, user: OptionalUser) -> dict[str, Any]:
|
|||||||
if action.get("command") == "done" and not resp.get("continue", True):
|
if action.get("command") == "done" and not resp.get("continue", True):
|
||||||
done_params = action.get("params") or {}
|
done_params = action.get("params") or {}
|
||||||
try:
|
try:
|
||||||
await run_in_threadpool(
|
record_id = await run_in_threadpool(
|
||||||
_harvest_done_blocking, trace_id, (user.id if user else None),
|
_harvest_done_blocking, trace_id, (user.id if user else None),
|
||||||
done_params, "food",
|
done_params, "food",
|
||||||
meta.get("device_id"), meta.get("device_info"),
|
meta.get("device_id"), meta.get("device_info"),
|
||||||
resp.get("trace_url") or done_params.get("trace_url"),
|
resp.get("trace_url") or done_params.get("trace_url"),
|
||||||
)
|
)
|
||||||
|
background_tasks.add_task(
|
||||||
|
backfill_comparison_llm_cost, record_id, trace_id
|
||||||
|
)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
logger.warning("harvest_done failed trace=%s: %s", trace_id, e)
|
logger.warning("harvest_done failed trace=%s: %s", trace_id, e)
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@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 +290,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 →
|
||||||
|
|||||||
@@ -16,10 +16,11 @@ import logging
|
|||||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, status
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, status
|
||||||
|
|
||||||
from app.api.deps import CurrentUser, DbSession
|
from app.api.deps import CurrentUser, DbSession
|
||||||
from app.db.session import SessionLocal
|
|
||||||
from app.models.comparison import ComparisonRecord
|
|
||||||
from app.repositories import comparison as crud_compare
|
from app.repositories import 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,13 +28,57 @@ from app.schemas.compare_record import (
|
|||||||
ComparisonRecordOut,
|
ComparisonRecordOut,
|
||||||
ComparisonRecordPage,
|
ComparisonRecordPage,
|
||||||
)
|
)
|
||||||
from app.services.pricebot_llm_calls import fetch_llm_calls
|
from app.services.comparison_llm_backfill import backfill_comparison_llm_cost
|
||||||
|
|
||||||
logger = logging.getLogger("shagua.compare_record")
|
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 +90,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,后台用
|
||||||
@@ -66,30 +118,7 @@ def report_record(
|
|||||||
def _backfill_llm_calls(record_id: int, trace_id: str) -> None:
|
def _backfill_llm_calls(record_id: int, trace_id: str) -> None:
|
||||||
"""后台回填本次比价的 LLM 调用明细 + 派生 llm_call_count/retry_count。
|
"""后台回填本次比价的 LLM 调用明细 + 派生 llm_call_count/retry_count。
|
||||||
独立 DB session(请求 session 此时已关);拉取/写库失败只 log,绝不影响已落库的上报。"""
|
独立 DB session(请求 session 此时已关);拉取/写库失败只 log,绝不影响已落库的上报。"""
|
||||||
calls = fetch_llm_calls(trace_id)
|
backfill_comparison_llm_cost(record_id, trace_id)
|
||||||
if not calls:
|
|
||||||
return
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
rec = db.get(ComparisonRecord, record_id)
|
|
||||||
if rec is None:
|
|
||||||
return
|
|
||||||
rec.llm_calls = calls
|
|
||||||
rec.llm_call_count = len(calls)
|
|
||||||
rec.retry_count = sum(1 for c in calls if c.get("error"))
|
|
||||||
# token 累加(usage 已被 pricebot llm_client 归一为 prompt/completion_tokens;
|
|
||||||
# error 的调用 usage 可能为 None,or {} 兜底)
|
|
||||||
rec.input_tokens = sum((c.get("usage") or {}).get("prompt_tokens") or 0 for c in calls)
|
|
||||||
rec.output_tokens = sum((c.get("usage") or {}).get("completion_tokens") or 0 for c in calls)
|
|
||||||
db.commit()
|
|
||||||
logger.info(
|
|
||||||
"backfill llm_calls trace=%s n=%d in_tok=%d out_tok=%d",
|
|
||||||
trace_id, len(calls), rec.input_tokens, rec.output_tokens,
|
|
||||||
)
|
|
||||||
except Exception as e: # noqa: BLE001 best-effort
|
|
||||||
logger.warning("backfill llm_calls failed trace=%s: %s", trace_id, e)
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -112,13 +141,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)
|
||||||
+154
-75
@@ -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(
|
||||||
MeituanCoupon.commission_percent >= 3.0,
|
db,
|
||||||
MeituanCoupon.city_id == city_id,
|
conds=[
|
||||||
)
|
MeituanCoupon.commission_percent >= 3.0,
|
||||||
deduped = base.distinct(MeituanCoupon.dedup_key).order_by(
|
MeituanCoupon.city_id == city_id,
|
||||||
MeituanCoupon.dedup_key,
|
],
|
||||||
MeituanCoupon.commission_percent.desc(),
|
# 同一去重键留佣金最高那条
|
||||||
).subquery()
|
dedup_order=[MeituanCoupon.commission_percent.desc()],
|
||||||
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。
|
||||||
|
conds = [
|
||||||
|
MeituanCoupon.sale_volume_num.isnot(None),
|
||||||
|
MeituanCoupon.city_id == city_id,
|
||||||
|
]
|
||||||
|
if req.platform is not None:
|
||||||
|
conds.append(MeituanCoupon.platform == req.platform)
|
||||||
try:
|
try:
|
||||||
# 1) DISTINCT ON (dedup_key):每个去重键(品牌|名|价)只留销量最高那条(同销量再按佣金)
|
ids, has_next = _paged_dedup_ids(
|
||||||
base = select(MeituanCoupon).where(
|
db,
|
||||||
MeituanCoupon.sale_volume_num.isnot(None),
|
conds=conds,
|
||||||
MeituanCoupon.city_id == city_id,
|
# 每个去重键(品牌|名|价)只留销量最高那条(同销量再按佣金)
|
||||||
)
|
dedup_order=[
|
||||||
if req.platform is not None:
|
MeituanCoupon.sale_volume_num.desc(),
|
||||||
base = base.where(MeituanCoupon.platform == req.platform)
|
MeituanCoupon.commission_percent.desc(),
|
||||||
deduped = base.distinct(MeituanCoupon.dedup_key).order_by(
|
],
|
||||||
MeituanCoupon.dedup_key,
|
|
||||||
MeituanCoupon.sale_volume_num.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(
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""客户端运行日志专用落盘 writer(独立于服务端 app-server.log)。
|
||||||
|
|
||||||
|
- 独占 logger "shagua.client_log" + 自己的 RotatingFileHandler,propagate=False → 不污染 app-server.log。
|
||||||
|
- 每条按「白名单键(client_ts/level/trace_id/tag/msg)提顶层 + 其余并入 data」封装,再
|
||||||
|
json.dumps 成一行写出(钉死 SLS 索引列;见 spec §5)。formatter 用 %(message)s——行本身
|
||||||
|
已是 JSON,不能再过 JsonFormatter 二次编码。
|
||||||
|
- 滚动 20MB×10(env 可调),与服务日志同机制。
|
||||||
|
⚠️ 依赖 --workers 1:RotatingFileHandler 多进程并发 doRollover 会损坏/丢日志;扩 worker
|
||||||
|
前换 QueueHandler→单写入者 / 外部 logrotate(copytruncate) / 写 stdout 交 journald。
|
||||||
|
|
||||||
|
服务端补的字段(time/source/service/client_ip/device_id/...)是「事实」,与客户端自述分开。
|
||||||
|
`time` 用服务端接收时间作 SLS 主时间(客户端时钟不可信),client_ts 另存为可查字段。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 仅这些客户端键提到输出行顶层;其余(含客户端自带 data)一律并入 data,防 SLS 索引列爆炸
|
||||||
|
_TOP_LEVEL_KEYS = ("client_ts", "level", "trace_id", "tag", "msg")
|
||||||
|
|
||||||
|
# trace_id/tag/level 是 SLS 索引字段(spec §8):给长度上限,防客户端塞超大值撑爆索引/抬升成本。
|
||||||
|
# (msg 另有字节截断;data 内的值不限,留待后续「服务端脱敏」knob。)
|
||||||
|
_MAX_LEVEL_LEN = 16
|
||||||
|
_MAX_TRACE_ID_LEN = 256
|
||||||
|
_MAX_TAG_LEN = 128
|
||||||
|
|
||||||
|
_logger: logging.Logger | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _max_msg_bytes() -> int:
|
||||||
|
# 每次调用现读 env(不设模块级常量):便于运行期调整 / 测试 monkeypatch,开销可忽略。
|
||||||
|
return int(os.getenv("APPLOG_MAX_MSG_BYTES", "8192"))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_logger() -> logging.Logger:
|
||||||
|
lg = logging.getLogger("shagua.client_log") # 与仓库 shagua.* 业务 logger 命名一致
|
||||||
|
lg.setLevel(logging.INFO)
|
||||||
|
lg.propagate = False # 不冒泡到 root → 不写进 app-server.log
|
||||||
|
log_file = os.getenv("CLIENT_LOG_FILE") or str(
|
||||||
|
Path(os.getenv("LOG_DIR", "logs")) / "app-client.log"
|
||||||
|
)
|
||||||
|
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
handler = RotatingFileHandler(
|
||||||
|
log_file,
|
||||||
|
maxBytes=int(os.getenv("CLIENT_LOG_MAX_BYTES", str(20 * 1024 * 1024))),
|
||||||
|
backupCount=int(os.getenv("CLIENT_LOG_BACKUP_COUNT", "10")),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
handler.setFormatter(logging.Formatter("%(message)s")) # 行已是 JSON,不再包装
|
||||||
|
lg.handlers = [handler]
|
||||||
|
return lg
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger() -> logging.Logger:
|
||||||
|
global _logger
|
||||||
|
if _logger is None:
|
||||||
|
_logger = _build_logger()
|
||||||
|
return _logger
|
||||||
|
|
||||||
|
|
||||||
|
def reset_client_logger() -> None:
|
||||||
|
"""测试用:关闭并丢弃当前 logger,使下次 get_logger 按当时 env 重建(切临时文件)。"""
|
||||||
|
global _logger
|
||||||
|
if _logger is not None:
|
||||||
|
for h in list(_logger.handlers):
|
||||||
|
h.close()
|
||||||
|
_logger.handlers = []
|
||||||
|
_logger = None
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_msg(msg: str) -> tuple[str, bool]:
|
||||||
|
raw = msg.encode("utf-8")
|
||||||
|
limit = _max_msg_bytes()
|
||||||
|
if len(raw) <= limit:
|
||||||
|
return msg, False
|
||||||
|
# 按字节截断后解码,忽略截断处半个多字节字符
|
||||||
|
return raw[:limit].decode("utf-8", "ignore") + "…[truncated]", True
|
||||||
|
|
||||||
|
|
||||||
|
def _build_line(
|
||||||
|
record: dict, *, meta: dict, client_ip: str, service: str, now_iso: str
|
||||||
|
) -> str:
|
||||||
|
out: dict = {
|
||||||
|
"time": now_iso,
|
||||||
|
"source": "client",
|
||||||
|
"service": service,
|
||||||
|
"client_ip": client_ip,
|
||||||
|
}
|
||||||
|
# 批级公共字段(非空才带)
|
||||||
|
for k in ("device_id", "user_id", "app_ver", "platform", "sent_at"):
|
||||||
|
v = meta.get(k)
|
||||||
|
if v is not None:
|
||||||
|
out[k] = v
|
||||||
|
# 白名单键提顶层(索引字段做长度上限 + 统一转 str,保证 SLS 里类型/大小可控)
|
||||||
|
if record.get("level") is not None:
|
||||||
|
out["level"] = str(record["level"])[:_MAX_LEVEL_LEN].upper()
|
||||||
|
if record.get("trace_id"):
|
||||||
|
out["trace_id"] = str(record["trace_id"])[:_MAX_TRACE_ID_LEN]
|
||||||
|
if record.get("tag"):
|
||||||
|
out["tag"] = str(record["tag"])[:_MAX_TAG_LEN]
|
||||||
|
if record.get("client_ts") is not None:
|
||||||
|
out["client_ts"] = record["client_ts"]
|
||||||
|
if record.get("msg") is not None:
|
||||||
|
msg, truncated = _truncate_msg(str(record["msg"]))
|
||||||
|
out["msg"] = msg
|
||||||
|
if truncated:
|
||||||
|
out["msg_truncated"] = True
|
||||||
|
# 其余键并入 data:先收白名单外的散键(兜底),再让客户端显式的 data 覆盖同名散键
|
||||||
|
# —— 显式 data 为准,不静默丢客户端明确给的值(保真)。
|
||||||
|
data: dict = {}
|
||||||
|
for k, v in record.items():
|
||||||
|
if k in _TOP_LEVEL_KEYS or k == "data":
|
||||||
|
continue
|
||||||
|
data[k] = v
|
||||||
|
client_data = record.get("data")
|
||||||
|
if isinstance(client_data, dict):
|
||||||
|
data.update(client_data)
|
||||||
|
if data:
|
||||||
|
out["data"] = data
|
||||||
|
return json.dumps(out, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def write_records(
|
||||||
|
records: list[dict], *, meta: dict, client_ip: str
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""把一批客户端日志逐条写入专用文件。返回 (received, dropped)。
|
||||||
|
|
||||||
|
尽力而为(fire-and-forget):logger 初始化或单条写入失败只跳过并计 dropped,
|
||||||
|
不抛给上层——端点因此永不因写日志而 500。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
lg = get_logger()
|
||||||
|
except Exception: # noqa: BLE001 — 初始化失败也不能让端点 500
|
||||||
|
logging.getLogger("shagua.applog").exception("client log writer init failed")
|
||||||
|
return 0, len(records)
|
||||||
|
service = os.getenv("CLIENT_LOG_SERVICE_NAME", "app-client")
|
||||||
|
# 一批共用同一「服务端接收时间」:降开销,且语义上是服务端「收到」而非逐条「处理」时间。
|
||||||
|
now_iso = datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]
|
||||||
|
received = dropped = 0
|
||||||
|
for rec in records:
|
||||||
|
try:
|
||||||
|
line = _build_line(
|
||||||
|
rec, meta=meta, client_ip=client_ip, service=service, now_iso=now_iso
|
||||||
|
)
|
||||||
|
lg.info(line)
|
||||||
|
received += 1
|
||||||
|
except Exception: # noqa: BLE001 — 坏条跳过,不影响其余
|
||||||
|
logging.getLogger("shagua.applog").exception(
|
||||||
|
"client log record dropped client_ip=%s", client_ip
|
||||||
|
)
|
||||||
|
dropped += 1
|
||||||
|
return received, dropped
|
||||||
+168
-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,8 +138,52 @@ 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 # 单个验证码最多校验失败次数,超过即作废(防爆破)
|
||||||
|
|
||||||
|
# ===== 短信提供商(可切换:极光 / 阿里云号码认证 / 创蓝云智)=====
|
||||||
|
# jiguang(默认):本服务生成验证码,极光只负责下发,本地内存校验(自管码,现状不变)。
|
||||||
|
# aliyun:阿里云 dypns 号码认证,阿里云生成+下发+校验(Mode A);缺凭证时 /sms/* 返 503(优雅降级)。
|
||||||
|
# chuanglan:创蓝云智(253)模板短信,本服务生成码、创蓝只下发、本地校验(Mode B,与极光同);缺凭证 503。
|
||||||
|
SMS_PROVIDER: Literal["jiguang", "aliyun", "chuanglan"] = "jiguang"
|
||||||
|
ALIYUN_SMS_ACCESS_KEY_ID: str = ""
|
||||||
|
ALIYUN_SMS_ACCESS_KEY_SECRET: str = ""
|
||||||
|
ALIYUN_SMS_SIGN_NAME: str = "" # 系统赠送签名(自定义签名下发易失败)
|
||||||
|
ALIYUN_SMS_TEMPLATE_CODE: str = "" # 赠送模板 CODE(须与赠送签名搭配)
|
||||||
|
ALIYUN_SMS_SCHEME_NAME: str = "" # 方案名(可空=默认方案);send/check 共用避免不匹配
|
||||||
|
ALIYUN_SMS_ENDPOINT: str = "dypnsapi.aliyuncs.com"
|
||||||
|
ALIYUN_SMS_CODE_LENGTH: int = 6 # 验证码位数(CodeLength 4~8)
|
||||||
|
ALIYUN_SMS_VALID_TIME_SEC: int = 300 # 验证码有效期秒(ValidTime);短信内 min 文案 = //60
|
||||||
|
ALIYUN_SMS_INTERVAL_SEC: int = 60 # 单号发送频控秒(Interval);核验免费
|
||||||
|
ALIYUN_SMS_TIMEOUT_SEC: int = 15 # 阿里云 API 读/连超时秒
|
||||||
|
|
||||||
|
# --- 创蓝云智(253)模板短信,Mode B 自管码,httpx 直连 + HMAC 签名(见 docs/integrations/chuanglan/tpl-send.md)---
|
||||||
|
CHUANGLAN_SMS_ACCOUNT: str = "" # YZM 前缀验证码账号
|
||||||
|
CHUANGLAN_SMS_PASSWORD: str = "" # API 密码(仅用于本地算 HMAC 签名,不随请求上行)
|
||||||
|
CHUANGLAN_SMS_TEMPLATE_ID: str = "" # 模板 ID(控制台创建)
|
||||||
|
CHUANGLAN_SMS_SIGNATURE: str = "" # 短信签名文案【品牌】;模板已关联签名则留空
|
||||||
|
CHUANGLAN_SMS_ENDPOINT: str = "https://smssh.253.com/msg/sms/v2/tpl/send"
|
||||||
|
CHUANGLAN_SMS_TIMEOUT_SEC: int = 10 # httpx 读/连超时秒
|
||||||
|
|
||||||
|
@property
|
||||||
|
def aliyun_sms_configured(self) -> bool:
|
||||||
|
"""阿里云短信凭证齐全(缺则 SMS_PROVIDER=aliyun 时 /sms/* 返 503,而非启动崩)。"""
|
||||||
|
return bool(
|
||||||
|
self.ALIYUN_SMS_ACCESS_KEY_ID
|
||||||
|
and self.ALIYUN_SMS_ACCESS_KEY_SECRET
|
||||||
|
and self.ALIYUN_SMS_SIGN_NAME
|
||||||
|
and self.ALIYUN_SMS_TEMPLATE_CODE
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def chuanglan_sms_configured(self) -> bool:
|
||||||
|
"""创蓝短信凭证齐全(缺则 SMS_PROVIDER=chuanglan 时 /sms/send 返 503,而非启动崩)。"""
|
||||||
|
return bool(
|
||||||
|
self.CHUANGLAN_SMS_ACCOUNT
|
||||||
|
and self.CHUANGLAN_SMS_PASSWORD
|
||||||
|
and self.CHUANGLAN_SMS_TEMPLATE_ID
|
||||||
|
)
|
||||||
|
|
||||||
# ===== 测试账号(release 包全流程联调用)=====
|
# ===== 测试账号(release 包全流程联调用)=====
|
||||||
# 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】
|
# 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】
|
||||||
# (real 模式下也跳过校验)、每次登录【强制重走新手引导】,并设【每日使用次数上限】防被人
|
# (real 模式下也跳过校验)、每次登录【强制重走新手引导】,并设【每日使用次数上限】防被人
|
||||||
@@ -106,6 +207,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 +231,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 +280,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 +309,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 不同(后台各自
|
||||||
@@ -287,6 +420,12 @@ class Settings(BaseSettings):
|
|||||||
# 靠这个共享密钥头(X-Internal-Secret)校验,与 pricebot 侧 INTERNAL_API_SECRET 同值。
|
# 靠这个共享密钥头(X-Internal-Secret)校验,与 pricebot 侧 INTERNAL_API_SECRET 同值。
|
||||||
# 默认空 = 内部写端点关闭(返 503),启用前两边都要配上同一高熵串。
|
# 默认空 = 内部写端点关闭(返 503),启用前两边都要配上同一高熵串。
|
||||||
INTERNAL_API_SECRET: str = ""
|
INTERNAL_API_SECRET: str = ""
|
||||||
|
# Current compare clients are persisted by server-side harvest. Repair any
|
||||||
|
# recent terminal rows left without LLM usage by transient upstream/auth failures.
|
||||||
|
LLM_COST_BACKFILL_ENABLED: bool = True
|
||||||
|
LLM_COST_BACKFILL_INTERVAL_SEC: int = 300
|
||||||
|
LLM_COST_BACKFILL_BATCH_SIZE: int = 100
|
||||||
|
LLM_COST_BACKFILL_LOOKBACK_DAYS: int = 30
|
||||||
|
|
||||||
# ===== 媒体文件(用户头像上传)=====
|
# ===== 媒体文件(用户头像上传)=====
|
||||||
# 落盘根目录(data/ 已 gitignore,上传不进库);对外经 StaticFiles 挂在 MEDIA_URL_PREFIX。
|
# 落盘根目录(data/ 已 gitignore,上传不进库);对外经 StaticFiles 挂在 MEDIA_URL_PREFIX。
|
||||||
@@ -294,6 +433,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 +458,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
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Periodic repair worker for comparison records with missing LLM token cost."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.services.comparison_llm_backfill import repair_missing_comparison_llm_costs
|
||||||
|
from app.services.pricebot_llm_calls import pricebot_llm_auth_ready
|
||||||
|
|
||||||
|
logger = logging.getLogger("shagua.llm_cost_backfill_worker")
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_loop() -> None:
|
||||||
|
interval = max(60, int(settings.LLM_COST_BACKFILL_INTERVAL_SEC))
|
||||||
|
logger.info(
|
||||||
|
"LLM cost backfill worker started interval=%ss batch=%s lookback_days=%s",
|
||||||
|
interval,
|
||||||
|
settings.LLM_COST_BACKFILL_BATCH_SIZE,
|
||||||
|
settings.LLM_COST_BACKFILL_LOOKBACK_DAYS,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
auth_ready = await asyncio.to_thread(pricebot_llm_auth_ready)
|
||||||
|
if auth_ready:
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
repair_missing_comparison_llm_costs,
|
||||||
|
limit=settings.LLM_COST_BACKFILL_BATCH_SIZE,
|
||||||
|
lookback_days=settings.LLM_COST_BACKFILL_LOOKBACK_DAYS,
|
||||||
|
)
|
||||||
|
logger.info("LLM cost backfill batch result=%s", result)
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
"LLM cost backfill skipped: PriceBot internal auth is not ready"
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.exception("LLM cost backfill batch failed")
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("LLM cost backfill worker stopped")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def start_llm_cost_backfill_worker() -> asyncio.Task | None:
|
||||||
|
if not settings.LLM_COST_BACKFILL_ENABLED:
|
||||||
|
logger.info("LLM cost backfill worker disabled")
|
||||||
|
return None
|
||||||
|
if not settings.INTERNAL_API_SECRET:
|
||||||
|
logger.warning(
|
||||||
|
"LLM cost backfill worker not started: INTERNAL_API_SECRET is empty"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
return asyncio.create_task(_run_loop(), name="llm-cost-backfill")
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_llm_cost_backfill_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
|
||||||
|
]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user