diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..196cc17 --- /dev/null +++ b/AGENTS.md @@ -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 diff --git a/alembic/versions/1a924c274fce_merge_direct_vendor_push_and_feedback_.py b/alembic/versions/1a924c274fce_merge_direct_vendor_push_and_feedback_.py new file mode 100644 index 0000000..eb0144b --- /dev/null +++ b/alembic/versions/1a924c274fce_merge_direct_vendor_push_and_feedback_.py @@ -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 diff --git a/alembic/versions/8e04cc13a211_merge_notification_comparison_user_idx_.py b/alembic/versions/8e04cc13a211_merge_notification_comparison_user_idx_.py new file mode 100644 index 0000000..7cdb211 --- /dev/null +++ b/alembic/versions/8e04cc13a211_merge_notification_comparison_user_idx_.py @@ -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 diff --git a/alembic/versions/ad_reward_boost_round_id.py b/alembic/versions/ad_reward_boost_round_id.py new file mode 100644 index 0000000..d63afa6 --- /dev/null +++ b/alembic/versions/ad_reward_boost_round_id.py @@ -0,0 +1,47 @@ +"""ad_reward_record.boost_round_id(金币膨胀「本轮累计」标签) + +看完一条激励视频后客户端要弹「本轮累计获得 N 金币」,N 必须等于这一轮实际到账之和(否则用户 +会认为少发了钱)。单条到账额 reward-result 已经能给,但「一轮」的边界只有客户端知道(点「放弃 +赚钱」才算结束),客户端自己累加又会在进程被杀后丢失。 + +解法:客户端把轮次 id 随 mediaExtra 透传,穿山甲 S2S 原样带回,发奖时打在记录上; +reward-result 按 (user_id, boost_round_id) 对 granted 记录求和返回 round_coin。 + +本列是**纯标签**:不参与发奖判定,发多少/发不发完全不受影响。客户端就算一直复用同一个 id, +也只是把展示数字滚大,不产生任何新入账(求和的是已发生的发奖记录),无资损风险。 + +Revision ID: ad_reward_boost_round_id +Revises: comparison_llm_cost +Create Date: 2026-07-20 +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "ad_reward_boost_round_id" +down_revision: str | Sequence[str] | None = "comparison_llm_cost" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # 可空、无默认:SQLite 原生支持 ADD COLUMN,不需要 batch_alter_table(同 comparison_llm_cost)。 + # 存量行留 NULL = 「不属于任何一轮」,求和时天然不参与,老客户端行为不变。 + op.add_column( + "ad_reward_record", + sa.Column("boost_round_id", sa.String(length=64), nullable=True), + ) + # 求和恒带 user_id(轮 id 是客户端生成的,不能跨用户信任),故建复合索引而非单列 + op.create_index( + "ix_ad_reward_user_boost_round", + "ad_reward_record", + ["user_id", "boost_round_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_ad_reward_user_boost_round", table_name="ad_reward_record") + op.drop_column("ad_reward_record", "boost_round_id") diff --git a/alembic/versions/analytics_active_idx_active_composite_index.py b/alembic/versions/analytics_active_idx_active_composite_index.py index 8cdaa5c..9a2b75a 100644 --- a/alembic/versions/analytics_active_idx_active_composite_index.py +++ b/alembic/versions/analytics_active_idx_active_composite_index.py @@ -5,8 +5,10 @@ Revises: 135e79414fd0 Create Date: 2026-07-18 17:35:00.000000 给 analytics_event 加活跃口径热点复合索引 (event, page, user_id, created_at): -activity.active_event_condition 按 (event=show & page=home) ∪ 比价 ∪ 领券 过滤后 -group by user_id、max(created_at)。覆盖索引让该聚合走 index-only,避免高频 show 事件全表扫。 +activity.active_event_condition 按 event IN (home_visible ∪ 比价 ∪ 领券) 过滤后 +group by user_id、max(created_at)。覆盖索引让该聚合走 index-only,避免高频活跃事件全表扫。 +(历史:早期首页可见用 event=show+page=home 组合,故索引含 page 列;现改单一 home_visible、 +不再按 page 过滤 → page 列成冗余,索引仍靠 event 前缀生效;如需更优可后续新迁移瘦成 (event,user_id,created_at)。) ⚠️ 本分支迁移树有**既有多头**:135e79414fd0(不活跃两表)与 phone_rebind_log 同从 comparison_llm_cost 分叉,`alembic upgrade head` 会多头报错。本迁移挂在 135e79414fd0 diff --git a/alembic/versions/comparison_user_created_idx.py b/alembic/versions/comparison_user_created_idx.py new file mode 100644 index 0000000..2c2da52 --- /dev/null +++ b/alembic/versions/comparison_user_created_idx.py @@ -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") diff --git a/alembic/versions/coupon_claim_event.py b/alembic/versions/coupon_claim_event.py new file mode 100644 index 0000000..c3634d0 --- /dev/null +++ b/alembic/versions/coupon_claim_event.py @@ -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") diff --git a/alembic/versions/d8dd2106e438_merge_guide_video_seq_uq_and_coupon_.py b/alembic/versions/d8dd2106e438_merge_guide_video_seq_uq_and_coupon_.py new file mode 100644 index 0000000..a8032c9 --- /dev/null +++ b/alembic/versions/d8dd2106e438_merge_guide_video_seq_uq_and_coupon_.py @@ -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 diff --git a/alembic/versions/d9c03cc3ea07_merge_guide_video_and_main_alembic_heads.py b/alembic/versions/d9c03cc3ea07_merge_guide_video_and_main_alembic_heads.py new file mode 100644 index 0000000..0834cae --- /dev/null +++ b/alembic/versions/d9c03cc3ea07_merge_guide_video_and_main_alembic_heads.py @@ -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 diff --git a/alembic/versions/direct_vendor_push_fields.py b/alembic/versions/direct_vendor_push_fields.py new file mode 100644 index 0000000..ec33ed2 --- /dev/null +++ b/alembic/versions/direct_vendor_push_fields.py @@ -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") diff --git a/alembic/versions/drop_signin_boost_record.py b/alembic/versions/drop_signin_boost_record.py new file mode 100644 index 0000000..0447661 --- /dev/null +++ b/alembic/versions/drop_signin_boost_record.py @@ -0,0 +1,56 @@ +"""下线签到膨胀:drop signin_boost_record + +产品 2026-07 确认「固定 3000 金币的签到膨胀」从来不是设计内的口径 —— 奖励只有「签到」和 +「看视频」两种。签到弹窗里的「看广告膨胀」改与福利页看视频走同一条 reward_video 路径 +(按 eCPM 公式发,记在 ad_reward_record),signin_boost 场景整体摘除。 + +⚠️ **只 drop 这张表,不动 coin_transaction**:`biz_type='signin_boost'` 的金币流水是真发过的 +钱,账必须留得住(admin 大盘的 signin_boost_coin_total / signin_boost_watch_count 改为从 +coin_transaction 统计,继续能查回历史)。本表只是「哪天膨胀过」的业务留痕,金额与去向都能 +从流水还原,drop 掉不影响对账。 + +downgrade 只重建空表结构,**不恢复数据** —— 真要回滚得先从备份捞行。 + +Revision ID: drop_signin_boost_record +Revises: ad_reward_boost_round_id +Create Date: 2026-07-20 +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "drop_signin_boost_record" +down_revision: str | Sequence[str] | None = "ad_reward_boost_round_id" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + with op.batch_alter_table("signin_boost_record", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_signin_boost_record_user_id")) + op.drop_table("signin_boost_record") + + +def downgrade() -> None: + # 只还结构不还数据(见模块 docstring) + op.create_table( + "signin_boost_record", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("signin_date", sa.Date(), nullable=False), + sa.Column("coin_awarded", sa.Integer(), nullable=False), + sa.Column("ad_ref_id", sa.String(length=64), nullable=True), + sa.Column( + "created_at", sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False, + ), + sa.ForeignKeyConstraint(["user_id"], ["user.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "signin_date", name="uq_signin_boost_user_date"), + ) + with op.batch_alter_table("signin_boost_record", schema=None) as batch_op: + batch_op.create_index( + batch_op.f("ix_signin_boost_record_user_id"), ["user_id"], unique=False + ) diff --git a/alembic/versions/drop_withdraw_active_uniq_idx.py b/alembic/versions/drop_withdraw_active_uniq_idx.py new file mode 100644 index 0000000..13ef3b7 --- /dev/null +++ b/alembic/versions/drop_withdraw_active_uniq_idx.py @@ -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')"), + ) diff --git a/alembic/versions/guide_video_play_table.py b/alembic/versions/guide_video_play_table.py new file mode 100644 index 0000000..3c6a62e --- /dev/null +++ b/alembic/versions/guide_video_play_table.py @@ -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") diff --git a/alembic/versions/guide_video_play_user_seq_unique.py b/alembic/versions/guide_video_play_user_seq_unique.py new file mode 100644 index 0000000..e4c105e --- /dev/null +++ b/alembic/versions/guide_video_play_user_seq_unique.py @@ -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") diff --git a/alembic/versions/meituan_coupon_feed_indexes.py b/alembic/versions/meituan_coupon_feed_indexes.py new file mode 100644 index 0000000..c120ca6 --- /dev/null +++ b/alembic/versions/meituan_coupon_feed_indexes.py @@ -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") diff --git a/alembic/versions/merge_signin_boost_main.py b/alembic/versions/merge_signin_boost_main.py new file mode 100644 index 0000000..8838a4e --- /dev/null +++ b/alembic/versions/merge_signin_boost_main.py @@ -0,0 +1,32 @@ +"""合并两个 alembic head:drop_signin_boost_record(本分支)+ merge_active_phone(main)。 + +两条线同从 comparison_llm_cost 分叉——本分支的 ad_reward_boost_round_id → drop_signin_boost_record +走「金币膨胀本轮累计 + 下线签到膨胀」;main 侧的 phone_rebind_log / analytics_active_idx 两支已由 +merge_active_phone 收敛。88f2380 把 main 合进本分支后,两条迁移线在 git 上汇合了、在 alembic 图上 +却没有,于是 `alembic upgrade head`(单数)报 "Multiple head revisions are present"——按 CLAUDE.md +run.sh 启动即自动迁移,app server 会直接起不来。 + +本迁移仅把二者收敛成单 head;**不含任何表结构 / 数据改动**(纯 merge)。 + +Revision ID: merge_signin_boost_main +Revises: drop_signin_boost_record, merge_active_phone +Create Date: 2026-07-21 00:00:00.000000 +""" + +from collections.abc import Sequence + +revision: str = "merge_signin_boost_main" +down_revision: str | Sequence[str] | None = ( + "drop_signin_boost_record", + "merge_active_phone", +) +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """纯合并 head,无 schema 改动。""" + + +def downgrade() -> None: + """拆回两个 head,无 schema 改动。""" diff --git a/alembic/versions/monitoring_audit_rbac.py b/alembic/versions/monitoring_audit_rbac.py new file mode 100644 index 0000000..397c067 --- /dev/null +++ b/alembic/versions/monitoring_audit_rbac.py @@ -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]) + ) diff --git a/alembic/versions/notification_table.py b/alembic/versions/notification_table.py new file mode 100644 index 0000000..aff72a6 --- /dev/null +++ b/alembic/versions/notification_table.py @@ -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') diff --git a/alembic/versions/risk_monitor_generic_tables.py b/alembic/versions/risk_monitor_generic_tables.py new file mode 100644 index 0000000..b4d4c3e --- /dev/null +++ b/alembic/versions/risk_monitor_generic_tables.py @@ -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") diff --git a/alembic/versions/user_manual_risk_fields.py b/alembic/versions/user_manual_risk_fields.py new file mode 100644 index 0000000..9c3616e --- /dev/null +++ b/alembic/versions/user_manual_risk_fields.py @@ -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") diff --git a/app/admin/deps.py b/app/admin/deps.py index ee3d656..8d346f8 100644 --- a/app/admin/deps.py +++ b/app/admin/deps.py @@ -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。 diff --git a/app/admin/main.py b/app/admin/main.py index 7345803..ec64c34 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -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,20 +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") @@ -52,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") @@ -96,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) @@ -109,4 +117,5 @@ admin_app.include_router(cps_router) admin_app.include_router(coupon_data_router) admin_app.include_router(ad_audit_router) admin_app.include_router(ad_config_router) +admin_app.include_router(huawei_review_router) admin_app.include_router(ad_revenue_router) diff --git a/app/admin/permissions.py b/app/admin/permissions.py index ca0ee33..6ac62b4 100644 --- a/app/admin/permissions.py +++ b/app/admin/permissions.py @@ -21,22 +21,28 @@ 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": "用户反馈"}, ]}, {"group": "数据配置", "pages": [ {"key": "config", "label": "系统配置"}, {"key": "ad-revenue", "label": "广告配置"}, + {"key": "huawei-review", "label": "华为审核开关"}, {"key": "users", "label": "用户管理"}, ]}, + {"group": "监控审计", "pages": [ + {"key": "risk-monitor", "label": "风控监控"}, + {"key": "device-liveness", "label": "设备存活"}, + {"key": "analytics-health", "label": "埋点成功率"}, + {"key": "event-logs", "label": "埋点日志"}, + {"key": "audit-logs", "label": "审计日志"}, + ]}, {"group": "其他", "pages": [ {"key": "admins", "label": "权限管理"}, - {"key": "event-logs", "label": "埋点日志"}, - {"key": "audit-logs", "label": "审计日志"}, ]}, ] @@ -51,13 +57,14 @@ BUILTIN_ROLES: list[dict] = [ {"name": SUPER_ADMIN_ROLE, "label": "管理员", "pages": []}, {"name": "operator", "label": "运营", "pages": [ "dashboard", "coupon-data", "ad-revenue-report", "comparison-records", - "cps", "device-liveness", "price-reports", "feedbacks", + "cps", "risk-monitor", "device-liveness", "price-reports", "feedbacks", "huawei-review", ]}, {"name": "finance", "label": "财务", "pages": [ - "dashboard", "ad-revenue-report", "cps", "withdraws", + "dashboard", "ad-revenue-report", "cps", "invite-withdraws", "withdraws", ]}, {"name": "tech", "label": "技术", "pages": [ - "dashboard", "device-liveness", "config", "ad-revenue", "event-logs", "audit-logs", + "dashboard", "risk-monitor", "device-liveness", "analytics-health", "config", "ad-revenue", "huawei-review", + "event-logs", "audit-logs", ]}, ] diff --git a/app/admin/repositories/ad_audit.py b/app/admin/repositories/ad_audit.py index 888467c..0be72ce 100644 --- a/app/admin/repositories/ad_audit.py +++ b/app/admin/repositories/ad_audit.py @@ -12,10 +12,11 @@ """ from __future__ import annotations -from sqlalchemy import func, select +from sqlalchemy import func, or_, select from sqlalchemy.orm import Session from app.core import rewards +from app.models.ad_ecpm import AdEcpmRecord from app.models.ad_feed_reward import AdFeedRewardRecord from app.models.ad_reward import AdRewardRecord from app.repositories.ad_feed_reward import FEED_REWARD_UNIT_SECONDS @@ -55,10 +56,21 @@ def _reward_video_rows( if user_id is not None: stmt = stmt.where(AdRewardRecord.user_id == user_id) + records = list(db.execute(stmt).scalars()) + # S2S 发奖回调不携带实际填充 ADN;用相同用户和 ad_session_id 的展示记录回填。 + session_ids = {record.ad_session_id for record in records if record.ad_session_id} + impression_by_session = { + (record.user_id, record.ad_session_id): record + for record in db.execute( + select(AdEcpmRecord).where(AdEcpmRecord.ad_session_id.in_(session_ids)) + ).scalars() + } if session_ids else {} + # 用本日之前的累计份数做起点,当日 granted 在其上继续递增 → 与 _granted_cumulative+1 对齐 granted_n: dict[int, int] = _prior_granted_counts(db, date=date, user_id=user_id) rows: list[dict] = [] - for rec in db.execute(stmt).scalars(): + for rec in records: + impression = impression_by_session.get((rec.user_id, rec.ad_session_id)) if rec.status == "granted": nth = granted_n.get(rec.user_id, 0) + 1 granted_n[rec.user_id] = nth @@ -68,6 +80,8 @@ def _reward_video_rows( "record_id": rec.id, "user_id": rec.user_id, "ad_session_id": rec.ad_session_id, + "adn": impression.adn if impression is not None else None, + "slot_id": impression.slot_id if impression is not None else None, "app_env": rec.app_env, "our_code_id": rec.our_code_id, "created_at": rec.created_at, @@ -90,6 +104,8 @@ def _reward_video_rows( "record_id": rec.id, "user_id": rec.user_id, "ad_session_id": rec.ad_session_id, + "adn": impression.adn if impression is not None else None, + "slot_id": impression.slot_id if impression is not None else None, "app_env": rec.app_env, "our_code_id": rec.our_code_id, "created_at": rec.created_at, @@ -149,6 +165,78 @@ def _feed_scene_matches(rec: AdFeedRewardRecord, scene: str | None) -> bool: return True +def _nonblank(value: str | None) -> str | None: + value = value.strip() if value else None + return value or None + + +def _unique_ad_source(records: list[AdEcpmRecord]) -> tuple[str | None, str | None]: + """仅在候选展示记录指向唯一 ADN 时回填来源,避免错误归因。""" + adns = {_nonblank(record.adn) for record in records} + adns.discard(None) + if len(adns) != 1: + return None, None + + slots = {_nonblank(record.slot_id) for record in records} + slots.discard(None) + return next(iter(adns)), next(iter(slots)) if len(slots) == 1 else None + + +def _feed_source_fallbacks( + db: Session, records: list[AdFeedRewardRecord] +) -> tuple[ + dict[tuple[int, str], tuple[str | None, str | None]], + dict[tuple[int, str, str], tuple[str | None, str | None]], +]: + """为旧信息流发奖记录构建安全来源索引。""" + session_ids = {record.ad_session_id for record in records if record.ad_session_id} + trace_ids = {record.trace_id for record in records if record.trace_id} + if not session_ids and not trace_ids: + return {}, {} + + filters = [] + if session_ids: + filters.append(AdEcpmRecord.ad_session_id.in_(session_ids)) + if trace_ids: + filters.append(AdEcpmRecord.trace_id.in_(trace_ids)) + impressions = list(db.execute(select(AdEcpmRecord).where(or_(*filters))).scalars()) + + by_session: dict[tuple[int, str], list[AdEcpmRecord]] = {} + by_trace_ecpm: dict[tuple[int, str, str], list[AdEcpmRecord]] = {} + for impression in impressions: + if impression.ad_session_id: + by_session.setdefault((impression.user_id, impression.ad_session_id), []).append(impression) + if impression.trace_id: + by_trace_ecpm.setdefault( + (impression.user_id, impression.trace_id, impression.ecpm_raw), [] + ).append(impression) + + return ( + {key: _unique_ad_source(value) for key, value in by_session.items()}, + {key: _unique_ad_source(value) for key, value in by_trace_ecpm.items()}, + ) + + +def _feed_source( + record: AdFeedRewardRecord, + *, + by_session: dict[tuple[int, str], tuple[str | None, str | None]], + by_trace_ecpm: dict[tuple[int, str, str], tuple[str | None, str | None]], +) -> tuple[str | None, str | None]: + """返回本条发奖广告的来源;无唯一证据时保留原始空值。""" + adn, slot_id = _nonblank(record.adn), _nonblank(record.slot_id) + if adn and slot_id: + return adn, slot_id + + candidate = by_session.get((record.user_id, record.ad_session_id or "")) + if candidate is None and record.trace_id: + candidate = by_trace_ecpm.get((record.user_id, record.trace_id, record.ecpm_raw)) + if candidate is None: + return adn, slot_id + candidate_adn, candidate_slot_id = candidate + return adn or candidate_adn, slot_id or candidate_slot_id + + def _feed_rows( db: Session, *, date: str, user_id: int | None, scene: str | None = None ) -> list[dict]: @@ -167,11 +255,17 @@ def _feed_rows( if user_id is not None: stmt = stmt.where(AdFeedRewardRecord.user_id == user_id) + records = list(db.execute(stmt).scalars()) + by_session, by_trace_ecpm = _feed_source_fallbacks(db, records) + # 本日之前的累计**条数**做起点,与发奖侧 granted_unit_total(COUNT granted)对齐 granted_count: dict[int, int] = _feed_prior_granted_count(db, date=date, user_id=user_id) rows: list[dict] = [] - for rec in db.execute(stmt).scalars(): + for rec in records: keep = _feed_scene_matches(rec, scene) # 累计照常推进,这里只决定是否展示本行 + adn, slot_id = _feed_source( + rec, by_session=by_session, by_trace_ecpm=by_trace_ecpm + ) if rec.status == "granted": # 一条广告 = 1 份(与 grant_feed_reward 同口径:看满一份即发该条满额,不按 unit_count 累加)。 # nth = 账号累计第几**条**(含本日之前),与发奖侧 granted_unit_total+1 对齐;累计照常推进 @@ -188,6 +282,8 @@ def _feed_rows( "record_id": rec.id, "user_id": rec.user_id, "ad_session_id": rec.ad_session_id, + "adn": adn, + "slot_id": slot_id, "trace_id": rec.trace_id, "app_env": rec.app_env, "our_code_id": rec.our_code_id, @@ -214,6 +310,8 @@ def _feed_rows( "record_id": rec.id, "user_id": rec.user_id, "ad_session_id": rec.ad_session_id, + "adn": adn, + "slot_id": slot_id, "trace_id": rec.trace_id, "app_env": rec.app_env, "our_code_id": rec.our_code_id, diff --git a/app/admin/repositories/ad_revenue.py b/app/admin/repositories/ad_revenue.py index b1e2162..6ce16b4 100644 --- a/app/admin/repositories/ad_revenue.py +++ b/app/admin/repositories/ad_revenue.py @@ -33,7 +33,29 @@ from app.admin.repositories import stats as admin_stats from app.core import rewards from app.models.ad_ecpm import AdEcpmRecord from app.models.user import User -from app.repositories import ad_pangle_revenue +from app.repositories import ad_pangle_revenue, app_config + +# 已上线过的正式业务代码位要永久保留,避免运营切换当前配置后,历史报表把旧业务位误判成测试流量。 +_KNOWN_PROD_BUSINESS_CODE_IDS = frozenset({"104098712", "104099389"}) + +# 测试应用中实际承载业务链路的代码位。广告测试 demo 的插屏/半屏/信息流测试位不在这里, +# 避免“业务口径”把开发诊断曝光混进客户端与穿山甲对账。 +_TEST_BUSINESS_CODE_IDS = frozenset({"104127529", "104127626", "104137445"}) + + +def _business_code_ids(db: Session, app_env: str | None) -> set[str]: + """返回指定应用环境下可用于业务收益对账的 GroMore 聚合代码位。""" + prod_config = app_config.get_ad_config(db) + prod_ids = set(_KNOWN_PROD_BUSINESS_CODE_IDS) | { + str(prod_config.get(key) or "").strip() + for key in ("reward_code_id", "compare_draw_code_id", "coupon_draw_code_id") + } + prod_ids.discard("") + if app_env == "prod": + return prod_ids + if app_env == "test": + return set(_TEST_BUSINESS_CODE_IDS) + return prod_ids | set(_TEST_BUSINESS_CODE_IDS) def _cn_hour(dt: datetime) -> int: @@ -59,6 +81,10 @@ def _date_range(date_from: str, date_to: str) -> list[str]: # ad_feed_reward_record,由 audit 内部按 ad_type 区分(feed 含历史 NULL,draw 仅 ad_type=="draw")。 _AUDIT_SCENES = {"reward_video", "feed", "draw"} +# 激励视频未满足有效播放条件时不计客户端预估收益。客户端仍会在 onAdShow +# 上报 eCPM,随后才在关闭时补报以下终态,因此必须在展示/发奖合并后修正收益。 +_ZERO_REVENUE_REWARD_VIDEO_STATUSES = frozenset({"closed_early", "too_short"}) + # 发奖复算明细字段(展开下钻看「金币怎么算出来的」)——从 audit 行原样取这些 key。 _REWARD_DETAIL_KEYS = ( @@ -70,7 +96,11 @@ _REWARD_DETAIL_KEYS = ( def _reward_detail(row: dict) -> dict: """从 audit 行抽出发奖复算明细(给前端展开行渲染因子1/因子2/份数/LT/应发实发)。""" - return {k: row[k] for k in _REWARD_DETAIL_KEYS} + detail = {key: row[key] for key in _REWARD_DETAIL_KEYS} + # 聚合父行可能包含多个 ADN,来源必须保留在每一条发奖明细上。 + detail["adn"] = row.get("adn") + detail["slot_id"] = row.get("slot_id") + return detail def ad_revenue_report( @@ -82,6 +112,7 @@ def ad_revenue_report( ad_type: str | None = None, feed_scene: str | None = None, app_env: str | None = None, + revenue_scope: str = "all", granularity: str = "day", limit: int = 500, offset: int = 0, @@ -179,6 +210,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({ @@ -212,8 +248,8 @@ def ad_revenue_report( "impressions": 0, "ecpm": row["ecpm"], "revenue_yuan": 0.0, - "adn": None, - "slot_id": None, + "adn": row.get("adn"), + "slot_id": row.get("slot_id"), "has_reward": True, "status": row["status"], "expected_coin": int(row["expected_coin"]), @@ -277,14 +313,18 @@ def ad_revenue_report( if feed_scene is not None: events = [e for e in events if e.get("feed_scene") == feed_scene] - # app_env 过滤(2026-06-29 新增能力,修隐患:测试应用上报的假 eCPM 如 ¥678 CPM 会污染正式收益合计/平均): - # 显式传 "prod"/"test" 只看该环境;不传=全部(维持现状)。**不擅自把默认改成排除 test**——本地 dev 库多为 - # test 数据、默认排除会使本地报表空,且「正式报表是否含 test」属产品口径。建议前端报表页加 app_env 筛选器 - # (默认选 prod),或产品确认后再把默认改成排除 test。注:穿山甲后台收益列(total_pangle_*)暂未联动此过滤 - # (它是独立对照列,且 pangle 的 test 是真实小额、非客户端那种假值)。 + # app_env 过滤:显式传 "prod"/"test" 只看该环境;不传=全部。该参数也会传给下方穿山甲聚合, + # 保证客户端预估与 GroMore 汇总使用同一应用环境口径。 if app_env is not None: events = [e for e in events if e.get("app_env") == app_env] + # 业务口径仅保留正式配置/测试业务链路实际使用的代码位。穿山甲“全量”还包含广告测试 + # demo、插屏等没有客户端收益上报的曝光,两边直接比较会天然产生假差额。 + business_code_ids: set[str] | None = None + if revenue_scope == "business": + business_code_ids = _business_code_ids(db, app_env) + events = [e for e in events if e.get("our_code_id") in business_code_ids] + # 排序:time=按时间倒序(新→旧);ecpm=按 eCPM 数值倒序(eCPM 原值是字符串「分」,转数值排; # 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。 if sort == "ecpm": @@ -336,7 +376,13 @@ def ad_revenue_report( total_pangle_revenue_yuan: float | None = None total_pangle_api_revenue_yuan: float | None = None if pangle_filterable: - pangle_aggs = ad_pangle_revenue.aggregate_by_date(db, date_from=date_from, date_to=date_to) + pangle_aggs = ad_pangle_revenue.aggregate_by_date( + db, + date_from=date_from, + date_to=date_to, + app_env=app_env, + our_code_ids=business_code_ids, + ) if pangle_aggs: by_date = {a["date"]: a for a in pangle_aggs} for d in daily: @@ -385,6 +431,38 @@ def ad_revenue_report( for k, v in type_map.items() } + # 经营看板的两类 eCPM 必须按真实展示的 SDK eCPM 加权,不能用发奖状态修正后的 + # 收益反推。Draw 包含新 draw 与历史 feed;看视频包含福利与提现视频。 + category_map: dict[str, dict] = {} + for event in events: + category = ( + "draw" if event["ad_type"] in {"draw", "feed"} + else "video" if event["ad_type"] in {"reward_video", "withdrawal_video"} + else None + ) + if category is None: + continue + stat = category_map.setdefault(category, { + "impressions": 0, + "revenue_yuan": 0.0, + "ecpm_fen_sum": 0.0, + }) + impressions = int(event["impressions"]) + stat["impressions"] += impressions + stat["revenue_yuan"] += event["revenue_yuan"] + stat["ecpm_fen_sum"] += rewards.parse_ecpm_fen(event["ecpm"]) * impressions + category_stats = { + key: { + "impressions": value["impressions"], + "revenue_yuan": round(value["revenue_yuan"], 6), + "ecpm_yuan": round( + value["ecpm_fen_sum"] / value["impressions"] / 100.0, + 6, + ) if value["impressions"] else 0.0, + } + for key, value in category_map.items() + } + # 分场景小计(按 feed_scene:展示条数 + 预估收益),同 type_stats 基于全量 events—— # 供数据大盘「领券广告 / 比价广告」卡用。此前大盘是在分页 items 里按 feed_scene 现算, # 2026-07-02 起信息流逐条展示行(唯一带收益 + 场景的行)不再进主表 items,现算恒为 0; @@ -439,6 +517,7 @@ def ad_revenue_report( "daily": daily, "hourly": hourly, "type_stats": type_stats, + "category_stats": category_stats, "scene_stats": scene_stats, "dau": dau, "items": main_rows[offset:offset + limit], diff --git a/app/admin/repositories/coupon_data.py b/app/admin/repositories/coupon_data.py index 0450404..f7f8179 100644 --- a/app/admin/repositories/coupon_data.py +++ b/app/admin/repositories/coupon_data.py @@ -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: diff --git a/app/admin/repositories/cps.py b/app/admin/repositories/cps.py index 419c62f..7fd177b 100644 --- a/app/admin/repositories/cps.py +++ b/app/admin/repositories/cps.py @@ -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( diff --git a/app/admin/repositories/mutations.py b/app/admin/repositories/mutations.py index f667811..7fd4cf5 100644 --- a/app/admin/repositories/mutations.py +++ b/app/admin/repositories/mutations.py @@ -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: diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index c9e0b74..41f113c 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -5,10 +5,11 @@ """ from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import date, datetime, time, timedelta, timezone +from decimal import ROUND_HALF_UP, Decimal from zoneinfo import ZoneInfo -from sqlalchemy import Select, asc, case, desc, func, or_, select +from sqlalchemy import Select, and_, asc, case, desc, func, or_, select from sqlalchemy.orm import Session from app.core import rewards @@ -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 @@ -44,6 +46,106 @@ _FEED_SCENE_LABEL = { } +_DEVICE_MARKETING_NAMES = { + "23078RKD5C": "Redmi K60 至尊版", + "M2012K11AC": "Redmi K40", + "PJA110": "一加 Ace 2 Pro", + "PPG-AN00": "荣耀 GT Pro", + "V2166BA": "vivo Y77e", + "V2309A": "vivo X100", +} + + +def _device_marketing_name(model: str | None) -> str | None: + """把线上已知 Build.MODEL 编码转成用户可识别的商品名。""" + if not model: + return None + return _DEVICE_MARKETING_NAMES.get(model.strip().upper()) + + +def _attach_comparison_order_status(db: Session, items: list[ComparisonRecord]) -> None: + """按 C 端既有口径给比价记录批量补充是否真实下单。""" + user_ids = {item.user_id for item in items if item.user_id is not None} + shop_names = {item.store_name for item in items if item.store_name} + ordered_pairs: set[tuple[int, str]] = set() + if user_ids and shop_names: + rows = db.execute( + select(SavingsRecord.user_id, SavingsRecord.shop_name) + .where( + SavingsRecord.user_id.in_(user_ids), + SavingsRecord.source == "compare", + SavingsRecord.shop_name.in_(shop_names), + ) + .distinct() + ).all() + ordered_pairs = { + (row.user_id, row.shop_name) + for row in rows + if row.shop_name is not None + } + for item in items: + item.ordered = bool( + item.user_id is not None + and item.store_name + and (item.user_id, item.store_name) in ordered_pairs + ) + + +def _attach_comparison_device_details(items: list[ComparisonRecord]) -> None: + """给比价记录补充可读机型名,同时保留原始设备编码。""" + for item in items: + item.device_model_name = _device_marketing_name(item.device_model) + + +def _attach_feedback_device_details(db: Session, feedbacks: list[Feedback]) -> None: + """按同一用户、同一设备编码及提交时间补齐厂商和 ROM 大版本。""" + candidates = [ + item for item in feedbacks if item.device_model and item.device_model.strip() + ] + for item in candidates: + item.device_model_name = _device_marketing_name(item.device_model) + item.device_manufacturer = None + item.rom_version = None + if not candidates: + return + + ranked = ( + select( + Feedback.id.label("feedback_id"), + ComparisonRecord.device_manufacturer.label("device_manufacturer"), + ComparisonRecord.rom_version.label("rom_version"), + func.row_number() + .over( + partition_by=Feedback.id, + order_by=( + ComparisonRecord.created_at.desc(), + ComparisonRecord.id.desc(), + ), + ) + .label("row_num"), + ) + .join( + ComparisonRecord, + and_( + ComparisonRecord.user_id == Feedback.user_id, + ComparisonRecord.device_model == Feedback.device_model, + ComparisonRecord.created_at <= Feedback.created_at, + ), + ) + .where(Feedback.id.in_([item.id for item in candidates])) + .subquery() + ) + details = { + row.feedback_id: row + for row in db.execute(select(ranked).where(ranked.c.row_num == 1)).all() + } + + for item in candidates: + detail = details.get(item.id) + item.device_manufacturer = detail.device_manufacturer if detail else None + item.rom_version = detail.rom_version if detail else None + + def cursor_paginate( db: Session, stmt: Select, id_col, *, limit: int, cursor: int | None ) -> tuple[list, int | None]: @@ -145,7 +247,7 @@ def list_users( 代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。 日期入参统一转 tz-aware UTC 比较(列为 timestamptz,见 _as_utc)。""" # 最近活跃 = max(注册时间, 最近行为事件, 最近领券发起)。baseline 由 last_login_at 改为 created_at - #(登录不代表在用 App;口径统一到 activity.py,含 home_view + 比价 + 领券,见 activity.ACTIVE_EVENTS)。 + #(登录不代表在用 App;口径统一到 activity.py,含 home_visible + 比价 + 领券,见 activity.ACTIVE_EVENTS)。 # 未命中侧 coalesce 到 created_at(恒非空基线)。派生表 1:1,outerjoin 不放大行数。 ev_agg, eng_agg = activity.last_active_subqueries(db) last_active = activity.last_active_expr( @@ -209,6 +311,45 @@ def _attach_user_info(db: Session, records: list[ComparisonRecord | Feedback | P r.nickname = nick +def _comparison_conditions( + *, + user_id: int | None = None, + phone: str | None = None, + status: str | None = None, + business_type: str | None = None, + store: str | None = None, + product: str | None = None, + date_from: date | None = None, + date_to: date | None = None, +) -> list: + """比价列表与概览共用筛选条件;日期按北京自然日闭区间解释。""" + conditions = [] + if user_id is not None: + conditions.append(ComparisonRecord.user_id == user_id) + if phone: + conditions.append( + ComparisonRecord.user_id.in_( + select(User.id).where(User.phone.like(f"{phone}%")) + ) + ) + if status: + conditions.append(ComparisonRecord.status == status) + if business_type: + conditions.append(ComparisonRecord.business_type == business_type) + if store: + conditions.append(ComparisonRecord.store_name.like(f"%{store}%")) + if product: + conditions.append(ComparisonRecord.product_names.like(f"%{product}%")) + beijing = ZoneInfo("Asia/Shanghai") + if date_from is not None: + start_utc = datetime.combine(date_from, time.min, tzinfo=beijing).astimezone(timezone.utc) + conditions.append(ComparisonRecord.created_at >= start_utc) + if date_to is not None: + end_utc = datetime.combine(date_to + timedelta(days=1), time.min, tzinfo=beijing).astimezone(timezone.utc) + conditions.append(ComparisonRecord.created_at < end_utc) + return conditions + + def list_comparison_records( db: Session, *, @@ -218,36 +359,27 @@ def list_comparison_records( business_type: str | None = None, store: str | None = None, product: str | None = None, + date_from: date | None = None, + date_to: date | None = None, limit: int = 20, cursor: int | None = None, ) -> tuple[list[ComparisonRecord], int | None, int]: """admin 比价记录列表(debug)。按 user_id 精确 或 phone 前缀定位用户 + status/业务类型筛, store(店名)/product(商品名)子串模糊匹配,offset 分页(创建时间倒序、id 兜底)。 join User 取 phone/nickname 瞬态挂记录上。""" - stmt = select(ComparisonRecord) - if user_id is not None: - stmt = stmt.where(ComparisonRecord.user_id == user_id) - if phone: - stmt = stmt.where( - ComparisonRecord.user_id.in_( - select(User.id).where(User.phone.like(f"{phone}%")) - ) - ) - if status: - stmt = stmt.where(ComparisonRecord.status == status) - if business_type: - stmt = stmt.where(ComparisonRecord.business_type == business_type) - if store: - stmt = stmt.where(ComparisonRecord.store_name.like(f"%{store}%")) - if product: - # 商品名搜 product_names 派生文本列(非 items JSON:SQLite 下 JSON 中文被转义无法直接 LIKE)。 - stmt = stmt.where(ComparisonRecord.product_names.like(f"%{product}%")) + conditions = _comparison_conditions( + user_id=user_id, phone=phone, status=status, business_type=business_type, + store=store, product=product, date_from=date_from, date_to=date_to, + ) + stmt = select(ComparisonRecord).where(*conditions) items, next_cursor, total = offset_paginate( db, stmt, (desc(ComparisonRecord.created_at), desc(ComparisonRecord.id)), limit=limit, cursor=cursor, ) _attach_user_info(db, items) + _attach_comparison_order_status(db, items) + _attach_comparison_device_details(items) # 「本次比价看广告的预估收益」:按本页 trace_id 一次性聚合(同 _attach_user_info 逐页范式)。 # ad_revenue_yuan 非 ORM 列,仅瞬态挂实例上供 AdminComparisonListItem(from_attributes)读出。 rev = ad_ecpm.revenue_yuan_by_trace(db, [it.trace_id for it in items]) @@ -256,11 +388,146 @@ def list_comparison_records( return items, next_cursor, total +def _comparison_percentile(sorted_values: list[int], q: float) -> int | None: + """线性插值分位数(非负毫秒值四舍五入;单条数据返回自身)。""" + if not sorted_values: + return None + if len(sorted_values) == 1: + return sorted_values[0] + index = (len(sorted_values) - 1) * q + lower = int(index) + upper = min(lower + 1, len(sorted_values) - 1) + value = sorted_values[lower] * (upper - index) + sorted_values[upper] * (index - lower) + return int(value + 0.5) + + +def _round_duration_ms(value) -> int | None: + """将数据库聚合结果按既有口径四舍五入为整数毫秒。""" + if value is None: + return None + return int(Decimal(str(value)).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) + + +def _comparison_duration_aggregate_stmt(conditions: list, status: str, quantiles: tuple[float, ...]): + """PostgreSQL 耗时聚合语句;每种状态只返回一行。""" + return select( + func.avg(ComparisonRecord.total_ms), + *( + func.percentile_cont(q).within_group(ComparisonRecord.total_ms) + for q in quantiles + ), + ).where( + *conditions, + ComparisonRecord.status == status, + ComparisonRecord.total_ms.is_not(None), + ) + + +def _comparison_duration_aggregates( + db: Session, + *, + conditions: list, + status: str, + quantiles: tuple[float, ...], +) -> list[int | None]: + """返回平均值和各分位数;生产 PG 在数据库内聚合,SQLite 仅作测试回退。""" + if db.bind is not None and db.bind.dialect.name == "postgresql": + row = db.execute( + _comparison_duration_aggregate_stmt(conditions, status, quantiles) + ).one() + return [_round_duration_ms(value) for value in row] + + # SQLite 没有 percentile_cont;本地/测试只回退读取耗时单列,不加载完整记录。 + values = list( + db.execute( + select(ComparisonRecord.total_ms) + .where( + *conditions, + ComparisonRecord.status == status, + ComparisonRecord.total_ms.is_not(None), + ) + .order_by(ComparisonRecord.total_ms) + ).scalars() + ) + average = _round_duration_ms(sum(values) / len(values)) if values else None + return [average, *(_comparison_percentile(values, q) for q in quantiles)] + + +def comparison_records_summary( + db: Session, + *, + user_id: int | None = None, + phone: str | None = None, + status: str | None = None, + business_type: str | None = None, + store: str | None = None, + product: str | None = None, + date_from: date | None = None, + date_to: date | None = None, +) -> dict: + """比价记录页概览聚合;主耗时均值及分位数只取成功记录。""" + conditions = _comparison_conditions( + user_id=user_id, phone=phone, status=status, business_type=business_type, + store=store, product=product, date_from=date_from, date_to=date_to, + ) + row = db.execute( + select( + func.count(ComparisonRecord.id), + func.sum(case((ComparisonRecord.status.in_(("success", "failed")), 1), else_=0)), + func.sum(case((ComparisonRecord.status == "success", 1), else_=0)), + func.avg(ComparisonRecord.llm_cost_yuan), + func.sum(case(( + (ComparisonRecord.status == "success") + & (ComparisonRecord.saved_amount_cents > 0), 1 + ), else_=0)), + func.sum(case((ComparisonRecord.status == "cancelled", 1), else_=0)), + ).where(*conditions) + ).one() + started = int(row[0] or 0) + completed = int(row[1] or 0) + success = int(row[2] or 0) + lower_price = int(row[4] or 0) + cancelled = int(row[5] or 0) + success_duration_stats = _comparison_duration_aggregates( + db, + conditions=conditions, + status="success", + quantiles=(0.05, 0.5, 0.95, 0.99), + ) + cancelled_duration_stats = _comparison_duration_aggregates( + db, + conditions=conditions, + status="cancelled", + quantiles=(0.05, 0.5, 0.95), + ) + success_rate_denominator = started - cancelled + return { + "started": started, + "completed": completed, + "success": success, + "success_rate": success / success_rate_denominator if success_rate_denominator else None, + "avg_token_cost": float(row[3]) if row[3] is not None else None, + "lower_price_rate": lower_price / success if success else None, + "avg_duration_ms": success_duration_stats[0], + "p5_duration_ms": success_duration_stats[1], + "p50_duration_ms": success_duration_stats[2], + "p95_duration_ms": success_duration_stats[3], + "p99_duration_ms": success_duration_stats[4], + "cancelled": cancelled, + "cancelled_rate": cancelled / started if started else None, + "cancelled_p5_ms": cancelled_duration_stats[1], + "cancelled_p50_ms": cancelled_duration_stats[2], + "cancelled_p95_ms": cancelled_duration_stats[3], + } + + def get_comparison_record(db: Session, record_id: int) -> ComparisonRecord | None: """admin 取单条比价记录(任意用户,不限本人;附 phone/nickname 瞬态)。""" rec = db.get(ComparisonRecord, record_id) if rec is not None: _attach_user_info(db, [rec]) + _attach_comparison_order_status(db, [rec]) + _attach_comparison_device_details([rec]) return rec @@ -548,16 +815,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, @@ -583,7 +841,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 @@ -593,25 +853,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 } @@ -655,6 +922,7 @@ def list_feedbacks( db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor ) _attach_user_info(db, items) # 列表展示完整手机号(点手机号查该用户全部反馈) + _attach_feedback_device_details(db, items) return items, next_cursor, total @@ -717,15 +985,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: - """提现审核台顶部统计。金额单位:分。""" - rows = db.execute( - select( - WithdrawOrder.status, - func.count(WithdrawOrder.id), - func.coalesce(func.sum(WithdrawOrder.amount_cents), 0), - ).group_by(WithdrawOrder.status) - ).all() +def withdraw_summary(db: Session, *, source: str | None = None) -> dict: + """提现审核台顶部统计。金额单位:分;口径与列表的 source 筛选一致。""" + 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 @@ -738,31 +1007,154 @@ 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_success_amount_stmt = select( + func.coalesce(func.sum(WithdrawOrder.amount_cents), 0) + ).where( + WithdrawOrder.status == "success", + WithdrawOrder.updated_at >= today_start, + ) + if source: + today_success_amount_stmt = today_success_amount_stmt.where( + WithdrawOrder.source == source + ) + today_success_amount = db.execute(today_success_amount_stmt).scalar_one() + + def _status_count(status: str) -> int: + return by_status.get(status, {}).get("count", 0) return { - "reviewing_count": by_status.get("reviewing", {}).get("count", 0), + "reviewing_count": _status_count("reviewing"), "reviewing_amount_cents": by_status.get("reviewing", {}).get("amount_cents", 0), - "pending_count": by_status.get("pending", {}).get("count", 0), - "failed_count": by_status.get("failed", {}).get("count", 0), + "pending_count": _status_count("pending"), + "success_count": _status_count("success"), + "rejected_count": _status_count("rejected"), + "failed_count": _status_count("failed"), + "total_count": sum(item["count"] for item in by_status.values()), "today_success_count": _today_count("success"), "today_success_amount_cents": int(today_success_amount), "today_rejected_count": _today_count("rejected"), } +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]: @@ -783,8 +1175,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: @@ -807,125 +1197,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) @@ -985,6 +1256,7 @@ def user_reward_stats( *, date_from: datetime | None = None, date_to: datetime | None = None, + withdraw_source: str | None = None, ) -> dict: """提现详情「用户统计区」10 项。窗口作用于除「现金余额」外的所有项(余额是当前快照)。 @@ -992,41 +1264,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) @@ -1060,6 +1351,11 @@ def _cn_wall_to_utc(dt: datetime) -> datetime: return dt.replace(tzinfo=rewards.CN_TZ).astimezone(timezone.utc).replace(tzinfo=None) +def _coin_record_sort_key(row: dict) -> datetime: + """金币明细跨数据源排序键:兼容 SQLite naive 与 PostgreSQL aware 时间。""" + return _as_utc(row["created_at"]) + + def user_coin_records( db: Session, user_id: int, @@ -1084,8 +1380,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", @@ -1093,7 +1395,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, @@ -1104,7 +1406,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", @@ -1112,7 +1419,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, "信息流广告"), @@ -1122,7 +1429,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", @@ -1130,7 +1437,7 @@ def user_coin_records( ) .order_by(CoinTransaction.created_at.desc()) .limit(fetch) - ).scalars(): + ).all(): rows.append({ "source": "signin", "source_label": "签到", @@ -1140,7 +1447,10 @@ def user_coin_records( "coin": rec.amount, }) - rows.sort(key=lambda r: r["created_at"], reverse=True) + # SQLite 常返回 naive datetime,PostgreSQL timestamptz 返回 aware datetime; + # 统一成 aware UTC 排序,避免线上合并广告记录与签到记录时抛 + # “can't compare offset-naive and offset-aware datetimes”。 + rows.sort(key=_coin_record_sort_key, reverse=True) has_more = len(rows) > offset + limit # 总数 = 三源在窗口内 granted 计数之和(供前端页码分页渲染页码/共 N 条) diff --git a/app/admin/repositories/risk_monitor.py b/app/admin/repositories/risk_monitor.py new file mode 100644 index 0000000..dbb986f --- /dev/null +++ b/app/admin/repositories/risk_monitor.py @@ -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, + } diff --git a/app/admin/repositories/stats.py b/app/admin/repositories/stats.py index 260c159..26e13df 100644 --- a/app/admin/repositories/stats.py +++ b/app/admin/repositories/stats.py @@ -6,10 +6,10 @@ user.last_login_at / comparison_record.status / withdraw_order.status)要加索 from __future__ import annotations from collections import Counter -from datetime import date, datetime, time, timedelta, timezone -from decimal import Decimal, InvalidOperation +from datetime import UTC, date, datetime, time, timedelta, timezone +from decimal import ROUND_HALF_UP, Decimal, InvalidOperation -from sqlalchemy import case, func, select +from sqlalchemy import case, func, or_, select from sqlalchemy.orm import Session from app.admin.repositories.coupon_data import _percentile @@ -25,26 +25,24 @@ from app.models.coupon_state import ( from app.models.cps_order import CpsOrder from app.models.feedback import Feedback from app.models.savings import SavingsRecord -from app.models.signin import SigninBoostRecord, SigninRecord +from app.models.signin import SigninRecord from app.models.user import User from app.models.wallet import CoinTransaction, WithdrawOrder _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, @@ -109,6 +107,23 @@ def _date_range(date_from: date, date_to: date) -> list[date]: return [date_from + timedelta(days=i) for i in range(days + 1)] +def _duration_percentile(sorted_values: list[int], q: float) -> int | None: + """Linear-interpolated percentile with the same half-up rounding as Math.round.""" + if not sorted_values: + return None + if len(sorted_values) == 1: + return sorted_values[0] + index = (len(sorted_values) - 1) * q + lower = int(index) + upper = min(lower + 1, len(sorted_values) - 1) + fraction = Decimal(str(index - lower)) + value = ( + Decimal(sorted_values[lower]) * (Decimal(1) - fraction) + + Decimal(sorted_values[upper]) * fraction + ) + return int(value.quantize(Decimal("1"), rounding=ROUND_HALF_UP)) + + def _id_set(db: Session, stmt) -> set[int]: return {int(v) for v in db.execute(stmt).scalars().all() if v is not None} @@ -242,16 +257,46 @@ def dashboard_overview( ComparisonRecord.created_at >= start_local, ComparisonRecord.created_at < end_local, ) - period_comparison_total = _count(ComparisonRecord, *period_comparison_conds) - period_comparison_success = _count( - ComparisonRecord, - *period_comparison_conds, - ComparisonRecord.status == "success", + period_comparison_stats = db.execute( + select( + func.count(ComparisonRecord.id), + func.coalesce( + func.sum( + case( + (ComparisonRecord.status.in_(("success", "failed")), 1), + else_=0, + ) + ), + 0, + ), + func.coalesce( + func.sum( + case((ComparisonRecord.status == "cancelled", 1), else_=0) + ), + 0, + ), + func.coalesce( + func.sum(case((ComparisonRecord.status == "success", 1), else_=0)), + 0, + ), + func.coalesce(func.sum(ComparisonRecord.llm_cost_yuan), 0.0), + ).where(*period_comparison_conds) + ).one() + period_comparison_total = int(period_comparison_stats[0]) + period_comparison_completed = int(period_comparison_stats[1]) + period_comparison_cancelled = int(period_comparison_stats[2]) + period_comparison_success = int(period_comparison_stats[3]) + period_comparison_token_cost_yuan = float(period_comparison_stats[4]) + period_comparison_success_denominator = ( + period_comparison_total - period_comparison_cancelled ) period_comparison_success_rate = ( - round(period_comparison_success / period_comparison_total, 4) - if period_comparison_total - else 0.0 + round( + period_comparison_success / period_comparison_success_denominator, + 4, + ) + if period_comparison_success_denominator > 0 + else None ) period_saved_positive_count = _count( ComparisonRecord, @@ -282,6 +327,47 @@ def dashboard_overview( if period_avg_duration_ms is not None else None ) + completed_duration_conds = ( + *period_comparison_conds, + ComparisonRecord.status.in_(("success", "failed")), + ComparisonRecord.total_ms.is_not(None), + ) + if db.bind is not None and db.bind.dialect.name == "postgresql": + period_median_duration_ms, period_p95_duration_ms = db.execute( + select( + func.percentile_cont(0.5).within_group(ComparisonRecord.total_ms), + func.percentile_cont(0.95).within_group(ComparisonRecord.total_ms), + ).where(*completed_duration_conds) + ).one() + period_median_duration_ms = ( + int( + Decimal(str(period_median_duration_ms)).quantize( + Decimal("1"), rounding=ROUND_HALF_UP + ) + ) + if period_median_duration_ms is not None + else None + ) + period_p95_duration_ms = ( + int( + Decimal(str(period_p95_duration_ms)).quantize( + Decimal("1"), rounding=ROUND_HALF_UP + ) + ) + if period_p95_duration_ms is not None + else None + ) + else: + # SQLite 测试环境没有 percentile_cont;仅回退读取耗时单列,不加载完整记录。 + completed_durations = list( + db.execute( + select(ComparisonRecord.total_ms) + .where(*completed_duration_conds) + .order_by(ComparisonRecord.total_ms) + ).scalars() + ) + period_median_duration_ms = _duration_percentile(completed_durations, 0.5) + period_p95_duration_ms = _duration_percentile(completed_durations, 0.95) ordered_exists = ( select(SavingsRecord.id) @@ -417,7 +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( @@ -590,7 +679,14 @@ def dashboard_overview( CoinTransaction.amount > 0, CoinTransaction.biz_type == "signin_boost", ), - "signin_boost_watch_count": _count(SigninBoostRecord), + # 签到膨胀 2026-07 已下线,signin_boost_record 表随之 drop。这两项保留为**历史口径** + # (钱是真发过的,账要能查回)。次数改数金币流水:一次膨胀 = 一笔 signin_boost 流水, + # 与原来数 signin_boost_record 行数等价。 + "signin_boost_watch_count": _count( + CoinTransaction, + CoinTransaction.biz_type == "signin_boost", + CoinTransaction.amount > 0, + ), }, "cash": { "withdraw_success_cents": _sum( @@ -622,11 +718,16 @@ def dashboard_overview( }, "comparison": { "total": period_comparison_total, + "completed": period_comparison_completed, + "cancelled": period_comparison_cancelled, "success": period_comparison_success, "success_rate": period_comparison_success_rate, "ordered": period_ordered_count, "average_duration_ms": period_avg_duration_ms, + "median_duration_ms": period_median_duration_ms, + "p95_duration_ms": period_p95_duration_ms, "average_saved_cents": period_avg_saved_cents, + "token_cost_total_yuan": period_comparison_token_cost_yuan, }, "coupon": { "started": coupon_started, diff --git a/app/admin/routers/ad_revenue.py b/app/admin/routers/ad_revenue.py index 77685d4..c958e31 100644 --- a/app/admin/routers/ad_revenue.py +++ b/app/admin/routers/ad_revenue.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import date as _date -from typing import Annotated +from typing import Annotated, Literal from fastapi import APIRouter, Depends, HTTPException, Query @@ -63,6 +63,13 @@ def get_ad_revenue_report( "建议正式收益报表选 prod,避免测试应用的假 eCPM 污染收益合计/平均" ), ] = None, + revenue_scope: Annotated[ + Literal["business", "all"], + Query( + description="business=仅业务代码位(用于客户端与穿山甲同口径对账)/ " + "all=穿山甲应用全部代码位(包含广告测试等非业务曝光)" + ), + ] = "all", granularity: Annotated[ str, Query(description="day=按天 / hour=按小时(北京时间);区间>1 天建议用 day") ] = "day", @@ -83,6 +90,7 @@ def get_ad_revenue_report( result = ad_revenue.ad_revenue_report( db, date_from=d_from.isoformat(), date_to=d_to.isoformat(), user_id=user_id, ad_type=ad_type, feed_scene=feed_scene, app_env=app_env, + revenue_scope=revenue_scope, granularity=granularity, limit=limit, offset=offset, sort=sort, ) return AdRevenueReportOut( diff --git a/app/admin/routers/analytics_health.py b/app/admin/routers/analytics_health.py index 517feb3..a3bb85f 100644 --- a/app/admin/routers/analytics_health.py +++ b/app/admin/routers/analytics_health.py @@ -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"))], ) diff --git a/app/admin/routers/audit.py b/app/admin/routers/audit.py index dd7cace..3e35609 100644 --- a/app/admin/routers/audit.py +++ b/app/admin/routers/audit.py @@ -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"))], ) diff --git a/app/admin/routers/comparison.py b/app/admin/routers/comparison.py index 38dc242..8876dc0 100644 --- a/app/admin/routers/comparison.py +++ b/app/admin/routers/comparison.py @@ -5,6 +5,7 @@ trace_url 无条件下发——admin 是内部 debug 工具,不走 C 端 user.de """ from __future__ import annotations +from datetime import date from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query @@ -12,7 +13,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query from app.admin.deps import AdminDb, get_current_admin from app.admin.repositories import queries from app.admin.schemas.common import CursorPage -from app.admin.schemas.comparison import AdminComparisonDetail, AdminComparisonListItem +from app.admin.schemas.comparison import ( + AdminComparisonDetail, + AdminComparisonListItem, + AdminComparisonSummary, +) router = APIRouter( prefix="/admin/api/comparison-records", @@ -34,12 +39,15 @@ def list_comparison_records( business_type: Annotated[str | None, Query()] = None, store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None, product: Annotated[str | None, Query(description="商品名子串模糊匹配")] = None, + date_from: Annotated[date | None, Query(description="北京自然日起始日")] = None, + date_to: Annotated[date | None, Query(description="北京自然日结束日")] = None, limit: Annotated[int, Query(ge=1, le=100)] = 20, cursor: Annotated[int | None, Query()] = None, ) -> CursorPage[AdminComparisonListItem]: items, next_cursor, total = queries.list_comparison_records( db, user_id=user_id, phone=phone, status=status, business_type=business_type, store=store, product=product, + date_from=date_from, date_to=date_to, limit=limit, cursor=cursor, ) return CursorPage( @@ -49,6 +57,29 @@ def list_comparison_records( ) +@router.get( + "/summary", + response_model=AdminComparisonSummary, + summary="比价记录概览聚合", +) +def comparison_records_summary( + db: AdminDb, + user_id: Annotated[int | None, Query()] = None, + phone: Annotated[str | None, Query(description="手机号前缀")] = None, + status: Annotated[str | None, Query(pattern="^(success|failed|cancelled)$")] = None, + business_type: Annotated[str | None, Query()] = None, + store: Annotated[str | None, Query(description="店名子串模糊匹配")] = None, + product: Annotated[str | None, Query(description="商品名子串模糊匹配")] = None, + date_from: Annotated[date | None, Query(description="北京自然日起始日")] = None, + date_to: Annotated[date | None, Query(description="北京自然日结束日")] = None, +) -> AdminComparisonSummary: + return AdminComparisonSummary(**queries.comparison_records_summary( + db, user_id=user_id, phone=phone, status=status, + business_type=business_type, store=store, product=product, + date_from=date_from, date_to=date_to, + )) + + @router.get( "/{record_id}", response_model=AdminComparisonDetail, diff --git a/app/admin/routers/config.py b/app/admin/routers/config.py index 6701c03..270b1b2 100644 --- a/app/admin/routers/config.py +++ b/app/admin/routers/config.py @@ -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("需为非空整数列表") diff --git a/app/admin/routers/coupon_data.py b/app/admin/routers/coupon_data.py index 82a882d..89a23e2 100644 --- a/app/admin/routers/coupon_data.py +++ b/app/admin/routers/coupon_data.py @@ -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, diff --git a/app/admin/routers/device_liveness.py b/app/admin/routers/device_liveness.py index 879697f..c48f86c 100644 --- a/app/admin/routers/device_liveness.py +++ b/app/admin/routers/device_liveness.py @@ -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"))], ) diff --git a/app/admin/routers/event_logs.py b/app/admin/routers/event_logs.py index eceb155..bc4d008 100644 --- a/app/admin/routers/event_logs.py +++ b/app/admin/routers/event_logs.py @@ -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"))], ) diff --git a/app/admin/routers/feedback.py b/app/admin/routers/feedback.py index f177ebd..28b5b71 100644 --- a/app/admin/routers/feedback.py +++ b/app/admin/routers/feedback.py @@ -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)) diff --git a/app/admin/routers/guide_video.py b/app/admin/routers/guide_video.py new file mode 100644 index 0000000..59f75ae --- /dev/null +++ b/app/admin/routers/guide_video.py @@ -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) diff --git a/app/admin/routers/huawei_review.py b/app/admin/routers/huawei_review.py new file mode 100644 index 0000000..b23fb18 --- /dev/null +++ b/app/admin/routers/huawei_review.py @@ -0,0 +1,58 @@ +"""admin 华为审核开关:控制新手引导页(快速设置)在华为 ROM 客户端能否被用户关闭。 + +存在 app_config 表的 huawei_review dict(见 repositories/app_config.get_huawei_review/set_huawei_review)。 +客户端经 /api/v1/platform/huawei-review 拉取(且只有华为 ROM 机型会去拉)。权限 operator/tech + 审计。 +""" +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.orm import Session + +from app.admin.audit import write_audit +from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role +from app.admin.schemas.huawei_review import HuaweiReviewOut, HuaweiReviewUpdate +from app.models.admin import AdminUser +from app.models.app_config import AppConfig +from app.repositories import app_config + +router = APIRouter( + prefix="/admin/api/huawei-review", + tags=["admin-huawei-review"], + dependencies=[Depends(get_current_admin)], +) + + +def _out(db: Session) -> HuaweiReviewOut: + row = db.get(AppConfig, app_config.HUAWEI_REVIEW_KEY) + return HuaweiReviewOut( + mode=app_config.get_huawei_review(db)["mode"], + updated_at=row.updated_at.isoformat() if row is not None else None, + ) + + +@router.get("", response_model=HuaweiReviewOut, summary="华为审核开关当前状态") +def get_huawei_review(db: AdminDb) -> HuaweiReviewOut: + return _out(db) + + +@router.patch("", response_model=HuaweiReviewOut, summary="切换华为审核开关(带审计)") +def update_huawei_review( + body: HuaweiReviewUpdate, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator", "tech"))], + db: AdminDb, +) -> HuaweiReviewOut: + before = app_config.get_huawei_review(db)["mode"] + try: + app_config.set_huawei_review(db, body.mode, admin_id=admin.id, commit=False) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + write_audit( + db, admin, action="huawei_review.set", target_type="huawei_review", + target_id=app_config.HUAWEI_REVIEW_KEY, + detail={"before": before, "after": body.mode}, ip=get_client_ip(request), commit=False, + ) + db.commit() + return _out(db) diff --git a/app/admin/routers/price_report.py b/app/admin/routers/price_report.py index daf3e81..6f656a7 100644 --- a/app/admin/routers/price_report.py +++ b/app/admin/routers/price_report.py @@ -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() diff --git a/app/admin/routers/risk_monitor.py b/app/admin/routers/risk_monitor.py new file mode 100644 index 0000000..3c37d64 --- /dev/null +++ b/app/admin/routers/risk_monitor.py @@ -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) diff --git a/app/admin/routers/users.py b/app/admin/routers/users.py index fce483a..1d8b056 100644 --- a/app/admin/routers/users.py +++ b/app/admin/routers/users.py @@ -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: + if not user_repo.user_exists(db, user_id): 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, diff --git a/app/admin/routers/withdraw.py b/app/admin/routers/withdraw.py index dbc006d..f12dc25 100644 --- a/app/admin/routers/withdraw.py +++ b/app/admin/routers/withdraw.py @@ -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( diff --git a/app/admin/schemas/ad_revenue.py b/app/admin/schemas/ad_revenue.py index 6f9f49a..94e2d96 100644 --- a/app/admin/schemas/ad_revenue.py +++ b/app/admin/schemas/ad_revenue.py @@ -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 秒份数") @@ -37,6 +40,8 @@ class AdRevenueRecord(BaseModel): expected_coin: int = Field(..., description="按公式复算应发金币") actual_coin: int = Field(..., description="实际入账金币") matched: bool = Field(..., description="复算与实发是否一致") + adn: str | None = Field(None, description="本条发奖对应的实际填充 ADN 子渠道") + slot_id: str | None = Field(None, description="本条发奖对应的底层 mediation rit") class AdRevenueDaily(BaseModel): @@ -44,7 +49,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);非全量视图/无数据为空" ) @@ -70,6 +75,7 @@ class AdRevenueTypeStat(BaseModel): impressions: int = Field(..., description="该类型展示条数合计") revenue_yuan: float = Field(..., description="该类型预估收益合计(元)") + ecpm_yuan: float | None = Field(None, description="按真实展示次数加权的 SDK eCPM(元/千次)") class AdRevenueRow(BaseModel): @@ -93,7 +99,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 折算之和;" @@ -136,6 +145,10 @@ class AdRevenueReportOut(BaseModel): default_factory=dict, description="按广告类型(ad_type)小计 {ad_type: {impressions, revenue_yuan}};前端取 draw / reward_video 做分类大盘", ) + category_stats: dict[str, AdRevenueTypeStat] = Field( + default_factory=dict, + description="按经营分类小计:draw=draw+历史 feed,video=reward_video+withdrawal_video", + ) scene_stats: dict[str, AdRevenueTypeStat] = Field( default_factory=dict, description="按信息流场景(feed_scene)小计 {comparison/coupon/welfare: {impressions, revenue_yuan}};" @@ -150,7 +163,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)。穿山甲无用户/类型/场景维度," diff --git a/app/admin/schemas/comparison.py b/app/admin/schemas/comparison.py index ec1d3aa..c9f1d43 100644 --- a/app/admin/schemas/comparison.py +++ b/app/admin/schemas/comparison.py @@ -29,6 +29,7 @@ class AdminComparisonListItem(BaseModel): source_price_cents: int | None = None best_price_cents: int | None = None saved_amount_cents: int | None = None + ordered: bool = False # debug 概览 total_ms: int | None = None step_count: int | None = None @@ -39,14 +40,37 @@ class AdminComparisonListItem(BaseModel): # 本次比价 LLM 总成本(元,按当时价冻结);旧记录/未回填为 None → 前端「成本」列回退估算。见 services/llm_cost.py。 llm_cost_yuan: float | None = None device_model: str | None = None + device_model_name: str | None = None rom_vendor: str | None = None rom_name: str | None = None + rom_version: int | None = None android_version: str | None = None app_version: str | None = None ad_revenue_yuan: float = 0.0 # 本次比价看的信息流广告预估收益(元),queries 瞬态挂 ORM 实例上 created_at: datetime +class AdminComparisonSummary(BaseModel): + """比价记录页概览;主耗时指标仅统计 status=success。""" + + started: int + completed: int + success: int + success_rate: float | None = None + avg_token_cost: float | None = None + lower_price_rate: float | None = None + avg_duration_ms: int | None = None + p5_duration_ms: int | None = None + p50_duration_ms: int | None = None + p95_duration_ms: int | None = None + p99_duration_ms: int | None = None + cancelled: int + cancelled_rate: float | None = None + cancelled_p5_ms: int | None = None + cancelled_p50_ms: int | None = None + cancelled_p95_ms: int | None = None + + class AdminComparisonDetail(AdminComparisonListItem): """详情:概要 + 全量明细(逐平台对比 / LLM 每次调用 / 原始 payload)。""" @@ -63,7 +87,6 @@ class AdminComparisonDetail(AdminComparisonListItem): skipped_dish_names: list = [] # 全量环境 device_manufacturer: str | None = None - rom_version: int | None = None android_sdk: int | None = None app_version_code: int | None = None source_app_version: str | None = None diff --git a/app/admin/schemas/coupon_data.py b/app/admin/schemas/coupon_data.py index c560397..6b19fd7 100644 --- a/app/admin/schemas/coupon_data.py +++ b/app/admin/schemas/coupon_data.py @@ -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 渲染)。""" diff --git a/app/admin/schemas/dashboard.py b/app/admin/schemas/dashboard.py index b3e008a..bb8a281 100644 --- a/app/admin/schemas/dashboard.py +++ b/app/admin/schemas/dashboard.py @@ -53,11 +53,16 @@ class DashboardPeriodUsers(BaseModel): class DashboardPeriodComparison(BaseModel): total: int + completed: int + cancelled: int success: int - success_rate: float + success_rate: float | None = None ordered: int average_duration_ms: int | None = None + median_duration_ms: int | None = None + p95_duration_ms: int | None = None average_saved_cents: int | None = None + token_cost_total_yuan: float = 0.0 class DashboardPeriodCoupon(BaseModel): diff --git a/app/admin/schemas/device.py b/app/admin/schemas/device.py index da3fe60..68568b9 100644 --- a/app/admin/schemas/device.py +++ b/app/admin/schemas/device.py @@ -24,7 +24,9 @@ class DeviceLivenessItem(BaseModel): device_model: str | None = None # 由 device_id 解析(device_<机型>_);非 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) diff --git a/app/admin/schemas/feedback.py b/app/admin/schemas/feedback.py index bb588cf..33ae0ba 100644 --- a/app/admin/schemas/feedback.py +++ b/app/admin/schemas/feedback.py @@ -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 @@ -32,7 +32,10 @@ class FeedbackOut(BaseModel): # 提交端环境快照(feedback 表列):提交版本号 / 机型OS版本;改版前的历史反馈为 None app_version: str | None = None device_model: str | None = None + device_model_name: str | None = None + device_manufacturer: str | None = None rom_name: str | None = None + rom_version: int | None = None android_version: str | None = None # 联表瞬态字段(queries._attach_user_info 挂):列表展示完整手机号,点手机号查该用户全部反馈 phone: str | None = None @@ -55,6 +58,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 态)。""" diff --git a/app/admin/schemas/guide_video.py b/app/admin/schemas/guide_video.py new file mode 100644 index 0000000..721f0a6 --- /dev/null +++ b/app/admin/schemas/guide_video.py @@ -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) diff --git a/app/admin/schemas/huawei_review.py b/app/admin/schemas/huawei_review.py new file mode 100644 index 0000000..198a321 --- /dev/null +++ b/app/admin/schemas/huawei_review.py @@ -0,0 +1,19 @@ +"""admin 华为审核开关 schemas(两态:default / review)。""" +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel + + +class HuaweiReviewOut(BaseModel): + """当前开关状态。updated_at 给后台展示「谁什么时候切的」提供时间锚点。""" + + mode: Literal["default", "review"] + updated_at: str | None = None # ISO 字符串;从未切过为 None + + +class HuaweiReviewUpdate(BaseModel): + """切换开关。整值覆盖,不做部分更新(就一个字段)。""" + + mode: Literal["default", "review"] diff --git a/app/admin/schemas/price_report.py b/app/admin/schemas/price_report.py index abb89d9..d67208a 100644 --- a/app/admin/schemas/price_report.py +++ b/app/admin/schemas/price_report.py @@ -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): """审核台顶部各状态计数。""" diff --git a/app/admin/schemas/risk_monitor.py b/app/admin/schemas/risk_monitor.py new file mode 100644 index 0000000..826393e --- /dev/null +++ b/app/admin/schemas/risk_monitor.py @@ -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 diff --git a/app/admin/schemas/user.py b/app/admin/schemas/user.py index 7d673a6..aa826da 100644 --- a/app/admin/schemas/user.py +++ b/app/admin/schemas/user.py @@ -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 diff --git a/app/admin/schemas/wallet.py b/app/admin/schemas/wallet.py index 7f61d7b..a3fe899 100644 --- a/app/admin/schemas/wallet.py +++ b/app/admin/schemas/wallet.py @@ -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' @@ -67,7 +69,10 @@ class WithdrawSummaryOut(BaseModel): reviewing_count: int reviewing_amount_cents: int pending_count: int + success_count: int + rejected_count: int failed_count: int + total_count: int today_success_count: int today_success_amount_cents: int today_rejected_count: int @@ -77,6 +82,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 +94,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 +150,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 diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index 3b9cbaf..bf98e80 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -3,6 +3,8 @@ 路由前缀 `/api/v1/ad`: GET /pangle-callback 穿山甲 S2S 发奖回调(**无 JWT,靠验签**),穿山甲服务器调 GET /reward-status 客户端查今日看广告发奖进度(Bearer) + GET /reward-result/{ad_session_id} + 客户端按会话查本次广告实发金币(Bearer,只读,弹窗金额用) 发奖走服务端:激励视频播完穿山甲回调本接口,验签通过后幂等发金币。客户端只负责 看完后刷新余额,不参与发奖,被破解也刷不到钱。 @@ -13,7 +15,7 @@ import json import logging import uuid -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, Depends, HTTPException, Path, Request, status from app.api.deps import CurrentUser, DbSession from app.core import rewards @@ -25,8 +27,8 @@ from app.repositories import ad_feed_reward as crud_feed from app.repositories import ad_reward as crud_ad from app.repositories import ad_watch as crud_watch from app.repositories import app_config -from app.repositories import signin as crud_signin from app.schemas.ad import ( + AdRewardResultOut, AdRewardStatusOut, EcpmReportIn, EcpmReportOut, @@ -52,11 +54,14 @@ REASON_BAD_PARAMS = 1 # 验签过但缺 trans_id / user_id 非数字 REASON_UNKNOWN_USER = 2 # user_id 不存在(可能伪造) REWARD_SCENE_REWARD_VIDEO = "reward_video" -REWARD_SCENE_SIGNIN_BOOST = "signin_boost" # 提现看视频:看完才能提现的「硬门槛」广告,**不发金币**,只记一条幂等记录(收益由 eCPM 上报口径 # ad_type="withdrawal_video" 单独统计)。故意不放进 SUPPORTED_REWARD_SCENES——它不走发币分支。 REWARD_SCENE_WITHDRAWAL_AD = "withdrawal_ad" -SUPPORTED_REWARD_SCENES = {REWARD_SCENE_REWARD_VIDEO, REWARD_SCENE_SIGNIN_BOOST} +# 2026-07 下线 signin_boost(签到膨胀):它按固定 3000 金币发,与广告实际收益脱钩,产品确认 +# 从来不是设计内的口径。签到弹窗里的「看广告膨胀」现在与福利页看视频走同一条 reward_video +# 路径(按 eCPM 公式发),奖励只剩「签到」+「看视频」两种。历史发币流水(coin_transaction +# .biz_type='signin_boost')保留不动——钱是真发过的,账必须留。 +SUPPORTED_REWARD_SCENES = {REWARD_SCENE_REWARD_VIDEO} def _parse_extra(raw_extra: str | None) -> dict[str, str]: @@ -118,6 +123,11 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut: extra.update(_parse_extra(params.get(extra_key))) reward_scene = extra.get("reward_scene") or REWARD_SCENE_REWARD_VIDEO ad_session_id = extra.get("ad_session_id") + # 「这条广告属于哪一轮膨胀」。纯标签:不参与发奖判定,只让 reward-result 能把同一轮求和成 + # 弹窗要显示的累计值(见 crud_ad.round_coin_total)。老客户端不带 → NULL → 累计值返 null。 + boost_round_id = (extra.get("boost_round_id") or None) + if boost_round_id is not None: + boost_round_id = boost_round_id[:64] ecpm = params.get("ecpm") # 环境隔离:激励视频 mediaExtra 里带「这次观看属于哪个后端环境」(srv_env=dev/prod,客户端按 @@ -169,50 +179,11 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut: user_id, trans_id, reward_scene, ) return PangleCallbackOut(is_verify=False, reason=REASON_BAD_PARAMS) - if reward_scene == REWARD_SCENE_SIGNIN_BOOST: - try: - boost, _balance = crud_signin.boost_today_signin( - db, user_id, ad_ref_id=trans_id, commit=False - ) - except crud_signin.NotSignedTodayError: - db.rollback() - rec = crud_ad.record_external_reward( - db, user_id, trans_id, coin=0, reward_scene=reward_scene, - ad_session_id=ad_session_id, ecpm=ecpm, - reward_name=params.get("reward_name"), raw=raw[:1024], - status="not_signed", - ) - except crud_signin.AlreadyBoostedError: - db.rollback() - rec = crud_ad.record_external_reward( - db, user_id, trans_id, coin=0, reward_scene=reward_scene, - ad_session_id=ad_session_id, ecpm=ecpm, - reward_name=params.get("reward_name"), raw=raw[:1024], - status="already_boosted", - ) - except crud_signin.LastCycleDayBoostBlockedError: - db.rollback() - rec = crud_ad.record_external_reward( - db, user_id, trans_id, coin=0, reward_scene=reward_scene, - ad_session_id=ad_session_id, ecpm=ecpm, - reward_name=params.get("reward_name"), raw=raw[:1024], - status="last_day", - ) - else: - rec = crud_ad.record_external_reward( - db, user_id, trans_id, coin=boost.coin_awarded, - reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm=ecpm, - reward_name=params.get("reward_name"), raw=raw[:1024], - commit=False, - ) - db.commit() - db.refresh(rec) - else: - rec = crud_ad.grant_ad_reward( - db, user_id, trans_id, ecpm=ecpm, ad_session_id=ad_session_id, - reward_scene=REWARD_SCENE_REWARD_VIDEO, - reward_name=params.get("reward_name"), raw=raw[:1024], - ) + rec = crud_ad.grant_ad_reward( + db, user_id, trans_id, ecpm=ecpm, ad_session_id=ad_session_id, + reward_scene=REWARD_SCENE_REWARD_VIDEO, boost_round_id=boost_round_id, + reward_name=params.get("reward_name"), raw=raw[:1024], + ) except crud_ad.UnknownUserError: logger.warning("pangle callback unknown user_id=%d trans_id=%s", user_id, trans_id) return PangleCallbackOut(is_verify=False, reason=REASON_UNKNOWN_USER) @@ -242,6 +213,46 @@ def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut: ) +@router.get( + "/reward-result/{ad_session_id}", + response_model=AdRewardResultOut, + summary="按 ad_session_id 查本次广告的权威发奖结果", + dependencies=[Depends(rate_limit(120, 60, "ad-reward-result"))], +) +def reward_result( + user: CurrentUser, + db: DbSession, + ad_session_id: str = Path(..., min_length=8, max_length=64, description="本次广告会话 id"), +) -> AdRewardResultOut: + """客户端看完激励视频后轮询本接口拿**本次实发金币 + 本轮累计**用于弹窗,不再用余额差 / + coin_per_ad 估算(修「弹窗数值与真实金币对不上」)。 + + round_coin 是「恭喜累计获得奖励」弹窗真正显示的数:本轮(= 客户端的 boost_round_id)所有 + granted 记录之和。由服务端求和而不是客户端自己累加——客户端进程被杀/重建后本地累计会丢, + 发奖记录不会。取不到轮 id(pending / 老客户端 / extra 丢失)时为 null,客户端退回显示单条。 + + S2S 回调异步:查不到记录 = 回调还没到 → 返 200 + status='pending' 让客户端继续重试, + **不返 404**(404 只表示路由不存在)。纯只读:发奖仍只由验签过的 S2S 回调完成, + 这里不写库、不产生任何奖励,被刷也只是查自己的记录。 + """ + rec = crud_ad.find_by_session(db, user.id, ad_session_id) + if rec is None: + # 连记录都没有 → 不知道属于哪一轮,round_coin 一并为 null(不是 0,0 会被当成"本轮没赚到") + return AdRewardResultOut( + ad_session_id=ad_session_id, status="pending", coin=None, round_coin=None, + ) + # 本条不是 granted 时**仍返本轮累计**(这条按 0 计):第 3 条撞每日上限那下,客户端的限额 + # toast 要显示的是前两条已到账的总额,不是空。 + round_coin = ( + crud_ad.round_coin_total(db, user.id, rec.boost_round_id) + if rec.boost_round_id + else None + ) + return AdRewardResultOut( + ad_session_id=ad_session_id, status=rec.status, coin=rec.coin, round_coin=round_coin, + ) + + @router.post( "/watch-report", response_model=WatchReportOut, @@ -280,19 +291,31 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm Bearer 鉴权,user_id 取自 JWT(不信 body)。best-effort:落库即 ok,客户端 fire-and-forget, 丢一两条不影响业务(穿山甲后台报表是结算权威)。eCPM 与发奖(S2S)是两条独立流,不逐条关联。 """ + attributed_trace_id = crud_ecpm.attributable_trace_id( + db, + feed_scene=payload.feed_scene, + trace_id=payload.trace_id, + exposure_ms=payload.exposure_ms, + ) + if payload.trace_id and attributed_trace_id is None: + logger.info( + "detach late coupon ad impression from failed trace user_id=%d trace=%s session=%s", + user.id, payload.trace_id, payload.ad_session_id, + ) crud_ecpm.create_ecpm_record( db, user.id, ad_type=payload.ad_type, ecpm_raw=payload.ecpm, ad_session_id=payload.ad_session_id, adn=payload.adn, slot_id=payload.slot_id, feed_scene=payload.feed_scene, - trace_id=payload.trace_id, + trace_id=attributed_trace_id, app_env=payload.app_env, our_code_id=payload.our_code_id, + exposure_ms=payload.exposure_ms, ) logger.info( - "ad ecpm report user_id=%d type=%s scene=%s session=%s ecpm=%s adn=%s slot=%s app=%s code=%s", + "ad ecpm report user_id=%d type=%s scene=%s session=%s ecpm=%s exposure_ms=%s adn=%s slot=%s app=%s code=%s", user.id, payload.ad_type, payload.feed_scene, payload.ad_session_id, payload.ecpm, - payload.adn, payload.slot_id, payload.app_env, payload.our_code_id, + payload.exposure_ms, payload.adn, payload.slot_id, payload.app_env, payload.our_code_id, ) return EcpmReportOut(ok=True) @@ -317,55 +340,27 @@ def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = N if reward_scene not in SUPPORTED_REWARD_SCENES: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="bad reward_scene") - # 每次新 trans_id,模拟一次独立的穿山甲发奖回调(幂等键各不相同 → 每次都发,直到当日上限/今日膨胀一次) + # 每次新 trans_id,模拟一次独立的穿山甲发奖回调(幂等键各不相同 → 每次都发,直到当日上限) trans_id = f"test-{user.id}-{uuid.uuid4().hex}" - if reward_scene == REWARD_SCENE_SIGNIN_BOOST: - try: - boost, _balance = crud_signin.boost_today_signin( - db, user.id, ad_ref_id=trans_id, commit=False - ) - except crud_signin.NotSignedTodayError: - db.rollback() - rec = crud_ad.record_external_reward( - db, user.id, trans_id, coin=0, reward_scene=reward_scene, - raw="client debug test-grant signin_boost", status="not_signed", - ) - except crud_signin.AlreadyBoostedError: - db.rollback() - rec = crud_ad.record_external_reward( - db, user.id, trans_id, coin=0, reward_scene=reward_scene, - raw="client debug test-grant signin_boost", status="already_boosted", - ) - except crud_signin.LastCycleDayBoostBlockedError: - db.rollback() - rec = crud_ad.record_external_reward( - db, user.id, trans_id, coin=0, reward_scene=reward_scene, - raw="client debug test-grant signin_boost", status="last_day", - ) - else: - rec = crud_ad.record_external_reward( - db, user.id, trans_id, coin=boost.coin_awarded, - reward_scene=reward_scene, reward_name="测试签到膨胀", - raw="client debug test-grant signin_boost", commit=False, - ) - db.commit() - db.refresh(rec) - else: - # 优先用客户端按 ad_session_id 上报的真实 eCPM(走与正式发奖相同的公式); - # 取不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍能验出非零金币。 - ad_session_id = payload.ad_session_id if payload is not None else None - ecpm_val = "200" - if ad_session_id: - ecpm_rec = crud_ecpm.find_by_session(db, user_id=user.id, ad_session_id=ad_session_id) - if ecpm_rec is not None and rewards.parse_ecpm_fen(ecpm_rec.ecpm_raw) > 0: - ecpm_val = ecpm_rec.ecpm_raw - try: - rec = crud_ad.grant_ad_reward( - db, user.id, trans_id, ecpm=ecpm_val, ad_session_id=ad_session_id, - reward_name="测试发奖", raw=f"client debug test-grant ecpm={ecpm_val}", - ) - except crud_ad.UnknownUserError as e: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e + # 正式链路的轮次 id 走 S2S 的 mediaExtra;本接口不经 S2S,只能由 body 补,否则 debug 包 + # 的 reward-result 恒返 round_coin=null,「弹窗 40 → 60」那套累计验收在本地跑不起来。 + boost_round_id = (payload.boost_round_id if payload is not None else None) or None + # 优先用客户端按 ad_session_id 上报的真实 eCPM(走与正式发奖相同的公式); + # 取不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍能验出非零金币。 + ad_session_id = payload.ad_session_id if payload is not None else None + ecpm_val = "200" + if ad_session_id: + ecpm_rec = crud_ecpm.find_by_session(db, user_id=user.id, ad_session_id=ad_session_id) + if ecpm_rec is not None and rewards.parse_ecpm_fen(ecpm_rec.ecpm_raw) > 0: + ecpm_val = ecpm_rec.ecpm_raw + try: + rec = crud_ad.grant_ad_reward( + db, user.id, trans_id, ecpm=ecpm_val, ad_session_id=ad_session_id, + boost_round_id=boost_round_id, + reward_name="测试发奖", raw=f"client debug test-grant ecpm={ecpm_val}", + ) + except crud_ad.UnknownUserError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e (used, limit, coin_per, round_count, cooldown_until, _watched, _watch_limit) = crud_ad.today_status(db, user.id) diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index e3728be..a58020c 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -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") diff --git a/app/api/v1/compare.py b/app/api/v1/compare.py index b1f56ae..fcb1e03 100644 --- a/app/api/v1/compare.py +++ b/app/api/v1/compare.py @@ -25,22 +25,34 @@ import uuid from typing import Any import httpx -from fastapi import APIRouter, HTTPException, Request, status +from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, status from fastapi.concurrency import run_in_threadpool -from 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 +from app.services.comparison_llm_backfill import backfill_comparison_llm_cost 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 +64,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}, @@ -65,13 +81,17 @@ def _harvest_running_blocking( def _harvest_done_blocking( trace_id: str, user_id: int | None, done_params: dict, business_type: str, device_id: str | None, device_info: dict | None, trace_url: str | None, -) -> None: +) -> int: with SessionLocal() as db: rec, newly_success = crud_compare.harvest_done( db, trace_id=trace_id, user_id=user_id, done_params=done_params, 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, @@ -83,6 +103,7 @@ def _harvest_done_blocking( # 不在此处发邀请奖:#113 已把发奖口径从「比价」移到「实际下单」(order.py),harvest # 只记录比价、不发奖。否则比价先于下单 + try_reward 幂等闸会让奖落在「比价」这步, # 架空 #113 的「下单才发奖」防刷意图(newly_success 仅留作日志观测)。 + return rec.id def _harvest_abort_blocking( @@ -199,25 +220,38 @@ 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, + background_tasks: BackgroundTasks, + user: OptionalUser, + db: DbSession, +) -> dict[str, Any]: + _ensure_compare_allowed(user, db) resp, trace_id, meta = await _forward(request, "/api/price/step", user) # 最终 done 帧(command=done 且 continue=false)→ harvest 更新成终态。 # (单平台中途 done 被 pricebot 改写成 wait+continue=true,不会命中这里,同 coupon 语义。) @@ -225,19 +259,25 @@ async def price_step(request: Request, user: OptionalUser) -> dict[str, Any]: if action.get("command") == "done" and not resp.get("continue", True): done_params = action.get("params") or {} try: - await run_in_threadpool( + record_id = await run_in_threadpool( _harvest_done_blocking, trace_id, (user.id if user else None), done_params, "food", meta.get("device_id"), meta.get("device_info"), resp.get("trace_url") or done_params.get("trace_url"), ) + background_tasks.add_task( + backfill_comparison_llm_cost, record_id, trace_id + ) except Exception as e: # noqa: BLE001 logger.warning("harvest_done failed trace=%s: %s", trace_id, e) return resp @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 +290,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 → diff --git a/app/api/v1/compare_record.py b/app/api/v1/compare_record.py index 1c34215..3d2b484 100644 --- a/app/api/v1/compare_record.py +++ b/app/api/v1/compare_record.py @@ -16,10 +16,11 @@ import logging from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, status 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, @@ -27,14 +28,57 @@ from app.schemas.compare_record import ( ComparisonRecordOut, ComparisonRecordPage, ) -from app.services.llm_cost import compute_llm_cost, get_llm_prices -from app.services.pricebot_llm_calls import fetch_llm_calls +from app.services.comparison_llm_backfill import backfill_comparison_llm_cost 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 +90,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,后台用 @@ -67,32 +118,7 @@ def report_record( def _backfill_llm_calls(record_id: int, trace_id: str) -> None: """后台回填本次比价的 LLM 调用明细 + 派生 llm_call_count/retry_count。 独立 DB session(请求 session 此时已关);拉取/写库失败只 log,绝不影响已落库的上报。""" - calls = fetch_llm_calls(trace_id) - if not calls: - return - db = SessionLocal() - try: - rec = db.get(ComparisonRecord, record_id) - if rec is None: - return - rec.llm_calls = calls - rec.llm_call_count = len(calls) - rec.retry_count = sum(1 for c in calls if c.get("error")) - # token 累加(usage 已被 pricebot llm_client 归一为 prompt/completion_tokens; - # error 的调用 usage 可能为 None,or {} 兜底) - rec.input_tokens = sum((c.get("usage") or {}).get("prompt_tokens") or 0 for c in calls) - rec.output_tokens = sum((c.get("usage") or {}).get("completion_tokens") or 0 for c in calls) - # 本次比价 LLM 成本(元)+ 当时单价快照:按 app_config 现价逐模型算好冻结(services/llm_cost.py)。 - rec.llm_cost_yuan, rec.llm_price_snapshot = compute_llm_cost(calls, get_llm_prices(db)) - db.commit() - logger.info( - "backfill llm_calls trace=%s n=%d in_tok=%d out_tok=%d", - trace_id, len(calls), rec.input_tokens, rec.output_tokens, - ) - except Exception as e: # noqa: BLE001 best-effort - logger.warning("backfill llm_calls failed trace=%s: %s", trace_id, e) - finally: - db.close() + backfill_comparison_llm_cost(record_id, trace_id) @router.get( @@ -115,13 +141,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(列表页「复制调试链接」靠它)。 diff --git a/app/api/v1/coupon.py b/app/api/v1/coupon.py index bd2127a..ecd40a8 100644 --- a/app/api/v1/coupon.py +++ b/app/api/v1/coupon.py @@ -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) diff --git a/app/api/v1/device.py b/app/api/v1/device.py index 56a5a85..893c6c5 100644 --- a/app/api/v1/device.py +++ b/app/api/v1/device.py @@ -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, diff --git a/app/api/v1/guide_video.py b/app/api/v1/guide_video.py new file mode 100644 index 0000000..c38251b --- /dev/null +++ b/app/api/v1/guide_video.py @@ -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) diff --git a/app/api/v1/meituan.py b/app/api/v1/meituan.py index c1d07a1..b3cf1c6 100644 --- a/app/api/v1/meituan.py +++ b/app/api/v1/meituan.py @@ -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) diff --git a/app/api/v1/notifications.py b/app/api/v1/notifications.py new file mode 100644 index 0000000..5a49197 --- /dev/null +++ b/app/api/v1/notifications.py @@ -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) diff --git a/app/api/v1/platform.py b/app/api/v1/platform.py index c7a5873..7ee5be4 100644 --- a/app/api/v1/platform.py +++ b/app/api/v1/platform.py @@ -20,6 +20,7 @@ from app.schemas.platform import ( AdConfigPublicOut, AppFlagsOut, AppVersionOut, + HuaweiReviewOut, PlatformStatsOut, SavingsFeedItem, SavingsFeedOut, @@ -72,6 +73,15 @@ def ad_config(db: DbSession) -> AdConfigPublicOut: ) +@router.get("/huawei-review", response_model=HuaweiReviewOut, summary="华为审核开关(不鉴权)") +def huawei_review(db: DbSession) -> HuaweiReviewOut: + """客户端进新手引导前拉一次,决定「快速设置」权限步左上角要不要给退出按钮。 + 不鉴权:引导页在登录之前就展示,此时必然没有 token。空库回退 default(=不给退出按钮,维持现状)。 + 只有华为 ROM 客户端会来拉(荣耀 MagicOS 不拉),故这里不做机型判断,由客户端自己 gate。""" + mode = app_config.get_huawei_review(db)["mode"] + return HuaweiReviewOut(mode=mode, onboarding_closable=(mode == "review")) + + @router.get("/app-version", response_model=AppVersionOut, summary="最新 App 版本(OTA 检查更新,不鉴权)") def app_version(db: DbSession) -> AppVersionOut: """客户端启动 / 手动检查更新时拉取。不鉴权:版本信息非敏感,且检查更新可能在登录前。 diff --git a/app/api/v1/push.py b/app/api/v1/push.py new file mode 100644 index 0000000..128b46c --- /dev/null +++ b/app/api/v1/push.py @@ -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, + ) diff --git a/app/api/v1/signin.py b/app/api/v1/signin.py index 45f3646..02848e0 100644 --- a/app/api/v1/signin.py +++ b/app/api/v1/signin.py @@ -1,9 +1,12 @@ """签到 endpoint。 路由前缀 `/api/v1/signin`: - GET /status 今日签到状态 + 14 天档位 + GET /status 今日签到状态 + 7 天档位 POST / 执行今日签到 - POST /boost 签到后看广告膨胀金币 + +2026-07 下线 `POST /boost`(签到膨胀):它按固定 3000 金币补发、与广告实际收益脱钩。 +签到弹窗里的「看广告膨胀」改与福利页看视频走同一条 reward_video 路径(按 eCPM 发, +`/ad/pangle-callback` → `/ad/reward-result` 取金额),奖励只剩「签到」+「看视频」两种。 """ from __future__ import annotations @@ -12,15 +15,8 @@ import logging from fastapi import APIRouter, HTTPException, status from app.api.deps import CurrentUser, DbSession -from app.repositories import ad_reward as crud_ad from app.repositories import signin as crud_signin -from app.repositories import wallet as crud_wallet -from app.schemas.welfare import ( - SigninBoostRequest, - SigninBoostResultOut, - SigninResultOut, - SigninStatusOut, -) +from app.schemas.welfare import SigninResultOut, SigninStatusOut logger = logging.getLogger("shagua.signin") @@ -50,34 +46,3 @@ def do_signin(user: CurrentUser, db: DbSession) -> SigninResultOut: streak=record.streak, coin_balance=balance, ) - - -@router.post("/boost", response_model=SigninBoostResultOut, summary="签到后看广告膨胀金币") -def boost_signin( - payload: SigninBoostRequest, user: CurrentUser, db: DbSession -) -> SigninBoostResultOut: - if not payload.ad_ref_id: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="ad reward required") - ad_rec = crud_ad.find_by_trans(db, payload.ad_ref_id) - if ( - ad_rec is None - or ad_rec.user_id != user.id - or ad_rec.reward_scene != "signin_boost" - or ad_rec.status != "granted" - ): - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="ad reward not verified") - record = crud_signin.boost_by_ad_ref(db, user.id, payload.ad_ref_id) - if record is None: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="signin boost not granted") - acc = crud_wallet.get_or_create_account(db, user.id) - balance = acc.coin_balance - - logger.info( - "signin boost ok user_id=%d date=%s coin=%d", - user.id, record.signin_date, record.coin_awarded, - ) - return SigninBoostResultOut( - coin_awarded=record.coin_awarded, - coin_balance=balance, - signin_date=record.signin_date.isoformat(), - ) diff --git a/app/api/v1/tasks.py b/app/api/v1/tasks.py index beaaf5d..e5f79e0 100644 --- a/app/api/v1/tasks.py +++ b/app/api/v1/tasks.py @@ -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: diff --git a/app/api/v1/wallet.py b/app/api/v1/wallet.py index 454ff7c..64ea6bf 100644 --- a/app/api/v1/wallet.py +++ b/app/api/v1/wallet.py @@ -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( diff --git a/app/core/config.py b/app/core/config.py index bc3d1c7..72b54e6 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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 移动应用,用于微信支付);这是【已认证服务号】。 @@ -318,6 +377,12 @@ class Settings(BaseSettings): # 靠这个共享密钥头(X-Internal-Secret)校验,与 pricebot 侧 INTERNAL_API_SECRET 同值。 # 默认空 = 内部写端点关闭(返 503),启用前两边都要配上同一高熵串。 INTERNAL_API_SECRET: str = "" + # Current compare clients are persisted by server-side harvest. Repair any + # recent terminal rows left without LLM usage by transient upstream/auth failures. + LLM_COST_BACKFILL_ENABLED: bool = True + LLM_COST_BACKFILL_INTERVAL_SEC: int = 300 + LLM_COST_BACKFILL_BATCH_SIZE: int = 100 + LLM_COST_BACKFILL_LOOKBACK_DAYS: int = 30 # ===== 媒体文件(用户头像上传)===== # 落盘根目录(data/ 已 gitignore,上传不进库);对外经 StaticFiles 挂在 MEDIA_URL_PREFIX。 @@ -325,6 +390,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)。 @@ -347,6 +415,31 @@ class Settings(BaseSettings): return [] return [o.strip() for o in self.CORS_ALLOW_ORIGINS.split(",") if o.strip()] + # ===== 可观测(OpenObserve 接口指标)===== + # 采集每个接口的 QPS + 耗时 + 错误率,批量直采到 OpenObserve(本地 Docker)。 + # 默认关(prod 安全):未开启 → 中间件透传、worker 不启动,整套 no-op。 + # 开启需 ENABLED=true 且 ENDPOINT/USER/PASSWORD 齐全(见 observe_configured)。 + OBSERVE_ENABLED: bool = False + OBSERVE_ENDPOINT: str = "http://localhost:5080" # OpenObserve base URL + OBSERVE_ORG: str = "default" # 组织名 + OBSERVE_STREAM: str = "app_requests" # stream 名(首次上报自动建) + OBSERVE_USER: str = "" # Basic auth 邮箱 + OBSERVE_PASSWORD: str = "" # Basic auth 密码/token + OBSERVE_FLUSH_INTERVAL_SEC: float = 5.0 # worker 最长攒批间隔 + OBSERVE_BATCH_MAX: int = 200 # 单批最大事件数 + OBSERVE_QUEUE_MAX: int = 10000 # 有界队列上限,满则丢 + OBSERVE_TIMEOUT_SEC: float = 5.0 # 上报 HTTP 超时 + + @property + def observe_configured(self) -> bool: + """观测上报可用 = 总开关开 且 endpoint/账号/密码齐全(缺则整套 no-op)。""" + return bool( + self.OBSERVE_ENABLED + and self.OBSERVE_ENDPOINT + and self.OBSERVE_USER + and self.OBSERVE_PASSWORD + ) + @property def is_prod(self) -> bool: return self.APP_ENV == "prod" diff --git a/app/core/config_schema.py b/app/core/config_schema.py index 9edb043..9a63720 100644 --- a/app/core/config_schema.py +++ b/app/core/config_schema.py @@ -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 按此过滤)。用于把已下线/已改由 @@ -66,11 +70,6 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = { "default": r.VIDEO_ROUND_COOLDOWN_SECONDS, "label": "广告关闭后冷却(秒)", "group": "看广告", "type": "int", "help": "点击退出广告后,下次点击观看前的冷却时间,默认 3 秒。", }, - "signin_boost_coin": { - "default": r.SIGNIN_BOOST_COIN, "label": "签到膨胀固定金币", - "group": "签到", "type": "int", - "help": "Day1-Day6 签到后看完激励视频额外发放的固定金币;Day7 不展示也不允许膨胀。", - }, "comparing_ad_enabled": { "default": True, "label": "比价/领券期信息流广告", "group": "看广告", "type": "bool", "hidden": True, @@ -111,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": "同一账户在北京时间同一自然日内发起比价达到该次数时告警。", + }, } diff --git a/app/core/cps_reconcile_worker.py b/app/core/cps_reconcile_worker.py new file mode 100644 index 0000000..d7c9fe6 --- /dev/null +++ b/app/core/cps_reconcile_worker.py @@ -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 diff --git a/app/core/heartbeat_monitor_worker.py b/app/core/heartbeat_monitor_worker.py index 6072d26..be27124 100644 --- a/app/core/heartbeat_monitor_worker.py +++ b/app/core/heartbeat_monitor_worker.py @@ -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: diff --git a/app/core/llm_cost_backfill_worker.py b/app/core/llm_cost_backfill_worker.py new file mode 100644 index 0000000..6dc65cf --- /dev/null +++ b/app/core/llm_cost_backfill_worker.py @@ -0,0 +1,63 @@ +"""Periodic repair worker for comparison records with missing LLM token cost.""" +from __future__ import annotations + +import asyncio +import contextlib +import logging + +from app.core.config import settings +from app.services.comparison_llm_backfill import repair_missing_comparison_llm_costs +from app.services.pricebot_llm_calls import pricebot_llm_auth_ready + +logger = logging.getLogger("shagua.llm_cost_backfill_worker") + + +async def _run_loop() -> None: + interval = max(60, int(settings.LLM_COST_BACKFILL_INTERVAL_SEC)) + logger.info( + "LLM cost backfill worker started interval=%ss batch=%s lookback_days=%s", + interval, + settings.LLM_COST_BACKFILL_BATCH_SIZE, + settings.LLM_COST_BACKFILL_LOOKBACK_DAYS, + ) + try: + while True: + try: + auth_ready = await asyncio.to_thread(pricebot_llm_auth_ready) + if auth_ready: + result = await asyncio.to_thread( + repair_missing_comparison_llm_costs, + limit=settings.LLM_COST_BACKFILL_BATCH_SIZE, + lookback_days=settings.LLM_COST_BACKFILL_LOOKBACK_DAYS, + ) + logger.info("LLM cost backfill batch result=%s", result) + else: + logger.error( + "LLM cost backfill skipped: PriceBot internal auth is not ready" + ) + except Exception: # noqa: BLE001 + logger.exception("LLM cost backfill batch failed") + await asyncio.sleep(interval) + except asyncio.CancelledError: + logger.info("LLM cost backfill worker stopped") + raise + + +def start_llm_cost_backfill_worker() -> asyncio.Task | None: + if not settings.LLM_COST_BACKFILL_ENABLED: + logger.info("LLM cost backfill worker disabled") + return None + if not settings.INTERNAL_API_SECRET: + logger.warning( + "LLM cost backfill worker not started: INTERNAL_API_SECRET is empty" + ) + return None + return asyncio.create_task(_run_loop(), name="llm-cost-backfill") + + +async def stop_llm_cost_backfill_worker(task: asyncio.Task | None) -> None: + if task is None: + return + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task diff --git a/app/core/logging.py b/app/core/logging.py index cb91b5c..c3833bd 100644 --- a/app/core/logging.py +++ b/app/core/logging.py @@ -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)) diff --git a/app/core/media.py b/app/core/media.py index 192b186..a6e84e4 100644 --- a/app/core/media.py +++ b/app/core/media.py @@ -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 与浏览器