Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0e6801c69 | |||
| 7bf04f2655 | |||
| 5f6593eaf2 | |||
| e11f506e1e | |||
| addc30817f | |||
| e165fcfc5e | |||
| 3f7b5167fa | |||
| 9e88ca72d3 | |||
| 21a4d0af5b | |||
| f7a7a49281 | |||
| 71aef455f4 | |||
| 66527f6cdc | |||
| b4a2a8c31d | |||
| ceceeb3458 | |||
| 31f61f6aad | |||
| b7cfcf7495 | |||
| 77f772f47c | |||
| cb8e8ccc1d | |||
| fda82fe313 | |||
| 28a86c3b2c | |||
| 0717c09721 | |||
| 2eb36b44c8 | |||
| 510df176b3 |
+56
-1
@@ -27,7 +27,55 @@ JG_PRIVATE_KEY_PATH=./secrets/jverify_rsa_private.pem
|
||||
JG_VERIFY_ENDPOINT=https://api.verification.jpush.cn/v1/web/loginTokenVerify
|
||||
JG_REQUEST_TIMEOUT_SEC=15
|
||||
|
||||
# ===== 无障碍保护存活监控(pull 后置检测;本期不接推送)=====
|
||||
# ===== 厂商直推(无障碍保护存活告警 + 消息中心 13 类通知)=====
|
||||
# 敏感密钥只放 .env / 服务器环境变量,不要提交到 git。
|
||||
# 各厂商配置状态可随时 GET /api/v1/push/vendors 查看(缺哪些键一目了然)。
|
||||
ANDROID_PACKAGE_NAME=com.jishisongfu.shaguabijia
|
||||
PUSH_REQUEST_TIMEOUT_SEC=15
|
||||
PUSH_TIME_TO_LIVE_SEC=86400
|
||||
|
||||
HONOR_PUSH_APP_ID=
|
||||
HONOR_PUSH_CLIENT_ID=
|
||||
HONOR_PUSH_CLIENT_SECRET=
|
||||
HONOR_PUSH_TOKEN_ENDPOINT=https://iam.developer.honor.com/auth/token
|
||||
HONOR_PUSH_SEND_ENDPOINT_TEMPLATE=https://push-api.cloud.honor.com/api/v1/{app_id}/sendMessage
|
||||
|
||||
# 华为 Push Kit:AGC 控制台 → 项目设置 → 常规 → 应用,AppId + AppSecret
|
||||
HUAWEI_PUSH_APP_ID=
|
||||
HUAWEI_PUSH_APP_SECRET=
|
||||
HUAWEI_PUSH_TOKEN_ENDPOINT=https://oauth-login.cloud.huawei.com/oauth2/v3/token
|
||||
HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE=https://push-api.cloud.huawei.com/v1/{app_id}/messages:send
|
||||
|
||||
VIVO_PUSH_APP_ID=
|
||||
VIVO_PUSH_APP_KEY=
|
||||
VIVO_PUSH_APP_SECRET=
|
||||
VIVO_PUSH_AUTH_ENDPOINT=https://api-push.vivo.com.cn/message/auth
|
||||
VIVO_PUSH_SEND_ENDPOINT=https://api-push.vivo.com.cn/message/send
|
||||
# vivo 未上架测试时可用 push_mode=1; 上架正式推送改为 0。
|
||||
VIVO_PUSH_MODE=1
|
||||
VIVO_PUSH_NOTIFY_TYPE=4
|
||||
VIVO_PUSH_CATEGORY=DEVICE_REMINDER
|
||||
|
||||
XIAOMI_PUSH_APP_SECRET=
|
||||
XIAOMI_PUSH_SEND_ENDPOINT=https://api.xmpush.xiaomi.com/v3/message/regid
|
||||
XIAOMI_PUSH_CHANNEL_ID=
|
||||
XIAOMI_PUSH_TEMPLATE_ID=
|
||||
XIAOMI_PUSH_TEMPLATE_TITLE=
|
||||
XIAOMI_PUSH_TEMPLATE_DESCRIPTION=
|
||||
# 可选: JSON 字符串,支持 {title}/{alert} 占位符,例如 {"title":"{title}","content":"{alert}"}
|
||||
XIAOMI_PUSH_TEMPLATE_PARAM_JSON=
|
||||
|
||||
OPPO_PUSH_APP_KEY=
|
||||
OPPO_PUSH_MASTER_SECRET=
|
||||
OPPO_PUSH_AUTH_ENDPOINT=https://api.push.oppomobile.com/server/v1/auth
|
||||
OPPO_PUSH_SEND_ENDPOINT=https://api.push.oppomobile.com/server/v1/message/notification/unicast
|
||||
# OPPO 新消息分类(2024-11-20 后创建的应用必须携带 category;channel_id 为后台「通道ID」;
|
||||
# notify_level 0=不传走默认,内容营销类仅支持 1/2)
|
||||
OPPO_PUSH_CHANNEL_ID=
|
||||
OPPO_PUSH_CATEGORY=
|
||||
OPPO_PUSH_NOTIFY_LEVEL=0
|
||||
|
||||
# ===== 无障碍保护存活监控(推送 + pull 后置兜底)=====
|
||||
HEARTBEAT_MONITOR_ENABLED=true
|
||||
HEARTBEAT_TIMEOUT_MINUTES=60
|
||||
HEARTBEAT_SCAN_INTERVAL_SEC=60
|
||||
@@ -65,6 +113,13 @@ JD_UNION_APP_SECRET=
|
||||
JD_UNION_SITE_ID=
|
||||
JD_UNION_AUTH_KEY=
|
||||
|
||||
# 美团 + 京东订单每天北京时间 05:00 自动对账;按更新时间回拉近 3 天,重叠防漏单并刷新状态。
|
||||
# 手动对账按钮不受该开关影响。通常保持开启;临时停自动任务时设为 false。
|
||||
CPS_AUTO_RECONCILE_ENABLED=true
|
||||
CPS_AUTO_RECONCILE_RUN_HOUR=5
|
||||
CPS_AUTO_RECONCILE_LOOKBACK_DAYS=3
|
||||
CPS_AUTO_RECONCILE_CHECK_INTERVAL_SEC=60
|
||||
|
||||
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
|
||||
# 客户端调本服务的 /api/v1/coupon/step 等,我们透传到 pricebot-backend。
|
||||
# 本地开发用 localhost:8000。生产部署改成内网地址(如 http://pricebot.internal:8000)。
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
||||
|
||||
## Project overview
|
||||
|
||||
Shaguabijia (傻瓜比价) App backend — FastAPI + SQLAlchemy 2.0 + JWT. Covers user auth (Jiguang one-click / SMS), welfare wallet (coins/cash/signin/tasks/savings), WeChat Pay withdrawals, ad-reward callbacks (Pangle/GroMore S2S), Meituan CPS (coupon forwarding / price comparison), and an admin backend.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Install
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Run app server (port 8770, auto-migrates, auto-reload)
|
||||
./run.sh # or: uvicorn app.main:app --reload --port 8770
|
||||
|
||||
# Run admin server (port 8771, separate process)
|
||||
uvicorn app.admin.main:admin_app --reload --port 8771
|
||||
|
||||
# Database
|
||||
alembic upgrade head # apply all migrations (idempotent)
|
||||
alembic revision --autogenerate -m "description" # generate new migration
|
||||
|
||||
# Tests
|
||||
pytest -q # all tests
|
||||
pytest tests/test_auth.py -q # single file
|
||||
pytest -k "test_sms_login" -q # single test by name
|
||||
|
||||
# Lint
|
||||
ruff check .
|
||||
ruff check --fix .
|
||||
```
|
||||
|
||||
## Architecture: two FastAPI apps
|
||||
|
||||
This repo runs **two separate FastAPI processes** sharing the same `app/` codebase (models, repositories, integrations, config):
|
||||
|
||||
| | App server | Admin server |
|
||||
|---|---|---|
|
||||
| Entry | `app/main.py` → `app:app` | `app/admin/main.py` → `admin_app` |
|
||||
| Port | 8770 | 8771 |
|
||||
| Auth | User JWT (`JWT_SECRET_KEY`) | Admin JWT (`ADMIN_JWT_SECRET`, independent) |
|
||||
| Audience | Mobile app clients | Internal admin dashboard |
|
||||
| Docs | `/docs` (non-prod only) | `/admin/docs` (non-prod only) |
|
||||
|
||||
The two apps are intentionally decoupled — `app.main` never imports `app.admin`. Admin has its own auth chain (`app/admin/deps.py`, `app/admin/security.py`), role-based guards (`require_role`), and routers under `app/admin/routers/`.
|
||||
|
||||
## Layered request flow
|
||||
|
||||
```
|
||||
api/v1/ (thin: parse → delegate → respond + HTTP errors)
|
||||
├── integrations/ (external SDKs: signature, encryption, HTTP calls)
|
||||
└── repositories/ (data access + transactions)
|
||||
└── models/ (SQLAlchemy ORM, DeclarativeBase)
|
||||
```
|
||||
|
||||
- **`api/v1/`**: Route handlers. Keep these thin — parse request, call repository or integration, return response. Never put business logic or external HTTP here.
|
||||
- **`api/deps.py`**: Shared FastAPI dependencies — `get_current_user` (Bearer JWT → User ORM object), `get_db` (request-scoped session).
|
||||
- **`integrations/`**: All external service logic — Jiguang REST + RSA decryption, WeChat Pay V3 signing/encryption, Meituan CPS gateway signing, Pangle callback signature verification, SMS sending. This is the layer you change when swapping vendors.
|
||||
- **`repositories/`**: Data access. Each file wraps SQLAlchemy queries + transactions for one domain (user, wallet, signin, savings, ad_reward, etc.). Some repositories also call integrations (e.g., `wallet.py` calls `integrations/wxpay.py` for withdrawals).
|
||||
- **`models/`**: ORM table definitions (SQLAlchemy 2.0 `Mapped` style, `DeclarativeBase`). Every new model must be imported in `app/models/__init__.py` so Alembic can discover it.
|
||||
- **`schemas/`**: Pydantic request/response contracts.
|
||||
- **`core/`**: Infrastructure — config (`pydantic-settings`), JWT (`security.py`), in-memory rate limiter (`ratelimit.py`), reward constants (`rewards.py`), logging setup, pricebot router (consistent-hash load balancing), withdraw reconcile worker.
|
||||
|
||||
## Internal (server-to-server) endpoints
|
||||
|
||||
Endpoints under `app/api/internal/` are for server-to-server communication (pricebot → app-server), NOT for clients. They use a shared secret header `X-Internal-Secret` (compared via `hmac.compare_digest`) instead of user JWT. If `INTERNAL_API_SECRET` is empty, these endpoints return 503.
|
||||
|
||||
## Auth system
|
||||
|
||||
- **User login**: Jiguang one-click (`integrations/jiguang.py` — REST token verification + RSA decryption with multi-padding retry) or SMS code (mock by default; `SMS_MOCK=true`).
|
||||
- **Tokens**: JWT access (2h) + refresh (30d). Both are JWT with `typ` claim (`"access"` vs `"refresh"`) to prevent refresh-as-access. See `core/security.py`.
|
||||
- **Admin auth**: Separate JWT secret (`ADMIN_JWT_SECRET`), 12h expiry, no refresh. Username + bcrypt password login. Role-based access via `require_role()` guard in `app/admin/deps.py` (`super_admin` bypasses all role checks).
|
||||
- **Rate limiting**: In-memory fixed-window by client IP (`core/ratelimit.py`). Single-worker only; disabled in tests via `RATE_LIMIT_ENABLED=false`.
|
||||
|
||||
## Database
|
||||
|
||||
- **Dev**: SQLite (`sqlite:///./data/app.db`), `check_same_thread=False`, no connection pool.
|
||||
- **Prod**: PostgreSQL — just change `DATABASE_URL` in `.env`. Pool size 10 + max overflow 20, pool_recycle 3600.
|
||||
- **Migrations**: Alembic with `render_as_batch` for SQLite compatibility. ~60+ migration files in `alembic/versions/` (filenames are descriptive, not hex prefixes). Migration chain uses `down_revision` within each file.
|
||||
- **New models**: Define in `app/models/`, import in `app/models/__init__.py`, then run `alembic revision --autogenerate`.
|
||||
|
||||
## Config
|
||||
|
||||
All config via `pydantic-settings` in `app/core/config.py`. Single `Settings` class with env vars / `.env` file. Access anywhere via `from app.core.config import settings`. Key patterns:
|
||||
- `*_configured` properties gate features gracefully (e.g., `mt_cps_configured`, `wxpay_configured`, `pangle_callback_configured`) — missing credentials → endpoints return empty/503 rather than crashing at startup.
|
||||
- Prod validation: `_enforce_prod_secrets` model validator blocks startup if `APP_ENV=prod` with weak JWT secrets.
|
||||
|
||||
## Testing
|
||||
|
||||
- `tests/conftest.py`: Sets env vars BEFORE imports, creates temp SQLite file, builds all tables with `Base.metadata.create_all()`, tears down with `drop_all()` + unlink.
|
||||
- External integrations are monkeypatched in tests (e.g., WeChat Pay, Jiguang, Pangle callbacks) — tests never make real HTTP calls.
|
||||
- `TestClient` from FastAPI is used for all tests. Rate limiting is disabled globally in tests.
|
||||
|
||||
## Key integration details
|
||||
|
||||
- **Jiguang one-click login**: REST call to verify `loginToken`, then RSA decrypt the returned phone number. Multiple padding schemes tried in order (PKCS1v15, OAEP with SHA1/SHA256) because Jiguang's encryption padding varies.
|
||||
- **WeChat Pay withdrawals**: V3 API merchant transfer to user WeChat balance. Lazy-loads merchant certificates from `secrets/`. Withdrawal flow: bind WeChat → create withdraw order → auto-reconcile worker polls pending orders.
|
||||
- **Pangle ad rewards**: S2S callback verification via SHA256 signature. Multiple `m-key` secrets supported (one per ad placement). Callback is idempotent by `trans_id`. Test grant endpoint (`AD_REWARD_TEST_GRANT_ENABLED`) for local debugging — must be false in prod.
|
||||
- **Meituan CPS**: Gateway signature-based API calls. Proxy support (`MT_CPS_PROXY`) for local dev (direct connection causes SSL EOF). Coupon endpoints gracefully return empty when credentials are missing.
|
||||
- **Pricebot forwarding**: `/api/v1/coupon/step` and `/api/v1/compare/*` proxy to pricebot-backend. Multi-instance support with consistent-hash routing by `trace_id` (see `core/pricebot_router.py`).
|
||||
- **CPS redirect**: `/c/{code}` is a public (no auth) short-link redirect — records a click then 302s to Meituan. Click recording failure never blocks the redirect.
|
||||
|
||||
## Money and units
|
||||
|
||||
All monetary amounts are in **cents** (`*_cents` fields). Coins/gold have their own unit. Conversion constants are in `core/rewards.py`.
|
||||
|
||||
## Scripts
|
||||
|
||||
Key operational scripts in `scripts/`:
|
||||
- `migrate.sh` — run migrations standalone
|
||||
- `create_admin.py` — create admin user
|
||||
- `daily_auto_exchange.py` — auto-convert coins to cash (triggered by systemd timer)
|
||||
- `reconcile_withdraws.py` — reconcile withdrawal orders with WeChat Pay
|
||||
- `sim_pangle_callback.py` — simulate Pangle S2S callback for testing
|
||||
@@ -0,0 +1,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,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,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,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,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,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,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 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.security import AdminTokenError, decode_admin_token
|
||||
from app.db.session import get_db
|
||||
@@ -72,6 +74,33 @@ def require_role(*roles: str):
|
||||
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:
|
||||
"""取客户端 IP(审计日志用)。生产经 nginx 反代,优先 X-Forwarded-For 第一段;否则直连 IP。
|
||||
|
||||
|
||||
+9
-2
@@ -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_revenue import router as ad_revenue_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.auth import router as auth_router
|
||||
from app.admin.routers.comparison import router as comparison_router
|
||||
@@ -25,21 +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.dashboard import router as dashboard_router
|
||||
from app.admin.routers.device_liveness import router as device_liveness_router
|
||||
from app.admin.routers.ops_stat_config import router as ops_stat_config_router
|
||||
from app.admin.routers.analytics_health import router as analytics_health_router
|
||||
from app.admin.routers.event_logs import router as event_logs_router
|
||||
from app.admin.routers.feedback import router as feedback_router
|
||||
from app.admin.routers.feedback_qr import router as feedback_qr_router
|
||||
from app.admin.routers.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.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.risk_monitor import router as risk_monitor_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.wallet import router as wallet_router
|
||||
from app.admin.routers.withdraw import router as withdraw_router
|
||||
from app.core.config import settings
|
||||
from app.core.logging import setup_logging
|
||||
from app.integrations import meituan as mt_meituan
|
||||
|
||||
setup_logging(debug=settings.APP_DEBUG)
|
||||
logger = logging.getLogger("shagua.admin")
|
||||
@@ -53,6 +56,8 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
settings.DATABASE_URL.split("://", 1)[0],
|
||||
)
|
||||
yield
|
||||
# CPS 后台页会打美团(routers/cps.py),那条共享 client 若被建过要在这里关掉连接池
|
||||
mt_meituan.close_client()
|
||||
logger.info("admin app shutting down")
|
||||
|
||||
|
||||
@@ -97,10 +102,12 @@ admin_app.include_router(onboarding_router)
|
||||
admin_app.include_router(wallet_router)
|
||||
admin_app.include_router(withdraw_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(event_logs_router)
|
||||
admin_app.include_router(analytics_health_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(roles_router)
|
||||
admin_app.include_router(audit_router)
|
||||
|
||||
@@ -21,10 +21,10 @@ PERMISSION_CATALOG: list[dict] = [
|
||||
{"key": "ad-revenue-report", "label": "广告收益"},
|
||||
{"key": "comparison-records", "label": "比价记录"},
|
||||
{"key": "cps", "label": "CPS收益"},
|
||||
{"key": "device-liveness", "label": "设备存活"},
|
||||
]},
|
||||
{"group": "奖励审核", "pages": [
|
||||
{"key": "withdraws", "label": "提现审核"},
|
||||
{"key": "invite-withdraws", "label": "邀请提现审核"},
|
||||
{"key": "withdraws", "label": "其他提现审核"},
|
||||
{"key": "price-reports", "label": "低价审核"},
|
||||
{"key": "feedbacks", "label": "用户反馈"},
|
||||
]},
|
||||
@@ -34,11 +34,16 @@ PERMISSION_CATALOG: list[dict] = [
|
||||
{"key": "huawei-review", "label": "华为审核开关"},
|
||||
{"key": "users", "label": "用户管理"},
|
||||
]},
|
||||
{"group": "其他", "pages": [
|
||||
{"key": "admins", "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": [
|
||||
{"key": "admins", "label": "权限管理"},
|
||||
]},
|
||||
]
|
||||
|
||||
# 全部页面 key(super_admin 有效可见 = 此全集;也用于校验角色 pages 合法性)
|
||||
@@ -52,13 +57,13 @@ BUILTIN_ROLES: list[dict] = [
|
||||
{"name": SUPER_ADMIN_ROLE, "label": "管理员", "pages": []},
|
||||
{"name": "operator", "label": "运营", "pages": [
|
||||
"dashboard", "coupon-data", "ad-revenue-report", "comparison-records",
|
||||
"cps", "device-liveness", "price-reports", "feedbacks", "huawei-review",
|
||||
"cps", "risk-monitor", "device-liveness", "price-reports", "feedbacks", "huawei-review",
|
||||
]},
|
||||
{"name": "finance", "label": "财务", "pages": [
|
||||
"dashboard", "ad-revenue-report", "cps", "withdraws",
|
||||
"dashboard", "ad-revenue-report", "cps", "invite-withdraws", "withdraws",
|
||||
]},
|
||||
{"name": "tech", "label": "技术", "pages": [
|
||||
"dashboard", "device-liveness", "config", "ad-revenue", "huawei-review",
|
||||
"dashboard", "risk-monitor", "device-liveness", "analytics-health", "config", "ad-revenue", "huawei-review",
|
||||
"event-logs", "audit-logs",
|
||||
]},
|
||||
]
|
||||
|
||||
@@ -81,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")。
|
||||
_AUDIT_SCENES = {"reward_video", "feed", "draw"}
|
||||
|
||||
# 激励视频未满足有效播放条件时不计客户端预估收益。客户端仍会在 onAdShow
|
||||
# 上报 eCPM,随后才在关闭时补报以下终态,因此必须在展示/发奖合并后修正收益。
|
||||
_ZERO_REVENUE_REWARD_VIDEO_STATUSES = frozenset({"closed_early", "too_short"})
|
||||
|
||||
|
||||
# 发奖复算明细字段(展开下钻看「金币怎么算出来的」)——从 audit 行原样取这些 key。
|
||||
_REWARD_DETAIL_KEYS = (
|
||||
@@ -202,6 +206,11 @@ def ad_revenue_report(
|
||||
"matched": bool(rwd["matched"]),
|
||||
"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:
|
||||
# 纯展示(信息流逐条展示、激励视频缺发奖记录):不计对账,matched=True。
|
||||
ev.update({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""admin「领券数据」看板聚合:发起/完成数、耗时均值与分位、按天/小时趋势、逐条明细。
|
||||
|
||||
数据源 coupon_session(一次领券一行,客户端 /api/v1/coupon/session 两段上报)。量级不大,全量拉
|
||||
区间数据后 Python 聚合(分位 SQLite 无 percentile,统一 Python 算,PG 上也一致)。
|
||||
数据源 coupon_session(一次领券一行,客户端 /api/v1/coupon/session 两段上报)。生产 PostgreSQL
|
||||
使用 percentile_cont 聚合耗时分位;SQLite 本地/测试环境回退读取耗时单列计算。
|
||||
- 发起数 = 区间内全部 session(含 started/completed/failed/abandoned),= 流失统计的基数。
|
||||
- 完成数 / 耗时均值 / 分位 = 仅 status==completed 子集(成功跑完才有可比的"领券耗时")。
|
||||
- summary/daily/hourly/total 在全量上算,不受分页;items 为排序后当前页。
|
||||
@@ -16,11 +16,14 @@ from sqlalchemy import case, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.models.coupon_state import CouponClaimRecord, CouponSession
|
||||
from app.models.coupon_state import CouponClaimEvent, CouponClaimRecord, CouponSession
|
||||
from app.models.user import User
|
||||
from app.repositories import ad_ecpm as crud_ecpm
|
||||
from app.repositories.coupon_state import DEFAULT_PLATFORMS, coupon_id_to_platform
|
||||
|
||||
_SLOT_OK = ("success", "already_claimed")
|
||||
_SLOT_TRIED = ("success", "already_claimed", "failed")
|
||||
|
||||
|
||||
def _cn_hour(dt: datetime) -> int:
|
||||
"""started_at(UTC 口径)→ 北京时间小时(0–23)。naive 当 UTC(sqlite),tz-aware 直接换算(pg)。"""
|
||||
@@ -42,6 +45,75 @@ def _percentile(sorted_vals: list[int], q: float) -> int | None:
|
||||
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:
|
||||
return round(sum(vals) / len(vals)) if vals else None
|
||||
|
||||
@@ -86,7 +158,13 @@ def _success_rates(rows: list) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _session_to_row(r, phone: str | None = None, nickname: str | None = None, ad_revenue_yuan: float = 0.0) -> dict:
|
||||
def _session_to_row(
|
||||
r,
|
||||
phone: str | None = None,
|
||||
nickname: str | None = None,
|
||||
ad_revenue_yuan: float = 0.0,
|
||||
point_stats: dict | None = None,
|
||||
) -> dict:
|
||||
"""CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。"""
|
||||
return {
|
||||
"id": r.id,
|
||||
@@ -104,11 +182,60 @@ def _session_to_row(r, phone: str | None = None, nickname: str | None = None, ad
|
||||
"app_env": r.app_env,
|
||||
"started_at": r.started_at,
|
||||
"claimed_count": r.claimed_count,
|
||||
"point_success_count": point_stats["succeeded"] if point_stats else None,
|
||||
"point_total_count": point_stats["tried"] if point_stats else None,
|
||||
"trace_url": r.trace_url,
|
||||
"ad_revenue_yuan": ad_revenue_yuan,
|
||||
}
|
||||
|
||||
|
||||
def _point_scores_by_trace(db: Session, trace_ids: list[str]) -> dict[str, dict[str, int]]:
|
||||
"""聚合查询批量返回逐场点位分数,不加载逐券明细。"""
|
||||
if not trace_ids:
|
||||
return {}
|
||||
succeeded = func.sum(case((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:
|
||||
return {
|
||||
"summary": {
|
||||
@@ -157,30 +284,22 @@ def coupon_data_report(
|
||||
if not user_ids:
|
||||
return _empty_result()
|
||||
|
||||
stmt = select(CouponSession).where(
|
||||
conditions = [
|
||||
CouponSession.started_date >= d_from,
|
||||
CouponSession.started_date <= d_to,
|
||||
)
|
||||
]
|
||||
if app_env is not None:
|
||||
stmt = stmt.where(CouponSession.app_env == app_env)
|
||||
conditions.append(CouponSession.app_env == app_env)
|
||||
if statuses:
|
||||
stmt = stmt.where(CouponSession.status.in_(statuses))
|
||||
conditions.append(CouponSession.status.in_(statuses))
|
||||
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())
|
||||
|
||||
# ── 汇总卡 ──
|
||||
completed_elapsed = sorted(
|
||||
r.elapsed_ms for r in rows if r.status == "completed" and r.elapsed_ms is not None
|
||||
)
|
||||
summary = {
|
||||
"started_count": len(rows),
|
||||
"completed_count": sum(1 for r in rows if r.status == "completed"),
|
||||
"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),
|
||||
**_coupon_summary_aggregates(db, conditions),
|
||||
**_success_rates(rows),
|
||||
}
|
||||
|
||||
@@ -249,16 +368,23 @@ def coupon_data_report(
|
||||
).all()
|
||||
}
|
||||
rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in page])
|
||||
point_stats_map = _point_scores_by_trace(db, [r.trace_id for r in page])
|
||||
items = []
|
||||
for r in page:
|
||||
phone, nickname = user_map.get(r.user_id, (None, None)) if r.user_id is not None else (None, None)
|
||||
items.append(_session_to_row(r, phone, nickname, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0)))
|
||||
items.append(_session_to_row(
|
||||
r,
|
||||
phone,
|
||||
nickname,
|
||||
ad_revenue_yuan=rev_map.get(r.trace_id, 0.0),
|
||||
point_stats=point_stats_map.get(r.trace_id),
|
||||
))
|
||||
|
||||
return {
|
||||
"summary": summary,
|
||||
"daily": daily,
|
||||
"hourly": hourly,
|
||||
"total": len(rows),
|
||||
"total": summary["started_count"],
|
||||
"items": items,
|
||||
}
|
||||
|
||||
@@ -276,15 +402,17 @@ def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict:
|
||||
).scalar_one()
|
||||
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],
|
||||
"items": [
|
||||
_session_to_row(
|
||||
r,
|
||||
ad_revenue_yuan=rev_map.get(r.trace_id, 0.0),
|
||||
)
|
||||
for r in rows
|
||||
],
|
||||
"total": int(total),
|
||||
}
|
||||
|
||||
|
||||
_SLOT_OK = ("success", "already_claimed")
|
||||
_SLOT_TRIED = ("success", "already_claimed", "failed")
|
||||
|
||||
|
||||
def coupon_slot_report(
|
||||
db: Session, *, date_from: str, date_to: str, app_env: str | None = None
|
||||
) -> dict:
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
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.integrations import jd_union, meituan
|
||||
from app.repositories import cps_link as cps_link_repo
|
||||
from app.models.cps_activity import CpsActivity
|
||||
from app.models.cps_group import CpsGroup
|
||||
from app.models.cps_link import CpsClick, CpsLink
|
||||
from app.models.cps_order import CpsOrder
|
||||
from app.models.cps_wx_user import CpsWxUser
|
||||
from app.repositories import cps_link as cps_link_repo
|
||||
|
||||
logger = logging.getLogger("shagua.cps_reconcile")
|
||||
|
||||
# 美团订单状态:取消(4)/风控(5)不计佣金;结算(6)为佣金真正到账
|
||||
_INVALID_STATUS = {"4", "5"}
|
||||
@@ -378,18 +381,35 @@ def effective_commission_cents(order: CpsOrder) -> int:
|
||||
def reconcile_orders(
|
||||
db: Session, *, start_time: int, end_time: int,
|
||||
query_time_type: int = 1, sid: str | None = None, max_pages: int = 200,
|
||||
audit_context: dict[str, Any] | None = None,
|
||||
) -> dict:
|
||||
"""调美团 query_order 分页拉单 → 按 order_id upsert。返回 {fetched, inserted, updated, pages}。
|
||||
|
||||
订单状态会随时间变(付款→完成→结算/退款),重复拉同一单则更新。max_pages 防异常死循环。
|
||||
"""
|
||||
fetched = inserted = updated = pages = 0
|
||||
fetched = inserted = updated = pages = api_requests = 0
|
||||
page = 1
|
||||
while page <= max_pages:
|
||||
resp = meituan.query_order(
|
||||
sid=sid, start_time=start_time, end_time=end_time,
|
||||
query_time_type=query_time_type, page=page, limit=100,
|
||||
)
|
||||
api_requests += 1
|
||||
try:
|
||||
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 []
|
||||
if not rows:
|
||||
break
|
||||
@@ -419,30 +439,58 @@ def reconcile_orders(
|
||||
break
|
||||
page += 1
|
||||
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(
|
||||
db: Session, *, start_time: datetime, end_time: datetime,
|
||||
query_time_type: int = 3, max_pages: int = 100,
|
||||
audit_context: dict[str, Any] | None = None,
|
||||
) -> dict:
|
||||
"""调京东 order.row.query 拉单 → 按订单行 upsert。
|
||||
|
||||
京东单次查询窗口最多 1 小时,这里按北京自然时间切窗并逐页拉取。
|
||||
"""
|
||||
fetched = inserted = updated = pages = 0
|
||||
fetched = inserted = updated = pages = api_requests = windows = 0
|
||||
cur = start_time
|
||||
while cur < end_time:
|
||||
win_end = min(cur + timedelta(hours=1), end_time)
|
||||
windows += 1
|
||||
page = 1
|
||||
while page <= max_pages:
|
||||
resp = jd_union.query_order_rows(
|
||||
start_time=cur,
|
||||
end_time=win_end,
|
||||
query_time_type=query_time_type,
|
||||
page_index=page,
|
||||
page_size=200,
|
||||
)
|
||||
api_requests += 1
|
||||
try:
|
||||
resp = jd_union.query_order_rows(
|
||||
start_time=cur,
|
||||
end_time=win_end,
|
||||
query_time_type=query_time_type,
|
||||
page_index=page,
|
||||
page_size=200,
|
||||
)
|
||||
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 []
|
||||
has_more = bool(resp.get("has_more"))
|
||||
if not rows:
|
||||
@@ -469,7 +517,14 @@ def reconcile_jd_orders(
|
||||
page += 1
|
||||
cur = win_end
|
||||
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(
|
||||
|
||||
@@ -44,6 +44,25 @@ def set_user_debug_trace(
|
||||
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(
|
||||
db: Session, feedback: Feedback, *, status: str, commit: bool = True
|
||||
) -> Feedback:
|
||||
|
||||
+275
-200
@@ -6,6 +6,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import Select, asc, case, desc, func, or_, select
|
||||
@@ -21,14 +22,15 @@ from app.models.comparison import ComparisonRecord
|
||||
from app.models.coupon_state import CouponPromptEngagement
|
||||
from app.models.device import DeviceLiveness
|
||||
from app.models.feedback import Feedback
|
||||
from app.models.invite import InviteRelation
|
||||
from app.models.onboarding import OnboardingCompletion
|
||||
from app.models.price_report import PriceReport
|
||||
from app.models.savings import SavingsRecord
|
||||
from app.models.user import User
|
||||
from app.models.wallet import (
|
||||
CashTransaction,
|
||||
CoinAccount,
|
||||
CoinTransaction,
|
||||
InviteCashTransaction,
|
||||
WithdrawOrder,
|
||||
)
|
||||
from app.repositories import activity, ad_ecpm
|
||||
@@ -297,6 +299,58 @@ def _comparison_percentile(sorted_values: list[int], q: float) -> int | None:
|
||||
return int(value + 0.5)
|
||||
|
||||
|
||||
def _round_duration_ms(value) -> int | None:
|
||||
"""将数据库聚合结果按既有口径四舍五入为整数毫秒。"""
|
||||
if value is None:
|
||||
return None
|
||||
return int(Decimal(str(value)).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _comparison_duration_aggregate_stmt(conditions: list, status: str, quantiles: tuple[float, ...]):
|
||||
"""PostgreSQL 耗时聚合语句;每种状态只返回一行。"""
|
||||
return select(
|
||||
func.avg(ComparisonRecord.total_ms),
|
||||
*(
|
||||
func.percentile_cont(q).within_group(ComparisonRecord.total_ms)
|
||||
for q in quantiles
|
||||
),
|
||||
).where(
|
||||
*conditions,
|
||||
ComparisonRecord.status == status,
|
||||
ComparisonRecord.total_ms.is_not(None),
|
||||
)
|
||||
|
||||
|
||||
def _comparison_duration_aggregates(
|
||||
db: Session,
|
||||
*,
|
||||
conditions: list,
|
||||
status: str,
|
||||
quantiles: tuple[float, ...],
|
||||
) -> list[int | None]:
|
||||
"""返回平均值和各分位数;生产 PG 在数据库内聚合,SQLite 仅作测试回退。"""
|
||||
if db.bind is not None and db.bind.dialect.name == "postgresql":
|
||||
row = db.execute(
|
||||
_comparison_duration_aggregate_stmt(conditions, status, quantiles)
|
||||
).one()
|
||||
return [_round_duration_ms(value) for value in row]
|
||||
|
||||
# SQLite 没有 percentile_cont;本地/测试只回退读取耗时单列,不加载完整记录。
|
||||
values = list(
|
||||
db.execute(
|
||||
select(ComparisonRecord.total_ms)
|
||||
.where(
|
||||
*conditions,
|
||||
ComparisonRecord.status == status,
|
||||
ComparisonRecord.total_ms.is_not(None),
|
||||
)
|
||||
.order_by(ComparisonRecord.total_ms)
|
||||
).scalars()
|
||||
)
|
||||
average = _round_duration_ms(sum(values) / len(values)) if values else None
|
||||
return [average, *(_comparison_percentile(values, q) for q in quantiles)]
|
||||
|
||||
|
||||
def comparison_records_summary(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -332,20 +386,18 @@ def comparison_records_summary(
|
||||
success = int(row[2] or 0)
|
||||
lower_price = int(row[4] or 0)
|
||||
cancelled = int(row[5] or 0)
|
||||
success_durations = sorted(db.execute(
|
||||
select(ComparisonRecord.total_ms).where(
|
||||
*conditions,
|
||||
ComparisonRecord.status == "success",
|
||||
ComparisonRecord.total_ms.is_not(None),
|
||||
)
|
||||
).scalars().all())
|
||||
cancelled_durations = sorted(db.execute(
|
||||
select(ComparisonRecord.total_ms).where(
|
||||
*conditions,
|
||||
ComparisonRecord.status == "cancelled",
|
||||
ComparisonRecord.total_ms.is_not(None),
|
||||
)
|
||||
).scalars().all())
|
||||
success_duration_stats = _comparison_duration_aggregates(
|
||||
db,
|
||||
conditions=conditions,
|
||||
status="success",
|
||||
quantiles=(0.05, 0.5, 0.95, 0.99),
|
||||
)
|
||||
cancelled_duration_stats = _comparison_duration_aggregates(
|
||||
db,
|
||||
conditions=conditions,
|
||||
status="cancelled",
|
||||
quantiles=(0.05, 0.5, 0.95),
|
||||
)
|
||||
success_rate_denominator = started - cancelled
|
||||
return {
|
||||
"started": started,
|
||||
@@ -354,19 +406,16 @@ def comparison_records_summary(
|
||||
"success_rate": success / success_rate_denominator if success_rate_denominator else None,
|
||||
"avg_token_cost": float(row[3]) if row[3] is not None else None,
|
||||
"lower_price_rate": lower_price / success if success else None,
|
||||
"avg_duration_ms": (
|
||||
int(sum(success_durations) / len(success_durations) + 0.5)
|
||||
if success_durations else None
|
||||
),
|
||||
"p5_duration_ms": _comparison_percentile(success_durations, 0.05),
|
||||
"p50_duration_ms": _comparison_percentile(success_durations, 0.5),
|
||||
"p95_duration_ms": _comparison_percentile(success_durations, 0.95),
|
||||
"p99_duration_ms": _comparison_percentile(success_durations, 0.99),
|
||||
"avg_duration_ms": success_duration_stats[0],
|
||||
"p5_duration_ms": success_duration_stats[1],
|
||||
"p50_duration_ms": success_duration_stats[2],
|
||||
"p95_duration_ms": success_duration_stats[3],
|
||||
"p99_duration_ms": success_duration_stats[4],
|
||||
"cancelled": cancelled,
|
||||
"cancelled_rate": cancelled / started if started else None,
|
||||
"cancelled_p5_ms": _comparison_percentile(cancelled_durations, 0.05),
|
||||
"cancelled_p50_ms": _comparison_percentile(cancelled_durations, 0.5),
|
||||
"cancelled_p95_ms": _comparison_percentile(cancelled_durations, 0.95),
|
||||
"cancelled_p5_ms": cancelled_duration_stats[1],
|
||||
"cancelled_p50_ms": cancelled_duration_stats[2],
|
||||
"cancelled_p95_ms": cancelled_duration_stats[3],
|
||||
}
|
||||
|
||||
|
||||
@@ -662,16 +711,7 @@ def list_all_withdraw_orders(
|
||||
elif quick_filter == "today":
|
||||
stmt = stmt.where(WithdrawOrder.created_at >= today_start)
|
||||
elif quick_filter == "high_risk":
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
WithdrawOrder.user_name.is_(None),
|
||||
WithdrawOrder.user_name == "",
|
||||
User.status != "active",
|
||||
User.created_at >= now - timedelta(hours=24),
|
||||
WithdrawOrder.status.in_(("failed", "rejected")),
|
||||
WithdrawOrder.fail_reason.is_not(None),
|
||||
)
|
||||
)
|
||||
stmt = stmt.where(User.is_high_risk.is_(True))
|
||||
|
||||
sort_cols = {
|
||||
"id": WithdrawOrder.id,
|
||||
@@ -697,7 +737,9 @@ def _as_utc(value: datetime) -> datetime:
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def withdraw_list_enrichment(db: Session, user_ids: list[int]) -> dict[int, dict]:
|
||||
def withdraw_list_enrichment(
|
||||
db: Session, user_ids: list[int], *, source: str | None = None
|
||||
) -> dict[int, dict]:
|
||||
"""批量富化提现单列表:按本页 user_id 取 手机号/昵称 + 各自累计成功提现金额(分)。
|
||||
|
||||
两条聚合查询搞定(避免逐行 N+1)。累计口径与 get_user_overview 的 withdraw_success_cents
|
||||
@@ -707,25 +749,32 @@ def withdraw_list_enrichment(db: Session, user_ids: list[int]) -> dict[int, dict
|
||||
uniq = list(set(user_ids))
|
||||
if not uniq:
|
||||
return {}
|
||||
success_rows = db.execute(
|
||||
select(
|
||||
WithdrawOrder.user_id,
|
||||
func.coalesce(func.sum(WithdrawOrder.amount_cents), 0),
|
||||
)
|
||||
.where(WithdrawOrder.user_id.in_(uniq), WithdrawOrder.status == "success")
|
||||
.group_by(WithdrawOrder.user_id)
|
||||
).all()
|
||||
success_stmt = select(
|
||||
WithdrawOrder.user_id,
|
||||
func.coalesce(func.sum(WithdrawOrder.amount_cents), 0),
|
||||
).where(WithdrawOrder.user_id.in_(uniq), WithdrawOrder.status == "success")
|
||||
if source:
|
||||
success_stmt = success_stmt.where(WithdrawOrder.source == source)
|
||||
success_rows = db.execute(success_stmt.group_by(WithdrawOrder.user_id)).all()
|
||||
success_map = {uid: int(total) for uid, total in success_rows}
|
||||
users = db.execute(
|
||||
select(User.id, User.phone, User.nickname).where(User.id.in_(uniq))
|
||||
select(
|
||||
User.id,
|
||||
User.phone,
|
||||
User.nickname,
|
||||
User.is_high_risk,
|
||||
User.high_risk_note,
|
||||
).where(User.id.in_(uniq))
|
||||
).all()
|
||||
return {
|
||||
uid: {
|
||||
"phone": phone,
|
||||
"nickname": nickname,
|
||||
"is_high_risk": is_high_risk,
|
||||
"high_risk_note": high_risk_note,
|
||||
"cumulative_success_cents": success_map.get(uid, 0),
|
||||
}
|
||||
for uid, phone, nickname in users
|
||||
for uid, phone, nickname, is_high_risk, high_risk_note in users
|
||||
}
|
||||
|
||||
|
||||
@@ -831,15 +880,16 @@ def get_withdraw_by_out_bill_no(db: Session, out_bill_no: str) -> WithdrawOrder
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def withdraw_summary(db: Session) -> dict:
|
||||
def withdraw_summary(db: Session, *, source: str | None = None) -> dict:
|
||||
"""提现审核台顶部统计。金额单位:分。"""
|
||||
rows = db.execute(
|
||||
select(
|
||||
WithdrawOrder.status,
|
||||
func.count(WithdrawOrder.id),
|
||||
func.coalesce(func.sum(WithdrawOrder.amount_cents), 0),
|
||||
).group_by(WithdrawOrder.status)
|
||||
).all()
|
||||
summary_stmt = select(
|
||||
WithdrawOrder.status,
|
||||
func.count(WithdrawOrder.id),
|
||||
func.coalesce(func.sum(WithdrawOrder.amount_cents), 0),
|
||||
)
|
||||
if source:
|
||||
summary_stmt = summary_stmt.where(WithdrawOrder.source == source)
|
||||
rows = db.execute(summary_stmt.group_by(WithdrawOrder.status)).all()
|
||||
by_status = {
|
||||
status: {"count": int(count), "amount_cents": int(amount_cents)}
|
||||
for status, count, amount_cents in rows
|
||||
@@ -852,19 +902,23 @@ def withdraw_summary(db: Session) -> dict:
|
||||
)
|
||||
|
||||
def _today_count(status: str) -> int:
|
||||
return db.execute(
|
||||
select(func.count(WithdrawOrder.id)).where(
|
||||
WithdrawOrder.status == status,
|
||||
WithdrawOrder.updated_at >= today_start,
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
today_success_amount = db.execute(
|
||||
select(func.coalesce(func.sum(WithdrawOrder.amount_cents), 0)).where(
|
||||
WithdrawOrder.status == "success",
|
||||
stmt = select(func.count(WithdrawOrder.id)).where(
|
||||
WithdrawOrder.status == status,
|
||||
WithdrawOrder.updated_at >= today_start,
|
||||
)
|
||||
).scalar_one()
|
||||
if source:
|
||||
stmt = stmt.where(WithdrawOrder.source == source)
|
||||
return db.execute(stmt).scalar_one()
|
||||
|
||||
today_amount_stmt = select(
|
||||
func.coalesce(func.sum(WithdrawOrder.amount_cents), 0)
|
||||
).where(
|
||||
WithdrawOrder.status == "success",
|
||||
WithdrawOrder.updated_at >= today_start,
|
||||
)
|
||||
if source:
|
||||
today_amount_stmt = today_amount_stmt.where(WithdrawOrder.source == source)
|
||||
today_success_amount = db.execute(today_amount_stmt).scalar_one()
|
||||
|
||||
return {
|
||||
"reviewing_count": by_status.get("reviewing", {}).get("count", 0),
|
||||
@@ -877,6 +931,117 @@ def withdraw_summary(db: Session) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def invite_overview(
|
||||
db: Session,
|
||||
inviter_user_id: int,
|
||||
*,
|
||||
date_from: datetime | None = None,
|
||||
date_to: datetime | None = None,
|
||||
) -> dict:
|
||||
"""邀请提现详情:邀请关系统计及受邀用户首次比价/首单信息。
|
||||
|
||||
首次记录按自增 id 取最早一条,批量查询避免按受邀用户逐行查询。
|
||||
邀请成功口径为完成首次比价并已发邀请奖励(compare_reward_granted)。
|
||||
"""
|
||||
relation_stmt = (
|
||||
select(InviteRelation, User)
|
||||
.join(User, User.id == InviteRelation.invitee_user_id)
|
||||
.where(InviteRelation.inviter_user_id == inviter_user_id)
|
||||
.order_by(InviteRelation.created_at.asc(), InviteRelation.id.asc())
|
||||
)
|
||||
if date_from is not None:
|
||||
relation_stmt = relation_stmt.where(User.created_at >= _as_utc_naive(date_from))
|
||||
if date_to is not None:
|
||||
relation_stmt = relation_stmt.where(User.created_at <= _as_utc_naive(date_to))
|
||||
relation_rows = db.execute(relation_stmt).all()
|
||||
invitee_ids = [relation.invitee_user_id for relation, _ in relation_rows]
|
||||
if not invitee_ids:
|
||||
return {"invite_total": 0, "invite_success_total": 0, "items": []}
|
||||
|
||||
first_compare_ids = list(
|
||||
db.execute(
|
||||
select(func.min(ComparisonRecord.id))
|
||||
.where(
|
||||
ComparisonRecord.user_id.in_(invitee_ids),
|
||||
ComparisonRecord.status == "success",
|
||||
)
|
||||
.group_by(ComparisonRecord.user_id)
|
||||
).scalars()
|
||||
)
|
||||
comparisons = (
|
||||
db.execute(
|
||||
select(
|
||||
ComparisonRecord.user_id,
|
||||
ComparisonRecord.store_name,
|
||||
ComparisonRecord.product_names,
|
||||
).where(ComparisonRecord.id.in_(first_compare_ids))
|
||||
).all()
|
||||
if first_compare_ids
|
||||
else []
|
||||
)
|
||||
comparison_map = {
|
||||
user_id: (store_name, product_names)
|
||||
for user_id, store_name, product_names in comparisons
|
||||
}
|
||||
|
||||
first_order_ids = list(
|
||||
db.execute(
|
||||
select(func.min(SavingsRecord.id))
|
||||
.where(
|
||||
SavingsRecord.user_id.in_(invitee_ids),
|
||||
SavingsRecord.source == "compare",
|
||||
)
|
||||
.group_by(SavingsRecord.user_id)
|
||||
).scalars()
|
||||
)
|
||||
orders = (
|
||||
db.execute(
|
||||
select(
|
||||
SavingsRecord.user_id,
|
||||
SavingsRecord.shop_name,
|
||||
SavingsRecord.title,
|
||||
SavingsRecord.dishes,
|
||||
SavingsRecord.order_amount_cents,
|
||||
).where(SavingsRecord.id.in_(first_order_ids))
|
||||
).all()
|
||||
if first_order_ids
|
||||
else []
|
||||
)
|
||||
order_map = {
|
||||
user_id: (
|
||||
shop_name,
|
||||
title or "、".join(dishes or []),
|
||||
order_amount_cents,
|
||||
)
|
||||
for user_id, shop_name, title, dishes, order_amount_cents in orders
|
||||
}
|
||||
|
||||
items = []
|
||||
for relation, user in relation_rows:
|
||||
compare_store, compare_products = comparison_map.get(user.id, (None, None))
|
||||
order_store, order_products, order_amount = order_map.get(
|
||||
user.id, (None, None, None)
|
||||
)
|
||||
items.append(
|
||||
{
|
||||
"user_id": user.id,
|
||||
"phone": user.phone,
|
||||
"registered_at": user.created_at,
|
||||
"invite_success": bool(relation.compare_reward_granted),
|
||||
"first_compare_store": compare_store,
|
||||
"first_compare_products": compare_products,
|
||||
"first_order_store": order_store,
|
||||
"first_order_products": order_products,
|
||||
"first_order_amount_cents": order_amount,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"invite_total": len(items),
|
||||
"invite_success_total": sum(1 for item in items if item["invite_success"]),
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
def list_withdraw_audit_logs(
|
||||
db: Session, out_bill_no: str, *, limit: int = 20
|
||||
) -> list[AdminAuditLog]:
|
||||
@@ -897,8 +1062,6 @@ def withdraw_risk_flags(
|
||||
cash_balance_cents: int,
|
||||
) -> tuple[list[str], int]:
|
||||
flags: list[str] = []
|
||||
if not order.user_name:
|
||||
flags.append("缺少提现实名")
|
||||
if user and user.status != "active":
|
||||
flags.append(f"账号状态:{user.status}")
|
||||
if user and user.created_at:
|
||||
@@ -921,125 +1084,6 @@ def withdraw_risk_flags(
|
||||
return flags, score
|
||||
|
||||
|
||||
def _check_withdraw_ledger_side(
|
||||
orders: list[WithdrawOrder], txns: list, *, withdraw_biz: str, refund_biz: str
|
||||
) -> dict:
|
||||
"""对某一本账(普通现金 / 邀请奖励金)做提现单 ↔ 流水的交叉校验。
|
||||
|
||||
orders 已按 source 过滤到本账;txns 是本账流水表里 withdraw_biz/refund_biz 两类流水。
|
||||
规则:每单发起应有一条扣款流水(ref_id=out_bill_no);失败/拒绝单应有且仅一条退款流水;
|
||||
非退款终态不应出现退款流水。四个计数全为 0 即本账自洽。
|
||||
"""
|
||||
withdraw_refs = {txn.ref_id for txn in txns if txn.biz_type == withdraw_biz}
|
||||
refund_counts: dict[str, int] = {}
|
||||
for txn in txns:
|
||||
if txn.biz_type == refund_biz and txn.ref_id:
|
||||
refund_counts[txn.ref_id] = refund_counts.get(txn.ref_id, 0) + 1
|
||||
|
||||
missing_withdraw = 0
|
||||
missing_refund = 0
|
||||
refund_on_non_terminal = 0
|
||||
for order in orders:
|
||||
if order.out_bill_no not in withdraw_refs:
|
||||
missing_withdraw += 1
|
||||
has_refund = refund_counts.get(order.out_bill_no, 0) > 0
|
||||
if order.status in {"failed", "rejected"} and not has_refund:
|
||||
missing_refund += 1
|
||||
if has_refund and order.status not in {"failed", "rejected"}:
|
||||
refund_on_non_terminal += 1
|
||||
|
||||
return {
|
||||
"missing_withdraw": missing_withdraw,
|
||||
"missing_refund": missing_refund,
|
||||
"duplicate_refund": sum(1 for count in refund_counts.values() if count > 1),
|
||||
"refund_on_non_terminal": refund_on_non_terminal,
|
||||
}
|
||||
|
||||
|
||||
def withdraw_ledger_check(db: Session) -> dict:
|
||||
"""现金账本校验:两本物理隔离的账各自对账(产品红线:coin_cash / invite_cash 不串)。
|
||||
|
||||
普通现金:CoinAccount.cash_balance_cents ↔ cash_transaction(withdraw/withdraw_refund);
|
||||
邀请奖励金:CoinAccount.invite_cash_balance_cents ↔ invite_cash_transaction
|
||||
(invite_withdraw/invite_withdraw_refund)。
|
||||
提现单按 source 分流到对应账核对——邀请提现的流水写在 invite_cash_transaction 表,
|
||||
绝不能拿去和普通现金流水比(否则每笔邀请提现单都会被误报「缺扣款/缺退款流水」)。
|
||||
分流口径与 create_withdraw 一致:仅 source==invite_cash 走邀请账,其余(含历史空值)归普通现金。
|
||||
"""
|
||||
orders = list(db.execute(select(WithdrawOrder)).scalars().all())
|
||||
coin_orders = [o for o in orders if o.source != "invite_cash"]
|
||||
invite_orders = [o for o in orders if o.source == "invite_cash"]
|
||||
|
||||
# —— 普通现金账(coin_cash) ——
|
||||
cash_balance_total = int(
|
||||
db.execute(select(func.coalesce(func.sum(CoinAccount.cash_balance_cents), 0))).scalar_one()
|
||||
)
|
||||
cash_txn_total = int(
|
||||
db.execute(select(func.coalesce(func.sum(CashTransaction.amount_cents), 0))).scalar_one()
|
||||
)
|
||||
cash_txns = list(
|
||||
db.execute(
|
||||
select(CashTransaction).where(
|
||||
CashTransaction.biz_type.in_(("withdraw", "withdraw_refund"))
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
coin = _check_withdraw_ledger_side(
|
||||
coin_orders, cash_txns, withdraw_biz="withdraw", refund_biz="withdraw_refund"
|
||||
)
|
||||
cash_diff = cash_balance_total - cash_txn_total
|
||||
|
||||
# —— 邀请奖励金账(invite_cash,独立账户 + 独立流水表) ——
|
||||
invite_balance_total = int(
|
||||
db.execute(
|
||||
select(func.coalesce(func.sum(CoinAccount.invite_cash_balance_cents), 0))
|
||||
).scalar_one()
|
||||
)
|
||||
invite_txn_total = int(
|
||||
db.execute(
|
||||
select(func.coalesce(func.sum(InviteCashTransaction.amount_cents), 0))
|
||||
).scalar_one()
|
||||
)
|
||||
invite_txns = list(
|
||||
db.execute(
|
||||
select(InviteCashTransaction).where(
|
||||
InviteCashTransaction.biz_type.in_(("invite_withdraw", "invite_withdraw_refund"))
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
invite = _check_withdraw_ledger_side(
|
||||
invite_orders, invite_txns,
|
||||
withdraw_biz="invite_withdraw", refund_biz="invite_withdraw_refund",
|
||||
)
|
||||
invite_diff = invite_balance_total - invite_txn_total
|
||||
|
||||
ok = (
|
||||
cash_diff == 0
|
||||
and invite_diff == 0
|
||||
and all(v == 0 for v in coin.values())
|
||||
and all(v == 0 for v in invite.values())
|
||||
)
|
||||
return {
|
||||
"ok": ok,
|
||||
# 普通现金账(coin_cash:金币兑换的现金)
|
||||
"cash_balance_total_cents": cash_balance_total,
|
||||
"cash_transaction_total_cents": cash_txn_total,
|
||||
"balance_diff_cents": cash_diff,
|
||||
"missing_withdraw_txn_count": coin["missing_withdraw"],
|
||||
"missing_refund_txn_count": coin["missing_refund"],
|
||||
"duplicate_refund_txn_count": coin["duplicate_refund"],
|
||||
"refund_txn_on_non_terminal_count": coin["refund_on_non_terminal"],
|
||||
# 邀请奖励金账(invite_cash:与普通现金物理隔离,各自对账)
|
||||
"invite_cash_balance_total_cents": invite_balance_total,
|
||||
"invite_cash_transaction_total_cents": invite_txn_total,
|
||||
"invite_balance_diff_cents": invite_diff,
|
||||
"invite_missing_withdraw_txn_count": invite["missing_withdraw"],
|
||||
"invite_missing_refund_txn_count": invite["missing_refund"],
|
||||
"invite_duplicate_refund_txn_count": invite["duplicate_refund"],
|
||||
"invite_refund_txn_on_non_terminal_count": invite["refund_on_non_terminal"],
|
||||
}
|
||||
|
||||
|
||||
def get_user_overview(db: Session, user_id: int) -> dict | None:
|
||||
"""用户 360 概览:基础资料 + 钱包余额 + 各项 count。历史明细走各自分页接口(带 user_id 过滤)。"""
|
||||
user = db.get(User, user_id)
|
||||
@@ -1099,6 +1143,7 @@ def user_reward_stats(
|
||||
*,
|
||||
date_from: datetime | None = None,
|
||||
date_to: datetime | None = None,
|
||||
withdraw_source: str | None = None,
|
||||
) -> dict:
|
||||
"""提现详情「用户统计区」10 项。窗口作用于除「现金余额」外的所有项(余额是当前快照)。
|
||||
|
||||
@@ -1106,41 +1151,60 @@ def user_reward_stats(
|
||||
平均 eCPM 用原始分值(分/千次)按记录取算术平均;各「提现」= 该来源累计金币折现。
|
||||
传统任务 = 窗口内正向金币中,排除广告(reward_video/feed_ad_reward)与人工调整后的折现。
|
||||
"""
|
||||
withdraw_source_conds = (
|
||||
[WithdrawOrder.source == withdraw_source] if withdraw_source else []
|
||||
)
|
||||
wd_success = db.execute(
|
||||
select(func.coalesce(func.sum(WithdrawOrder.amount_cents), 0)).where(
|
||||
WithdrawOrder.user_id == user_id,
|
||||
WithdrawOrder.status == "success",
|
||||
*withdraw_source_conds,
|
||||
*_window_conds(WithdrawOrder.created_at, date_from, date_to),
|
||||
)
|
||||
).scalar_one()
|
||||
wd_total = db.execute(
|
||||
select(func.count(WithdrawOrder.id)).where(
|
||||
WithdrawOrder.user_id == user_id,
|
||||
*withdraw_source_conds,
|
||||
*_window_conds(WithdrawOrder.created_at, date_from, date_to),
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
acc = db.get(CoinAccount, user_id) # 现金余额:当前快照,不随窗口
|
||||
cash_balance = acc.cash_balance_cents if acc else 0
|
||||
cash_balance = (
|
||||
(
|
||||
acc.invite_cash_balance_cents
|
||||
if withdraw_source == "invite_cash"
|
||||
else acc.cash_balance_cents
|
||||
)
|
||||
if acc
|
||||
else 0
|
||||
)
|
||||
|
||||
rv = list(db.execute(
|
||||
select(AdRewardRecord).where(
|
||||
# 只投影本统计实际使用的列。避免滚动发布或旧本地库尚未补齐无关新列时,
|
||||
# SQLAlchemy 因 select(ORM) 自动展开整表字段而让提现详情整体 500。
|
||||
rv = db.execute(
|
||||
select(AdRewardRecord.ecpm_raw, AdRewardRecord.coin).where(
|
||||
AdRewardRecord.user_id == user_id,
|
||||
AdRewardRecord.reward_scene == "reward_video",
|
||||
AdRewardRecord.status == "granted",
|
||||
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
|
||||
)
|
||||
).scalars())
|
||||
).all()
|
||||
rv_ecpms = [rewards.parse_ecpm_fen(r.ecpm_raw) for r in rv if r.ecpm_raw]
|
||||
rv_coins = sum(r.coin for r in rv)
|
||||
|
||||
feed = list(db.execute(
|
||||
select(AdFeedRewardRecord).where(
|
||||
feed = db.execute(
|
||||
select(
|
||||
AdFeedRewardRecord.unit_count,
|
||||
AdFeedRewardRecord.ecpm_raw,
|
||||
AdFeedRewardRecord.coin,
|
||||
).where(
|
||||
AdFeedRewardRecord.user_id == user_id,
|
||||
AdFeedRewardRecord.status == "granted",
|
||||
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
|
||||
)
|
||||
).scalars())
|
||||
).all()
|
||||
feed_ecpms = [rewards.parse_ecpm_fen(f.ecpm_raw) for f in feed if f.ecpm_raw]
|
||||
feed_coins = sum(f.coin for f in feed)
|
||||
|
||||
@@ -1198,8 +1262,14 @@ def user_coin_records(
|
||||
signin_from = date_from + timedelta(hours=8) if date_from is not None else None
|
||||
signin_to = date_to + timedelta(hours=8) if date_to is not None else None
|
||||
|
||||
# 三类来源都只取页面需要的列,避免无关 ORM 新列造成旧库查询失败。
|
||||
for rec in db.execute(
|
||||
select(AdRewardRecord)
|
||||
select(
|
||||
AdRewardRecord.reward_scene,
|
||||
AdRewardRecord.created_at,
|
||||
AdRewardRecord.ecpm_raw,
|
||||
AdRewardRecord.coin,
|
||||
)
|
||||
.where(
|
||||
AdRewardRecord.user_id == user_id,
|
||||
AdRewardRecord.status == "granted",
|
||||
@@ -1207,7 +1277,7 @@ def user_coin_records(
|
||||
)
|
||||
.order_by(AdRewardRecord.created_at.desc())
|
||||
.limit(fetch)
|
||||
).scalars():
|
||||
).all():
|
||||
is_video = rec.reward_scene == "reward_video"
|
||||
rows.append({
|
||||
"source": rec.reward_scene,
|
||||
@@ -1218,7 +1288,12 @@ def user_coin_records(
|
||||
})
|
||||
|
||||
for rec in db.execute(
|
||||
select(AdFeedRewardRecord)
|
||||
select(
|
||||
AdFeedRewardRecord.feed_scene,
|
||||
AdFeedRewardRecord.created_at,
|
||||
AdFeedRewardRecord.ecpm_raw,
|
||||
AdFeedRewardRecord.coin,
|
||||
)
|
||||
.where(
|
||||
AdFeedRewardRecord.user_id == user_id,
|
||||
AdFeedRewardRecord.status == "granted",
|
||||
@@ -1226,7 +1301,7 @@ def user_coin_records(
|
||||
)
|
||||
.order_by(AdFeedRewardRecord.created_at.desc())
|
||||
.limit(fetch)
|
||||
).scalars():
|
||||
).all():
|
||||
rows.append({
|
||||
"source": "feed",
|
||||
"source_label": _FEED_SCENE_LABEL.get(rec.feed_scene, "信息流广告"),
|
||||
@@ -1236,7 +1311,7 @@ def user_coin_records(
|
||||
})
|
||||
|
||||
for rec in db.execute(
|
||||
select(CoinTransaction)
|
||||
select(CoinTransaction.created_at, CoinTransaction.amount)
|
||||
.where(
|
||||
CoinTransaction.user_id == user_id,
|
||||
CoinTransaction.biz_type == "signin",
|
||||
@@ -1244,7 +1319,7 @@ def user_coin_records(
|
||||
)
|
||||
.order_by(CoinTransaction.created_at.desc())
|
||||
.limit(fetch)
|
||||
).scalars():
|
||||
).all():
|
||||
rows.append({
|
||||
"source": "signin",
|
||||
"source_label": "签到",
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
"""风控监控读模型:聚合通用行为流水、风险事件与比价记录。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.device import DeviceLiveness
|
||||
from app.models.risk import BehaviorEvent, RiskIncident
|
||||
from app.models.user import User
|
||||
from app.repositories import risk as risk_repo
|
||||
|
||||
KIND_TO_RULE = {
|
||||
"sms": risk_repo.RULE_SMS_HOURLY,
|
||||
"oneclick": risk_repo.RULE_ONECLICK_DAILY,
|
||||
"compare": risk_repo.RULE_COMPARE_DAILY,
|
||||
}
|
||||
RULE_TO_KIND = {rule: kind for kind, rule in KIND_TO_RULE.items()}
|
||||
|
||||
|
||||
def _day_bounds(now: datetime | None = None) -> tuple[str, datetime, datetime]:
|
||||
current = (now or datetime.now(UTC)).astimezone(risk_repo.CN_TZ)
|
||||
start = current.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
end = start + timedelta(days=1)
|
||||
return (
|
||||
start.strftime("%Y-%m-%d"),
|
||||
start.astimezone(UTC),
|
||||
end.astimezone(UTC),
|
||||
)
|
||||
|
||||
|
||||
def _compare_day_bounds(now: datetime | None = None) -> tuple[datetime, datetime]:
|
||||
current = (now or datetime.now(UTC)).astimezone(risk_repo.CN_TZ).replace(
|
||||
tzinfo=None
|
||||
)
|
||||
start = current.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return start, start + timedelta(days=1)
|
||||
|
||||
|
||||
def sync_today_compare_incidents(db: Session, now: datetime | None = None) -> None:
|
||||
risk_repo.reconcile_compare_rule(db, at=now)
|
||||
|
||||
|
||||
def reset_open_alerts(
|
||||
db: Session, *, admin_id: int, reset_at: datetime
|
||||
) -> dict[str, int]:
|
||||
"""将三类待处理报警归零,并从 reset_at 开始重新累计。"""
|
||||
rule_codes = tuple(KIND_TO_RULE.values())
|
||||
incidents = list(
|
||||
db.scalars(
|
||||
select(RiskIncident).where(
|
||||
RiskIncident.rule_code.in_(rule_codes),
|
||||
RiskIncident.status == "open",
|
||||
)
|
||||
).all()
|
||||
)
|
||||
counts = {kind: 0 for kind in KIND_TO_RULE}
|
||||
for incident in incidents:
|
||||
incident.status = "resolved"
|
||||
incident.action_reason = risk_repo.MANUAL_RESET_REASON
|
||||
incident.handled_by = admin_id
|
||||
incident.handled_at = reset_at
|
||||
counts[RULE_TO_KIND[incident.rule_code]] += 1
|
||||
risk_repo.reset_rule_baselines(
|
||||
db,
|
||||
rule_codes=rule_codes,
|
||||
reset_at=reset_at,
|
||||
admin_id=admin_id,
|
||||
)
|
||||
return counts
|
||||
|
||||
|
||||
def summary(db: Session, now: datetime | None = None) -> dict:
|
||||
date_key, start, end = _day_bounds(now)
|
||||
compare_start, compare_end = _compare_day_bounds(now)
|
||||
current = now or datetime.now(UTC)
|
||||
risk_repo.reconcile_behavior_rule(
|
||||
db, rule_code=risk_repo.RULE_SMS_HOURLY, at=current, commit=False
|
||||
)
|
||||
risk_repo.reconcile_behavior_rule(
|
||||
db, rule_code=risk_repo.RULE_ONECLICK_DAILY, at=current, commit=False
|
||||
)
|
||||
risk_repo.reconcile_compare_rule(db, at=current, commit=False)
|
||||
db.commit()
|
||||
sms_threshold = risk_repo.get_rule_threshold(db, risk_repo.RULE_SMS_HOURLY)
|
||||
oneclick_threshold = risk_repo.get_rule_threshold(
|
||||
db, risk_repo.RULE_ONECLICK_DAILY
|
||||
)
|
||||
compare_threshold = risk_repo.get_rule_threshold(db, risk_repo.RULE_COMPARE_DAILY)
|
||||
|
||||
def _behavior_total(event_type: str, outcomes: tuple[str, ...]) -> int:
|
||||
return int(
|
||||
db.scalar(
|
||||
select(func.count(BehaviorEvent.id)).where(
|
||||
BehaviorEvent.event_type == event_type,
|
||||
BehaviorEvent.outcome.in_(outcomes),
|
||||
BehaviorEvent.occurred_at >= start,
|
||||
BehaviorEvent.occurred_at < end,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
compare_total = int(
|
||||
db.scalar(
|
||||
select(func.count(ComparisonRecord.id)).where(
|
||||
ComparisonRecord.created_at >= compare_start,
|
||||
ComparisonRecord.created_at < compare_end,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
def _open_count(rule_code: str) -> int:
|
||||
return int(
|
||||
db.scalar(
|
||||
select(func.count(RiskIncident.id)).where(
|
||||
RiskIncident.rule_code == rule_code,
|
||||
RiskIncident.status == "open",
|
||||
RiskIncident.window_key.like(f"{date_key}%"),
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
return {
|
||||
"date": date_key,
|
||||
"updated_at": now or datetime.now(UTC),
|
||||
"cards": [
|
||||
{
|
||||
"kind": "sms",
|
||||
"alert_subject_count": _open_count(risk_repo.RULE_SMS_HOURLY),
|
||||
"today_total": _behavior_total(risk_repo.EVENT_SMS_SEND, ("success",)),
|
||||
"threshold": sms_threshold,
|
||||
"rule_text": f"单设备 1 小时 ≥ {sms_threshold} 次",
|
||||
},
|
||||
{
|
||||
"kind": "oneclick",
|
||||
"alert_subject_count": _open_count(risk_repo.RULE_ONECLICK_DAILY),
|
||||
"today_total": _behavior_total(
|
||||
risk_repo.EVENT_ONECLICK_LOGIN, ("success", "failed")
|
||||
),
|
||||
"threshold": oneclick_threshold,
|
||||
"rule_text": f"单设备当日 ≥ {oneclick_threshold} 次",
|
||||
},
|
||||
{
|
||||
"kind": "compare",
|
||||
"alert_subject_count": _open_count(risk_repo.RULE_COMPARE_DAILY),
|
||||
"today_total": compare_total,
|
||||
"threshold": compare_threshold,
|
||||
"rule_text": f"单账户当日 ≥ {compare_threshold} 次",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _latest_device_model(db: Session, subject_id: str) -> str | None:
|
||||
return db.execute(
|
||||
select(BehaviorEvent.device_model)
|
||||
.where(
|
||||
BehaviorEvent.subject_type == "device",
|
||||
BehaviorEvent.subject_id == subject_id,
|
||||
BehaviorEvent.device_model.is_not(None),
|
||||
BehaviorEvent.device_model != "",
|
||||
)
|
||||
.order_by(BehaviorEvent.occurred_at.desc(), BehaviorEvent.id.desc())
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _first_used_at(db: Session, subject_id: str) -> datetime | None:
|
||||
behavior_first = db.scalar(
|
||||
select(func.min(BehaviorEvent.occurred_at)).where(
|
||||
BehaviorEvent.subject_type == "device",
|
||||
BehaviorEvent.subject_id == subject_id,
|
||||
)
|
||||
)
|
||||
registered_first = db.scalar(
|
||||
select(func.min(DeviceLiveness.created_at)).where(
|
||||
DeviceLiveness.device_id == subject_id
|
||||
)
|
||||
)
|
||||
observed = [value for value in (behavior_first, registered_first) if value is not None]
|
||||
if not observed:
|
||||
return None
|
||||
|
||||
def _utc(value: datetime) -> datetime:
|
||||
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
|
||||
|
||||
return min(observed, key=_utc)
|
||||
|
||||
|
||||
def _common_device(db: Session, user_id: int, start: datetime, end: datetime) -> str | None:
|
||||
return db.execute(
|
||||
select(ComparisonRecord.device_id)
|
||||
.where(
|
||||
ComparisonRecord.user_id == user_id,
|
||||
ComparisonRecord.device_id.is_not(None),
|
||||
ComparisonRecord.created_at >= start,
|
||||
ComparisonRecord.created_at < end,
|
||||
)
|
||||
.group_by(ComparisonRecord.device_id)
|
||||
.order_by(func.count(ComparisonRecord.id).desc(), ComparisonRecord.device_id.asc())
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def list_incidents(
|
||||
db: Session,
|
||||
*,
|
||||
kind: str,
|
||||
status: str = "open",
|
||||
limit: int = 20,
|
||||
cursor: int = 0,
|
||||
) -> tuple[list[dict], int | None, int]:
|
||||
rule_code = KIND_TO_RULE[kind]
|
||||
stmt = select(RiskIncident).where(RiskIncident.rule_code == rule_code)
|
||||
count_stmt = select(func.count(RiskIncident.id)).where(
|
||||
RiskIncident.rule_code == rule_code
|
||||
)
|
||||
if status != "all":
|
||||
stmt = stmt.where(RiskIncident.status == status)
|
||||
count_stmt = count_stmt.where(RiskIncident.status == status)
|
||||
total = int(db.scalar(count_stmt) or 0)
|
||||
incidents = list(
|
||||
db.scalars(
|
||||
stmt.order_by(desc(RiskIncident.triggered_at), desc(RiskIncident.id))
|
||||
.offset(cursor)
|
||||
.limit(limit)
|
||||
).all()
|
||||
)
|
||||
|
||||
user_ids = [int(i.subject_id) for i in incidents if i.subject_type == "user"]
|
||||
users = {
|
||||
u.id: u
|
||||
for u in db.scalars(select(User).where(User.id.in_(user_ids))).all()
|
||||
} if user_ids else {}
|
||||
|
||||
items: list[dict] = []
|
||||
for incident in incidents:
|
||||
item = {
|
||||
"incident_id": incident.id,
|
||||
"kind": kind,
|
||||
"subject_type": incident.subject_type,
|
||||
"subject_id": incident.subject_id,
|
||||
"window_start": incident.window_start,
|
||||
"window_end": incident.window_end,
|
||||
"first_event_at": incident.first_event_at,
|
||||
"triggered_at": incident.triggered_at,
|
||||
"last_event_at": incident.last_event_at,
|
||||
"event_count": incident.event_count,
|
||||
"status": incident.status,
|
||||
}
|
||||
if incident.subject_type == "device":
|
||||
restriction = risk_repo.get_active_restriction(
|
||||
db,
|
||||
subject_type="device",
|
||||
subject_id=incident.subject_id,
|
||||
scope=risk_repo.SCOPE_AUTH_DEVICE,
|
||||
)
|
||||
item["device_model"] = _latest_device_model(db, incident.subject_id)
|
||||
item["first_used_at"] = _first_used_at(db, incident.subject_id)
|
||||
item["restricted"] = restriction is not None
|
||||
item["restriction_id"] = restriction.id if restriction else None
|
||||
else:
|
||||
uid = int(incident.subject_id)
|
||||
user = users.get(uid)
|
||||
restriction = risk_repo.get_active_restriction(
|
||||
db,
|
||||
subject_type="user",
|
||||
subject_id=incident.subject_id,
|
||||
scope=risk_repo.SCOPE_ECONOMIC_ACCOUNT,
|
||||
)
|
||||
item.update(
|
||||
{
|
||||
"user_id": uid,
|
||||
"phone": user.phone if user else None,
|
||||
"registered_at": user.created_at if user else None,
|
||||
"common_device_id": _common_device(
|
||||
db, uid, incident.window_start, incident.window_end
|
||||
),
|
||||
"restricted": restriction is not None,
|
||||
"restriction_id": restriction.id if restriction else None,
|
||||
}
|
||||
)
|
||||
items.append(item)
|
||||
next_cursor = cursor + len(items) if cursor + len(items) < total else None
|
||||
return items, next_cursor, total
|
||||
|
||||
|
||||
def _page_events(
|
||||
db: Session,
|
||||
incident: RiskIncident,
|
||||
*,
|
||||
event_type: str,
|
||||
outcomes: tuple[str, ...] | None,
|
||||
limit: int,
|
||||
cursor: int,
|
||||
) -> tuple[list[BehaviorEvent], int]:
|
||||
filters = [
|
||||
BehaviorEvent.event_type == event_type,
|
||||
BehaviorEvent.subject_type == incident.subject_type,
|
||||
BehaviorEvent.subject_id == incident.subject_id,
|
||||
BehaviorEvent.occurred_at >= incident.window_start,
|
||||
BehaviorEvent.occurred_at < incident.window_end,
|
||||
]
|
||||
if outcomes:
|
||||
filters.append(BehaviorEvent.outcome.in_(outcomes))
|
||||
total = int(db.scalar(select(func.count(BehaviorEvent.id)).where(*filters)) or 0)
|
||||
rows = list(
|
||||
db.scalars(
|
||||
select(BehaviorEvent)
|
||||
.where(*filters)
|
||||
.order_by(BehaviorEvent.occurred_at.desc(), BehaviorEvent.id.desc())
|
||||
.offset(cursor)
|
||||
.limit(limit)
|
||||
).all()
|
||||
)
|
||||
return rows, total
|
||||
|
||||
|
||||
def _user_map(db: Session, user_ids: set[int]) -> dict[int, User]:
|
||||
if not user_ids:
|
||||
return {}
|
||||
return {u.id: u for u in db.scalars(select(User).where(User.id.in_(user_ids))).all()}
|
||||
|
||||
|
||||
def _distinct_event_users(
|
||||
db: Session,
|
||||
incident: RiskIncident,
|
||||
*,
|
||||
event_type: str,
|
||||
outcomes: tuple[str, ...],
|
||||
) -> int:
|
||||
return int(
|
||||
db.scalar(
|
||||
select(func.count(func.distinct(BehaviorEvent.user_id))).where(
|
||||
BehaviorEvent.event_type == event_type,
|
||||
BehaviorEvent.subject_type == incident.subject_type,
|
||||
BehaviorEvent.subject_id == incident.subject_id,
|
||||
BehaviorEvent.outcome.in_(outcomes),
|
||||
BehaviorEvent.user_id.is_not(None),
|
||||
BehaviorEvent.occurred_at >= incident.window_start,
|
||||
BehaviorEvent.occurred_at < incident.window_end,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def _sms_distinct_accounts(db: Session, incident: RiskIncident) -> int:
|
||||
"""统计整个告警窗口内,确实完成本设备短信验证的不同账号数。"""
|
||||
send_rows = db.execute(
|
||||
select(BehaviorEvent.phone, BehaviorEvent.occurred_at).where(
|
||||
BehaviorEvent.event_type == risk_repo.EVENT_SMS_SEND,
|
||||
BehaviorEvent.subject_type == incident.subject_type,
|
||||
BehaviorEvent.subject_id == incident.subject_id,
|
||||
BehaviorEvent.outcome == "success",
|
||||
BehaviorEvent.phone.is_not(None),
|
||||
BehaviorEvent.occurred_at >= incident.window_start,
|
||||
BehaviorEvent.occurred_at < incident.window_end,
|
||||
)
|
||||
).all()
|
||||
phones = {phone for phone, _ in send_rows}
|
||||
if not phones:
|
||||
return 0
|
||||
login_rows = db.execute(
|
||||
select(
|
||||
BehaviorEvent.user_id,
|
||||
BehaviorEvent.phone,
|
||||
BehaviorEvent.occurred_at,
|
||||
)
|
||||
.where(
|
||||
BehaviorEvent.event_type == risk_repo.EVENT_SMS_LOGIN,
|
||||
BehaviorEvent.subject_id == incident.subject_id,
|
||||
BehaviorEvent.phone.in_(phones),
|
||||
BehaviorEvent.user_id.is_not(None),
|
||||
BehaviorEvent.outcome == "success",
|
||||
BehaviorEvent.occurred_at >= incident.window_start,
|
||||
BehaviorEvent.occurred_at
|
||||
< incident.window_end + timedelta(seconds=settings.SMS_CODE_TTL_SEC),
|
||||
)
|
||||
.order_by(BehaviorEvent.occurred_at.asc(), BehaviorEvent.id.asc())
|
||||
).all()
|
||||
matched_users = {
|
||||
user_id
|
||||
for user_id, phone, login_at in login_rows
|
||||
if any(
|
||||
sent_phone == phone
|
||||
and sent_at <= login_at
|
||||
and login_at
|
||||
<= sent_at + timedelta(seconds=settings.SMS_CODE_TTL_SEC)
|
||||
for sent_phone, sent_at in send_rows
|
||||
)
|
||||
}
|
||||
return len(matched_users)
|
||||
|
||||
|
||||
def incident_details(
|
||||
db: Session,
|
||||
*,
|
||||
incident: RiskIncident,
|
||||
kind: str,
|
||||
limit: int,
|
||||
cursor: int,
|
||||
) -> dict:
|
||||
if kind == "sms":
|
||||
rows, total = _page_events(
|
||||
db,
|
||||
incident,
|
||||
event_type=risk_repo.EVENT_SMS_SEND,
|
||||
outcomes=("success",),
|
||||
limit=limit,
|
||||
cursor=cursor,
|
||||
)
|
||||
phone_values = {r.phone for r in rows if r.phone}
|
||||
login_rows = list(
|
||||
db.scalars(
|
||||
select(BehaviorEvent)
|
||||
.where(
|
||||
BehaviorEvent.event_type == risk_repo.EVENT_SMS_LOGIN,
|
||||
BehaviorEvent.subject_id == incident.subject_id,
|
||||
BehaviorEvent.phone.in_(phone_values),
|
||||
BehaviorEvent.outcome == "success",
|
||||
BehaviorEvent.occurred_at >= incident.window_start,
|
||||
BehaviorEvent.occurred_at
|
||||
< incident.window_end + timedelta(seconds=settings.SMS_CODE_TTL_SEC),
|
||||
)
|
||||
.order_by(BehaviorEvent.occurred_at.asc(), BehaviorEvent.id.asc())
|
||||
).all()
|
||||
) if phone_values else []
|
||||
users = _user_map(db, {r.user_id for r in login_rows if r.user_id is not None})
|
||||
items = []
|
||||
for row in rows:
|
||||
match = next(
|
||||
(
|
||||
login
|
||||
for login in login_rows
|
||||
if login.phone == row.phone
|
||||
and login.occurred_at >= row.occurred_at
|
||||
and login.occurred_at
|
||||
<= row.occurred_at + timedelta(seconds=settings.SMS_CODE_TTL_SEC)
|
||||
),
|
||||
None,
|
||||
)
|
||||
user = users.get(match.user_id) if match and match.user_id else None
|
||||
items.append(
|
||||
{
|
||||
"id": row.id,
|
||||
"occurred_at": row.occurred_at,
|
||||
"outcome": row.outcome,
|
||||
"phone": row.phone,
|
||||
"user_id": user.id if user else None,
|
||||
"username": user.username if user else None,
|
||||
"account_registered_at": user.created_at if user else None,
|
||||
"verified": match is not None,
|
||||
}
|
||||
)
|
||||
distinct_accounts = _sms_distinct_accounts(db, incident)
|
||||
elif kind == "oneclick":
|
||||
rows, total = _page_events(
|
||||
db,
|
||||
incident,
|
||||
event_type=risk_repo.EVENT_ONECLICK_LOGIN,
|
||||
outcomes=("success", "failed"),
|
||||
limit=limit,
|
||||
cursor=cursor,
|
||||
)
|
||||
users = _user_map(db, {r.user_id for r in rows if r.user_id is not None})
|
||||
items = []
|
||||
for row in rows:
|
||||
user = users.get(row.user_id) if row.user_id else None
|
||||
items.append(
|
||||
{
|
||||
"id": row.id,
|
||||
"occurred_at": row.occurred_at,
|
||||
"outcome": row.outcome,
|
||||
"phone": row.phone,
|
||||
"user_id": row.user_id,
|
||||
"username": user.username if user else None,
|
||||
"account_registered_at": user.created_at if user else None,
|
||||
"reason": row.reason,
|
||||
}
|
||||
)
|
||||
distinct_accounts = _distinct_event_users(
|
||||
db,
|
||||
incident,
|
||||
event_type=risk_repo.EVENT_ONECLICK_LOGIN,
|
||||
outcomes=("success", "failed"),
|
||||
)
|
||||
else:
|
||||
user_id = int(incident.subject_id)
|
||||
filters = (
|
||||
ComparisonRecord.user_id == user_id,
|
||||
ComparisonRecord.created_at >= incident.window_start,
|
||||
ComparisonRecord.created_at < incident.window_end,
|
||||
)
|
||||
total = int(db.scalar(select(func.count(ComparisonRecord.id)).where(*filters)) or 0)
|
||||
rows = list(
|
||||
db.scalars(
|
||||
select(ComparisonRecord)
|
||||
.where(*filters)
|
||||
.order_by(ComparisonRecord.created_at.desc(), ComparisonRecord.id.desc())
|
||||
.offset(cursor)
|
||||
.limit(limit)
|
||||
).all()
|
||||
)
|
||||
items = []
|
||||
for row in rows:
|
||||
prices = {
|
||||
str(result.get("platform_name") or result.get("platform_id") or "未知平台"): (
|
||||
float(result["price"]) if result.get("price") is not None else None
|
||||
)
|
||||
for result in (row.comparison_results or [])
|
||||
}
|
||||
items.append(
|
||||
{
|
||||
"id": row.id,
|
||||
"occurred_at": row.created_at,
|
||||
"outcome": row.status,
|
||||
"store_name": row.store_name,
|
||||
"product_names": row.product_names,
|
||||
"prices": prices,
|
||||
"saved_amount_yuan": (
|
||||
row.saved_amount_cents / 100
|
||||
if row.saved_amount_cents is not None
|
||||
else None
|
||||
),
|
||||
"status": row.status,
|
||||
}
|
||||
)
|
||||
distinct_accounts = None
|
||||
next_cursor = cursor + len(items) if cursor + len(items) < total else None
|
||||
return {
|
||||
"kind": kind,
|
||||
"incident_id": incident.id,
|
||||
"event_count": incident.event_count,
|
||||
"distinct_accounts": distinct_accounts,
|
||||
"items": items,
|
||||
"next_cursor": next_cursor,
|
||||
"total": total,
|
||||
}
|
||||
@@ -6,10 +6,10 @@ user.last_login_at / comparison_record.status / withdraw_order.status)要加索
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from datetime import UTC, date, datetime, time, timedelta, timezone
|
||||
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy import case, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin.repositories.coupon_data import _percentile
|
||||
@@ -30,21 +30,19 @@ from app.models.user import User
|
||||
from app.models.wallet import CoinTransaction, WithdrawOrder
|
||||
|
||||
_BEIJING = timezone(timedelta(hours=8))
|
||||
REWARD_VIDEO_BIZ_TYPES = ("reward_video", "ad_reward")
|
||||
REWARD_VIDEO_BIZ_TYPES = ("reward_video", "ad_reward", "signin_boost")
|
||||
# 领券/比价奖励金币的真实来源是信息流广告发奖(ad_feed_reward_record,按 feed_scene 分场景);
|
||||
# coin_transaction 里只有扁平的 feed_ad_reward、biz_type 不分 coupon/comparison,故这俩桶历史从未
|
||||
# 被写入,仅留作未来兜底,实际金额在下方按 feed_scene 汇总 ad_feed_reward_record 得出。reward_video/
|
||||
# ad_reward 是激励视频,单独成桶、不再混进领券奖励(历史误并会把激励视频金币双计进领券)。
|
||||
# 被写入,仅留作未来兜底,实际金额在下方按 feed_scene 汇总 ad_feed_reward_record 得出。
|
||||
# reward_video/ad_reward 及历史 signin_boost 均归看视频桶,不再混进领券奖励或常规任务。
|
||||
COUPON_REWARD_BIZ_TYPES = ("coupon", "coupon_reward")
|
||||
COMPARISON_REWARD_BIZ_TYPES = ("comparison", "compare_reward", "comparison_reward")
|
||||
EXCLUDED_REWARD_BIZ_TYPES = ("invite_inviter", "invite_invitee", "admin_grant")
|
||||
UNCLASSIFIED_FEED_BIZ_TYPES = ("feed_ad_reward",)
|
||||
REGULAR_TASK_EXCLUDED_BIZ_TYPES = (
|
||||
*REWARD_VIDEO_BIZ_TYPES,
|
||||
*COUPON_REWARD_BIZ_TYPES,
|
||||
*COMPARISON_REWARD_BIZ_TYPES,
|
||||
*EXCLUDED_REWARD_BIZ_TYPES,
|
||||
*UNCLASSIFIED_FEED_BIZ_TYPES,
|
||||
# 常规任务必须按明确来源相加;不能从全部正向流水反减排除项,否则新增广告/运营
|
||||
# biz_type 时会在排除清单更新前自动混入该桶。task_ 前缀在查询处单独覆盖现有及未来任务。
|
||||
REGULAR_TASK_EXACT_BIZ_TYPES = (
|
||||
"signin",
|
||||
"price_report_reward",
|
||||
"feedback_reward",
|
||||
)
|
||||
MEITUAN_CPS_INVALID_STATUSES = ("4", "5")
|
||||
MEITUAN_CPS_SETTLED_STATUS = "6"
|
||||
@@ -61,7 +59,7 @@ def _beijing_today_start_utc() -> datetime:
|
||||
"""北京时间今天 0 点对应的 UTC 时刻(DAU / 今日新增按北京时区切天)。"""
|
||||
now_bj = datetime.now(_BEIJING)
|
||||
start_bj = now_bj.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return start_bj.astimezone(timezone.utc)
|
||||
return start_bj.astimezone(UTC)
|
||||
|
||||
|
||||
def today_dau(db: Session) -> int:
|
||||
@@ -94,8 +92,8 @@ def _period_bounds(date_from: date, date_to: date) -> tuple[datetime, datetime,
|
||||
"""
|
||||
start_bj = datetime.combine(date_from, time.min, tzinfo=_BEIJING)
|
||||
end_bj = datetime.combine(date_to + timedelta(days=1), time.min, tzinfo=_BEIJING)
|
||||
start_utc = start_bj.astimezone(timezone.utc)
|
||||
end_utc = end_bj.astimezone(timezone.utc)
|
||||
start_utc = start_bj.astimezone(UTC)
|
||||
end_utc = end_bj.astimezone(UTC)
|
||||
return (
|
||||
start_utc,
|
||||
end_utc,
|
||||
@@ -505,7 +503,10 @@ def dashboard_overview(
|
||||
period_regular_task_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.notin_(REGULAR_TASK_EXCLUDED_BIZ_TYPES),
|
||||
or_(
|
||||
CoinTransaction.biz_type.in_(REGULAR_TASK_EXACT_BIZ_TYPES),
|
||||
CoinTransaction.biz_type.like(r"task\_%", escape="\\"),
|
||||
),
|
||||
)
|
||||
period_cps_orders = list(
|
||||
db.execute(
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Annotated
|
||||
|
||||
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 analytics_health as repo
|
||||
from app.admin.schemas.analytics_health import (
|
||||
HealthBreakdownRow,
|
||||
@@ -17,7 +17,7 @@ from app.admin.schemas.analytics_health import (
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/analytics-health",
|
||||
tags=["admin-analytics-health"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
dependencies=[Depends(require_page("analytics-health"))],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""admin 操作审计日志查询(所有 admin 可看:谁在何时对什么做了什么)。"""
|
||||
"""admin 操作审计日志查询(需要 audit-logs 页面权限)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.admin.deps import AdminDb, get_current_admin
|
||||
from app.admin.deps import AdminDb, require_page
|
||||
from app.admin.repositories import audit_log as audit_repo
|
||||
from app.admin.schemas.admin import AdminAuditLogOut
|
||||
from app.admin.schemas.common import CursorPage
|
||||
@@ -13,7 +13,7 @@ from app.admin.schemas.common import CursorPage
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/audit-logs",
|
||||
tags=["admin-audit"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
dependencies=[Depends(require_page("audit-logs"))],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -30,6 +30,12 @@ def _validate(key: str, value: Any) -> None:
|
||||
if t == "int":
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
||||
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":
|
||||
if not isinstance(value, list) or not value:
|
||||
raise ValueError("需为非空整数列表")
|
||||
|
||||
@@ -18,6 +18,8 @@ from app.admin.schemas.coupon_data import (
|
||||
CouponDataOut,
|
||||
CouponDataRow,
|
||||
CouponDataSummary,
|
||||
CouponPointDetail,
|
||||
CouponPointDetailsOut,
|
||||
CouponSlotRow,
|
||||
CouponSlotsOut,
|
||||
CouponUserRecordsOut,
|
||||
@@ -122,6 +124,22 @@ def get_coupon_slots(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/point-details",
|
||||
response_model=CouponPointDetailsOut,
|
||||
summary="按 trace 查询单次领券任务的逐券点位明细",
|
||||
)
|
||||
def get_coupon_point_details(
|
||||
db: AdminDb,
|
||||
trace_id: Annotated[str, Query(min_length=1, max_length=64, description="领券 trace_id")],
|
||||
) -> CouponPointDetailsOut:
|
||||
items = coupon_data.coupon_point_details(db, trace_id=trace_id)
|
||||
return CouponPointDetailsOut(
|
||||
trace_id=trace_id,
|
||||
items=[CouponPointDetail(**item) for item in items],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/user-records",
|
||||
response_model=CouponUserRecordsOut,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
数据源 device_liveness 表(心跳 last_heartbeat_at + liveness_state + kill_alert_pending,
|
||||
见 app/models/device.py)。在线/掉线、掉线时长由 repo 按 HEARTBEAT_TIMEOUT_MINUTES 阈值派生。
|
||||
纯读:无写、无审计。任意登录管理员可看(同大盘/设备管理,无角色门)。
|
||||
纯读:无写、无审计。需要 device-liveness 页面权限。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,7 +10,7 @@ from typing import Annotated
|
||||
|
||||
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.schemas.common import CursorPage
|
||||
from app.admin.schemas.device import DeviceLivenessItem, DeviceLivenessStats
|
||||
@@ -18,7 +18,7 @@ from app.admin.schemas.device import DeviceLivenessItem, DeviceLivenessStats
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/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 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.schemas.analytics import AnalyticsEventOut
|
||||
from app.admin.schemas.common import CursorPage
|
||||
@@ -14,7 +14,7 @@ from app.admin.schemas.common import CursorPage
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/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.feedback import (
|
||||
FeedbackApproveRequest,
|
||||
FeedbackBulkApproveRequest,
|
||||
FeedbackBulkItemResult,
|
||||
FeedbackBulkRejectRequest,
|
||||
FeedbackBulkResult,
|
||||
FeedbackOut,
|
||||
FeedbackRejectRequest,
|
||||
FeedbackSummary,
|
||||
@@ -19,6 +23,7 @@ from app.admin.schemas.feedback import (
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.feedback import Feedback
|
||||
from app.repositories import wallet as wallet_repo
|
||||
from app.services import notification_events
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/feedbacks",
|
||||
@@ -32,6 +37,123 @@ def _ensure_pending(fb: Feedback) -> None:
|
||||
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="反馈工单列表")
|
||||
def list_feedbacks(
|
||||
db: AdminDb,
|
||||
@@ -72,6 +194,50 @@ def feedback_summary(db: AdminDb) -> FeedbackSummary:
|
||||
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="标记反馈已处理")
|
||||
def handle_feedback(
|
||||
feedback_id: int,
|
||||
@@ -92,49 +258,7 @@ def approve_feedback(
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> FeedbackOut:
|
||||
fb = db.get(Feedback, feedback_id)
|
||||
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)
|
||||
return _approve_feedback(db, admin, feedback_id, payload, get_client_ip(request))
|
||||
|
||||
|
||||
@router.post("/{feedback_id}/reject", response_model=FeedbackOut, summary="拒绝采纳反馈")
|
||||
@@ -145,38 +269,4 @@ def reject_feedback(
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> FeedbackOut:
|
||||
fb = db.get(Feedback, feedback_id)
|
||||
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)
|
||||
return _reject_feedback(db, admin, feedback_id, payload, get_client_ip(request))
|
||||
|
||||
@@ -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)
|
||||
@@ -3,7 +3,8 @@
|
||||
数据由客户端 POST /api/v1/report 写入 price_report 表(提交即 pending);本路由是运营后台
|
||||
对它的人工审核窗口。**通过** → 给上报用户钱包发固定金币(PRICE_REPORT_REWARD_COINS):
|
||||
改状态 + 发金币(wallet.grant_coins)+ 审计同一事务一起 commit(原子,仿 users.grant_user_coins),
|
||||
绝不只改状态不发钱或反之。客户端轮询 GET /api/v1/report/records 自动看到结果(无需推送)。
|
||||
绝不只改状态不发钱或反之。通过后下发「爆料审核通过」通知(站内 + push,PRD #11);
|
||||
客户端也可轮询 GET /api/v1/report/records 看到结果。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -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.schemas.common import CursorPage, OkResponse
|
||||
from app.admin.schemas.price_report import (
|
||||
PriceReportBulkItemResult,
|
||||
PriceReportBulkRejectRequest,
|
||||
PriceReportBulkRequest,
|
||||
PriceReportBulkResult,
|
||||
PriceReportOut,
|
||||
PriceReportRejectRequest,
|
||||
PriceReportSummary,
|
||||
@@ -24,6 +29,7 @@ from app.core.rewards import PRICE_REPORT_REWARD_COINS
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.price_report import PriceReport
|
||||
from app.repositories import wallet as wallet_repo
|
||||
from app.services import notification_events
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/price-reports",
|
||||
@@ -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="上报更低价列表(筛选+分页)")
|
||||
def list_price_reports(
|
||||
db: AdminDb,
|
||||
@@ -58,6 +117,50 @@ def price_report_summary(db: AdminDb) -> PriceReportSummary:
|
||||
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="通过上报(发固定金币)")
|
||||
def approve_price_report(
|
||||
report_id: int,
|
||||
@@ -65,25 +168,7 @@ def approve_price_report(
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> OkResponse:
|
||||
# 行锁(SELECT FOR UPDATE):并发/连点双请求会都读到 pending → 各发一次金币双倍发奖,
|
||||
# 锁住该行串行化,第二个请求拿锁后看到 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()
|
||||
_approve_price_report(db, admin, report_id, get_client_ip(request))
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@@ -95,16 +180,5 @@ def reject_price_report(
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> OkResponse:
|
||||
rep = db.get(PriceReport, report_id, with_for_update=True) # 行锁,同 approve(防并发重复审核)
|
||||
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()
|
||||
_reject_price_report(db, admin, report_id, body.reason, get_client_ip(request))
|
||||
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,
|
||||
GrantCoinsRequest,
|
||||
SetDebugTraceRequest,
|
||||
SetUserRiskRequest,
|
||||
SetUserStatusRequest,
|
||||
UserCoinRecord,
|
||||
UserRewardStats,
|
||||
@@ -84,12 +85,21 @@ def get_user_reward_stats(
|
||||
db: AdminDb,
|
||||
date_from: 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:
|
||||
"""提现详情抽屉「用户统计区」。date_from/date_to 都不传 = 注册至今(全量)。"""
|
||||
if user_repo.get_user_by_id(db, user_id) is None:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
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()
|
||||
|
||||
|
||||
@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="开关调试链接权限")
|
||||
def set_user_debug_trace(
|
||||
user_id: int,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""admin 提现:列表(读)+ 单笔重试查单 + 批量对账(写,带审计)。
|
||||
"""admin 提现:列表(读)+ 单笔/批量查单与审核操作(写,带审计)。
|
||||
|
||||
提现的钱逻辑(查微信/退款/撤单/幂等)全部复用 app.repositories.wallet,admin 只触发 + 记审计。
|
||||
这些 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.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||
from app.admin.repositories import queries
|
||||
from app.admin.schemas.common import CursorPage
|
||||
from app.admin.schemas.admin import AdminAuditLogOut
|
||||
from app.admin.schemas.common import CursorPage
|
||||
from app.admin.schemas.wallet import (
|
||||
CashTxnOut,
|
||||
ReconcileResult,
|
||||
InviteOverviewOut,
|
||||
WithdrawBulkItemResult,
|
||||
WithdrawBulkRejectRequest,
|
||||
WithdrawBulkRequest,
|
||||
WithdrawBulkResult,
|
||||
WithdrawBulkItemResult,
|
||||
WithdrawDetailOut,
|
||||
WithdrawLedgerCheckOut,
|
||||
WithdrawListItemOut,
|
||||
WithdrawOrderOut,
|
||||
WithdrawRejectRequest,
|
||||
@@ -83,8 +82,16 @@ def list_withdraws(
|
||||
cursor=cursor,
|
||||
)
|
||||
# 联表带出手机号/昵称 + 累计成功提现(本页 user_id 批量富化,2 条聚合查询,无 N+1)。
|
||||
enrichment = queries.withdraw_list_enrichment(db, [o.user_id for o in items])
|
||||
_empty = {"phone": None, "nickname": None, "cumulative_success_cents": 0}
|
||||
enrichment = queries.withdraw_list_enrichment(
|
||||
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 = [
|
||||
WithdrawListItemOut.model_validate(o).model_copy(
|
||||
update=enrichment.get(o.user_id, _empty)
|
||||
@@ -95,8 +102,13 @@ def list_withdraws(
|
||||
|
||||
|
||||
@router.get("/summary", response_model=WithdrawSummaryOut, summary="提现审核台统计")
|
||||
def withdraws_summary(db: AdminDb) -> WithdrawSummaryOut:
|
||||
return WithdrawSummaryOut(**queries.withdraw_summary(db))
|
||||
def withdraws_summary(
|
||||
db: AdminDb,
|
||||
source: Annotated[
|
||||
str | None, Query(pattern="^(coin_cash|invite_cash)$")
|
||||
] = None,
|
||||
) -> WithdrawSummaryOut:
|
||||
return WithdrawSummaryOut(**queries.withdraw_summary(db, source=source))
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -129,13 +141,11 @@ def withdraw_health_check(db: AdminDb) -> WxpayHealthCheckOut:
|
||||
issues.append("免确认授权回调地址未配置")
|
||||
|
||||
# 实际是否自动对账 = env 部署总闸(worker 起没起)AND 运营后台 DB 开关(本轮跑不跑)。
|
||||
# 自动查单属于非阻断运维能力:状态继续返回给调用方,但关闭时不计入微信提现配置 issues,
|
||||
# 避免把“没有自动扫单”误报成“无法打款”。
|
||||
worker_running = settings.WITHDRAW_AUTO_RECONCILE_ENABLED
|
||||
daily_on = bool(app_config.get_value(db, "withdraw_auto_reconcile_enabled"))
|
||||
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(
|
||||
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="提现单详情")
|
||||
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)
|
||||
if order is None:
|
||||
raise HTTPException(status_code=404, detail="提现单不存在")
|
||||
@@ -174,6 +184,8 @@ def withdraw_detail(out_bill_no: str, db: AdminDb) -> WithdrawDetailOut:
|
||||
id=user.id,
|
||||
phone=user.phone,
|
||||
nickname=user.nickname,
|
||||
is_high_risk=user.is_high_risk,
|
||||
high_risk_note=user.high_risk_note,
|
||||
status=user.status,
|
||||
wechat_nickname=user.wechat_nickname,
|
||||
wechat_avatar_url=user.wechat_avatar_url,
|
||||
@@ -197,37 +209,36 @@ def withdraw_detail(out_bill_no: str, db: AdminDb) -> WithdrawDetailOut:
|
||||
recent_withdraws,
|
||||
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(
|
||||
order=WithdrawOrderOut.model_validate(order),
|
||||
user=user_snapshot,
|
||||
cumulative_success_cents=int(
|
||||
detail_enrichment.get("cumulative_success_cents", 0)
|
||||
),
|
||||
risk_flags=risk_flags,
|
||||
risk_score=risk_score,
|
||||
recent_withdraws=[WithdrawOrderOut.model_validate(o) for o in recent_withdraws],
|
||||
recent_cash_transactions=[CashTxnOut.model_validate(t) for t in recent_cash_transactions],
|
||||
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:
|
||||
success = sum(1 for item in items if item.ok)
|
||||
return WithdrawBulkResult(
|
||||
|
||||
@@ -26,7 +26,10 @@ class AdRevenueRecord(BaseModel):
|
||||
|
||||
record_id: int
|
||||
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_factor: float | None = Field(None, description="因子1(eCPM 档);非 granted 为空")
|
||||
units: int = Field(..., description="折算份数:激励视频恒 1;信息流 = 满 10 秒份数")
|
||||
@@ -44,7 +47,7 @@ class AdRevenueDaily(BaseModel):
|
||||
|
||||
date: str = Field(..., description="北京时间 YYYY-MM-DD")
|
||||
impressions: int = Field(..., description="当天展示条数合计")
|
||||
revenue_yuan: float = Field(..., description="当天客户端预估收益合计(元;eCPM 折算)")
|
||||
revenue_yuan: float = Field(..., description="当天客户端有效预估收益合计(元;eCPM 折算)")
|
||||
pangle_revenue_yuan: float | None = Field(
|
||||
None, description="当天穿山甲后台预估收益(元;GroMore revenue);非全量视图/无数据为空"
|
||||
)
|
||||
@@ -93,7 +96,10 @@ class AdRevenueRow(BaseModel):
|
||||
has_impression: bool = Field(..., description="是否有广告展示(信息流逐条展示=True,纯发奖行=False)")
|
||||
impressions: int = Field(..., description="本行展示条数:有展示=1 / 纯发奖=0(供日汇总、趋势图复用)")
|
||||
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(
|
||||
None,
|
||||
description="主表逐行展示用的预估收益(元):一次比价/领券聚合行=该次发奖广告 eCPM 折算之和;"
|
||||
@@ -150,7 +156,7 @@ class AdRevenueReportOut(BaseModel):
|
||||
total: int = Field(..., description="广告事件总数(全量,不受分页影响;= 当前筛选下的分页总条数)")
|
||||
truncated: bool = Field(..., description="当前页之后是否还有更多事件(len(events) > offset + limit)")
|
||||
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(
|
||||
None,
|
||||
description="全量穿山甲后台预估收益合计(元;GroMore revenue)。穿山甲无用户/类型/场景维度,"
|
||||
|
||||
@@ -49,6 +49,15 @@ class CouponDataHourly(BaseModel):
|
||||
avg_elapsed_ms: int | None = None
|
||||
|
||||
|
||||
class CouponPointDetail(BaseModel):
|
||||
"""一次领券任务中的单券点位结果。"""
|
||||
|
||||
coupon_id: str
|
||||
coupon_name: str | None = None
|
||||
status: str = Field(..., description="success / already_claimed / failed / skipped")
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class CouponDataRow(BaseModel):
|
||||
"""一条领券明细(一次领券任务)。"""
|
||||
|
||||
@@ -69,6 +78,12 @@ class CouponDataRow(BaseModel):
|
||||
app_env: str | None = None
|
||||
started_at: datetime = Field(..., description="发起时刻(明细「时间」列)")
|
||||
claimed_count: int | None = None
|
||||
point_success_count: int | None = Field(
|
||||
None, description="本次成功单券数(success+already_claimed);无逐券事件为空"
|
||||
)
|
||||
point_total_count: int | None = Field(
|
||||
None, description="本次尝试单券数(success+already_claimed+failed,不含 skipped);无逐券事件为空"
|
||||
)
|
||||
trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id")
|
||||
ad_revenue_yuan: float = Field(
|
||||
0.0, description="本次领券看的信息流广告预估收益(元);按 trace_id 聚合 ad_ecpm_record"
|
||||
@@ -89,6 +104,13 @@ class CouponDataOut(BaseModel):
|
||||
items: list[CouponDataRow] = Field(..., description="逐条领券明细(当前页)")
|
||||
|
||||
|
||||
class CouponPointDetailsOut(BaseModel):
|
||||
"""单次领券任务的逐券点位结果,供点击分数时按需加载。"""
|
||||
|
||||
trace_id: str
|
||||
items: list[CouponPointDetail] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CouponUserRecordsOut(BaseModel):
|
||||
"""某用户全部领券记录(点手机号抽屉用):total=该用户领券总次数,items=记录列表(UserRecordsDrawer 渲染)。"""
|
||||
|
||||
|
||||
@@ -24,7 +24,9 @@ class DeviceLivenessItem(BaseModel):
|
||||
device_model: str | None = None # 由 device_id 解析(device_<机型>_<hash>);非 DB 列
|
||||
platform: str
|
||||
app_version: str | None = None
|
||||
registration_id: str | None = None # 非空 = 拿到极光 token、可推送
|
||||
registration_id: str | None = None # 旧极光字段,仅兼容历史数据
|
||||
push_vendor: str | None = None
|
||||
push_token: str | None = None
|
||||
|
||||
ever_protected: bool # 是否开过无障碍(=该设备对功能有意义)
|
||||
first_protected_at: datetime | None = None # 首次开无障碍时刻(老设备为 null)
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
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
|
||||
|
||||
@@ -55,6 +55,54 @@ class FeedbackRejectRequest(BaseModel):
|
||||
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):
|
||||
"""审核台顶部各状态计数(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)
|
||||
@@ -56,6 +56,42 @@ class PriceReportRejectRequest(BaseModel):
|
||||
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):
|
||||
"""审核台顶部各状态计数。"""
|
||||
|
||||
|
||||
@@ -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
|
||||
status: str
|
||||
debug_trace_enabled: bool = False
|
||||
is_high_risk: bool = False
|
||||
high_risk_note: str | None = None
|
||||
wechat_openid: str | None = None
|
||||
wechat_nickname: str | None = None
|
||||
created_at: datetime
|
||||
@@ -118,3 +120,19 @@ class SetUserStatusRequest(BaseModel):
|
||||
|
||||
class SetDebugTraceRequest(BaseModel):
|
||||
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
|
||||
|
||||
+24
-25
@@ -60,6 +60,8 @@ class WithdrawListItemOut(WithdrawOrderOut):
|
||||
|
||||
phone: 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'
|
||||
|
||||
|
||||
@@ -77,6 +79,8 @@ class WithdrawUserSnapshot(BaseModel):
|
||||
id: int
|
||||
phone: str
|
||||
nickname: str | None = None
|
||||
is_high_risk: bool = False
|
||||
high_risk_note: str | None = None
|
||||
status: str
|
||||
wechat_nickname: str | None = None
|
||||
wechat_avatar_url: str | None = None
|
||||
@@ -87,19 +91,34 @@ class WithdrawUserSnapshot(BaseModel):
|
||||
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):
|
||||
order: WithdrawOrderOut
|
||||
user: WithdrawUserSnapshot | None = None
|
||||
cumulative_success_cents: int = 0
|
||||
risk_flags: list[str]
|
||||
risk_score: int
|
||||
recent_withdraws: list[WithdrawOrderOut]
|
||||
recent_cash_transactions: list[CashTxnOut]
|
||||
audit_logs: list[AdminAuditLogOut]
|
||||
|
||||
|
||||
class ReconcileResult(BaseModel):
|
||||
checked: int
|
||||
resolved: int
|
||||
invite_overview: InviteOverviewOut | None = None
|
||||
|
||||
|
||||
class WithdrawBulkRequest(BaseModel):
|
||||
@@ -128,26 +147,6 @@ class WithdrawBulkResult(BaseModel):
|
||||
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):
|
||||
ok: bool
|
||||
wxpay_configured: bool
|
||||
|
||||
+136
-2
@@ -37,6 +37,7 @@ from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_ph
|
||||
from app.integrations.sms import SmsError, send_code, verify_code
|
||||
from app.repositories import onboarding as onboarding_repo
|
||||
from app.repositories import phone_rebind as rebind_repo
|
||||
from app.repositories import risk as risk_repo
|
||||
from app.repositories import user as user_repo
|
||||
from app.schemas.auth import (
|
||||
JverifyLoginRequest,
|
||||
@@ -70,6 +71,18 @@ 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(
|
||||
user, *, onboarding_completed: bool, force_onboarding: bool = False
|
||||
) -> TokenWithUser:
|
||||
@@ -87,7 +100,15 @@ def _login_response(
|
||||
# ===================== 极光一键登录 =====================
|
||||
|
||||
@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(
|
||||
"jverify_login operator=%s token_len=%d",
|
||||
req.operator or "-",
|
||||
@@ -97,10 +118,37 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser:
|
||||
try:
|
||||
phone = verify_and_get_phone(req.login_token)
|
||||
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)
|
||||
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")
|
||||
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":
|
||||
raise HTTPException(status_code=403, detail="account disabled")
|
||||
|
||||
@@ -116,12 +164,34 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser:
|
||||
response_model=SmsSendResponse,
|
||||
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 不耗额度)。
|
||||
# (也不受下面设备发码限流约束:QA 联调要反复发码。)
|
||||
if test_account.is_test_account(req.phone):
|
||||
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)
|
||||
|
||||
# 发码防刷:同一设备(device_id) + 同一 IP,每小时 / 每天两道闸,**均只按成功发码计数**。
|
||||
@@ -139,6 +209,18 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse:
|
||||
try:
|
||||
cooldown = send_code(req.phone)
|
||||
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
|
||||
|
||||
# 发码成功 → 两道闸各 +1(被单号冷却挡下的重发走不到这里,故不占额度)
|
||||
@@ -146,6 +228,20 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse:
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -155,6 +251,7 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse:
|
||||
summary="手机号+验证码登录",
|
||||
)
|
||||
def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWithUser:
|
||||
subject_id = _device_subject(req.device_id, request)
|
||||
# 测试账号:免验证码登录 + 每日上限 + 每次都走新手引导(详见 app/core/test_account.py)。
|
||||
# 放在最前面:命中即不校验验证码;先扣当日额度,超限直接拒,挡住有人猜到号后脚本刷。
|
||||
# 测试账号走自己的每日额度、不受下面 (设备+IP) 每小时限流约束(QA 需在一小时内反复登录联调)。
|
||||
@@ -162,6 +259,19 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit
|
||||
if not test_account.try_consume_quota():
|
||||
raise HTTPException(status_code=429, detail="测试账号今日使用次数已达上限,请明天再试")
|
||||
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":
|
||||
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))
|
||||
@@ -183,9 +293,33 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit
|
||||
)
|
||||
|
||||
if not verify_code(req.phone, req.code):
|
||||
risk_repo.record_behavior_event(
|
||||
db,
|
||||
event_type=risk_repo.EVENT_SMS_LOGIN,
|
||||
subject_type="device",
|
||||
subject_id=subject_id,
|
||||
device_id=req.device_id or None,
|
||||
device_model=req.device_model or None,
|
||||
phone=req.phone,
|
||||
client_ip=_client_ip(request),
|
||||
outcome="failed",
|
||||
reason="invalid sms code",
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="invalid sms code")
|
||||
|
||||
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":
|
||||
raise HTTPException(status_code=403, detail="account disabled")
|
||||
|
||||
|
||||
+41
-8
@@ -28,19 +28,30 @@ import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
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.logging import trace_id_ctx
|
||||
from app.core.pricebot_client import get_pricebot_client
|
||||
from app.core.pricebot_router import pick_pricebot
|
||||
from app.db.session import SessionLocal
|
||||
from app.repositories import comparison as crud_compare
|
||||
from app.repositories import risk as risk_repo
|
||||
|
||||
logger = logging.getLogger("shagua.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,不阻塞事件循环;
|
||||
# 任何写库异常都吞掉、绝不连累比价透传返回 —— 同 coupon.py 现有 best-effort 写法)。
|
||||
@@ -52,10 +63,14 @@ def _harvest_running_blocking(
|
||||
device_id: str | None, device_info: dict | None, trace_url: str | None,
|
||||
) -> None:
|
||||
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,
|
||||
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(
|
||||
"harvest running row (user=%s)", user_id,
|
||||
extra={"phase": "harvest_running", "status": "running", "user_id": user_id},
|
||||
@@ -72,6 +87,10 @@ def _harvest_done_blocking(
|
||||
business_type=business_type, device_id=device_id,
|
||||
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(
|
||||
"harvest done → %s saved=%s newly=%s", rec.status,
|
||||
rec.saved_amount_cents, newly_success,
|
||||
@@ -199,25 +218,33 @@ async def _forward(
|
||||
|
||||
|
||||
@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)
|
||||
return resp
|
||||
|
||||
|
||||
@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)
|
||||
return resp
|
||||
|
||||
|
||||
@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)
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传 + done 落库)")
|
||||
async def price_step(request: Request, user: OptionalUser) -> dict[str, Any]:
|
||||
async def price_step(request: Request, user: OptionalUser, db: DbSession) -> dict[str, Any]:
|
||||
_ensure_compare_allowed(user, db)
|
||||
resp, trace_id, meta = await _forward(request, "/api/price/step", user)
|
||||
# 最终 done 帧(command=done 且 continue=false)→ harvest 更新成终态。
|
||||
# (单平台中途 done 被 pricebot 改写成 wait+continue=true,不会命中这里,同 coupon 语义。)
|
||||
@@ -237,7 +264,10 @@ async def price_step(request: Request, user: OptionalUser) -> dict[str, Any]:
|
||||
|
||||
|
||||
@router.post("/trace/epilogue", summary="比价结果页尾声帧 (透传到 pricebot, 用户视角截图入 trace)")
|
||||
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
|
||||
# 目录并触发重传 —— trace 里补上"用户实际看到的汇总页"(步骤帧只有目标 App 画面)。
|
||||
# body ~几百 KB(截图 base64), 纯透传壳(harvest_first_frame=False:不建行/不落库,
|
||||
@@ -250,7 +280,10 @@ async def trace_epilogue(request: Request, user: OptionalUser) -> dict[str, Any]
|
||||
|
||||
|
||||
@router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传 + 夭折落库)")
|
||||
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};
|
||||
# app-server 顺手把该 trace 的 running 行更新成夭折终态(**不降级 success**)。
|
||||
# 客户端 finalize body 带 status(cancelled/failed)+ reason;老客户端只带 trace_id →
|
||||
|
||||
@@ -19,7 +19,10 @@ from app.api.deps import CurrentUser, DbSession
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.repositories import comparison as crud_compare
|
||||
from app.repositories import risk as risk_repo
|
||||
from app.schemas.compare_record import (
|
||||
CompareStartReserveIn,
|
||||
CompareStartReserveOut,
|
||||
CompareStatsOut,
|
||||
ComparisonRecordCreatedOut,
|
||||
ComparisonRecordDetailOut,
|
||||
@@ -35,6 +38,50 @@ logger = logging.getLogger("shagua.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(
|
||||
"/record",
|
||||
response_model=ComparisonRecordCreatedOut,
|
||||
@@ -46,6 +93,13 @@ def report_record(
|
||||
db: DbSession,
|
||||
background_tasks: BackgroundTasks,
|
||||
) -> 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)
|
||||
# LLM 调用明细异步回填:同机拉 pricebot llm_calls 最长 5s 且是 best-effort,放后台
|
||||
# 任务做,不阻塞上报响应(顺带给 pricebot 落盘留足余量)。upsert 已 commit,后台用
|
||||
@@ -115,13 +169,22 @@ def list_records(
|
||||
db: DbSession,
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
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(
|
||||
False,
|
||||
description="客户端开了本机 agent 调试模式时带 true,放行本人记录的 trace_url",
|
||||
),
|
||||
) -> ComparisonRecordPage:
|
||||
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]
|
||||
# 权限闸:未开 debug_trace_enabled 的用户不下发 trace_url(列表页「复制调试链接」靠它)。
|
||||
|
||||
@@ -81,7 +81,7 @@ def _record_claims_blocking(
|
||||
device_id: str, user_id: int | None, trace_id: str | None, results: list[dict]
|
||||
) -> None:
|
||||
with SessionLocal() as db:
|
||||
# 取本次 session 环境,给 coupon_claim_record 打 app_env 标(每券成功率表按它过滤;设计 §13)。
|
||||
# 取本次 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 领券数据 ②整单/③点位成功率;
|
||||
@@ -176,7 +176,7 @@ async def coupon_step(
|
||||
|
||||
resp_json = resp.json()
|
||||
|
||||
# 领券结果沉淀(资产):中间帧 last_coupon_result + done 帧 coupon_results 幂等写库。
|
||||
# 领券结果沉淀:每日资产 + 逐次事件;中间帧和 done 全量帧均幂等写库。
|
||||
# 当前只记录、不参与"要不要领"判断(MVP 先不去重)。写库失败不影响返回。
|
||||
if device_id:
|
||||
results = _extract_coupon_results(resp_json)
|
||||
|
||||
+93
-4
@@ -1,19 +1,22 @@
|
||||
"""设备注册 / 心跳 endpoint(无障碍保护存活检测)。
|
||||
|
||||
路由前缀 /api/v1/device,需 Bearer 鉴权(设备绑登录用户)。
|
||||
POST /register 注册设备 / 更新 registration_id(App 前台、拿到 push token 时调)
|
||||
POST /register 注册设备 / 更新厂商 push token(App 前台、拿到 push token 时调)
|
||||
POST /heartbeat 上报心跳(无障碍服务存活时周期调,刷新存活)
|
||||
POST /push-test 开发验收:延迟发送厂商通道测试推送
|
||||
|
||||
后端 heartbeat_monitor_worker 据此发现心跳超时的设备并极光推送告警。
|
||||
后端 heartbeat_monitor_worker 据此发现心跳超时的设备并厂商直推告警。
|
||||
见 spec: spec/accessibility-liveness-push.md。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, status
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.integrations import vendor_push
|
||||
from app.repositories import device as device_repo
|
||||
from app.schemas.device import (
|
||||
DeviceOut,
|
||||
@@ -22,6 +25,8 @@ from app.schemas.device import (
|
||||
LivenessAckRequest,
|
||||
LivenessOut,
|
||||
OkResponse,
|
||||
PushTestOut,
|
||||
PushTestRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.device")
|
||||
@@ -29,6 +34,37 @@ logger = logging.getLogger("shagua.device")
|
||||
router = APIRouter(prefix="/api/v1/device", tags=["device"])
|
||||
|
||||
|
||||
def _send_push_test_after_delay(
|
||||
push_vendor: str,
|
||||
push_token: str,
|
||||
delay_seconds: int,
|
||||
user_id: int,
|
||||
device_id: str,
|
||||
) -> None:
|
||||
if delay_seconds > 0:
|
||||
time.sleep(delay_seconds)
|
||||
try:
|
||||
vendor_push.send_accessibility_disabled(
|
||||
push_vendor,
|
||||
push_token,
|
||||
title="测试推送",
|
||||
alert="这是一条厂商通道测试推送。收到它说明 App 被划掉后仍可通过系统通知栏触达。",
|
||||
)
|
||||
logger.info(
|
||||
"push test sent user_id=%d device_id=%s delay=%ds",
|
||||
user_id,
|
||||
device_id,
|
||||
delay_seconds,
|
||||
)
|
||||
except vendor_push.VendorPushError as e:
|
||||
logger.warning(
|
||||
"push test failed user_id=%d device_id=%s error=%s",
|
||||
user_id,
|
||||
device_id,
|
||||
e,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/register", response_model=DeviceOut, summary="注册设备/更新推送token")
|
||||
def register_device(
|
||||
req: DeviceRegisterRequest,
|
||||
@@ -40,13 +76,17 @@ def register_device(
|
||||
user_id=user.id,
|
||||
device_id=req.device_id,
|
||||
registration_id=req.registration_id,
|
||||
push_vendor=req.push_vendor,
|
||||
push_token=req.push_token,
|
||||
platform=req.platform,
|
||||
app_version=req.app_version,
|
||||
)
|
||||
logger.info(
|
||||
"device register user_id=%d device_id=%s reg=%s",
|
||||
"device register user_id=%d device_id=%s vendor=%s token=%s legacy_reg=%s",
|
||||
user.id,
|
||||
req.device_id,
|
||||
req.push_vendor,
|
||||
bool(req.push_token),
|
||||
bool(req.registration_id),
|
||||
)
|
||||
return DeviceOut.model_validate(device)
|
||||
@@ -64,10 +104,59 @@ def report_heartbeat(
|
||||
device_id=req.device_id,
|
||||
accessibility_enabled=req.accessibility_enabled,
|
||||
registration_id=req.registration_id,
|
||||
push_vendor=req.push_vendor,
|
||||
push_token=req.push_token,
|
||||
)
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.post("/push-test", response_model=PushTestOut, summary="延迟发送厂商通道测试推送")
|
||||
def request_push_test(
|
||||
req: PushTestRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
) -> PushTestOut:
|
||||
"""开发验收用:App 内点一次,服务端延迟发厂商直推,验证离线通道。"""
|
||||
push_vendor = req.push_vendor.strip() if req.push_vendor else None
|
||||
push_token = req.push_token.strip() if req.push_token else None
|
||||
if push_vendor and push_token:
|
||||
device_repo.register_or_update(
|
||||
db,
|
||||
user_id=user.id,
|
||||
device_id=req.device_id,
|
||||
registration_id=req.registration_id,
|
||||
push_vendor=push_vendor,
|
||||
push_token=push_token,
|
||||
)
|
||||
else:
|
||||
device = device_repo.get_device(db, user_id=user.id, device_id=req.device_id)
|
||||
push_vendor = device.push_vendor if device is not None else None
|
||||
push_token = device.push_token if device is not None else None
|
||||
|
||||
if not push_vendor or not push_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="push vendor token not ready",
|
||||
)
|
||||
|
||||
background_tasks.add_task(
|
||||
_send_push_test_after_delay,
|
||||
push_vendor,
|
||||
push_token,
|
||||
req.delay_seconds,
|
||||
user.id,
|
||||
req.device_id,
|
||||
)
|
||||
logger.info(
|
||||
"push test scheduled user_id=%d device_id=%s delay=%ds",
|
||||
user.id,
|
||||
req.device_id,
|
||||
req.delay_seconds,
|
||||
)
|
||||
return PushTestOut(delay_seconds=req.delay_seconds, has_push_token=True)
|
||||
|
||||
|
||||
@router.get("/liveness", response_model=LivenessOut, summary="查询本机掉线告警(后置检测)")
|
||||
def get_liveness(
|
||||
device_id: str,
|
||||
|
||||
@@ -0,0 +1,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
|
||||
|
||||
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 sqlalchemy import nullslast, select
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import get_db
|
||||
@@ -25,8 +26,14 @@ from app.schemas.meituan import (
|
||||
ReferralLinkResponse,
|
||||
TopSalesRequest,
|
||||
)
|
||||
from app.utils import mt_search_cursor
|
||||
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")
|
||||
|
||||
|
||||
@@ -109,6 +116,86 @@ def _commission_pct(card: CouponCard) -> float:
|
||||
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距离最近")
|
||||
def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse:
|
||||
lon, lat = req.longitude, req.latitude
|
||||
@@ -133,17 +220,21 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse:
|
||||
return [], True
|
||||
|
||||
# 距离最近:搜索召回(外卖搜"外卖" + 到店搜"美食",都 sortField=6 离我最近)一页页拉。
|
||||
# 搜索翻页必须用 searchId(pageNo 翻不动),所以每个 feed 页顺序翻到第 N 页;两路并行、page 1 最快。
|
||||
# 无状态、不改 APP(传页码即可);按你位置实时算距离(库里没存 POI 经纬度,只能实时)。
|
||||
# 搜索翻页必须用 searchId(pageNo 翻不动),而接口是无状态的(客户端只传页码)—— 原实现因此
|
||||
# 每次都从第 1 页顺序重放到第 N 页,取第 N 页要向美团发 N 次请求,越往下滑越慢。
|
||||
# 现在把沿途 searchId 记进 [mt_search_cursor],稳态下每翻一页恒定 1 次请求;两路仍并行。
|
||||
# 按你位置实时算距离(库里没存 POI 经纬度,只能实时)。
|
||||
if tab == "distance":
|
||||
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]:
|
||||
"""顺序翻到第 n 页(搜索须 searchId 续页),返回(第 n 页 items, 是否还有下一页, 是否调用失败)。"""
|
||||
sid: str | None = None
|
||||
def _replay(
|
||||
platform: int, biz_line: int | None, keyword: str,
|
||||
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] = []
|
||||
has_next = False
|
||||
for pg in range(1, n + 1):
|
||||
for pg in range(start, n + 1):
|
||||
body: dict = {
|
||||
"platform": platform, "searchText": keyword, "sortField": 6,
|
||||
"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 []
|
||||
sid = r.get("searchId")
|
||||
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):
|
||||
return [], False, 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:
|
||||
f_wm = pool.submit(_search_page_n, 1, None, "外卖", 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")
|
||||
PAGE = 20
|
||||
try:
|
||||
base = select(MeituanCoupon).where(
|
||||
MeituanCoupon.commission_percent >= 3.0,
|
||||
MeituanCoupon.city_id == city_id,
|
||||
)
|
||||
deduped = base.distinct(MeituanCoupon.dedup_key).order_by(
|
||||
MeituanCoupon.dedup_key,
|
||||
MeituanCoupon.commission_percent.desc(),
|
||||
).subquery()
|
||||
m = aliased(MeituanCoupon, deduped)
|
||||
start = (req.page - 1) * PAGE
|
||||
rows = db.execute(
|
||||
select(m)
|
||||
ids, has_next = _paged_dedup_ids(
|
||||
db,
|
||||
conds=[
|
||||
MeituanCoupon.commission_percent >= 3.0,
|
||||
MeituanCoupon.city_id == city_id,
|
||||
],
|
||||
# 同一去重键留佣金最高那条
|
||||
dedup_order=[MeituanCoupon.commission_percent.desc()],
|
||||
# 销量高的优先(无销量档排后),同档佣金高优先,id 兜底稳定分页
|
||||
.order_by(nullslast(m.sale_volume_num.desc()), m.commission_percent.desc(), m.id)
|
||||
.offset(start)
|
||||
.limit(PAGE + 1)
|
||||
).scalars().all()
|
||||
page_order=lambda c: [
|
||||
nullslast(c.sale_volume_num.desc()), c.commission_percent.desc(), c.id,
|
||||
],
|
||||
page=req.page, page_size=PAGE,
|
||||
)
|
||||
raws = _load_raws(db, ids)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("[feed] rec 库查询失败,降级返空")
|
||||
return FeedResponse(items=[], has_next=False, page=req.page, status="degraded")
|
||||
has_next = len(rows) > PAGE
|
||||
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)
|
||||
cards = _cards_from_raws(raws, hide_distance=True)
|
||||
if not cards and req.page == 1:
|
||||
# 命中城市却 0 券:该城确无 ≥3% 券,或 ETL 灌的 city_id 与 city_dict 口径不一致。
|
||||
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:
|
||||
return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded")
|
||||
|
||||
# 去重 + 排序 + 分页全在 SQL 做,每页只取并解析当前页 ~20 条。
|
||||
# (之前实现每翻一页都全表拉取 + 全量 from_raw 解析,翻页慢 → 客户端滑动卡顿/翻不动。)
|
||||
# 去重 + 排序 + 分页全在 SQL 做,每页只回表并解析当前页 ~20 条(见 _paged_dedup_ids 的性能说明)。
|
||||
# 库为空(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:
|
||||
# 1) DISTINCT ON (dedup_key):每个去重键(品牌|名|价)只留销量最高那条(同销量再按佣金)
|
||||
base = select(MeituanCoupon).where(
|
||||
MeituanCoupon.sale_volume_num.isnot(None),
|
||||
MeituanCoupon.city_id == city_id,
|
||||
)
|
||||
if req.platform is not None:
|
||||
base = base.where(MeituanCoupon.platform == req.platform)
|
||||
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)
|
||||
ids, has_next = _paged_dedup_ids(
|
||||
db,
|
||||
conds=conds,
|
||||
# 每个去重键(品牌|名|价)只留销量最高那条(同销量再按佣金)
|
||||
dedup_order=[
|
||||
MeituanCoupon.sale_volume_num.desc(),
|
||||
MeituanCoupon.commission_percent.desc(),
|
||||
],
|
||||
# 加 id 作稳定 tiebreaker:同销量同佣金的并列项排序确定,避免跨页重复/漏项
|
||||
.order_by(m.sale_volume_num.desc(), m.commission_percent.desc(), m.id)
|
||||
.offset(start)
|
||||
.limit(req.page_size + 1)
|
||||
).scalars().all()
|
||||
page_order=lambda c: [
|
||||
c.sale_volume_num.desc(), c.commission_percent.desc(), c.id,
|
||||
],
|
||||
page=req.page, page_size=req.page_size,
|
||||
)
|
||||
raws = _load_raws(db, ids)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("[top-sales] 库查询失败,降级返空")
|
||||
return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded")
|
||||
|
||||
has_next = len(rows) > req.page_size
|
||||
cards: list[CouponCard] = []
|
||||
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)
|
||||
# 不显示距离:库里的距离是相对城市默认点的(对用户无意义、且误导),与推荐流口径一致。
|
||||
cards = _cards_from_raws(raws, hide_distance=True)
|
||||
if not cards and req.page == 1:
|
||||
# 命中城市却 0 券:可能该城确无券,也可能 ETL 灌的 city_id 与 city_dict 口径不一致(静默降级的隐患)。
|
||||
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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -11,6 +11,7 @@ import logging
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
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.schemas.welfare import TaskClaimResultOut, TaskListOut, TaskOut
|
||||
|
||||
@@ -36,6 +37,13 @@ def list_tasks(user: CurrentUser, db: DbSession) -> TaskListOut:
|
||||
summary="领取任务奖励",
|
||||
)
|
||||
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:
|
||||
coin, balance = crud_task.claim_task(db, user.id, task_key)
|
||||
except crud_task.UnknownTaskError as e:
|
||||
|
||||
+12
-8
@@ -20,8 +20,9 @@ from app.core.rewards import (
|
||||
WITHDRAW_MAX_CENTS,
|
||||
WITHDRAW_MIN_CENTS,
|
||||
)
|
||||
from app.repositories import wallet as crud_wallet
|
||||
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 (
|
||||
BindWechatRequest,
|
||||
BindWechatResultOut,
|
||||
@@ -192,7 +193,7 @@ def withdraw_info(
|
||||
wechat_bound=bool(u and u.wechat_openid),
|
||||
wechat_nickname=u.wechat_nickname if u else None,
|
||||
wechat_avatar_url=u.wechat_avatar_url if u else None,
|
||||
transfer_auth_enabled=bool(auth and auth.state == "active"),
|
||||
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)],
|
||||
)
|
||||
|
||||
@@ -204,6 +205,13 @@ def withdraw_info(
|
||||
dependencies=[Depends(rate_limit(5, 60, "withdraw"))], # IP 级粗限流;用户级未完成单限制在仓库层
|
||||
)
|
||||
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:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="wechat pay not configured")
|
||||
@@ -222,11 +230,6 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw
|
||||
) from e
|
||||
except crud_wallet.WechatNotBoundError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请先绑定微信") from e
|
||||
except crud_wallet.WithdrawTooFrequentError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="已有提现申请正在审核或打款中,请处理完成后再申请",
|
||||
) from e
|
||||
except crud_wallet.WithdrawTierUnavailableError as e:
|
||||
# 福利页档位闸(7-9):次数满/已选其他额度。正常客户端已按 tiers 预拦,此处兜底防绕过。
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="今日额度已达上限") from e
|
||||
@@ -335,7 +338,8 @@ def open_transfer_auth(user: CurrentUser, db: DbSession) -> TransferAuthResultOu
|
||||
def transfer_auth_status(user: CurrentUser, db: DbSession) -> TransferAuthStatusOut:
|
||||
auth = crud_wallet.sync_transfer_auth(db, user.id)
|
||||
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(
|
||||
|
||||
+63
-1
@@ -71,7 +71,59 @@ class Settings(BaseSettings):
|
||||
JG_VERIFY_ENDPOINT: str = "https://api.verification.jpush.cn/v1/web/loginTokenVerify"
|
||||
JG_REQUEST_TIMEOUT_SEC: int = 15
|
||||
|
||||
# 无障碍保护存活监控后台任务(pull 后置检测;本期不接推送)
|
||||
# ===== 厂商直推(无障碍保护存活告警)=====
|
||||
ANDROID_PACKAGE_NAME: str = "com.jishisongfu.shaguabijia"
|
||||
PUSH_REQUEST_TIMEOUT_SEC: int = 15
|
||||
PUSH_TIME_TO_LIVE_SEC: int = 86400
|
||||
|
||||
HONOR_PUSH_APP_ID: str = ""
|
||||
HONOR_PUSH_CLIENT_ID: str = ""
|
||||
HONOR_PUSH_CLIENT_SECRET: str = ""
|
||||
HONOR_PUSH_TOKEN_ENDPOINT: str = "https://iam.developer.honor.com/auth/token"
|
||||
HONOR_PUSH_SEND_ENDPOINT_TEMPLATE: str = (
|
||||
"https://push-api.cloud.honor.com/api/v1/{app_id}/sendMessage"
|
||||
)
|
||||
|
||||
# 华为 Push Kit:AGC 控制台 → 项目设置 → 常规 → 应用,取 AppId + AppSecret
|
||||
# (OAuth 换 token 时 client_id 即 AppId)。发送走 v1 messages:send,成功码 80000000。
|
||||
HUAWEI_PUSH_APP_ID: str = ""
|
||||
HUAWEI_PUSH_APP_SECRET: str = ""
|
||||
HUAWEI_PUSH_TOKEN_ENDPOINT: str = "https://oauth-login.cloud.huawei.com/oauth2/v3/token"
|
||||
HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE: str = (
|
||||
"https://push-api.cloud.huawei.com/v1/{app_id}/messages:send"
|
||||
)
|
||||
|
||||
VIVO_PUSH_APP_ID: str = ""
|
||||
VIVO_PUSH_APP_KEY: str = ""
|
||||
VIVO_PUSH_APP_SECRET: str = ""
|
||||
VIVO_PUSH_AUTH_ENDPOINT: str = "https://api-push.vivo.com.cn/message/auth"
|
||||
VIVO_PUSH_SEND_ENDPOINT: str = "https://api-push.vivo.com.cn/message/send"
|
||||
VIVO_PUSH_MODE: int = 1 # 0=正式推送,1=测试推送(未上架 vivo 时用)
|
||||
VIVO_PUSH_NOTIFY_TYPE: int = 4 # 1=无,2=响铃,3=振动,4=响铃+振动
|
||||
VIVO_PUSH_CATEGORY: str = "DEVICE_REMINDER"
|
||||
|
||||
XIAOMI_PUSH_APP_SECRET: str = ""
|
||||
XIAOMI_PUSH_SEND_ENDPOINT: str = "https://api.xmpush.xiaomi.com/v3/message/regid"
|
||||
XIAOMI_PUSH_CHANNEL_ID: str = ""
|
||||
XIAOMI_PUSH_TEMPLATE_ID: str = ""
|
||||
XIAOMI_PUSH_TEMPLATE_TITLE: str = ""
|
||||
XIAOMI_PUSH_TEMPLATE_DESCRIPTION: str = ""
|
||||
XIAOMI_PUSH_TEMPLATE_PARAM_JSON: str = ""
|
||||
|
||||
OPPO_PUSH_APP_KEY: str = ""
|
||||
OPPO_PUSH_MASTER_SECRET: str = ""
|
||||
OPPO_PUSH_AUTH_ENDPOINT: str = "https://api.push.oppomobile.com/server/v1/auth"
|
||||
OPPO_PUSH_SEND_ENDPOINT: str = (
|
||||
"https://api.push.oppomobile.com/server/v1/message/notification/unicast"
|
||||
)
|
||||
# OPPO 新消息分类(2024-11-20 后创建的应用必须携带,否则可能被拒/限):
|
||||
# channel_id=通知栏通道(OPPO 后台「通道ID」),category=消息分类 code(如 MARKETING 内容营销)。
|
||||
# notify_level=提醒方式(0=不传走 OPPO 默认;内容营销类仅支持 1 通知栏/2 通知栏+锁屏)。
|
||||
OPPO_PUSH_CHANNEL_ID: str = ""
|
||||
OPPO_PUSH_CATEGORY: str = ""
|
||||
OPPO_PUSH_NOTIFY_LEVEL: int = 0
|
||||
|
||||
# 无障碍保护存活监控后台任务(推送 + pull 后置兜底)
|
||||
HEARTBEAT_MONITOR_ENABLED: bool = True # 总开关
|
||||
HEARTBEAT_TIMEOUT_MINUTES: int = 60 # 多久没心跳算掉线(1 小时,避免短暂离线误判被杀)
|
||||
HEARTBEAT_SCAN_INTERVAL_SEC: int = 60 # 扫描周期
|
||||
@@ -136,6 +188,13 @@ class Settings(BaseSettings):
|
||||
"""京东联盟订单查询凭证齐全。"""
|
||||
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),做用户级群统计。
|
||||
# ⚠️ 区别于 WECHAT_APP_ID(那是 App 移动应用,用于微信支付);这是【已认证服务号】。
|
||||
@@ -325,6 +384,9 @@ class Settings(BaseSettings):
|
||||
MEDIA_ROOT: str = "./data/media"
|
||||
MEDIA_URL_PREFIX: str = "/media"
|
||||
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)。
|
||||
|
||||
@@ -11,6 +11,10 @@ from typing import Any
|
||||
|
||||
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
|
||||
# hidden=True:仍是合法可配项(业务照常 get_value / admin 可经专用端点读写),但**不在通用
|
||||
# 「系统配置」页渲染**(admin/routers/config.py:list_config 按此过滤)。用于把已下线/已改由
|
||||
@@ -106,4 +110,36 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
|
||||
"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 被彻底杀掉/无障碍已停(心跳断了),
|
||||
**命中即在服务器终端打印告警**(本期先不接推送,工程量大,用终端打印代替真实通知);并把状态机
|
||||
**命中即在服务器终端打印告警并尝试厂商直推**;并把状态机
|
||||
推进到 notified 防每轮重复打印(心跳恢复时由 repositories.device.touch_heartbeat 重置回 alive)。
|
||||
结构仿 withdraw_reconcile_worker(单实例锁 + asyncio 轮询 + 优雅退出)。
|
||||
|
||||
@@ -22,6 +22,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import SessionLocal
|
||||
from app.integrations import vendor_push
|
||||
from app.repositories import device as device_repo
|
||||
|
||||
logger = logging.getLogger("shagua.heartbeat_monitor")
|
||||
@@ -71,32 +72,66 @@ def _silent_seconds(last: datetime | None) -> int | None:
|
||||
"""距上次心跳的秒数(兼容 sqlite 取回的 naive datetime)。"""
|
||||
if last is None:
|
||||
return None
|
||||
ref = datetime.now(timezone.utc) if last.tzinfo is not None else datetime.utcnow()
|
||||
ref = datetime.now(timezone.utc) if last.tzinfo is not None else datetime.utcnow() # noqa: UP017
|
||||
return int((ref - last).total_seconds())
|
||||
|
||||
|
||||
def _scan_once(timeout_minutes: int) -> dict:
|
||||
"""扫描一轮:找出心跳超时(App 被彻底杀掉/无障碍已停)的设备,在**服务器终端打印**告警代替真实推送。
|
||||
"""扫描一轮:找出心跳超时(App 被彻底杀掉/无障碍已停)的设备并召回。
|
||||
|
||||
本期不接推送(极光/厂商通道工程量大),只做服务端掉线检测:命中即 logger.warning 打印到终端,
|
||||
并把状态机推进到 notified 防每轮重复打印(心跳恢复时 touch_heartbeat 会重置回 alive)。
|
||||
有 push_vendor + push_token 时先发厂商直推,无 token 或推送失败时仍置
|
||||
kill_alert_pending,客户端下次进 App 继续走后置提醒兜底。
|
||||
"""
|
||||
notified = 0
|
||||
pushed = 0
|
||||
push_failed = 0
|
||||
with SessionLocal() as db:
|
||||
overdue = device_repo.list_overdue(db, timeout_minutes=timeout_minutes)
|
||||
for device in overdue:
|
||||
silent = _silent_seconds(device.last_heartbeat_at)
|
||||
logger.warning(
|
||||
"[掉线检测] user_id=%s device_id=%s 已 %s 秒无心跳(阈值 %d 分钟)"
|
||||
" → 判定 App 已被杀/无障碍已停。【已置 kill_alert_pending: 用户下次进 App 将弹「开启自启动」引导(后置检测);推送本期未接】",
|
||||
" → 判定 App 已被杀/无障碍已停。",
|
||||
device.user_id,
|
||||
device.device_id,
|
||||
silent if silent is not None else "?",
|
||||
timeout_minutes,
|
||||
)
|
||||
if device.push_vendor and device.push_token:
|
||||
try:
|
||||
vendor_push.send_accessibility_disabled(
|
||||
device.push_vendor,
|
||||
device.push_token,
|
||||
)
|
||||
pushed += 1
|
||||
logger.info(
|
||||
"[掉线检测] push sent user_id=%s device_id=%s vendor=%s",
|
||||
device.user_id,
|
||||
device.device_id,
|
||||
device.push_vendor,
|
||||
)
|
||||
except vendor_push.VendorPushError as e:
|
||||
push_failed += 1
|
||||
logger.warning(
|
||||
"[掉线检测] push failed user_id=%s device_id=%s error=%s",
|
||||
device.user_id,
|
||||
device.device_id,
|
||||
e,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"[掉线检测] device has no push vendor/token, skip push user_id=%s device_id=%s",
|
||||
device.user_id,
|
||||
device.device_id,
|
||||
)
|
||||
device_repo.mark_notified(db, device_id_pk=device.id)
|
||||
notified += 1
|
||||
return {"checked": len(overdue), "notified": notified}
|
||||
return {
|
||||
"checked": len(overdue),
|
||||
"notified": notified,
|
||||
"pushed": pushed,
|
||||
"push_failed": push_failed,
|
||||
}
|
||||
|
||||
|
||||
async def _run_loop() -> None:
|
||||
|
||||
+51
-2
@@ -22,13 +22,13 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from contextvars import ContextVar
|
||||
from datetime import datetime
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# 请求级 trace_id:入口(如 compare.py 透传壳)set 之后, 本请求上下文(含 run_in_threadpool
|
||||
# 拷贝出去的线程)内所有日志自动带上。默认空串 = 非请求上下文(启动/后台 worker)。
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -126,7 +175,7 @@ def setup_logging(debug: bool = False) -> None:
|
||||
Path(os.getenv("LOG_DIR", "logs")) / "app-server.log"
|
||||
)
|
||||
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",
|
||||
)
|
||||
file_handler.setFormatter(JsonFormatter(service))
|
||||
|
||||
@@ -77,6 +77,35 @@ def save_feedback_qr(data: bytes) -> str:
|
||||
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:
|
||||
"""保存 CPS 活动落地页图,返回相对 URL(`/media/cps/<file>`)。admin_id 入文件名便于追溯。"""
|
||||
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:
|
||||
"""删除本服务托管的旧反馈页二维码文件;外部 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
|
||||
]
|
||||
@@ -11,6 +11,7 @@ import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
@@ -25,6 +26,44 @@ class MeituanCpsError(Exception):
|
||||
"""美团 CPS 接口调用失败。"""
|
||||
|
||||
|
||||
# ────────────────────── 共享 httpx.Client(连接池 + keep-alive) ──────────────────────
|
||||
# 原实现每次 _call 都 `with httpx.Client(...)` 新建再关掉,等于每发一次请求就:
|
||||
# ① 重建一套 SSL 上下文(加载 certifi CA,实测几百 ms) ② 重做一次 TLS 握手 ③ 用完即弃连接。
|
||||
# 首页 feed 一次翻页要向美团发好几次请求(外卖/到店两路,「距离最近」还要续页),这份固定开销被成倍放大,
|
||||
# 是「滑到底部加载很慢」的一大块。改成进程内单例:握手一次、后续走 keep-alive 复用。
|
||||
# httpx.Client 本身线程安全,可被 feed 的 ThreadPoolExecutor 两条抓取线程共用。
|
||||
_client: httpx.Client | None = None
|
||||
_client_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_client() -> httpx.Client:
|
||||
"""取美团 CPS 共享 client(幂等,懒建)。lifespan 启动时预热,把建 SSL 的成本摊到启动。"""
|
||||
global _client
|
||||
if _client is None:
|
||||
with _client_lock:
|
||||
if _client is None:
|
||||
# 走 MT_CPS_PROXY(本机开发直连会 SSL EOF,必须走代理;线上留空=直连)。
|
||||
# trust_env=False:不读进程环境的 HTTP_PROXY,只认配置,避免被错误/失效代理误导。
|
||||
_client = httpx.Client(
|
||||
proxy=settings.MT_CPS_PROXY or None,
|
||||
trust_env=False,
|
||||
timeout=settings.MT_CPS_TIMEOUT_SEC,
|
||||
# 池子留足:feed 每个请求会起 2 条抓取线程,并发用户多时别在池上排队
|
||||
# (排满会等到 MT_CPS_TIMEOUT_SEC 抛 PoolTimeout,表现成"又慢又降级")。
|
||||
limits=httpx.Limits(max_keepalive_connections=16, max_connections=32),
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
def close_client() -> None:
|
||||
"""lifespan 关停时调,优雅关连接池。"""
|
||||
global _client
|
||||
with _client_lock:
|
||||
if _client is not None:
|
||||
_client.close()
|
||||
_client = None
|
||||
|
||||
|
||||
def _content_md5(body: bytes) -> str:
|
||||
return base64.b64encode(hashlib.md5(body).digest()).decode()
|
||||
|
||||
@@ -62,12 +101,8 @@ def _call(path: str, body_obj: dict[str, Any]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
url = f"{settings.MT_CPS_HOST}{path}"
|
||||
# 美团调用走 MT_CPS_PROXY(本机开发直连会 SSL EOF,必须走代理;线上留空=直连)。
|
||||
# trust_env=False:不读进程环境的 HTTP_PROXY,只认配置,避免被错误/失效代理误导。
|
||||
proxy = settings.MT_CPS_PROXY or None
|
||||
try:
|
||||
with httpx.Client(proxy=proxy, trust_env=False, timeout=settings.MT_CPS_TIMEOUT_SEC) as client:
|
||||
resp = client.post(url, content=body, headers=headers)
|
||||
resp = get_client().post(url, content=body, headers=headers)
|
||||
except httpx.HTTPError as e:
|
||||
logger.exception("[MT] http error calling %s", url)
|
||||
raise MeituanCpsError(f"meituan http error: {e}") from e
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
"""厂商直推集成(荣耀 / 华为 / 小米 / OPPO / vivo)。
|
||||
|
||||
服务端不经由 JPush Push API,而是按客户端上报的 push_vendor + push_token
|
||||
分发到各手机厂商的服务端 API。
|
||||
|
||||
对外两个入口:
|
||||
- send_notification() 通用:任意标题/正文/extras(消息中心 13 类推送走这里),
|
||||
mock=True 时不真调厂商、返回渲染结果(虚拟数据联调用)
|
||||
- send_accessibility_disabled() 旧:无障碍掉线召回(heartbeat_monitor_worker 在用),
|
||||
已改为 send_notification 的薄封装,行为不变
|
||||
|
||||
各厂商鉴权方式:荣耀/华为 OAuth client_credentials 换 access_token(进程内缓存);
|
||||
vivo/OPPO 签名换 authToken(缓存 24h);小米直接 AppSecret 走 Authorization 头。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger("shagua.vendor_push")
|
||||
|
||||
TYPE_ACCESSIBILITY_DISABLED = "accessibility_disabled"
|
||||
SUPPORTED_VENDORS = frozenset({"honor", "huawei", "vivo", "xiaomi", "oppo"})
|
||||
|
||||
# vendor key → 中文名(测试/配置状态接口展示用)
|
||||
VENDOR_LABELS: dict[str, str] = {
|
||||
"honor": "荣耀",
|
||||
"huawei": "华为",
|
||||
"xiaomi": "小米",
|
||||
"oppo": "OPPO",
|
||||
"vivo": "vivo",
|
||||
}
|
||||
|
||||
# 各厂商真发推送所需的 settings 键(缺任一即视为未配置;/api/v1/push/vendors 据此报缺)
|
||||
REQUIRED_SETTINGS: dict[str, tuple[str, ...]] = {
|
||||
"honor": ("HONOR_PUSH_APP_ID", "HONOR_PUSH_CLIENT_ID", "HONOR_PUSH_CLIENT_SECRET"),
|
||||
"huawei": ("HUAWEI_PUSH_APP_ID", "HUAWEI_PUSH_APP_SECRET"),
|
||||
"xiaomi": ("XIAOMI_PUSH_APP_SECRET",),
|
||||
"oppo": ("OPPO_PUSH_APP_KEY", "OPPO_PUSH_MASTER_SECRET"),
|
||||
"vivo": ("VIVO_PUSH_APP_ID", "VIVO_PUSH_APP_KEY", "VIVO_PUSH_APP_SECRET"),
|
||||
}
|
||||
|
||||
|
||||
def missing_settings(vendor: str) -> list[str]:
|
||||
"""该厂商还缺哪些配置键(全配齐返回空列表)。vendor 需已 normalize。"""
|
||||
return [key for key in REQUIRED_SETTINGS.get(vendor, ()) if not getattr(settings, key, "")]
|
||||
|
||||
|
||||
class VendorPushError(Exception):
|
||||
"""厂商推送调用失败。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CachedToken:
|
||||
value: str
|
||||
expires_at: float
|
||||
|
||||
|
||||
_token_cache: dict[str, _CachedToken] = {}
|
||||
|
||||
|
||||
def normalize_vendor(push_vendor: str | None) -> str | None:
|
||||
if not push_vendor:
|
||||
return None
|
||||
vendor = push_vendor.strip().lower()
|
||||
aliases = {
|
||||
"hihonor": "honor",
|
||||
"荣耀": "honor",
|
||||
"hms": "huawei",
|
||||
"华为": "huawei",
|
||||
"harmony": "huawei",
|
||||
"harmonyos": "huawei",
|
||||
"mi": "xiaomi",
|
||||
"小米": "xiaomi",
|
||||
"oneplus": "oppo",
|
||||
"realme": "oppo",
|
||||
}
|
||||
return aliases.get(vendor, vendor)
|
||||
|
||||
|
||||
def send_notification(
|
||||
push_vendor: str,
|
||||
push_token: str,
|
||||
*,
|
||||
title: str,
|
||||
body: str,
|
||||
extras: dict[str, str] | None = None,
|
||||
mock: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""按厂商 token 向单台设备发送一条通知(通用入口)。
|
||||
|
||||
- extras:透传给客户端的自定义键值(值统一 string,兼容各厂商限制)。消息中心推送约定
|
||||
至少带 {"type": <13 种类型 key>, "notificationId": <站内消息 id>},客户端据此
|
||||
深链落地 + 调 /notifications/read 同步置读(PRD §4 push 联动)。
|
||||
- mock=True:不真调厂商 API,校验参数后原样返回渲染结果(虚拟数据阶段联调/自动化测试用)。
|
||||
"""
|
||||
vendor = normalize_vendor(push_vendor)
|
||||
token = push_token.strip() if push_token else ""
|
||||
if not vendor or vendor not in SUPPORTED_VENDORS:
|
||||
raise VendorPushError(f"unsupported push vendor: {push_vendor}")
|
||||
if not token:
|
||||
raise VendorPushError("push token is empty")
|
||||
extras = {str(k): str(v) for k, v in (extras or {}).items()}
|
||||
|
||||
if mock:
|
||||
logger.info(
|
||||
"[mock push] vendor=%s token=%s... title=%s body=%s extras=%s",
|
||||
vendor, token[:12], title, body, extras,
|
||||
)
|
||||
return {
|
||||
"mock": True,
|
||||
"vendor": vendor,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"extras": extras,
|
||||
}
|
||||
|
||||
dispatch: dict[str, Callable[[str, str, str, dict[str, str]], dict[str, Any]]] = {
|
||||
"honor": _send_honor,
|
||||
"huawei": _send_huawei,
|
||||
"vivo": _send_vivo,
|
||||
"xiaomi": _send_xiaomi,
|
||||
"oppo": _send_oppo,
|
||||
}
|
||||
return dispatch[vendor](token, title, body, extras)
|
||||
|
||||
|
||||
def send_accessibility_disabled(
|
||||
push_vendor: str,
|
||||
push_token: str,
|
||||
*,
|
||||
title: str = "保护已关闭",
|
||||
alert: str = "傻瓜比价的无障碍保护被关了,点此重新开启,继续帮你自动比价省钱。",
|
||||
) -> dict[str, Any]:
|
||||
"""按厂商 token 向单台设备发送无障碍掉线通知(heartbeat_monitor_worker 在用,行为不变)。"""
|
||||
return send_notification(
|
||||
push_vendor,
|
||||
push_token,
|
||||
title=title,
|
||||
body=alert,
|
||||
extras={"type": TYPE_ACCESSIBILITY_DISABLED},
|
||||
)
|
||||
|
||||
|
||||
def _require(value: str, name: str) -> str:
|
||||
if not value:
|
||||
raise VendorPushError(f"{name} not configured")
|
||||
return value
|
||||
|
||||
|
||||
def _request_json(
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
expected_status: tuple[int, ...] = (200,),
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
resp = httpx.request(
|
||||
method,
|
||||
url,
|
||||
timeout=settings.PUSH_REQUEST_TIMEOUT_SEC,
|
||||
**kwargs,
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
raise VendorPushError(f"push http error: {e}") from e
|
||||
|
||||
if resp.status_code not in expected_status:
|
||||
logger.error("vendor push http failed url=%s http=%s body=%s", url, resp.status_code, resp.text[:500])
|
||||
raise VendorPushError(f"push http {resp.status_code}")
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError as e:
|
||||
raise VendorPushError(f"push invalid json: {resp.text[:200]}") from e
|
||||
|
||||
|
||||
def _request_form(
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
expected_status: tuple[int, ...] = (200,),
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
resp = httpx.request(
|
||||
method,
|
||||
url,
|
||||
timeout=settings.PUSH_REQUEST_TIMEOUT_SEC,
|
||||
**kwargs,
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
raise VendorPushError(f"push http error: {e}") from e
|
||||
|
||||
if resp.status_code not in expected_status:
|
||||
logger.error("vendor push http failed url=%s http=%s body=%s", url, resp.status_code, resp.text[:500])
|
||||
raise VendorPushError(f"push http {resp.status_code}")
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError as e:
|
||||
raise VendorPushError(f"push invalid json: {resp.text[:200]}") from e
|
||||
|
||||
|
||||
def _cache_get(key: str) -> str | None:
|
||||
cached = _token_cache.get(key)
|
||||
if cached and cached.expires_at > time.time() + 60:
|
||||
return cached.value
|
||||
return None
|
||||
|
||||
|
||||
def _cache_put(key: str, value: str, expires_in: int | float | None) -> str:
|
||||
ttl = int(expires_in or 3600)
|
||||
_token_cache[key] = _CachedToken(value=value, expires_at=time.time() + max(60, ttl - 60))
|
||||
return value
|
||||
|
||||
|
||||
def _honor_access_token() -> str:
|
||||
cache_key = "honor"
|
||||
cached = _cache_get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
client_id = _require(settings.HONOR_PUSH_CLIENT_ID, "HONOR_PUSH_CLIENT_ID")
|
||||
client_secret = _require(settings.HONOR_PUSH_CLIENT_SECRET, "HONOR_PUSH_CLIENT_SECRET")
|
||||
data = _request_form(
|
||||
"POST",
|
||||
settings.HONOR_PUSH_TOKEN_ENDPOINT,
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
token = data.get("access_token")
|
||||
if not token:
|
||||
raise VendorPushError(f"honor auth failed: {data}")
|
||||
return _cache_put(cache_key, str(token), data.get("expires_in"))
|
||||
|
||||
|
||||
def _send_honor(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]:
|
||||
app_id = _require(settings.HONOR_PUSH_APP_ID, "HONOR_PUSH_APP_ID")
|
||||
access_token = _honor_access_token()
|
||||
payload = {
|
||||
# clickAction type=3(打开应用首页)时,荣耀点击会把 data JSON 的键值对注入启动 intent 的
|
||||
# extras(与 HMS 同机制)→ MainActivity.consumeNavTarget 读 notif_id/notif_type 直达落地。
|
||||
"data": json.dumps(_click_extras(extras), ensure_ascii=False),
|
||||
"notification": {"title": title, "body": body},
|
||||
"android": {
|
||||
"ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s",
|
||||
"targetUserType": 1,
|
||||
"notification": {
|
||||
"title": title,
|
||||
"body": body,
|
||||
"clickAction": {"type": 3},
|
||||
"importance": "NORMAL",
|
||||
},
|
||||
},
|
||||
"token": [token],
|
||||
}
|
||||
data = _request_json(
|
||||
"POST",
|
||||
settings.HONOR_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id),
|
||||
json=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json; charset=UTF-8",
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"timestamp": str(int(time.time() * 1000)),
|
||||
},
|
||||
)
|
||||
code = data.get("code")
|
||||
if code is not None and int(code) != 200:
|
||||
raise VendorPushError(f"honor push failed: {data}")
|
||||
return data
|
||||
|
||||
|
||||
def _huawei_access_token() -> str:
|
||||
"""华为 OAuth2 client_credentials 换 access_token(client_id 即 AGC 应用的 AppId)。"""
|
||||
cache_key = "huawei"
|
||||
cached = _cache_get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
app_id = _require(settings.HUAWEI_PUSH_APP_ID, "HUAWEI_PUSH_APP_ID")
|
||||
app_secret = _require(settings.HUAWEI_PUSH_APP_SECRET, "HUAWEI_PUSH_APP_SECRET")
|
||||
data = _request_form(
|
||||
"POST",
|
||||
settings.HUAWEI_PUSH_TOKEN_ENDPOINT,
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": app_id,
|
||||
"client_secret": app_secret,
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
token = data.get("access_token")
|
||||
if not token:
|
||||
raise VendorPushError(f"huawei auth failed: {data}")
|
||||
return _cache_put(cache_key, str(token), data.get("expires_in"))
|
||||
|
||||
|
||||
def _send_huawei(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]:
|
||||
"""华为 Push Kit 下行消息(v1 messages:send)。成功码 '80000000';
|
||||
'80100000' 为部分成功(单 token 场景仍视为失败,错误里带原始响应便于排障)。"""
|
||||
app_id = _require(settings.HUAWEI_PUSH_APP_ID, "HUAWEI_PUSH_APP_ID")
|
||||
access_token = _huawei_access_token()
|
||||
payload = {
|
||||
"validate_only": False,
|
||||
"message": {
|
||||
# click_action type=3(打开应用首页)时,HMS 点击会把 data JSON 的键值对注入启动 intent
|
||||
# 的 extras → MainActivity.consumeNavTarget 读 notif_id/notif_type 直达落地。
|
||||
"data": json.dumps(_click_extras(extras), ensure_ascii=False),
|
||||
"android": {
|
||||
"ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s",
|
||||
"notification": {
|
||||
"title": title,
|
||||
"body": body,
|
||||
"click_action": {"type": 3},
|
||||
"importance": "NORMAL",
|
||||
},
|
||||
},
|
||||
"token": [token],
|
||||
},
|
||||
}
|
||||
data = _request_json(
|
||||
"POST",
|
||||
settings.HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id),
|
||||
json=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json; charset=UTF-8",
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
},
|
||||
)
|
||||
if str(data.get("code", "")) != "80000000":
|
||||
raise VendorPushError(f"huawei push failed: {data}")
|
||||
return data
|
||||
|
||||
|
||||
def _vivo_auth_token() -> str:
|
||||
cache_key = "vivo"
|
||||
cached = _cache_get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
app_id = _require(settings.VIVO_PUSH_APP_ID, "VIVO_PUSH_APP_ID")
|
||||
app_key = _require(settings.VIVO_PUSH_APP_KEY, "VIVO_PUSH_APP_KEY")
|
||||
app_secret = _require(settings.VIVO_PUSH_APP_SECRET, "VIVO_PUSH_APP_SECRET")
|
||||
timestamp = str(int(time.time() * 1000))
|
||||
sign = hashlib.md5(f"{app_id}{app_key}{timestamp}{app_secret}".encode()).hexdigest() # noqa: S324
|
||||
data = _request_json(
|
||||
"POST",
|
||||
settings.VIVO_PUSH_AUTH_ENDPOINT,
|
||||
json={
|
||||
"appId": app_id,
|
||||
"appKey": app_key,
|
||||
"timestamp": timestamp,
|
||||
"sign": sign,
|
||||
},
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
if int(data.get("result", -1)) != 0:
|
||||
raise VendorPushError(f"vivo auth failed: {data}")
|
||||
token = data.get("authToken")
|
||||
if not token:
|
||||
raise VendorPushError(f"vivo auth missing authToken: {data}")
|
||||
return _cache_put(cache_key, str(token), 24 * 3600)
|
||||
|
||||
|
||||
def _send_vivo(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]:
|
||||
app_id = _require(settings.VIVO_PUSH_APP_ID, "VIVO_PUSH_APP_ID")
|
||||
auth_token = _vivo_auth_token()
|
||||
payload: dict[str, Any] = {
|
||||
"appId": app_id,
|
||||
"regId": token,
|
||||
"notifyType": settings.VIVO_PUSH_NOTIFY_TYPE,
|
||||
"title": title,
|
||||
"content": body,
|
||||
"timeToLive": settings.PUSH_TIME_TO_LIVE_SEC,
|
||||
"requestId": uuid.uuid4().hex,
|
||||
"pushMode": settings.VIVO_PUSH_MODE,
|
||||
"clientCustomMap": extras,
|
||||
}
|
||||
# 点击落地:消息中心推送(带 notificationId)→ skipType=4 + skipContent=intent uri,由 vivo
|
||||
# 系统直启 MainActivity 并携带 S. extras(与小米 notify_effect=2 同机制)。不依赖客户端
|
||||
# VivoPushReceiver.onNotificationMessageClicked 里的后台 startActivity——Android 10+ BAL
|
||||
# 会静默拦掉,receiver 路径仅作兜底。无 notificationId 的召回类保持 skipType=1 仅打开首页。
|
||||
if extras.get("notificationId"):
|
||||
payload["skipType"] = 4
|
||||
payload["skipContent"] = _click_intent_uri(extras)
|
||||
else:
|
||||
payload["skipType"] = 1
|
||||
if settings.VIVO_PUSH_CATEGORY:
|
||||
payload["category"] = settings.VIVO_PUSH_CATEGORY
|
||||
data = _request_json(
|
||||
"POST",
|
||||
settings.VIVO_PUSH_SEND_ENDPOINT,
|
||||
json=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"authToken": auth_token,
|
||||
},
|
||||
)
|
||||
if int(data.get("result", -1)) != 0:
|
||||
raise VendorPushError(f"vivo push failed: {data}")
|
||||
return data
|
||||
|
||||
|
||||
def _send_xiaomi(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]:
|
||||
app_secret = _require(settings.XIAOMI_PUSH_APP_SECRET, "XIAOMI_PUSH_APP_SECRET")
|
||||
message_title = settings.XIAOMI_PUSH_TEMPLATE_TITLE.strip() or title
|
||||
message_description = settings.XIAOMI_PUSH_TEMPLATE_DESCRIPTION.strip() or body
|
||||
form = {
|
||||
"registration_id": token,
|
||||
"restricted_package_name": settings.ANDROID_PACKAGE_NAME,
|
||||
"title": message_title,
|
||||
"description": message_description,
|
||||
"payload": json.dumps(extras, ensure_ascii=False),
|
||||
"pass_through": "0",
|
||||
"notify_type": "-1",
|
||||
"time_to_live": str(settings.PUSH_TIME_TO_LIVE_SEC * 1000),
|
||||
}
|
||||
# 点击落地:带 notificationId 的消息中心推送 → notify_effect=2 + intent_uri,MiPush 直接打开
|
||||
# MainActivity 并把 extras 作为 String extra 传入(客户端 MainActivity.consumeNavTarget 读
|
||||
# notif_id/notif_type,兜底 notificationId/type)→ 置读 + 刷角标 + 按 type 直达对应页(PRD §5)。
|
||||
# ⚠️ 早前用 notify_effect=1(仅打开 Launcher),小米自身不会把 payload 拆成普通 extra、而是塞进
|
||||
# 序列化的 MiPushMessage(key_message),客户端读不到 → 点击后停在首页「没反应」。
|
||||
# 无 notificationId 的系统召回类(如无障碍掉线)保持 notify_effect=1 仅拉起 App,行为不变。
|
||||
if extras.get("notificationId"):
|
||||
form["extra.notify_effect"] = "2"
|
||||
form["extra.intent_uri"] = _click_intent_uri(extras)
|
||||
else:
|
||||
form["extra.notify_effect"] = "1"
|
||||
if settings.XIAOMI_PUSH_CHANNEL_ID:
|
||||
form["extra.channel_id"] = settings.XIAOMI_PUSH_CHANNEL_ID.strip()
|
||||
if settings.XIAOMI_PUSH_TEMPLATE_ID:
|
||||
form["extra.template_id"] = settings.XIAOMI_PUSH_TEMPLATE_ID.strip()
|
||||
if settings.XIAOMI_PUSH_TEMPLATE_PARAM_JSON:
|
||||
form["extra.template_param"] = _xiaomi_template_param(title, body)
|
||||
data = _request_form(
|
||||
"POST",
|
||||
settings.XIAOMI_PUSH_SEND_ENDPOINT,
|
||||
data=form,
|
||||
headers={"Authorization": f"key={app_secret}"},
|
||||
)
|
||||
code = data.get("code")
|
||||
if code not in (0, "0", None):
|
||||
raise VendorPushError(f"xiaomi push failed: {data}")
|
||||
if str(data.get("result", "ok")).lower() not in ("ok", "success"):
|
||||
raise VendorPushError(f"xiaomi push failed: {data}")
|
||||
return data
|
||||
|
||||
|
||||
def _click_extras(extras: dict[str, str]) -> dict[str, str]:
|
||||
"""点击落地参数:消息中心推送(extras 带 notificationId)补 notif_id/notif_type 别名——
|
||||
客户端 MainActivity.consumeNavTarget 首选这两个键(厂商 receiver 路径的历史约定),原始键
|
||||
(notificationId/type/feedbackId/reportId/…)保留作兜底与业务跳转参数。
|
||||
无 notificationId(如无障碍召回)原样返回,不喂点击路由参数。"""
|
||||
if not extras.get("notificationId"):
|
||||
return dict(extras)
|
||||
merged = dict(extras)
|
||||
merged.setdefault("notif_id", extras["notificationId"])
|
||||
if extras.get("type"):
|
||||
merged.setdefault("notif_type", extras["type"])
|
||||
return merged
|
||||
|
||||
|
||||
def _click_intent_uri(extras: dict[str, str]) -> str:
|
||||
"""构造「系统直启 MainActivity 并带 extras」的 intent uri(小米 notify_effect=2 的
|
||||
extra.intent_uri、vivo skipType=4 的 skipContent 共用):点击后厂商系统用 Intent.parseUri
|
||||
解析并 startActivity,extras 作为 String extra 原样送达。
|
||||
|
||||
- component 显式指向本包 MainActivity(exported=true、singleTask)→ 已运行则走 onNewIntent、
|
||||
未运行则 onCreate,两条都会执行 consumeNavTarget。
|
||||
- 参数 = _click_extras(补 notif_id/notif_type 别名 + 透传 feedbackId/reportId 等跳转参数)。
|
||||
- 值按 Android Uri.encode 规则百分号编码(quote(safe="")):中文/分号/等号都不会破坏 intent uri
|
||||
结构;客户端 Intent.parseUri 侧 Uri.decode 无损还原。表单/JSON 传输层的编码与本层相互独立、
|
||||
各自解码,不会双重转义(2026-07-15 小米联调结论)。
|
||||
"""
|
||||
pkg = settings.ANDROID_PACKAGE_NAME
|
||||
parts = ["intent:#Intent", f"component={pkg}/{pkg}.MainActivity"]
|
||||
parts += [f"S.{key}={quote(str(value), safe='')}" for key, value in _click_extras(extras).items()]
|
||||
parts.append("end")
|
||||
return ";".join(parts)
|
||||
|
||||
|
||||
def _xiaomi_template_param(title: str, alert: str) -> str:
|
||||
rendered = (
|
||||
settings.XIAOMI_PUSH_TEMPLATE_PARAM_JSON
|
||||
.replace("{title}", title)
|
||||
.replace("{alert}", alert)
|
||||
)
|
||||
try:
|
||||
payload = json.loads(rendered)
|
||||
except ValueError as e:
|
||||
raise VendorPushError("XIAOMI_PUSH_TEMPLATE_PARAM_JSON invalid json") from e
|
||||
if not isinstance(payload, dict):
|
||||
raise VendorPushError("XIAOMI_PUSH_TEMPLATE_PARAM_JSON must be a json object")
|
||||
for key, value in payload.items():
|
||||
if not isinstance(key, str) or not isinstance(value, str):
|
||||
raise VendorPushError("xiaomi template params must be string key-value pairs")
|
||||
if not value.strip() or len(value) > 128:
|
||||
raise VendorPushError("xiaomi template param value length must be 1-128")
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def _oppo_auth_token() -> str:
|
||||
cache_key = "oppo"
|
||||
cached = _cache_get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
app_key = _require(settings.OPPO_PUSH_APP_KEY, "OPPO_PUSH_APP_KEY")
|
||||
master_secret = _require(settings.OPPO_PUSH_MASTER_SECRET, "OPPO_PUSH_MASTER_SECRET")
|
||||
timestamp = str(int(time.time() * 1000))
|
||||
sign = hashlib.sha256(f"{app_key}{timestamp}{master_secret}".encode()).hexdigest()
|
||||
data = _request_form(
|
||||
"POST",
|
||||
settings.OPPO_PUSH_AUTH_ENDPOINT,
|
||||
data={
|
||||
"app_key": app_key,
|
||||
"timestamp": timestamp,
|
||||
"sign": sign,
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
if int(data.get("code", -1)) != 0:
|
||||
raise VendorPushError(f"oppo auth failed: {data}")
|
||||
token = (data.get("data") or {}).get("auth_token") or data.get("auth_token")
|
||||
if not token:
|
||||
raise VendorPushError(f"oppo auth missing auth_token: {data}")
|
||||
return _cache_put(cache_key, str(token), 24 * 3600)
|
||||
|
||||
|
||||
def _send_oppo(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]:
|
||||
auth_token = _oppo_auth_token()
|
||||
ttl_hours = max(1, min(72, settings.PUSH_TIME_TO_LIVE_SEC // 3600))
|
||||
notification: dict[str, Any] = {
|
||||
"app_message_id": f"{extras.get('type', 'notify')}_{uuid.uuid4().hex}",
|
||||
"title": title,
|
||||
"content": body,
|
||||
"off_line": True,
|
||||
"off_line_ttl": ttl_hours,
|
||||
"action_parameters": json.dumps(_click_extras(extras), ensure_ascii=False),
|
||||
}
|
||||
# 点击落地:OPPO SDK 没有点击回调,参数只能靠服务端点击动作配置送达——action_parameters 的
|
||||
# 键值对仅在 click_action_type=1/4 时才会注入目标 Activity 的 intent extras(type=0「启动应用」
|
||||
# 会忽略它,extras 全丢 → 点了没反应,与小米 notify_effect=1 同款坑)。
|
||||
# 消息中心推送(带 notificationId)→ type=4(打开应用内页面,Activity 全路径,exported=true);
|
||||
# 无 notificationId 的召回类保持 type=0 仅打开应用。
|
||||
if extras.get("notificationId"):
|
||||
notification["click_action_type"] = 4
|
||||
notification["click_action_activity"] = f"{settings.ANDROID_PACKAGE_NAME}.MainActivity"
|
||||
else:
|
||||
notification["click_action_type"] = 0
|
||||
# 新消息分类(2024-11-20 后创建的 OPPO 应用必须带 category,否则可能被拒收/降级)
|
||||
if settings.OPPO_PUSH_CHANNEL_ID.strip():
|
||||
notification["channel_id"] = settings.OPPO_PUSH_CHANNEL_ID.strip()
|
||||
if settings.OPPO_PUSH_CATEGORY.strip():
|
||||
notification["category"] = settings.OPPO_PUSH_CATEGORY.strip()
|
||||
if settings.OPPO_PUSH_NOTIFY_LEVEL:
|
||||
notification["notify_level"] = settings.OPPO_PUSH_NOTIFY_LEVEL
|
||||
message = {
|
||||
"target_type": 2,
|
||||
"target_value": token,
|
||||
"notification": notification,
|
||||
}
|
||||
data = _request_form(
|
||||
"POST",
|
||||
settings.OPPO_PUSH_SEND_ENDPOINT,
|
||||
data={
|
||||
"auth_token": auth_token,
|
||||
"message": json.dumps(message, ensure_ascii=False),
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
if int(data.get("code", -1)) != 0:
|
||||
raise VendorPushError(f"oppo push failed: {data}")
|
||||
return data
|
||||
+20
@@ -29,10 +29,13 @@ from app.api.v1.coupon import router as coupon_router
|
||||
from app.api.v1.cps_redirect import router as cps_redirect_router
|
||||
from app.api.v1.device import router as device_router
|
||||
from app.api.v1.feedback import router as feedback_router
|
||||
from app.api.v1.guide_video import router as guide_video_router
|
||||
from app.api.v1.invite import router as invite_router
|
||||
from app.api.v1.meituan import router as meituan_router
|
||||
from app.api.v1.notifications import router as notifications_router
|
||||
from app.api.v1.order import router as order_router
|
||||
from app.api.v1.platform import router as platform_router
|
||||
from app.api.v1.push import router as push_router
|
||||
from app.api.v1.report import router as report_router
|
||||
from app.api.v1.savings import router as savings_router
|
||||
from app.api.v1.signin import router as signin_router
|
||||
@@ -41,6 +44,10 @@ from app.api.v1.user import router as user_router
|
||||
from app.api.v1.wallet import router as wallet_router
|
||||
from app.api.v1.wxpay import router as wxpay_router
|
||||
from app.core.config import settings
|
||||
from app.core.cps_reconcile_worker import (
|
||||
start_cps_reconcile_worker,
|
||||
stop_cps_reconcile_worker,
|
||||
)
|
||||
from app.core.daily_exchange_worker import (
|
||||
start_daily_exchange_worker,
|
||||
stop_daily_exchange_worker,
|
||||
@@ -64,6 +71,7 @@ from app.core.withdraw_reconcile_worker import (
|
||||
start_withdraw_reconcile_worker,
|
||||
stop_withdraw_reconcile_worker,
|
||||
)
|
||||
from app.integrations import meituan as mt_meituan
|
||||
|
||||
setup_logging(debug=settings.APP_DEBUG)
|
||||
logger = logging.getLogger("shagua.main")
|
||||
@@ -80,6 +88,9 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
settings.DATABASE_URL.split("://", 1)[0],
|
||||
)
|
||||
get_pricebot_client() # 预热透传 client:把建 SSL 上下文的一次性成本付在启动,首个领券请求即热
|
||||
if settings.mt_cps_configured:
|
||||
# 同理预热美团 CPS client(TLS 上下文 + 连接池建一次,后续 keep-alive 复用)
|
||||
mt_meituan.get_client()
|
||||
try:
|
||||
# 预热离线地理库:首次加载 ~2.5M 行 CSV + 建 KDTree,摊到启动、不砸首个按城市过滤的请求
|
||||
from app.utils import geo
|
||||
@@ -87,6 +98,7 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("reverse_geocoder 预热失败(城市反查将在首个请求时懒加载)")
|
||||
reconcile_task = start_withdraw_reconcile_worker()
|
||||
cps_reconcile_task = start_cps_reconcile_worker()
|
||||
heartbeat_task = start_heartbeat_monitor()
|
||||
daily_exchange_task = start_daily_exchange_worker()
|
||||
observe_task = start_observe_worker()
|
||||
@@ -96,10 +108,12 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
finally:
|
||||
await stop_heartbeat_monitor(heartbeat_task)
|
||||
await stop_withdraw_reconcile_worker(reconcile_task)
|
||||
await stop_cps_reconcile_worker(cps_reconcile_task)
|
||||
await stop_daily_exchange_worker(daily_exchange_task)
|
||||
await stop_observe_worker(observe_task)
|
||||
await stop_inactivity_reset_worker(inactivity_task)
|
||||
await aclose_pricebot_client()
|
||||
mt_meituan.close_client()
|
||||
logger.info("shutting down")
|
||||
|
||||
|
||||
@@ -146,8 +160,14 @@ app.include_router(signin_router)
|
||||
app.include_router(tasks_router)
|
||||
app.include_router(savings_router)
|
||||
app.include_router(ad_router)
|
||||
# 新手引导视频(领券等候浮层前 N 次替代广告;运营后台传片,见 repositories/guide_video.py)
|
||||
app.include_router(guide_video_router)
|
||||
app.include_router(order_router)
|
||||
app.include_router(report_router)
|
||||
# 消息通知中心(PRD;数据落库 notification 表,见 repositories/notification.py)
|
||||
app.include_router(notifications_router)
|
||||
# 厂商推送测试三件套(配置状态/模板预览/测试发送,支持 mock 与真发)
|
||||
app.include_router(push_router)
|
||||
# 内部(server→server)端点:pricebot 上报价格观测 / 店铺映射,靠共享密钥头校验,不对客户端开放。
|
||||
app.include_router(internal_price_router)
|
||||
app.include_router(internal_store_router)
|
||||
|
||||
+11
-7
@@ -13,20 +13,22 @@ from app.models.analytics_selfstat import ( # noqa: F401
|
||||
)
|
||||
from app.models.app_config import AppConfig # noqa: F401
|
||||
from app.models.comparison import ComparisonRecord # noqa: F401
|
||||
from app.models.cps_activity import CpsActivity # noqa: F401
|
||||
from app.models.cps_group import CpsGroup # noqa: F401
|
||||
from app.models.cps_link import CpsClick, CpsLink # noqa: F401
|
||||
from app.models.cps_order import CpsOrder # noqa: F401
|
||||
from app.models.cps_wx_user import CpsWxUser # noqa: F401
|
||||
from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401
|
||||
from app.models.device import DeviceLiveness # noqa: F401
|
||||
from app.models.coupon_state import ( # noqa: F401
|
||||
CouponClaimEvent,
|
||||
CouponClaimRecord,
|
||||
CouponDailyCompletion,
|
||||
CouponPromptEngagement,
|
||||
CouponSession,
|
||||
)
|
||||
from app.models.cps_activity import CpsActivity # noqa: F401
|
||||
from app.models.cps_group import CpsGroup # noqa: F401
|
||||
from app.models.cps_link import CpsClick, CpsLink # noqa: F401
|
||||
from app.models.cps_order import CpsOrder # noqa: F401
|
||||
from app.models.cps_wx_user import CpsWxUser # noqa: F401
|
||||
from app.models.device import DeviceLiveness # noqa: F401
|
||||
from app.models.feedback import Feedback # noqa: F401
|
||||
from app.models.guide_video import GuideVideoPlay # noqa: F401
|
||||
from app.models.inactivity import ( # noqa: F401
|
||||
InactivityNotificationLog,
|
||||
InactivityResetLog,
|
||||
@@ -35,12 +37,14 @@ from app.models.invite import InviteRelation # noqa: F401
|
||||
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
|
||||
from app.models.launch_confirm_sample import LaunchConfirmSample # noqa: F401
|
||||
from app.models.meituan_coupon import MeituanCoupon # noqa: F401
|
||||
from app.models.notification import Notification # noqa: F401
|
||||
from app.models.onboarding import OnboardingCompletion # noqa: F401
|
||||
from app.models.phone_rebind_log import PhoneRebindLog # noqa: F401
|
||||
from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401
|
||||
from app.models.ops_stat_config import OpsStatConfig # noqa: F401
|
||||
from app.models.phone_rebind_log import PhoneRebindLog # noqa: F401
|
||||
from app.models.price_observation import PriceObservation # noqa: F401
|
||||
from app.models.price_report import PriceReport # noqa: F401
|
||||
from app.models.risk import BehaviorEvent, RiskIncident, SubjectRestriction # noqa: F401
|
||||
from app.models.savings import SavingsRecord # noqa: F401
|
||||
from app.models.signin import SigninRecord # noqa: F401
|
||||
from app.models.store_mapping import StoreMapping # noqa: F401
|
||||
|
||||
@@ -45,6 +45,10 @@ class ComparisonRecord(Base):
|
||||
# 首页轮播 / 省钱战绩聚合都按 status='success' 过滤 + created_at 近期排序;
|
||||
# 复合索引避免随数据量增大退化成全表扫(单列 created_at 索引不含 status)。
|
||||
Index("ix_comparison_status_created", "status", "created_at"),
|
||||
# C 端「我的比价记录」列表:WHERE user_id=? ORDER BY created_at DESC, id DESC LIMIT n。
|
||||
# 单列 user_id 索引只能过滤,排序仍要把该用户全部记录取出来排一遍;这条复合索引的**反向扫**
|
||||
# 恰好等于 (created_at DESC, id DESC),PG 直接取前 n 条、免排序。列序不能动。
|
||||
Index("ix_comparison_user_created", "user_id", "created_at", "id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
@@ -96,6 +96,40 @@ class CouponClaimRecord(Base):
|
||||
)
|
||||
|
||||
|
||||
class CouponClaimEvent(Base):
|
||||
"""一次领券任务中的单券结果,按 ``(trace_id, coupon_id)`` 幂等。"""
|
||||
|
||||
__tablename__ = "coupon_claim_event"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"trace_id", "coupon_id",
|
||||
name="uq_coupon_claim_event_trace_coupon",
|
||||
),
|
||||
Index("ix_coupon_claim_event_date_env", "claim_date", "app_env"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
trace_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
|
||||
coupon_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
claim_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(24), nullable=False)
|
||||
app_env: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)
|
||||
vendor: Mapped[str | None] = mapped_column(String(48), nullable=True)
|
||||
coupon_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
claimed_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
extra: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class CouponDailyCompletion(Base):
|
||||
"""按 (device, 自然日) 记"今天是否已跑完整轮领券(到 done 帧)"——首页置灰源。
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""设备表(无障碍保护存活检测 + 极光推送)。
|
||||
"""设备表(无障碍保护存活检测 + 厂商直推)。
|
||||
|
||||
每条 = 一个用户的一台设备(per-install,device_id 由客户端 DeviceId.get() 生成)。
|
||||
客户端的无障碍服务存活时周期上报心跳刷新 last_heartbeat_at;App 前台/登录时上报
|
||||
registration_id(极光推送目标)。后端 heartbeat_monitor_worker 扫描「曾经保护过、
|
||||
现在心跳超时」的设备,通过极光推送提醒用户重开无障碍。
|
||||
push_vendor + push_token(厂商推送目标)。后端 heartbeat_monitor_worker 扫描「曾经保护过、
|
||||
现在心跳超时」的设备,通过厂商直推提醒用户重开无障碍。
|
||||
|
||||
liveness_state 状态机(防刷屏,一次掉线只推一条):
|
||||
unknown → alive(收到 service 心跳)→ silent/notified(扫描发现超时并已推送)
|
||||
@@ -30,7 +30,7 @@ from app.db.base import Base
|
||||
|
||||
class DeviceLiveness(Base):
|
||||
# 表名不叫 device:device 易被当成「设备信息(品牌/型号/系统)」表;本表实为**无障碍存活监控状态**
|
||||
# (心跳 last_heartbeat_at + liveness_state + kill_alert_pending + 推送目标 registration_id),故名 device_liveness。
|
||||
# (心跳 last_heartbeat_at + liveness_state + kill_alert_pending + 厂商推送目标),故名 device_liveness。
|
||||
__tablename__ = "device_liveness"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "device_id", name="uq_device_liveness_user_device"),
|
||||
@@ -42,8 +42,12 @@ class DeviceLiveness(Base):
|
||||
)
|
||||
# 客户端 DeviceId.get() 生成的 per-install id(如 device_Pixel_ab12cd34)
|
||||
device_id: Mapped[str] = mapped_column(String(128), index=True, nullable=False)
|
||||
# 极光推送 registration id;拿到才填(JCollectionAuth 同意后才下发)
|
||||
# 旧极光推送 registration id,仅为兼容历史客户端/数据保留;新链路使用 push_vendor + push_token。
|
||||
registration_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# 厂商推送类型:honor/vivo/xiaomi/oppo 等;客户端按实际 SDK token 来源上报。
|
||||
push_vendor: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# 厂商 push token / regId / registration_id;不同厂商命名不同,后端统一存这里。
|
||||
push_token: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
platform: Mapped[str] = mapped_column(String(16), nullable=False, default="android")
|
||||
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""新手引导视频播放记录(领券浮层前 N 次用它替代广告)。
|
||||
|
||||
产品规则(2026-07 拍板):新用户点「一键自动领取」后的等候浮层,**前 3 次**不放广告,
|
||||
改放运营后台上传的引导视频;每次固定 120 金币,中途关闭也算看完照发。
|
||||
|
||||
口径:
|
||||
- **计次按账号**(user_id),与设备无关 —— 换设备不重新送 3 次。
|
||||
- **开播即计数**:客户端每次要展示浮层时调 `/api/v1/guide-video/start`,服务端当场
|
||||
写一行(status='playing')并返回 play_token;`COUNT(*)` 即已用次数。用户中途 kill
|
||||
App 也算用掉一次(产品选定口径,防反复进出刷金币)。
|
||||
- **发币幂等**靠 play_token 定位 + `status='playing'` 条件更新:并发两次上报只有一次
|
||||
改到行(另一次 rowcount=0),所以只发一次币。光有 play_token 唯一键挡不住 —— 发币走的是
|
||||
UPDATE,不 INSERT,撞不到任何唯一键。
|
||||
- **次数上限**靠 (user_id, seq) 唯一键兜底,防并发 /start 绕过 COUNT 判定(见下)。
|
||||
|
||||
与广告收益(ad_feed_reward_record)彻底分离:引导视频不是广告,不该进广告收益报表。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class GuideVideoPlay(Base):
|
||||
"""一次引导视频播放一行。开播时建(status='playing'),发币后置 'granted'。"""
|
||||
|
||||
__tablename__ = "guide_video_play"
|
||||
__table_args__ = (
|
||||
# 客户端幂等键:同一次播放重复上报奖励只发一次。
|
||||
UniqueConstraint("play_token", name="uq_guide_video_play_token"),
|
||||
# 次数上限的**硬约束**:start_play 是无锁 check-then-insert(读 COUNT 算 seq 再插),
|
||||
# N 个并发 /start 会都读到同一个已用次数、算出同一个 seq,不拦就能各拿一个 token、
|
||||
# 各发一次金币,3 次上限形同虚设(改包即可无限刷)。seq 唯一 → 并发同 seq 必撞,
|
||||
# start_play 捕获 IntegrityError 降级成"这次不放视频"。
|
||||
# 用 unique Index 而非 UniqueConstraint:与迁移里的 create_index 对齐(SQLite 加约束
|
||||
# 要整表重建),autogenerate 才不会每次报一条假 diff。
|
||||
Index("uq_guide_video_play_user_seq", "user_id", "seq", unique=True),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||
)
|
||||
# 服务端生成下发给客户端的幂等键(uuid hex)。
|
||||
play_token: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
# 触发场景:目前只有 coupon(领券等候浮层);留字段以便日后比价等场景复用。
|
||||
scene: Mapped[str] = mapped_column(String(16), nullable=False, default="coupon")
|
||||
# 本账号第几次(1-based),= 建行时已有行数 + 1。日常判定仍以 COUNT 为准,但 (user_id, seq)
|
||||
# 唯一键让并发 /start 只能成一个 —— 见 __table_args__。
|
||||
seq: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
# 当次下发的视频地址(运营换片后能回溯用户当时看的是哪支)。
|
||||
video_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
# 实发金币;未发时 0。
|
||||
coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# playing(已开播未发币) / granted(已发币)。
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="playing")
|
||||
# 客户端上报时是否播完(true=自然播完 / false=中途关闭)。仅留痕:两者都发币。
|
||||
completed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
started_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
granted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
f"<GuideVideoPlay user={self.user_id} seq={self.seq} "
|
||||
f"{self.status} coin={self.coin}>"
|
||||
)
|
||||
@@ -23,7 +23,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Float, Integer, String, UniqueConstraint, func
|
||||
from sqlalchemy import JSON, DateTime, Float, Index, Integer, String, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -95,3 +95,23 @@ class MeituanCoupon(Base):
|
||||
f"<MeituanCoupon id={self.id} source={self.source} "
|
||||
f"name={self.name!r} sale={self.sale_volume} comm={self.commission_percent}>"
|
||||
)
|
||||
|
||||
|
||||
# 首页「销量最高 / 智能推荐」两个 tab 的分页索引(见 api/v1/meituan.py 的 _paged_dedup_ids)。
|
||||
# 两条 tab 都是 `WHERE city_id=? [+过滤] ORDER BY dedup_key, <排序键> DESC` 的 DISTINCT ON 去重,
|
||||
# 列顺序对齐后 Postgres 可以顺着索引流式去重,免掉整城数据的排序 —— 否则每翻一页都要把该城
|
||||
# 全部券重排一遍(下滑到底越来越慢的根因之一)。
|
||||
# 定义放在类外:__table_args__ 里拿不到还没建好的类属性,写不了 .desc()。
|
||||
Index(
|
||||
"ix_meituan_coupon_city_dedup_sales",
|
||||
MeituanCoupon.city_id,
|
||||
MeituanCoupon.dedup_key,
|
||||
MeituanCoupon.sale_volume_num.desc(),
|
||||
MeituanCoupon.commission_percent.desc(),
|
||||
)
|
||||
Index(
|
||||
"ix_meituan_coupon_city_dedup_comm",
|
||||
MeituanCoupon.city_id,
|
||||
MeituanCoupon.dedup_key,
|
||||
MeituanCoupon.commission_percent.desc(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""消息通知中心:站内消息表(一行 = 一条下发给某用户的站内消息)。
|
||||
|
||||
13 类通知的**静态定义**(分类 / 版式 / 标题 / 操作行 / push 模板)在
|
||||
`app/core/notification_catalog.py`,是代码常量,**不入库**;本表只存**每条消息的动态部分**
|
||||
(与接口 NotificationItem 的动态字段一一对应):type + 金额 + 信息行 + extra + 已读态 + 时间。
|
||||
category / card_style / title / action_text 都由 `type` 经 catalog 派生,不冗余存库。
|
||||
|
||||
- 写:`repositories/notification.create_notification`(业务事件下发站内消息的统一入口)。
|
||||
- 读:`api/v1/notifications.py`(列表 / 未读数 / 标记已读),均按 user 隔离、sent_at 倒序。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
func,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
# PG 用 JSONB,SQLite(本地/测试)退化为通用 JSON(同 comparison_record.raw_payload 等)。
|
||||
_JSON = JSON().with_variant(JSONB(), "postgresql")
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = "notification"
|
||||
__table_args__ = (
|
||||
# 列表分页:按用户取 + sent_at 倒序(核心查询,覆盖 user_id 前缀查找,故不再单独索引 user_id)
|
||||
Index("ix_notification_user_sent", "user_id", "sent_at"),
|
||||
# 铃铛角标:count where user_id=? and is_read=false —— 部分索引只覆盖未读行
|
||||
Index(
|
||||
"ix_notification_user_unread",
|
||||
"user_id",
|
||||
sqlite_where=text("is_read = 0"),
|
||||
postgresql_where=text("is_read = false"),
|
||||
),
|
||||
# 去重/合并:同一 (user, type, dedup_key) 未读期间只允许一条(perm_* 权限异常、
|
||||
# reward_expiring 同批次即用它);消息一旦已读即离开索引,之后可再生成新的未读消息。
|
||||
Index(
|
||||
"uq_notification_user_type_dedup",
|
||||
"user_id",
|
||||
"type",
|
||||
"dedup_key",
|
||||
unique=True,
|
||||
sqlite_where=text("dedup_key IS NOT NULL AND is_read = 0"),
|
||||
postgresql_where=text("dedup_key IS NOT NULL AND is_read = false"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
# 13 类之一(catalog.TYPES 的 key);category/card_style/title/action_text 由它派生,不入库
|
||||
type: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
|
||||
# 金币数(dual_amount / coin_reward 卡);其余类型 None
|
||||
coins: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# 现金,单位【分】(dual_amount / withdraw / friend_cash 卡);其余 None
|
||||
cash_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# 信息行 [{label, value}](已渲染好文案,前端逐行展示)
|
||||
info_rows: Mapped[list] = mapped_column(_JSON, nullable=False, default=list)
|
||||
# 点击跳转/联动参数(feedbackId / withdrawId / permission / inviteeNickname / batchId …)
|
||||
extra: Mapped[dict] = mapped_column(_JSON, nullable=False, default=dict)
|
||||
is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
# 置读时刻(未读时为 None;埋点/分析用)
|
||||
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# 去重键(可空):perm_*→permission、reward_expiring→batchId 等;配合部分唯一索引防重复未读
|
||||
dedup_key: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# 下发/业务时间;列表排序与展示都用它(带 +08:00 下发)
|
||||
sent_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
f"<Notification id={self.id} user_id={self.user_id} "
|
||||
f"type={self.type} read={self.is_read}>"
|
||||
)
|
||||
@@ -0,0 +1,163 @@
|
||||
"""通用行为事件、风险事件与主体限制。
|
||||
|
||||
这三张表不是“风控监控页面专用表”:
|
||||
|
||||
- ``behavior_event`` 保存服务端权威的关键行为流水,后续可复用于安全审计、
|
||||
漏斗核查和客诉排查;
|
||||
- ``risk_incident`` 保存规则命中后的可处置事件,负责“待处理/忽略/封禁/解除”
|
||||
生命周期;
|
||||
- ``subject_restriction`` 保存当前生效的主体限制,统一承载设备、账号等主体的
|
||||
业务拦截状态。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
DateTime,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
true,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
_JSON = JSON().with_variant(JSONB(), "postgresql")
|
||||
|
||||
|
||||
class BehaviorEvent(Base):
|
||||
"""服务端权威行为流水;只增不改。"""
|
||||
|
||||
__tablename__ = "behavior_event"
|
||||
__table_args__ = (
|
||||
Index("ix_behavior_event_type_time", "event_type", "occurred_at"),
|
||||
Index(
|
||||
"ix_behavior_event_subject_time",
|
||||
"subject_type",
|
||||
"subject_id",
|
||||
"occurred_at",
|
||||
),
|
||||
Index("ix_behavior_event_user_time", "user_id", "occurred_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
event_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
subject_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
subject_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
|
||||
user_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
device_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
device_model: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
phone: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
client_ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
# success / failed / rejected / attempted
|
||||
outcome: Mapped[str] = mapped_column(String(24), nullable=False, default="success")
|
||||
reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
details: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
|
||||
|
||||
occurred_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
|
||||
class RiskIncident(Base):
|
||||
"""规则命中后形成的可处置风险事件。"""
|
||||
|
||||
__tablename__ = "risk_incident"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"rule_code",
|
||||
"subject_type",
|
||||
"subject_id",
|
||||
"window_key",
|
||||
name="uq_risk_incident_rule_subject_window",
|
||||
),
|
||||
Index("ix_risk_incident_rule_status", "rule_code", "status", "triggered_at"),
|
||||
Index(
|
||||
"ix_risk_incident_subject",
|
||||
"subject_type",
|
||||
"subject_id",
|
||||
"triggered_at",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
rule_code: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
event_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
subject_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
subject_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
window_key: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
window_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
window_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
first_event_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
triggered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
last_event_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
event_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
# open / ignored / blocked / resolved
|
||||
status: Mapped[str] = mapped_column(String(24), nullable=False, default="open")
|
||||
action_reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
handled_by: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
handled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
details: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class SubjectRestriction(Base):
|
||||
"""主体当前限制状态;同一主体同一作用域只有一条,可封禁后再解除/重启。"""
|
||||
|
||||
__tablename__ = "subject_restriction"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"subject_type",
|
||||
"subject_id",
|
||||
"scope",
|
||||
name="uq_subject_restriction_subject_scope",
|
||||
),
|
||||
Index(
|
||||
"ix_subject_restriction_lookup",
|
||||
"subject_type",
|
||||
"subject_id",
|
||||
"scope",
|
||||
"active",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
subject_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
subject_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
# auth_device / economic_account / all
|
||||
scope: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
active: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True, server_default=true()
|
||||
)
|
||||
reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
incident_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
created_by: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
revoked_by: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
details: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
|
||||
+8
-1
@@ -11,7 +11,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String, false, func
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String, Text, false, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
@@ -62,6 +62,13 @@ class User(Base):
|
||||
Boolean, nullable=False, default=False, server_default=false()
|
||||
)
|
||||
|
||||
# 运营人工风险标记。与自动风控分值分开:这里表达人工复核结论,并保留可编辑备注,
|
||||
# 供邀请提现、其他提现和用户管理三个页面统一展示。
|
||||
is_high_risk: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default=false(), index=True
|
||||
)
|
||||
high_risk_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
@@ -96,15 +96,6 @@ class WithdrawOrder(Base):
|
||||
"""
|
||||
|
||||
__tablename__ = "withdraw_order"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ux_withdraw_order_user_active",
|
||||
"user_id",
|
||||
unique=True,
|
||||
sqlite_where=text("status IN ('reviewing', 'pending')"),
|
||||
postgresql_where=text("status IN ('reviewing', 'pending')"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
|
||||
@@ -5,17 +5,28 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session, defer
|
||||
|
||||
from app.core.rewards import CN_TZ
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.savings import SavingsRecord
|
||||
from app.models.user import User
|
||||
from app.schemas.compare_record import ComparisonRecordIn
|
||||
|
||||
DAILY_COMPARE_START_LIMIT = 100
|
||||
|
||||
|
||||
class DailyCompareStartLimitExceeded(Exception):
|
||||
"""The authenticated user has consumed today's comparison-start quota."""
|
||||
|
||||
|
||||
class ComparisonTraceOwnershipError(Exception):
|
||||
"""A trace id already belongs to a different authenticated user."""
|
||||
|
||||
|
||||
def _yuan_to_cents(yuan: float | None) -> int | None:
|
||||
"""元(float)→ 分(int)。None 透传。"""
|
||||
@@ -243,6 +254,78 @@ def _get_by_trace(db: Session, trace_id: str) -> ComparisonRecord | None:
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def reserve_daily_start(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: int,
|
||||
trace_id: str,
|
||||
business_type: str = "food",
|
||||
device_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> tuple[ComparisonRecord, int]:
|
||||
"""Atomically reserve one of a user's 100 Beijing-day comparison starts.
|
||||
|
||||
``trace_id`` makes client retries idempotent. Locking the user row serializes
|
||||
concurrent starts for one account, so parallel requests cannot both consume
|
||||
the final available slot. The reservation is the existing ``running``
|
||||
comparison row; later result reporting updates that same row.
|
||||
"""
|
||||
db.execute(select(User.id).where(User.id == user_id).with_for_update()).scalar_one()
|
||||
|
||||
existing = _get_by_trace(db, trace_id)
|
||||
if existing is not None:
|
||||
if existing.user_id not in (None, user_id):
|
||||
raise ComparisonTraceOwnershipError
|
||||
if existing.user_id is None:
|
||||
existing.user_id = user_id
|
||||
if existing.device_id is None and device_id:
|
||||
existing.device_id = device_id
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
|
||||
existing_at = existing.created_at
|
||||
if existing_at.tzinfo is not None:
|
||||
existing_at = existing_at.astimezone(CN_TZ).replace(tzinfo=None)
|
||||
day_start = existing_at.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
day_end = day_start + timedelta(days=1)
|
||||
used = db.scalar(
|
||||
select(func.count(ComparisonRecord.id)).where(
|
||||
ComparisonRecord.user_id == user_id,
|
||||
ComparisonRecord.created_at >= day_start,
|
||||
ComparisonRecord.created_at < day_end,
|
||||
)
|
||||
) or 0
|
||||
return existing, int(used)
|
||||
|
||||
current = now or datetime.now(CN_TZ)
|
||||
if current.tzinfo is not None:
|
||||
current = current.astimezone(CN_TZ).replace(tzinfo=None)
|
||||
day_start = current.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
day_end = day_start + timedelta(days=1)
|
||||
used = db.scalar(
|
||||
select(func.count(ComparisonRecord.id)).where(
|
||||
ComparisonRecord.user_id == user_id,
|
||||
ComparisonRecord.created_at >= day_start,
|
||||
ComparisonRecord.created_at < day_end,
|
||||
)
|
||||
) or 0
|
||||
if used >= DAILY_COMPARE_START_LIMIT:
|
||||
raise DailyCompareStartLimitExceeded
|
||||
|
||||
rec = ComparisonRecord(
|
||||
trace_id=trace_id,
|
||||
user_id=user_id,
|
||||
business_type=business_type or "food",
|
||||
device_id=device_id,
|
||||
status="running",
|
||||
created_at=current,
|
||||
)
|
||||
db.add(rec)
|
||||
db.commit()
|
||||
db.refresh(rec)
|
||||
return rec, int(used) + 1
|
||||
|
||||
|
||||
def harvest_running(
|
||||
db: Session,
|
||||
*,
|
||||
@@ -375,19 +458,48 @@ def harvest_abort(
|
||||
return rec
|
||||
|
||||
|
||||
def _ordered_shop_names(db: Session, user_id: int) -> set[str]:
|
||||
"""该用户「真实下单」(source='compare')覆盖到的店名集合,用来给比价记录打「已下单」。
|
||||
def _ordered_shop_name_select(user_id: int):
|
||||
"""该用户「真实下单」(source='compare')覆盖到的店名 select,给「已下单」筛选当子查询。
|
||||
|
||||
口径与 [_ordered_shop_names] 完全一致,只是时机不同:那边是**拿到本页之后**按 candidates
|
||||
反查打标;这边是**分页之前**就要过滤,拿不到 candidates,只能整段下推成子查询。
|
||||
没有先捞成集合再展开 IN (...) 字面量 —— 重度用户下单过的店名可能上千,展开会撞 SQLite
|
||||
的绑定变量上限,而且又变回了那个「随下单量线性变慢」的老写法。
|
||||
"""
|
||||
return select(SavingsRecord.shop_name).where(
|
||||
SavingsRecord.user_id == user_id,
|
||||
SavingsRecord.source == "compare",
|
||||
SavingsRecord.shop_name.is_not(None),
|
||||
)
|
||||
|
||||
|
||||
def _like_escape(kw: str) -> str:
|
||||
"""转义 LIKE 通配符(百分号 / 下划线 / 反斜杠),让用户输入只按字面量匹配(配合 escape 参数)。
|
||||
|
||||
不转义的话搜一个「%」就等于把整表拉回来。
|
||||
"""
|
||||
return kw.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
def _ordered_shop_names(db: Session, user_id: int, candidates: set[str]) -> set[str]:
|
||||
"""[candidates] 里哪些店名被该用户「真实下单」(source='compare')覆盖过,用来打「已下单」。
|
||||
|
||||
只认 compare(归因命中后真实上报),demo 演示数据不算。下单上报不带 trace_id,
|
||||
只能按店名对齐——两边店名同源(都来自比价意图识别阶段的门店名 query),精确相等即视为同店。
|
||||
语义=店级:同一家店比价过多次,这些记录会一并标「已下单」。
|
||||
|
||||
⚠️ 只查**本页出现过的店名**(candidates ≤ limit 条),不再把该用户全部下单店名捞回内存:
|
||||
老写法随下单量线性增长,重度用户几千行全读一遍只为跟 50 条记录取交集。空集合直接返回
|
||||
(避免 IN () 非法)。
|
||||
"""
|
||||
if not candidates:
|
||||
return set()
|
||||
rows = db.execute(
|
||||
select(SavingsRecord.shop_name).where(
|
||||
SavingsRecord.user_id == user_id,
|
||||
SavingsRecord.source == "compare",
|
||||
SavingsRecord.shop_name.is_not(None),
|
||||
)
|
||||
SavingsRecord.shop_name.in_(candidates),
|
||||
).distinct()
|
||||
).scalars().all()
|
||||
return {s for s in rows if s}
|
||||
|
||||
@@ -415,17 +527,60 @@ def _ad_coins_by_trace(db: Session, user_id: int, trace_ids: list[str]) -> dict[
|
||||
return {tid: int(coin) for tid, coin in rows if tid}
|
||||
|
||||
|
||||
# 列表出参(ComparisonRecordOut)根本不读、但 select(ORM) 默认会一并捞回来的重型 JSON 列:
|
||||
# - raw_payload:done.params 上报体全量,**每条记录都有**(harvest 与 POST 两条写路径都落)。
|
||||
# 单条几 KB~几十 KB,一页 50 条就是稳定几百 KB~几 MB 的白读 + 白反序列化。
|
||||
# - llm_calls:每次 LLM 调用的 input_messages + output 全文。只有走老客户端 POST /compare/record
|
||||
# 的记录才有(_backfill_llm_calls 回填;harvest 路径不落),但有的时候单条就能到 MB 级 —— 一页里
|
||||
# 混进几条这种记录,整个请求就被它们拖住。
|
||||
# - llm_price_snapshot:逐模型单价快照,同样只在回填时落。
|
||||
# 三列全部读出来再被 pydantic 丢掉,是「比价记录/全部记录」页慢的主要来源。
|
||||
# ⚠️ defer 的列一旦在别处被读到会触发**逐行**懒加载(N+1);列表这条链路(ComparisonRecordOut
|
||||
# 不声明这三个字段 → 不会 getattr 到)是安全的。详情接口 get_record 不 defer,raw_payload 照常返回。
|
||||
_LIST_DEFERRED = (
|
||||
ComparisonRecord.raw_payload,
|
||||
ComparisonRecord.llm_calls,
|
||||
ComparisonRecord.llm_price_snapshot,
|
||||
)
|
||||
|
||||
|
||||
def list_records(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
*,
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
ordered: bool | None = None,
|
||||
keyword: str | None = None,
|
||||
) -> tuple[list[ComparisonRecord], int | None]:
|
||||
"""比价记录分页(按创建时间倒序、id 兜底,游标式)。附「已下单」店级标记 + 「看广告赚的金币」(瞬态,不写库)。"""
|
||||
stmt = select(ComparisonRecord).where(ComparisonRecord.user_id == user_id)
|
||||
stmt = (
|
||||
select(ComparisonRecord)
|
||||
.where(ComparisonRecord.user_id == user_id)
|
||||
.options(*(defer(col) for col in _LIST_DEFERRED))
|
||||
)
|
||||
if cursor is not None:
|
||||
stmt = stmt.where(ComparisonRecord.id < cursor)
|
||||
# 「已下单」tab 与搜索框的过滤都下推到这里,不能留给客户端对整页结果 filter ——
|
||||
# 分页之后一页里可能一条都不命中,列表看着就是空的/卡住的,得翻很多页才蹦出一条。
|
||||
if ordered:
|
||||
stmt = stmt.where(
|
||||
ComparisonRecord.store_name.in_(_ordered_shop_name_select(user_id))
|
||||
)
|
||||
kw = (keyword or "").strip()
|
||||
if kw:
|
||||
# product_names 是写路径从 items[].name 派生的普通文本列(items 本身是 JSON,SQLite 下
|
||||
# 中文被 ensure_ascii 转义,没法直接 LIKE)—— 搜「菜名」靠的就是它。
|
||||
# ilike:PG 原生 ILIKE,SQLite 渲染成 lower() LIKE lower(),两边都忽略大小写。
|
||||
pattern = f"%{_like_escape(kw)}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
ComparisonRecord.store_name.ilike(pattern, escape="\\"),
|
||||
ComparisonRecord.product_names.ilike(pattern, escape="\\"),
|
||||
)
|
||||
)
|
||||
# 排序与 ix_comparison_user_created(user_id, created_at, id)对齐 —— DESC/DESC 正好是该索引的
|
||||
# 反向扫,PG 免排序直接取前 limit 条。改排序方向前先想清楚索引还吃不吃得上。
|
||||
stmt = stmt.order_by(ComparisonRecord.created_at.desc(), ComparisonRecord.id.desc()).limit(limit)
|
||||
|
||||
items = list(db.execute(stmt).scalars().all())
|
||||
@@ -433,7 +588,10 @@ def list_records(
|
||||
|
||||
# 「已下单」标记:本页记录的 store_name 若落在该用户真实下单的店名集合里即 True。
|
||||
# ordered / ad_coins_earned 均非 ORM 列,仅挂实例上供 ComparisonRecordOut(from_attributes) 读出,不持久化。
|
||||
ordered_shops = _ordered_shop_names(db, user_id)
|
||||
page_shops = {it.store_name for it in items if it.store_name}
|
||||
# ordered=True 时上面已按同一口径(_ordered_shop_name_select)筛过,本页必然全是已下单,
|
||||
# 省掉这次反查;其余情况照旧按本页店名反查 savings。
|
||||
ordered_shops = page_shops if ordered else _ordered_shop_names(db, user_id, page_shops)
|
||||
# 「本次比价看广告赚的金币」:按本页 trace_id 一次性聚合(同 ordered 范式)。
|
||||
ad_coins = _ad_coins_by_trace(db, user_id, [it.trace_id for it in items])
|
||||
for it in items:
|
||||
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.coupon_state import (
|
||||
CouponClaimEvent,
|
||||
CouponClaimRecord,
|
||||
CouponDailyCompletion,
|
||||
CouponPromptEngagement,
|
||||
@@ -164,11 +165,12 @@ def record_claims(
|
||||
results: list[dict],
|
||||
app_env: str | None = None,
|
||||
) -> int:
|
||||
"""一批券领取结果幂等写入,返回写入(新增 + 更新)条数。
|
||||
"""一批券领取结果同时写入每日资产表和逐次事件表。
|
||||
|
||||
results 单项取自 pricebot 的 last_coupon_result / done.coupon_results,识别字段:
|
||||
coupon_id(必需)/ status(必需)/ name / vendor / reason /(display_count)。
|
||||
(device, coupon_id, 今天) 唯一:重复上报同张券走更新(status 以最后一次为准)。
|
||||
- CouponClaimRecord 按 (device, coupon_id, 今天) 幂等,供每日资产口径使用。
|
||||
- CouponClaimEvent 按 (trace_id, coupon_id) 幂等,供 admin 逐场统计使用。
|
||||
"""
|
||||
today = today_cn()
|
||||
written = 0
|
||||
@@ -208,6 +210,41 @@ def record_claims(
|
||||
claimed_count=count, trace_id=trace_id, reason=r.get("reason"),
|
||||
extra=r,
|
||||
))
|
||||
if trace_id:
|
||||
event = db.execute(
|
||||
select(CouponClaimEvent).where(
|
||||
CouponClaimEvent.trace_id == trace_id,
|
||||
CouponClaimEvent.coupon_id == coupon_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if event is not None:
|
||||
event.device_id = device_id
|
||||
event.status = status
|
||||
event.reason = r.get("reason")
|
||||
event.vendor = r.get("vendor")
|
||||
event.coupon_name = r.get("name")
|
||||
event.extra = r
|
||||
if user_id is not None:
|
||||
event.user_id = user_id
|
||||
if count is not None:
|
||||
event.claimed_count = count
|
||||
if app_env is not None:
|
||||
event.app_env = app_env
|
||||
else:
|
||||
db.add(CouponClaimEvent(
|
||||
trace_id=trace_id,
|
||||
device_id=device_id,
|
||||
user_id=user_id,
|
||||
coupon_id=coupon_id,
|
||||
claim_date=today,
|
||||
status=status,
|
||||
app_env=app_env,
|
||||
vendor=r.get("vendor"),
|
||||
coupon_name=r.get("name"),
|
||||
claimed_count=count,
|
||||
reason=r.get("reason"),
|
||||
extra=r,
|
||||
))
|
||||
written += 1
|
||||
if written == 0:
|
||||
return 0
|
||||
|
||||
@@ -21,17 +21,23 @@ def register_or_update(
|
||||
*,
|
||||
user_id: int,
|
||||
device_id: str,
|
||||
registration_id: str | None,
|
||||
registration_id: str | None = None,
|
||||
push_vendor: str | None = None,
|
||||
push_token: str | None = None,
|
||||
platform: str = "android",
|
||||
app_version: str | None = None,
|
||||
) -> DeviceLiveness:
|
||||
"""注册设备或更新其 registration_id / 元信息。upsert by (user_id, device_id)。"""
|
||||
"""注册设备或更新其厂商 push token / 元信息。upsert by (user_id, device_id)。"""
|
||||
normalized_vendor = _normalize_push_vendor(push_vendor)
|
||||
normalized_token = push_token.strip() if push_token else None
|
||||
device = _get(db, user_id=user_id, device_id=device_id)
|
||||
if device is None:
|
||||
device = DeviceLiveness(
|
||||
user_id=user_id,
|
||||
device_id=device_id,
|
||||
registration_id=registration_id,
|
||||
push_vendor=normalized_vendor,
|
||||
push_token=normalized_token,
|
||||
platform=platform or "android",
|
||||
app_version=app_version,
|
||||
)
|
||||
@@ -39,6 +45,10 @@ def register_or_update(
|
||||
else:
|
||||
if registration_id:
|
||||
device.registration_id = registration_id
|
||||
if normalized_vendor:
|
||||
device.push_vendor = normalized_vendor
|
||||
if normalized_token:
|
||||
device.push_token = normalized_token
|
||||
if platform:
|
||||
device.platform = platform
|
||||
if app_version:
|
||||
@@ -54,7 +64,9 @@ def touch_heartbeat(
|
||||
user_id: int,
|
||||
device_id: str,
|
||||
accessibility_enabled: bool,
|
||||
registration_id: str | None,
|
||||
registration_id: str | None = None,
|
||||
push_vendor: str | None = None,
|
||||
push_token: str | None = None,
|
||||
) -> DeviceLiveness:
|
||||
"""处理一次心跳(心跳也能自注册)。
|
||||
|
||||
@@ -69,6 +81,12 @@ def touch_heartbeat(
|
||||
|
||||
if registration_id:
|
||||
device.registration_id = registration_id
|
||||
normalized_vendor = _normalize_push_vendor(push_vendor)
|
||||
normalized_token = push_token.strip() if push_token else None
|
||||
if normalized_vendor:
|
||||
device.push_vendor = normalized_vendor
|
||||
if normalized_token:
|
||||
device.push_token = normalized_token
|
||||
device.last_report_protection_on = accessibility_enabled
|
||||
|
||||
if accessibility_enabled:
|
||||
@@ -87,7 +105,7 @@ def touch_heartbeat(
|
||||
def list_overdue(db: Session, *, timeout_minutes: int) -> list[DeviceLiveness]:
|
||||
"""掉线设备:曾经保护过、当前 alive、心跳超时。
|
||||
|
||||
本期只做终端打印检测、不推送 → 不再要求有 registration_id(没接极光 token 的设备也要检出)。
|
||||
即使没有厂商 token 也要检出,后续由 kill_alert_pending 走客户端进 App 后兜底提醒。
|
||||
"""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=timeout_minutes)
|
||||
stmt = select(DeviceLiveness).where(
|
||||
@@ -124,3 +142,55 @@ def ack_kill_alert(db: Session, *, user_id: int, device_id: str) -> None:
|
||||
if device is not None and device.kill_alert_pending:
|
||||
device.kill_alert_pending = False
|
||||
db.commit()
|
||||
|
||||
|
||||
def has_push_target(device: DeviceLiveness | None) -> bool:
|
||||
"""是否已有厂商直推所需的 vendor + token。"""
|
||||
return bool(device and device.push_vendor and device.push_token)
|
||||
|
||||
|
||||
def list_push_targets(db: Session, *, user_id: int) -> list[DeviceLiveness]:
|
||||
"""该用户全部可用厂商推送目标(push_vendor + push_token 双非空),最近更新在前。
|
||||
|
||||
同 (vendor, token) 只留最新一行:同一台手机重装 App 后 device_id 会变、
|
||||
留下 token 相同的旧行,去重防一次业务事件对同一台手机重复推送。
|
||||
"""
|
||||
stmt = (
|
||||
select(DeviceLiveness)
|
||||
.where(
|
||||
DeviceLiveness.user_id == user_id,
|
||||
DeviceLiveness.push_vendor.is_not(None),
|
||||
DeviceLiveness.push_token.is_not(None),
|
||||
)
|
||||
.order_by(DeviceLiveness.updated_at.desc(), DeviceLiveness.id.desc())
|
||||
)
|
||||
seen: set[tuple[str, str]] = set()
|
||||
targets: list[DeviceLiveness] = []
|
||||
for dev in db.execute(stmt).scalars():
|
||||
if not dev.push_vendor or not dev.push_token: # 空串兜底(旧数据)
|
||||
continue
|
||||
key = (dev.push_vendor, dev.push_token)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
targets.append(dev)
|
||||
return targets
|
||||
|
||||
|
||||
def _normalize_push_vendor(push_vendor: str | None) -> str | None:
|
||||
if not push_vendor:
|
||||
return None
|
||||
vendor = push_vendor.strip().lower()
|
||||
aliases = {
|
||||
"honor": "honor",
|
||||
"hihonor": "honor",
|
||||
"荣耀": "honor",
|
||||
"vivo": "vivo",
|
||||
"xiaomi": "xiaomi",
|
||||
"mi": "xiaomi",
|
||||
"小米": "xiaomi",
|
||||
"oppo": "oppo",
|
||||
"oneplus": "oppo",
|
||||
"realme": "oppo",
|
||||
}
|
||||
return aliases.get(vendor, vendor)
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""新手引导视频:运营配置读写 + 播放计次 + 发币。
|
||||
|
||||
**配置**(开关 / 视频地址 / 前几次 / 每次金币)整体作为一个 JSON 存进通用 app_config 表
|
||||
(key=coupon_guide_video),写法完全对齐 feedback_qr —— 不进 CONFIG_DEFS,所以不会污染
|
||||
系统配置页的通用列表,由本模块独占维护。
|
||||
|
||||
**计次**按账号(user_id)、**开播即计数**:客户端每次要展示领券等候浮层时调
|
||||
`/api/v1/guide-video/start`,命中则当场写一行 guide_video_play(status='playing')。
|
||||
已用次数 = 该账号的行数,达到 max_plays 后不再下发,客户端改放广告(原逻辑)。
|
||||
COUNT 判定本身无锁,真正卡住次数上限的是 (user_id, seq) 唯一键:并发 /start 只能成一个。
|
||||
|
||||
**发币**幂等键是 play_token,落地方式是 `status='playing' → 'granted'` 的**条件更新**:
|
||||
同一次播放重复上报只入账一次(网络重试 / 关闭与播完同时触发都靠它挡住)。
|
||||
中途关闭也照发 —— 产品拍板「中途关闭也算看完」。
|
||||
|
||||
两处都是直接铸币的路径,改动前先看 `start_play` / `grant_play` 上的并发注释。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.models.app_config import AppConfig
|
||||
from app.models.guide_video import GuideVideoPlay
|
||||
from app.repositories import wallet as crud_wallet
|
||||
|
||||
_KEY = "coupon_guide_video"
|
||||
|
||||
#: 金币流水 biz_type。客户端收益明细按它显示「新手引导视频奖励」。
|
||||
BIZ_TYPE = "guide_video"
|
||||
|
||||
# 默认值 = 「运营还没配」时的行为:video_url 为空 → 一律不下发引导视频,浮层维持现状(放广告)。
|
||||
# 所以本功能上线后**不配视频就等于没上线**,不会影响存量用户。
|
||||
_DEFAULTS: dict[str, Any] = {
|
||||
"enabled": True,
|
||||
"video_url": None, # None/空 = 未配片 → 不下发,浮层照旧放广告
|
||||
"max_plays": 3, # 每个账号前 N 次浮层放引导视频
|
||||
"reward_coin": 120, # 每次固定金币
|
||||
}
|
||||
|
||||
_FIELDS = tuple(_DEFAULTS.keys())
|
||||
|
||||
# 后台可配范围的护栏:防手滑把次数/金币填成天文数字(配置直接决定发币)。
|
||||
MAX_PLAYS_LIMIT = 50
|
||||
REWARD_COIN_LIMIT = 10_000
|
||||
|
||||
|
||||
# ===== 配置 =====
|
||||
|
||||
|
||||
def _merge(raw: Any) -> dict[str, Any]:
|
||||
"""DB 里(可能不全的)dict 叠加到默认上,得到完整配置(4 个字段,无 updated_at)。"""
|
||||
out = dict(_DEFAULTS)
|
||||
if isinstance(raw, dict):
|
||||
for k in _FIELDS:
|
||||
v = raw.get(k)
|
||||
if v is not None:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def get_config(db: Session) -> dict[str, Any]:
|
||||
"""完整配置 + updated_at(admin 读 / 业务读共用)。"""
|
||||
row = db.get(AppConfig, _KEY)
|
||||
cfg = _merge(row.value if row is not None else None)
|
||||
cfg["updated_at"] = row.updated_at.isoformat() if row is not None and row.updated_at else None
|
||||
return cfg
|
||||
|
||||
|
||||
def _write(db: Session, value: dict[str, Any], *, admin_id: int, commit: bool) -> dict[str, Any]:
|
||||
"""整体覆写该行(value 须为完整字段 dict),返回合并后的完整配置(含 updated_at)。"""
|
||||
row = db.get(AppConfig, _KEY)
|
||||
if row is None:
|
||||
row = AppConfig(key=_KEY, value=value, updated_by_admin_id=admin_id)
|
||||
db.add(row)
|
||||
else:
|
||||
row.value = value # 整体重新赋值,SQLAlchemy 才侦测得到变更
|
||||
row.updated_by_admin_id = admin_id
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
else:
|
||||
db.flush()
|
||||
out = _merge(row.value)
|
||||
out["updated_at"] = row.updated_at.isoformat() if row.updated_at else None
|
||||
return out
|
||||
|
||||
|
||||
def update_config(
|
||||
db: Session,
|
||||
*,
|
||||
enabled: bool | None = None,
|
||||
max_plays: int | None = None,
|
||||
reward_coin: int | None = None,
|
||||
admin_id: int,
|
||||
commit: bool = True,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""改开关 / 次数 / 金币(只改传了的字段;视频走 set_video)。返回 (before, after) 供审计。"""
|
||||
row = db.get(AppConfig, _KEY)
|
||||
before = _merge(row.value if row is not None else None)
|
||||
new_value = {k: before[k] for k in _FIELDS}
|
||||
if enabled is not None:
|
||||
new_value["enabled"] = enabled
|
||||
if max_plays is not None:
|
||||
new_value["max_plays"] = max(0, min(int(max_plays), MAX_PLAYS_LIMIT))
|
||||
if reward_coin is not None:
|
||||
new_value["reward_coin"] = max(0, min(int(reward_coin), REWARD_COIN_LIMIT))
|
||||
after = _write(db, new_value, admin_id=admin_id, commit=commit)
|
||||
return before, after
|
||||
|
||||
|
||||
def set_video(
|
||||
db: Session, video_url: str | None, *, admin_id: int, commit: bool = True
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""设置/清空引导视频地址。返回 (before, after);before['video_url'] 供调用方删旧文件。"""
|
||||
row = db.get(AppConfig, _KEY)
|
||||
before = _merge(row.value if row is not None else None)
|
||||
new_value = {k: before[k] for k in _FIELDS}
|
||||
new_value["video_url"] = video_url
|
||||
after = _write(db, new_value, admin_id=admin_id, commit=commit)
|
||||
return before, after
|
||||
|
||||
|
||||
# ===== 播放计次 =====
|
||||
|
||||
|
||||
def used_plays(db: Session, user_id: int) -> int:
|
||||
"""该账号已用掉的引导视频次数(开播即算,含未发币的)。"""
|
||||
return int(
|
||||
db.execute(
|
||||
select(func.count()).select_from(GuideVideoPlay).where(
|
||||
GuideVideoPlay.user_id == user_id
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def play_stats(db: Session) -> dict[str, int]:
|
||||
"""全站播放统计(admin 页展示):总播放次数 / 其中已发币次数。"""
|
||||
total = int(
|
||||
db.execute(select(func.count()).select_from(GuideVideoPlay)).scalar_one()
|
||||
)
|
||||
granted = int(
|
||||
db.execute(
|
||||
select(func.count()).select_from(GuideVideoPlay).where(
|
||||
GuideVideoPlay.status == "granted"
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
return {"total_plays": total, "granted_plays": granted}
|
||||
|
||||
|
||||
def start_play(
|
||||
db: Session, user_id: int, *, scene: str = "coupon", commit: bool = True
|
||||
) -> dict[str, Any]:
|
||||
"""决定这次浮层是否放引导视频;命中则**当场计次**并返回 play_token。
|
||||
|
||||
返回 dict:
|
||||
should_play 是否放引导视频(False → 客户端照旧放广告)
|
||||
video_url 相对地址(/media/...);客户端自行拼 BASE_URL
|
||||
play_token 发币幂等键(should_play=False 时为空串)
|
||||
reward_coin 播完/中途关闭都发的固定金币
|
||||
seq / remaining 第几次 / 发完这次还剩几次(仅展示与排查用)
|
||||
"""
|
||||
cfg = get_config(db)
|
||||
video_url = (cfg.get("video_url") or "").strip()
|
||||
max_plays = int(cfg.get("max_plays") or 0)
|
||||
reward_coin = int(cfg.get("reward_coin") or 0)
|
||||
used = used_plays(db, user_id)
|
||||
|
||||
def _miss(used_now: int) -> dict[str, Any]:
|
||||
return {
|
||||
"should_play": False,
|
||||
"video_url": None,
|
||||
"play_token": "",
|
||||
"reward_coin": reward_coin,
|
||||
"seq": used_now,
|
||||
"remaining": max(0, max_plays - used_now),
|
||||
}
|
||||
|
||||
if not cfg.get("enabled") or not video_url or max_plays <= 0 or used >= max_plays:
|
||||
return _miss(used)
|
||||
|
||||
seq = used + 1
|
||||
play = GuideVideoPlay(
|
||||
user_id=user_id,
|
||||
play_token=uuid.uuid4().hex,
|
||||
scene=scene,
|
||||
seq=seq,
|
||||
video_url=video_url,
|
||||
coin=0,
|
||||
status="playing",
|
||||
completed=0,
|
||||
started_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
|
||||
)
|
||||
db.add(play)
|
||||
# 上面的 COUNT 判定是无锁 check-then-insert:并发 /start 会都算出同一个 seq。
|
||||
# (user_id, seq) 唯一键让只有一个能落库,其余撞键 → 回滚后按"这次不放视频"降级,
|
||||
# 客户端照旧走广告链路。没有它,并发就能绕过 max_plays 无限刷金币。
|
||||
try:
|
||||
if commit:
|
||||
db.commit()
|
||||
else:
|
||||
db.flush()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
return _miss(used_plays(db, user_id))
|
||||
return {
|
||||
"should_play": True,
|
||||
"video_url": video_url,
|
||||
"play_token": play.play_token,
|
||||
"reward_coin": reward_coin,
|
||||
"seq": seq,
|
||||
"remaining": max(0, max_plays - seq),
|
||||
}
|
||||
|
||||
|
||||
def _find_play(db: Session, user_id: int, token: str) -> GuideVideoPlay | None:
|
||||
"""按 (play_token, user_id) 取播放行 —— 带 user_id 是防拿别人的 token 来兑。"""
|
||||
return db.execute(
|
||||
select(GuideVideoPlay).where(
|
||||
GuideVideoPlay.play_token == token,
|
||||
GuideVideoPlay.user_id == user_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def grant_play(
|
||||
db: Session, user_id: int, *, play_token: str, completed: bool
|
||||
) -> dict[str, Any]:
|
||||
"""按 play_token 发这次引导视频的金币(幂等)。播完 / 中途关闭都发。
|
||||
|
||||
返回 {granted, coin, status}:granted=True 表示**本次调用真的入账了**;
|
||||
重复上报返回 granted=False + 已发金币(客户端据此不重复累加 toast 金额)。
|
||||
"""
|
||||
token = (play_token or "").strip()
|
||||
# 金币额度以**服务端配置**为准,不信客户端(客户端只上报"播完/关闭")。
|
||||
coin = int(get_config(db).get("reward_coin") or 0)
|
||||
|
||||
# 幂等核心:把 status 放进 WHERE 做条件更新(compare-and-set),而不是"先读再判再写"。
|
||||
# 「播完」与「✕ 关闭」抢跑、或客户端超时重试时,两个请求会都读到 status='playing',
|
||||
# 无锁的话就都往下发币、都 commit,金币入账两次(不用恶意,重试就会中招)。改成条件更新后
|
||||
# 并发里只有一条 rowcount=1,另一条拿 0 → 按已发返回,不二次铸币。
|
||||
# (PG READ COMMITTED 下后到的 UPDATE 阻塞到对手提交,再按新版本重判 status;SQLite 写串行。)
|
||||
#
|
||||
# 别指望 IntegrityError 兜底:这里只 UPDATE 不 INSERT,撞不到 uq_guide_video_play_token;
|
||||
# 而 biz_type='guide_video' 的金币流水也不在 ux_coin_transaction_task_ref 的谓词
|
||||
# (biz_type LIKE 'task%')覆盖范围内 —— 两个唯一键在这条路径上都是不生效的。
|
||||
won = db.execute(
|
||||
update(GuideVideoPlay)
|
||||
.where(
|
||||
GuideVideoPlay.play_token == token,
|
||||
GuideVideoPlay.user_id == user_id,
|
||||
GuideVideoPlay.status == "playing",
|
||||
)
|
||||
.values(
|
||||
status="granted",
|
||||
coin=coin,
|
||||
completed=1 if completed else 0,
|
||||
granted_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
).rowcount
|
||||
|
||||
if not won:
|
||||
# 没抢到:token 不存在 / 不是本人的 / 已被另一次上报发过。回滚拿干净快照再区分两者
|
||||
# (对手此时必然已提交 —— 我们就是被它挡下的,所以读得到它写的 coin)。
|
||||
db.rollback()
|
||||
play = _find_play(db, user_id, token)
|
||||
if play is None:
|
||||
return {"granted": False, "coin": 0, "status": "not_found"}
|
||||
return {"granted": False, "coin": play.coin, "status": "already_granted"}
|
||||
|
||||
if coin > 0:
|
||||
crud_wallet.grant_coins(
|
||||
db,
|
||||
user_id,
|
||||
coin,
|
||||
biz_type=BIZ_TYPE,
|
||||
ref_id=token,
|
||||
remark="新手引导视频奖励",
|
||||
)
|
||||
db.commit()
|
||||
return {"granted": True, "coin": coin, "status": "granted"}
|
||||
@@ -25,6 +25,7 @@ from app.models.invite import InviteRelation
|
||||
from app.models.invite_fingerprint import InviteFingerprint
|
||||
from app.models.user import User
|
||||
from app.repositories import wallet as crud_wallet
|
||||
from app.services import notification_events
|
||||
|
||||
# 邀请码字符集:去掉易混字符(0/O/1/I/L/B/8/S/5/Z/2),用户口述/手输不易错
|
||||
_CODE_ALPHABET = "ACDEFGHJKMNPQRTUVWXY34679"
|
||||
@@ -197,12 +198,13 @@ def try_reward_on_compare(db: Session, invitee_user_id: int) -> CompareRewardRes
|
||||
return CompareRewardResult("inviter_inactive", rel.inviter_user_id)
|
||||
|
||||
reward = rewards.INVITE_COMPARE_REWARD_CENTS
|
||||
inviter_id = inviter.id
|
||||
rel.compare_reward_granted = True
|
||||
rel.compare_reward_cents = reward
|
||||
rel.compare_rewarded_at = datetime.now(timezone.utc)
|
||||
# 发邀请奖励金到邀请人的独立账户(与金币隔离),ref_id 指向被邀请人便于对账
|
||||
crud_wallet.grant_invite_cash(
|
||||
db, inviter.id, reward,
|
||||
db, inviter_id, reward,
|
||||
biz_type="invite_reward", ref_id=str(invitee_user_id), remark="好友比价奖励",
|
||||
)
|
||||
try:
|
||||
@@ -210,7 +212,11 @@ def try_reward_on_compare(db: Session, invitee_user_id: int) -> CompareRewardRes
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
return CompareRewardResult("granted", inviter.id, reward)
|
||||
# PRD #12 好友下单到账:发奖已 commit,通知邀请人(站内 + push;失败只 log 不影响发奖)
|
||||
notification_events.notify_invite_order_reward(
|
||||
db, inviter_user_id=inviter_id, invitee_user_id=invitee_user_id, cash_cents=reward
|
||||
)
|
||||
return CompareRewardResult("granted", inviter_id, reward)
|
||||
|
||||
|
||||
def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
"""消息通知中心 数据仓库(落库版,查/写 `notification` 表)。
|
||||
|
||||
沿用原 notification_mock 的同名函数(list_notifications / unread_count / mark_read /
|
||||
insert_sample),由内存 mock 迁到落库,**API 契约不变**。
|
||||
|
||||
- 读:按 user 隔离、sent_at 倒序;未读数 / 标记已读同口径。
|
||||
- 写:`create_notification` 是落库统一入口。**业务事件请走 services/notification_events**
|
||||
(站内消息 + 厂商 push 一起下发,已接入提现回执/反馈审核/爆料通过/好友下单);
|
||||
`build_sample_card` / `insert_sample` 按类型造样例内容,供
|
||||
`/api/v1/push/test` 的 createNotification 做「push → 站内已读联动」联调。
|
||||
|
||||
排序规则:全列表按 sent_at 倒序(最新在前;同秒再按 id 倒序稳定化),不分组。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import notification_catalog as catalog
|
||||
from app.models.notification import Notification
|
||||
|
||||
# 北京时间:sent_at 统一带 +08:00 下发,前端直接按本地时区渲染「今天/昨天/M月D日」。
|
||||
_CST = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def cash_yuan(cents: int | None) -> str | None:
|
||||
"""分 → 保留两位小数的元字符串(PRD §3:现金/提现金额保留两位小数)。"""
|
||||
if cents is None:
|
||||
return None
|
||||
return f"{cents // 100}.{cents % 100:02d}"
|
||||
|
||||
|
||||
def as_cst(dt: datetime) -> datetime:
|
||||
"""把库里取出的时间归一到北京时间(+08:00)再下发,保证接口 sentAt 恒带 +08:00。
|
||||
|
||||
SQLite 的 DateTime 不存时区,取出为 naive(存的就是写入时的 CST 墙上时间)→ 直接贴 +08:00;
|
||||
PostgreSQL 的 timestamptz 取出为 aware(通常 UTC)→ 转到 +08:00。两端下发口径一致。
|
||||
"""
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=_CST)
|
||||
return dt.astimezone(_CST)
|
||||
|
||||
|
||||
def _fmt_time(dt: datetime) -> str:
|
||||
"""信息行里「到账时间」等 value 的展示格式。"""
|
||||
return dt.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 读:列表 / 未读数 / 标记已读
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _unread_count(db: Session, user_id: int) -> int:
|
||||
return int(
|
||||
db.execute(
|
||||
select(func.count())
|
||||
.select_from(Notification)
|
||||
.where(Notification.user_id == user_id, Notification.is_read.is_(False))
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def list_notifications(
|
||||
db: Session, user_id: int, *, page: int, page_size: int
|
||||
) -> tuple[list[Notification], int, int]:
|
||||
"""分页取通知列表。返回 (当前页条目, 总条数, 未读条数)。"""
|
||||
total = int(
|
||||
db.execute(
|
||||
select(func.count())
|
||||
.select_from(Notification)
|
||||
.where(Notification.user_id == user_id)
|
||||
).scalar_one()
|
||||
)
|
||||
unread = _unread_count(db, user_id)
|
||||
rows = (
|
||||
db.execute(
|
||||
select(Notification)
|
||||
.where(Notification.user_id == user_id)
|
||||
.order_by(Notification.sent_at.desc(), Notification.id.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return list(rows), total, unread
|
||||
|
||||
|
||||
def unread_count(db: Session, user_id: int) -> int:
|
||||
"""未读总数(首页铃铛角标)。"""
|
||||
return _unread_count(db, user_id)
|
||||
|
||||
|
||||
def mark_read(
|
||||
db: Session, user_id: int, *, ids: list[int] | None = None, mark_all: bool = False
|
||||
) -> tuple[int, int]:
|
||||
"""标记已读。mark_all=True 全量清零,否则按 ids 逐条置读(不存在的 id 忽略,幂等)。
|
||||
|
||||
返回 (本次实际由未读→已读的条数, 剩余未读数)。
|
||||
"""
|
||||
if not mark_all:
|
||||
wanted = set(ids or [])
|
||||
if not wanted:
|
||||
return 0, _unread_count(db, user_id)
|
||||
|
||||
stmt = select(Notification).where(
|
||||
Notification.user_id == user_id, Notification.is_read.is_(False)
|
||||
)
|
||||
if not mark_all:
|
||||
stmt = stmt.where(Notification.id.in_(wanted))
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
marked = 0
|
||||
for n in db.execute(stmt).scalars().all():
|
||||
n.is_read = True
|
||||
n.read_at = now
|
||||
marked += 1
|
||||
db.commit()
|
||||
return marked, _unread_count(db, user_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 写:业务下发入口
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_notification(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: int,
|
||||
type_key: str,
|
||||
coins: int | None = None,
|
||||
cash_cents: int | None = None,
|
||||
info_rows: list[dict[str, str]] | None = None,
|
||||
extra: dict[str, str] | None = None,
|
||||
sent_at: datetime | None = None,
|
||||
dedup_key: str | None = None,
|
||||
) -> Notification:
|
||||
"""下发一条站内消息(业务事件统一入口)。type_key 必须是 catalog 的 13 类之一。
|
||||
|
||||
dedup_key 非空时受部分唯一索引约束(同 user+type+dedup_key 未读期间仅一条);
|
||||
需要「同批次/同权限只保留一条未读」的调用方,应捕获 IntegrityError 或先查已存在的未读再决定
|
||||
更新 sent_at,而非重复插入(见 models/notification 的 uq_notification_user_type_dedup)。
|
||||
"""
|
||||
catalog.get_type(type_key) # 校验类型合法(未知类型抛 UnknownNotificationType)
|
||||
row = Notification(
|
||||
user_id=user_id,
|
||||
type=type_key,
|
||||
coins=coins,
|
||||
cash_cents=cash_cents,
|
||||
info_rows=info_rows or [],
|
||||
extra=extra or {},
|
||||
sent_at=sent_at or datetime.now(_CST),
|
||||
dedup_key=dedup_key,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 样例内容(供 /push/test createNotification 联调;文案对齐 PRD §3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _card_reward_expiring(sent_at: datetime, coins: int = 86, cash: int = 1280, days: int = 3) -> dict:
|
||||
return {
|
||||
"coins": coins,
|
||||
"cash_cents": cash,
|
||||
"info_rows": [
|
||||
{
|
||||
"label": "过期说明",
|
||||
"value": f"您有{coins}金币和{cash_yuan(cash)}元现金即将失效,"
|
||||
"完成一次一键领券或一键比价即可激活收益",
|
||||
},
|
||||
{"label": "过期时间", "value": f"{days}天后失效"},
|
||||
],
|
||||
# batchId:同一批次激活成功后不再重复推送(PRD §2 激活逻辑)
|
||||
"extra": {"batchId": f"batch_{sent_at:%Y%m%d}"},
|
||||
}
|
||||
|
||||
|
||||
def _card_reward_expired(sent_at: datetime, coins: int = 35, cash: int = 60) -> dict:
|
||||
return {
|
||||
"coins": coins,
|
||||
"cash_cents": cash,
|
||||
"info_rows": [
|
||||
{
|
||||
"label": "过期说明",
|
||||
"value": f"您的{coins}金币和{cash_yuan(cash)}元现金已失效,"
|
||||
"完成一次一键领券或一键比价可赚取新收益",
|
||||
},
|
||||
{"label": "过期时间", "value": f"已过期 {sent_at.month}月{sent_at.day}日失效"},
|
||||
],
|
||||
"extra": {}, # 点击跳赚钱页(tab),无需参数
|
||||
}
|
||||
|
||||
|
||||
def _card_withdraw_success(sent_at: datetime, cash: int = 50) -> dict:
|
||||
return {
|
||||
"cash_cents": cash,
|
||||
"info_rows": [
|
||||
{"label": "到账账户", "value": "微信钱包"},
|
||||
{"label": "到账时间", "value": _fmt_time(sent_at)},
|
||||
],
|
||||
"extra": {}, # 无跳转,仅消红点
|
||||
}
|
||||
|
||||
|
||||
def _card_withdraw_failed(sent_at: datetime, cash: int = 350, reason: str = "微信零钱未实名") -> dict:
|
||||
return {
|
||||
"cash_cents": cash,
|
||||
"info_rows": [
|
||||
{"label": "失败原因", "value": reason},
|
||||
{"label": "退回说明", "value": "款项已原路退回现金余额"},
|
||||
],
|
||||
"extra": {"withdrawId": "88001"}, # 点击跳提现页
|
||||
}
|
||||
|
||||
|
||||
def _card_permission(permission: str) -> dict:
|
||||
# permission ∈ accessibility(无障碍)/ battery(省电策略)/ autostart(自启动)/ overlay(悬浮窗)
|
||||
# 客户端点击时按此 key 实时检测该权限并弹对应开启弹窗(PRD §2 权限逻辑)。
|
||||
return {
|
||||
"info_rows": [
|
||||
{"label": "说明文案", "value": "未开启将导致核心功能不可用,请尽快开启"},
|
||||
],
|
||||
"extra": {"permission": permission},
|
||||
}
|
||||
|
||||
|
||||
def _card_feedback_reply(feedback_id: str) -> dict:
|
||||
return {
|
||||
"info_rows": [
|
||||
{"label": "说明文案", "value": "快去看看官方给您的回复吧~"},
|
||||
],
|
||||
"extra": {"feedbackId": feedback_id}, # 跳反馈历史页并滚动高亮该条(PRD §2)
|
||||
}
|
||||
|
||||
|
||||
def _card_feedback_reward(sent_at: datetime, coins: int = 300,
|
||||
reply: str = "感谢反馈,您说的问题已经修复上线,送您的金币请查收~") -> dict:
|
||||
return {
|
||||
"coins": coins,
|
||||
"info_rows": [
|
||||
{"label": "奖励说明", "value": "感谢您的反馈,您的金币奖励已到账"},
|
||||
{"label": "官方留言", "value": reply}, # PRD §3:官方留言必填(发奖励必带留言)
|
||||
{"label": "到账时间", "value": _fmt_time(sent_at)},
|
||||
],
|
||||
"extra": {"feedbackId": "3002"},
|
||||
}
|
||||
|
||||
|
||||
def _card_report_approved(sent_at: datetime, coins: int = 1000, store: str = "蜀大侠火锅") -> dict:
|
||||
return {
|
||||
"coins": coins,
|
||||
"info_rows": [
|
||||
{"label": "奖励说明", "value": f"您爆料的「{store}」更低价已通过审核,金币奖励已到账"},
|
||||
{"label": "到账时间", "value": _fmt_time(sent_at)},
|
||||
],
|
||||
"extra": {"reportId": "5001"}, # 跳爆料记录页并滚动高亮该条
|
||||
}
|
||||
|
||||
|
||||
def _card_invite_order_reward(sent_at: datetime, cash: int = 200, nickname: str = "柚子") -> dict:
|
||||
return {
|
||||
"cash_cents": cash,
|
||||
"info_rows": [
|
||||
{"label": "奖励说明", "value": f"好友「{nickname}」完成首次下单"},
|
||||
{"label": "到账时间", "value": _fmt_time(sent_at)},
|
||||
],
|
||||
"extra": {"inviteeNickname": nickname}, # 跳邀请页(welfare/invite.html?from=notifications)
|
||||
}
|
||||
|
||||
|
||||
def _card_invite_remind(nickname: str = "阿泽") -> dict:
|
||||
return {
|
||||
"info_rows": [
|
||||
{
|
||||
"label": "说明文案",
|
||||
"value": f"好友「{nickname}」已注册,还没完成比价下单,提醒TA完成后你可得2元现金",
|
||||
},
|
||||
],
|
||||
# scrollTo=remind:跳邀请页并自动滚动到底部「提醒好友」模块(PRD §2 #13)
|
||||
"extra": {"inviteeNickname": nickname, "scrollTo": "remind"},
|
||||
}
|
||||
|
||||
|
||||
def build_sample_card(type_key: str, sent_at: datetime | None = None) -> dict:
|
||||
"""按类型生成一份样例卡片内容({coins?, cash_cents?, info_rows, extra}),/push/test 联调用。"""
|
||||
catalog.get_type(type_key) # 校验 type 合法
|
||||
now = sent_at or datetime.now(_CST)
|
||||
builders = {
|
||||
"reward_expiring": lambda: _card_reward_expiring(now),
|
||||
"reward_expired": lambda: _card_reward_expired(now),
|
||||
"withdraw_success": lambda: _card_withdraw_success(now),
|
||||
"withdraw_failed": lambda: _card_withdraw_failed(now),
|
||||
"perm_accessibility": lambda: _card_permission("accessibility"),
|
||||
"perm_battery": lambda: _card_permission("battery"),
|
||||
"perm_autostart": lambda: _card_permission("autostart"),
|
||||
"perm_overlay": lambda: _card_permission("overlay"),
|
||||
"feedback_reply": lambda: _card_feedback_reply("3001"),
|
||||
"feedback_reward": lambda: _card_feedback_reward(now),
|
||||
"report_approved": lambda: _card_report_approved(now),
|
||||
"invite_order_reward": lambda: _card_invite_order_reward(now),
|
||||
"invite_remind": lambda: _card_invite_remind(),
|
||||
}
|
||||
return builders[type_key]()
|
||||
|
||||
|
||||
def insert_sample(db: Session, user_id: int, type_key: str) -> Notification:
|
||||
"""插入一条该类型的样例未读通知并落库(/push/test createNotification 联调:push extras 带上
|
||||
它的 id,客户端点击 push 后调 POST /notifications/read {ids:[id]} 即闭环验证已读联动)。"""
|
||||
return create_notification(db, user_id=user_id, type_key=type_key, **build_sample_card(type_key))
|
||||
@@ -0,0 +1,569 @@
|
||||
"""通用行为事件、风险规则与主体限制仓库。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config_schema import (
|
||||
RISK_COMPARE_DAILY_THRESHOLD_KEY,
|
||||
RISK_ONECLICK_DAILY_THRESHOLD_KEY,
|
||||
RISK_SMS_HOURLY_THRESHOLD_KEY,
|
||||
)
|
||||
from app.models.app_config import AppConfig
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.risk import BehaviorEvent, RiskIncident, SubjectRestriction
|
||||
from app.repositories import app_config
|
||||
|
||||
CN_TZ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
RULE_SMS_HOURLY = "sms_device_hourly_5"
|
||||
RULE_ONECLICK_DAILY = "oneclick_device_daily_20"
|
||||
RULE_COMPARE_DAILY = "compare_account_daily_100"
|
||||
|
||||
EVENT_SMS_SEND = "sms_send"
|
||||
EVENT_SMS_LOGIN = "sms_login"
|
||||
EVENT_ONECLICK_LOGIN = "oneclick_login"
|
||||
|
||||
SCOPE_AUTH_DEVICE = "auth_device"
|
||||
SCOPE_ECONOMIC_ACCOUNT = "economic_account"
|
||||
AUTO_RESOLVED_REASON = "规则阈值调整后不再命中"
|
||||
MANUAL_RESET_REASON = "管理员重置报警计数"
|
||||
RISK_RESET_BASELINES_KEY = "risk_monitor_reset_baselines"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuleSpec:
|
||||
code: str
|
||||
event_type: str
|
||||
subject_type: str
|
||||
threshold_key: str
|
||||
window: str
|
||||
count_outcomes: tuple[str, ...]
|
||||
|
||||
|
||||
RULES: dict[str, RuleSpec] = {
|
||||
RULE_SMS_HOURLY: RuleSpec(
|
||||
code=RULE_SMS_HOURLY,
|
||||
event_type=EVENT_SMS_SEND,
|
||||
subject_type="device",
|
||||
threshold_key=RISK_SMS_HOURLY_THRESHOLD_KEY,
|
||||
window="hour",
|
||||
count_outcomes=("success",),
|
||||
),
|
||||
RULE_ONECLICK_DAILY: RuleSpec(
|
||||
code=RULE_ONECLICK_DAILY,
|
||||
event_type=EVENT_ONECLICK_LOGIN,
|
||||
subject_type="device",
|
||||
threshold_key=RISK_ONECLICK_DAILY_THRESHOLD_KEY,
|
||||
window="day",
|
||||
count_outcomes=("success", "failed"),
|
||||
),
|
||||
}
|
||||
|
||||
RULE_THRESHOLD_KEYS: dict[str, str] = {
|
||||
RULE_SMS_HOURLY: RISK_SMS_HOURLY_THRESHOLD_KEY,
|
||||
RULE_ONECLICK_DAILY: RISK_ONECLICK_DAILY_THRESHOLD_KEY,
|
||||
RULE_COMPARE_DAILY: RISK_COMPARE_DAILY_THRESHOLD_KEY,
|
||||
}
|
||||
|
||||
|
||||
def get_rule_threshold(db: Session, rule_code: str) -> int:
|
||||
"""读取规则当前阈值;配置表为空时回退上线前的 5/20/100 默认值。"""
|
||||
return int(app_config.get_value(db, RULE_THRESHOLD_KEYS[rule_code]))
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def _ensure_aware(value: datetime) -> datetime:
|
||||
return value.replace(tzinfo=UTC) if value.tzinfo is None else value
|
||||
|
||||
|
||||
def get_rule_reset_at(db: Session, rule_code: str) -> datetime | None:
|
||||
"""返回规则最近一次全局重置时间;异常旧值按未重置处理。"""
|
||||
row = db.get(AppConfig, RISK_RESET_BASELINES_KEY)
|
||||
if row is None or not isinstance(row.value, dict):
|
||||
return None
|
||||
raw = row.value.get(rule_code)
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return _ensure_aware(parsed).astimezone(UTC)
|
||||
|
||||
|
||||
def reset_rule_baselines(
|
||||
db: Session,
|
||||
*,
|
||||
rule_codes: tuple[str, ...],
|
||||
reset_at: datetime,
|
||||
admin_id: int,
|
||||
) -> None:
|
||||
"""持久化全局重置基线;不删除任何行为或比价流水。"""
|
||||
row = db.get(AppConfig, RISK_RESET_BASELINES_KEY)
|
||||
value = dict(row.value) if row and isinstance(row.value, dict) else {}
|
||||
timestamp = _ensure_aware(reset_at).astimezone(UTC).isoformat()
|
||||
value.update({rule_code: timestamp for rule_code in rule_codes})
|
||||
if row is None:
|
||||
db.add(
|
||||
AppConfig(
|
||||
key=RISK_RESET_BASELINES_KEY,
|
||||
value=value,
|
||||
updated_by_admin_id=admin_id,
|
||||
)
|
||||
)
|
||||
else:
|
||||
row.value = value
|
||||
row.updated_by_admin_id = admin_id
|
||||
db.flush()
|
||||
|
||||
|
||||
def _window_bounds(at: datetime, window: str) -> tuple[str, datetime, datetime]:
|
||||
local = _ensure_aware(at).astimezone(CN_TZ)
|
||||
if window == "hour":
|
||||
start_local = local.replace(minute=0, second=0, microsecond=0)
|
||||
key = start_local.strftime("%Y-%m-%dT%H")
|
||||
end_local = start_local + timedelta(hours=1)
|
||||
elif window == "day":
|
||||
start_local = local.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
key = start_local.strftime("%Y-%m-%d")
|
||||
end_local = start_local + timedelta(days=1)
|
||||
else: # pragma: no cover - 规则声明错误应尽早暴露
|
||||
raise ValueError(f"unsupported risk window: {window}")
|
||||
return key, start_local.astimezone(UTC), end_local.astimezone(UTC)
|
||||
|
||||
|
||||
def _event_stats(
|
||||
db: Session,
|
||||
spec: RuleSpec,
|
||||
*,
|
||||
subject_id: str,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
threshold: int,
|
||||
) -> tuple[int, datetime | None, datetime | None, datetime | None]:
|
||||
filters = (
|
||||
BehaviorEvent.event_type == spec.event_type,
|
||||
BehaviorEvent.subject_type == spec.subject_type,
|
||||
BehaviorEvent.subject_id == subject_id,
|
||||
BehaviorEvent.outcome.in_(spec.count_outcomes),
|
||||
BehaviorEvent.occurred_at >= start,
|
||||
BehaviorEvent.occurred_at < end,
|
||||
)
|
||||
count, first_at, last_at = db.execute(
|
||||
select(
|
||||
func.count(BehaviorEvent.id),
|
||||
func.min(BehaviorEvent.occurred_at),
|
||||
func.max(BehaviorEvent.occurred_at),
|
||||
).where(*filters)
|
||||
).one()
|
||||
triggered_at = None
|
||||
if int(count or 0) >= threshold:
|
||||
triggered_at = db.execute(
|
||||
select(BehaviorEvent.occurred_at)
|
||||
.where(*filters)
|
||||
.order_by(BehaviorEvent.occurred_at.asc(), BehaviorEvent.id.asc())
|
||||
.offset(threshold - 1)
|
||||
.limit(1)
|
||||
).scalar_one()
|
||||
return int(count or 0), first_at, last_at, triggered_at
|
||||
|
||||
|
||||
def _upsert_incident(
|
||||
db: Session,
|
||||
*,
|
||||
rule_code: str,
|
||||
event_type: str,
|
||||
subject_type: str,
|
||||
subject_id: str,
|
||||
window_key: str,
|
||||
window_start: datetime,
|
||||
window_end: datetime,
|
||||
first_event_at: datetime,
|
||||
triggered_at: datetime,
|
||||
last_event_at: datetime,
|
||||
event_count: int,
|
||||
details: dict | None = None,
|
||||
) -> RiskIncident:
|
||||
incident = db.execute(
|
||||
select(RiskIncident).where(
|
||||
RiskIncident.rule_code == rule_code,
|
||||
RiskIncident.subject_type == subject_type,
|
||||
RiskIncident.subject_id == subject_id,
|
||||
RiskIncident.window_key == window_key,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if incident is None:
|
||||
incident = RiskIncident(
|
||||
rule_code=rule_code,
|
||||
event_type=event_type,
|
||||
subject_type=subject_type,
|
||||
subject_id=subject_id,
|
||||
window_key=window_key,
|
||||
window_start=window_start,
|
||||
window_end=window_end,
|
||||
first_event_at=first_event_at,
|
||||
triggered_at=triggered_at,
|
||||
last_event_at=last_event_at,
|
||||
event_count=event_count,
|
||||
status="open",
|
||||
details=details,
|
||||
)
|
||||
# 并发首次命中可能同时插入;保存点只回滚重复 incident,不丢行为流水。
|
||||
try:
|
||||
with db.begin_nested():
|
||||
db.add(incident)
|
||||
db.flush()
|
||||
except IntegrityError:
|
||||
incident = db.execute(
|
||||
select(RiskIncident).where(
|
||||
RiskIncident.rule_code == rule_code,
|
||||
RiskIncident.subject_type == subject_type,
|
||||
RiskIncident.subject_id == subject_id,
|
||||
RiskIncident.window_key == window_key,
|
||||
)
|
||||
).scalar_one()
|
||||
else:
|
||||
reopened = incident.status == "resolved" and incident.action_reason in (
|
||||
AUTO_RESOLVED_REASON,
|
||||
MANUAL_RESET_REASON,
|
||||
)
|
||||
if reopened:
|
||||
incident.status = "open"
|
||||
incident.action_reason = None
|
||||
incident.handled_by = None
|
||||
incident.handled_at = None
|
||||
# 同一自然窗口内重置后会复用唯一 incident;明细窗口必须同步切到新基线,
|
||||
# 否则展开时会把重置前的旧流水也混进来。
|
||||
incident.window_start = window_start
|
||||
incident.window_end = window_end
|
||||
incident.first_event_at = first_event_at
|
||||
if incident.status == "open":
|
||||
incident.triggered_at = triggered_at
|
||||
incident.last_event_at = last_event_at
|
||||
incident.event_count = event_count
|
||||
# 已忽略/封禁的事件只更新事实数据,不重新打开。
|
||||
if details:
|
||||
incident.details = {**(incident.details or {}), **details}
|
||||
return incident
|
||||
|
||||
|
||||
def evaluate_behavior_rule(
|
||||
db: Session, *, rule_code: str, subject_id: str, at: datetime
|
||||
) -> RiskIncident | None:
|
||||
spec = RULES[rule_code]
|
||||
threshold = get_rule_threshold(db, rule_code)
|
||||
window_key, window_start, end = _window_bounds(at, spec.window)
|
||||
reset_at = get_rule_reset_at(db, rule_code)
|
||||
start = max(window_start, reset_at) if reset_at else window_start
|
||||
count, first_at, last_at, triggered_at = _event_stats(
|
||||
db,
|
||||
spec,
|
||||
subject_id=subject_id,
|
||||
start=start,
|
||||
end=end,
|
||||
threshold=threshold,
|
||||
)
|
||||
if count < threshold or first_at is None or last_at is None or triggered_at is None:
|
||||
return None
|
||||
return _upsert_incident(
|
||||
db,
|
||||
rule_code=spec.code,
|
||||
event_type=spec.event_type,
|
||||
subject_type=spec.subject_type,
|
||||
subject_id=subject_id,
|
||||
window_key=window_key,
|
||||
window_start=start,
|
||||
window_end=end,
|
||||
first_event_at=first_at,
|
||||
triggered_at=triggered_at,
|
||||
last_event_at=last_at,
|
||||
event_count=count,
|
||||
)
|
||||
|
||||
|
||||
def reconcile_behavior_rule(
|
||||
db: Session,
|
||||
*,
|
||||
rule_code: str,
|
||||
at: datetime | None = None,
|
||||
commit: bool = True,
|
||||
) -> int:
|
||||
"""按当前阈值重算短信/一键登录当前窗口,并收起已不再命中的待处理告警。"""
|
||||
spec = RULES[rule_code]
|
||||
current = at or utcnow()
|
||||
threshold = get_rule_threshold(db, rule_code)
|
||||
window_key, window_start, end = _window_bounds(current, spec.window)
|
||||
reset_at = get_rule_reset_at(db, rule_code)
|
||||
start = max(window_start, reset_at) if reset_at else window_start
|
||||
filters = (
|
||||
BehaviorEvent.event_type == spec.event_type,
|
||||
BehaviorEvent.subject_type == spec.subject_type,
|
||||
BehaviorEvent.outcome.in_(spec.count_outcomes),
|
||||
BehaviorEvent.occurred_at >= start,
|
||||
BehaviorEvent.occurred_at < end,
|
||||
)
|
||||
qualifying_rows = db.execute(
|
||||
select(
|
||||
BehaviorEvent.subject_id,
|
||||
func.max(BehaviorEvent.occurred_at),
|
||||
)
|
||||
.where(*filters)
|
||||
.group_by(BehaviorEvent.subject_id)
|
||||
.having(func.count(BehaviorEvent.id) >= threshold)
|
||||
).all()
|
||||
qualifying = {str(subject_id) for subject_id, _ in qualifying_rows}
|
||||
for subject_id, last_at in qualifying_rows:
|
||||
evaluate_behavior_rule(
|
||||
db,
|
||||
rule_code=rule_code,
|
||||
subject_id=str(subject_id),
|
||||
at=last_at or current,
|
||||
)
|
||||
|
||||
open_incidents = db.scalars(
|
||||
select(RiskIncident).where(
|
||||
RiskIncident.rule_code == rule_code,
|
||||
RiskIncident.window_key == window_key,
|
||||
RiskIncident.status == "open",
|
||||
)
|
||||
).all()
|
||||
for incident in open_incidents:
|
||||
if incident.subject_id not in qualifying:
|
||||
incident.status = "resolved"
|
||||
incident.action_reason = AUTO_RESOLVED_REASON
|
||||
incident.handled_by = None
|
||||
incident.handled_at = utcnow()
|
||||
if commit:
|
||||
db.commit()
|
||||
return len(qualifying)
|
||||
|
||||
|
||||
def record_behavior_event(
|
||||
db: Session,
|
||||
*,
|
||||
event_type: str,
|
||||
subject_type: str,
|
||||
subject_id: str,
|
||||
user_id: int | None = None,
|
||||
device_id: str | None = None,
|
||||
device_model: str | None = None,
|
||||
phone: str | None = None,
|
||||
client_ip: str | None = None,
|
||||
outcome: str = "success",
|
||||
reason: str | None = None,
|
||||
details: dict | None = None,
|
||||
occurred_at: datetime | None = None,
|
||||
evaluate_rule: str | None = None,
|
||||
commit: bool = True,
|
||||
) -> BehaviorEvent:
|
||||
at = occurred_at or utcnow()
|
||||
event = BehaviorEvent(
|
||||
event_type=event_type,
|
||||
subject_type=subject_type,
|
||||
subject_id=subject_id,
|
||||
user_id=user_id,
|
||||
device_id=device_id,
|
||||
device_model=device_model,
|
||||
phone=phone,
|
||||
client_ip=client_ip,
|
||||
outcome=outcome,
|
||||
reason=reason,
|
||||
details=details,
|
||||
occurred_at=at,
|
||||
)
|
||||
db.add(event)
|
||||
db.flush()
|
||||
if evaluate_rule:
|
||||
evaluate_behavior_rule(db, rule_code=evaluate_rule, subject_id=subject_id, at=at)
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(event)
|
||||
return event
|
||||
|
||||
|
||||
def sync_compare_incident(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: int,
|
||||
at: datetime,
|
||||
threshold: int | None = None,
|
||||
commit: bool = True,
|
||||
) -> RiskIncident | None:
|
||||
effective_threshold = threshold or get_rule_threshold(db, RULE_COMPARE_DAILY)
|
||||
# comparison_record 的既有写入口统一落“北京时间 naive”时间;这里必须沿用同一
|
||||
# 口径,否则 SQLite/PG session timezone 不同时会把凌晨记录算到前一天。
|
||||
local = at.astimezone(CN_TZ).replace(tzinfo=None) if at.tzinfo else at
|
||||
window_start = local.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
end = window_start + timedelta(days=1)
|
||||
window_key = window_start.strftime("%Y-%m-%d")
|
||||
reset_at = get_rule_reset_at(db, RULE_COMPARE_DAILY)
|
||||
reset_local = (
|
||||
reset_at.astimezone(CN_TZ).replace(tzinfo=None) if reset_at else None
|
||||
)
|
||||
start = max(window_start, reset_local) if reset_local else window_start
|
||||
filters = (
|
||||
ComparisonRecord.user_id == user_id,
|
||||
ComparisonRecord.created_at >= start,
|
||||
ComparisonRecord.created_at < end,
|
||||
)
|
||||
count, first_at, last_at = db.execute(
|
||||
select(
|
||||
func.count(ComparisonRecord.id),
|
||||
func.min(ComparisonRecord.created_at),
|
||||
func.max(ComparisonRecord.created_at),
|
||||
).where(*filters)
|
||||
).one()
|
||||
if int(count or 0) < effective_threshold or first_at is None or last_at is None:
|
||||
return None
|
||||
triggered_at = db.execute(
|
||||
select(ComparisonRecord.created_at)
|
||||
.where(*filters)
|
||||
.order_by(ComparisonRecord.created_at.asc(), ComparisonRecord.id.asc())
|
||||
.offset(effective_threshold - 1)
|
||||
.limit(1)
|
||||
).scalar_one()
|
||||
incident = _upsert_incident(
|
||||
db,
|
||||
rule_code=RULE_COMPARE_DAILY,
|
||||
event_type="compare_start",
|
||||
subject_type="user",
|
||||
subject_id=str(user_id),
|
||||
window_key=window_key,
|
||||
window_start=start,
|
||||
window_end=end,
|
||||
first_event_at=first_at,
|
||||
triggered_at=triggered_at,
|
||||
last_event_at=last_at,
|
||||
event_count=int(count),
|
||||
)
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(incident)
|
||||
return incident
|
||||
|
||||
|
||||
def reconcile_compare_rule(
|
||||
db: Session, *, at: datetime | None = None, commit: bool = True
|
||||
) -> int:
|
||||
"""按当前阈值重算北京时间当日比价告警,并收起不再命中的待处理告警。"""
|
||||
current = (at or utcnow()).astimezone(CN_TZ).replace(tzinfo=None)
|
||||
window_start = current.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
end = window_start + timedelta(days=1)
|
||||
window_key = window_start.strftime("%Y-%m-%d")
|
||||
reset_at = get_rule_reset_at(db, RULE_COMPARE_DAILY)
|
||||
reset_local = (
|
||||
reset_at.astimezone(CN_TZ).replace(tzinfo=None) if reset_at else None
|
||||
)
|
||||
start = max(window_start, reset_local) if reset_local else window_start
|
||||
threshold = get_rule_threshold(db, RULE_COMPARE_DAILY)
|
||||
rows = db.execute(
|
||||
select(
|
||||
ComparisonRecord.user_id,
|
||||
func.max(ComparisonRecord.created_at),
|
||||
)
|
||||
.where(
|
||||
ComparisonRecord.user_id.is_not(None),
|
||||
ComparisonRecord.created_at >= start,
|
||||
ComparisonRecord.created_at < end,
|
||||
)
|
||||
.group_by(ComparisonRecord.user_id)
|
||||
.having(func.count(ComparisonRecord.id) >= threshold)
|
||||
).all()
|
||||
qualifying = {str(user_id) for user_id, _ in rows}
|
||||
for user_id, last_at in rows:
|
||||
sync_compare_incident(
|
||||
db,
|
||||
user_id=int(user_id),
|
||||
at=last_at or current,
|
||||
threshold=threshold,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
open_incidents = db.scalars(
|
||||
select(RiskIncident).where(
|
||||
RiskIncident.rule_code == RULE_COMPARE_DAILY,
|
||||
RiskIncident.window_key == window_key,
|
||||
RiskIncident.status == "open",
|
||||
)
|
||||
).all()
|
||||
for incident in open_incidents:
|
||||
if incident.subject_id not in qualifying:
|
||||
incident.status = "resolved"
|
||||
incident.action_reason = AUTO_RESOLVED_REASON
|
||||
incident.handled_by = None
|
||||
incident.handled_at = utcnow()
|
||||
if commit:
|
||||
db.commit()
|
||||
return len(qualifying)
|
||||
|
||||
|
||||
def get_active_restriction(
|
||||
db: Session, *, subject_type: str, subject_id: str, scope: str
|
||||
) -> SubjectRestriction | None:
|
||||
return db.execute(
|
||||
select(SubjectRestriction).where(
|
||||
SubjectRestriction.subject_type == subject_type,
|
||||
SubjectRestriction.subject_id == subject_id,
|
||||
SubjectRestriction.scope.in_((scope, "all")),
|
||||
SubjectRestriction.active.is_(True),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def is_restricted(db: Session, *, subject_type: str, subject_id: str, scope: str) -> bool:
|
||||
return get_active_restriction(
|
||||
db, subject_type=subject_type, subject_id=subject_id, scope=scope
|
||||
) is not None
|
||||
|
||||
|
||||
def activate_restriction(
|
||||
db: Session,
|
||||
*,
|
||||
subject_type: str,
|
||||
subject_id: str,
|
||||
scope: str,
|
||||
reason: str,
|
||||
incident_id: int | None,
|
||||
admin_id: int,
|
||||
) -> SubjectRestriction:
|
||||
restriction = db.execute(
|
||||
select(SubjectRestriction).where(
|
||||
SubjectRestriction.subject_type == subject_type,
|
||||
SubjectRestriction.subject_id == subject_id,
|
||||
SubjectRestriction.scope == scope,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if restriction is None:
|
||||
restriction = SubjectRestriction(
|
||||
subject_type=subject_type,
|
||||
subject_id=subject_id,
|
||||
scope=scope,
|
||||
)
|
||||
db.add(restriction)
|
||||
restriction.active = True
|
||||
restriction.reason = reason
|
||||
restriction.incident_id = incident_id
|
||||
restriction.created_by = admin_id
|
||||
restriction.created_at = utcnow()
|
||||
restriction.revoked_by = None
|
||||
restriction.revoked_at = None
|
||||
db.flush()
|
||||
return restriction
|
||||
|
||||
|
||||
def revoke_restriction(
|
||||
db: Session, *, restriction: SubjectRestriction, admin_id: int
|
||||
) -> None:
|
||||
restriction.active = False
|
||||
restriction.revoked_by = admin_id
|
||||
restriction.revoked_at = utcnow()
|
||||
db.flush()
|
||||
+149
-31
@@ -6,6 +6,7 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
import uuid
|
||||
@@ -20,7 +21,6 @@ from app.core.config import settings
|
||||
from app.core.rewards import COIN_PER_CENT, coins_to_cents
|
||||
from app.integrations import wxpay
|
||||
from app.models.user import User
|
||||
from app.repositories.user import apply_wechat_display_identity
|
||||
from app.models.wallet import (
|
||||
CashTransaction,
|
||||
CoinAccount,
|
||||
@@ -29,12 +29,55 @@ from app.models.wallet import (
|
||||
WechatTransferAuthorization,
|
||||
WithdrawOrder,
|
||||
)
|
||||
from app.repositories.user import apply_wechat_display_identity
|
||||
from app.services import notification_events
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 微信转账终态:成功 / 失败(失败/取消/关闭都退款)
|
||||
_WX_STATE_SUCCESS = "SUCCESS"
|
||||
_WX_STATE_FAILED = {"FAIL", "CANCELLED", "CLOSED"}
|
||||
_WX_STATE_WAIT_CONFIRM = "WAIT_USER_CONFIRM" # 用户还没在微信确认页确认
|
||||
_WITHDRAW_ACTIVE_STATUSES = {"reviewing", "pending"}
|
||||
_WX_FAIL_REASON_LABELS = {
|
||||
"ACCOUNT_FROZEN": "用户微信账户被冻结",
|
||||
"ACCOUNT_NOT_EXIST": "用户微信账户不存在",
|
||||
"BANK_CARD_ACCOUNT_ABNORMAL": "用户银行卡已销户、冻结、作废或挂失",
|
||||
"BANK_CARD_BANK_INFO_WRONG": "用户登记的银行或分支行信息有误",
|
||||
"BANK_CARD_CARD_INFO_WRONG": "用户银行卡户名或卡号有误",
|
||||
"BANK_CARD_COLLECTIONS_ABOVE_QUOTA": "用户银行卡收款达到限额",
|
||||
"BANK_CARD_PARAM_ERROR": "用户收款银行卡信息错误",
|
||||
"BANK_CARD_STATUS_ABNORMAL": "用户银行卡状态异常",
|
||||
"BLOCK_B2C_USERLIMITAMOUNT_BSRULE_MONTH": "用户本月转账收款已达限额",
|
||||
"BLOCK_B2C_USERLIMITAMOUNT_MONTH": "用户账户存在风险,本月收款受限",
|
||||
"DAY_RECEIVED_COUNT_EXCEED": "用户当日收款次数已达上限",
|
||||
"DAY_RECEIVED_QUOTA_EXCEED": "用户当日收款额度已达上限",
|
||||
"EXCEEDED_ESTIMATED_AMOUNT": "转账金额超过预约金额范围",
|
||||
"ID_CARD_NOT_CORRECT": "收款人身份证校验不通过",
|
||||
"MCH_CANCEL": "商户已撤销付款",
|
||||
"MERCHANT_REJECT": "商户转账验密人已驳回",
|
||||
"MERCHANT_NOT_CONFIRM": "商户转账验密人超时未确认",
|
||||
"NAME_NOT_CORRECT": "收款人姓名校验不通过",
|
||||
"OPENID_INVALID": "用户 OpenID 无效或不属于当前 AppID",
|
||||
"OTHER_FAIL_REASON_TYPE": "微信返回其他失败原因",
|
||||
"OVERDUE_CLOSE": "超过微信系统重试期,订单自动关闭",
|
||||
"PAYEE_ACCOUNT_ABNORMAL": "用户微信账户收款异常",
|
||||
"PAYER_ACCOUNT_ABNORMAL": "商户账户付款受限",
|
||||
"PRODUCT_AUTH_CHECK_FAIL": "商户未开通转账权限或权限已冻结",
|
||||
"REALNAME_ACCOUNT_RECEIVED_QUOTA_EXCEED": "用户微信实名账户收款受限",
|
||||
"REAL_NAME_CHECK_FAIL": "用户未完成微信实名认证",
|
||||
"RECEIVE_ACCOUNT_NOT_CONFIGURE": "商户未配置收款用户列表",
|
||||
"RESERVATION_INFO_NOT_MATCH": "转账信息与预约信息不一致",
|
||||
"RESERVATION_SCENE_NOT_MATCH": "转账场景与预约场景不一致",
|
||||
"RESERVATION_STATE_INVALID": "预约转账单状态异常",
|
||||
"TRANSFER_QUOTA_EXCEED": "用户单笔收款额度已达上限",
|
||||
"TRANSFER_REMARK_SET_FAIL": "微信转账备注设置失败",
|
||||
"TRANSFER_RISK": "该笔转账存在风险,已被微信拦截",
|
||||
"TRANSFER_SCENE_INVALID": "商户未获取当前转账场景",
|
||||
"TRANSFER_SCENE_UNAVAILABLE": "当前转账场景暂不可用",
|
||||
"RELATED_ORDER_TRANSFER_AMOUNT_EXCEED": "关联订单累计付款金额超过上限",
|
||||
"RELATED_ORDER_TRANSFER_COUNT_EXCEED": "关联订单累计付款次数超过上限",
|
||||
"BUDGET_NOT_ENOUGH": "商户预算资金不足",
|
||||
}
|
||||
# 占用新人档「一次性」资格的提现状态:进行中(reviewing/pending)或成功打款(success)。
|
||||
# 被拒/转账失败/解绑退回(rejected/failed,均已退款、钱没到手)不在此列 → 新人档恢复可提
|
||||
# (2026-07-16 修正:此前判定不看状态,解绑微信退回后 0.1 被误判已用、资格永久锁死)。
|
||||
@@ -68,10 +111,6 @@ class InsufficientCashError(Exception):
|
||||
"""现金余额不足。"""
|
||||
|
||||
|
||||
class WithdrawTooFrequentError(Exception):
|
||||
"""提现申请过于频繁,或已有未完成提现单。"""
|
||||
|
||||
|
||||
class WithdrawTierUnavailableError(Exception):
|
||||
"""该档位今日不可提:次数已满,或今天已选了其他额度(7-9 福利页档位规则)。"""
|
||||
|
||||
@@ -483,6 +522,19 @@ def _try_deduct_cash(db: Session, user_id: int, amount_cents: int, source: str =
|
||||
|
||||
def _add_cash(db: Session, user_id: int, amount_cents: int, source: str = "coin_cash") -> int:
|
||||
"""原子增加指定账户余额(退款用),返回加后余额。source 决定退回哪个账户(两账户隔离)。"""
|
||||
# Normal withdrawals always create the account before deducting funds. This
|
||||
# fallback covers legacy rows, hand-written fixtures, and broken migrations:
|
||||
# a missing balance snapshot must not make a legitimate refund fail with 500.
|
||||
if db.get(CoinAccount, user_id) is None:
|
||||
logger.error(
|
||||
"withdraw refund found missing coin_account; recreating empty account: "
|
||||
"user_id=%s source=%s amount_cents=%s",
|
||||
user_id,
|
||||
source,
|
||||
amount_cents,
|
||||
)
|
||||
get_or_create_account(db, user_id, commit=False)
|
||||
|
||||
col = _balance_col(source)
|
||||
db.execute(
|
||||
update(CoinAccount)
|
||||
@@ -525,6 +577,8 @@ def _refund_withdraw(
|
||||
order.status = final_status
|
||||
order.fail_reason = reason[:256]
|
||||
db.commit()
|
||||
# 上次退款后没走完终态(如中途崩溃)的补账路径:这里补发通知(dedup 防重)
|
||||
notification_events.notify_withdraw_failed(db, order)
|
||||
return
|
||||
bal = _add_cash(db, order.user_id, order.amount_cents, order.source)
|
||||
db.add(
|
||||
@@ -568,6 +622,11 @@ def _refund_withdraw(
|
||||
fresh_order.status = final_status
|
||||
fresh_order.fail_reason = reason[:256]
|
||||
db.commit()
|
||||
notification_events.notify_withdraw_failed(db, fresh_order)
|
||||
return
|
||||
# PRD #4 提现失败通知:所有退款终态(failed/rejected)在此收口下发;
|
||||
# dedup=out_bill_no,与上面并发路径重复触发时未读期间只落一条。
|
||||
notification_events.notify_withdraw_failed(db, order)
|
||||
|
||||
|
||||
def _wx_not_found(result: dict) -> bool:
|
||||
@@ -579,6 +638,32 @@ def _wx_not_found(result: dict) -> bool:
|
||||
return "NOT_FOUND" in str(code)
|
||||
|
||||
|
||||
def _wechat_api_error_reason(data: object) -> str:
|
||||
"""Format a non-200 WeChat API response for operator display."""
|
||||
if not isinstance(data, dict):
|
||||
return f"微信发起转账失败:{data}"
|
||||
code = str(data.get("code") or "").strip()
|
||||
message = str(data.get("message") or "").strip()
|
||||
if code and message:
|
||||
return f"微信发起转账失败:{message}({code})"
|
||||
return f"微信发起转账失败:{message or code or '未知错误'}"
|
||||
|
||||
|
||||
def _wechat_terminal_failure_reason(data: dict, state: str) -> str:
|
||||
"""Translate WeChat query ``fail_reason`` while preserving unknown codes."""
|
||||
code = str(data.get("fail_reason") or "").strip()
|
||||
if code:
|
||||
label = _WX_FAIL_REASON_LABELS.get(code)
|
||||
if label:
|
||||
return f"微信转账失败:{label}({code})"
|
||||
return f"微信转账失败:{code}"
|
||||
if state == "CANCELLED":
|
||||
return "微信转账已撤销(CANCELLED)"
|
||||
if state == "CLOSED":
|
||||
return "微信转账已关闭(CLOSED)"
|
||||
return f"微信转账失败(状态:{state or 'FAIL'})"
|
||||
|
||||
|
||||
def _settle_after_ambiguous(db: Session, order: WithdrawOrder, reason: str) -> None:
|
||||
"""转账调用结果不明(超时/异常/非200)时,**先查单再决定**,绝不盲目退款(防退款后又到账)。
|
||||
- 微信查到 SUCCESS → 钱已出,置 success,不退款
|
||||
@@ -604,12 +689,17 @@ def _settle_after_ambiguous(db: Session, order: WithdrawOrder, reason: str) -> N
|
||||
|
||||
state = q["data"].get("state", "")
|
||||
order.wechat_state = state
|
||||
order.transfer_bill_no = q["data"].get("transfer_bill_no") or order.transfer_bill_no
|
||||
if state == _WX_STATE_SUCCESS:
|
||||
order.status = "success"
|
||||
order.transfer_bill_no = q["data"].get("transfer_bill_no")
|
||||
db.commit()
|
||||
notification_events.notify_withdraw_success(db, order) # PRD #3 提现到账
|
||||
elif state in _WX_STATE_FAILED:
|
||||
_refund_withdraw(db, order, reason=reason)
|
||||
_refund_withdraw(
|
||||
db,
|
||||
order,
|
||||
reason=_wechat_terminal_failure_reason(q["data"], state),
|
||||
)
|
||||
else:
|
||||
order.package_info = q["data"].get("package_info") or order.package_info
|
||||
db.commit()
|
||||
@@ -746,17 +836,8 @@ def create_withdraw(
|
||||
else:
|
||||
out_bill_no = uuid.uuid4().hex
|
||||
|
||||
active_order_id = db.execute(
|
||||
select(WithdrawOrder.id).where(
|
||||
WithdrawOrder.user_id == user_id,
|
||||
WithdrawOrder.status.in_(_WITHDRAW_ACTIVE_STATUSES),
|
||||
).limit(1)
|
||||
).scalar_one_or_none()
|
||||
if active_order_id is not None:
|
||||
raise WithdrawTooFrequentError
|
||||
|
||||
# 福利页档位闸(7-9):coin_cash 只能提预设档位,且该档今日可提(服务端权威口径,防绕过
|
||||
# 客户端刷)。放在幂等返回/在途互斥之后:同号重试仍原样返回旧单,不被档位闸误杀。
|
||||
# 客户端刷)。放在幂等返回之后:同号重试仍原样返回旧单,不被档位闸误杀。
|
||||
# allow_sub_min(0.01 调试直发)保持原样放行,不受档位约束;invite_cash 本轮无档位概念不校验。
|
||||
if source == "coin_cash" and not allow_sub_min:
|
||||
tier_state = next(
|
||||
@@ -806,6 +887,7 @@ def create_withdraw(
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
# 唯一冲突只可能来自 out_bill_no 幂等键并发重试:原样返回既有单;否则未知冲突,上抛。
|
||||
existing = db.execute(
|
||||
select(WithdrawOrder).where(
|
||||
WithdrawOrder.out_bill_no == out_bill_no, WithdrawOrder.user_id == user_id
|
||||
@@ -813,14 +895,6 @@ def create_withdraw(
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return existing
|
||||
active_order_id = db.execute(
|
||||
select(WithdrawOrder.id).where(
|
||||
WithdrawOrder.user_id == user_id,
|
||||
WithdrawOrder.status.in_(_WITHDRAW_ACTIVE_STATUSES),
|
||||
).limit(1)
|
||||
).scalar_one_or_none()
|
||||
if active_order_id is not None:
|
||||
raise WithdrawTooFrequentError from None
|
||||
raise
|
||||
db.refresh(order)
|
||||
return order # 待管理员审核;**不在此处打款**
|
||||
@@ -979,8 +1053,18 @@ def _apply_transfer_result(db: Session, order: WithdrawOrder, data: dict) -> Wit
|
||||
order.package_info = data.get("package_info") # 免确认转账无此字段(None);确认模式带它供拉确认页
|
||||
if data.get("state") == _WX_STATE_SUCCESS:
|
||||
order.status = "success"
|
||||
elif data.get("state") in _WX_STATE_FAILED:
|
||||
_refund_withdraw(
|
||||
db,
|
||||
order,
|
||||
reason=_wechat_terminal_failure_reason(data, str(data.get("state") or "")),
|
||||
)
|
||||
db.refresh(order)
|
||||
return order
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
if order.status == "success": # 免确认转账直接到账 → PRD #3 提现到账
|
||||
notification_events.notify_withdraw_success(db, order)
|
||||
return order
|
||||
|
||||
|
||||
@@ -1021,7 +1105,11 @@ def execute_withdraw_transfer(db: Session, order: WithdrawOrder) -> WithdrawOrde
|
||||
return order
|
||||
if result["status_code"] != 200:
|
||||
# 金额安全:查转账单后定夺,绝不盲退(未创建→退款,已创建→按真实状态)
|
||||
_settle_after_ambiguous(db, order, reason=str(result["data"].get("message") or result["data"]))
|
||||
_settle_after_ambiguous(
|
||||
db,
|
||||
order,
|
||||
reason=_wechat_api_error_reason(result["data"]),
|
||||
)
|
||||
# 授权有效性:回查授权单,微信侧已失效(用户关闭/风控)→标 closed,下次提现自动回退方式一重新授权
|
||||
_refresh_active_auth(db, order.user_id)
|
||||
db.refresh(order)
|
||||
@@ -1049,7 +1137,11 @@ def execute_withdraw_transfer(db: Session, order: WithdrawOrder) -> WithdrawOrde
|
||||
return order
|
||||
|
||||
if result["status_code"] != 200:
|
||||
_settle_after_ambiguous(db, order, reason=str(result["data"].get("message") or result["data"]))
|
||||
_settle_after_ambiguous(
|
||||
db,
|
||||
order,
|
||||
reason=_wechat_api_error_reason(result["data"]),
|
||||
)
|
||||
db.refresh(order)
|
||||
return order
|
||||
|
||||
@@ -1106,19 +1198,24 @@ def refresh_withdraw_status(
|
||||
).scalar_one_or_none()
|
||||
if order is None:
|
||||
raise WithdrawOrderNotFound
|
||||
if order.status != "pending":
|
||||
return order # 已终态,不再查
|
||||
if order.status not in {"pending", "failed"}:
|
||||
return order
|
||||
enrich_failed_order = order.status == "failed"
|
||||
|
||||
try:
|
||||
result = wxpay.query_transfer(out_bill_no)
|
||||
except wxpay.WxPayNotConfiguredError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - 查单失败不能把运营后台打成 500
|
||||
if enrich_failed_order:
|
||||
return order
|
||||
order.fail_reason = f"微信查单异常,保持pending: {exc}"[:256]
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
return order
|
||||
if result["status_code"] != 200:
|
||||
if enrich_failed_order:
|
||||
return order
|
||||
if _wx_not_found(result):
|
||||
# 微信明确无此单 → 转账从未创建(如崩溃在扣款后/调用前),退款安全
|
||||
_refund_withdraw(db, order, reason="微信无此单,已退回")
|
||||
@@ -1127,11 +1224,32 @@ def refresh_withdraw_status(
|
||||
|
||||
state = result["data"].get("state", "")
|
||||
order.wechat_state = state
|
||||
order.transfer_bill_no = (
|
||||
result["data"].get("transfer_bill_no") or order.transfer_bill_no
|
||||
)
|
||||
if enrich_failed_order:
|
||||
if state in _WX_STATE_FAILED:
|
||||
order.fail_reason = _wechat_terminal_failure_reason(
|
||||
result["data"], state
|
||||
)[:256]
|
||||
elif state == _WX_STATE_SUCCESS:
|
||||
order.fail_reason = (
|
||||
"资金状态异常:本地已退款,但微信查单显示已到账,请人工核查"
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
return order
|
||||
|
||||
if state == _WX_STATE_SUCCESS:
|
||||
order.status = "success"
|
||||
db.commit()
|
||||
notification_events.notify_withdraw_success(db, order) # PRD #3 提现到账
|
||||
elif state in _WX_STATE_FAILED:
|
||||
_refund_withdraw(db, order, reason=f"微信转账状态 {state}")
|
||||
_refund_withdraw(
|
||||
db,
|
||||
order,
|
||||
reason=_wechat_terminal_failure_reason(result["data"], state),
|
||||
)
|
||||
elif state == _WX_STATE_WAIT_CONFIRM and cancel_if_unconfirmed:
|
||||
# 用户从确认页回来了却仍未确认 → 视为放弃:撤销微信单(防事后确认导致重复打款)后退款。
|
||||
# 撤单失败(可能已被确认进 ACCEPTED 的竞态)则保持 pending,等下次查询。
|
||||
|
||||
+9
-1
@@ -11,7 +11,6 @@ from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# ===== 用户对外信息 =====
|
||||
|
||||
class UserOut(BaseModel):
|
||||
@@ -65,6 +64,9 @@ class JverifyLoginRequest(BaseModel):
|
||||
"", max_length=64,
|
||||
description="硬件级设备标识(Android ANDROID_ID),用于新手引导按 设备+账号 去重;空=按未完成处理",
|
||||
)
|
||||
device_model: str = Field(
|
||||
"", max_length=128, description="客户端设备型号快照,用于登录安全审计"
|
||||
)
|
||||
|
||||
|
||||
# ===== 短信验证码 =====
|
||||
@@ -75,6 +77,9 @@ class SmsSendRequest(BaseModel):
|
||||
"", max_length=64,
|
||||
description="硬件级设备标识(Android ANDROID_ID),用于发码防刷按 设备+IP 限流;空=按 IP 聚一桶",
|
||||
)
|
||||
device_model: str = Field(
|
||||
"", max_length=128, description="客户端设备型号快照,用于短信安全审计"
|
||||
)
|
||||
|
||||
|
||||
class SmsSendResponse(BaseModel):
|
||||
@@ -90,6 +95,9 @@ class SmsLoginRequest(BaseModel):
|
||||
"", max_length=64,
|
||||
description="硬件级设备标识(Android ANDROID_ID),用于新手引导按 设备+账号 去重;空=按未完成处理",
|
||||
)
|
||||
device_model: str = Field(
|
||||
"", max_length=128, description="客户端设备型号快照,用于短信验证安全审计"
|
||||
)
|
||||
|
||||
|
||||
# ===== Refresh =====
|
||||
|
||||
@@ -13,7 +13,6 @@ from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
# ===== 上报请求 =====
|
||||
|
||||
class ComparisonItemIn(BaseModel):
|
||||
@@ -198,6 +197,20 @@ class ComparisonRecordCreatedOut(BaseModel):
|
||||
id: int = Field(..., description="写入(或已存在)的记录 id")
|
||||
|
||||
|
||||
class CompareStartReserveIn(BaseModel):
|
||||
"""Reserve one authenticated comparison start before the agent begins."""
|
||||
|
||||
trace_id: str = Field(..., min_length=1, max_length=64)
|
||||
business_type: str = Field(default="food", min_length=1, max_length=16)
|
||||
device_id: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
class CompareStartReserveOut(BaseModel):
|
||||
limit: int
|
||||
used: int
|
||||
remaining: int
|
||||
|
||||
|
||||
class CompareStatsOut(BaseModel):
|
||||
"""「我的」页省钱战绩卡(比价口径)聚合。"""
|
||||
|
||||
|
||||
+22
-1
@@ -3,12 +3,15 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class DeviceRegisterRequest(BaseModel):
|
||||
device_id: str
|
||||
# registration_id 为旧极光字段,新推送链路统一使用 push_vendor + push_token。
|
||||
registration_id: str | None = None
|
||||
push_vendor: str | None = None
|
||||
push_token: str | None = None
|
||||
platform: str = "android"
|
||||
app_version: str | None = None
|
||||
|
||||
@@ -18,6 +21,8 @@ class HeartbeatRequest(BaseModel):
|
||||
source: str = "service" # service | app
|
||||
accessibility_enabled: bool = True
|
||||
registration_id: str | None = None
|
||||
push_vendor: str | None = None
|
||||
push_token: str | None = None
|
||||
|
||||
|
||||
class DeviceOut(BaseModel):
|
||||
@@ -26,6 +31,8 @@ class DeviceOut(BaseModel):
|
||||
id: int
|
||||
device_id: str
|
||||
registration_id: str | None
|
||||
push_vendor: str | None
|
||||
push_token: str | None
|
||||
ever_protected: bool
|
||||
liveness_state: str
|
||||
last_heartbeat_at: datetime | None
|
||||
@@ -46,3 +53,17 @@ class LivenessOut(BaseModel):
|
||||
|
||||
class LivenessAckRequest(BaseModel):
|
||||
device_id: str
|
||||
|
||||
|
||||
class PushTestRequest(BaseModel):
|
||||
device_id: str
|
||||
delay_seconds: int = Field(default=10, ge=0, le=60)
|
||||
push_vendor: str | None = None
|
||||
push_token: str | None = None
|
||||
registration_id: str | None = None
|
||||
|
||||
|
||||
class PushTestOut(BaseModel):
|
||||
ok: bool = True
|
||||
delay_seconds: int
|
||||
has_push_token: bool
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""新手引导视频(领券等候浮层前 N 次替代广告)的客户端请求/响应契约。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class GuideVideoStartIn(BaseModel):
|
||||
"""开播询问。scene 目前只有 coupon(领券浮层);预留给日后比价等场景。"""
|
||||
|
||||
scene: str = Field(default="coupon", max_length=16)
|
||||
|
||||
|
||||
class GuideVideoStartOut(BaseModel):
|
||||
"""should_play=False 时客户端照旧走广告链路,其余字段无意义。"""
|
||||
|
||||
should_play: bool
|
||||
video_url: str | None = None # 相对地址 /media/...;客户端自行拼 BASE_URL
|
||||
play_token: str = "" # 发奖幂等键
|
||||
reward_coin: int = 0 # 播完/中途关闭都发的固定金币
|
||||
seq: int = 0 # 本账号第几次
|
||||
remaining: int = 0 # 发完这次还剩几次
|
||||
|
||||
|
||||
class GuideVideoRewardIn(BaseModel):
|
||||
"""播完或中途关闭都调这个;completed 只做留痕,两者都发币。"""
|
||||
|
||||
play_token: str = Field(min_length=1, max_length=64)
|
||||
completed: bool = False
|
||||
|
||||
|
||||
class GuideVideoRewardOut(BaseModel):
|
||||
"""granted=True 表示本次调用真的入账(重复上报为 False,coin 是已发金额)。"""
|
||||
|
||||
granted: bool
|
||||
coin: int
|
||||
status: str
|
||||
@@ -0,0 +1,128 @@
|
||||
"""消息通知中心 请求/响应契约。
|
||||
|
||||
⚠️ 命名约定:本组接口按 PRD 前端契约使用 **camelCase**(sentAt / isRead / pageSize …),
|
||||
与库内其他 snake_case 接口不同——PRD 与前端原型(notifications.html)按 camelCase 对接,
|
||||
需求方接口清单亦明确写作 sentAt / isRead,故整组遵循之。响应序列化走 pydantic alias。
|
||||
|
||||
字段说明都写在 Field(description=...) 里,起服务后打开 /docs 即是给前端的在线文档。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic.alias_generators import to_camel
|
||||
|
||||
|
||||
class _CamelModel(BaseModel):
|
||||
"""出参统一 camelCase(alias);populate_by_name 允许服务端代码仍用 snake_case 构造。"""
|
||||
|
||||
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
||||
|
||||
|
||||
class InfoRow(_CamelModel):
|
||||
"""卡片信息行(PRD §3「信息行」列),前端按 label: value 逐行渲染。"""
|
||||
|
||||
label: str = Field(description="行标签,如「过期说明」「到账账户」「失败原因」")
|
||||
value: str = Field(description="行内容(已按 PRD 文案拼好变量,前端直接展示)")
|
||||
|
||||
|
||||
class NotificationItem(_CamelModel):
|
||||
"""一条通知卡片。
|
||||
|
||||
卡片头部三要素:categoryLabel(分类标签)+ 未读红点(isRead=false 时展示)+ 时间(sentAt)。
|
||||
时间显示规则(前端处理):今天→「今天」;昨天→「昨天」;当年→「M月D日」;跨年→「YYYY年M月D日」。
|
||||
"""
|
||||
|
||||
id: int = Field(description="通知 id(未读消除、push 联动都用它)")
|
||||
category: str = Field(
|
||||
description="分类 key:withdraw_assistant=提现助手 / system=系统通知 / "
|
||||
"feedback=我的反馈 / report=我的爆料 / invite=好友邀请"
|
||||
)
|
||||
category_label: str = Field(description="分类中文标签(卡片头部直接展示)")
|
||||
type: str = Field(
|
||||
description="类型 key(13 种,决定点击行为,见 PRD §2):reward_expiring 即将失效 / "
|
||||
"reward_expired 已失效 / withdraw_success 提现成功 / withdraw_failed 提现失败 / "
|
||||
"perm_accessibility 无障碍异常 / perm_battery 省电策略异常 / "
|
||||
"perm_autostart 自启动异常 / perm_overlay 悬浮窗异常 / "
|
||||
"feedback_reply 官方回复 / feedback_reward 反馈奖励 / "
|
||||
"report_approved 爆料审核通过 / invite_order_reward 好友下单奖励 / "
|
||||
"invite_remind 好友催单提醒"
|
||||
)
|
||||
card_style: str = Field(
|
||||
description="卡片版式:dual_amount 双金额卡 / withdraw 提现卡 / plain_text 纯文本卡 / "
|
||||
"coin_reward 金币奖励卡 / friend_cash 好友现金卡"
|
||||
)
|
||||
title: str = Field(description="卡片标题(双金额/提现/金币奖励/好友现金卡标题居中)")
|
||||
coins: int | None = Field(
|
||||
default=None,
|
||||
description="金币数(整数,不带小数)。dual_amount / coin_reward 卡有值,其余 null",
|
||||
)
|
||||
cash_cents: int | None = Field(
|
||||
default=None,
|
||||
description="现金金额,单位【分】。dual_amount / withdraw / friend_cash 卡有值,其余 null",
|
||||
)
|
||||
cash_yuan: str | None = Field(
|
||||
default=None,
|
||||
description="现金金额展示串(元,保留两位小数,如 \"12.80\"),与 cashCents 同源,可直接展示",
|
||||
)
|
||||
info_rows: list[InfoRow] = Field(
|
||||
description="信息行列表(label: value),内容已按 PRD §3 拼好,前端逐行渲染即可"
|
||||
)
|
||||
action_text: str | None = Field(
|
||||
default=None,
|
||||
description="操作行文案(如「立即激活您的收益」「去开启」);null=无操作行(提现成功卡)。"
|
||||
"注意:点击目标是整张卡片,不区分卡片主体和操作行",
|
||||
)
|
||||
extra: dict[str, Any] = Field(
|
||||
description="点击跳转所需业务参数,按 type 取用:perm_* → {permission: accessibility|battery|"
|
||||
"autostart|overlay}(点击时实时检测该权限);feedback_* → {feedbackId};"
|
||||
"report_approved → {reportId};withdraw_failed → {withdrawId};"
|
||||
"invite_order_reward → {inviteeNickname};invite_remind → "
|
||||
"{inviteeNickname, scrollTo:\"remind\"};reward_expiring → {batchId}"
|
||||
)
|
||||
sent_at: datetime = Field(description="下发时间(ISO8601 带 +08:00 时区),前端按显示规则格式化")
|
||||
is_read: bool = Field(description="是否已读;false 时分类标签右侧展示 6px 红点(#E53935)")
|
||||
|
||||
|
||||
class NotificationListOut(_CamelModel):
|
||||
"""GET /api/v1/notifications 出参。列表已按时间倒序排好(最新在前,**不分组**;
|
||||
PRD §1 的"按分类分组"为笔误,已确认取消),前端无需再排。"""
|
||||
|
||||
items: list[NotificationItem] = Field(description="当前页通知卡片")
|
||||
page: int = Field(description="当前页码(1 起)")
|
||||
page_size: int = Field(description="每页条数")
|
||||
total: int = Field(description="全部通知总条数(含已读)")
|
||||
has_more: bool = Field(description="是否还有下一页")
|
||||
unread_count: int = Field(description="当前未读总数(与 /notifications/unread-count 同口径,省一次请求)")
|
||||
|
||||
|
||||
class UnreadCountOut(_CamelModel):
|
||||
"""GET /api/v1/notifications/unread-count 出参(首页铃铛角标)。"""
|
||||
|
||||
count: int = Field(description="未读总条数(精确值)")
|
||||
badge_text: str | None = Field(
|
||||
description="角标展示文案:超过 99 返回 \"99+\";等于 0 返回 null(整个角标隐藏,不展示空红点)"
|
||||
)
|
||||
|
||||
|
||||
class MarkReadRequest(_CamelModel):
|
||||
"""POST /api/v1/notifications/read 入参,两种模式二选一:
|
||||
|
||||
- `{"ids": [90001, 90002]}` 单条/多条置读——点击某张卡片、点击 push 落地后同步置读;
|
||||
- `{"all": true}` 全量清零——进入通知中心(或退出时)自动清零(PRD §4)。
|
||||
|
||||
同时传时 all=true 优先;不存在/已读的 id 自动忽略(幂等,可放心重试)。
|
||||
"""
|
||||
|
||||
ids: list[int] | None = Field(default=None, description="要置为已读的通知 id 列表")
|
||||
all: bool = Field(default=False, description="true=清空该用户全部未读")
|
||||
|
||||
|
||||
class MarkReadOut(_CamelModel):
|
||||
"""POST /api/v1/notifications/read 出参。"""
|
||||
|
||||
ok: bool = Field(description="固定 true(参数非法时走 400,不会到这里)")
|
||||
marked_count: int = Field(description="本次实际由未读变为已读的条数(重复请求会是 0)")
|
||||
unread_count: int = Field(description="处理后的剩余未读总数,可直接刷新铃铛角标")
|
||||
@@ -0,0 +1,99 @@
|
||||
"""厂商推送(测试/联调)接口契约。与消息中心同族,出参统一 camelCase。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic.alias_generators import to_camel
|
||||
|
||||
|
||||
class _CamelModel(BaseModel):
|
||||
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
||||
|
||||
|
||||
class PushVendorStatus(_CamelModel):
|
||||
vendor: str = Field(description="厂商 key:honor / huawei / xiaomi / oppo / vivo")
|
||||
label: str = Field(description="厂商中文名")
|
||||
configured: bool = Field(description="服务端凭据是否齐全(齐全才能真发,mock 不受影响)")
|
||||
missing_keys: list[str] = Field(description="缺失的 .env 配置键;configured=true 时为空")
|
||||
|
||||
|
||||
class PushVendorsOut(_CamelModel):
|
||||
vendors: list[PushVendorStatus] = Field(description="5 个厂商的配置状态")
|
||||
|
||||
|
||||
class PushTemplateOut(_CamelModel):
|
||||
type: str = Field(description="通知类型 key(13 种,与消息中心 type 一致)")
|
||||
category: str = Field(description="分类 key")
|
||||
category_label: str = Field(description="分类中文标签")
|
||||
card_style: str = Field(description="站内卡片版式")
|
||||
push_title: str = Field(description="push 标题(≤11 字固定文案,PRD §5)")
|
||||
push_body_sample: str = Field(description="push 正文示例(模板用 PRD 示例值渲染后的效果)")
|
||||
push_body_template: str = Field(description="push 正文模板原文,{var} 为变量占位")
|
||||
variables: list[str] = Field(description="模板变量名列表(调 /push/test 时可在 vars 里覆盖)")
|
||||
sample_vars: dict[str, str] = Field(description="各变量的 PRD 示例值(vars 未覆盖时的缺省)")
|
||||
|
||||
|
||||
class PushTemplatesOut(_CamelModel):
|
||||
templates: list[PushTemplateOut] = Field(description="13 种通知类型的 push 模板(PRD 编号顺序)")
|
||||
|
||||
|
||||
class PushTestRequest(_CamelModel):
|
||||
"""POST /api/v1/push/test 入参。三种发送内容来源(优先级从高到低):
|
||||
|
||||
1. 直接指定 title + content;
|
||||
2. 指定 type(13 种之一)→ 按 PRD §5 模板渲染,vars 可覆盖模板变量;
|
||||
3. 都不传 → 发一条通用测试文案。
|
||||
|
||||
推送目标:pushToken 直填,或 deviceId 反查该用户已注册设备(/api/v1/device/register 上报过的)。
|
||||
"""
|
||||
|
||||
vendor: str = Field(
|
||||
default="",
|
||||
description="厂商:honor/huawei/xiaomi/oppo/vivo(中文「华为」「小米」等别名也识别)。"
|
||||
"留空时用 deviceId 对应设备上报的 push_vendor",
|
||||
)
|
||||
push_token: str = Field(default="", description="厂商 push token / regId;留空则走 deviceId 反查")
|
||||
device_id: str = Field(default="", description="设备 id(客户端 DeviceId.get());用于反查 token")
|
||||
type: str = Field(
|
||||
default="",
|
||||
description="通知类型 key(13 种,见 GET /push/templates);留空且未直接给 title/content 时发通用测试文案",
|
||||
)
|
||||
vars: dict[str, str] = Field(
|
||||
default_factory=dict,
|
||||
description="覆盖 push 模板变量,如 {\"coins\":\"520\",\"cash\":\"6.66\"};缺省用 PRD 示例值",
|
||||
)
|
||||
title: str = Field(default="", description="直接指定标题(优先于 type 模板)")
|
||||
content: str = Field(default="", description="直接指定正文(优先于 type 模板)")
|
||||
create_notification: bool = Field(
|
||||
default=False,
|
||||
description="true=同时往该用户的消息中心 mock 列表插入一条同类型未读通知,push extras 带上它的"
|
||||
" notificationId → 可闭环验证「点 push → 落地 → 调 /notifications/read 消红点」联动"
|
||||
"(仅 type 为 13 种类型之一时生效)",
|
||||
)
|
||||
mock: bool = Field(
|
||||
default=True,
|
||||
description="true(默认)=不真调厂商 API,返回渲染结果(联调安全);false=真发,要求该厂商凭据已配置",
|
||||
)
|
||||
|
||||
|
||||
class PushTestOut(_CamelModel):
|
||||
ok: bool = Field(description="发送(或 mock 渲染)成功")
|
||||
mock: bool = Field(description="本次是否 mock(未真调厂商 API)")
|
||||
vendor: str = Field(description="实际使用的厂商 key(已归一化)")
|
||||
title: str = Field(description="实际下发的 push 标题")
|
||||
body: str = Field(description="实际下发的 push 正文")
|
||||
extras: dict[str, str] = Field(
|
||||
description="随 push 下发的自定义键值(客户端深链用):type 必有;createNotification=true 时带"
|
||||
" notificationId 及该通知的业务参数(feedbackId / permission / …)"
|
||||
)
|
||||
notification_id: int | None = Field(
|
||||
default=None, description="createNotification=true 时新插入的站内 mock 通知 id"
|
||||
)
|
||||
missing_keys: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="该厂商仍缺失的配置键(mock 发送时提示「真发前还需配什么」;真发时必为空)",
|
||||
)
|
||||
vendor_response: dict[str, Any] | None = Field(
|
||||
default=None, description="真发时厂商 API 的原始响应(mock 时为 null)"
|
||||
)
|
||||
@@ -0,0 +1,269 @@
|
||||
"""消息通知中心:业务事件 → 站内消息 + 厂商 push 的统一下发口。
|
||||
|
||||
PRD《消息通知中心》真实业务触发在此收口(替代 /push/test 的样例数据),已接入:
|
||||
#3 withdraw_success 提现到账(repositories/wallet 各「pending→success」转换点)
|
||||
#4 withdraw_failed 提现失败/退回(repositories/wallet._refund_withdraw,含审核拒绝)
|
||||
#9 feedback_reply 官方回复(admin 反馈审核「拒绝」,带用户可见原因/留言)
|
||||
#10 feedback_reward 反馈奖励(admin 反馈审核「采纳」发金币,必带官方留言)
|
||||
#11 report_approved 爆料审核通过(admin 上报更低价「通过」发金币)
|
||||
#12 invite_order_reward 好友下单到账(repositories/invite.try_reward_on_compare 发奖后)
|
||||
|
||||
行为约定(调用方唯一需要知道的两条):
|
||||
1. **绝不抛异常**——通知只是业务的副产物,站内消息落库失败/推送失败只 log,
|
||||
绝不让提现退款、审核发奖等主流程回滚或报错。
|
||||
2. **必须在业务事务 commit 之后调用**——内部会再 commit(写 notification 表);
|
||||
若在业务半途调用,会把调用方未提交的脏状态一并提交。
|
||||
|
||||
去重:各事件用业务主键做 dedup_key(提现单号/反馈 id/爆料 id/被邀请人 id),配合
|
||||
notification 表的部分唯一索引,同一事件并发重复触发时未读期间只落一条、只推一次。
|
||||
|
||||
推送:向该用户所有已上报厂商 token 的设备直推(integrations/vendor_push);
|
||||
厂商凭据未配置(本地/测试环境)时自动跳过推送、只落站内消息。extras 按
|
||||
PRD §4 约定带 {type, notificationId, ...跳转参数},客户端点击 push 深链落地
|
||||
并调 POST /notifications/read 同步置读。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import notification_catalog as catalog
|
||||
from app.core.rewards import CN_TZ
|
||||
from app.integrations import vendor_push
|
||||
from app.models.user import User
|
||||
from app.repositories import device as device_repo
|
||||
from app.repositories import notification as notif_repo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.feedback import Feedback
|
||||
from app.models.notification import Notification
|
||||
from app.models.price_report import PriceReport
|
||||
from app.models.wallet import WithdrawOrder
|
||||
|
||||
logger = logging.getLogger("shagua.notification_events")
|
||||
|
||||
|
||||
def _fmt_time(dt: datetime) -> str:
|
||||
"""信息行「到账时间」的展示格式(与 repositories/notification 样例卡一致)。"""
|
||||
return dt.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def _yuan_trim(cents: int) -> str:
|
||||
"""分 → 元,去掉多余的 0(200→"2"、1280→"12.80")。push 正文用(PRD §5 示例口径:
|
||||
「{2}元现金已到账」);卡片数值仍走 cash_cents 由前端按两位小数渲染。"""
|
||||
yuan = cents / 100
|
||||
return f"{yuan:.2f}".rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def _display_name(user: User | None) -> str:
|
||||
"""好友昵称展示:昵称 → 微信昵称 → 手机尾号,全无则「好友」。"""
|
||||
name = ((user.nickname if user else None) or (user.wechat_nickname if user else None) or "").strip()
|
||||
if not name and user and user.phone:
|
||||
name = f"用户{user.phone[-4:]}"
|
||||
return name or "好友"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 内核:落站内消息 + 厂商推送(全程吞异常)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _dispatch(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: int,
|
||||
type_key: str,
|
||||
coins: int | None = None,
|
||||
cash_cents: int | None = None,
|
||||
info_rows: list[dict[str, str]] | None = None,
|
||||
extra: dict[str, str] | None = None,
|
||||
dedup_key: str | None = None,
|
||||
push_vars: dict[str, str] | None = None,
|
||||
) -> Notification | None:
|
||||
"""落一条站内消息并向该用户设备直推。返回落库行;去重命中/失败返回 None。"""
|
||||
try:
|
||||
row = notif_repo.create_notification(
|
||||
db,
|
||||
user_id=user_id,
|
||||
type_key=type_key,
|
||||
coins=coins,
|
||||
cash_cents=cash_cents,
|
||||
info_rows=info_rows,
|
||||
extra=extra,
|
||||
dedup_key=dedup_key,
|
||||
)
|
||||
except IntegrityError:
|
||||
# 同 (user, type, dedup_key) 已有未读消息 = 同一事件并发/重复触发 → 不重复落、不重复推
|
||||
db.rollback()
|
||||
logger.info(
|
||||
"notification dedup hit user_id=%s type=%s dedup_key=%s", user_id, type_key, dedup_key
|
||||
)
|
||||
return None
|
||||
except Exception: # noqa: BLE001 — 通知失败绝不影响业务主流程
|
||||
logger.exception("create notification failed user_id=%s type=%s", user_id, type_key)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception: # noqa: BLE001 — 回滚失败也不外抛,session 由请求生命周期兜底
|
||||
logger.exception("rollback after notification failure also failed")
|
||||
return None
|
||||
|
||||
_push_to_user_devices(db, row, push_vars)
|
||||
return row
|
||||
|
||||
|
||||
def _push_to_user_devices(db: Session, row: Notification, push_vars: dict[str, str] | None) -> None:
|
||||
"""向消息归属用户的全部厂商推送目标直推(best-effort,单设备失败不影响其余)。"""
|
||||
try:
|
||||
title, body = catalog.render_push(row.type, push_vars)
|
||||
# PRD §4 push 联动:extras 至少带 type + notificationId,外加该类型的跳转参数(extra 列)
|
||||
extras: dict[str, str] = {"type": row.type}
|
||||
extras.update({str(k): str(v) for k, v in (row.extra or {}).items()})
|
||||
extras["notificationId"] = str(row.id)
|
||||
|
||||
for dev in device_repo.list_push_targets(db, user_id=row.user_id):
|
||||
vendor = vendor_push.normalize_vendor(dev.push_vendor)
|
||||
if not vendor or vendor not in vendor_push.SUPPORTED_VENDORS:
|
||||
continue
|
||||
if vendor_push.missing_settings(vendor):
|
||||
# 本地/测试环境凭据不齐 → 只落站内消息,不发真推送(与 push/vendors 的报缺口径一致)
|
||||
logger.info(
|
||||
"skip push (vendor %s not configured) user_id=%s type=%s",
|
||||
vendor, row.user_id, row.type,
|
||||
)
|
||||
continue
|
||||
try:
|
||||
vendor_push.send_notification(
|
||||
vendor, dev.push_token, title=title, body=body, extras=extras
|
||||
)
|
||||
logger.info(
|
||||
"push sent user_id=%s type=%s vendor=%s notification_id=%s",
|
||||
row.user_id, row.type, vendor, row.id,
|
||||
)
|
||||
except vendor_push.VendorPushError as e:
|
||||
logger.warning(
|
||||
"push failed user_id=%s type=%s vendor=%s: %s", row.user_id, row.type, vendor, e
|
||||
)
|
||||
except Exception: # noqa: BLE001 — 渲染/查设备等意外失败同样不外抛
|
||||
logger.exception("push notification failed user_id=%s type=%s", row.user_id, row.type)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 六个业务事件(PRD §1/§3/§5 编号见文件头)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def notify_withdraw_success(db: Session, order: WithdrawOrder) -> None:
|
||||
"""#3 提现成功:款项已存入微信零钱。点击无跳转仅消红点(extra 空)。"""
|
||||
_dispatch(
|
||||
db,
|
||||
user_id=order.user_id,
|
||||
type_key="withdraw_success",
|
||||
cash_cents=order.amount_cents,
|
||||
info_rows=[
|
||||
{"label": "到账账户", "value": "微信钱包"},
|
||||
{"label": "到账时间", "value": _fmt_time(datetime.now(CN_TZ))},
|
||||
],
|
||||
extra={},
|
||||
dedup_key=order.out_bill_no,
|
||||
push_vars={"amount": notif_repo.cash_yuan(order.amount_cents)},
|
||||
)
|
||||
|
||||
|
||||
def notify_withdraw_failed(db: Session, order: WithdrawOrder) -> None:
|
||||
"""#4 提现失败/退回:含微信侧失败、审核拒绝、解绑退回。点击跳提现页重新提现。
|
||||
|
||||
失败原因用 order.fail_reason(与 /withdraw/status 下发的用户可读原因同源)。
|
||||
"""
|
||||
reason = (order.fail_reason or "").strip() or "提现未成功"
|
||||
_dispatch(
|
||||
db,
|
||||
user_id=order.user_id,
|
||||
type_key="withdraw_failed",
|
||||
cash_cents=order.amount_cents,
|
||||
info_rows=[
|
||||
{"label": "失败原因", "value": reason},
|
||||
{"label": "退回说明", "value": "款项已原路退回现金余额"},
|
||||
],
|
||||
extra={"withdrawId": order.out_bill_no},
|
||||
dedup_key=order.out_bill_no,
|
||||
push_vars={"amount": notif_repo.cash_yuan(order.amount_cents), "reason": reason},
|
||||
)
|
||||
|
||||
|
||||
def notify_feedback_reply(db: Session, feedback: Feedback) -> None:
|
||||
"""#9 官方回复:运营审核了反馈且未采纳(用户可见原因/留言落在反馈记录上)。
|
||||
点击跳反馈历史页滚动高亮该条(extra.feedbackId)。"""
|
||||
_dispatch(
|
||||
db,
|
||||
user_id=feedback.user_id,
|
||||
type_key="feedback_reply",
|
||||
info_rows=[{"label": "说明文案", "value": "快去看看官方给您的回复吧~"}],
|
||||
extra={"feedbackId": str(feedback.id)},
|
||||
dedup_key=str(feedback.id),
|
||||
)
|
||||
|
||||
|
||||
def notify_feedback_reward(db: Session, feedback: Feedback) -> None:
|
||||
"""#10 反馈奖励:反馈被采纳,金币已到账。PRD 约定发奖必带官方留言(admin_reply);
|
||||
运营漏填时省略该信息行,不硬造文案。"""
|
||||
coins = int(feedback.reward_coins or 0)
|
||||
info_rows = [{"label": "奖励说明", "value": "感谢您的反馈,您的金币奖励已到账"}]
|
||||
reply = (feedback.admin_reply or "").strip()
|
||||
if reply:
|
||||
info_rows.append({"label": "官方留言", "value": reply})
|
||||
info_rows.append({"label": "到账时间", "value": _fmt_time(datetime.now(CN_TZ))})
|
||||
_dispatch(
|
||||
db,
|
||||
user_id=feedback.user_id,
|
||||
type_key="feedback_reward",
|
||||
coins=coins,
|
||||
info_rows=info_rows,
|
||||
extra={"feedbackId": str(feedback.id)},
|
||||
dedup_key=str(feedback.id),
|
||||
push_vars={"coins": str(coins)},
|
||||
)
|
||||
|
||||
|
||||
def notify_report_approved(db: Session, report: PriceReport) -> None:
|
||||
"""#11 爆料审核通过:上报的更低价过审,金币已到账。点击跳爆料记录页高亮该条。"""
|
||||
coins = int(report.reward_coins or 0)
|
||||
store = (report.store_name or "").strip() or "该店铺"
|
||||
_dispatch(
|
||||
db,
|
||||
user_id=report.user_id,
|
||||
type_key="report_approved",
|
||||
coins=coins,
|
||||
info_rows=[
|
||||
{"label": "奖励说明", "value": f"您爆料的「{store}」更低价已通过审核,金币奖励已到账"},
|
||||
{"label": "到账时间", "value": _fmt_time(datetime.now(CN_TZ))},
|
||||
],
|
||||
extra={"reportId": str(report.id)},
|
||||
dedup_key=str(report.id),
|
||||
push_vars={"store": store, "coins": str(coins)},
|
||||
)
|
||||
|
||||
|
||||
def notify_invite_order_reward(
|
||||
db: Session, *, inviter_user_id: int, invitee_user_id: int, cash_cents: int
|
||||
) -> None:
|
||||
"""#12 好友下单到账:被邀请好友完成首次下单(比价),现金奖励已入邀请人账户。
|
||||
通知发给【邀请人】;每个好友只发一次奖 → dedup 按被邀请人。"""
|
||||
invitee = db.get(User, invitee_user_id)
|
||||
nickname = _display_name(invitee)
|
||||
_dispatch(
|
||||
db,
|
||||
user_id=inviter_user_id,
|
||||
type_key="invite_order_reward",
|
||||
cash_cents=cash_cents,
|
||||
info_rows=[
|
||||
{"label": "奖励说明", "value": f"好友「{nickname}」完成首次下单"},
|
||||
{"label": "到账时间", "value": _fmt_time(datetime.now(CN_TZ))},
|
||||
],
|
||||
extra={"inviteeNickname": nickname},
|
||||
dedup_key=str(invitee_user_id),
|
||||
push_vars={"nickname": nickname, "amount": _yuan_trim(cash_cents)},
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
"""美团搜索翻页游标(searchId)缓存 —— 「距离最近」tab 翻页提速。
|
||||
|
||||
**问题**:美团 query_coupon 的搜索结果只能靠 searchId 续页(pageNo 翻不动),而我们的 HTTP 接口
|
||||
是无状态的、客户端只传页码。原实现因此每次都从第 1 页顺序重放到第 N 页 —— 取第 N 页要向美团发
|
||||
N 次请求,用户越往下滑越慢(第 5 页 ≈ 第 1 页的 5 倍耗时,且每次调用都可能撞 402 限流)。
|
||||
|
||||
**做法**:把「翻到第 n 页所需的 searchId」按 (量化坐标, platform, 关键词) 记下来,
|
||||
下次请求第 n 页直接一发命中;未命中才从缓存里最深的那一页往后重放,并把沿途游标补进缓存。
|
||||
稳态下每翻一页恒定 1 次请求。
|
||||
|
||||
**坐标量化**到 2 位小数(~1km),与 `utils/meituan_city` 同口径:原始 GPS 每次抖动到小数点后 5~6 位,
|
||||
不量化几乎不命中缓存;而同一路搜索的排序原点相差 1km 以内,对券列表的实际顺序无感知差别。
|
||||
|
||||
**TTL** 取 10 分钟:searchId 属于上游会话态,放太久会翻到过期游标(调用失败 → 调用方回退重放)。
|
||||
缓存是**进程内**的,多 worker 各存一份、不共享,这没问题:命中率只影响快慢,不影响正确性。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
# 缓存键:(量化纬度, 量化经度, platform, 搜索词)
|
||||
RouteKey = tuple[float, float, int, str]
|
||||
|
||||
_TTL_SEC = 10 * 60
|
||||
_MAX_ROUTES = 512 # 最多缓存多少条搜索路线(每条 = 一个坐标×平台×关键词)
|
||||
_MAX_PAGES_PER_ROUTE = 60 # 单条路线最多记多少页游标,防某个坐标被无限翻页撑爆
|
||||
|
||||
# {route_key: {page_no: (search_id, 写入时刻)}};page_no 表示「用这个 searchId 能取到第几页」。
|
||||
# 第 1 页不需要 searchId(直接 pageNo=1),故永远不入表。
|
||||
_cursors: dict[RouteKey, dict[int, tuple[str, float]]] = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def route_key(latitude: float, longitude: float, platform: int, keyword: str) -> RouteKey:
|
||||
"""构造缓存键(坐标量化到 ~1km)。"""
|
||||
return (round(latitude, 2), round(longitude, 2), platform, keyword)
|
||||
|
||||
|
||||
def lookup(key: RouteKey, page: int) -> tuple[int, str | None]:
|
||||
"""找到能最省调用地拿到第 `page` 页的起点。
|
||||
|
||||
返回 (起始页, 该页要用的 search_id):
|
||||
- (page, "xxx") 缓存命中 → 调用方一发直达第 page 页
|
||||
- (m, "xxx") 命中更浅的第 m 页(1 < m < page)→ 从第 m 页重放到第 page 页
|
||||
- (1, None) 全未命中 → 从第 1 页(pageNo=1)重放
|
||||
"""
|
||||
if page <= 1:
|
||||
return 1, None
|
||||
now = time.time()
|
||||
with _lock:
|
||||
pages = _cursors.get(key)
|
||||
if not pages:
|
||||
return 1, None
|
||||
for p in range(page, 1, -1):
|
||||
hit = pages.get(p)
|
||||
if hit is None:
|
||||
continue
|
||||
search_id, wrote_at = hit
|
||||
if now - wrote_at > _TTL_SEC:
|
||||
pages.pop(p, None) # 过期即清,继续往浅了找
|
||||
continue
|
||||
return p, search_id
|
||||
return 1, None
|
||||
|
||||
|
||||
def remember(key: RouteKey, page: int, search_id: str) -> None:
|
||||
"""记下「用 search_id 可取到第 page 页」。page<=1 无意义(第 1 页不用游标)。"""
|
||||
if page <= 1 or not search_id:
|
||||
return
|
||||
now = time.time()
|
||||
with _lock:
|
||||
pages = _cursors.get(key)
|
||||
if pages is None:
|
||||
if len(_cursors) >= _MAX_ROUTES:
|
||||
_evict_oldest_route_locked()
|
||||
pages = _cursors[key] = {}
|
||||
pages[page] = (search_id, now)
|
||||
if len(pages) > _MAX_PAGES_PER_ROUTE:
|
||||
# 越浅的页越容易被重新走到,优先丢最深的那些(它们下次多半也过期了)
|
||||
for p in sorted(pages, reverse=True)[: len(pages) - _MAX_PAGES_PER_ROUTE]:
|
||||
pages.pop(p, None)
|
||||
|
||||
|
||||
def drop(key: RouteKey) -> None:
|
||||
"""整条路线作废。用在「拿缓存游标去请求却失败」时(多半是上游 searchId 过期)。"""
|
||||
with _lock:
|
||||
_cursors.pop(key, None)
|
||||
|
||||
|
||||
def _evict_oldest_route_locked() -> None:
|
||||
"""淘汰最久没更新过的一条路线(调用方须已持锁)。"""
|
||||
oldest_key = min(
|
||||
_cursors,
|
||||
key=lambda k: max((w for _, w in _cursors[k].values()), default=0.0),
|
||||
)
|
||||
_cursors.pop(oldest_key, None)
|
||||
@@ -25,6 +25,18 @@ server {
|
||||
# (纯文字反馈体积小、不受影响 → 呈现为「时好时坏」)。根治仍需客户端上传前压缩。
|
||||
client_max_body_size 32m;
|
||||
|
||||
# JSON 响应压缩。nginx 默认 gzip off,且就算 on 了 gzip_types 也只含 text/html、
|
||||
# gzip_proxied 默认 off(反代来的响应一律不压)—— 三个默认值凑一起 = 我们所有接口都在裸奔。
|
||||
# 比价记录列表这种一次 50 条、字段名 + 中文店名/菜名高度重复的 JSON,gzip 压缩比稳定在 8~10 倍
|
||||
# (几百 KB → 几十 KB),弱网下省的就是首屏那几秒。
|
||||
# 只压 JSON:APK 直链(/media/shaguabijia.apk)、图片本身已是压缩格式,再压纯浪费 CPU。
|
||||
gzip on;
|
||||
gzip_proxied any; # 反代响应也压(默认 off = 对我们这套反代等于没开)
|
||||
gzip_types application/json;
|
||||
gzip_min_length 1024; # 小响应压了反而更大(gzip 头开销),不值当
|
||||
gzip_comp_level 5; # 5 是体积/CPU 的常用折中点,再往上收益递减
|
||||
gzip_vary on; # 给 CDN/中间缓存正确按 Accept-Encoding 分桶
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8770;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
+19
-1
@@ -1,9 +1,13 @@
|
||||
# 傻瓜比价 App 后端 — API 接口文档(索引)
|
||||
|
||||
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
|
||||
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
|
||||
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**(⚠️ 例外:消息通知中心 `notifications` 族与厂商推送 `push` 族按 PRD 前端契约用 **camelCase**,见各自文档)
|
||||
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
|
||||
<<<<<<< HEAD
|
||||
> 最后更新:2026-07-14(新增 **消息通知中心** 3 端点(M1-M3,虚拟数据阶段)与 **厂商推送测试** 3 端点(P1-P3,荣耀/华为/小米/OPPO/vivo);上一次 2026-06-23 补全 device/internal/CPS 短链等整族端点)
|
||||
=======
|
||||
> 最后更新:2026-07-09(① 比价透传改「软鉴权 + trace_id 签发 + harvest 落库」(#112 尾声帧 `trace/epilogue` 一并补录);② 新端点:`user/onboarding/reset`(#114)、`GET /internal/launch-confirm-samples`(#91);③ 参数更新:提现族 `source` 分账(#82/#121)、`wallet/account` 邀请奖励金余额、美团 feed/top-sales 按城市过滤(#116)、admin 调现金 `account` 目标账户(#95);④ **Admin 索引补全到当前全量**:新家族 roles(#117/#126)/coupon-data(#99)/device-liveness(#80)/event-logs(#83)/price-reports(#94)/CPS 运营台/提现审核族,及 feedbacks 采纳拒绝(#94/#105)、marquee 模式与真实条浏览(#122/#123)等。上一次 2026-07-03)
|
||||
>>>>>>> origin/main
|
||||
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
|
||||
|
||||
---
|
||||
@@ -103,12 +107,26 @@
|
||||
| 36c | `POST /api/v1/user/onboarding/reset` | Bearer | [详情](./user/user-onboarding.md)(重置本设备引导标记,下次登录重走,#114) |
|
||||
| 37 | `DELETE /api/v1/user` | Bearer | [详情](./user/user-delete.md) |
|
||||
| **帮助与反馈**(前缀 `/api/v1/feedback`) |||
|
||||
<<<<<<< HEAD
|
||||
| 38 | `POST /api/v1/feedback` | Bearer | [详情](./feedback.md) |
|
||||
| 38a | `GET /api/v1/feedback/config` | Bearer | 反馈页「加群二维码」卡配置(开关 + 二维码图 + 三行文案)(无单独文档) |
|
||||
| 38b | `GET /api/v1/feedback/records` | Bearer | 我的反馈历史(pending/adopted/rejected)(无单独文档) |
|
||||
| **消息通知中心**(前缀 `/api/v1/notifications`;⚠️ 本族对外 **camelCase**;虚拟数据阶段:内存 mock,重启复位) |||
|
||||
| M1 | `GET /api/v1/notifications` | Bearer | [详情](./notifications.md)(消息列表,分页;13 类型卡片字段 + sentAt/isRead;服务端已按时间倒序排好,不分组) |
|
||||
| M2 | `GET /api/v1/notifications/unread-count` | Bearer | [详情](./notifications.md)(未读总数,首页铃铛角标;>99 → "99+",0 → null 隐藏) |
|
||||
| M3 | `POST /api/v1/notifications/read` | Bearer | [详情](./notifications.md)(标记已读:`{ids:[...]}` 单条/多条 或 `{all:true}` 进通知中心全量清零;幂等) |
|
||||
| **厂商推送测试**(前缀 `/api/v1/push`;荣耀/华为/小米/OPPO/vivo 五通道联调三件套,同为 camelCase) |||
|
||||
| P1 | `GET /api/v1/push/vendors` | Bearer | [详情](./push-vendor-test.md)(5 厂商服务端凭据配置状态,缺哪些 .env 键一目了然) |
|
||||
| P2 | `GET /api/v1/push/templates` | Bearer | [详情](./push-vendor-test.md)(13 类通知的 push 标题/正文模板 + PRD 示例渲染效果) |
|
||||
| P3 | `POST /api/v1/push/test` | Bearer | [详情](./push-vendor-test.md)(测试发送:默认 mock 不真发;mock=false 真发;可联动插一条站内 mock 通知闭环验证已读) |
|
||||
=======
|
||||
| 38 | `POST /api/v1/feedback` | Bearer | [详情](./other/feedback.md) |
|
||||
| 38a | `GET /api/v1/feedback/config` | Bearer | [详情](./other/feedback-config.md)(反馈页「加群二维码」卡配置:开关+二维码图+三行文案) |
|
||||
| 38b | `GET /api/v1/feedback/records` | Bearer | [详情](./other/feedback-records.md)(我的反馈历史,pending/adopted/rejected) |
|
||||
| **埋点 & 订单上报**(前缀分散;全部 Bearer 除 analytics/events 不强制登录) |||
|
||||
| E1 | `POST /api/v1/analytics/events` | 无 | [详情](./other/analytics-events.md)(批量上报埋点事件,不强制登录,每批最多200条) |
|
||||
| E2 | `POST /api/v1/order/report` | Bearer | [详情](./other/order-report.md)(上报归因订单,比价后5分钟内点链接+支付金额与比价价相差≤1元) |
|
||||
>>>>>>> origin/main
|
||||
| **首页门面数据 / 客户端配置**(前缀 `/api/v1/platform`;全平台展示数字 + 运营开关,**全部不鉴权**,登录前可读) |||
|
||||
| 39 | `GET /api/v1/platform/stats` | 无 | [详情](./platform/platform-stats.md) |
|
||||
| 40 | `GET /api/v1/platform/savings-feed` | 无 | [详情](./savings/platform-savings-feed.md) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# GET /admin/api/audit-logs — 审计日志(谁改了什么,游标分页)
|
||||
|
||||
> 所属:Admin·Audit 组(前缀 `/admin/api/audit-logs`) | 鉴权:Bearer admin_token(角色:任意已登录 admin) | [← 返回 API 索引](../README.md)
|
||||
> 所属:Admin·Audit 组(前缀 `/admin/api/audit-logs`) | 鉴权:Bearer admin_token + `audit-logs` 页面权限 | [← 返回 API 索引](../README.md)
|
||||
|
||||
## 入参(query)
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
@@ -29,7 +29,8 @@
|
||||
|
||||
## 错误码
|
||||
- `401` 未带 admin token / token 无效或过期 / 管理员被禁用
|
||||
- `403` 当前管理员没有 `audit-logs` 页面权限
|
||||
|
||||
## 说明
|
||||
- 整组(`/admin/api/audit-logs`)守卫为 `get_current_admin`,任意已登录 admin 均可查看,无角色限制。
|
||||
- 整组(`/admin/api/audit-logs`)守卫为 `require_page("audit-logs")`,默认仅超级管理员和技术角色可查看,也可由超管给自定义角色授权。
|
||||
- 审计日志只增不改不删,任何写操作经 `write_audit` 落一条。数据表见 [admin_audit_log](../database/admin_audit_log.md)。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# /admin/api/device-liveness — 设备存活监控(#80)
|
||||
|
||||
> 所属:Admin 子应用(前缀 `/admin/api`) | 鉴权:admin | 表 [device_liveness](../../database/device_liveness.md) | [← 返回 API 索引](../README.md)
|
||||
> 所属:Admin 子应用(前缀 `/admin/api`) | 鉴权:admin + `device-liveness` 页面权限 | 表 [device_liveness](../../database/device_liveness.md) | [← 返回 API 索引](../README.md)
|
||||
|
||||
无障碍保护存活的后台视角:哪些设备开过保护(`ever_protected`)、现在在线还是掉线(心跳超时,#107 起阈值 1 小时)、首次开启时间(`first_protected_at`)。
|
||||
|
||||
@@ -13,3 +13,4 @@
|
||||
|
||||
## 说明
|
||||
- 「在线」= `last_heartbeat_at` 距今 < 超时阈值;掉线召回链路(worker 置 `kill_alert_pending` → 客户端 pull)见表文档。
|
||||
- 无 `device-liveness` 页面权限时返回 `403`。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# /admin/api/event-logs — 埋点日志(#83)
|
||||
|
||||
> 所属:Admin 子应用(前缀 `/admin/api`) | 鉴权:admin | 表 [analytics_event](../../database/analytics_event.md) | [← 返回 API 索引](../README.md)
|
||||
> 所属:Admin 子应用(前缀 `/admin/api`) | 鉴权:admin + `event-logs` 页面权限 | 表 [analytics_event](../../database/analytics_event.md) | [← 返回 API 索引](../README.md)
|
||||
|
||||
客户端埋点(`POST /api/v1/analytics/events` 批量上报)的后台检索页。
|
||||
|
||||
@@ -13,3 +13,4 @@
|
||||
## 说明
|
||||
- 纯只读;无聚合报表(要分析导出后自己算)。
|
||||
- 时间轴用 `client_ts`(事件真实发生时刻),入库时间受客户端攒批影响。
|
||||
- 无 `event-logs` 页面权限时返回 `403`。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user