Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fec89662ed |
@@ -58,13 +58,6 @@ MT_CPS_DEFAULT_SID=sgbjia
|
||||
# 线上国内服务器留空(=直连)。留空且本机直连失败时 /feed、/coupons、/top-sales 会返回空。
|
||||
MT_CPS_PROXY=
|
||||
|
||||
# ===== 京东联盟 CPS =====
|
||||
# 京东联盟/京东宙斯开放平台创建应用后填写。AUTH_KEY 是工具商授权 key,自有应用可留空。
|
||||
JD_UNION_APP_KEY=
|
||||
JD_UNION_APP_SECRET=
|
||||
JD_UNION_SITE_ID=
|
||||
JD_UNION_AUTH_KEY=
|
||||
|
||||
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
|
||||
# 客户端调本服务的 /api/v1/coupon/step 等,我们透传到 pricebot-backend。
|
||||
# 本地开发用 localhost:8000。生产部署改成内网地址(如 http://pricebot.internal:8000)。
|
||||
@@ -124,16 +117,3 @@ PANGLE_REWARD_SECRET=
|
||||
# ⚠️ 仅本地联调:true 时开放 POST /api/v1/ad/test-grant,让 debug 客户端看完广告直接发奖,
|
||||
# 验证"看广告→金币到账"全链路(未部署公网、穿山甲 S2S 打不到本地时用)。生产必须 false(绕过反作弊)。
|
||||
AD_REWARD_TEST_GRANT_ENABLED=false
|
||||
|
||||
# ===== 穿山甲 GroMore 数据 API(按天拉收益报表,供后台广告收益报表的「穿山甲后台收益」)=====
|
||||
# ⚠️ 与上面发奖回调的 m-key 是【两套不同凭证】:这三样在穿山甲后台「接入中心 → GroMore-API →
|
||||
# 聚合数据报告 API」文档页领取。只读拉取 GroMore 天级 revenue(预估)/ api_revenue(收益Api),
|
||||
# 不参与发奖。三样齐全才生效;留空 = scripts/sync_pangle_revenue 直接 no-op。
|
||||
# 子账号(role_id≠user_id)需主账号在「角色管理」授予「查看全部数据」权限,否则查不到收益(接口 118);
|
||||
# role_id 填成 = user_id 即查主账号数据。同步:线上每天 ~10:30 由 timer 跑 python -m scripts.sync_pangle_revenue。
|
||||
PANGLE_REPORT_USER_ID=0
|
||||
PANGLE_REPORT_ROLE_ID=0
|
||||
PANGLE_REPORT_SECURITY_KEY=
|
||||
# GroMore AppId(报表 site_id 维度)→ 应用环境;默认取现网两个应用,按需覆盖。
|
||||
PANGLE_REPORT_SITE_ID_PROD=5830519
|
||||
PANGLE_REPORT_SITE_ID_TEST=5832303
|
||||
|
||||
+2
-5
@@ -23,17 +23,14 @@ dist/
|
||||
*.db-journal
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
# data/ 整体不入库(运行时数据/上传文件/大二进制)。例外:邀请落地页 dl.html 及其
|
||||
# 引用的静态插画(coupon-page-bg.png 底图 + sb-brand.png logo)——既是生产落地页资产
|
||||
# 又是本地测试资产,纳入 git 便于同事一致测试、且随部署进生产 /media(否则线上 404→落地页毛坯)。
|
||||
# data/ 整体不入库(运行时数据/上传文件/大二进制)。例外:邀请落地页 dl.html——
|
||||
# 它既是生产落地页又是本地测试资产,纳入 git 便于同事一致测试
|
||||
# (见 docs/邀请功能-实现原理与本地测试.md)。其余(avatars/ / *.apk / app.db 等)仍忽略。
|
||||
data/*
|
||||
!data/media/
|
||||
data/media/*
|
||||
!data/media/dl.html
|
||||
!data/media/taobao_landing.jpg
|
||||
!data/media/coupon-page-bg.png
|
||||
!data/media/sb-brand.png
|
||||
|
||||
secrets/*
|
||||
!secrets/.gitkeep
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.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
|
||||
@@ -1,65 +0,0 @@
|
||||
"""add analytics_event table
|
||||
|
||||
Revision ID: 1699fc2c069f
|
||||
Revises: bcfcaf07152b
|
||||
Create Date: 2026-06-26 16:35:16.133975
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '1699fc2c069f'
|
||||
down_revision: str | Sequence[str] | None = 'bcfcaf07152b'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('analytics_event',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('event', sa.String(length=64), nullable=False),
|
||||
sa.Column('props', sa.JSON(), nullable=True),
|
||||
sa.Column('device_id', sa.String(length=64), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('session_id', sa.String(length=64), nullable=True),
|
||||
sa.Column('client_ts', sa.BigInteger(), nullable=False),
|
||||
sa.Column('sent_at', sa.BigInteger(), nullable=True),
|
||||
sa.Column('page', sa.String(length=64), nullable=True),
|
||||
sa.Column('client_ip', sa.String(length=64), nullable=True),
|
||||
sa.Column('oem', sa.String(length=32), nullable=True),
|
||||
sa.Column('os', sa.String(length=32), nullable=True),
|
||||
sa.Column('model', sa.String(length=64), nullable=True),
|
||||
sa.Column('app_ver', sa.String(length=32), nullable=True),
|
||||
sa.Column('network', sa.String(length=16), nullable=True),
|
||||
sa.Column('channel', sa.String(length=32), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('analytics_event', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_analytics_event_created_at'), ['created_at'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_analytics_event_device_id'), ['device_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_analytics_event_event'), ['event'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_analytics_event_session_id'), ['session_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_analytics_event_user_id'), ['user_id'], unique=False)
|
||||
|
||||
# 注:autogenerate 顺带检出 ad_ecpm/cps_*/invite_fingerprint 的历史索引漂移,
|
||||
# 与本次「新增埋点表」无关,已手动移除,避免本迁移误改他人表。
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('analytics_event', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_analytics_event_user_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_analytics_event_session_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_analytics_event_event'))
|
||||
batch_op.drop_index(batch_op.f('ix_analytics_event_device_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_analytics_event_created_at'))
|
||||
|
||||
op.drop_table('analytics_event')
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,26 +0,0 @@
|
||||
"""merge jd_cps_order_fields and coupon_session_origin_package heads
|
||||
|
||||
Revision ID: 761ef181ce7c
|
||||
Revises: coupon_session_origin_package, jd_cps_order_fields
|
||||
Create Date: 2026-07-01 13:52:16.068808
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '761ef181ce7c'
|
||||
down_revision: Union[str, Sequence[str], None] = ('coupon_session_origin_package', 'jd_cps_order_fields')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -1,26 +0,0 @@
|
||||
"""merge invite_cash + analytics + device heads
|
||||
|
||||
Revision ID: 7db22acee504
|
||||
Revises: c2874d2bf705, device_first_protected_at, invite_cash_compare_reward
|
||||
Create Date: 2026-06-27 03:06:52.594401
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '7db22acee504'
|
||||
down_revision: Union[str, Sequence[str], None] = ('c2874d2bf705', 'device_first_protected_at', 'invite_cash_compare_reward')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -1,63 +0,0 @@
|
||||
"""ad_pangle_daily_revenue: 穿山甲 GroMore 天级收益报表(后台结算口径)
|
||||
|
||||
新建表存放从 GroMore 数据 API 按天拉取的收益(revenue 预估 + api_revenue 收益Api),
|
||||
粒度 = 日期 × 应用(app_env) × 代码位(our_code_id) × 广告源(adn)。供广告收益报表的
|
||||
汇总/趋势级展示「穿山甲后台收益」,与客户端自报 eCPM 折算的预估互为对照。
|
||||
|
||||
Revision ID: ad_pangle_daily_revenue
|
||||
Revises: 7db22acee504
|
||||
Create Date: 2026-06-28
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "ad_pangle_daily_revenue"
|
||||
down_revision = "7db22acee504"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"ad_pangle_daily_revenue",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("report_date", sa.String(length=10), nullable=False),
|
||||
sa.Column("app_env", sa.String(length=16), nullable=False),
|
||||
sa.Column("site_id", sa.String(length=32), nullable=True),
|
||||
sa.Column("our_code_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("adn", sa.String(length=16), nullable=False, server_default=""),
|
||||
sa.Column("revenue_yuan", sa.Float(), nullable=False, server_default="0"),
|
||||
sa.Column("api_revenue_yuan", sa.Float(), nullable=True),
|
||||
sa.Column("ecpm", sa.String(length=32), nullable=True),
|
||||
sa.Column("impressions", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("currency", sa.String(length=8), nullable=False, server_default="cny"),
|
||||
sa.Column(
|
||||
"synced_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"report_date", "app_env", "our_code_id", "adn", name="uq_ad_pangle_daily"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_ad_pangle_daily_revenue_report_date",
|
||||
"ad_pangle_daily_revenue",
|
||||
["report_date"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_ad_pangle_daily_revenue_our_code_id",
|
||||
"ad_pangle_daily_revenue",
|
||||
["our_code_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_ad_pangle_daily_revenue_our_code_id", table_name="ad_pangle_daily_revenue")
|
||||
op.drop_index("ix_ad_pangle_daily_revenue_report_date", table_name="ad_pangle_daily_revenue")
|
||||
op.drop_table("ad_pangle_daily_revenue")
|
||||
@@ -1,46 +0,0 @@
|
||||
"""add ad_type to ad_feed_reward and feed_scene to ad_ecpm
|
||||
|
||||
把信息流广告全面改造成 Draw 信息流(draw):
|
||||
- ad_feed_reward_record.ad_type:广告形态 feed(信息流) / draw(Draw 信息流)。可空,旧数据 NULL
|
||||
一律视为 feed(向后兼容);每日上限与因子2(LT)仍按本表全表 unit 累计,不按 ad_type 拆。
|
||||
- ad_ecpm_record.feed_scene:点位场景 comparison(比价) / coupon(领券) / welfare(福利),供广告
|
||||
收益报表区分比价/领券 Draw 收益;仅信息流/Draw 上报,激励视频为 NULL。
|
||||
|
||||
注:autogenerate 会顺带探测到 analytics_event / cps_* / invite_fingerprint 等无关索引差异(本地库与
|
||||
metadata 漂移、analytics 模型尚未并入 __init__),与本次改动无关,已手工剔除——本迁移只 add 两列。
|
||||
SQLite 经 env.py 的 render_as_batch 自动走 batch_alter_table 重建表。
|
||||
|
||||
Revision ID: c2874d2bf705
|
||||
Revises: 1699fc2c069f
|
||||
Create Date: 2026-06-26 16:48:52.158609
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "c2874d2bf705"
|
||||
down_revision: str | Sequence[str] | None = "1699fc2c069f"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 信息流发奖记录:新增广告形态 feed/draw(可空,旧数据 NULL=feed)
|
||||
op.add_column(
|
||||
"ad_feed_reward_record",
|
||||
sa.Column("ad_type", sa.String(length=16), nullable=True),
|
||||
)
|
||||
# eCPM 展示上报:新增点位场景 comparison/coupon/welfare(可空,激励视频/旧数据 NULL)
|
||||
op.add_column(
|
||||
"ad_ecpm_record",
|
||||
sa.Column("feed_scene", sa.String(length=16), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("ad_ecpm_record", "feed_scene")
|
||||
op.drop_column("ad_feed_reward_record", "ad_type")
|
||||
@@ -1,32 +0,0 @@
|
||||
"""coupon_session 加 origin_package 列(发起来源 App 包名 → admin「发起平台」)
|
||||
|
||||
null=App 内(傻瓜比价首页)发起,非空=从美团/淘宝/京东弹券发起。与 trace_url 同理单独成迁移,
|
||||
已建表环境靠它补列、全新环境顺序应用,不重复加列。
|
||||
|
||||
Revision ID: coupon_session_origin_package
|
||||
Revises: coupon_session_trace_url
|
||||
Create Date: 2026-06-30 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "coupon_session_origin_package"
|
||||
down_revision: str | Sequence[str] | None = "coupon_session_trace_url"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("origin_package", sa.String(length=64), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
|
||||
batch_op.drop_column("origin_package")
|
||||
@@ -1,81 +0,0 @@
|
||||
"""coupon session table(领券任务全程流水 → admin「领券数据」看板数据源)
|
||||
|
||||
一次领券一行(trace_id 唯一):客户端 POST /api/v1/coupon/session 两段上报 —— 发起建行
|
||||
(status=started)、收尾(completed/failed/abandoned)按 trace_id 更新同一行。记全程耗时
|
||||
elapsed_ms + 各平台耗时 platform_elapsed + 机型/ROM,供 admin 算发起/完成数、耗时分位、机型维度。
|
||||
|
||||
Revision ID: coupon_session_table
|
||||
Revises: feedback_submit_env
|
||||
Create Date: 2026-06-30 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "coupon_session_table"
|
||||
down_revision: str | Sequence[str] | None = "feedback_submit_env"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
# PG 用 JSONB,SQLite(本地/测试)退化为通用 JSON(同 model 的 _JSON variant)。
|
||||
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"coupon_session",
|
||||
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("status", sa.String(length=16), nullable=False),
|
||||
sa.Column("app_env", sa.String(length=16), nullable=True),
|
||||
sa.Column("platforms", _JSON, nullable=True),
|
||||
sa.Column("device_model", sa.String(length=128), nullable=True),
|
||||
sa.Column("rom", sa.String(length=64), nullable=True),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("started_date", sa.Date(), nullable=False),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("elapsed_ms", sa.Integer(), nullable=True),
|
||||
sa.Column("platform_elapsed", _JSON, nullable=True),
|
||||
sa.Column("claimed_count", sa.Integer(), 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("trace_id", name="uq_coupon_session_trace"),
|
||||
)
|
||||
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_coupon_session_user_id"), ["user_id"], unique=False
|
||||
)
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_coupon_session_app_env"), ["app_env"], unique=False
|
||||
)
|
||||
# admin 主聚合/筛选:按上海自然日 + 环境。
|
||||
batch_op.create_index(
|
||||
"ix_coupon_session_date_env", ["started_date", "app_env"], unique=False
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
|
||||
batch_op.drop_index("ix_coupon_session_date_env")
|
||||
batch_op.drop_index(batch_op.f("ix_coupon_session_app_env"))
|
||||
batch_op.drop_index(batch_op.f("ix_coupon_session_user_id"))
|
||||
op.drop_table("coupon_session")
|
||||
@@ -1,32 +0,0 @@
|
||||
"""coupon_session 加 trace_url 列(pricebot done 帧公网调试链接)
|
||||
|
||||
建表迁移 coupon_session_table 落地后才追加本列,故单独成一个迁移:已建表的环境(本地/已 upgrade 过)
|
||||
靠它补列,全新环境则「建表(无 trace_url)→ 本迁移加列」,两条路一致、不重复加列。
|
||||
|
||||
Revision ID: coupon_session_trace_url
|
||||
Revises: coupon_session_table
|
||||
Create Date: 2026-06-30 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "coupon_session_trace_url"
|
||||
down_revision: str | Sequence[str] | None = "coupon_session_table"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("trace_url", sa.String(length=512), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
|
||||
batch_op.drop_column("trace_url")
|
||||
@@ -23,64 +23,39 @@ branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _has_column(insp: sa.Inspector, table: str, column: str) -> bool:
|
||||
return any(c["name"] == column for c in insp.get_columns(table))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
# SQLite 不认 ::jsonb cast(会报 "unrecognized token: :"),只有 PG 用显式 cast。
|
||||
# 两边列类型都靠上面的 with_variant 区分,这里只管 server_default 文本。
|
||||
platforms_default = (
|
||||
sa.text("'[\"meituan\"]'::jsonb")
|
||||
if bind.dialect.name == "postgresql"
|
||||
else sa.text("'[\"meituan\"]'")
|
||||
)
|
||||
|
||||
# 活动:淘宝淘口令 / 京东链接
|
||||
# 加 add_column 存在性守卫:本迁移历史上在 SQLite 第一次跑时加完 payload 即在
|
||||
# platforms 那步炸掉(::jsonb),DDL 非事务不回滚 → 半应用状态。守卫让其可自愈重跑。
|
||||
if not _has_column(insp, "cps_activity", "payload"):
|
||||
op.add_column("cps_activity", sa.Column("payload", sa.Text(), nullable=True))
|
||||
op.add_column("cps_activity", sa.Column("payload", sa.Text(), nullable=True))
|
||||
|
||||
# 群:多平台(现有群都是美团,server_default 回填)+ sid 可空。
|
||||
# SQLite 不支持 ALTER COLUMN,必须用 batch(重建表)才能改 nullable;PG 走原生 ALTER。
|
||||
with op.batch_alter_table("cps_group") as batch_op:
|
||||
if not _has_column(insp, "cps_group", "platforms"):
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"platforms",
|
||||
sa.JSON().with_variant(postgresql.JSONB(), "postgresql"),
|
||||
nullable=False,
|
||||
server_default=platforms_default,
|
||||
)
|
||||
)
|
||||
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=True)
|
||||
# 群:多平台(现有群都是美团,server_default 回填)+ sid 可空
|
||||
op.add_column(
|
||||
"cps_group",
|
||||
sa.Column(
|
||||
"platforms",
|
||||
sa.JSON().with_variant(postgresql.JSONB(), "postgresql"),
|
||||
nullable=False,
|
||||
server_default=sa.text("'[\"meituan\"]'::jsonb"),
|
||||
),
|
||||
)
|
||||
op.alter_column("cps_group", "sid", existing_type=sa.String(64), nullable=True)
|
||||
|
||||
# link:sid 可空 + target 加长
|
||||
with op.batch_alter_table("cps_link") as batch_op:
|
||||
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=True)
|
||||
batch_op.alter_column("target_url", existing_type=sa.String(1024), type_=sa.String(2048))
|
||||
op.alter_column("cps_link", "sid", existing_type=sa.String(64), nullable=True)
|
||||
op.alter_column("cps_link", "target_url", existing_type=sa.String(1024), type_=sa.String(2048))
|
||||
|
||||
# click:sid 可空 + 事件类型
|
||||
with op.batch_alter_table("cps_click") as batch_op:
|
||||
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=True)
|
||||
if not _has_column(insp, "cps_click", "event_type"):
|
||||
batch_op.add_column(
|
||||
sa.Column("event_type", sa.String(16), nullable=False, server_default="visit")
|
||||
)
|
||||
op.alter_column("cps_click", "sid", existing_type=sa.String(64), nullable=True)
|
||||
op.add_column(
|
||||
"cps_click",
|
||||
sa.Column("event_type", sa.String(16), nullable=False, server_default="visit"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("cps_click") as batch_op:
|
||||
batch_op.drop_column("event_type")
|
||||
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=False)
|
||||
with op.batch_alter_table("cps_link") as batch_op:
|
||||
batch_op.alter_column("target_url", existing_type=sa.String(2048), type_=sa.String(1024))
|
||||
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=False)
|
||||
with op.batch_alter_table("cps_group") as batch_op:
|
||||
batch_op.drop_column("platforms")
|
||||
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=False)
|
||||
with op.batch_alter_table("cps_activity") as batch_op:
|
||||
batch_op.drop_column("payload")
|
||||
op.drop_column("cps_click", "event_type")
|
||||
op.alter_column("cps_click", "sid", existing_type=sa.String(64), nullable=False)
|
||||
op.alter_column("cps_link", "target_url", existing_type=sa.String(2048), type_=sa.String(1024))
|
||||
op.alter_column("cps_link", "sid", existing_type=sa.String(64), nullable=False)
|
||||
op.drop_column("cps_group", "platforms")
|
||||
op.alter_column("cps_group", "sid", existing_type=sa.String(64), nullable=False)
|
||||
op.drop_column("cps_activity", "payload")
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"""feedback 加提交端环境快照(app_version / device_model / rom_name / android_version)
|
||||
|
||||
Revision ID: feedback_submit_env
|
||||
Revises: ad_pangle_daily_revenue
|
||||
Create Date: 2026-06-29 00:00:00.000000
|
||||
|
||||
admin 用户反馈页要展示「提交版本号」「机型OS版本」,需在提交时落库端环境。仅新增可空列,
|
||||
SQLite 原生支持 add_column、不用 batch;downgrade 的 drop_column 在 SQLite 走 batch 兜底。
|
||||
历史反馈无此快照 → 留 NULL(无法回填);客户端改版带上后的新反馈才有值。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "feedback_submit_env"
|
||||
down_revision: Union[str, Sequence[str], None] = "ad_pangle_daily_revenue"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("feedback", sa.Column("app_version", sa.String(length=32), nullable=True))
|
||||
op.add_column("feedback", sa.Column("device_model", sa.String(length=64), nullable=True))
|
||||
op.add_column("feedback", sa.Column("rom_name", sa.String(length=32), nullable=True))
|
||||
op.add_column("feedback", sa.Column("android_version", sa.String(length=16), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("feedback") as batch_op:
|
||||
batch_op.drop_column("android_version")
|
||||
batch_op.drop_column("rom_name")
|
||||
batch_op.drop_column("device_model")
|
||||
batch_op.drop_column("app_version")
|
||||
@@ -1,88 +0,0 @@
|
||||
"""invite cash account isolation + compare reward tracking (🅱-1)
|
||||
|
||||
Revision ID: invite_cash_compare_reward
|
||||
Revises: feedback_review_fields
|
||||
Create Date: 2026-06-23 00:00:00.000000
|
||||
|
||||
邀请功能 v2 账户隔离 + 比价发奖追踪:
|
||||
- coin_account 加 invite_cash_balance_cents(邀请奖励金独立余额,与金币兑换的 cash 物理隔离)
|
||||
- withdraw_order 加 source(标记提现扣哪个账户,退款退回对应账户;旧单默认 coin_cash)
|
||||
- invite_relation 加比价发奖追踪三列(好友比价多次只发一次)
|
||||
- 新增 invite_cash_transaction 表(邀请奖励金独立流水账本)
|
||||
|
||||
加列均 NOT NULL + server_default,存量行自动填默认值,安全。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'invite_cash_compare_reward'
|
||||
down_revision: Union[str, Sequence[str], None] = 'feedback_review_fields'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. 邀请奖励金独立余额(与金币兑换的 cash_balance_cents 物理隔离;红线:两本账不可累加)
|
||||
op.add_column(
|
||||
'coin_account',
|
||||
sa.Column('invite_cash_balance_cents', sa.Integer(), nullable=False, server_default='0'),
|
||||
)
|
||||
# 2. 提现单标记账户来源:coin_cash / invite_cash,退款退回对应账户(旧单默认 coin_cash)
|
||||
op.add_column(
|
||||
'withdraw_order',
|
||||
sa.Column('source', sa.String(length=16), nullable=False, server_default='coin_cash'),
|
||||
)
|
||||
# 3. 邀请关系加比价发奖追踪(好友"下载+登录+比价一次"→ 给邀请人发奖,只发一次)
|
||||
op.add_column(
|
||||
'invite_relation',
|
||||
sa.Column('compare_reward_granted', sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
op.add_column(
|
||||
'invite_relation',
|
||||
sa.Column('compare_reward_cents', sa.Integer(), nullable=False, server_default='0'),
|
||||
)
|
||||
op.add_column(
|
||||
'invite_relation',
|
||||
sa.Column('compare_rewarded_at', sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
# 4. 邀请奖励金独立流水表(结构同 cash_transaction;balance_after 记 invite_cash_balance_cents)
|
||||
op.create_table(
|
||||
'invite_cash_transaction',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('amount_cents', sa.Integer(), nullable=False),
|
||||
sa.Column('balance_after_cents', sa.Integer(), nullable=False),
|
||||
sa.Column('biz_type', sa.String(length=32), nullable=False),
|
||||
sa.Column('ref_id', sa.String(length=64), nullable=True),
|
||||
sa.Column('remark', sa.String(length=128), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index('ix_invite_cash_transaction_user_id', 'invite_cash_transaction', ['user_id'])
|
||||
op.create_index('ix_invite_cash_transaction_created_at', 'invite_cash_transaction', ['created_at'])
|
||||
# 提现退款幂等:一个提现单只退一次(partial unique,对齐 cash_transaction 的 withdraw_refund 去重)
|
||||
op.create_index(
|
||||
'ux_invite_cash_txn_refund_ref',
|
||||
'invite_cash_transaction',
|
||||
['ref_id'],
|
||||
unique=True,
|
||||
sqlite_where=sa.text("biz_type = 'invite_withdraw_refund' AND ref_id IS NOT NULL"),
|
||||
postgresql_where=sa.text("biz_type = 'invite_withdraw_refund' AND ref_id IS NOT NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ux_invite_cash_txn_refund_ref', table_name='invite_cash_transaction')
|
||||
op.drop_index('ix_invite_cash_transaction_created_at', table_name='invite_cash_transaction')
|
||||
op.drop_index('ix_invite_cash_transaction_user_id', table_name='invite_cash_transaction')
|
||||
op.drop_table('invite_cash_transaction')
|
||||
op.drop_column('invite_relation', 'compare_rewarded_at')
|
||||
op.drop_column('invite_relation', 'compare_reward_cents')
|
||||
op.drop_column('invite_relation', 'compare_reward_granted')
|
||||
op.drop_column('withdraw_order', 'source')
|
||||
op.drop_column('coin_account', 'invite_cash_balance_cents')
|
||||
@@ -1,56 +0,0 @@
|
||||
"""add jd cps order fields
|
||||
|
||||
Revision ID: jd_cps_order_fields
|
||||
Revises: 7db22acee504
|
||||
Create Date: 2026-06-28 20:30:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "jd_cps_order_fields"
|
||||
down_revision = "7db22acee504"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("cps_order") as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column("platform", sa.String(length=20), nullable=False, server_default="meituan")
|
||||
)
|
||||
batch_op.add_column(sa.Column("external_order_id", sa.String(length=128), nullable=True))
|
||||
batch_op.add_column(sa.Column("external_row_id", sa.String(length=128), nullable=True))
|
||||
batch_op.add_column(sa.Column("estimated_commission_cents", sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column("actual_commission_cents", sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column("jd_valid_code", sa.String(length=16), nullable=True))
|
||||
batch_op.add_column(sa.Column("settle_month", sa.String(length=16), nullable=True))
|
||||
batch_op.add_column(sa.Column("site_id", sa.String(length=128), nullable=True))
|
||||
batch_op.add_column(sa.Column("position_id", sa.String(length=128), nullable=True))
|
||||
batch_op.add_column(sa.Column("pid", sa.String(length=128), nullable=True))
|
||||
batch_op.add_column(sa.Column("sub_union_id", sa.String(length=128), nullable=True))
|
||||
batch_op.create_index("ix_cps_order_platform", ["platform"])
|
||||
batch_op.create_index("ix_cps_order_external_order_id", ["external_order_id"])
|
||||
batch_op.create_index("ix_cps_order_external_row_id", ["external_row_id"])
|
||||
batch_op.create_index("ix_cps_order_jd_valid_code", ["jd_valid_code"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("cps_order") as batch_op:
|
||||
batch_op.drop_index("ix_cps_order_jd_valid_code")
|
||||
batch_op.drop_index("ix_cps_order_external_row_id")
|
||||
batch_op.drop_index("ix_cps_order_external_order_id")
|
||||
batch_op.drop_index("ix_cps_order_platform")
|
||||
batch_op.drop_column("sub_union_id")
|
||||
batch_op.drop_column("pid")
|
||||
batch_op.drop_column("position_id")
|
||||
batch_op.drop_column("site_id")
|
||||
batch_op.drop_column("settle_month")
|
||||
batch_op.drop_column("jd_valid_code")
|
||||
batch_op.drop_column("actual_commission_cents")
|
||||
batch_op.drop_column("estimated_commission_cents")
|
||||
batch_op.drop_column("external_row_id")
|
||||
batch_op.drop_column("external_order_id")
|
||||
batch_op.drop_column("platform")
|
||||
@@ -21,12 +21,10 @@ 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
|
||||
from app.admin.routers.config import router as config_router
|
||||
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.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.onboarding import router as onboarding_router
|
||||
@@ -95,14 +93,12 @@ admin_app.include_router(wallet_router)
|
||||
admin_app.include_router(withdraw_router)
|
||||
admin_app.include_router(price_report_router)
|
||||
admin_app.include_router(feedback_router)
|
||||
admin_app.include_router(event_logs_router)
|
||||
admin_app.include_router(feedback_qr_router)
|
||||
admin_app.include_router(admins_router)
|
||||
admin_app.include_router(audit_router)
|
||||
admin_app.include_router(config_router)
|
||||
admin_app.include_router(comparison_router)
|
||||
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(ad_revenue_router)
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
- 看视频:每条 granted = 1 份,第 N 份 = 该用户 granted 的 reward_video **账号累计**顺序号
|
||||
(与 ad_reward.grant_ad_reward 里 `_granted_cumulative + 1` 一致;LT 因子不按天重置,
|
||||
故复算时要把当日序号叠加上该用户在本日**之前**的累计已发份数)。
|
||||
- 信息流:**每条 granted = 1 份**(与 ad_feed_reward.grant_feed_reward 同口径:看满一份即发该条
|
||||
满额,**不按 unit_count 逐份累加**),LT 序号 = 该用户 granted **条数**账号累计
|
||||
(与 ad_feed_reward.granted_unit_total 的 COUNT 一致;不按天重置,复算需叠加本日之前的累计条数)。
|
||||
- 信息流:每条按 unit_count 份逐份累加,LT 序号 = 该用户 granted 份数**账号累计**
|
||||
(与 ad_feed_reward._unit_reward_total 的 existing_units 一致;同样不按天重置,
|
||||
复算需叠加本日之前的累计份数)。
|
||||
|
||||
非 granted(capped/ecpm_missing)不占用份序号、应发恒 0,据此校验闸口是否确实没发。
|
||||
"""
|
||||
@@ -108,18 +108,14 @@ def _reward_video_rows(
|
||||
return rows
|
||||
|
||||
|
||||
def _feed_prior_granted_count(
|
||||
def _feed_prior_granted_units(
|
||||
db: Session, *, date: str, user_id: int | None
|
||||
) -> dict[int, int]:
|
||||
"""各用户在 date **之前** granted 的信息流**条数**累计,作为当日复算的 LT 序号起点。
|
||||
|
||||
与发奖侧 ad_feed_reward.granted_unit_total(COUNT status=granted)对齐:一条广告 = 1 份,
|
||||
LT 按账号累计**条数**递进。**不再用 SUM(unit_count)**——那是「一条按时长折多份」的过时口径,
|
||||
与现行发奖(每条 1 份)漂移,会让 unit_count>1 的记录复算虚高、对账恒「不符」。"""
|
||||
"""各用户在 date **之前** granted 的信息流份数累计,作为当日复算的 LT 序号起点。"""
|
||||
stmt = (
|
||||
select(
|
||||
AdFeedRewardRecord.user_id,
|
||||
func.count(),
|
||||
func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0),
|
||||
)
|
||||
.where(
|
||||
AdFeedRewardRecord.reward_date < date,
|
||||
@@ -132,33 +128,8 @@ def _feed_prior_granted_count(
|
||||
return {uid: int(n) for uid, n in db.execute(stmt).all()}
|
||||
|
||||
|
||||
def _feed_scene_matches(rec: AdFeedRewardRecord, scene: str | None) -> bool:
|
||||
"""该信息流记录是否落入请求的展示筛选 scene。
|
||||
- scene=="feed":ad_type in ("feed", NULL)(旧数据 NULL 视为 feed,向后兼容)
|
||||
- scene=="draw":ad_type=="draw"
|
||||
- scene=="feed_all":所有信息流(feed/draw/NULL 都要)——业务已全切 Draw 信息流,收益报表把「Draw 信息流」
|
||||
当作整个信息流口径(含历史误标 feed/NULL),用它避免筛选漏历史。
|
||||
- scene 为 None:不筛(两类都要)。
|
||||
"""
|
||||
if scene == "feed":
|
||||
return rec.ad_type in (None, "feed")
|
||||
if scene == "draw":
|
||||
return rec.ad_type == "draw"
|
||||
if scene == "feed_all":
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
def _feed_rows(
|
||||
db: Session, *, date: str, user_id: int | None, scene: str | None = None
|
||||
) -> list[dict]:
|
||||
"""信息流记录复算。**每条 granted = 1 份**(与发奖同口径,不按 unit_count 累加),
|
||||
LT 序号沿用账号累计**条数**(含本日之前)。
|
||||
|
||||
**关键:LT 因子账号累计按全表 granted 条数累计(feed+draw 共享同一发奖池/上限),不按 ad_type 拆分**——
|
||||
故无论 scene 怎么筛展示,这里都遍历当日**全部**信息流记录维持 granted_count 累加;scene 只决定
|
||||
哪些行被**留下展示**(由 _feed_scene_matches 判断),不影响累计基线,保证复算序号与正式发奖一致。
|
||||
"""
|
||||
def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
|
||||
"""信息流记录复算。granted 记录逐份累加,LT 序号沿用账号累计份数(含本日之前)。"""
|
||||
stmt = (
|
||||
select(AdFeedRewardRecord)
|
||||
.where(AdFeedRewardRecord.reward_date == date)
|
||||
@@ -167,54 +138,46 @@ def _feed_rows(
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(AdFeedRewardRecord.user_id == user_id)
|
||||
|
||||
# 本日之前的累计**条数**做起点,与发奖侧 granted_unit_total(COUNT granted)对齐
|
||||
granted_count: dict[int, int] = _feed_prior_granted_count(db, date=date, user_id=user_id)
|
||||
# 本日之前的累计份数做起点,与 _unit_reward_total 的 existing_units(累计)对齐
|
||||
granted_units: dict[int, int] = _feed_prior_granted_units(db, date=date, user_id=user_id)
|
||||
rows: list[dict] = []
|
||||
for rec in db.execute(stmt).scalars():
|
||||
keep = _feed_scene_matches(rec, scene) # 累计照常推进,这里只决定是否展示本行
|
||||
if rec.status == "granted":
|
||||
# 一条广告 = 1 份(与 grant_feed_reward 同口径:看满一份即发该条满额,不按 unit_count 累加)。
|
||||
# nth = 账号累计第几**条**(含本日之前),与发奖侧 granted_unit_total+1 对齐;累计照常推进
|
||||
# (即便 scene 不匹配不展示也要 +1,保证序号与正式发奖一致)。
|
||||
nth = granted_count.get(rec.user_id, 0) + 1
|
||||
granted_count[rec.user_id] = nth
|
||||
if not keep:
|
||||
continue
|
||||
expected = rewards.calculate_ad_reward_coin(rec.ecpm_raw, nth)
|
||||
existing = granted_units.get(rec.user_id, 0)
|
||||
units = rec.unit_count
|
||||
expected = sum(
|
||||
rewards.calculate_ad_reward_coin(rec.ecpm_raw, existing + offset)
|
||||
for offset in range(1, units + 1)
|
||||
)
|
||||
granted_units[rec.user_id] = existing + units
|
||||
start = existing + 1 if units > 0 else None
|
||||
end = existing + units if units > 0 else None
|
||||
rows.append({
|
||||
"scene": "feed",
|
||||
"ad_type": rec.ad_type or "feed",
|
||||
"feed_scene": rec.feed_scene,
|
||||
"record_id": rec.id,
|
||||
"user_id": rec.user_id,
|
||||
"ad_session_id": rec.ad_session_id,
|
||||
"trace_id": rec.trace_id,
|
||||
"app_env": rec.app_env,
|
||||
"our_code_id": rec.our_code_id,
|
||||
"created_at": rec.created_at,
|
||||
"status": rec.status,
|
||||
"ecpm": rec.ecpm_raw,
|
||||
"ecpm_factor": rewards.ad_ecpm_factor(rewards.parse_ecpm_yuan(rec.ecpm_raw)),
|
||||
"units": 1,
|
||||
"lt_index_start": nth,
|
||||
"lt_index_end": nth,
|
||||
"lt_factor_start": rewards.ad_lt_factor(nth),
|
||||
"lt_factor_end": rewards.ad_lt_factor(nth),
|
||||
"units": units,
|
||||
"lt_index_start": start,
|
||||
"lt_index_end": end,
|
||||
"lt_factor_start": rewards.ad_lt_factor(start) if start else None,
|
||||
"lt_factor_end": rewards.ad_lt_factor(end) if end else None,
|
||||
"expected_coin": expected,
|
||||
"actual_coin": rec.coin,
|
||||
"matched": expected == rec.coin,
|
||||
})
|
||||
else:
|
||||
if not keep:
|
||||
continue
|
||||
rows.append({
|
||||
"scene": "feed",
|
||||
"ad_type": rec.ad_type or "feed",
|
||||
"feed_scene": rec.feed_scene,
|
||||
"record_id": rec.id,
|
||||
"user_id": rec.user_id,
|
||||
"ad_session_id": rec.ad_session_id,
|
||||
"trace_id": rec.trace_id,
|
||||
"app_env": rec.app_env,
|
||||
"our_code_id": rec.our_code_id,
|
||||
"created_at": rec.created_at,
|
||||
@@ -236,19 +199,16 @@ def _feed_rows(
|
||||
def audit_rows(
|
||||
db: Session, *, date: str, user_id: int | None, scene: str | None = None
|
||||
) -> list[dict]:
|
||||
"""当日逐条发奖复算行(未排序)。scene: None=两类 / "reward_video" / "feed" / "draw"。
|
||||
"""当日逐条发奖复算行(未排序)。scene: None=两类 / "reward_video" / "feed"。
|
||||
|
||||
"feed" 与 "draw" 都查 ad_feed_reward_record(同一发奖表),按 ad_type 区分:feed 含历史 NULL,
|
||||
draw 仅 ad_type=="draw"。信息流行额外带 `ad_type`/`feed_scene`,供收益报表区分比价/领券 Draw 收益。
|
||||
每行含 `app_env`/`our_code_id`/`expected_coin`/`actual_coin` 等,供金币审计逐条对账,
|
||||
也供广告收益报表把「应发/实发」按 用户×类型×应用×代码位 聚合(见 ad_revenue,复用同一复算口径)。
|
||||
**LT 因子账号累计仍按全表 unit 累计(feed+draw 共享),scene 只筛展示,不拆累计。**
|
||||
"""
|
||||
rows: list[dict] = []
|
||||
if scene in (None, "reward_video"):
|
||||
rows.extend(_reward_video_rows(db, date=date, user_id=user_id))
|
||||
if scene in (None, "feed", "draw", "feed_all"):
|
||||
rows.extend(_feed_rows(db, date=date, user_id=user_id, scene=scene))
|
||||
if scene in (None, "feed"):
|
||||
rows.extend(_feed_rows(db, date=date, user_id=user_id))
|
||||
return rows
|
||||
|
||||
|
||||
|
||||
@@ -3,43 +3,34 @@
|
||||
只读。每行 = 一次广告事件(不再按用户聚合):
|
||||
- **激励视频**:一次观看 = 1 条展示(ad_ecpm)+ 1 条发奖(ad_reward),按 ad_session_id 合并成一行,
|
||||
直接给出 eCPM / 收益 + 状态 / 应发 / 实发 / 一致;点开看该条金币复算因子。
|
||||
- **信息流(比价/领券)**:一次比价 / 一次领券 = 一条整场发奖(ad_feed_reward)一行,给出 eCPM /
|
||||
发奖金币 + 应发 / 实发 / 一致;点开看金币复算因子。⚠️ draw 的逐条展示(ad_ecpm,impressionId 各自
|
||||
独立、与整场发奖无公共键、无法归到「哪一次」)**不再单独占行**(2026-07 按「一次比价/领券放一块」调整)——
|
||||
其展示数 / eCPM / 预估收益仍进全量统计(合计 / 趋势 / 分类大盘 / 穿山甲对照),只是主表不逐条铺开。
|
||||
- 兜底:激励视频有展示无发奖(中途关 / 未达发奖)、有发奖无展示(未上报 eCPM)仍各自成行。
|
||||
- **信息流**:轮播每条展示各一行(impressionId 各自独立);整场发奖(ad_feed_reward,client_event_id)
|
||||
与逐条展示无法对应,单独成「纯发奖」行。
|
||||
- 兜底:有展示无发奖(中途关 / 未达发奖)、有发奖无展示(未上报 eCPM)都各自成行。
|
||||
|
||||
展示与收益来自 ad_ecpm_record(收益 = eCPM元 ÷ 1000);应发 / 实发金币复用金币审计逐条复算
|
||||
(ad_audit.audit_rows,与正式发奖同一公式口径,不另写公式)。合计与对账在全量上统计,
|
||||
不受 limit(只截断 items)影响。
|
||||
|
||||
每行带 ad_type(reward_video/feed/draw)与 feed_scene(comparison/coupon/welfare),供前端区分
|
||||
「比价 Draw 收益」与「领券 Draw 收益」(比价/领券共用同一代码位,只能靠 feed_scene 分)。
|
||||
|
||||
⚠️ 局限:① 历史信息流/Draw 发奖 ad_type 为 NULL 的旧记录统一视为 feed(向后兼容);Draw 仅
|
||||
ad_type=="draw" 的新记录单独成类。② 跨天 S2S 回调:同一次广告的展示与发奖偶尔落相邻日,各自按
|
||||
report_date / reward_date 归日。
|
||||
⚠️ 局限:① 历史 Draw 发奖混在 ad_feed_reward_record 无类型标记,金币侧统一记 feed。
|
||||
② 跨天 S2S 回调:同一次广告的展示与发奖偶尔落相邻日,各自按 report_date / reward_date 归日。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import date as _date
|
||||
from datetime import date as _date, datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin.repositories import ad_audit
|
||||
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
|
||||
|
||||
|
||||
def _cn_hour(dt: datetime) -> int:
|
||||
"""created_at(UTC 口径)→ 北京时间小时(0–23)。naive 当 UTC 处理(sqlite),tz-aware 直接换算(pg)。"""
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(rewards.CN_TZ).hour
|
||||
|
||||
|
||||
@@ -55,10 +46,8 @@ def _date_range(date_from: str, date_to: str) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
# 报表 ad_type 与审计 scene 取值一致(reward_video / feed / draw):feed 与 draw 同查发奖表
|
||||
# ad_feed_reward_record,由 audit 内部按 ad_type 区分(feed 含历史 NULL,draw 仅 ad_type=="draw")。
|
||||
_AUDIT_SCENES = {"reward_video", "feed", "draw"}
|
||||
|
||||
# 审计行的 scene 与报表 ad_type 一一对应
|
||||
_SCENE_TO_AD_TYPE = {"reward_video": "reward_video", "feed": "feed"}
|
||||
|
||||
# 发奖复算明细字段(展开下钻看「金币怎么算出来的」)——从 audit 行原样取这些 key。
|
||||
_REWARD_DETAIL_KEYS = (
|
||||
@@ -80,21 +69,14 @@ def ad_revenue_report(
|
||||
date_to: str,
|
||||
user_id: int | None = None,
|
||||
ad_type: str | None = None,
|
||||
feed_scene: str | None = None,
|
||||
app_env: str | None = None,
|
||||
granularity: str = "day",
|
||||
limit: int = 500,
|
||||
offset: int = 0,
|
||||
sort: str = "time",
|
||||
) -> dict:
|
||||
"""日期区间(北京时间,闭区间)**逐条广告事件**列表 + 发奖对账。单日时 date_from==date_to。
|
||||
|
||||
每个 item = 一次广告事件(展示与发奖按 ad_session_id 合并;信息流展示 / 发奖各自成行)。
|
||||
ad_type: None=全部 / reward_video / feed / draw。feed_scene: None=全部 /
|
||||
comparison / coupon / welfare,作为全局筛选(同时作用于明细、合计与 daily/hourly 趋势)。
|
||||
granularity=hour 时每行带北京小时(由各自时间算),并额外返回全量 hourly 序列。
|
||||
事件按时间倒序(新→旧)排列;limit/offset 对排序后的全量做分页切片(items 为当前页),
|
||||
total 与 total_* / daily / hourly 在全量上统计,不受分页影响。
|
||||
ad_type: None=全部 / reward_video / feed / draw。granularity=hour 时每行带北京小时(由各自时间算)。
|
||||
limit 只截断 items(事件明细),total 与 total_* / daily 在全量上统计,数字始终可信。
|
||||
"""
|
||||
by_hour = granularity == "hour"
|
||||
|
||||
@@ -102,14 +84,7 @@ def ad_revenue_report(
|
||||
# 同时保留全量列表,未被展示合并的成「纯发奖」事件。
|
||||
reward_by_session: dict[tuple[int, str], list[dict]] = {}
|
||||
all_reward_rows: list[dict] = []
|
||||
# 报表 ad_type → audit scene:reward_video/feed 直传;**draw(前端「Draw 信息流」)映射成 feed_all**
|
||||
# ——业务已全切 Draw,把「Draw 信息流」当作整个信息流口径(含历史误标 feed/NULL),否则筛选会漏历史。
|
||||
if ad_type == "draw":
|
||||
audit_scene = "feed_all"
|
||||
elif ad_type in _AUDIT_SCENES:
|
||||
audit_scene = ad_type
|
||||
else:
|
||||
audit_scene = None
|
||||
audit_scene = _SCENE_TO_AD_TYPE.get(ad_type) if ad_type is not None else None
|
||||
if ad_type is None or audit_scene is not None:
|
||||
for d in _date_range(date_from, date_to):
|
||||
for row in ad_audit.audit_rows(db, date=d, user_id=user_id, scene=audit_scene):
|
||||
@@ -139,10 +114,7 @@ def ad_revenue_report(
|
||||
)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(AdEcpmRecord.user_id == user_id)
|
||||
if ad_type == "draw":
|
||||
# draw = 所有信息流展示(业务已全 Draw,含历史误标 feed);展示行只进统计,不占主表行
|
||||
stmt = stmt.where(AdEcpmRecord.ad_type.in_(["draw", "feed"]))
|
||||
elif ad_type is not None:
|
||||
if ad_type is not None:
|
||||
stmt = stmt.where(AdEcpmRecord.ad_type == ad_type)
|
||||
for rec in db.execute(stmt).scalars():
|
||||
rwd = _pop_reward(rec.user_id, rec.ad_session_id)
|
||||
@@ -151,7 +123,6 @@ def ad_revenue_report(
|
||||
"report_date": rec.report_date,
|
||||
"user_id": rec.user_id,
|
||||
"ad_type": rec.ad_type,
|
||||
"feed_scene": rec.feed_scene,
|
||||
"app_env": rec.app_env,
|
||||
"our_code_id": rec.our_code_id,
|
||||
"created_at": rec.created_at,
|
||||
@@ -159,16 +130,10 @@ def ad_revenue_report(
|
||||
"has_impression": True,
|
||||
"impressions": 1,
|
||||
"ecpm": rec.ecpm_raw,
|
||||
# 单次展示收益(元)= eCPM元 ÷ 1000(每千次→单次)。eCPM 先钳到 AD_ECPM_MAX_FEN(¥500 CPM)
|
||||
# 再折收益,与发奖口径 [rewards.calculate_ad_reward_coin] 一致(2026-06-29 修:原裸 parse_ecpm_yuan
|
||||
# 不钳,伪造/异常天价 eCPM 会把报表预估收益冲到任意大;金币侧已钳、收益侧漏钳)。
|
||||
"revenue_yuan": round(
|
||||
min(rewards.parse_ecpm_yuan(rec.ecpm_raw), rewards.AD_ECPM_MAX_FEN / 100.0) / 1000.0, 6,
|
||||
),
|
||||
# 单次展示收益(元)= eCPM元 ÷ 1000(每千次→单次);与发奖同源解析,口径一致。
|
||||
"revenue_yuan": round(rewards.parse_ecpm_yuan(rec.ecpm_raw) / 1000.0, 6),
|
||||
"adn": rec.adn,
|
||||
"slot_id": rec.slot_id,
|
||||
"sub_rewards": [],
|
||||
"sub_count": 1,
|
||||
}
|
||||
if rwd is not None:
|
||||
ev.update({
|
||||
@@ -188,109 +153,35 @@ def ad_revenue_report(
|
||||
})
|
||||
events.append(ev)
|
||||
|
||||
# 3) 未被展示合并的发奖行 → 事件:
|
||||
# - 激励视频(reward_video):逐条成「纯发奖」事件(每次一个 ad_session_id;有发奖无展示等)。
|
||||
# - 信息流(feed/draw):同一次比价/领券的多条广告共享**整场 ad_session_id**(客户端整场复用),
|
||||
# 按 (user_id, ad_session_id) 聚成**一次比价 / 一次领券**父事件;sub_rewards 为组内逐条明细,
|
||||
# 应发/实发取组内合计;业务已全 Draw → 类型统一 "draw"。session 缺失(极少旧数据)各自单独成组。
|
||||
feed_groups: dict[tuple[int, str], list[dict]] = {}
|
||||
# 3) 未被展示合并的发奖行 → 「纯发奖」事件(信息流整场发奖 / 有发奖无展示)。
|
||||
# 收益恒 0(收益只算展示侧,避免与展示行重复计)。
|
||||
for row in all_reward_rows:
|
||||
if row["record_id"] in used_reward_ids:
|
||||
continue
|
||||
if row["scene"] == "reward_video":
|
||||
events.append({
|
||||
"event_key": f"rwd-{row['record_id']}",
|
||||
"report_date": row["_report_date"],
|
||||
"user_id": row["user_id"],
|
||||
"ad_type": "reward_video",
|
||||
"feed_scene": row.get("feed_scene"),
|
||||
"app_env": row.get("app_env"),
|
||||
"our_code_id": row.get("our_code_id"),
|
||||
"created_at": row["created_at"],
|
||||
"hour": _cn_hour(row["created_at"]) if by_hour else None,
|
||||
"has_impression": False,
|
||||
"impressions": 0,
|
||||
"ecpm": row["ecpm"],
|
||||
"revenue_yuan": 0.0,
|
||||
"adn": None,
|
||||
"slot_id": None,
|
||||
"has_reward": True,
|
||||
"status": row["status"],
|
||||
"expected_coin": int(row["expected_coin"]),
|
||||
"actual_coin": int(row["actual_coin"]),
|
||||
"matched": bool(row["matched"]),
|
||||
"reward_detail": _reward_detail(row),
|
||||
"sub_rewards": [],
|
||||
"sub_count": 1,
|
||||
})
|
||||
else:
|
||||
# 聚合单位 = 一次完整比价/领券流程:优先用 trace_id(比价带 comparisonTraceId、领券带 sessionTraceId,
|
||||
# 整个流程不变;即使中途点广告致浮层关闭重弹、ad_session_id 变了,trace_id 仍不变 → 全流程聚成一行)。
|
||||
# 无 trace_id(历史领券未上报 / 旧数据)回退整场 ad_session_id;再无则 record_id 各自成组、不误并。
|
||||
grp_key = row.get("trace_id") or row.get("ad_session_id") or f"_rid-{row['record_id']}"
|
||||
feed_groups.setdefault((row["user_id"], grp_key), []).append(row)
|
||||
|
||||
# 信息流分组 → 「一次比价 / 一次领券」父事件(收益恒 0:收益只算展示侧,避免与展示行重复计)。
|
||||
for (uid, grp_key), group in feed_groups.items():
|
||||
group.sort(key=lambda r: (r["created_at"], r["record_id"]))
|
||||
rep = group[-1] # 代表条(最新一条):时间/场景/应用/代码位取它
|
||||
expected_sum = sum(int(g["expected_coin"]) for g in group)
|
||||
actual_sum = sum(int(g["actual_coin"]) for g in group)
|
||||
# 父行 eCPM:组内各条 eCPM(分)均值(展示用,各条不同);无有效值则取代表条
|
||||
ecpm_fens = [rewards.parse_ecpm_fen(g["ecpm"]) for g in group if g.get("ecpm")]
|
||||
avg_ecpm = str(round(sum(ecpm_fens) / len(ecpm_fens))) if ecpm_fens else rep.get("ecpm")
|
||||
# 主表逐行显示用:这次发奖广告的预估收益之和(发奖侧 eCPM 折算,钳顶同展示侧)。只放进
|
||||
# row_revenue_yuan 给主表逐行展示,不进 revenue_yuan/合计/趋势——避免与展示侧 total 重复计。
|
||||
row_revenue = round(sum(
|
||||
min(rewards.parse_ecpm_yuan(g["ecpm"]), rewards.AD_ECPM_MAX_FEN / 100.0) / 1000.0
|
||||
for g in group if g.get("ecpm")
|
||||
), 6)
|
||||
events.append({
|
||||
"event_key": f"feedgrp-{uid}-{grp_key}",
|
||||
"report_date": rep["_report_date"],
|
||||
"user_id": uid,
|
||||
"ad_type": "draw", # 业务已全切 Draw 信息流,聚合行统一 draw
|
||||
"feed_scene": rep.get("feed_scene"),
|
||||
"app_env": rep.get("app_env"),
|
||||
"our_code_id": rep.get("our_code_id"),
|
||||
"created_at": rep["created_at"],
|
||||
"hour": _cn_hour(rep["created_at"]) if by_hour else None,
|
||||
"event_key": f"rwd-{row['record_id']}",
|
||||
"report_date": row["_report_date"],
|
||||
"user_id": row["user_id"],
|
||||
"ad_type": _SCENE_TO_AD_TYPE.get(row["scene"], row["scene"]),
|
||||
"app_env": row.get("app_env"),
|
||||
"our_code_id": row.get("our_code_id"),
|
||||
"created_at": row["created_at"],
|
||||
"hour": _cn_hour(row["created_at"]) if by_hour else None,
|
||||
"has_impression": False,
|
||||
"impressions": 0,
|
||||
"ecpm": avg_ecpm,
|
||||
"ecpm": row["ecpm"],
|
||||
"revenue_yuan": 0.0,
|
||||
"row_revenue_yuan": row_revenue,
|
||||
"adn": None,
|
||||
"slot_id": None,
|
||||
"has_reward": True,
|
||||
"status": rep["status"], # 代表状态(逐条见展开)
|
||||
"expected_coin": expected_sum,
|
||||
"actual_coin": actual_sum,
|
||||
"matched": all(bool(g["matched"]) for g in group),
|
||||
"reward_detail": None,
|
||||
"sub_rewards": [_reward_detail(g) for g in group],
|
||||
"sub_count": len(group),
|
||||
"status": row["status"],
|
||||
"expected_coin": int(row["expected_coin"]),
|
||||
"actual_coin": int(row["actual_coin"]),
|
||||
"matched": bool(row["matched"]),
|
||||
"reward_detail": _reward_detail(row),
|
||||
})
|
||||
|
||||
# 「场景」作为全局筛选(与 user_id/ad_type 一致):同时作用于明细、合计与 daily/hourly 趋势。
|
||||
# feed_scene 仅信息流 / Draw 有值,激励视频与旧数据为 None;选中后只保留该场景事件。
|
||||
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 是真实小额、非客户端那种假值)。
|
||||
if app_env is not None:
|
||||
events = [e for e in events if e.get("app_env") == app_env]
|
||||
|
||||
# 排序:time=按时间倒序(新→旧);ecpm=按 eCPM 数值倒序(eCPM 原值是字符串「分」,转数值排;
|
||||
# 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。
|
||||
if sort == "ecpm":
|
||||
events.sort(key=lambda e: rewards.parse_ecpm_fen(e["ecpm"]), reverse=True)
|
||||
else:
|
||||
events.sort(key=lambda e: (e["report_date"], e["created_at"]), reverse=True)
|
||||
events.sort(key=lambda e: (e["report_date"], e["user_id"], e["created_at"]))
|
||||
|
||||
# 补手机号(admin 展示用,完整不脱敏,与用户 / 钱包 / 比价记录页一致):批量一次查,避免 N+1。
|
||||
uids = {e["user_id"] for e in events}
|
||||
@@ -328,94 +219,14 @@ def ad_revenue_report(
|
||||
for d in sorted(daily_map.values(), key=lambda x: x["date"])
|
||||
]
|
||||
|
||||
# 穿山甲后台收益(GroMore 数据 API,T+1 入库 ad_pangle_daily_revenue):汇总 + 按天趋势级展示,
|
||||
# 与上面客户端自报 eCPM 折算的预估并列对照(看 gap)。穿山甲数据**无用户/场景/类型维度**,故仅在
|
||||
# 「全量视图」(未按 user_id / ad_type / feed_scene 过滤)给值;一旦带这些过滤,穿山甲数无法对应口径
|
||||
# → 置 None,前端显示「-」并提示。逐条事件行不动(仍是客户端预估)。
|
||||
pangle_filterable = user_id is None and ad_type is None and feed_scene is None
|
||||
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)
|
||||
if pangle_aggs:
|
||||
by_date = {a["date"]: a for a in pangle_aggs}
|
||||
for d in daily:
|
||||
pa = by_date.get(d["date"])
|
||||
d["pangle_revenue_yuan"] = pa["revenue_yuan"] if pa else None
|
||||
d["pangle_api_revenue_yuan"] = pa["api_revenue_yuan"] if pa else None
|
||||
total_pangle_revenue_yuan = round(sum(a["revenue_yuan"] for a in pangle_aggs), 6)
|
||||
api_vals = [a["api_revenue_yuan"] for a in pangle_aggs if a["api_revenue_yuan"] is not None]
|
||||
total_pangle_api_revenue_yuan = round(sum(api_vals), 6) if api_vals else None
|
||||
|
||||
# 按小时汇总(全量,不受分页 limit/offset 影响):供前端按小时趋势图(单日 granularity=hour 时用)。
|
||||
# 只在 by_hour 下聚合(此时每个 event 带 hour);否则空。前端按天趋势仍用 daily。
|
||||
hourly: list[dict] = []
|
||||
if by_hour:
|
||||
hour_map: dict[int, dict] = {}
|
||||
for e in events:
|
||||
h = e["hour"]
|
||||
if h is None:
|
||||
continue
|
||||
hd = hour_map.get(h)
|
||||
if hd is None:
|
||||
hd = {"hour": h, "impressions": 0, "revenue_yuan": 0.0,
|
||||
"expected_coin": 0, "actual_coin": 0}
|
||||
hour_map[h] = hd
|
||||
hd["impressions"] += e["impressions"]
|
||||
hd["revenue_yuan"] += e["revenue_yuan"]
|
||||
hd["expected_coin"] += e["expected_coin"]
|
||||
hd["actual_coin"] += e["actual_coin"]
|
||||
hourly = [
|
||||
{**hd, "revenue_yuan": round(hd["revenue_yuan"], 6)}
|
||||
for hd in sorted(hour_map.values(), key=lambda x: x["hour"])
|
||||
]
|
||||
|
||||
# 分广告类型小计(按 ad_type:展示条数 + 预估收益;eCPM 由前端用 收益÷展示×1000 算)。
|
||||
# 基于全量(已按 feed_scene 过滤)events;前端只取 draw / reward_video 两类展示。
|
||||
type_map: dict[str, dict] = {}
|
||||
for e in events:
|
||||
t = type_map.get(e["ad_type"])
|
||||
if t is None:
|
||||
t = {"impressions": 0, "revenue_yuan": 0.0}
|
||||
type_map[e["ad_type"]] = t
|
||||
t["impressions"] += e["impressions"]
|
||||
t["revenue_yuan"] += e["revenue_yuan"]
|
||||
type_stats = {
|
||||
k: {"impressions": v["impressions"], "revenue_yuan": round(v["revenue_yuan"], 6)}
|
||||
for k, v in type_map.items()
|
||||
}
|
||||
|
||||
# DAU:复用大盘「今日活跃」口径(stats.today_dau,last_login_at)。该口径只能算今日,
|
||||
# 故仅当查询=今日单天时给值;历史 / 多天区间返回 None,前端显示「-」。
|
||||
is_today = date_from == date_to == rewards.cn_today().isoformat()
|
||||
dau = admin_stats.today_dau(db) if is_today else None
|
||||
|
||||
# 主表「逐行」= 单次广告行为(2026-07 按「一次比价/领券放一块」聚合):激励视频 = 一次观看一行(展示+发奖
|
||||
# 按 ad_session_id 合并);一次比价 / 一次领券 = 该次整场多条广告按 ad_session_id 聚成一行(展开看逐条)。
|
||||
# 信息流(draw/feed)的逐条展示(ad_ecpm,impressionId 各自独立、与整场发奖无公共键)不再单独占行
|
||||
# ——其展示数 / eCPM / 预估收益已计入上面的全量统计(total_*、daily / hourly、type_stats、穿山甲对照),
|
||||
# 只是主表不逐条铺开;逐条明细在父行展开里看(sub_rewards)。合计 / 趋势 / 分类大盘均基于全量 events,
|
||||
# 不受此过滤影响;total / 分页只作用于主表行。
|
||||
main_rows = [
|
||||
e for e in events
|
||||
if not (e["ad_type"] in ("draw", "feed") and e["has_impression"] and not e["has_reward"])
|
||||
]
|
||||
|
||||
return {
|
||||
"total": len(main_rows),
|
||||
"truncated": len(main_rows) > offset + limit,
|
||||
"total": len(events),
|
||||
"truncated": len(events) > limit,
|
||||
"total_impressions": total_impressions,
|
||||
"total_revenue_yuan": total_revenue_yuan,
|
||||
# 穿山甲后台收益合计(元):预估 revenue + 收益Api;非全量视图(带 user/类型/场景过滤)或无数据为 None。
|
||||
"total_pangle_revenue_yuan": total_pangle_revenue_yuan,
|
||||
"total_pangle_api_revenue_yuan": total_pangle_api_revenue_yuan,
|
||||
"pangle_revenue_available": total_pangle_revenue_yuan is not None,
|
||||
"total_expected_coin": total_expected_coin,
|
||||
"total_actual_coin": total_actual_coin,
|
||||
"mismatch_count": mismatch_count,
|
||||
"daily": daily,
|
||||
"hourly": hourly,
|
||||
"type_stats": type_stats,
|
||||
"dau": dau,
|
||||
"items": main_rows[offset:offset + limit],
|
||||
"items": events[:limit],
|
||||
}
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
"""admin「领券数据」看板聚合:发起/完成数、耗时均值与分位、按天/小时趋势、逐条明细。
|
||||
|
||||
数据源 coupon_session(一次领券一行,客户端 /api/v1/coupon/session 两段上报)。量级不大,全量拉
|
||||
区间数据后 Python 聚合(分位 SQLite 无 percentile,统一 Python 算,PG 上也一致)。
|
||||
- 发起数 = 区间内全部 session(含 started/completed/failed/abandoned),= 流失统计的基数。
|
||||
- 完成数 / 耗时均值 / 分位 = 仅 status==completed 子集(成功跑完才有可比的"领券耗时")。
|
||||
- summary/daily/hourly/total 在全量上算,不受分页;items 为排序后当前页。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date as _date, datetime
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.models.coupon_state import CouponSession
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def _cn_hour(dt: datetime) -> int:
|
||||
"""started_at(UTC 口径)→ 北京时间小时(0–23)。naive 当 UTC(sqlite),tz-aware 直接换算(pg)。"""
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
return dt.astimezone(rewards.CN_TZ).hour
|
||||
|
||||
|
||||
def _percentile(sorted_vals: list[int], q: float) -> int | None:
|
||||
"""线性插值分位(q=0..100,numpy 默认法)。sorted_vals 须已升序;空返回 None。"""
|
||||
if not sorted_vals:
|
||||
return None
|
||||
if len(sorted_vals) == 1:
|
||||
return sorted_vals[0]
|
||||
idx = (len(sorted_vals) - 1) * q / 100.0
|
||||
lo = int(idx)
|
||||
hi = min(lo + 1, len(sorted_vals) - 1)
|
||||
frac = idx - lo
|
||||
return round(sorted_vals[lo] * (1 - frac) + sorted_vals[hi] * frac)
|
||||
|
||||
|
||||
def _avg(vals: list[int]) -> int | None:
|
||||
return round(sum(vals) / len(vals)) if vals else None
|
||||
|
||||
|
||||
def _session_to_row(r, phone: str | None = None, nickname: str | None = None) -> dict:
|
||||
"""CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。"""
|
||||
return {
|
||||
"id": r.id,
|
||||
"trace_id": r.trace_id,
|
||||
"user_id": r.user_id,
|
||||
"user_phone": phone,
|
||||
"user_nickname": nickname,
|
||||
"status": r.status,
|
||||
"platforms": r.platforms,
|
||||
"origin_package": r.origin_package,
|
||||
"elapsed_ms": r.elapsed_ms,
|
||||
"platform_elapsed": r.platform_elapsed,
|
||||
"device_model": r.device_model,
|
||||
"rom": r.rom,
|
||||
"app_env": r.app_env,
|
||||
"started_at": r.started_at,
|
||||
"claimed_count": r.claimed_count,
|
||||
"trace_url": r.trace_url,
|
||||
}
|
||||
|
||||
|
||||
def _empty_result() -> dict:
|
||||
return {
|
||||
"summary": {
|
||||
"started_count": 0, "completed_count": 0, "avg_elapsed_ms": None,
|
||||
"p5_ms": None, "p50_ms": None, "p95_ms": None, "p99_ms": None,
|
||||
},
|
||||
"daily": [],
|
||||
"hourly": [],
|
||||
"total": 0,
|
||||
"items": [],
|
||||
}
|
||||
|
||||
|
||||
def coupon_data_report(
|
||||
db: Session,
|
||||
*,
|
||||
date_from: str,
|
||||
date_to: str,
|
||||
user: str | None = None,
|
||||
app_env: str | None = None,
|
||||
granularity: str = "day",
|
||||
limit: int = 500,
|
||||
offset: int = 0,
|
||||
sort: str = "time",
|
||||
) -> dict:
|
||||
"""日期区间(北京自然日 started_date,闭区间)领券数据:汇总卡 + 趋势 + 逐条明细。
|
||||
|
||||
- user:手机号/昵称模糊搜(匹配不到任何用户 → 空结果)。
|
||||
- app_env:prod/dev 精确;None=全部。
|
||||
- sort:time=发起时刻倒序(默认) / elapsed=全程耗时倒序(None 末尾)。
|
||||
"""
|
||||
by_hour = granularity == "hour"
|
||||
d_from = _date.fromisoformat(date_from)
|
||||
d_to = _date.fromisoformat(date_to)
|
||||
|
||||
# user 模糊 → 先定位匹配用户 id;匹配不到直接空结果(不全表扫)。
|
||||
user_ids: set[int] | None = None
|
||||
if user:
|
||||
like = f"%{user}%"
|
||||
user_ids = set(db.execute(
|
||||
select(User.id).where(or_(User.phone.like(like), User.nickname.like(like)))
|
||||
).scalars().all())
|
||||
if not user_ids:
|
||||
return _empty_result()
|
||||
|
||||
stmt = select(CouponSession).where(
|
||||
CouponSession.started_date >= d_from,
|
||||
CouponSession.started_date <= d_to,
|
||||
)
|
||||
if app_env is not None:
|
||||
stmt = stmt.where(CouponSession.app_env == app_env)
|
||||
if user_ids is not None:
|
||||
stmt = stmt.where(CouponSession.user_id.in_(user_ids))
|
||||
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),
|
||||
}
|
||||
|
||||
# ── 按天趋势(柱=发起/完成数,线=平均耗时)──
|
||||
daily_map: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
d = r.started_date.isoformat()
|
||||
b = daily_map.get(d)
|
||||
if b is None:
|
||||
b = {"date": d, "started_count": 0, "completed_count": 0, "_elapsed": []}
|
||||
daily_map[d] = b
|
||||
b["started_count"] += 1
|
||||
if r.status == "completed":
|
||||
b["completed_count"] += 1
|
||||
if r.elapsed_ms is not None:
|
||||
b["_elapsed"].append(r.elapsed_ms)
|
||||
daily = [
|
||||
{
|
||||
"date": b["date"],
|
||||
"started_count": b["started_count"],
|
||||
"completed_count": b["completed_count"],
|
||||
"avg_elapsed_ms": _avg(b["_elapsed"]),
|
||||
}
|
||||
for b in sorted(daily_map.values(), key=lambda x: x["date"])
|
||||
]
|
||||
|
||||
# ── 按小时趋势(单日 hour 粒度)──
|
||||
hourly: list[dict] = []
|
||||
if by_hour:
|
||||
hour_map: dict[int, dict] = {}
|
||||
for r in rows:
|
||||
h = _cn_hour(r.started_at)
|
||||
b = hour_map.get(h)
|
||||
if b is None:
|
||||
b = {"hour": h, "started_count": 0, "completed_count": 0, "_elapsed": []}
|
||||
hour_map[h] = b
|
||||
b["started_count"] += 1
|
||||
if r.status == "completed":
|
||||
b["completed_count"] += 1
|
||||
if r.elapsed_ms is not None:
|
||||
b["_elapsed"].append(r.elapsed_ms)
|
||||
hourly = [
|
||||
{
|
||||
"hour": b["hour"],
|
||||
"started_count": b["started_count"],
|
||||
"completed_count": b["completed_count"],
|
||||
"avg_elapsed_ms": _avg(b["_elapsed"]),
|
||||
}
|
||||
for b in sorted(hour_map.values(), key=lambda x: x["hour"])
|
||||
]
|
||||
|
||||
# ── 明细:排序 + 分页 + 补用户手机号/昵称(批量,防 N+1)──
|
||||
if sort == "elapsed":
|
||||
rows.sort(key=lambda r: (r.elapsed_ms is None, -(r.elapsed_ms or 0)))
|
||||
else: # time:发起时刻倒序
|
||||
rows.sort(key=lambda r: r.started_at, reverse=True)
|
||||
page = rows[offset:offset + limit]
|
||||
|
||||
uids = {r.user_id for r in page if r.user_id is not None}
|
||||
user_map: dict[int, tuple[str | None, str | None]] = {}
|
||||
if uids:
|
||||
user_map = {
|
||||
uid: (phone, nickname)
|
||||
for uid, phone, nickname in db.execute(
|
||||
select(User.id, User.phone, User.nickname).where(User.id.in_(uids))
|
||||
).all()
|
||||
}
|
||||
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))
|
||||
|
||||
return {
|
||||
"summary": summary,
|
||||
"daily": daily,
|
||||
"hourly": hourly,
|
||||
"total": len(rows),
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict:
|
||||
"""某用户全部领券记录(点手机号抽屉用):按发起时刻倒序、不限日期,total=该用户领券总次数。"""
|
||||
rows = list(db.execute(
|
||||
select(CouponSession)
|
||||
.where(CouponSession.user_id == user_id)
|
||||
.order_by(CouponSession.started_at.desc())
|
||||
.limit(limit)
|
||||
).scalars())
|
||||
total = db.execute(
|
||||
select(func.count()).select_from(CouponSession).where(CouponSession.user_id == user_id)
|
||||
).scalar_one()
|
||||
return {"items": [_session_to_row(r) for r in rows], "total": int(total)}
|
||||
@@ -7,29 +7,23 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin.repositories.queries import _as_utc, offset_paginate
|
||||
from app.integrations import jd_union, meituan
|
||||
from app.integrations import 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_link import CpsClick
|
||||
from app.models.cps_order import CpsOrder
|
||||
from app.models.cps_wx_user import CpsWxUser
|
||||
|
||||
# 美团订单状态:取消(4)/风控(5)不计佣金;结算(6)为佣金真正到账
|
||||
_INVALID_STATUS = {"4", "5"}
|
||||
_SETTLED_STATUS = "6"
|
||||
_JD_INVALID_CODES = {
|
||||
"2", "3", "4", "5", "6", "7", "8", "9", "11", "13", "14", "19", "20", "21",
|
||||
"22", "23", "25", "26", "27", "28", "29", "30", "31", "34", "35", "36",
|
||||
}
|
||||
_JD_UNPAID_CODES = {"15"}
|
||||
|
||||
# CPS 点击时序按北京时区分桶(运营看的是北京时间)
|
||||
_BJ_TZ = timezone(timedelta(hours=8))
|
||||
@@ -53,36 +47,6 @@ def _ts_to_dt(ts: object) -> datetime | None:
|
||||
"""秒级时间戳 → tz-aware UTC datetime(绝对时刻,前端按北京展示)。"""
|
||||
if not ts:
|
||||
return None
|
||||
|
||||
|
||||
def _jd_dt_to_utc(value: object) -> datetime | None:
|
||||
"""京东时间字符串(北京时间) → UTC aware datetime。"""
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
return None
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
|
||||
try:
|
||||
dt = datetime.strptime(s, fmt)
|
||||
return dt.replace(tzinfo=_BJ_TZ).astimezone(timezone.utc)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _text(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip()
|
||||
return s or None
|
||||
|
||||
|
||||
def _pick(row: dict[str, Any], *keys: str) -> Any:
|
||||
for key in keys:
|
||||
if key in row and row[key] is not None:
|
||||
return row[key]
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(int(ts), tz=timezone.utc)
|
||||
except (ValueError, OSError, TypeError):
|
||||
@@ -285,80 +249,6 @@ def _map_order_fields(r: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _jd_order_key(r: dict[str, Any]) -> str | None:
|
||||
row_id = _text(_pick(r, "id", "rowId", "orderRowId"))
|
||||
if row_id:
|
||||
return f"jd:{row_id}"
|
||||
order_id = _text(_pick(r, "orderId", "parentOrderId"))
|
||||
sku_id = _text(_pick(r, "skuId"))
|
||||
if order_id and sku_id:
|
||||
return f"jd:{order_id}:{sku_id}"
|
||||
if order_id:
|
||||
return f"jd:{order_id}"
|
||||
return None
|
||||
|
||||
|
||||
def _map_jd_order_fields(r: dict[str, Any]) -> dict:
|
||||
"""京东 order.row.query 单条订单行 → CpsOrder 字段。"""
|
||||
sku_name = _text(_pick(r, "skuName", "goodsName", "productName"))
|
||||
if sku_name and len(sku_name) > 500:
|
||||
sku_name = sku_name[:500]
|
||||
valid_code = _text(_pick(r, "validCode", "valid_code"))
|
||||
actual_fee = _yuan_to_cents(_pick(r, "actualFee", "actual_fee"))
|
||||
estimate_fee = _yuan_to_cents(_pick(r, "estimateFee", "estimate_fee"))
|
||||
commission = actual_fee if actual_fee not in (None, 0) else estimate_fee
|
||||
order_time = _jd_dt_to_utc(_pick(r, "orderTime", "order_time"))
|
||||
return {
|
||||
"platform": "jd",
|
||||
"external_order_id": _text(_pick(r, "orderId", "parentOrderId")),
|
||||
"external_row_id": _text(_pick(r, "id", "rowId", "orderRowId")),
|
||||
"sid": _text(_pick(r, "subUnionId", "sub_union_id")),
|
||||
"act_id": None,
|
||||
"biz_line": None,
|
||||
"trade_type": None,
|
||||
"pay_price_cents": _yuan_to_cents(
|
||||
_pick(r, "actualCosPrice", "estimateCosPrice", "price")
|
||||
),
|
||||
"commission_cents": commission,
|
||||
"commission_rate": _text(_pick(r, "commissionRate", "commission_rate")),
|
||||
"refund_price_cents": None,
|
||||
"refund_profit_cents": None,
|
||||
"estimated_commission_cents": estimate_fee,
|
||||
"actual_commission_cents": actual_fee,
|
||||
"mt_status": None,
|
||||
"jd_valid_code": valid_code,
|
||||
"invalid_reason": None if _is_jd_valid_code(valid_code) else f"validCode={valid_code}",
|
||||
"product_name": sku_name,
|
||||
"settle_month": _text(_pick(r, "payMonth", "settleMonth", "pay_month")),
|
||||
"site_id": _text(_pick(r, "siteId", "site_id")),
|
||||
"position_id": _text(_pick(r, "positionId", "position_id")),
|
||||
"pid": _text(_pick(r, "pid")),
|
||||
"sub_union_id": _text(_pick(r, "subUnionId", "sub_union_id")),
|
||||
"pay_time": order_time,
|
||||
"mt_update_time": _jd_dt_to_utc(_pick(r, "modifyTime", "updateTime", "modify_time"))
|
||||
or order_time,
|
||||
"raw": r,
|
||||
}
|
||||
|
||||
|
||||
def _is_jd_valid_code(valid_code: str | None) -> bool:
|
||||
code = str(valid_code).strip() if valid_code is not None else ""
|
||||
return bool(code and code not in _JD_INVALID_CODES and code not in _JD_UNPAID_CODES)
|
||||
|
||||
|
||||
def is_jd_order_valid(order: CpsOrder) -> bool:
|
||||
return _is_jd_valid_code(order.jd_valid_code)
|
||||
|
||||
|
||||
def effective_commission_cents(order: CpsOrder) -> int:
|
||||
if order.platform == "jd":
|
||||
if order.actual_commission_cents not in (None, 0):
|
||||
return order.actual_commission_cents or 0
|
||||
if order.estimated_commission_cents is not None:
|
||||
return order.estimated_commission_cents or 0
|
||||
return order.commission_cents or 0
|
||||
|
||||
|
||||
def reconcile_orders(
|
||||
db: Session, *, start_time: int, end_time: int,
|
||||
query_time_type: int = 1, sid: str | None = None, max_pages: int = 200,
|
||||
@@ -384,11 +274,6 @@ def reconcile_orders(
|
||||
continue
|
||||
fetched += 1
|
||||
fields = _map_order_fields(r)
|
||||
fields.setdefault("platform", "meituan")
|
||||
fields.setdefault("external_order_id", order_id)
|
||||
fields.setdefault("external_row_id", None)
|
||||
fields.setdefault("estimated_commission_cents", fields.get("commission_cents"))
|
||||
fields.setdefault("actual_commission_cents", None)
|
||||
existing = db.execute(
|
||||
select(CpsOrder).where(CpsOrder.order_id == order_id)
|
||||
).scalar_one_or_none()
|
||||
@@ -406,56 +291,6 @@ def reconcile_orders(
|
||||
return {"fetched": fetched, "inserted": inserted, "updated": updated, "pages": pages}
|
||||
|
||||
|
||||
def reconcile_jd_orders(
|
||||
db: Session, *, start_time: datetime, end_time: datetime,
|
||||
query_time_type: int = 3, max_pages: int = 100,
|
||||
) -> dict:
|
||||
"""调京东 order.row.query 拉单 → 按订单行 upsert。
|
||||
|
||||
京东单次查询窗口最多 1 小时,这里按北京自然时间切窗并逐页拉取。
|
||||
"""
|
||||
fetched = inserted = updated = pages = 0
|
||||
cur = start_time
|
||||
while cur < end_time:
|
||||
win_end = min(cur + timedelta(hours=1), end_time)
|
||||
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,
|
||||
)
|
||||
rows = resp.get("rows") or []
|
||||
has_more = bool(resp.get("has_more"))
|
||||
if not rows:
|
||||
break
|
||||
pages += 1
|
||||
for r in rows:
|
||||
order_id = _jd_order_key(r)
|
||||
if not order_id:
|
||||
continue
|
||||
fetched += 1
|
||||
fields = _map_jd_order_fields(r)
|
||||
existing = db.execute(
|
||||
select(CpsOrder).where(CpsOrder.order_id == order_id)
|
||||
).scalar_one_or_none()
|
||||
if existing is None:
|
||||
db.add(CpsOrder(order_id=order_id, **fields))
|
||||
inserted += 1
|
||||
else:
|
||||
for k, v in fields.items():
|
||||
setattr(existing, k, v)
|
||||
updated += 1
|
||||
if not has_more or len(rows) < 200:
|
||||
break
|
||||
page += 1
|
||||
cur = win_end
|
||||
db.commit()
|
||||
return {"fetched": fetched, "inserted": inserted, "updated": updated, "pages": pages}
|
||||
|
||||
|
||||
def list_orders(
|
||||
db: Session, *, sid: str | None = None, mt_status: str | None = None,
|
||||
limit: int = 20, cursor: int | None = None,
|
||||
@@ -689,90 +524,3 @@ def group_wx_users(db: Session, *, group_id: int, limit: int = 200) -> list[dict
|
||||
]
|
||||
result.sort(key=lambda x: x["first_seen"], reverse=True)
|
||||
return result[:limit]
|
||||
|
||||
|
||||
def group_day_users(
|
||||
db: Session, *, group_id: int, start: datetime, end: datetime, limit: int = 200,
|
||||
) -> list[dict]:
|
||||
"""该群某天(北京)以用户为单位的领券/点击 + 每人 visit 过的券。
|
||||
|
||||
时间窗为半开区间 [start, end)(end=次日 00:00),避免午夜双计。只统计 openid 非空
|
||||
(可归属到人)的点击 —— 匿名点击(美团/京东 302 多为匿名)不计入。券名 = 该点击 link
|
||||
对应活动名;活动被硬删则兜底 活动#{id}。copy=领券次数、visit=点击次数;coupons 仅
|
||||
取 visit 事件按活动分组、按次数倒序(合计 = visit_count)。排序:领券 desc、再点击 desc。
|
||||
与 group_wx_users 同风格(Python 侧聚合,跨 PG/SQLite 无方言坑)。
|
||||
|
||||
注:每日明细行的 click_pv/copy_pv 计全部点击(含匿名、UV 按 ip,ua);本函数只计 openid
|
||||
用户,故各用户求和 <= 当天行总数,二者口径不同、不必相等。
|
||||
"""
|
||||
rows = db.execute(
|
||||
select(CpsClick.openid, CpsClick.event_type, CpsClick.link_id)
|
||||
.where(CpsClick.group_id == group_id)
|
||||
.where(CpsClick.clicked_at >= _as_utc(start))
|
||||
.where(CpsClick.clicked_at < _as_utc(end))
|
||||
.where(CpsClick.openid.is_not(None))
|
||||
).all()
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
# link_id -> activity_id -> 券名(活动名)
|
||||
link_ids = {r.link_id for r in rows}
|
||||
link_to_act = dict(
|
||||
db.execute(
|
||||
select(CpsLink.id, CpsLink.activity_id).where(CpsLink.id.in_(link_ids))
|
||||
).all()
|
||||
)
|
||||
act_ids = {aid for aid in link_to_act.values() if aid is not None}
|
||||
act_name = (
|
||||
dict(
|
||||
db.execute(
|
||||
select(CpsActivity.id, CpsActivity.name).where(CpsActivity.id.in_(act_ids))
|
||||
).all()
|
||||
)
|
||||
if act_ids
|
||||
else {}
|
||||
)
|
||||
|
||||
def _coupon_name(link_id: int) -> str:
|
||||
aid = link_to_act.get(link_id)
|
||||
if aid is None:
|
||||
return f"链接#{link_id}"
|
||||
return act_name.get(aid) or f"活动#{aid}"
|
||||
|
||||
stat: dict[str, dict] = {}
|
||||
for openid, event_type, link_id in rows:
|
||||
s = stat.setdefault(openid, {"copy": 0, "visit": 0, "coupons": {}})
|
||||
if event_type == "copy":
|
||||
s["copy"] += 1
|
||||
else:
|
||||
s["visit"] += 1
|
||||
name = _coupon_name(link_id)
|
||||
s["coupons"][name] = s["coupons"].get(name, 0) + 1
|
||||
|
||||
openids = list(stat.keys())
|
||||
users = {
|
||||
u.openid: u
|
||||
for u in db.execute(
|
||||
select(CpsWxUser).where(CpsWxUser.openid.in_(openids))
|
||||
).scalars().all()
|
||||
}
|
||||
|
||||
result = [
|
||||
{
|
||||
"openid": openid,
|
||||
"nickname": users[openid].nickname if openid in users else None,
|
||||
"headimgurl": users[openid].headimgurl if openid in users else None,
|
||||
"copy_count": s["copy"],
|
||||
"visit_count": s["visit"],
|
||||
"coupons": [
|
||||
{"name": name, "count": cnt}
|
||||
# 次数倒序;同次数按券名升序兜底,保证 PG 无 ORDER BY 行序下输出稳定
|
||||
for name, cnt in sorted(
|
||||
s["coupons"].items(), key=lambda kv: (-kv[1], kv[0])
|
||||
)
|
||||
],
|
||||
}
|
||||
for openid, s in stat.items()
|
||||
]
|
||||
result.sort(key=lambda x: (x["copy_count"], x["visit_count"]), reverse=True)
|
||||
return result[:limit]
|
||||
|
||||
@@ -16,7 +16,6 @@ from app.core.config import settings
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
from app.models.ad_reward import AdRewardRecord
|
||||
from app.models.admin import AdminAuditLog
|
||||
from app.models.analytics_event import AnalyticsEvent
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.device import DeviceLiveness
|
||||
from app.models.feedback import Feedback
|
||||
@@ -125,9 +124,8 @@ def list_users(
|
||||
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
|
||||
|
||||
|
||||
def _attach_user_info(db: Session, records: list[ComparisonRecord | Feedback | PriceReport]) -> None:
|
||||
"""给每条记录瞬态挂 phone/nickname(非 DB 列,供 admin schema from_attributes 读)。
|
||||
按 user_id 鸭子类型,比价记录/反馈/上报通用。"""
|
||||
def _attach_user_info(db: Session, records: list[ComparisonRecord]) -> None:
|
||||
"""给每条比价记录瞬态挂 phone/nickname(非 DB 列,供 admin schema from_attributes 读)。"""
|
||||
uids = {r.user_id for r in records}
|
||||
if not uids:
|
||||
return
|
||||
@@ -563,62 +561,6 @@ def list_feedbacks(
|
||||
sort_col = sort_cols.get(sort_by, Feedback.id)
|
||||
order_fn = asc if sort_order == "asc" else desc
|
||||
id_order = asc(Feedback.id) if sort_order == "asc" else desc(Feedback.id)
|
||||
items, next_cursor, total = offset_paginate(
|
||||
db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor
|
||||
)
|
||||
_attach_user_info(db, items) # 列表展示完整手机号(点手机号查该用户全部反馈)
|
||||
return items, next_cursor, total
|
||||
|
||||
|
||||
def feedback_summary(db: Session) -> dict:
|
||||
"""反馈审核台各状态计数(待审核/已采纳/未采纳/合计)。待审核含历史 new 态(与前端 isPending 一致)。"""
|
||||
rows = db.execute(
|
||||
select(Feedback.status, func.count(Feedback.id)).group_by(Feedback.status)
|
||||
).all()
|
||||
by_status = {status: int(count) for status, count in rows}
|
||||
return {
|
||||
"pending": by_status.get("pending", 0) + by_status.get("new", 0),
|
||||
"adopted": by_status.get("adopted", 0),
|
||||
"rejected": by_status.get("rejected", 0),
|
||||
"total": sum(by_status.values()),
|
||||
}
|
||||
|
||||
|
||||
def list_analytics_events(
|
||||
db: Session,
|
||||
*,
|
||||
event: str | None = None,
|
||||
device_id: str | None = None,
|
||||
user_id: int | None = None,
|
||||
session_id: str | None = None,
|
||||
created_from: datetime | None = None,
|
||||
created_to: datetime | None = None,
|
||||
sort_by: str = "id",
|
||||
sort_order: str = "desc",
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[AnalyticsEvent], int | None, int]:
|
||||
"""埋点日志列表(admin 全量)。按 事件名 / 设备ID前缀 / 用户ID / 会话ID / 接收时间范围 筛选,
|
||||
按 id·接收时间排序。offset 分页(同 [list_feedbacks])。created_at 为 timestamptz,
|
||||
日期入参统一转 tz-aware UTC 比较。"""
|
||||
stmt = select(AnalyticsEvent)
|
||||
if event:
|
||||
stmt = stmt.where(AnalyticsEvent.event == event)
|
||||
if device_id and device_id.strip():
|
||||
stmt = stmt.where(AnalyticsEvent.device_id.like(f"{device_id.strip()}%"))
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(AnalyticsEvent.user_id == user_id)
|
||||
if session_id and session_id.strip():
|
||||
stmt = stmt.where(AnalyticsEvent.session_id == session_id.strip())
|
||||
if created_from is not None:
|
||||
stmt = stmt.where(AnalyticsEvent.created_at >= _as_utc(created_from))
|
||||
if created_to is not None:
|
||||
stmt = stmt.where(AnalyticsEvent.created_at <= _as_utc(created_to))
|
||||
|
||||
sort_cols = {"id": AnalyticsEvent.id, "created_at": AnalyticsEvent.created_at}
|
||||
sort_col = sort_cols.get(sort_by, AnalyticsEvent.id)
|
||||
order_fn = asc if sort_order == "asc" else desc
|
||||
id_order = asc(AnalyticsEvent.id) if sort_order == "asc" else desc(AnalyticsEvent.id)
|
||||
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
|
||||
|
||||
|
||||
@@ -788,7 +730,6 @@ def get_user_overview(db: Session, user_id: int) -> dict | None:
|
||||
"user": user,
|
||||
"coin_balance": acc.coin_balance if acc else 0,
|
||||
"cash_balance_cents": acc.cash_balance_cents if acc else 0,
|
||||
"invite_cash_balance_cents": acc.invite_cash_balance_cents if acc else 0,
|
||||
"total_coin_earned": acc.total_coin_earned if acc else 0,
|
||||
"comparison_total": _count(ComparisonRecord, ComparisonRecord.user_id == user_id),
|
||||
"comparison_success": _count(
|
||||
@@ -806,12 +747,6 @@ def get_user_overview(db: Session, user_id: int) -> dict | None:
|
||||
}
|
||||
|
||||
|
||||
def _as_utc_naive(value: datetime) -> datetime:
|
||||
"""窗口入参 → UTC naive(= _as_utc 去时区),与库里按 naive UTC 存取的 created_at 同口径比较。
|
||||
历史遗留:_window_conds 一直引用本函数却未定义(自定义区间会 NameError),此处补上。"""
|
||||
return _as_utc(value).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _window_conds(col, date_from: datetime | None, date_to: datetime | None) -> list:
|
||||
"""把 [date_from, date_to] 转成对 col(created_at)的过滤条件;都为 None = 全量(注册至今)。"""
|
||||
conds = []
|
||||
@@ -901,13 +836,6 @@ def user_reward_stats(
|
||||
}
|
||||
|
||||
|
||||
def _cn_wall_to_utc(dt: datetime) -> datetime:
|
||||
"""coin_transaction 存的是北京 wall-clock(naive,见 wallet.grant_coins「存北京 wall-clock」),转成 UTC naive,
|
||||
与广告表(func.now() UTC)统一 —— 让本函数按同一绝对时刻排序、且前端 apiTime(把无时区时间当 UTC 再 +8 展示)
|
||||
口径一致;否则签到会比实际多显示 8 小时(北京时间又被 +8)。"""
|
||||
return dt.replace(tzinfo=rewards.CN_TZ).astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def user_coin_records(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
@@ -928,9 +856,6 @@ def user_coin_records(
|
||||
offset = max(cursor or 0, 0)
|
||||
fetch = offset + limit + 1
|
||||
rows: list[dict] = []
|
||||
# coin_transaction 存北京 wall-clock(其余表存 UTC);签到窗口边界 +8h 对齐北京,过滤/计数才不偏移 8 小时
|
||||
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
|
||||
|
||||
for rec in db.execute(
|
||||
select(AdRewardRecord)
|
||||
@@ -974,7 +899,7 @@ def user_coin_records(
|
||||
.where(
|
||||
CoinTransaction.user_id == user_id,
|
||||
CoinTransaction.biz_type == "signin",
|
||||
*_window_conds(CoinTransaction.created_at, signin_from, signin_to),
|
||||
*_window_conds(CoinTransaction.created_at, date_from, date_to),
|
||||
)
|
||||
.order_by(CoinTransaction.created_at.desc())
|
||||
.limit(fetch)
|
||||
@@ -982,8 +907,7 @@ def user_coin_records(
|
||||
rows.append({
|
||||
"source": "signin",
|
||||
"source_label": "签到",
|
||||
# 北京 wall-clock → UTC,与广告记录统一(前端 apiTime 会 +8 回北京展示,不然签到会多 8 小时)
|
||||
"created_at": _cn_wall_to_utc(rec.created_at),
|
||||
"created_at": rec.created_at,
|
||||
"ecpm": None,
|
||||
"coin": rec.amount,
|
||||
})
|
||||
@@ -1009,72 +933,27 @@ def user_coin_records(
|
||||
+ _count(
|
||||
CoinTransaction, CoinTransaction.user_id == user_id,
|
||||
CoinTransaction.biz_type == "signin",
|
||||
*_window_conds(CoinTransaction.created_at, signin_from, signin_to),
|
||||
*_window_conds(CoinTransaction.created_at, date_from, date_to),
|
||||
)
|
||||
)
|
||||
return rows[offset:offset + limit], (offset + limit if has_more else None), total
|
||||
|
||||
|
||||
def _attach_price_report_comparison(db: Session, records: list[PriceReport]) -> None:
|
||||
"""给每条上报瞬态挂关联比价记录的 trace + 设备/版本快照:
|
||||
trace_id/trace_url(点 Trace 看完整比价过程)、device_model/rom_name/android_version(机型OS版本列)、
|
||||
app_version(提交版本号列,= 提交时我们 app 的 versionName)。
|
||||
comparison_record_id 为空 / 关联记录查不到 → 全 None。"""
|
||||
cids = {r.comparison_record_id for r in records if r.comparison_record_id is not None}
|
||||
rows = (
|
||||
db.execute(
|
||||
select(
|
||||
ComparisonRecord.id,
|
||||
ComparisonRecord.trace_id,
|
||||
ComparisonRecord.trace_url,
|
||||
ComparisonRecord.device_model,
|
||||
ComparisonRecord.rom_name,
|
||||
ComparisonRecord.android_version,
|
||||
ComparisonRecord.app_version,
|
||||
).where(ComparisonRecord.id.in_(cids))
|
||||
).all()
|
||||
if cids
|
||||
else []
|
||||
)
|
||||
cmap = {row.id: row for row in rows}
|
||||
for r in records:
|
||||
row = cmap.get(r.comparison_record_id)
|
||||
r.trace_id = row.trace_id if row else None
|
||||
r.trace_url = row.trace_url if row else None
|
||||
r.device_model = row.device_model if row else None
|
||||
r.rom_name = row.rom_name if row else None
|
||||
r.android_version = row.android_version if row else None
|
||||
r.app_version = row.app_version if row else None
|
||||
|
||||
|
||||
def list_price_reports(
|
||||
db: Session,
|
||||
*,
|
||||
status: str | None = None,
|
||||
user_id: int | None = None,
|
||||
sort_by: str = "id",
|
||||
sort_order: str = "desc",
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[PriceReport], int | None, int]:
|
||||
"""上报更低价列表(admin 全量,可按状态/用户筛)。offset 分页 + total,按 id·提交时间排序。
|
||||
join User 取 phone 瞬态挂(列表展示完整手机号);按 comparison_record_id 挂 trace_id/trace_url。"""
|
||||
"""上报更低价列表(admin 全量,可按状态/用户筛)。offset 分页 + total,id 倒序。"""
|
||||
stmt = select(PriceReport)
|
||||
if status:
|
||||
stmt = stmt.where(PriceReport.status == status)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(PriceReport.user_id == user_id)
|
||||
|
||||
sort_cols = {"id": PriceReport.id, "created_at": PriceReport.created_at}
|
||||
sort_col = sort_cols.get(sort_by, PriceReport.id)
|
||||
order_fn = asc if sort_order == "asc" else desc
|
||||
id_order = asc(PriceReport.id) if sort_order == "asc" else desc(PriceReport.id)
|
||||
items, next_cursor, total = offset_paginate(
|
||||
db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor
|
||||
)
|
||||
_attach_user_info(db, items)
|
||||
_attach_price_report_comparison(db, items)
|
||||
return items, next_cursor, total
|
||||
return offset_paginate(db, stmt, (PriceReport.id.desc(),), limit=limit, cursor=cursor)
|
||||
|
||||
|
||||
def price_report_summary(db: Session) -> dict:
|
||||
|
||||
@@ -5,44 +5,20 @@ user.last_login_at / comparison_record.status / withdraw_order.status)要加索
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
from app.models.ad_reward import AdRewardRecord
|
||||
from app.models.analytics_event import AnalyticsEvent
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.coupon_state import CouponPromptEngagement
|
||||
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.user import User
|
||||
from app.models.wallet import CoinTransaction, WithdrawOrder
|
||||
|
||||
_BEIJING = timezone(timedelta(hours=8))
|
||||
COUPON_REWARD_BIZ_TYPES = ("reward_video", "ad_reward", "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 = (
|
||||
*COUPON_REWARD_BIZ_TYPES,
|
||||
*COMPARISON_REWARD_BIZ_TYPES,
|
||||
*EXCLUDED_REWARD_BIZ_TYPES,
|
||||
*UNCLASSIFIED_FEED_BIZ_TYPES,
|
||||
)
|
||||
MEITUAN_CPS_INVALID_STATUSES = ("4", "5")
|
||||
MEITUAN_CPS_SETTLED_STATUS = "6"
|
||||
COMPARE_START_EVENT = "real_compare_start"
|
||||
COUPON_START_EVENT = "real_coupon_start"
|
||||
JD_CPS_INVALID_CODES = {
|
||||
"2", "3", "4", "5", "6", "7", "8", "9", "11", "13", "14", "19", "20", "21",
|
||||
"22", "23", "25", "26", "27", "28", "29", "30", "31", "34", "35", "36",
|
||||
}
|
||||
JD_CPS_UNPAID_CODES = {"15"}
|
||||
|
||||
|
||||
def _beijing_today_start_utc() -> datetime:
|
||||
@@ -52,130 +28,20 @@ def _beijing_today_start_utc() -> datetime:
|
||||
return start_bj.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def today_dau(db: Session) -> int:
|
||||
"""今日活跃用户数(DAU):登录 + 开始比价 + 开始领券,按用户去重。
|
||||
|
||||
广告收益报表复用这个函数;历史窗口 DAU 由 dashboard_overview 的 period 口径另算。
|
||||
"""
|
||||
today_bj = datetime.now(_BEIJING).date()
|
||||
def dashboard_overview(db: Session) -> dict:
|
||||
today_start = _beijing_today_start_utc()
|
||||
tomorrow_start = today_start + timedelta(days=1)
|
||||
login_user_ids = _id_set(
|
||||
db,
|
||||
select(User.id).where(User.last_login_at >= today_start, User.last_login_at < tomorrow_start),
|
||||
)
|
||||
compare_start_user_ids = _event_user_ids(
|
||||
db, (COMPARE_START_EVENT,), today_start, tomorrow_start
|
||||
)
|
||||
coupon_event_user_ids = _event_user_ids(
|
||||
db, (COUPON_START_EVENT,), today_start, tomorrow_start
|
||||
)
|
||||
coupon_claim_user_ids = _id_set(
|
||||
db,
|
||||
select(CouponPromptEngagement.user_id).where(
|
||||
CouponPromptEngagement.engage_date == today_bj,
|
||||
CouponPromptEngagement.engage_type == "claim_started",
|
||||
),
|
||||
)
|
||||
return len(login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids)
|
||||
|
||||
|
||||
def _default_period_end() -> date:
|
||||
"""新版大盘不含今日,默认窗口结束日=北京时间昨天。"""
|
||||
return datetime.now(_BEIJING).date() - timedelta(days=1)
|
||||
|
||||
|
||||
def _normalize_period(date_from: date | None, date_to: date | None) -> tuple[date, date]:
|
||||
end = date_to or _default_period_end()
|
||||
start = date_from or end
|
||||
if start > end:
|
||||
start, end = end, start
|
||||
return start, end
|
||||
|
||||
|
||||
def _period_bounds(date_from: date, date_to: date) -> tuple[datetime, datetime, datetime, datetime]:
|
||||
"""返回同一北京自然日窗口的 UTC aware 边界和北京 naive 边界。
|
||||
|
||||
user.created_at / last_login_at 是 UTC aware 口径;比较/金币等历史上有北京 naive
|
||||
写入,所以两套边界同时保留。
|
||||
"""
|
||||
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)
|
||||
return (
|
||||
start_utc,
|
||||
end_utc,
|
||||
start_bj.replace(tzinfo=None),
|
||||
end_bj.replace(tzinfo=None),
|
||||
)
|
||||
|
||||
|
||||
def _date_range(date_from: date, date_to: date) -> list[date]:
|
||||
days = (date_to - date_from).days
|
||||
return [date_from + timedelta(days=i) for i in range(days + 1)]
|
||||
|
||||
|
||||
def _id_set(db: Session, stmt) -> set[int]:
|
||||
return {int(v) for v in db.execute(stmt).scalars().all() if v is not None}
|
||||
|
||||
|
||||
def _event_user_ids(
|
||||
db: Session, event_names: tuple[str, ...], start_utc: datetime, end_utc: datetime
|
||||
) -> set[int]:
|
||||
return _id_set(
|
||||
db,
|
||||
select(AnalyticsEvent.user_id).where(
|
||||
AnalyticsEvent.user_id.is_not(None),
|
||||
AnalyticsEvent.event.in_(event_names),
|
||||
AnalyticsEvent.created_at >= start_utc,
|
||||
AnalyticsEvent.created_at < end_utc,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _commission_rate_percent(raw: str | None) -> Decimal | None:
|
||||
"""美团 commissionRate 原值: "300"=3%, "10"=0.1%;也兼容 "3%"。"""
|
||||
if raw is None:
|
||||
return None
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
if s.endswith("%"):
|
||||
return Decimal(s[:-1])
|
||||
val = Decimal(s)
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
return val / Decimal("100")
|
||||
|
||||
|
||||
def _jd_valid_order(order: CpsOrder) -> bool:
|
||||
code = str(order.jd_valid_code).strip() if order.jd_valid_code is not None else ""
|
||||
return bool(code and code not in JD_CPS_INVALID_CODES and code not in JD_CPS_UNPAID_CODES)
|
||||
|
||||
|
||||
def dashboard_overview(
|
||||
db: Session, *, date_from: date | None = None, date_to: date | None = None
|
||||
) -> dict:
|
||||
today_start = _beijing_today_start_utc()
|
||||
period_from, period_to = _normalize_period(date_from, date_to)
|
||||
start_utc, end_utc, start_local, end_local = _period_bounds(period_from, period_to)
|
||||
|
||||
def _count(model, *conds) -> int:
|
||||
stmt = select(func.count(model.id))
|
||||
if conds:
|
||||
stmt = stmt.where(*conds)
|
||||
return int(db.execute(stmt).scalar_one())
|
||||
return db.execute(stmt).scalar_one()
|
||||
|
||||
def _sum(col, *conds) -> int:
|
||||
stmt = select(func.coalesce(func.sum(col), 0))
|
||||
if conds:
|
||||
stmt = stmt.where(*conds)
|
||||
return int(db.execute(stmt).scalar_one())
|
||||
|
||||
def _user_id_set(stmt) -> set[int]:
|
||||
return {int(v) for v in db.execute(stmt).scalars().all() if v is not None}
|
||||
return db.execute(stmt).scalar_one()
|
||||
|
||||
# ===== 用户 =====
|
||||
by_status = dict(
|
||||
@@ -195,223 +61,6 @@ def dashboard_overview(
|
||||
comparison_total = _count(ComparisonRecord)
|
||||
comparison_success = _count(ComparisonRecord, ComparisonRecord.status == "success")
|
||||
success_rate = round(comparison_success / comparison_total, 4) if comparison_total else 0.0
|
||||
period_comparison_conds = (
|
||||
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_success_rate = (
|
||||
round(period_comparison_success / period_comparison_total, 4)
|
||||
if period_comparison_total
|
||||
else 0.0
|
||||
)
|
||||
period_saved_positive_count = _count(
|
||||
ComparisonRecord,
|
||||
*period_comparison_conds,
|
||||
ComparisonRecord.status == "success",
|
||||
ComparisonRecord.saved_amount_cents > 0,
|
||||
)
|
||||
period_saved_positive_sum = _sum(
|
||||
ComparisonRecord.saved_amount_cents,
|
||||
*period_comparison_conds,
|
||||
ComparisonRecord.status == "success",
|
||||
ComparisonRecord.saved_amount_cents > 0,
|
||||
)
|
||||
period_avg_saved_cents = (
|
||||
round(period_saved_positive_sum / period_saved_positive_count)
|
||||
if period_saved_positive_count
|
||||
else None
|
||||
)
|
||||
period_avg_duration_ms = db.execute(
|
||||
select(func.avg(ComparisonRecord.total_ms)).where(
|
||||
*period_comparison_conds,
|
||||
ComparisonRecord.total_ms.is_not(None),
|
||||
ComparisonRecord.total_ms > 0,
|
||||
)
|
||||
).scalar_one()
|
||||
period_avg_duration_ms = (
|
||||
round(float(period_avg_duration_ms))
|
||||
if period_avg_duration_ms is not None
|
||||
else None
|
||||
)
|
||||
|
||||
ordered_exists = (
|
||||
select(SavingsRecord.id)
|
||||
.where(
|
||||
SavingsRecord.user_id == ComparisonRecord.user_id,
|
||||
SavingsRecord.source == "compare",
|
||||
SavingsRecord.shop_name.is_not(None),
|
||||
SavingsRecord.shop_name == ComparisonRecord.store_name,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
period_ordered_count = _count(
|
||||
ComparisonRecord,
|
||||
*period_comparison_conds,
|
||||
ComparisonRecord.store_name.is_not(None),
|
||||
ordered_exists,
|
||||
)
|
||||
|
||||
# ===== 日期窗口用户 =====
|
||||
period_new_user_ids = _user_id_set(
|
||||
select(User.id).where(User.created_at >= start_utc, User.created_at < end_utc)
|
||||
)
|
||||
login_user_ids = _user_id_set(
|
||||
select(User.id).where(User.last_login_at >= start_utc, User.last_login_at < end_utc)
|
||||
)
|
||||
compare_start_user_ids = _event_user_ids(
|
||||
db, (COMPARE_START_EVENT,), start_utc, end_utc
|
||||
)
|
||||
coupon_event_user_ids = _event_user_ids(
|
||||
db, (COUPON_START_EVENT,), start_utc, end_utc
|
||||
)
|
||||
coupon_claim_user_ids = _user_id_set(
|
||||
select(CouponPromptEngagement.user_id).where(
|
||||
CouponPromptEngagement.engage_date >= period_from,
|
||||
CouponPromptEngagement.engage_date <= period_to,
|
||||
CouponPromptEngagement.engage_type == "claim_started",
|
||||
)
|
||||
)
|
||||
period_active_user_ids = (
|
||||
login_user_ids | compare_start_user_ids | coupon_event_user_ids | coupon_claim_user_ids
|
||||
)
|
||||
period_retained_new_user_ids = period_new_user_ids & period_active_user_ids
|
||||
period_retention_rate = (
|
||||
round(len(period_retained_new_user_ids) / len(period_new_user_ids), 4)
|
||||
if period_new_user_ids
|
||||
else None
|
||||
)
|
||||
trend_points: list[dict] = []
|
||||
for cur_date in _date_range(period_from, period_to):
|
||||
day_start_utc, day_end_utc, day_start_local, day_end_local = _period_bounds(
|
||||
cur_date, cur_date
|
||||
)
|
||||
daily_comparison_conds = (
|
||||
ComparisonRecord.created_at >= day_start_local,
|
||||
ComparisonRecord.created_at < day_end_local,
|
||||
)
|
||||
daily_login_user_ids = _user_id_set(
|
||||
select(User.id).where(
|
||||
User.last_login_at >= day_start_utc,
|
||||
User.last_login_at < day_end_utc,
|
||||
)
|
||||
)
|
||||
daily_compare_start_user_ids = _event_user_ids(
|
||||
db, (COMPARE_START_EVENT,), day_start_utc, day_end_utc
|
||||
)
|
||||
daily_coupon_event_user_ids = _event_user_ids(
|
||||
db, (COUPON_START_EVENT,), day_start_utc, day_end_utc
|
||||
)
|
||||
daily_coupon_claim_user_ids = _user_id_set(
|
||||
select(CouponPromptEngagement.user_id).where(
|
||||
CouponPromptEngagement.engage_date == cur_date,
|
||||
CouponPromptEngagement.engage_type == "claim_started",
|
||||
)
|
||||
)
|
||||
trend_points.append(
|
||||
{
|
||||
"date": cur_date,
|
||||
"active_users": len(
|
||||
daily_login_user_ids
|
||||
| daily_compare_start_user_ids
|
||||
| daily_coupon_event_user_ids
|
||||
| daily_coupon_claim_user_ids
|
||||
),
|
||||
"new_users": _count(
|
||||
User,
|
||||
User.created_at >= day_start_utc,
|
||||
User.created_at < day_end_utc,
|
||||
),
|
||||
"comparisons": _count(ComparisonRecord, *daily_comparison_conds),
|
||||
}
|
||||
)
|
||||
|
||||
period_coin_conds = (
|
||||
CoinTransaction.created_at >= start_local,
|
||||
CoinTransaction.created_at < end_local,
|
||||
CoinTransaction.amount > 0,
|
||||
)
|
||||
period_reward_video_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.in_(("reward_video", "ad_reward")),
|
||||
)
|
||||
period_feed_ad_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type == "feed_ad_reward",
|
||||
)
|
||||
period_signin_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type == "signin",
|
||||
)
|
||||
period_signin_boost_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type == "signin_boost",
|
||||
)
|
||||
period_task_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.like("task_%"),
|
||||
)
|
||||
period_coupon_reward_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.in_(COUPON_REWARD_BIZ_TYPES),
|
||||
)
|
||||
period_comparison_reward_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.in_(COMPARISON_REWARD_BIZ_TYPES),
|
||||
)
|
||||
period_regular_task_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.notin_(REGULAR_TASK_EXCLUDED_BIZ_TYPES),
|
||||
)
|
||||
period_cps_orders = list(
|
||||
db.execute(
|
||||
select(CpsOrder).where(
|
||||
CpsOrder.pay_time >= start_utc,
|
||||
CpsOrder.pay_time < end_utc,
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
period_meituan_orders = [
|
||||
o for o in period_cps_orders if (o.platform or "meituan") == "meituan"
|
||||
]
|
||||
period_meituan_valid_orders = [
|
||||
o for o in period_meituan_orders if o.mt_status not in MEITUAN_CPS_INVALID_STATUSES
|
||||
]
|
||||
period_jd_orders = [o for o in period_cps_orders if o.platform == "jd"]
|
||||
period_jd_valid_orders = [o for o in period_jd_orders if _jd_valid_order(o)]
|
||||
period_jd_invalid_orders = [
|
||||
o for o in period_jd_orders if o.jd_valid_code and not _jd_valid_order(o)
|
||||
]
|
||||
period_meituan_hit_count = 0
|
||||
period_meituan_miss_count = 0
|
||||
period_meituan_unknown_rate_count = 0
|
||||
for order in period_meituan_valid_orders:
|
||||
rate = _commission_rate_percent(order.commission_rate)
|
||||
if rate is None:
|
||||
period_meituan_unknown_rate_count += 1
|
||||
elif rate < Decimal("1"):
|
||||
period_meituan_miss_count += 1
|
||||
else:
|
||||
period_meituan_hit_count += 1
|
||||
period_meituan_hit_denominator = period_meituan_hit_count + period_meituan_miss_count
|
||||
period_meituan_hit_rate = (
|
||||
round(period_meituan_hit_count / period_meituan_hit_denominator, 4)
|
||||
if period_meituan_hit_denominator
|
||||
else None
|
||||
)
|
||||
|
||||
return {
|
||||
"users": {
|
||||
@@ -420,7 +69,7 @@ def dashboard_overview(
|
||||
"disabled": by_status.get("disabled", 0),
|
||||
"deleted": by_status.get("deleted", 0),
|
||||
"new_today": _count(User, User.created_at >= today_start),
|
||||
"dau": today_dau(db),
|
||||
"dau": _count(User, User.last_login_at >= today_start),
|
||||
},
|
||||
"coins": {
|
||||
# 累计发放金币(coin_transaction 里所有 amount>0 之和;负数是兑换/扣减不计)
|
||||
@@ -470,73 +119,7 @@ def dashboard_overview(
|
||||
"success": comparison_success,
|
||||
"success_rate": success_rate,
|
||||
},
|
||||
"period": {
|
||||
"date_from": period_from,
|
||||
"date_to": period_to,
|
||||
"users": {
|
||||
"new": len(period_new_user_ids),
|
||||
"active": len(period_active_user_ids),
|
||||
"retained_new_users": len(period_retained_new_user_ids),
|
||||
"retention_rate": period_retention_rate,
|
||||
"retention_note": (
|
||||
"口径:登录(last_login_at)+开始比价(real_compare_start)+"
|
||||
"开始领券(real_coupon_start/claim_started),按用户去重"
|
||||
),
|
||||
},
|
||||
"comparison": {
|
||||
"total": period_comparison_total,
|
||||
"success": period_comparison_success,
|
||||
"success_rate": period_comparison_success_rate,
|
||||
"ordered": period_ordered_count,
|
||||
"average_duration_ms": period_avg_duration_ms,
|
||||
"average_saved_cents": period_avg_saved_cents,
|
||||
},
|
||||
"coins": {
|
||||
"granted_total": _sum(CoinTransaction.amount, *period_coin_conds),
|
||||
"reward_video_coin_total": period_reward_video_coin_total,
|
||||
"feed_ad_coin_total": period_feed_ad_coin_total,
|
||||
"signin_coin_total": period_signin_coin_total,
|
||||
"signin_boost_coin_total": period_signin_boost_coin_total,
|
||||
"task_coin_total": period_task_coin_total,
|
||||
"coupon_reward_coin_total": period_coupon_reward_coin_total,
|
||||
"comparison_reward_coin_total": period_comparison_reward_coin_total,
|
||||
"regular_task_coin_total": period_regular_task_coin_total,
|
||||
},
|
||||
"cash": {
|
||||
"withdraw_success_cents": _sum(
|
||||
WithdrawOrder.amount_cents,
|
||||
WithdrawOrder.status == "success",
|
||||
WithdrawOrder.created_at >= start_local,
|
||||
WithdrawOrder.created_at < end_local,
|
||||
),
|
||||
},
|
||||
"trend": trend_points,
|
||||
},
|
||||
"feedback": {
|
||||
"new": _count(Feedback, Feedback.status.in_(("pending", "new"))),
|
||||
},
|
||||
"cps": {
|
||||
"available": True,
|
||||
"note": "美团/JD CPS 读 cps_order 对账订单;淘宝佣金暂空",
|
||||
"meituan_order_count": len(period_meituan_valid_orders),
|
||||
"meituan_commission_cents": sum(
|
||||
o.commission_cents or 0 for o in period_meituan_valid_orders
|
||||
),
|
||||
"meituan_hit_count": period_meituan_hit_count,
|
||||
"meituan_miss_count": period_meituan_miss_count,
|
||||
"meituan_unknown_rate_count": period_meituan_unknown_rate_count,
|
||||
"meituan_hit_rate": period_meituan_hit_rate,
|
||||
"jd_order_count": len(period_jd_valid_orders),
|
||||
# 数据大盘京东 CPS 只看实际佣金,不再用预估佣金兜底。
|
||||
"jd_commission_cents": sum(
|
||||
o.actual_commission_cents or 0 for o in period_jd_valid_orders
|
||||
),
|
||||
"jd_actual_commission_cents": sum(
|
||||
o.actual_commission_cents or 0 for o in period_jd_valid_orders
|
||||
),
|
||||
"jd_estimated_commission_cents": sum(
|
||||
o.estimated_commission_cents or 0 for o in period_jd_valid_orders
|
||||
),
|
||||
"jd_invalid_count": len(period_jd_invalid_orders),
|
||||
},
|
||||
"feedback": {"new": _count(Feedback, Feedback.status.in_(("pending", "new")))},
|
||||
# CPS 收入数据源未接(referral-link 只换链接,转化/佣金未回收)→ 前端显示"待接入"。
|
||||
"cps": {"available": False, "note": "CPS 转化数据未接入(P2)"},
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ def get_ad_coin_audit(
|
||||
date: Annotated[str | None, Query(description="北京时间 YYYY-MM-DD,默认今天")] = None,
|
||||
user_id: Annotated[int | None, Query(description="只看某用户;不传=全部用户")] = None,
|
||||
scene: Annotated[
|
||||
str | None, Query(description="reward_video / feed / draw;不传=全部")
|
||||
str | None, Query(description="reward_video / feed;不传=两类都要")
|
||||
] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=500)] = 100,
|
||||
only_mismatch: Annotated[
|
||||
|
||||
@@ -11,13 +11,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from app.admin.deps import AdminDb, get_current_admin
|
||||
from app.admin.repositories import ad_revenue
|
||||
from app.admin.schemas.ad_revenue import (
|
||||
AdRevenueDaily,
|
||||
AdRevenueHourly,
|
||||
AdRevenueReportOut,
|
||||
AdRevenueRow,
|
||||
AdRevenueTypeStat,
|
||||
)
|
||||
from app.admin.schemas.ad_revenue import AdRevenueDaily, AdRevenueReportOut, AdRevenueRow
|
||||
from app.core.rewards import cn_today
|
||||
|
||||
router = APIRouter(
|
||||
@@ -49,28 +43,10 @@ def get_ad_revenue_report(
|
||||
str | None,
|
||||
Query(description="reward_video / feed / draw;不传=全部类型"),
|
||||
] = None,
|
||||
feed_scene: Annotated[
|
||||
str | None,
|
||||
Query(
|
||||
description="comparison(比价) / coupon(领券) / welfare(福利);不传=全部场景。"
|
||||
"全局筛选,同时影响明细 / 合计 / 趋势"
|
||||
),
|
||||
] = None,
|
||||
app_env: Annotated[
|
||||
str | None,
|
||||
Query(
|
||||
description="prod(正式应用) / test(测试应用);不传=全部。"
|
||||
"建议正式收益报表选 prod,避免测试应用的假 eCPM 污染收益合计/平均"
|
||||
),
|
||||
] = None,
|
||||
granularity: Annotated[
|
||||
str, Query(description="day=按天 / hour=按小时(北京时间);区间>1 天建议用 day")
|
||||
] = "day",
|
||||
limit: Annotated[int, Query(ge=1, le=1000, description="每页条数(分页大小)")] = 500,
|
||||
offset: Annotated[int, Query(ge=0, description="分页偏移(已跳过的条数)=(页码-1)×每页条数")] = 0,
|
||||
sort: Annotated[
|
||||
str, Query(description="排序:time=时间倒序(默认) / ecpm=按 eCPM 数值倒序")
|
||||
] = "time",
|
||||
limit: Annotated[int, Query(ge=1, le=1000)] = 500,
|
||||
) -> AdRevenueReportOut:
|
||||
today = cn_today()
|
||||
d_from = _parse_day(date_from, field="date_from", default=today)
|
||||
@@ -82,23 +58,16 @@ 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,
|
||||
granularity=granularity, limit=limit, offset=offset, sort=sort,
|
||||
user_id=user_id, ad_type=ad_type, granularity=granularity, limit=limit,
|
||||
)
|
||||
return AdRevenueReportOut(
|
||||
date_from=d_from.isoformat(),
|
||||
date_to=d_to.isoformat(),
|
||||
daily=[AdRevenueDaily(**d) for d in result["daily"]],
|
||||
hourly=[AdRevenueHourly(**h) for h in result["hourly"]],
|
||||
type_stats={k: AdRevenueTypeStat(**v) for k, v in result["type_stats"].items()},
|
||||
dau=result["dau"],
|
||||
total=result["total"],
|
||||
truncated=result["truncated"],
|
||||
total_impressions=result["total_impressions"],
|
||||
total_revenue_yuan=result["total_revenue_yuan"],
|
||||
total_pangle_revenue_yuan=result["total_pangle_revenue_yuan"],
|
||||
total_pangle_api_revenue_yuan=result["total_pangle_api_revenue_yuan"],
|
||||
pangle_revenue_available=result["pangle_revenue_available"],
|
||||
total_expected_coin=result["total_expected_coin"],
|
||||
total_actual_coin=result["total_actual_coin"],
|
||||
mismatch_count=result["mismatch_count"],
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
"""admin「领券数据」看板:发起/完成数 + 领券耗时(均值 + P5/P50/P95/P99)+ 按天趋势 + 逐条明细。
|
||||
|
||||
任意已登录 admin 可看(只读)。聚合逻辑在 app/admin/repositories/coupon_data.py。
|
||||
数据源 coupon_session(客户端 /api/v1/coupon/session 两段上报)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date as _date
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from app.admin.deps import AdminDb, get_current_admin
|
||||
from app.admin.repositories import coupon_data
|
||||
from app.admin.schemas.coupon_data import (
|
||||
CouponDataDaily,
|
||||
CouponDataHourly,
|
||||
CouponDataOut,
|
||||
CouponDataRow,
|
||||
CouponDataSummary,
|
||||
CouponUserRecordsOut,
|
||||
)
|
||||
from app.core.rewards import cn_today
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/coupon-data",
|
||||
tags=["admin-coupon-data"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
# 区间最大跨度(天);超出拒绝,避免一次拉过多天拖垮接口(对齐广告收益报表)。
|
||||
_MAX_RANGE_DAYS = 92
|
||||
|
||||
|
||||
def _parse_day(value: str | None, *, field: str, default: _date) -> _date:
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return _date.fromisoformat(value)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=f"{field} 需为 YYYY-MM-DD") from e
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=CouponDataOut,
|
||||
summary="领券数据看板(发起/完成数 + 耗时分位 + 按天趋势 + 逐条明细)",
|
||||
)
|
||||
def get_coupon_data(
|
||||
db: AdminDb,
|
||||
date_from: Annotated[str | None, Query(description="起始日 北京 YYYY-MM-DD,默认今天")] = None,
|
||||
date_to: Annotated[str | None, Query(description="结束日 北京 YYYY-MM-DD,闭区间,默认=date_from")] = None,
|
||||
user: Annotated[str | None, Query(description="用户手机号/昵称模糊搜;不传=全部")] = None,
|
||||
app_env: Annotated[str, Query(description="prod(默认) / dev / all(全部环境)")] = "prod",
|
||||
granularity: Annotated[
|
||||
str, Query(description="day=按天 / hour=按小时(北京);区间>1 天建议 day")
|
||||
] = "day",
|
||||
limit: Annotated[int, Query(ge=1, le=1000, description="每页条数(分页大小)")] = 500,
|
||||
offset: Annotated[int, Query(ge=0, description="分页偏移(已跳过条数)=(页码-1)×每页条数")] = 0,
|
||||
sort: Annotated[
|
||||
str, Query(description="排序:time=发起时间倒序(默认) / elapsed=耗时倒序")
|
||||
] = "time",
|
||||
) -> CouponDataOut:
|
||||
today = cn_today()
|
||||
d_from = _parse_day(date_from, field="date_from", default=today)
|
||||
d_to = _parse_day(date_to, field="date_to", default=d_from)
|
||||
if d_to < d_from:
|
||||
raise HTTPException(status_code=422, detail="date_to 不能早于 date_from")
|
||||
if (d_to - d_from).days + 1 > _MAX_RANGE_DAYS:
|
||||
raise HTTPException(status_code=422, detail=f"区间最长 {_MAX_RANGE_DAYS} 天")
|
||||
|
||||
# 报表默认只看 prod(对齐广告报表防串台口径);app_env=all 时不过滤、看全部环境。
|
||||
env = None if app_env == "all" else app_env
|
||||
result = coupon_data.coupon_data_report(
|
||||
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(),
|
||||
user=user, app_env=env, granularity=granularity,
|
||||
limit=limit, offset=offset, sort=sort,
|
||||
)
|
||||
return CouponDataOut(
|
||||
date_from=d_from.isoformat(),
|
||||
date_to=d_to.isoformat(),
|
||||
summary=CouponDataSummary(**result["summary"]),
|
||||
daily=[CouponDataDaily(**d) for d in result["daily"]],
|
||||
hourly=[CouponDataHourly(**h) for h in result["hourly"]],
|
||||
total=result["total"],
|
||||
items=[CouponDataRow(**r) for r in result["items"]],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/user-records",
|
||||
response_model=CouponUserRecordsOut,
|
||||
summary="某用户全部领券记录(点手机号抽屉:领券次数 + 记录列表)",
|
||||
)
|
||||
def get_user_coupon_records(
|
||||
db: AdminDb,
|
||||
user_id: Annotated[int, Query(description="用户 id")],
|
||||
limit: Annotated[int, Query(ge=1, le=500, description="最多返回条数")] = 100,
|
||||
sort_by: Annotated[str, Query(description="兼容 UserRecordsDrawer 参数;固定按发起时间倒序")] = "created_at",
|
||||
sort_order: Annotated[str, Query(description="兼容参数,忽略")] = "desc",
|
||||
) -> CouponUserRecordsOut:
|
||||
result = coupon_data.coupon_user_records(db, user_id=user_id, limit=limit)
|
||||
return CouponUserRecordsOut(
|
||||
items=[CouponDataRow(**r) for r in result["items"]],
|
||||
total=result["total"],
|
||||
)
|
||||
+11
-121
@@ -1,13 +1,13 @@
|
||||
"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 联盟订单对账 + 统计。
|
||||
"""admin CPS 分发与对账:群/活动管理 + 生成落地页短链 + 美团订单对账 + 统计。
|
||||
|
||||
平台:meituan(actId+sid 转链 + query_order 对账) / taobao(整段淘口令) / jd(链接)。
|
||||
淘宝暂未接 API → 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"。
|
||||
淘宝/京东无 API → 只统计点击(咱落地页 PV/UV + 淘宝复制),对账字段显示 "-"。
|
||||
群/活动管理 = operator;订单对账(涉佣金) = finance;只读列表/统计 = 登录即可。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import date as _date, datetime, time as _dt_time, timedelta, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
|
||||
@@ -34,7 +34,6 @@ from app.admin.schemas.cps import (
|
||||
from app.core import media
|
||||
from app.core.config import settings
|
||||
from app.integrations import meituan
|
||||
from app.integrations.jd_union import JdUnionError
|
||||
from app.integrations.meituan import MeituanCpsError
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.cps_activity import CpsActivity
|
||||
@@ -341,108 +340,24 @@ def generate_referral_links(
|
||||
|
||||
|
||||
# ───────────── 订单对账 ─────────────
|
||||
_BEIJING = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def _parse_day(value: str | None, *, field: str) -> _date | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return _date.fromisoformat(value)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=f"{field} 需为 YYYY-MM-DD") from e
|
||||
|
||||
|
||||
def _reconcile_range_to_ts(
|
||||
date_from: _date | None, date_to: _date | None, days: int
|
||||
) -> tuple[int, int]:
|
||||
if date_from is None and date_to is None:
|
||||
now = int(time.time())
|
||||
return now - days * 86400, now
|
||||
|
||||
start_day = date_from or date_to
|
||||
end_day = date_to or date_from
|
||||
if start_day is None or end_day is None:
|
||||
raise HTTPException(status_code=422, detail="日期参数不完整")
|
||||
if start_day > end_day:
|
||||
start_day, end_day = end_day, start_day
|
||||
if (end_day - start_day).days + 1 > 90:
|
||||
raise HTTPException(status_code=422, detail="美团订单查询最长 90 天")
|
||||
|
||||
start_dt = datetime.combine(start_day, _dt_time.min, tzinfo=_BEIJING)
|
||||
end_dt = datetime.combine(end_day + timedelta(days=1), _dt_time.min, tzinfo=_BEIJING)
|
||||
return int(start_dt.timestamp()), int(end_dt.timestamp())
|
||||
|
||||
|
||||
def _reconcile_range_to_bj_dt(
|
||||
date_from: _date | None, date_to: _date | None, days: int
|
||||
) -> tuple[datetime, datetime]:
|
||||
start_ts, end_ts = _reconcile_range_to_ts(date_from, date_to, days)
|
||||
return (
|
||||
datetime.fromtimestamp(start_ts, tz=_BEIJING),
|
||||
datetime.fromtimestamp(end_ts, tz=_BEIJING),
|
||||
)
|
||||
|
||||
|
||||
def _merge_reconcile_result(total: dict, current: dict) -> None:
|
||||
for key in ("fetched", "inserted", "updated", "pages"):
|
||||
total[key] = int(total.get(key, 0)) + int(current.get(key, 0))
|
||||
|
||||
|
||||
@router.post("/orders/reconcile", response_model=CpsReconcileResult, summary="拉取联盟订单对账")
|
||||
@router.post("/orders/reconcile", response_model=CpsReconcileResult, summary="拉取美团订单对账")
|
||||
def reconcile_orders(
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("finance"))],
|
||||
db: AdminDb,
|
||||
date_from: Annotated[str | None, Query(description="起始日 YYYY-MM-DD")] = None,
|
||||
date_to: Annotated[str | None, Query(description="结束日 YYYY-MM-DD")] = None,
|
||||
days: Annotated[int, Query(ge=1, le=90, description="未传日期时默认拉近 N 天")] = 7,
|
||||
days: Annotated[int, Query(ge=1, le=90)] = 7,
|
||||
sid: Annotated[str | None, Query(max_length=64)] = None,
|
||||
query_time_type: Annotated[int, Query(ge=1, le=3)] = 1,
|
||||
platform: Annotated[str, Query(pattern="^(all|meituan|jd)$")] = "all",
|
||||
) -> CpsReconcileResult:
|
||||
parsed_from = _parse_day(date_from, field="date_from")
|
||||
parsed_to = _parse_day(date_to, field="date_to")
|
||||
result = {"fetched": 0, "inserted": 0, "updated": 0, "pages": 0}
|
||||
now = int(time.time())
|
||||
try:
|
||||
if platform in {"all", "meituan"}:
|
||||
start_ts, end_ts = _reconcile_range_to_ts(parsed_from, parsed_to, days)
|
||||
mt_result = cps_repo.reconcile_orders(
|
||||
db,
|
||||
start_time=start_ts,
|
||||
end_time=end_ts,
|
||||
query_time_type=query_time_type if query_time_type in (1, 2) else 2,
|
||||
sid=sid,
|
||||
)
|
||||
_merge_reconcile_result(result, mt_result)
|
||||
if platform in {"all", "jd"}:
|
||||
if sid:
|
||||
raise HTTPException(status_code=422, detail="京东订单刷新不支持 sid 筛选")
|
||||
start_dt, end_dt = _reconcile_range_to_bj_dt(parsed_from, parsed_to, days)
|
||||
jd_result = cps_repo.reconcile_jd_orders(
|
||||
db,
|
||||
start_time=start_dt,
|
||||
end_time=end_dt,
|
||||
query_time_type=query_time_type,
|
||||
)
|
||||
_merge_reconcile_result(result, jd_result)
|
||||
result = cps_repo.reconcile_orders(
|
||||
db, start_time=now - days * 86400, end_time=now, sid=sid,
|
||||
)
|
||||
except MeituanCpsError as e:
|
||||
raise HTTPException(status_code=502, detail=f"美团拉单失败: {e}") from e
|
||||
except JdUnionError as e:
|
||||
raise HTTPException(status_code=502, detail=f"京东拉单失败: {e}") from e
|
||||
write_audit(
|
||||
db, admin, action="cps.order.reconcile", target_type="cps_order", target_id=None,
|
||||
detail={
|
||||
"platform": platform,
|
||||
"date_from": date_from,
|
||||
"date_to": date_to,
|
||||
"days": days,
|
||||
"sid": sid,
|
||||
"query_time_type": query_time_type,
|
||||
**result,
|
||||
},
|
||||
ip=get_client_ip(request),
|
||||
commit=True,
|
||||
detail={"days": days, "sid": sid, **result}, ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return CpsReconcileResult(**result)
|
||||
|
||||
@@ -553,7 +468,7 @@ def group_daily(
|
||||
while cur <= last:
|
||||
cp = click_points[idx] if idx < len(click_points) else None
|
||||
row = {
|
||||
"date": cur.strftime("%Y-%m-%d"),
|
||||
"date": cur.strftime("%m-%d"),
|
||||
"click_pv": cp["click_pv"] if cp else 0,
|
||||
"click_uv": cp["click_uv"] if cp else 0,
|
||||
"copy_pv": cp["copy_pv"] if cp else 0,
|
||||
@@ -581,28 +496,3 @@ def group_wx_users(group_id: int, db: AdminDb) -> dict:
|
||||
if group is None:
|
||||
raise HTTPException(status_code=404, detail="群不存在")
|
||||
return {"users": cps_repo.group_wx_users(db, group_id=group_id)}
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}/day-users", summary="某天该群按用户的领券/点击 + 每人点过的券")
|
||||
def group_day_users(
|
||||
group_id: int,
|
||||
db: AdminDb,
|
||||
date: Annotated[str, Query(description="北京日期 YYYY-MM-DD")],
|
||||
) -> dict:
|
||||
group = cps_repo.get_group(db, group_id)
|
||||
if group is None:
|
||||
raise HTTPException(status_code=404, detail="群不存在")
|
||||
bj = timezone(timedelta(hours=8))
|
||||
try:
|
||||
day0 = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=bj)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail="date 格式应为 YYYY-MM-DD") from e
|
||||
start = day0.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
end = start + timedelta(days=1)
|
||||
users = cps_repo.group_day_users(db, group_id=group_id, start=start, end=end)
|
||||
return {
|
||||
"group_id": group.id,
|
||||
"group_name": group.name,
|
||||
"date": date,
|
||||
"users": users,
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"""admin 数据大盘(只读聚合)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.admin.deps import AdminDb, get_current_admin
|
||||
from app.admin.repositories import stats
|
||||
@@ -17,11 +15,5 @@ router = APIRouter(
|
||||
|
||||
|
||||
@router.get("/overview", response_model=DashboardOverview, summary="大盘核心指标")
|
||||
def overview(
|
||||
db: AdminDb,
|
||||
date_from: date | None = Query(None, description="北京时间自然日起始日 YYYY-MM-DD"),
|
||||
date_to: date | None = Query(None, description="北京时间自然日结束日 YYYY-MM-DD"),
|
||||
) -> DashboardOverview:
|
||||
return DashboardOverview.model_validate(
|
||||
stats.dashboard_overview(db, date_from=date_from, date_to=date_to)
|
||||
)
|
||||
def overview(db: AdminDb) -> DashboardOverview:
|
||||
return DashboardOverview.model_validate(stats.dashboard_overview(db))
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
"""admin 埋点日志:列表 + 按事件 / 设备 / 用户 / 会话 / 时间筛选(只读,同库直接查 analytics_event)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.admin.deps import AdminDb, get_current_admin
|
||||
from app.admin.repositories import queries
|
||||
from app.admin.schemas.analytics import AnalyticsEventOut
|
||||
from app.admin.schemas.common import CursorPage
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/event-logs",
|
||||
tags=["admin-event-logs"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=CursorPage[AnalyticsEventOut], summary="埋点日志列表")
|
||||
def list_event_logs(
|
||||
db: AdminDb,
|
||||
event: Annotated[str | None, Query(max_length=64)] = None,
|
||||
device_id: Annotated[str | None, Query(max_length=64)] = None,
|
||||
user_id: Annotated[int | None, Query()] = None,
|
||||
session_id: Annotated[str | None, Query(max_length=64)] = None,
|
||||
created_from: Annotated[datetime | None, Query()] = None,
|
||||
created_to: Annotated[datetime | None, Query()] = None,
|
||||
sort_by: Annotated[str, Query(pattern="^(id|created_at)$")] = "id",
|
||||
sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[AnalyticsEventOut]:
|
||||
items, next_cursor, total = queries.list_analytics_events(
|
||||
db,
|
||||
event=event,
|
||||
device_id=device_id,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
created_from=created_from,
|
||||
created_to=created_to,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
limit=limit,
|
||||
cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[AnalyticsEventOut.model_validate(e) for e in items],
|
||||
next_cursor=next_cursor,
|
||||
total=total,
|
||||
)
|
||||
@@ -10,12 +10,7 @@ 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 mutations, queries
|
||||
from app.admin.schemas.common import CursorPage, OkResponse
|
||||
from app.admin.schemas.feedback import (
|
||||
FeedbackApproveRequest,
|
||||
FeedbackOut,
|
||||
FeedbackRejectRequest,
|
||||
FeedbackSummary,
|
||||
)
|
||||
from app.admin.schemas.feedback import FeedbackApproveRequest, FeedbackOut, FeedbackRejectRequest
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.feedback import Feedback
|
||||
from app.repositories import wallet as wallet_repo
|
||||
@@ -64,11 +59,6 @@ def list_feedbacks(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/summary", response_model=FeedbackSummary, summary="反馈审核统计(各状态计数)")
|
||||
def feedback_summary(db: AdminDb) -> FeedbackSummary:
|
||||
return FeedbackSummary.model_validate(queries.feedback_summary(db))
|
||||
|
||||
|
||||
@router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理")
|
||||
def handle_feedback(
|
||||
feedback_id: int,
|
||||
|
||||
@@ -37,14 +37,11 @@ def list_price_reports(
|
||||
db: AdminDb,
|
||||
status: Annotated[str | None, Query()] = None,
|
||||
user_id: Annotated[int | None, Query()] = None,
|
||||
sort_by: Annotated[str, Query(pattern="^(id|created_at)$")] = "id",
|
||||
sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[PriceReportOut]:
|
||||
items, next_cursor, total = queries.list_price_reports(
|
||||
db, status=status, user_id=user_id,
|
||||
sort_by=sort_by, sort_order=sort_order, limit=limit, cursor=cursor,
|
||||
db, status=status, user_id=user_id, limit=limit, cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[PriceReportOut.model_validate(r) for r in items],
|
||||
|
||||
+11
-19
@@ -210,28 +210,22 @@ def grant_user_cash(
|
||||
db: AdminDb,
|
||||
) -> OkResponse:
|
||||
"""给指定用户增/减或设值现金(分)。delta:正=发放、负=扣减;set:直接设为目标值。
|
||||
account=coin_cash(金币兑现金)/ invite_cash(邀请奖励金):两本账物理隔离、各调各的。
|
||||
主要用于让无现金用户直接测试提现。"""
|
||||
user = user_repo.get_user_by_id(db, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
# 按目标账户选「余额字段 + 变动入口」(grant_invite_cash 与 grant_cash 同构)
|
||||
is_invite = body.account == "invite_cash"
|
||||
balance_attr = "invite_cash_balance_cents" if is_invite else "cash_balance_cents"
|
||||
grant_fn = wallet_repo.grant_invite_cash if is_invite else wallet_repo.grant_cash
|
||||
acct_label = "邀请奖励金" if is_invite else "现金"
|
||||
before: int | None = None
|
||||
# set=设为目标值:读当前余额算差值,仍复用 grant 写一笔流水(沿用原子/审计/扣负保护)
|
||||
# set=设为目标值:读当前余额算差值,仍复用 grant_cash 写一笔流水(沿用原子/审计/扣负保护)
|
||||
if body.mode == "set":
|
||||
if body.amount_cents < 0:
|
||||
raise HTTPException(status_code=400, detail=f"目标{acct_label}值不能为负")
|
||||
raise HTTPException(status_code=400, detail="目标现金值不能为负")
|
||||
# lock=True:锁账户行,防连点/并发各读同一 before 算同一 delta 双写,余额错位
|
||||
acc_locked = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True)
|
||||
before = getattr(acc_locked, balance_attr)
|
||||
before = wallet_repo.get_or_create_account(
|
||||
db, user_id, commit=False, lock=True
|
||||
).cash_balance_cents
|
||||
delta = body.amount_cents - before
|
||||
if delta == 0:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"当前{acct_label}已为 {body.amount_cents} 分,无需调整"
|
||||
status_code=400, detail=f"当前现金已为 {body.amount_cents} 分,无需调整"
|
||||
)
|
||||
else:
|
||||
if body.amount_cents == 0:
|
||||
@@ -240,20 +234,18 @@ def grant_user_cash(
|
||||
# 负数扣减时不允许扣成负余额(运营误操作保护);lock=True 防并发扣穿
|
||||
if delta < 0:
|
||||
acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False, lock=True)
|
||||
if getattr(acc_now, balance_attr) + delta < 0:
|
||||
if acc_now.cash_balance_cents + delta < 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"扣减后{acct_label}为负(当前余额 {getattr(acc_now, balance_attr)} 分)",
|
||||
status_code=400, detail=f"扣减后现金为负(当前余额 {acc_now.cash_balance_cents} 分)"
|
||||
)
|
||||
biz_type = "admin_grant" if delta > 0 else "admin_deduct"
|
||||
# grant 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕)
|
||||
acc, _ = grant_fn(
|
||||
# grant_cash 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕)
|
||||
acc, _ = wallet_repo.grant_cash(
|
||||
db, user_id, delta, biz_type=biz_type, remark=f"admin:{body.reason}"[:128],
|
||||
)
|
||||
detail = {
|
||||
"account": body.account,
|
||||
"amount_cents": delta,
|
||||
"balance_after_cents": getattr(acc, balance_attr),
|
||||
"balance_after_cents": acc.cash_balance_cents,
|
||||
"reason": body.reason,
|
||||
}
|
||||
if body.mode == "set":
|
||||
|
||||
@@ -9,8 +9,8 @@ class AdConfigOut(BaseModel):
|
||||
|
||||
app_id: str
|
||||
reward_code_id: str
|
||||
compare_draw_code_id: str
|
||||
coupon_draw_code_id: str
|
||||
compare_feed_code_id: str
|
||||
coupon_feed_code_id: str
|
||||
reward_mkey: str
|
||||
reward_enabled: bool
|
||||
compare_ad_enabled: bool
|
||||
@@ -23,8 +23,8 @@ class AdConfigUpdate(BaseModel):
|
||||
|
||||
app_id: str | None = None
|
||||
reward_code_id: str | None = None
|
||||
compare_draw_code_id: str | None = None
|
||||
coupon_draw_code_id: str | None = None
|
||||
compare_feed_code_id: str | None = None
|
||||
coupon_feed_code_id: str | None = None
|
||||
reward_mkey: str | None = None
|
||||
reward_enabled: bool | None = None
|
||||
compare_ad_enabled: bool | None = None
|
||||
|
||||
@@ -40,38 +40,15 @@ class AdRevenueRecord(BaseModel):
|
||||
|
||||
|
||||
class AdRevenueDaily(BaseModel):
|
||||
"""按日期汇总的一天(供前端按天趋势图;全量,不受分页影响)。"""
|
||||
"""按日期汇总的一天(供前端按天趋势图;全量,不受 limit 影响)。"""
|
||||
|
||||
date: str = Field(..., description="北京时间 YYYY-MM-DD")
|
||||
impressions: int = Field(..., description="当天展示条数合计")
|
||||
revenue_yuan: float = Field(..., description="当天客户端预估收益合计(元;eCPM 折算)")
|
||||
pangle_revenue_yuan: float | None = Field(
|
||||
None, description="当天穿山甲后台预估收益(元;GroMore revenue);非全量视图/无数据为空"
|
||||
)
|
||||
pangle_api_revenue_yuan: float | None = Field(
|
||||
None, description="当天穿山甲收益Api(元;GroMore api_revenue,更接近结算);未配/当天/无数据为空"
|
||||
)
|
||||
revenue_yuan: float = Field(..., description="当天预估收益合计(元)")
|
||||
expected_coin: int = Field(..., description="当天应发金币合计")
|
||||
actual_coin: int = Field(..., description="当天实发金币合计")
|
||||
|
||||
|
||||
class AdRevenueHourly(BaseModel):
|
||||
"""按北京小时(0–23)汇总的一小时(供前端按小时趋势图;全量,不受分页影响,单日 granularity=hour 时非空)。"""
|
||||
|
||||
hour: int = Field(..., description="北京时间小时 0–23")
|
||||
impressions: int = Field(..., description="该小时展示条数合计")
|
||||
revenue_yuan: float = Field(..., description="该小时预估收益合计(元)")
|
||||
expected_coin: int = Field(..., description="该小时应发金币合计")
|
||||
actual_coin: int = Field(..., description="该小时实发金币合计")
|
||||
|
||||
|
||||
class AdRevenueTypeStat(BaseModel):
|
||||
"""按广告类型(ad_type)的小计:展示条数 + 预估收益(eCPM 由前端用 收益÷展示×1000 算)。"""
|
||||
|
||||
impressions: int = Field(..., description="该类型展示条数合计")
|
||||
revenue_yuan: float = Field(..., description="该类型预估收益合计(元)")
|
||||
|
||||
|
||||
class AdRevenueRow(BaseModel):
|
||||
"""一次广告事件(逐条一行):激励视频展示与发奖按 ad_session_id 合并;信息流展示 / 发奖各自成行。"""
|
||||
|
||||
@@ -79,12 +56,7 @@ class AdRevenueRow(BaseModel):
|
||||
report_date: str = Field(..., description="该事件所属日期(北京时间 YYYY-MM-DD)")
|
||||
user_id: int
|
||||
user_phone: str | None = Field(None, description="用户手机号(admin 展示用,完整;用户已删 / 查不到为空)")
|
||||
ad_type: str = Field(..., description="reward_video(激励视频) / feed(信息流) / draw(Draw 信息流);历史 NULL 视为 feed")
|
||||
feed_scene: str | None = Field(
|
||||
None,
|
||||
description="点位场景:comparison(比价) / coupon(领券) / welfare(福利);供区分比价/领券 Draw 收益;"
|
||||
"激励视频与旧数据为空",
|
||||
)
|
||||
ad_type: str = Field(..., description="reward_video(激励视频) / feed(信息流) / draw(历史 Draw 信息流)")
|
||||
app_env: str | None = Field(None, description="我们的应用:prod(傻瓜比价正式) / test(测试应用);旧数据为空")
|
||||
our_code_id: str | None = Field(None, description="我们后台配置的代码位 ID(104xxx);旧数据为空")
|
||||
hour: int | None = Field(None, description="北京时间小时 0–23(granularity=hour 时有值;按天为 null)")
|
||||
@@ -94,11 +66,6 @@ class AdRevenueRow(BaseModel):
|
||||
impressions: int = Field(..., description="本行展示条数:有展示=1 / 纯发奖=0(供日汇总、趋势图复用)")
|
||||
ecpm: str | None = Field(None, description="eCPM 原始值(分/千次);展示行取展示值,纯发奖行取发奖采用值")
|
||||
revenue_yuan: float = Field(..., description="本次展示预估收益(元)= eCPM元 ÷ 1000;纯发奖行=0")
|
||||
row_revenue_yuan: float | None = Field(
|
||||
None,
|
||||
description="主表逐行展示用的预估收益(元):一次比价/领券聚合行=该次发奖广告 eCPM 折算之和;"
|
||||
"其它行为空(前端回退取 revenue_yuan)。不进合计/趋势,避免与展示侧重复计",
|
||||
)
|
||||
adn: str | None = Field(None, description="实际填充 ADN 子渠道(pangle/gdt…);纯发奖行为空")
|
||||
slot_id: str | None = Field(None, description="底层 mediation rit(非我们配置的广告位 ID);纯发奖行为空")
|
||||
# ── 发奖侧 ──
|
||||
@@ -111,15 +78,6 @@ class AdRevenueRow(BaseModel):
|
||||
None,
|
||||
description="发奖复算明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致);点行展开下钻用,纯展示为空",
|
||||
)
|
||||
sub_rewards: list[AdRevenueRecord] = Field(
|
||||
default_factory=list,
|
||||
description="一次比价/领券聚合行的组内逐条发奖明细(同一整场 ad_session_id 的多条广告);"
|
||||
"点行展开渲染多行。激励视频/纯展示行为空(单条看 reward_detail)",
|
||||
)
|
||||
sub_count: int = Field(
|
||||
1,
|
||||
description="本行聚合的发奖条数:一次比价/领券=该次广告条数(≥1);激励视频/纯展示=1",
|
||||
)
|
||||
|
||||
|
||||
class AdRevenueReportOut(BaseModel):
|
||||
@@ -128,36 +86,10 @@ class AdRevenueReportOut(BaseModel):
|
||||
date_from: str = Field(..., description="报表起始日期(北京时间 YYYY-MM-DD)")
|
||||
date_to: str = Field(..., description="报表结束日期(北京时间 YYYY-MM-DD,闭区间;单日时与 date_from 相同)")
|
||||
daily: list[AdRevenueDaily] = Field(..., description="按日期汇总序列(全量,供按天趋势图)")
|
||||
hourly: list[AdRevenueHourly] = Field(
|
||||
default_factory=list,
|
||||
description="按小时汇总序列(全量,供按小时趋势图;按天查询时为空)",
|
||||
)
|
||||
type_stats: dict[str, AdRevenueTypeStat] = Field(
|
||||
default_factory=dict,
|
||||
description="按广告类型(ad_type)小计 {ad_type: {impressions, revenue_yuan}};前端取 draw / reward_video 做分类大盘",
|
||||
)
|
||||
dau: int | None = Field(
|
||||
None,
|
||||
description="今日活跃用户数(复用大盘口径,last_login_at);**仅查询=今日单天时有值**,历史/多天为 null",
|
||||
)
|
||||
total: int = Field(..., description="广告事件总数(全量,不受分页影响;= 当前筛选下的分页总条数)")
|
||||
truncated: bool = Field(..., description="当前页之后是否还有更多事件(len(events) > offset + limit)")
|
||||
total: int = Field(..., description="广告事件总数(全量,不受 limit 影响)")
|
||||
truncated: bool = Field(..., description="明细是否被 limit 截断")
|
||||
total_impressions: int = Field(..., description="全量展示条数合计")
|
||||
total_revenue_yuan: float = Field(..., description="全量客户端预估收益合计(元;eCPM 折算)")
|
||||
total_pangle_revenue_yuan: float | None = Field(
|
||||
None,
|
||||
description="全量穿山甲后台预估收益合计(元;GroMore revenue)。穿山甲无用户/类型/场景维度,"
|
||||
"仅「全量视图」(未按 user_id/ad_type/feed_scene 过滤)时有值,否则为 null",
|
||||
)
|
||||
total_pangle_api_revenue_yuan: float | None = Field(
|
||||
None,
|
||||
description="全量穿山甲收益Api合计(元;GroMore api_revenue,各 ADN 回传、更接近结算);"
|
||||
"未配 Reporting / 查当天 / 非全量视图 时为 null",
|
||||
)
|
||||
pangle_revenue_available: bool = Field(
|
||||
False,
|
||||
description="本次结果是否带穿山甲后台收益(=全量视图且已同步到数据)。false 时前端「穿山甲收益」显示「-」",
|
||||
)
|
||||
total_revenue_yuan: float = Field(..., description="全量收益合计(元)")
|
||||
total_expected_coin: int = Field(..., description="全量应发金币合计")
|
||||
total_actual_coin: int = Field(..., description="全量实发金币合计")
|
||||
mismatch_count: int = Field(..., description="应发≠实发的发奖条数(=0 说明全部按公式发放)")
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"""admin 埋点日志列表响应。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class AnalyticsEventOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
event: str
|
||||
# Who
|
||||
device_id: str
|
||||
user_id: int | None
|
||||
# When
|
||||
session_id: str | None
|
||||
client_ts: int
|
||||
sent_at: int | None
|
||||
created_at: datetime # 服务端接收时间(server_at)
|
||||
# Where
|
||||
page: str | None
|
||||
client_ip: str | None
|
||||
# How
|
||||
oem: str | None
|
||||
os: str | None
|
||||
model: str | None
|
||||
app_ver: str | None
|
||||
network: str | None
|
||||
channel: str | None
|
||||
# What 专属
|
||||
props: dict | None
|
||||
@@ -1,83 +0,0 @@
|
||||
"""admin「领券数据」看板 schemas:汇总卡 + 按天/小时趋势 + 逐条领券明细。
|
||||
|
||||
数据源 coupon_session(一次领券一行)。耗时单位 ms(前端按需折秒);均值/分位只统计 completed。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CouponDataSummary(BaseModel):
|
||||
"""汇总卡:发起/完成数 + 耗时均值与分位(P5/P50/P95/P99,基于 completed 的 elapsed_ms)。"""
|
||||
|
||||
started_count: int = Field(..., description="发起数(区间内所有领券 session)")
|
||||
completed_count: int = Field(..., description="完成数(status=completed)")
|
||||
avg_elapsed_ms: int | None = Field(None, description="平均耗时(ms,仅 completed;无数据为空)")
|
||||
p5_ms: int | None = Field(None, description="耗时 5 分位(ms)")
|
||||
p50_ms: int | None = Field(None, description="耗时 50 分位(ms,中位数)")
|
||||
p95_ms: int | None = Field(None, description="耗时 95 分位(ms)")
|
||||
p99_ms: int | None = Field(None, description="耗时 99 分位(ms)")
|
||||
|
||||
|
||||
class CouponDataDaily(BaseModel):
|
||||
"""按天趋势(全量,不受分页影响):柱=发起/完成数,线=平均耗时。"""
|
||||
|
||||
date: str = Field(..., description="北京时间 YYYY-MM-DD")
|
||||
started_count: int
|
||||
completed_count: int
|
||||
avg_elapsed_ms: int | None = Field(None, description="当天平均耗时(ms,仅 completed)")
|
||||
|
||||
|
||||
class CouponDataHourly(BaseModel):
|
||||
"""按北京小时(0–23)趋势(单日 granularity=hour 时非空)。"""
|
||||
|
||||
hour: int = Field(..., description="北京时间小时 0–23")
|
||||
started_count: int
|
||||
completed_count: int
|
||||
avg_elapsed_ms: int | None = None
|
||||
|
||||
|
||||
class CouponDataRow(BaseModel):
|
||||
"""一条领券明细(一次领券任务)。"""
|
||||
|
||||
id: int = Field(..., description="coupon_session 主键(抽屉 rowKey 用)")
|
||||
trace_id: str
|
||||
user_id: int | None = None
|
||||
user_phone: str | None = Field(None, description="手机号(admin 展示;匿名领券/查不到为空)")
|
||||
user_nickname: str | None = Field(None, description="昵称")
|
||||
status: str = Field(..., description="started / completed / failed / abandoned")
|
||||
platforms: list[str] | None = Field(None, description="发起勾选平台")
|
||||
origin_package: str | None = Field(None, description="发起来源 App 包名;null=App 内(傻瓜比价首页)发起")
|
||||
elapsed_ms: int | None = Field(None, description="全程耗时(ms)")
|
||||
platform_elapsed: dict[str, int] | None = Field(
|
||||
None, description="各平台耗时 {meituan-waimai/taobao-shanguang/jd-waimai: ms}"
|
||||
)
|
||||
device_model: str | None = None
|
||||
rom: str | None = None
|
||||
app_env: str | None = None
|
||||
started_at: datetime = Field(..., description="发起时刻(明细「时间」列)")
|
||||
claimed_count: int | None = None
|
||||
trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id")
|
||||
|
||||
|
||||
class CouponDataOut(BaseModel):
|
||||
"""领券数据看板响应:汇总卡 + 趋势 + 明细分页。"""
|
||||
|
||||
date_from: str
|
||||
date_to: str
|
||||
summary: CouponDataSummary
|
||||
daily: list[CouponDataDaily] = Field(default_factory=list, description="按天趋势(全量)")
|
||||
hourly: list[CouponDataHourly] = Field(
|
||||
default_factory=list, description="按小时趋势(单日 hour 粒度时非空)"
|
||||
)
|
||||
total: int = Field(..., description="明细总条数(全量,不受分页)")
|
||||
items: list[CouponDataRow] = Field(..., description="逐条领券明细(当前页)")
|
||||
|
||||
|
||||
class CouponUserRecordsOut(BaseModel):
|
||||
"""某用户全部领券记录(点手机号抽屉用):total=该用户领券总次数,items=记录列表(UserRecordsDrawer 渲染)。"""
|
||||
|
||||
items: list[CouponDataRow]
|
||||
total: int
|
||||
@@ -1,7 +1,7 @@
|
||||
"""admin CPS 分发与对账 schemas。金额统一「分」(cents),前端 yuan() 展示。
|
||||
|
||||
平台:meituan(actId+sid 转链对账) / taobao(淘口令,只统计点击) / jd(链接 + 订单 API 对账)。
|
||||
对账类字段对淘宝为 None → 前端显示 "-"(暂未对账)。
|
||||
平台:meituan(actId+sid 转链对账) / taobao(淘口令,只统计点击) / jd(链接,只统计点击)。
|
||||
对账类字段对淘宝/京东为 None → 前端显示 "-"(无法对账)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -116,22 +116,15 @@ class CpsOrderOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
platform: str = "meituan"
|
||||
order_id: str
|
||||
external_order_id: str | None = None
|
||||
external_row_id: str | None = None
|
||||
sid: str | None = None
|
||||
act_id: str | None = None
|
||||
pay_price_cents: int | None = None
|
||||
commission_cents: int | None = None
|
||||
estimated_commission_cents: int | None = None
|
||||
actual_commission_cents: int | None = None
|
||||
commission_rate: str | None = None
|
||||
mt_status: str | None = None
|
||||
jd_valid_code: str | None = None
|
||||
invalid_reason: str | None = None
|
||||
product_name: str | None = None
|
||||
settle_month: str | None = None
|
||||
pay_time: datetime | None = None
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""admin 大盘 schemas(对应 stats.dashboard_overview 的嵌套结构)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
@@ -40,56 +38,6 @@ class DashboardComparison(BaseModel):
|
||||
success_rate: float
|
||||
|
||||
|
||||
class DashboardPeriodUsers(BaseModel):
|
||||
new: int
|
||||
active: int
|
||||
retained_new_users: int
|
||||
retention_rate: float | None = None
|
||||
retention_note: str
|
||||
|
||||
|
||||
class DashboardPeriodComparison(BaseModel):
|
||||
total: int
|
||||
success: int
|
||||
success_rate: float
|
||||
ordered: int
|
||||
average_duration_ms: int | None = None
|
||||
average_saved_cents: int | None = None
|
||||
|
||||
|
||||
class DashboardPeriodCoins(BaseModel):
|
||||
granted_total: int
|
||||
reward_video_coin_total: int = 0
|
||||
feed_ad_coin_total: int = 0
|
||||
signin_coin_total: int = 0
|
||||
signin_boost_coin_total: int = 0
|
||||
task_coin_total: int = 0
|
||||
coupon_reward_coin_total: int = 0
|
||||
comparison_reward_coin_total: int = 0
|
||||
regular_task_coin_total: int = 0
|
||||
|
||||
|
||||
class DashboardPeriodCash(BaseModel):
|
||||
withdraw_success_cents: int
|
||||
|
||||
|
||||
class DashboardTrendPoint(BaseModel):
|
||||
date: date
|
||||
active_users: int
|
||||
new_users: int
|
||||
comparisons: int
|
||||
|
||||
|
||||
class DashboardPeriod(BaseModel):
|
||||
date_from: date
|
||||
date_to: date
|
||||
users: DashboardPeriodUsers
|
||||
comparison: DashboardPeriodComparison
|
||||
coins: DashboardPeriodCoins
|
||||
cash: DashboardPeriodCash
|
||||
trend: list[DashboardTrendPoint] = []
|
||||
|
||||
|
||||
class DashboardFeedback(BaseModel):
|
||||
new: int
|
||||
|
||||
@@ -97,17 +45,6 @@ class DashboardFeedback(BaseModel):
|
||||
class DashboardCps(BaseModel):
|
||||
available: bool
|
||||
note: str
|
||||
meituan_order_count: int = 0
|
||||
meituan_commission_cents: int = 0
|
||||
meituan_hit_count: int = 0
|
||||
meituan_miss_count: int = 0
|
||||
meituan_unknown_rate_count: int = 0
|
||||
meituan_hit_rate: float | None = None
|
||||
jd_order_count: int = 0
|
||||
jd_commission_cents: int = 0
|
||||
jd_actual_commission_cents: int = 0
|
||||
jd_estimated_commission_cents: int = 0
|
||||
jd_invalid_count: int = 0
|
||||
|
||||
|
||||
class DashboardOverview(BaseModel):
|
||||
@@ -115,6 +52,5 @@ class DashboardOverview(BaseModel):
|
||||
coins: DashboardCoins
|
||||
cash: DashboardCash
|
||||
comparison: DashboardComparison
|
||||
period: DashboardPeriod
|
||||
feedback: DashboardFeedback
|
||||
cps: DashboardCps
|
||||
|
||||
@@ -23,14 +23,6 @@ class FeedbackOut(BaseModel):
|
||||
reviewed_by_admin_id: int | None = None
|
||||
reviewed_at: datetime | None = None
|
||||
created_at: datetime
|
||||
# 提交端环境快照(feedback 表列):提交版本号 / 机型OS版本;改版前的历史反馈为 None
|
||||
app_version: str | None = None
|
||||
device_model: str | None = None
|
||||
rom_name: str | None = None
|
||||
android_version: str | None = None
|
||||
# 联表瞬态字段(queries._attach_user_info 挂):列表展示完整手机号,点手机号查该用户全部反馈
|
||||
phone: str | None = None
|
||||
nickname: str | None = None
|
||||
|
||||
|
||||
class FeedbackApproveRequest(BaseModel):
|
||||
@@ -45,12 +37,3 @@ class FeedbackApproveRequest(BaseModel):
|
||||
class FeedbackRejectRequest(BaseModel):
|
||||
reason: str = Field(min_length=1, max_length=256, description="未采纳原因,用户端可见")
|
||||
note: str | None = Field(default=None, max_length=256, description="运营内部审核备注")
|
||||
|
||||
|
||||
class FeedbackSummary(BaseModel):
|
||||
"""审核台顶部各状态计数(pending 含历史 new 态)。"""
|
||||
|
||||
pending: int
|
||||
adopted: int
|
||||
rejected: int
|
||||
total: int
|
||||
|
||||
@@ -31,17 +31,6 @@ class PriceReportOut(BaseModel):
|
||||
reward_coins: int | None = None
|
||||
reviewed_at: datetime | None = None
|
||||
created_at: datetime
|
||||
# 联表瞬态字段:phone/nickname 由 _attach_user_info 挂(展示完整手机号、点击查该用户全部上报);
|
||||
# 其余由 _attach_price_report_comparison 从关联比价记录挂:trace_*(Trace 列点跳调试链接)、
|
||||
# device_model/rom_name/android_version(机型OS版本列)、app_version(提交版本号列)
|
||||
phone: str | None = None
|
||||
nickname: str | None = None
|
||||
trace_id: str | None = None
|
||||
trace_url: str | None = None
|
||||
device_model: str | None = None
|
||||
rom_name: str | None = None
|
||||
android_version: str | None = None
|
||||
app_version: str | None = None
|
||||
|
||||
|
||||
class PriceReportRejectRequest(BaseModel):
|
||||
|
||||
@@ -17,7 +17,6 @@ class AdminUserListItem(BaseModel):
|
||||
status: str
|
||||
debug_trace_enabled: bool = False
|
||||
wechat_openid: str | None = None
|
||||
wechat_nickname: str | None = None
|
||||
created_at: datetime
|
||||
last_login_at: datetime
|
||||
|
||||
@@ -30,7 +29,6 @@ class AdminUserOverview(BaseModel):
|
||||
user: AdminUserListItem
|
||||
coin_balance: int
|
||||
cash_balance_cents: int
|
||||
invite_cash_balance_cents: int # 邀请奖励金余额(与 cash_balance_cents 物理隔离)
|
||||
total_coin_earned: int
|
||||
comparison_total: int
|
||||
comparison_success: int
|
||||
@@ -90,11 +88,6 @@ class GrantCoinsRequest(BaseModel):
|
||||
|
||||
|
||||
class GrantCashRequest(BaseModel):
|
||||
# 目标账户:金币兑换的现金(cash_balance_cents)与邀请奖励金(invite_cash_balance_cents)物理隔离,
|
||||
# 各调各的、不可串。默认 coin_cash 兼容旧调用。
|
||||
account: Literal["coin_cash", "invite_cash"] = Field(
|
||||
"coin_cash", description="目标账户:coin_cash=金币兑现金账户 / invite_cash=邀请奖励金账户"
|
||||
)
|
||||
mode: Literal["delta", "set"] = Field(
|
||||
"delta", description="delta=增减(amount_cents 为变动量) / set=设为(amount_cents 为目标值,须≥0)"
|
||||
)
|
||||
|
||||
@@ -11,7 +11,6 @@ from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, status
|
||||
@@ -22,7 +21,6 @@ from app.repositories import launch_confirm_sample as repo
|
||||
from app.schemas.launch_confirm_sample import (
|
||||
LaunchConfirmSampleIn,
|
||||
LaunchConfirmSampleOut,
|
||||
LaunchConfirmSampleRow,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.internal.launch_confirm")
|
||||
@@ -63,34 +61,3 @@ def report_launch_confirm_sample(
|
||||
payload.exec_success, payload.trace_id,
|
||||
)
|
||||
return LaunchConfirmSampleOut(id=sid)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/launch-confirm-samples",
|
||||
response_model=list[LaunchConfirmSampleRow],
|
||||
summary="启动确认窗兜底样本列表(沉淀脚本 distill 读; server→server, 不走 JWT)",
|
||||
)
|
||||
def list_launch_confirm_samples(
|
||||
db: DbSession,
|
||||
x_internal_secret: Annotated[str | None, Header()] = None,
|
||||
exec_success: bool | None = None,
|
||||
host_package: str | None = None,
|
||||
since_days: int | None = None,
|
||||
limit: int = 1000,
|
||||
) -> list[LaunchConfirmSampleRow]:
|
||||
"""读样本供 pricebot 的 distill_launch_confirm.py 聚合沉淀回 PROFILES。
|
||||
|
||||
与上报端点同一把共享密钥;exec_success/host_package/since_days 均可选,limit 默认 1000。
|
||||
"""
|
||||
_check_secret(x_internal_secret)
|
||||
since = None
|
||||
if since_days and since_days > 0:
|
||||
since = datetime.now(timezone.utc) - timedelta(days=since_days)
|
||||
rows = repo.list_samples(
|
||||
db,
|
||||
exec_success=exec_success,
|
||||
host_package=host_package,
|
||||
since=since,
|
||||
limit=limit,
|
||||
)
|
||||
return [LaunchConfirmSampleRow.model_validate(r) for r in rows]
|
||||
|
||||
+5
-23
@@ -9,8 +9,8 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
@@ -18,8 +18,8 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import rewards
|
||||
from app.core.config import settings
|
||||
from app.core.ratelimit import rate_limit
|
||||
from app.integrations import pangle
|
||||
from app.core.ratelimit import rate_limit
|
||||
from app.repositories import ad_ecpm as crud_ecpm
|
||||
from app.repositories import ad_feed_reward as crud_feed
|
||||
from app.repositories import ad_reward as crud_ad
|
||||
@@ -120,21 +120,6 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
|
||||
ad_session_id = extra.get("ad_session_id")
|
||||
ecpm = params.get("ecpm")
|
||||
|
||||
# 环境隔离:激励视频 mediaExtra 里带「这次观看属于哪个后端环境」(srv_env=dev/prod,客户端按
|
||||
# BuildConfig.DEBUG 决定),穿山甲 S2S 回调原样带回。回调 URL 在穿山甲后台只配一个(指向生产),
|
||||
# 所以测试包(dev)看广告的 S2S 也会打到生产——若不拦,生产会把奖发给「本库里同 user_id 的另一个
|
||||
# 真人」(user_id 是跨库不隔离的裸数字 → 跨库串号)。这里只处理「属于本服环境」的回调:环境不符
|
||||
# 直接受理但不发币、不写任何记录,保证各环境后台广告收益页只含本环境用户。is_verify=true 让穿山甲
|
||||
# 不再重试(测试用户的币由 localhost 的 test-grant 单独发,不依赖这条 S2S)。
|
||||
# 兼容:旧客户端不带 srv_env(取不到)→ 视为本环境,照常处理,不误伤存量正式用户。
|
||||
callback_env = extra.get("srv_env")
|
||||
if callback_env and callback_env != settings.APP_ENV:
|
||||
logger.info(
|
||||
"pangle callback foreign env skip: callback_env=%s self_env=%s user_id=%d trans_id=%s",
|
||||
callback_env, settings.APP_ENV, user_id, trans_id,
|
||||
)
|
||||
return PangleCallbackOut(is_verify=True, reason=REASON_OK)
|
||||
|
||||
existing = crud_ad.find_by_trans(db, trans_id)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
@@ -285,13 +270,12 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm
|
||||
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,
|
||||
app_env=payload.app_env, our_code_id=payload.our_code_id,
|
||||
)
|
||||
logger.info(
|
||||
"ad ecpm report user_id=%d type=%s scene=%s session=%s ecpm=%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,
|
||||
"ad ecpm report user_id=%d type=%s session=%s ecpm=%s adn=%s slot=%s app=%s code=%s",
|
||||
user.id, payload.ad_type, payload.ad_session_id, payload.ecpm, payload.adn, payload.slot_id,
|
||||
payload.app_env, payload.our_code_id,
|
||||
)
|
||||
return EcpmReportOut(ok=True)
|
||||
|
||||
@@ -402,7 +386,6 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
|
||||
client_event_id=payload.client_event_id,
|
||||
ecpm=payload.ecpm,
|
||||
duration_seconds=payload.duration_seconds,
|
||||
ad_type=payload.ad_type,
|
||||
ad_session_id=payload.ad_session_id,
|
||||
adn=payload.adn,
|
||||
slot_id=payload.slot_id,
|
||||
@@ -411,7 +394,6 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
|
||||
app_env=payload.app_env,
|
||||
our_code_id=payload.our_code_id,
|
||||
aborted=payload.aborted,
|
||||
display_coin=payload.display_coin,
|
||||
)
|
||||
logger.info(
|
||||
"feed ad reward user_id=%d event=%s status=%s units=%d coin=%d",
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
"""客户端埋点上报接口。
|
||||
|
||||
POST /api/v1/analytics/events — 批量接收新手引导(及后续)埋点,append 落 analytics_event 表。
|
||||
**不强制登录**(未登录态也要采集行为):user_id 由客户端在 body 里可选带上,不靠 Bearer。
|
||||
服务端补 client_ip(X-Forwarded-For)与 created_at(接收时间 = server_at)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.repositories import analytics as analytics_repo
|
||||
from app.schemas.analytics import AnalyticsBatchIn, AnalyticsIngestOut
|
||||
|
||||
router = APIRouter(prefix="/api/v1/analytics", tags=["analytics"])
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
"""取客户端 IP:生产经 nginx 反代优先 X-Forwarded-For 第一段,否则直连 IP(同 admin get_client_ip)。"""
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
return request.client.host if request.client else ""
|
||||
|
||||
|
||||
@router.post("/events", response_model=AnalyticsIngestOut, summary="批量上报埋点事件")
|
||||
def ingest_events(
|
||||
batch: AnalyticsBatchIn, request: Request, db: DbSession
|
||||
) -> AnalyticsIngestOut:
|
||||
n = analytics_repo.record_batch(db, batch, client_ip=_client_ip(request))
|
||||
return AnalyticsIngestOut(received=n)
|
||||
+5
-37
@@ -12,11 +12,11 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import test_account
|
||||
from app.core.ratelimit import enforce_rate_limit, rate_limit
|
||||
from app.core.ratelimit import rate_limit
|
||||
from app.core.security import TokenError, decode_token, issue_token_pair
|
||||
from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone
|
||||
from app.integrations.sms import SmsError, send_code, verify_code
|
||||
@@ -38,12 +38,6 @@ logger = logging.getLogger("shagua.auth")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
# 手机号登录防刷:同一设备(device_id) + 同一 IP 每小时最多的登录尝试次数(成功/失败都计)。
|
||||
SMS_LOGIN_MAX_PER_HOUR = 5
|
||||
# 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。
|
||||
# 堵「换手机号绕开单号 60s 冷却 / 单号每日上限」的洞 —— 那两道是单号维度,一机换号能绕开。
|
||||
SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5
|
||||
|
||||
|
||||
def _login_response(
|
||||
user, *, onboarding_completed: bool, force_onboarding: bool = False
|
||||
@@ -90,27 +84,15 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser:
|
||||
"/sms/send",
|
||||
response_model=SmsSendResponse,
|
||||
summary="发送短信验证码",
|
||||
dependencies=[Depends(rate_limit(10, 60, "sms-send"))], # 同 IP 每分钟≤10 次(防一 IP 刷不同号)
|
||||
)
|
||||
def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse:
|
||||
def sms_send(req: SmsSendRequest) -> SmsSendResponse:
|
||||
# 测试账号:不真发短信(号码非真实手机号,真发会失败/浪费),直接放行让客户端进入填码界面。
|
||||
# 真正的"免验证码"在 /sms/login 跳过校验;每日上限也在 login 处算(send 不耗额度)。
|
||||
# (也不受下面设备发码限流约束:QA 联调要反复发码。)
|
||||
if test_account.is_test_account(req.phone):
|
||||
logger.info("test_account sms_send short-circuit (不真发)")
|
||||
return SmsSendResponse(sent=True, mock=True, cooldown_sec=0)
|
||||
|
||||
# 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_SEND_MAX_PER_HOUR_PER_DEVICE 次发码。
|
||||
# 补「换手机号绕开单号 60s 冷却 / 单号每日上限」的洞(那两道是单号维度,一机换号能绕);设备维度按机器封顶,
|
||||
# 挡短信轰炸/烧钱。放在真发(send_code)之前 → 超限直接拦下、不真发短信。与路由上 IP 维度(10次/分钟)互补。
|
||||
enforce_rate_limit(
|
||||
request,
|
||||
scope="sms-send-device",
|
||||
subject=req.device_id,
|
||||
limit=SMS_SEND_MAX_PER_HOUR_PER_DEVICE,
|
||||
window_sec=3600,
|
||||
detail="操作过于频繁,请稍后再试",
|
||||
)
|
||||
|
||||
try:
|
||||
cooldown = send_code(req.phone)
|
||||
except SmsError as e:
|
||||
@@ -127,10 +109,9 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse:
|
||||
summary="手机号+验证码登录",
|
||||
dependencies=[Depends(rate_limit(20, 60, "sms-login"))], # 防撞库爆破(另有单码失败次数上限)
|
||||
)
|
||||
def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWithUser:
|
||||
def sms_login(req: SmsLoginRequest, db: DbSession) -> TokenWithUser:
|
||||
# 测试账号:免验证码登录 + 每日上限 + 每次都走新手引导(详见 app/core/test_account.py)。
|
||||
# 放在最前面:命中即不校验验证码;先扣当日额度,超限直接拒,挡住有人猜到号后脚本刷。
|
||||
# 测试账号走自己的每日额度、不受下面 (设备+IP) 每小时限流约束(QA 需在一小时内反复登录联调)。
|
||||
if test_account.is_test_account(req.phone):
|
||||
if not test_account.try_consume_quota():
|
||||
raise HTTPException(status_code=429, detail="测试账号今日使用次数已达上限,请明天再试")
|
||||
@@ -142,19 +123,6 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit
|
||||
# 不读/不写 onboarding_completion 表。
|
||||
return _login_response(user, onboarding_completed=False, force_onboarding=True)
|
||||
|
||||
# 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_LOGIN_MAX_PER_HOUR 次登录尝试。放在验证码校验
|
||||
# **之前** → 输错验证码的失败尝试也计数,才挡得住撞库/爆破。与路由上 IP 维度的 sms-login 限流(同 IP)互补。
|
||||
# ⚠️ 按设备而非手机号 → 一台机器换不同手机号刷登录也受限(防一机狂登多号);device_id 空(老客户端)时
|
||||
# 退化为该 IP 下所有空设备聚一桶,仍受限。
|
||||
enforce_rate_limit(
|
||||
request,
|
||||
scope="sms-login-device",
|
||||
subject=req.device_id,
|
||||
limit=SMS_LOGIN_MAX_PER_HOUR,
|
||||
window_sec=3600,
|
||||
detail="登录尝试过于频繁,请稍后再试",
|
||||
)
|
||||
|
||||
if not verify_code(req.phone, req.code):
|
||||
raise HTTPException(status_code=400, detail="invalid sms code")
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.pricebot_client import get_pricebot_client
|
||||
from app.core.pricebot_router import pick_pricebot
|
||||
|
||||
logger = logging.getLogger("shagua.compare")
|
||||
@@ -66,10 +65,10 @@ async def _passthrough(request: Request, upstream_path: str) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
try:
|
||||
client = get_pricebot_client()
|
||||
resp = await client.post(
|
||||
url, content=raw, headers={"Content-Type": "application/json"}, timeout=timeout
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(
|
||||
url, content=raw, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.error("[pricebot] request failed: %s", e)
|
||||
raise HTTPException(
|
||||
|
||||
@@ -21,7 +21,6 @@ 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 invite as crud_invite
|
||||
from app.services.pricebot_llm_calls import fetch_llm_calls
|
||||
from app.schemas.compare_record import (
|
||||
CompareStatsOut,
|
||||
@@ -53,18 +52,6 @@ def report_record(
|
||||
# 任务做,不阻塞上报响应(顺带给 pricebot 落盘留足余量)。upsert 已 commit,后台用
|
||||
# 独立 session 按 record id 回填 llm_calls + 派生 llm_call_count/retry_count。
|
||||
background_tasks.add_task(_backfill_llm_calls, rec.id, rec.trace_id)
|
||||
# 邀请 v2 发奖:被邀请人完成一次【成功】比价 → 给邀请人发邀请奖励金(幂等,只发一次)。
|
||||
# best-effort:发奖异常不影响比价上报本身(rec 已 commit),只 log;邀请人补偿靠后续对账。
|
||||
if rec.status == "success":
|
||||
try:
|
||||
reward = crud_invite.try_reward_on_compare(db, user.id)
|
||||
if reward.status == "granted":
|
||||
logger.info(
|
||||
"invite compare reward granted inviter=%s invitee=%s cents=%s",
|
||||
reward.inviter_user_id, user.id, reward.reward_cents,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 best-effort,发奖失败不阻塞上报
|
||||
logger.warning("invite compare reward failed invitee=%s: %s", user.id, e)
|
||||
logger.info(
|
||||
"compare record user=%s trace=%s biz=%s status=%s saved=%s (llm_calls backfill queued)",
|
||||
user.id,
|
||||
|
||||
+4
-34
@@ -20,7 +20,6 @@ from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core.config import settings
|
||||
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 coupon_state as coupon_repo
|
||||
@@ -29,7 +28,6 @@ from app.schemas.coupon_state import (
|
||||
CouponPromptDismissIn,
|
||||
CouponPromptShouldShowOut,
|
||||
CouponPromptShownIn,
|
||||
CouponSessionIn,
|
||||
CouponStatsOut,
|
||||
)
|
||||
|
||||
@@ -143,10 +141,10 @@ async def coupon_step(
|
||||
)
|
||||
|
||||
try:
|
||||
client = get_pricebot_client()
|
||||
resp = await client.post(
|
||||
url, content=raw, headers={"Content-Type": "application/json"}, timeout=timeout
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(
|
||||
url, content=raw, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.error("[pricebot] request failed: %s", e)
|
||||
raise HTTPException(
|
||||
@@ -195,34 +193,6 @@ async def coupon_step(
|
||||
return resp_json
|
||||
|
||||
|
||||
@router.post("/session", summary="领券任务流水上报(admin 领券数据看板数据源)")
|
||||
def coupon_session(payload: CouponSessionIn, db: DbSession) -> dict[str, bool]:
|
||||
"""客户端两段上报一次领券流水(发起 started / 收尾 completed-failed-abandoned),按 trace_id upsert
|
||||
到 coupon_session。不鉴权(同领券循环 MVP,按 device_id/trace_id);供 admin「领券数据」看板算
|
||||
发起/完成数、耗时分位、机型维度。写库失败不应连累客户端(本就 fire-and-forget),吞掉返回 ok。"""
|
||||
try:
|
||||
coupon_repo.upsert_coupon_session(
|
||||
db,
|
||||
trace_id=payload.trace_id,
|
||||
device_id=payload.device_id,
|
||||
status=payload.status,
|
||||
started_at_ms=payload.started_at_ms,
|
||||
user_id=payload.user_id,
|
||||
platforms=payload.platforms,
|
||||
origin_package=payload.origin_package,
|
||||
device_model=payload.device_model,
|
||||
rom=payload.rom,
|
||||
app_env=payload.app_env,
|
||||
elapsed_ms=payload.elapsed_ms,
|
||||
platform_elapsed=payload.platform_elapsed,
|
||||
claimed_count=payload.claimed_count,
|
||||
trace_url=payload.trace_url,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("coupon session write failed: %s", e)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/prompt/shown", summary="领券引导窗弹出即上报(按 App 记 shown)")
|
||||
def coupon_prompt_shown(payload: CouponPromptShownIn, db: DbSession) -> dict[str, bool]:
|
||||
"""客户端弹出引导窗那刻调 → 记一条今日 engagement(shown),今天**这个 App** 不再自动弹。
|
||||
|
||||
@@ -94,35 +94,16 @@ def cps_landing(code: str, request: Request, db: Session = Depends(get_db)):
|
||||
return HTMLResponse(
|
||||
_taobao_landing_html(link.target_url, image_url, code, openid, has_uinfo)
|
||||
)
|
||||
# 美团短链 / 京东链接:微信内 + 已拿 openid + 还没授权过头像 → 先出「领券前授权」插页,
|
||||
# 点按钮(交互手势,避免微信快照页)走 snsapi_userinfo 拿昵称头像,授权回来经
|
||||
# /c/{code}?authed=1 自动 302 跳券。其余(非微信 / 未配授权 / 无 openid / 已授权)直接 302。
|
||||
# 产品决定:每次未授权都提示(不做冷却、不种"已提示"cookie)。取消授权由插页前端自动
|
||||
# 转跳目标券页(无单独"暂不授权"出口),拒绝也能拿到券(对齐「绝不阻断领券」)。
|
||||
if (
|
||||
settings.wx_oauth_active
|
||||
and _is_wechat(request)
|
||||
and openid is not None
|
||||
and request.cookies.get(_WX_UINFO_COOKIE) != "1"
|
||||
):
|
||||
return HTMLResponse(_coupon_auth_landing_html(link.target_url, code))
|
||||
# 美团短链 / 京东链接:直接 302 跳
|
||||
return RedirectResponse(link.target_url, status_code=302)
|
||||
|
||||
|
||||
@router.get("/wx/oauth/cb", include_in_schema=False)
|
||||
def wx_oauth_cb(
|
||||
code: str | None = None, state: str | None = None, db: Session = Depends(get_db)
|
||||
):
|
||||
def wx_oauth_cb(code: str, state: str, db: Session = Depends(get_db)):
|
||||
"""微信网页授权回调。state='base:{原code}'(静默拿 openid) 或 'uinfo:{原code}'(补昵称头像)。
|
||||
换 openid(+userinfo)→ upsert 用户 → 种 cookie → 302 回落地页。任何失败兜底回落地页,
|
||||
绝不阻断用户领券。"""
|
||||
kind, _, orig_code = (state or "").partition(":")
|
||||
# 授权弹窗点「取消」时,部分微信版本会带空 code 回调本端点(而非退回落地页)。无 code 换
|
||||
# 不到 openid,直接兜底跳回落地页(美团/京东落地页前端会再转跳目标券页),不报 422。
|
||||
if not code:
|
||||
return RedirectResponse(
|
||||
f"/c/{orig_code}" if orig_code else _FALLBACK_URL, status_code=302
|
||||
)
|
||||
kind, _, orig_code = state.partition(":")
|
||||
try:
|
||||
token = wx_oauth.exchange_code(code) # {openid, access_token, scope, ...}
|
||||
openid = token["openid"]
|
||||
@@ -158,12 +139,6 @@ def cps_copy(code: str, request: Request, db: Session = Depends(get_db)) -> dict
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _js_str(s: str) -> str:
|
||||
"""把字符串安全嵌进 <script> 的 JS 字面量:json.dumps 不转义 '<',值里含 '</script>'
|
||||
会逃逸标签 → 把 '<' 转成 \\u003c(JS 仍解析为 '<')。auth/target/淘口令等都走它。"""
|
||||
return json.dumps(s).replace("<", "\\u003c")
|
||||
|
||||
|
||||
def _taobao_landing_html(
|
||||
token: str, image_url: str, code: str, openid: str | None, has_uinfo: bool
|
||||
) -> str:
|
||||
@@ -181,8 +156,8 @@ def _taobao_landing_html(
|
||||
return (
|
||||
_TAOBAO_HTML
|
||||
.replace("__IMAGE_URL__", safe_img)
|
||||
.replace("__UINFO_URL__", _js_str(uinfo_url))
|
||||
.replace("__TOKEN_JS__", _js_str(token))
|
||||
.replace("__UINFO_URL__", json.dumps(uinfo_url))
|
||||
.replace("__TOKEN_JS__", json.dumps(token))
|
||||
)
|
||||
|
||||
|
||||
@@ -235,66 +210,3 @@ if(location.search.indexOf('authed=1')>=0){doCopy();history.replaceState(null,''
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def _coupon_auth_landing_html(target_url: str, code: str) -> str:
|
||||
"""美团/京东「领券前先授权头像」插页(单按钮)。
|
||||
|
||||
唯一按钮「立即领取优惠券」→ snsapi_userinfo 授权(用户点击=交互手势,避免微信快照页)。
|
||||
- 点「允许」→ 回调拿昵称头像 + 种 wx_uinfo cookie → 经 /c/{code}?authed=1 自动 302 跳券。
|
||||
- 点「取消」→ 微信退回本插页,前端 pageshow/visibilitychange 检测到"刚点过领取"即直接
|
||||
转跳 target(不再设单独的"暂不授权"出口),保证拒绝也能拿到券(对齐「绝不阻断领券」)。
|
||||
AUTH/TARGET 经 json.dumps 安全嵌入 JS 字符串(URL 里的 & 不会被 HTML 转义,直跳更稳)。
|
||||
"""
|
||||
redirect_uri = f"{settings.CPS_REDIRECT_BASE.rstrip('/')}/wx/oauth/cb"
|
||||
auth_url = wx_oauth.build_authorize_url(redirect_uri, "snsapi_userinfo", f"uinfo:{code}")
|
||||
return (
|
||||
_COUPON_AUTH_HTML
|
||||
.replace("__AUTH_URL__", _js_str(auth_url))
|
||||
.replace("__TARGET_URL__", _js_str(target_url))
|
||||
)
|
||||
|
||||
|
||||
_COUPON_AUTH_HTML = """<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
|
||||
<title>领取优惠券</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
|
||||
body{font-family:-apple-system,"PingFang SC",sans-serif;background:#fff0ef;color:#333;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
|
||||
.card{width:100%;max-width:340px;background:#fff;border-radius:18px;padding:34px 24px;text-align:center;box-shadow:0 8px 24px rgba(255,59,59,.12)}
|
||||
.title{font-size:22px;font-weight:800;color:#ff3b3b;margin-bottom:10px}
|
||||
.sub{font-size:14px;color:#999;line-height:1.6;margin-bottom:28px}
|
||||
.btn{display:block;width:100%;background:linear-gradient(90deg,#ff5b5b,#ff3b3b);color:#fff;font-size:18px;font-weight:800;font-family:inherit;text-align:center;padding:15px;border:none;border-radius:28px;cursor:pointer;box-shadow:0 6px 16px rgba(255,59,59,.4)}
|
||||
.btn:active{transform:scale(.98)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="title">🎁 优惠券待领取</div>
|
||||
<div class="sub">点击下方按钮,领取你的专属优惠券</div>
|
||||
<button class="btn" onclick="claim()">立即领取优惠券</button>
|
||||
</div>
|
||||
<script>
|
||||
var AUTH=__AUTH_URL__,TARGET=__TARGET_URL__;
|
||||
function claim(){
|
||||
try{sessionStorage.setItem('mt_auth_ts',String(Date.now()))}catch(e){}
|
||||
location.href=AUTH;
|
||||
}
|
||||
// 从授权页返回(点了「取消」/未完成授权):若刚点过领取(3 分钟内)→ 直接进目标券页,
|
||||
// 保证拒绝也能拿到券(绝不阻断领券)。pageshow 覆盖 BFCache 与整页重载,visibilitychange
|
||||
// 兜底"页面保活只切可见性"的情况;靠 sessionStorage 时间戳防陈旧/误触发。
|
||||
function maybeForward(){
|
||||
var ts=0;try{ts=parseInt(sessionStorage.getItem('mt_auth_ts')||'0',10)}catch(e){}
|
||||
if(ts&&Date.now()-ts<180000){
|
||||
try{sessionStorage.removeItem('mt_auth_ts')}catch(e){}
|
||||
location.href=TARGET;
|
||||
}
|
||||
}
|
||||
window.addEventListener('pageshow',maybeForward);
|
||||
document.addEventListener('visibilitychange',function(){if(document.visibilityState==='visible')maybeForward()});
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
@@ -61,11 +61,6 @@ async def submit_feedback(
|
||||
content: str = Form(...),
|
||||
# 原型改版后客户端不再采集联系方式;保留字段以兼容旧端 + 后续可能复用,默认空串。
|
||||
contact: str = Form(default=""),
|
||||
# 提交端环境快照(admin 反馈页展示「提交版本号」「机型OS版本」);旧端不带 → 空串 → 存 NULL。
|
||||
app_version: str = Form(default=""),
|
||||
device_model: str = Form(default=""),
|
||||
rom_name: str = Form(default=""),
|
||||
android_version: str = Form(default=""),
|
||||
images: list[UploadFile] = File(default=[]),
|
||||
) -> FeedbackOut:
|
||||
content = content.strip()
|
||||
@@ -91,8 +86,6 @@ async def submit_feedback(
|
||||
|
||||
fb = feedback_repo.create_feedback(
|
||||
db, user_id=user.id, content=content, contact=contact, images=urls,
|
||||
app_version=app_version.strip(), device_model=device_model.strip(),
|
||||
rom_name=rom_name.strip(), android_version=android_version.strip(),
|
||||
)
|
||||
logger.info("feedback id=%d user_id=%d images=%d", fb.id, user.id, len(urls))
|
||||
return FeedbackOut.model_validate(fb)
|
||||
|
||||
@@ -38,7 +38,7 @@ logger = logging.getLogger("shagua.invite")
|
||||
router = APIRouter(prefix="/api/v1/invite", tags=["invite"])
|
||||
|
||||
_BIND_MESSAGES = {
|
||||
"success": "邀请绑定成功",
|
||||
"success": "邀请绑定成功,金币已到账",
|
||||
"already_bound": "你已绑定过邀请人",
|
||||
"invalid_code": "邀请码无效",
|
||||
"self_invite": "不能填写自己的邀请码",
|
||||
@@ -84,8 +84,6 @@ def _parse_device_model(ua: str) -> str:
|
||||
def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut:
|
||||
code = invite_repo.ensure_code(db, user)
|
||||
invited, coins = invite_repo.get_stats(db, user.id)
|
||||
reward_balance, reward_withdrawn = invite_repo.get_reward_stats(db, user.id)
|
||||
days_left, is_fresh_round, countdown_text = invite_repo.compute_invite_countdown(user.created_at)
|
||||
sep = "&" if "?" in settings.INVITE_LANDING_URL else "?"
|
||||
share_url = f"{settings.INVITE_LANDING_URL}{sep}ref={code}"
|
||||
return InviteInfoOut(
|
||||
@@ -93,11 +91,6 @@ def my_invite(user: CurrentUser, db: DbSession) -> InviteInfoOut:
|
||||
share_url=share_url,
|
||||
invited_count=invited,
|
||||
coins_earned=coins,
|
||||
reward_balance_cents=reward_balance,
|
||||
reward_withdrawn_cents=reward_withdrawn,
|
||||
countdown_days_left=days_left,
|
||||
countdown_is_fresh_round=is_fresh_round,
|
||||
countdown_text=countdown_text,
|
||||
)
|
||||
|
||||
|
||||
@@ -161,7 +154,7 @@ def landing_track(
|
||||
return LandingTrackOut(status="ok")
|
||||
|
||||
|
||||
@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,绑定不发奖)")
|
||||
@router.post("/bind", response_model=BindInviteOut, summary="绑定邀请人(注册即生效,双方发金币)")
|
||||
def bind_invite(
|
||||
req: BindInviteIn, user: CurrentUser, db: DbSession, request: Request
|
||||
) -> BindInviteOut:
|
||||
|
||||
@@ -63,8 +63,8 @@ def ad_config(db: DbSession) -> AdConfigPublicOut:
|
||||
return AdConfigPublicOut(
|
||||
app_id=c["app_id"],
|
||||
reward_code_id=c["reward_code_id"],
|
||||
compare_draw_code_id=c["compare_draw_code_id"],
|
||||
coupon_draw_code_id=c["coupon_draw_code_id"],
|
||||
compare_feed_code_id=c["compare_feed_code_id"],
|
||||
coupon_feed_code_id=c["coupon_feed_code_id"],
|
||||
reward_enabled=c["reward_enabled"],
|
||||
compare_ad_enabled=c["compare_ad_enabled"],
|
||||
coupon_ad_enabled=c["coupon_ad_enabled"],
|
||||
|
||||
@@ -200,11 +200,7 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="wechat pay not configured")
|
||||
try:
|
||||
order = crud_wallet.create_withdraw(
|
||||
db, user.id, req.amount_cents, source=req.source,
|
||||
user_name=req.user_name, out_bill_no=req.out_bill_no,
|
||||
# 0.01 元调试提现:放行低于最低额的小额。双闸——客户端仅 debug 包在「0.01 元提现」开关开时
|
||||
# 连同 skip_review 一起下发;服务端仅非 prod 才认。生产恒 False,最低额校验照常。
|
||||
allow_sub_min=(req.skip_review and not settings.is_prod),
|
||||
db, user.id, req.amount_cents, user_name=req.user_name, out_bill_no=req.out_bill_no
|
||||
)
|
||||
except crud_wallet.InvalidWithdrawAmountError as e:
|
||||
raise HTTPException(
|
||||
@@ -278,11 +274,10 @@ def withdraw_status(
|
||||
def withdraw_orders(
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
source: str | None = Query(None, description="按账户来源过滤:coin_cash / invite_cash;不传=全部"),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
cursor: int | None = Query(None, description="上一页末条 id"),
|
||||
) -> WithdrawOrderPage:
|
||||
items, next_cursor = crud_wallet.list_withdraw_orders(db, user.id, source=source, limit=limit, cursor=cursor)
|
||||
items, next_cursor = crud_wallet.list_withdraw_orders(db, user.id, limit=limit, cursor=cursor)
|
||||
return WithdrawOrderPage(
|
||||
items=[WithdrawOrderOut.model_validate(it) for it in items],
|
||||
next_cursor=next_cursor,
|
||||
|
||||
@@ -113,21 +113,6 @@ class Settings(BaseSettings):
|
||||
"""美团 CPS 凭证齐全(缺则接口返空,而非 502)。"""
|
||||
return bool(self.MT_CPS_APP_KEY and self.MT_CPS_APP_SECRET)
|
||||
|
||||
# ===== 京东联盟 CPS =====
|
||||
# app_key/app_secret 来自京东联盟应用;site_id 是推广管理里的 APP/网站 ID;
|
||||
# auth_key 是工具商授权 key,自有应用查询可留空。
|
||||
JD_UNION_APP_KEY: str = ""
|
||||
JD_UNION_APP_SECRET: str = ""
|
||||
JD_UNION_SITE_ID: str = ""
|
||||
JD_UNION_AUTH_KEY: str = ""
|
||||
JD_UNION_GATEWAY: str = "https://api.jd.com/routerjson"
|
||||
JD_UNION_TIMEOUT_SEC: int = 15
|
||||
|
||||
@property
|
||||
def jd_union_configured(self) -> bool:
|
||||
"""京东联盟订单查询凭证齐全。"""
|
||||
return bool(self.JD_UNION_APP_KEY and self.JD_UNION_APP_SECRET)
|
||||
|
||||
# ===== 微信服务号(网页授权) =====
|
||||
# CPS 落地页在微信内拿用户 openid(base 静默)/昵称头像(userinfo),做用户级群统计。
|
||||
# ⚠️ 区别于 WECHAT_APP_ID(那是 App 移动应用,用于微信支付);这是【已认证服务号】。
|
||||
@@ -226,41 +211,6 @@ class Settings(BaseSettings):
|
||||
"""回调开关打开且至少配了一个验签密钥,才接受发奖回调。"""
|
||||
return bool(self.PANGLE_CALLBACK_ENABLED and self.pangle_reward_secrets)
|
||||
|
||||
# ===== 穿山甲 GroMore 数据 API(报表收益拉取,T+1)=====
|
||||
# ⚠️ 与上面发奖回调的 m-key 是【两套完全不同的凭证】:这三样在穿山甲后台
|
||||
# 「接入中心 → GroMore-API → 聚合数据报告 API」文档页领取(user_id / role_id / Security Key),
|
||||
# 仅用于按天拉 GroMore 收益报表(revenue 预估收益 + api_revenue 收益Api),不参与发奖。
|
||||
# 该 API 只能查【GroMore 聚合代码位】的数据(=我们 useMediation 的口径),非穿山甲 SDK 数据;
|
||||
# 且不提供用户/设备维度(官方明确),故收益只能落到 日期×代码位 汇总,不能挂到逐条事件。
|
||||
# 子账号(role_id≠user_id)需主账号在「角色管理」授予「查看全部数据」权限,否则查不到
|
||||
# ecpm/revenue(接口返回 118);role_id 填成与 user_id 一致 = 查主账号数据。
|
||||
PANGLE_REPORT_USER_ID: int = 0 # 媒体账号 user_id
|
||||
PANGLE_REPORT_ROLE_ID: int = 0 # 子账号 role_id(=user_id 时查主账号)
|
||||
PANGLE_REPORT_SECURITY_KEY: str = "" # 该账号的 Security Key(secure_key,≠ 发奖 m-key)
|
||||
# GroMore 聚合 AppId(报表 site_id 维度)→ 我们的应用环境。报表按 site_id 区分两个穿山甲应用,
|
||||
# 用它把每行归到 prod(傻瓜比价正式)/ test(测试应用)。默认值取自现网两个应用,部署时按需覆盖。
|
||||
PANGLE_REPORT_SITE_ID_PROD: str = "5830519" # 傻瓜比价正式应用 AppId
|
||||
PANGLE_REPORT_SITE_ID_TEST: str = "5832303" # 测试应用 AppId
|
||||
|
||||
@property
|
||||
def pangle_report_configured(self) -> bool:
|
||||
"""三样齐全(user_id / role_id / security_key)才能拉 GroMore 报表;缺任一 → 同步脚本 no-op。"""
|
||||
return bool(
|
||||
self.PANGLE_REPORT_USER_ID
|
||||
and self.PANGLE_REPORT_ROLE_ID
|
||||
and self.PANGLE_REPORT_SECURITY_KEY
|
||||
)
|
||||
|
||||
@property
|
||||
def pangle_report_site_id_to_env(self) -> dict[str, str]:
|
||||
"""site_id(穿山甲 AppId)→ app_env(prod/test);供同步脚本把报表行归到我们的应用。留空项忽略。"""
|
||||
out: dict[str, str] = {}
|
||||
if self.PANGLE_REPORT_SITE_ID_PROD.strip():
|
||||
out[self.PANGLE_REPORT_SITE_ID_PROD.strip()] = "prod"
|
||||
if self.PANGLE_REPORT_SITE_ID_TEST.strip():
|
||||
out[self.PANGLE_REPORT_SITE_ID_TEST.strip()] = "test"
|
||||
return out
|
||||
|
||||
# ===== Pricebot 上游 (领券/比价业务透传目标) =====
|
||||
# pricebot-backend 默认跑在 8000。/api/v1/coupon/step 会透传到这里的 /api/coupon/step
|
||||
PRICEBOT_BASE_URL: str = "http://localhost:8000"
|
||||
|
||||
@@ -87,9 +87,5 @@ def setup_logging(debug: bool = False) -> None:
|
||||
# 第三方库降噪
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
# uvicorn --reload 的文件监视器: DEBUG 下会把每次"检测到变更"打出来。我们的 root 文件
|
||||
# handler 又把这行写回 logs/app-server.log → watchfiles 再次检测 → 自我喂食死循环。
|
||||
# 降到 WARNING 同时消除噪音和这个回环(真正的 .py 热重载不受影响)。
|
||||
logging.getLogger("watchfiles").setLevel(logging.WARNING)
|
||||
|
||||
_CONFIGURED = True
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
"""透传到 pricebot 的共享 httpx.AsyncClient 单例。
|
||||
|
||||
为什么不能每请求新建(coupon.py / compare.py 老写法 async with httpx.AsyncClient(...)):
|
||||
① 每次构造都重建一套 SSL 上下文(httpx.create_ssl_context 加载 certifi CA),实测
|
||||
~1s+/次;而 pricebot 是纯 http 透传,根本用不到 TLS → 纯浪费,且每帧重交一次。
|
||||
② trust_env 默认 True 会读进程 HTTP_PROXY,把 http://localhost:8000 这条本地透传整个
|
||||
塞进本机代理(如 Clash 7897),恒定再多几秒。
|
||||
单例:启动只建一次(SSL/连接池一次性),keep-alive 复用 TCP,每帧降到个位数 ms。
|
||||
trust_env=False:对齐 integrations/meituan.py 的既有约定,不被进程代理误导,直连 pricebot。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
_client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
def get_pricebot_client() -> httpx.AsyncClient:
|
||||
"""取透传单例。lifespan 启动会预热;未预热(如测试态)懒建兜底。
|
||||
|
||||
超时不在此固化(coupon 30s / compare 60s 不同),由调用点 client.post(timeout=...) 传。
|
||||
懒建无 await,asyncio 单线程下不会有并发竞态。
|
||||
"""
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = httpx.AsyncClient(trust_env=False)
|
||||
return _client
|
||||
|
||||
|
||||
async def aclose_pricebot_client() -> None:
|
||||
"""lifespan 关停时调,优雅关连接池。"""
|
||||
global _client
|
||||
if _client is not None:
|
||||
await _client.aclose()
|
||||
_client = None
|
||||
@@ -57,29 +57,3 @@ def rate_limit(limit: int, window_sec: float, scope: str):
|
||||
)
|
||||
|
||||
return _dep
|
||||
|
||||
|
||||
def enforce_rate_limit(
|
||||
request: Request,
|
||||
scope: str,
|
||||
subject: str,
|
||||
limit: int,
|
||||
window_sec: float,
|
||||
*,
|
||||
detail: str = "操作过于频繁,请稍后再试",
|
||||
) -> None:
|
||||
"""在路由内部手动限流,按 (subject, 客户端 IP) 计数。
|
||||
|
||||
用于限流 key 需要请求体字段(如手机号)、Depends 阶段还拿不到 body 时
|
||||
—— 此时无法用 [rate_limit] 依赖,改在 handler 解析完 body 后调用本函数。
|
||||
key = `scope:subject:client_ip`;同一 (subject, IP) 在 window_sec 内超过 limit 次 → 抛 429。
|
||||
受 [settings.RATE_LIMIT_ENABLED] 总开关控制(与 [rate_limit] 一致)。
|
||||
"""
|
||||
if not settings.RATE_LIMIT_ENABLED:
|
||||
return
|
||||
key = f"{scope}:{subject}:{_client_ip(request)}"
|
||||
if not _hit(key, limit, window_sec):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
+4
-10
@@ -113,9 +113,10 @@ PRICE_REPORT_REWARD_COINS: int = 1000
|
||||
FEEDBACK_REWARD_MAX_COINS: int = 10000
|
||||
|
||||
|
||||
# ===== 邀请好友(绑定即生效,绑定只建归因关系、双方都不发钱)=====
|
||||
# v3(冰 2026-06-26):废除 v1 的"绑定双方各发金币"——被邀请人无奖励、邀请人改比价后发现金
|
||||
# (见下方 INVITE_COMPARE_REWARD_CENTS)。原 INVITE_INVITER_COINS / INVITE_INVITEE_COINS 已删。
|
||||
# ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)=====
|
||||
# 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。
|
||||
INVITE_INVITER_COINS: int = 10000
|
||||
INVITE_INVITEE_COINS: int = 10000
|
||||
# "新用户闸":被邀请人必须在注册后此窗口内绑定才发奖(挡存量老用户互相填码薅羊毛)。
|
||||
# 自动绑(剪贴板)在首次注册登录后几秒内发生;留 72h 给手动填码兜底。
|
||||
INVITE_NEW_USER_WINDOW_HOURS: int = 72
|
||||
@@ -126,13 +127,6 @@ INVITE_NEW_USER_WINDOW_HOURS: int = 72
|
||||
INVITE_FP_WINDOW_DAYS: int = 7
|
||||
|
||||
|
||||
# ===== 邀请好友 v2(好友"下载+登录+比价一次"→ 给邀请人发邀请奖励金·现金)=====
|
||||
# v2 新规则:不再注册即发金币,改"好友完成首次成功比价"才给【邀请人】发奖,发的是【现金·分】
|
||||
# 进独立的邀请奖励金账户(coin_account.invite_cash_balance_cents),与金币体系物理隔离。
|
||||
# 200 分 = 2 元。⚠️ 金额待产品定准:邀请主页=2元 / 福利入口=3.5元 不一致,定后改此处。
|
||||
INVITE_COMPARE_REWARD_CENTS: int = 200
|
||||
|
||||
|
||||
# ===== 看激励视频 / 信息流广告发金币 =====
|
||||
# eCPM 取自穿山甲 SDK getShowEcpm().getEcpm(),官方口径单位是【分/千次展示】(不是元!
|
||||
# csjplatform 文档原文"通过 getEcpm 获取的单位是分")。计算时先 ÷100 转成元;
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
"""京东联盟 OpenAPI 客户端。
|
||||
|
||||
当前只接数据大盘需要的订单明细接口:
|
||||
`jd.union.open.order.row.query`。京东要求订单查询时间窗最长 1 小时,
|
||||
调用方负责切窗分页。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BEIJING = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
class JdUnionError(RuntimeError):
|
||||
"""京东联盟 API 调用失败。"""
|
||||
|
||||
|
||||
def _parse_json_maybe(value: Any) -> Any:
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return value
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
|
||||
|
||||
def _sign(params: dict[str, Any], secret: str) -> str:
|
||||
pieces = [secret]
|
||||
for key in sorted(k for k in params if k != "sign"):
|
||||
value = params[key]
|
||||
if value is None:
|
||||
continue
|
||||
pieces.append(f"{key}{value}")
|
||||
pieces.append(secret)
|
||||
raw = "".join(pieces)
|
||||
return hashlib.md5(raw.encode("utf-8")).hexdigest().upper()
|
||||
|
||||
|
||||
def _unwrap_response(data: dict[str, Any]) -> dict[str, Any]:
|
||||
if "error_response" in data:
|
||||
err = data["error_response"] or {}
|
||||
msg = err.get("zh_desc") or err.get("en_desc") or err.get("msg") or err
|
||||
raise JdUnionError(f"京东 API 错误: {msg}")
|
||||
|
||||
body: Any = data
|
||||
for key, value in data.items():
|
||||
if key.endswith("_responce") or key.endswith("_response"):
|
||||
body = value
|
||||
break
|
||||
|
||||
body = _parse_json_maybe(body)
|
||||
if not isinstance(body, dict):
|
||||
raise JdUnionError("京东 API 返回格式异常")
|
||||
|
||||
result = body.get("queryResult", body.get("result", body))
|
||||
result = _parse_json_maybe(result)
|
||||
if not isinstance(result, dict):
|
||||
raise JdUnionError("京东 API 业务结果格式异常")
|
||||
|
||||
code = str(result.get("code", result.get("resultCode", "200")))
|
||||
if code not in {"0", "200"}:
|
||||
msg = result.get("message") or result.get("msg") or result.get("resultMsg") or result
|
||||
raise JdUnionError(f"京东 API 业务错误: {msg}")
|
||||
return result
|
||||
|
||||
|
||||
def call(method: str, payload: dict[str, Any], *, version: str = "1.0") -> dict[str, Any]:
|
||||
if not settings.jd_union_configured:
|
||||
raise JdUnionError("京东联盟凭证未配置")
|
||||
|
||||
biz_json = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
params: dict[str, Any] = {
|
||||
"method": method,
|
||||
"app_key": settings.JD_UNION_APP_KEY,
|
||||
"timestamp": datetime.now(_BEIJING).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"format": "json",
|
||||
"v": version,
|
||||
"sign_method": "md5",
|
||||
"360buy_param_json": biz_json,
|
||||
}
|
||||
params["sign"] = _sign(params, settings.JD_UNION_APP_SECRET)
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=settings.JD_UNION_TIMEOUT_SEC, trust_env=False) as client:
|
||||
resp = client.post(settings.JD_UNION_GATEWAY, data=params)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.HTTPError as e:
|
||||
raise JdUnionError(f"京东 API 网络错误: {e}") from e
|
||||
except json.JSONDecodeError as e:
|
||||
raise JdUnionError("京东 API 返回非 JSON") from e
|
||||
|
||||
return _unwrap_response(data)
|
||||
|
||||
|
||||
def _extract_rows(result: dict[str, Any]) -> tuple[list[dict[str, Any]], bool]:
|
||||
payload = _parse_json_maybe(result.get("data", result.get("result", result)))
|
||||
has_more = bool(result.get("hasMore") or result.get("has_more"))
|
||||
|
||||
if isinstance(payload, dict):
|
||||
for key in ("orderRowResp", "orderRows", "orderList", "orders", "list", "rows"):
|
||||
rows = _parse_json_maybe(payload.get(key))
|
||||
if isinstance(rows, list):
|
||||
return [r for r in rows if isinstance(r, dict)], bool(
|
||||
payload.get("hasMore") or payload.get("has_more") or has_more
|
||||
)
|
||||
return [], bool(payload.get("hasMore") or payload.get("has_more") or has_more)
|
||||
if isinstance(payload, list):
|
||||
return [r for r in payload if isinstance(r, dict)], has_more
|
||||
return [], has_more
|
||||
|
||||
|
||||
def query_order_rows(
|
||||
*,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
query_time_type: int = 3,
|
||||
page_index: int = 1,
|
||||
page_size: int = 200,
|
||||
) -> dict[str, Any]:
|
||||
"""查询京东 CPS 订单行。
|
||||
|
||||
query_time_type: 1 下单时间, 2 完成时间, 3 更新时间。
|
||||
start_time/end_time 用北京时间展示给京东;调用方需保证窗口不超过 1 小时。
|
||||
"""
|
||||
start_bj = start_time.astimezone(_BEIJING)
|
||||
end_bj = end_time.astimezone(_BEIJING)
|
||||
if end_bj <= start_bj:
|
||||
return {"rows": [], "has_more": False}
|
||||
if end_bj - start_bj > timedelta(hours=1):
|
||||
raise JdUnionError("京东订单查询单次时间窗不能超过 1 小时")
|
||||
|
||||
order_req: dict[str, Any] = {
|
||||
"pageIndex": page_index,
|
||||
"pageSize": min(max(page_size, 1), 200),
|
||||
"type": query_time_type,
|
||||
"startTime": start_bj.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"endTime": end_bj.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
if settings.JD_UNION_AUTH_KEY:
|
||||
order_req["key"] = settings.JD_UNION_AUTH_KEY
|
||||
result = call("jd.union.open.order.row.query", {"orderReq": order_req})
|
||||
rows, has_more = _extract_rows(result)
|
||||
logger.info(
|
||||
"jd.union.open.order.row.query fetched rows=%s page=%s has_more=%s",
|
||||
len(rows),
|
||||
page_index,
|
||||
has_more,
|
||||
)
|
||||
return {"rows": rows, "has_more": has_more, "raw": result}
|
||||
@@ -1,148 +0,0 @@
|
||||
"""穿山甲 GroMore「聚合数据报告 API」客户端 —— 按天拉取收益报表(只读、T+1)。
|
||||
|
||||
⚠️ 这是 **GroMore 数据拉取 API**,与发奖回调验签(integrations/pangle.py 的 m-key)是
|
||||
两套完全不同的凭证与用途。凭证在后台「接入中心 → GroMore-API → 聚合数据报告 API」领取:
|
||||
媒体账号 user_id、子账号 role_id、Security Key(secure_key)。
|
||||
|
||||
鉴权(文档 v2.x「方法一」):
|
||||
1. 去掉请求参数里的 `sign` 字段与值为空的字段;
|
||||
2. 其余参数按 key 字典序升序,拼成 `k1=v1&k2=v2&...&kn=vn`;
|
||||
3. 末尾直接拼接 security_key(无分隔符),对整串做 MD5,取 32 位小写十六进制 = sign。
|
||||
签名有 3 分钟过期(天级用 `timestamp` 秒级时间戳;小时级才用 `current_time` 字符串)。
|
||||
|
||||
数据口径要点(来自官方文档):
|
||||
- 只返回【GroMore 聚合代码位】在 GroMore 内的数据(=我们 useMediation 的口径),
|
||||
查不到穿山甲 SDK 自身的数据;
|
||||
- **不提供分用户/设备维度**(官方 FAQ 明确拒绝),最细到 日期×应用×代码位×广告源;
|
||||
- `revenue` = 预估收益(元,所有 ADN 都有);`api_revenue` = 收益Api(各 ADN 经 Reporting
|
||||
回传、按实时汇率折算账号币种,更接近结算),需后台为该 ADN 配置 Reporting 才有、且不支持当天;
|
||||
- 「今天」与「今天以前」必须分开查;天级跨度 ≤ 1 个月、不早于 12 个月。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger("shagua.pangle_report")
|
||||
|
||||
HOST = "https://www.csjplatform.com"
|
||||
# 天级收益报表(路径与文档代码示例一致;另有小时级 get_hour_report_data,本服务只用天级)。
|
||||
DAILY_PATH = "/union_media/open/api/mediation/get_daily_income_report_data"
|
||||
VERSION = "2.0"
|
||||
SIGN_TYPE = "MD5"
|
||||
PAGE_LIMIT = 5000 # 文档上限;一次尽量多取,减少翻页
|
||||
DEFAULT_TIMEOUT = 30.0
|
||||
_MAX_PAGES = 200 # 翻页安全阀(5000×200=100w 行,远超我们规模),防异常 has_next 死循环
|
||||
|
||||
|
||||
class PangleReportError(Exception):
|
||||
"""GroMore 报表接口调用失败(未配置 / 网络 / 业务码非 100)。"""
|
||||
|
||||
|
||||
def build_sign(params: Mapping[str, Any], security_key: str) -> str:
|
||||
"""按文档「方法一」生成 sign:去 sign/空值 → key 升序 → k=v& 拼接 → 末尾接 secure_key → MD5(32 位小写)。
|
||||
|
||||
与官方 Python/Java 示例逐字节一致(见 tests/test_pangle_report.py 的两个测试向量)。
|
||||
"""
|
||||
items = [
|
||||
(str(k), str(v))
|
||||
for k, v in params.items()
|
||||
if k != "sign" and v is not None and str(v) != ""
|
||||
]
|
||||
items.sort(key=lambda kv: kv[0])
|
||||
raw = "&".join(f"{k}={v}" for k, v in items)
|
||||
return hashlib.md5((raw + security_key).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def fetch_daily_report(
|
||||
*,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
# ⚠️ 用 ad_unit_id(GroMore 广告位ID = 我们的 104xxx),不要用 code_id(底层各 ADN 代码位,对不上)
|
||||
dimensions: str = "date,site_id,ad_unit_id",
|
||||
metrics: str = "revenue,api_revenue,ecpm,imp_cnt",
|
||||
site_ids: str | None = None,
|
||||
code_ids: str | None = None,
|
||||
os: str | None = None,
|
||||
network: str | None = None,
|
||||
time_zone: int = 8,
|
||||
limit: int = PAGE_LIMIT,
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""拉一个日期区间(北京时间,time_zone=8)的天级收益报表,自动翻页,返回 report_list 全量行。
|
||||
|
||||
每行是字符串字典(接口原值),形如
|
||||
{"start_date": "2026-06-27", "site_id": "5830519", "ad_unit_id": "104142227",
|
||||
"revenue": "1.23", "api_revenue": "1.05", "ecpm": "0.80", "imp_cnt": "1537", ...}。
|
||||
业务码非 100 直接抛 PangleReportError(由调用方/脚本兜底,不静默吞)。
|
||||
"""
|
||||
if not settings.pangle_report_configured:
|
||||
raise PangleReportError(
|
||||
"PANGLE_REPORT_USER_ID / ROLE_ID / SECURITY_KEY 未配置,无法拉取 GroMore 报表"
|
||||
)
|
||||
|
||||
base_params: dict[str, Any] = {
|
||||
"user_id": settings.PANGLE_REPORT_USER_ID,
|
||||
"role_id": settings.PANGLE_REPORT_ROLE_ID,
|
||||
"version": VERSION,
|
||||
"sign_type": SIGN_TYPE,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"dimensions": dimensions,
|
||||
"metrics": metrics,
|
||||
"time_zone": time_zone,
|
||||
"limit": limit,
|
||||
}
|
||||
# 可选过滤(空则不传,避免进签名串)
|
||||
for key, val in (
|
||||
("site_ids", site_ids), ("code_ids", code_ids),
|
||||
("os", os), ("network", network),
|
||||
):
|
||||
if val:
|
||||
base_params[key] = val
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
offset = 0
|
||||
try:
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
for _ in range(_MAX_PAGES):
|
||||
params = dict(base_params)
|
||||
params["offset"] = offset
|
||||
# timestamp 每页临请求时取最新(3 分钟过期),并参与签名
|
||||
params["timestamp"] = int(time.time())
|
||||
params["sign"] = build_sign(params, settings.PANGLE_REPORT_SECURITY_KEY)
|
||||
|
||||
resp = client.get(HOST + DAILY_PATH, params=params)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
|
||||
code = str(body.get("code"))
|
||||
if code != "100":
|
||||
raise PangleReportError(
|
||||
f"GroMore 报表业务失败 code={code} message={body.get('message')!r} "
|
||||
f"(101=验签失败/102=userid无效/107=无权限/118=无收益查看权限,详见文档状态码)"
|
||||
)
|
||||
|
||||
data = body.get("data") or {}
|
||||
page = data.get("report_list") or []
|
||||
rows.extend(page)
|
||||
|
||||
# has_next=1 还有下一页;无该字段则按本页是否取满判断
|
||||
has_next = str(data.get("has_next", "")) == "1"
|
||||
if not page or not has_next:
|
||||
break
|
||||
offset += len(page)
|
||||
except httpx.HTTPError as e:
|
||||
raise PangleReportError(f"GroMore 报表请求异常: {e}") from e
|
||||
|
||||
logger.info(
|
||||
"GroMore 天级报表拉取完成 %s~%s dims=%s 行数=%d", start_date, end_date, dimensions, len(rows)
|
||||
)
|
||||
return rows
|
||||
@@ -14,9 +14,7 @@ worker / 多机时内存不共享 → 冷却、每日上限、校验都会失效
|
||||
防刷三层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权):
|
||||
1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件)
|
||||
2. 单号每日 `SMS_DAILY_LIMIT_PER_PHONE` 条上限(本文件)
|
||||
3. 单设备(device_id)每小时频控(api 层 auth.sms_send 内 enforce_rate_limit)+ 极光控制台 IP 白名单/防轰炸(运维侧)。
|
||||
⚠️ 原「单 IP 频控(rate_limit 依赖)」2026-06-26 按产品要求删除、改设备维度;但 device_id 客户端可伪造/轮换,
|
||||
脚本轮换 id 能绕过本层 → 挡脚本狂发主要靠极光控制台侧(+ 可选 nginx 限流)。
|
||||
3. 单 IP 频控(api 层 rate_limit 依赖)+ 极光控制台 IP 白名单/防轰炸(运维侧)
|
||||
另:单码校验失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废(防爆破),验过即作废(一次性)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
+10
-40
@@ -2,32 +2,30 @@
|
||||
|
||||
通过 `uvicorn app.main:app --reload` 启动。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.api.internal.app_version import router as internal_app_version_router
|
||||
from app.api.internal.launch_confirm import router as internal_launch_confirm_router
|
||||
from app.api.internal.price import router as internal_price_router
|
||||
from app.api.internal.store import router as internal_store_router
|
||||
from app.api.v1.ad import router as ad_router
|
||||
from app.api.v1.analytics import router as analytics_router
|
||||
from app.api.v1.auth import router as auth_router
|
||||
from app.api.v1.compare import router as compare_router
|
||||
from app.api.v1.compare_milestone import router as compare_milestone_router
|
||||
from app.api.v1.compare_record import router as compare_record_router
|
||||
from app.api.v1.coupon import router as coupon_router
|
||||
from app.api.v1.cps_redirect import router as cps_redirect_router
|
||||
from app.api.v1.device import router as device_router
|
||||
from app.api.v1.cps_redirect import router as cps_redirect_router
|
||||
from app.api.internal.app_version import router as internal_app_version_router
|
||||
from app.api.internal.launch_confirm import router as internal_launch_confirm_router
|
||||
from app.api.internal.price import router as internal_price_router
|
||||
from app.api.internal.store import router as internal_store_router
|
||||
from app.api.v1.feedback import router as feedback_router
|
||||
from app.api.v1.invite import router as invite_router
|
||||
from app.api.v1.meituan import router as meituan_router
|
||||
@@ -41,16 +39,15 @@ from app.api.v1.user import router as user_router
|
||||
from app.api.v1.wallet import router as wallet_router
|
||||
from app.api.v1.wxpay import router as wxpay_router
|
||||
from app.core.config import settings
|
||||
from app.core.daily_exchange_worker import (
|
||||
start_daily_exchange_worker,
|
||||
stop_daily_exchange_worker,
|
||||
)
|
||||
from app.core.heartbeat_monitor_worker import (
|
||||
start_heartbeat_monitor,
|
||||
stop_heartbeat_monitor,
|
||||
)
|
||||
from app.core.daily_exchange_worker import (
|
||||
start_daily_exchange_worker,
|
||||
stop_daily_exchange_worker,
|
||||
)
|
||||
from app.core.logging import setup_logging
|
||||
from app.core.pricebot_client import aclose_pricebot_client, get_pricebot_client
|
||||
from app.core.withdraw_reconcile_worker import (
|
||||
start_withdraw_reconcile_worker,
|
||||
stop_withdraw_reconcile_worker,
|
||||
@@ -70,7 +67,6 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
settings.APP_DEBUG,
|
||||
settings.DATABASE_URL.split("://", 1)[0],
|
||||
)
|
||||
get_pricebot_client() # 预热透传 client:把建 SSL 上下文的一次性成本付在启动,首个领券请求即热
|
||||
reconcile_task = start_withdraw_reconcile_worker()
|
||||
heartbeat_task = start_heartbeat_monitor()
|
||||
daily_exchange_task = start_daily_exchange_worker()
|
||||
@@ -80,7 +76,6 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
await stop_heartbeat_monitor(heartbeat_task)
|
||||
await stop_withdraw_reconcile_worker(reconcile_task)
|
||||
await stop_daily_exchange_worker(daily_exchange_task)
|
||||
await aclose_pricebot_client()
|
||||
logger.info("shutting down")
|
||||
|
||||
|
||||
@@ -110,7 +105,6 @@ def health() -> dict[str, str]:
|
||||
app.include_router(auth_router)
|
||||
app.include_router(user_router)
|
||||
app.include_router(feedback_router)
|
||||
app.include_router(analytics_router)
|
||||
app.include_router(invite_router)
|
||||
app.include_router(coupon_router)
|
||||
app.include_router(device_router)
|
||||
@@ -138,30 +132,6 @@ app.include_router(cps_redirect_router)
|
||||
# 用户上传文件(头像)静态服务。生产可改由 nginx 直接 serve MEDIA_ROOT。
|
||||
_media_root = Path(settings.MEDIA_ROOT)
|
||||
_media_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# 官网下载的 APK 直链(落地页 dl.html「官网下载」按钮指向 /media/shaguabijia.apk)。
|
||||
# 必须在 StaticFiles 挂载【之前】注册,否则被静态挂载吃掉。
|
||||
# StaticFiles 给 .apk 的 Content-Type 不对、且无 attachment 头 → 部分国产浏览器不触发下载、转甩应用市场;
|
||||
# 这里显式回 application/vnd.android.package-archive + Content-Disposition:attachment 强制浏览器下载。
|
||||
# 文件由 scripts/publish_apk.sh 编 release 包后放到 data/media/shaguabijia.apk(*.apk 不入 git,需部署时放)。
|
||||
_APK_PATH = _media_root / "shaguabijia.apk"
|
||||
|
||||
|
||||
@app.get(f"{settings.MEDIA_URL_PREFIX}/shaguabijia.apk", tags=["meta"], include_in_schema=False)
|
||||
def download_apk() -> FileResponse:
|
||||
if not _APK_PATH.is_file():
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(status_code=404, detail="安装包未就绪")
|
||||
return FileResponse(
|
||||
_APK_PATH,
|
||||
media_type="application/vnd.android.package-archive",
|
||||
filename="shaguabijia.apk",
|
||||
headers={"Content-Disposition": 'attachment; filename="shaguabijia.apk"'},
|
||||
)
|
||||
|
||||
|
||||
app.mount(
|
||||
settings.MEDIA_URL_PREFIX,
|
||||
StaticFiles(directory=str(_media_root)),
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"""所有 ORM model 必须在这里 import 一次,Alembic / metadata 才能扫到。"""
|
||||
from app.models.ad_ecpm import AdEcpmRecord # noqa: F401
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord # noqa: F401
|
||||
from app.models.ad_pangle_revenue import AdPangleDailyRevenue # noqa: F401
|
||||
from app.models.ad_reward import AdRewardRecord # noqa: F401
|
||||
from app.models.ad_watch_log import AdWatchLog # noqa: F401
|
||||
from app.models.admin import AdminAuditLog, AdminUser # noqa: F401
|
||||
from app.models.analytics_event import AnalyticsEvent # noqa: F401
|
||||
from app.models.app_config import AppConfig # noqa: F401
|
||||
from app.models.comparison import ComparisonRecord # noqa: F401
|
||||
from app.models.cps_activity import CpsActivity # noqa: F401
|
||||
@@ -19,7 +17,6 @@ from app.models.coupon_state import ( # noqa: F401
|
||||
CouponClaimRecord,
|
||||
CouponDailyCompletion,
|
||||
CouponPromptEngagement,
|
||||
CouponSession,
|
||||
)
|
||||
from app.models.feedback import Feedback # noqa: F401
|
||||
from app.models.invite import InviteRelation # noqa: F401
|
||||
@@ -40,6 +37,5 @@ from app.models.wallet import ( # noqa: F401
|
||||
CashTransaction,
|
||||
CoinAccount,
|
||||
CoinTransaction,
|
||||
InviteCashTransaction,
|
||||
WithdrawOrder,
|
||||
)
|
||||
|
||||
@@ -29,9 +29,6 @@ class AdEcpmRecord(Base):
|
||||
)
|
||||
# 广告类型:reward_video(激励视频) / draw(Draw 信息流) 等;不强行统一代码位,各类型各自上报
|
||||
ad_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
# 点位场景:comparison(比价) / coupon(领券) / welfare(福利),供收益报表区分比价/领券 Draw 收益;
|
||||
# 仅信息流/Draw 上报(比价与领券共用同一代码位,只能客户端各调用点显式打标),激励视频为 NULL。
|
||||
feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
# 客户端生成的一次广告会话 id;激励视频 S2S 回调 extra 会透传同值
|
||||
ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
# 实际投放的 ADN(穿山甲 getShowEcpm().getSdkName(),如 pangle / gdt)
|
||||
|
||||
@@ -30,9 +30,6 @@ class AdFeedRewardRecord(Base):
|
||||
ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
adn: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# 广告类型:feed(信息流) / draw(Draw 信息流)。比价与领券共用同一 Draw 代码位,靠 feed_scene
|
||||
# 区分收益;ad_type 区分广告形态。旧数据(未升级客户端)为 NULL,一律视为 feed,保持向后兼容。
|
||||
ad_type: Mapped[str | None] = mapped_column(String(16), nullable=True, default="feed")
|
||||
# 点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页)。比价与领券共用同一信息流
|
||||
# 代码位,slot_id/our_code_id 分不出,只能客户端各调用点显式打标;NULL=历史/未升级客户端=未分类。
|
||||
feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
"""穿山甲 GroMore 天级收益报表(后台结算口径,定时拉取入库)。
|
||||
|
||||
每行 = GroMore 数据 API 返回的一条「日期 × 应用 × 代码位」聚合收益(`integrations/pangle_report`
|
||||
+ `scripts/sync_pangle_revenue` 落库)。**权威/预估收益的来源**,与 `ad_ecpm_record`(客户端自报
|
||||
eCPM 折算的预估)互为对照:
|
||||
|
||||
- `revenue_yuan` ← 接口 `revenue`(预估收益,元;排序价×展示/1000,所有 ADN 都有);
|
||||
- `api_revenue_yuan` ← 接口 `api_revenue`(收益Api,元;各 ADN 经 Reporting 回传、更接近结算;
|
||||
未配置该 ADN 的 Reporting 或查当天时为空)。
|
||||
|
||||
⚠️ 穿山甲不提供分用户/设备维度,故本表最细只到 日期×应用×代码位,**无法挂到逐条广告事件**;
|
||||
广告收益报表里只用于汇总/趋势级的「穿山甲后台收益」,不改逐条行的客户端预估。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
DateTime,
|
||||
Float,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class AdPangleDailyRevenue(Base):
|
||||
__tablename__ = "ad_pangle_daily_revenue"
|
||||
__table_args__ = (
|
||||
# 一行 = (日期, 应用, 代码位, 广告源);adn 用 "" 表示「未分广告源、该代码位汇总」,
|
||||
# 避免 NULL 在唯一约束里被视为各不相同导致 upsert 重复(SQLite/PG 行为一致)。
|
||||
UniqueConstraint(
|
||||
"report_date", "app_env", "our_code_id", "adn",
|
||||
name="uq_ad_pangle_daily",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# 北京时间日期串 'YYYY-MM-DD'(拉取时 time_zone=8),与 ad_ecpm_record.report_date 同口径,可直接 join。
|
||||
report_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
|
||||
# 我们的应用环境:prod(傻瓜比价正式)/ test(测试);由 site_id 经 settings 映射而来。
|
||||
app_env: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
# 原始 GroMore AppId(site_id),留痕便于排查映射。
|
||||
site_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# 广告位 ID(= 接口 ad_unit_id = 我们客户端配的 104xxx = ad_ecpm_record.our_code_id),join key。
|
||||
# ⚠️ 不是接口的 code_id —— 那是底层各 ADN 的代码位(如 983674557),对不上我们的口径。
|
||||
our_code_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
# 广告源(接口 network 数字→名,如 pangle/gdt);"" = 未分广告源的代码位汇总行(当前默认口径)。
|
||||
adn: Mapped[str] = mapped_column(String(16), nullable=False, default="")
|
||||
# 预估收益(元)← 接口 revenue。
|
||||
revenue_yuan: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
# 收益Api(元)← 接口 api_revenue;未配 Reporting / 当天 等情况接口不返回 → NULL。
|
||||
api_revenue_yuan: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
# 预估 eCPM 原值(接口 ecpm,单位元/千次,**与客户端 getEcpm 的「分」不同**),参考用原样存。
|
||||
ecpm: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# 展示次数 ← 接口 imp_cnt。
|
||||
impressions: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# 货币类型(接口 currency,正常为 cny)。
|
||||
currency: Mapped[str] = mapped_column(String(8), nullable=False, default="cny")
|
||||
# 最近一次同步写入时间(同一行可被多次回补覆盖;T+1 数据穿山甲会订正)。
|
||||
synced_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
f"<AdPangleDailyRevenue {self.report_date} {self.app_env} "
|
||||
f"code={self.our_code_id} revenue={self.revenue_yuan}>"
|
||||
)
|
||||
@@ -1,60 +0,0 @@
|
||||
"""新手引导(及后续)埋点事件表。
|
||||
|
||||
每行 = 客户端上报的一条行为埋点,按「Who / When / Where / What / How」五维组织:
|
||||
- What :event(事件名,如 video_play)+ props(事件专属属性,JSON)
|
||||
- Who :device_id(硬件级设备标识)+ user_id(登录后才有,可空)
|
||||
- When :client_ts(端事件时间 epoch ms)+ session_id(本次引导会话)+ sent_at(端上报时间)
|
||||
+ created_at(服务端接收时间 = server_at)
|
||||
- Where:page(引导步/页面)+ client_ip(服务端从 X-Forwarded-For 取)
|
||||
- How :oem / os / model / app_ver / network / channel(设备与环境)
|
||||
|
||||
append-only,不更新;客户端批量上报(见 app/api/v1/analytics.py),admin 同库直接查(见
|
||||
app/admin/routers/event_logs.py)。未登录态也允许上报(user_id 为空),故 user_id 不设外键、只索引。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, BigInteger, DateTime, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class AnalyticsEvent(Base):
|
||||
__tablename__ = "analytics_event"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# ---- What:做了什么 ----
|
||||
event: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
props: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
# ---- Who:谁 ----
|
||||
device_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
|
||||
|
||||
# ---- When:何时 ----
|
||||
session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
client_ts: Mapped[int] = mapped_column(BigInteger, nullable=False) # 端事件时间 epoch ms
|
||||
sent_at: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # 端上报时间 epoch ms
|
||||
|
||||
# ---- Where:何地 ----
|
||||
page: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
client_ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
# ---- How:用什么环境 ----
|
||||
oem: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
os: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
model: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
app_ver: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
network: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
channel: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
|
||||
# 服务端接收时间(= When.server_at);客户端时间不可信,以此为权威落库时刻。
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<AnalyticsEvent id={self.id} event={self.event} device={self.device_id}>"
|
||||
@@ -181,79 +181,3 @@ class CouponPromptEngagement(Base):
|
||||
f"<CouponPromptEngagement device={self.device_id} "
|
||||
f"date={self.engage_date} type={self.engage_type}>"
|
||||
)
|
||||
|
||||
|
||||
class CouponSession(Base):
|
||||
"""一次领券任务的全程流水(admin「领券数据」看板数据源,2026-06-30)。
|
||||
|
||||
与 coupon_claim_record(按券一天一条去重)、coupon_daily_completion(按设备一天一条)都不同:
|
||||
本表**一次领券一条**(trace_id 唯一),记从发起(started)到收尾(completed/failed/abandoned)的
|
||||
全程耗时 + 各平台耗时 + 机型/ROM。客户端 POST /api/v1/coupon/session 两段上报:发起建行、
|
||||
收尾按 trace_id 更新同一行。发起即落库 → admin 可算「发起数」与中途流失(started 无终态=未完成)。
|
||||
|
||||
口径:elapsed_ms 由客户端全程计时(点发起→收尾)、权威;started_at/finished_at 为时刻留痕。
|
||||
started_date = started_at 的 Asia/Shanghai 自然日,供 admin 按天聚合 / 日期筛选(索引)。
|
||||
"""
|
||||
|
||||
__tablename__ = "coupon_session"
|
||||
__table_args__ = (
|
||||
# 一次领券一行:trace_id 幂等 upsert(发起建、收尾更新同一行)。
|
||||
UniqueConstraint("trace_id", name="uq_coupon_session_trace"),
|
||||
# admin 主聚合/筛选:按上海自然日 + 环境(报表默认只看 prod,避免测试数据串台)。
|
||||
Index("ix_coupon_session_date_env", "started_date", "app_env"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# 一次领券唯一 id(客户端 UUID,全程贯穿),upsert 键。
|
||||
trace_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
# 登录态才带(admin join 用户表出手机号/昵称);匿名领券为空。
|
||||
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
|
||||
|
||||
# started / completed / failed / abandoned。started 无终态 = 中途流失(未完成)。
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
# prod / dev(客户端 BuildConfig.DEBUG)。admin 报表默认只看 prod(对齐广告报表防串台口径)。
|
||||
app_env: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)
|
||||
|
||||
# 发起勾选平台 ["meituan-waimai", ...](空=全领)。
|
||||
platforms: Mapped[list | None] = mapped_column(_JSON, nullable=True)
|
||||
# 发起来源外卖 App 包名;null=App 内(傻瓜比价首页)发起,非空=从美团/淘宝/京东弹券发起。
|
||||
# admin「发起平台」列据此区分(空→傻瓜比价,包名→对应平台)。
|
||||
origin_package: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# 机型(Build.MANUFACTURER + MODEL)与 ROM(OemDetector,如 "ColorOS 14")。明细「机型/ROM」列 + 维度筛选。
|
||||
device_model: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
rom: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
# 发起时刻(客户端墙钟):明细「时间」列、趋势 X 轴。
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
# 发起的 Asia/Shanghai 自然日:按天聚合 / 日期范围筛选(索引)。
|
||||
started_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
# 收尾时刻(服务端 now);未收尾(流失)为空。
|
||||
finished_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
# 全程耗时(ms,客户端点发起→收尾):平均 / 分位都基于它(只统计 completed)。
|
||||
elapsed_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# 各平台领券耗时 {"meituan-waimai":3200,...}(ms)。明细美团/淘宝/京东耗时列。
|
||||
platform_elapsed: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
|
||||
# 领到总张数(收尾帧带)。
|
||||
claimed_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# pricebot done 帧回传的公网调试链接(price.shaguabijia.com/traces/{dir});含落盘时分秒、拼不出,只能存
|
||||
# (同 ComparisonRecord.trace_url)。admin「领券数据」明细据此渲染可点 trace 链接;未到 done(failed/abandoned)为空。
|
||||
trace_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
f"<CouponSession trace={self.trace_id} status={self.status} "
|
||||
f"elapsed_ms={self.elapsed_ms}>"
|
||||
)
|
||||
|
||||
+4
-22
@@ -1,13 +1,11 @@
|
||||
"""CPS 对账订单(cps_order)。
|
||||
|
||||
从联盟 API 按时间窗拉回、按平台落库的 CPS 订单明细。字段最初对齐美团 query_order,
|
||||
后续兼容京东订单报表:
|
||||
从美团联盟 query_order 按时间窗拉回、按 sid 归群的订单明细。字段对齐 query_order
|
||||
实测返回:
|
||||
- payPrice / profit 是「元」字符串 → 入库统一转「分」(与全站口径一致)
|
||||
- payTime / updateTime 是秒级时间戳 → 入库转 tz-aware datetime
|
||||
- status: 2付款 3完成 4取消 5风控 6结算(取消/风控不计佣金)
|
||||
order_id 全局唯一,reconcile 按它 upsert(订单状态会变,重复拉则更新)。京东订单用
|
||||
`jd:<row_id>` 前缀避免与美团订单号碰撞。
|
||||
order_id 全局唯一,reconcile 按它 upsert(订单状态会变,重复拉则更新)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -24,13 +22,8 @@ class CpsOrder(Base):
|
||||
__tablename__ = "cps_order"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# 平台:meituan / jd。历史数据迁移默认 meituan。
|
||||
platform: Mapped[str] = mapped_column(String(20), default="meituan", index=True, nullable=False)
|
||||
# 平台订单号/行号包装后的全局唯一键,upsert 幂等。
|
||||
# 美团订单号(加密串),全局唯一,upsert 幂等键。
|
||||
order_id: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||
# 平台原始订单号/行号。京东一笔订单多 SKU 时可按行号区分。
|
||||
external_order_id: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True)
|
||||
external_row_id: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True)
|
||||
# 渠道追踪位 = 群 sid(历史无 sid 订单为空)。按它归群聚合。
|
||||
sid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
act_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
@@ -42,21 +35,11 @@ class CpsOrder(Base):
|
||||
commission_rate: Mapped[str | None] = mapped_column(String(16), nullable=True) # "300"=3% "10"=0.1%
|
||||
refund_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
refund_profit_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# 通用佣金拆分。美团只有预估 profit;京东有预估/实际佣金。
|
||||
estimated_commission_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
actual_commission_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# 美团订单状态: 2付款 3完成 4取消 5风控 6结算
|
||||
mt_status: Mapped[str | None] = mapped_column(String(8), index=True, nullable=True)
|
||||
# 京东订单有效码(validCode),用于判断是否有效/已完成。
|
||||
jd_valid_code: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)
|
||||
invalid_reason: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
product_name: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
settle_month: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
site_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
position_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
pid: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
sub_union_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
|
||||
pay_time: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), index=True, nullable=True
|
||||
@@ -76,6 +59,5 @@ class CpsOrder(Base):
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
f"<CpsOrder id={self.id} order_id={self.order_id!r} "
|
||||
f"platform={self.platform!r} sid={self.sid!r} "
|
||||
f"status={self.mt_status or self.jd_valid_code} profit_cents={self.commission_cents}>"
|
||||
f"sid={self.sid!r} status={self.mt_status} profit_cents={self.commission_cents}>"
|
||||
)
|
||||
|
||||
@@ -25,11 +25,6 @@ class Feedback(Base):
|
||||
contact: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
# 截图 URL 列表(相对路径,如 ["/media/feedback/u1_ab12.jpg"]);无图为 None
|
||||
images: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
|
||||
# 提交时的端环境快照(admin 排查用;客户端改版带上后的新反馈才有,历史数据为 NULL)
|
||||
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True) # 我们 app versionName
|
||||
device_model: Mapped[str | None] = mapped_column(String(64), nullable=True) # Build.MODEL
|
||||
rom_name: Mapped[str | None] = mapped_column(String(32), nullable=True) # OemDetector os:ColorOS/MIUI/...
|
||||
android_version: Mapped[str | None] = mapped_column(String(16), nullable=True) # Build.VERSION.RELEASE
|
||||
# pending(审核中) / adopted(已采纳) / rejected(未采纳)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
|
||||
reject_reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
|
||||
+1
-15
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, false, func
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
@@ -35,20 +35,6 @@ class InviteRelation(Base):
|
||||
inviter_coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
invitee_coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
# ===== v2 比价发奖追踪(好友"下载+登录+比价一次"→ 给邀请人发邀请奖励金)=====
|
||||
# 是否已因"好友完成比价"发过奖:好友比价多次只发一次(防重复发,与 invitee 唯一约束双保险)
|
||||
compare_reward_granted: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default=false()
|
||||
)
|
||||
# 实发给邀请人的邀请奖励金(分);未发为 0
|
||||
compare_reward_cents: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0, server_default="0"
|
||||
)
|
||||
# 发奖时间(未发为 None)
|
||||
compare_rewarded_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
|
||||
@@ -25,11 +25,6 @@ class CoinAccount(Base):
|
||||
)
|
||||
coin_balance: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
cash_balance_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# 邀请奖励金余额(分)——与金币兑换来的 cash_balance_cents **物理隔离**(产品红线:
|
||||
# 邀请奖励金 ≠ 看广告/金币现金,两本账不可累加)。好友比价发奖入账、提现出账走它。
|
||||
invite_cash_balance_cents: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0, server_default="0"
|
||||
)
|
||||
# 累计赚取的金币(只增不减),用于"历史总收益"类展示
|
||||
total_coin_earned: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
@@ -113,11 +108,6 @@ class WithdrawOrder(Base):
|
||||
# 商户单号(我们生成,微信查单的 out_bill_no),唯一
|
||||
out_bill_no: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
# 这笔提现扣的是哪个账户:coin_cash(金币兑换的现金) / invite_cash(邀请奖励金)。
|
||||
# 退款时据此退回**对应**账户,两本账不串。旧单默认 coin_cash。
|
||||
source: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="coin_cash", server_default="coin_cash"
|
||||
)
|
||||
# 提现实名(微信达额转账要求):审核后异步打款时要用,发起提现时存下,可空
|
||||
user_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# 归一化状态:reviewing(待审核) / pending(打款在途) / success / failed(打款失败已退) / rejected(审核拒绝已退)
|
||||
@@ -210,41 +200,3 @@ class CashTransaction(Base):
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<CashTransaction id={self.id} user_id={self.user_id} cents={self.amount_cents}>"
|
||||
|
||||
|
||||
class InviteCashTransaction(Base):
|
||||
"""邀请奖励金流水(单位:分)。与 cash_transaction(金币兑换现金)**物理隔离**——
|
||||
产品红线:邀请奖励金 ≠ 看广告/金币现金,两本账不可累加、各自提现。
|
||||
入账=好友比价发奖(invite_reward),出账=提现(invite_withdraw)/退款(invite_withdraw_refund)。
|
||||
结构与 cash_transaction 同构,balance_after_cents 记的是 coin_account.invite_cash_balance_cents。"""
|
||||
|
||||
__tablename__ = "invite_cash_transaction"
|
||||
__table_args__ = (
|
||||
# 提现退款幂等:一个提现单只退一次(同 cash_transaction 的 withdraw_refund 去重)
|
||||
Index(
|
||||
"ux_invite_cash_txn_refund_ref",
|
||||
"ref_id",
|
||||
unique=True,
|
||||
sqlite_where=text("biz_type = 'invite_withdraw_refund' AND ref_id IS NOT NULL"),
|
||||
postgresql_where=text("biz_type = 'invite_withdraw_refund' AND ref_id IS NOT NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||
)
|
||||
# 正数=入账(发奖),负数=出账(提现)
|
||||
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
balance_after_cents: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
# 业务类型:invite_reward / invite_withdraw / invite_withdraw_refund
|
||||
biz_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
ref_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
remark: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<InviteCashTransaction id={self.id} user_id={self.user_id} cents={self.amount_cents}>"
|
||||
|
||||
@@ -23,7 +23,6 @@ def create_ecpm_record(
|
||||
ad_session_id: str | None = None,
|
||||
adn: str | None = None,
|
||||
slot_id: str | None = None,
|
||||
feed_scene: str | None = None,
|
||||
app_env: str | None = None,
|
||||
our_code_id: str | None = None,
|
||||
) -> AdEcpmRecord:
|
||||
@@ -42,7 +41,6 @@ def create_ecpm_record(
|
||||
ad_session_id=ad_session_id,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
feed_scene=feed_scene,
|
||||
app_env=app_env,
|
||||
our_code_id=our_code_id,
|
||||
ecpm_raw=ecpm_raw,
|
||||
@@ -53,16 +51,11 @@ def create_ecpm_record(
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
# 撞唯一约束 uq_ad_ecpm_record_session(全局按 ad_session_id、不含 user_id):并发同会话重复上报,
|
||||
# 或同一 ad_session_id 已被先到的上报占用。本接口 fire-and-forget、best-effort —— 丢一条不影响业务
|
||||
# (穿山甲后台才是结算权威),绝不向客户端抛 500。兜底查找须与唯一约束**同口径**(只按 ad_session_id、
|
||||
# 不带 user_id):否则不同 user 上报了同一 ad_session_id 时,带 user_id 的查找会漏掉那条别人的记录 →
|
||||
# 旧逻辑在此 raise 成 500(本应静默吞掉)。
|
||||
existing = _find_by_session_global(db, ad_session_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
# 极少:rollback 后既存记录又查不到(并发删除 / 竞态)。吞掉、返回未入库的内存对象(调用方不读返回值)。
|
||||
return rec
|
||||
if ad_session_id:
|
||||
existing = find_by_session(db, user_id=user_id, ad_session_id=ad_session_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
raise
|
||||
db.refresh(rec)
|
||||
return rec
|
||||
|
||||
@@ -81,20 +74,6 @@ def find_by_session(
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _find_by_session_global(db: Session, ad_session_id: str | None) -> AdEcpmRecord | None:
|
||||
"""按 ad_session_id **全局**查找(与唯一约束 uq_ad_ecpm_record_session 同口径,不含 user_id)。
|
||||
|
||||
仅 create_ecpm_record 撞约束后兜底用:此时撞的是全局会话约束,既存记录可能属于**另一个 user**,
|
||||
带 user_id 的 find_by_session 会漏掉它、导致误判「查无 → raise 500」。其它业务查「某 user 的某次
|
||||
展示 eCPM」仍用 find_by_session(带 user_id,语义更准),不走这里。
|
||||
"""
|
||||
if not ad_session_id:
|
||||
return None
|
||||
return db.execute(
|
||||
select(AdEcpmRecord).where(AdEcpmRecord.ad_session_id == ad_session_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def count_today(db: Session, user_id: int) -> int:
|
||||
"""该用户今日(北京时间)上报的 eCPM 条数,排查/对账辅助用。"""
|
||||
return db.execute(
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.core.rewards import cn_today
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
from app.repositories import wallet as crud_wallet
|
||||
|
||||
|
||||
FEED_REWARD_UNIT_SECONDS = 10
|
||||
# 单个 feed 事件的时长上限(秒):一期 duration_seconds 由客户端上报,伪造超长时长会刷份数
|
||||
# (每 10 秒 1 份)。真实单条信息流视频远小于此;取 120s=12 份封顶,挡刷量、不影响正规单。
|
||||
@@ -64,7 +65,6 @@ def grant_feed_reward(
|
||||
client_event_id: str,
|
||||
ecpm: str,
|
||||
duration_seconds: int,
|
||||
ad_type: str = "feed",
|
||||
ad_session_id: str | None = None,
|
||||
adn: str | None = None,
|
||||
slot_id: str | None = None,
|
||||
@@ -73,22 +73,17 @@ def grant_feed_reward(
|
||||
app_env: str | None = None,
|
||||
our_code_id: str | None = None,
|
||||
aborted: bool = False,
|
||||
display_coin: int = 0,
|
||||
) -> AdFeedRewardRecord:
|
||||
"""**每条**信息流广告(客户端每条各上报一次)结算奖励。client_event_id 幂等,同号重试不重复发。
|
||||
|
||||
发奖规则(所见即所得, 2026-06-27 用户拍板「显示多少给多少」):优先**直接发客户端小球显示的金币
|
||||
display_coin**;防刷钳到本条「1 份满额」(eCPM 已钳 AD_ECPM_MAX_FEN, 因子2 按账号累计已发条数取档),
|
||||
合法显示(实际因子2 × 进度 p ≤ 1 份)不被砍, 只挡伪造天价值。旧客户端不传 display_coin 时退回
|
||||
「看满 10 秒发整份」(兼容不断币)。因子2(LT)由**客户端**按 granted 行 COUNT(拉自 /feed-reward/units)
|
||||
算进 display_coin, 后端只记 granted 行让该计数自增, 不再服务端重算份值。
|
||||
发奖规则:**一条广告 = 一个单次公式值**(rewards.calculate_ad_reward_coin),因子2(LT)按账号累计
|
||||
**条**数递进;看满一份时长(unit_count>=1, 即 ≥10 秒)才发,**不逐份累加**。
|
||||
- aborted=True(用户中途 ✕ 关闭这条):本条不发,记 status='closed_early'。
|
||||
- display_coin 为 0 且时长不足一份:记 status='too_short' 不发(不计 LT / 当日上限)。
|
||||
- 时长不足 10 秒(unit_count==0):记 status='too_short' 不发。
|
||||
- 命中当日条数上限:记 status='capped' 不发。
|
||||
duration_seconds 落库留痕(unit_count 字段), 旧端兼容路径据它判是否满 1 份。
|
||||
duration_seconds 是**这一条**的观看秒数。服务端两道硬闸防刷:时长钳到 FEED_MAX_DURATION_SECONDS、
|
||||
eCPM 在 calculate_ad_reward_coin 内钳到 AD_ECPM_MAX_FEN;叠加每日 get_ad_daily_limit 条数上限。
|
||||
feed_scene:点位场景(comparison/coupon/welfare),仅归类落库,不参与计算。
|
||||
ad_type:广告形态(feed 信息流 / draw Draw 信息流),仅归类落库;**每日上限与因子2(LT)仍按本表
|
||||
全表 unit 累计(feed+draw 共享同一发奖池/上限),不按 ad_type 拆分**。
|
||||
"""
|
||||
existing = _find_by_event(db, client_event_id)
|
||||
if existing is not None:
|
||||
@@ -111,7 +106,6 @@ def grant_feed_reward(
|
||||
ecpm_raw=ecpm,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
ad_type=ad_type,
|
||||
feed_scene=feed_scene,
|
||||
trace_id=trace_id,
|
||||
app_env=app_env,
|
||||
@@ -132,7 +126,6 @@ def grant_feed_reward(
|
||||
ecpm_raw=ecpm,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
ad_type=ad_type,
|
||||
feed_scene=feed_scene,
|
||||
trace_id=trace_id,
|
||||
app_env=app_env,
|
||||
@@ -142,31 +135,18 @@ def grant_feed_reward(
|
||||
)
|
||||
return _commit_record(db, rec, client_event_id)
|
||||
|
||||
# 所见即所得(用户 2026-06-27「显示多少给多少」): 优先发**客户端小球显示**的金币 display_coin,
|
||||
# 钳到本条「1 份满额」防刷(eCPM 已钳 AD_ECPM_MAX_FEN; 合法显示=因子2×p≤1份, 不会被砍)。
|
||||
# 因子2(LT)按账号累计已发条数(granted 行 COUNT), 第 existing_ads+1 条。
|
||||
existing_ads = granted_unit_total(db, user_id)
|
||||
unit_cap = rewards.calculate_ad_reward_coin(ecpm, existing_ads + 1)
|
||||
if display_coin > 0:
|
||||
coin = min(display_coin, unit_cap) # 新端: 所见即所得(直接发小球显示金币)
|
||||
elif unit_count >= 1:
|
||||
coin = unit_cap # 旧端没传 display_coin: 退回「看满 1 份发整份」(兼容)
|
||||
else:
|
||||
coin = 0
|
||||
|
||||
# 显示金币为 0 且没满一份 → 不发, 记 too_short 留痕(不写 granted 行 → 不计 LT / 当日上限)。
|
||||
if coin <= 0:
|
||||
# 整场总时长不足 10 秒,凑不满一份 → 不发,记 too_short 留痕。
|
||||
if unit_count == 0:
|
||||
rec = AdFeedRewardRecord(
|
||||
client_event_id=client_event_id,
|
||||
user_id=user_id,
|
||||
reward_date=today,
|
||||
duration_seconds=safe_duration,
|
||||
unit_count=unit_count,
|
||||
unit_count=0,
|
||||
ad_session_id=ad_session_id,
|
||||
ecpm_raw=ecpm,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
ad_type=ad_type,
|
||||
feed_scene=feed_scene,
|
||||
trace_id=trace_id,
|
||||
app_env=app_env,
|
||||
@@ -176,11 +156,15 @@ def grant_feed_reward(
|
||||
)
|
||||
return _commit_record(db, rec, client_event_id)
|
||||
|
||||
crud_wallet.grant_coins(
|
||||
db, user_id, coin,
|
||||
biz_type="feed_ad_reward", ref_id=client_event_id,
|
||||
remark="信息流广告奖励",
|
||||
)
|
||||
# 一条广告 = 一个「单次公式值」(因子2 按账号累计**条**数, 即第 existing_ads+1 条);看满一份(unit_count>=1)即发,不逐份累加。
|
||||
existing_ads = granted_unit_total(db, user_id)
|
||||
coin = rewards.calculate_ad_reward_coin(ecpm, existing_ads + 1)
|
||||
if coin > 0:
|
||||
crud_wallet.grant_coins(
|
||||
db, user_id, coin,
|
||||
biz_type="feed_ad_reward", ref_id=client_event_id,
|
||||
remark="信息流广告奖励",
|
||||
)
|
||||
rec = AdFeedRewardRecord(
|
||||
client_event_id=client_event_id,
|
||||
user_id=user_id,
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
"""穿山甲 GroMore 天级收益 读写(`ad_pangle_daily_revenue` 表)。
|
||||
|
||||
`scripts/sync_pangle_revenue` 拉数后调 `upsert_daily_rows` 落库(同一(日期×应用×代码位×广告源)
|
||||
幂等覆盖,T+1 订正可重跑);admin 广告收益报表调 `aggregate_by_date` 取「穿山甲后台收益」做
|
||||
汇总/趋势级展示。穿山甲无用户维度,故这里不涉及 user_id。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.ad_pangle_revenue import AdPangleDailyRevenue
|
||||
|
||||
# upsert 时可被覆盖更新的列(唯一键之外的业务列)。
|
||||
_UPDATABLE = ("site_id", "revenue_yuan", "api_revenue_yuan", "ecpm", "impressions", "currency")
|
||||
|
||||
|
||||
class PangleDateAgg(TypedDict):
|
||||
date: str
|
||||
revenue_yuan: float
|
||||
api_revenue_yuan: float | None
|
||||
impressions: int
|
||||
|
||||
|
||||
def upsert_daily_rows(db: Session, rows: list[dict[str, Any]]) -> dict[str, int]:
|
||||
"""按唯一键 (report_date, app_env, our_code_id, adn) 逐行 upsert。
|
||||
|
||||
每个 row 须含:report_date, app_env, our_code_id;可选 adn(默认"")、site_id、
|
||||
revenue_yuan、api_revenue_yuan、ecpm、impressions、currency。返回 {inserted, updated}。
|
||||
规模很小(代码位数×天数),逐行 select-then-write 足够,且 SQLite/PG 通用。
|
||||
"""
|
||||
inserted = updated = 0
|
||||
for row in rows:
|
||||
adn = row.get("adn") or ""
|
||||
existing = db.execute(
|
||||
select(AdPangleDailyRevenue).where(
|
||||
AdPangleDailyRevenue.report_date == row["report_date"],
|
||||
AdPangleDailyRevenue.app_env == row["app_env"],
|
||||
AdPangleDailyRevenue.our_code_id == row["our_code_id"],
|
||||
AdPangleDailyRevenue.adn == adn,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if existing is None:
|
||||
db.add(AdPangleDailyRevenue(
|
||||
report_date=row["report_date"],
|
||||
app_env=row["app_env"],
|
||||
site_id=row.get("site_id"),
|
||||
our_code_id=row["our_code_id"],
|
||||
adn=adn,
|
||||
revenue_yuan=float(row.get("revenue_yuan") or 0.0),
|
||||
api_revenue_yuan=row.get("api_revenue_yuan"),
|
||||
ecpm=row.get("ecpm"),
|
||||
impressions=int(row.get("impressions") or 0),
|
||||
currency=row.get("currency") or "cny",
|
||||
))
|
||||
inserted += 1
|
||||
else:
|
||||
for col in _UPDATABLE:
|
||||
if col in row:
|
||||
setattr(existing, col, row[col])
|
||||
updated += 1
|
||||
db.commit()
|
||||
return {"inserted": inserted, "updated": updated}
|
||||
|
||||
|
||||
def aggregate_by_date(
|
||||
db: Session,
|
||||
*,
|
||||
date_from: str,
|
||||
date_to: str,
|
||||
app_env: str | None = None,
|
||||
our_code_id: str | None = None,
|
||||
) -> list[PangleDateAgg]:
|
||||
"""按日期汇总穿山甲收益(闭区间,北京时间),供报表趋势 + 合计。
|
||||
|
||||
revenue_yuan = Σrevenue;api_revenue_yuan = Σapi_revenue(SQL SUM 忽略 NULL,
|
||||
全为空则返回 None,前端显示「-」)。可选按应用 / 代码位过滤。返回按日期升序。
|
||||
"""
|
||||
stmt = (
|
||||
select(
|
||||
AdPangleDailyRevenue.report_date,
|
||||
func.sum(AdPangleDailyRevenue.revenue_yuan),
|
||||
func.sum(AdPangleDailyRevenue.api_revenue_yuan),
|
||||
func.sum(AdPangleDailyRevenue.impressions),
|
||||
)
|
||||
.where(
|
||||
AdPangleDailyRevenue.report_date >= date_from,
|
||||
AdPangleDailyRevenue.report_date <= date_to,
|
||||
)
|
||||
.group_by(AdPangleDailyRevenue.report_date)
|
||||
.order_by(AdPangleDailyRevenue.report_date)
|
||||
)
|
||||
if app_env is not None:
|
||||
stmt = stmt.where(AdPangleDailyRevenue.app_env == app_env)
|
||||
if our_code_id is not None:
|
||||
stmt = stmt.where(AdPangleDailyRevenue.our_code_id == our_code_id)
|
||||
|
||||
out: list[PangleDateAgg] = []
|
||||
for report_date, rev, api_rev, imp in db.execute(stmt).all():
|
||||
out.append(PangleDateAgg(
|
||||
date=report_date,
|
||||
revenue_yuan=round(float(rev or 0.0), 6),
|
||||
api_revenue_yuan=(round(float(api_rev), 6) if api_rev is not None else None),
|
||||
impressions=int(imp or 0),
|
||||
))
|
||||
return out
|
||||
@@ -1,34 +0,0 @@
|
||||
"""埋点事件批量落库。append-only,一次 commit 提交整批。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.analytics_event import AnalyticsEvent
|
||||
from app.schemas.analytics import AnalyticsBatchIn
|
||||
|
||||
|
||||
def record_batch(db: Session, batch: AnalyticsBatchIn, *, client_ip: str | None) -> int:
|
||||
"""把一批上报事件展开成多行落库(公共维度复制到每行),返回写入条数。"""
|
||||
rows = [
|
||||
AnalyticsEvent(
|
||||
event=e.event,
|
||||
props=e.props or None,
|
||||
device_id=batch.device_id,
|
||||
user_id=batch.user_id,
|
||||
session_id=e.session_id,
|
||||
client_ts=e.client_ts,
|
||||
sent_at=batch.sent_at,
|
||||
page=e.page,
|
||||
client_ip=client_ip or None,
|
||||
oem=batch.oem,
|
||||
os=batch.os,
|
||||
model=batch.model,
|
||||
app_ver=batch.app_ver,
|
||||
network=e.network,
|
||||
channel=batch.channel,
|
||||
)
|
||||
for e in batch.events
|
||||
]
|
||||
db.add_all(rows)
|
||||
db.commit()
|
||||
return len(rows)
|
||||
@@ -101,10 +101,10 @@ AD_CONFIG_KEY = "ad_config"
|
||||
_AD_CONFIG_DEFAULTS: dict[str, Any] = {
|
||||
"app_id": "5830519", # 穿山甲应用ID(正式)
|
||||
"reward_code_id": "104099389", # 福利页激励视频位
|
||||
# 比价与领券共用同一穿山甲 Draw 代码位(默认 104098712 = 后台「Draw信息流」位),靠 feed_scene
|
||||
# (comparison/coupon)区分收益;运营可在后台把两者拆成不同代码位。字段从旧 *_feed_code_id 改名为 *_draw_code_id。
|
||||
"compare_draw_code_id": "104098712", # 比价 Draw 代码位(104098712 = Draw 信息流位)
|
||||
"coupon_draw_code_id": "104098712", # 领券 Draw 代码位(初始同比价,运营可拆)
|
||||
# ⚠️ 2026-06-21 真机核对穿山甲后台:5830519 名下信息流真实位是 104142227「信息流 1」;
|
||||
# 旧值 104090333 不在该应用名下(请求会报 44406/配置 null、出不了广告)。客户端接入下发后以本值为准。
|
||||
"compare_feed_code_id": "104142227", # 比价信息流位
|
||||
"coupon_feed_code_id": "104142227", # 领券信息流位(初始同比价,运营可拆)
|
||||
"reward_mkey": "", # 激励位 GroMore 验签密钥(空则回退 .env PANGLE_REWARD_SECRET*)
|
||||
"reward_enabled": True, # 福利激励视频开关
|
||||
"compare_ad_enabled": True, # 比价广告开关
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime, timezone
|
||||
from datetime import date, datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
@@ -17,7 +17,6 @@ from app.models.coupon_state import (
|
||||
CouponClaimRecord,
|
||||
CouponDailyCompletion,
|
||||
CouponPromptEngagement,
|
||||
CouponSession,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.coupon_state")
|
||||
@@ -233,91 +232,3 @@ def sum_claimed_count(db: Session, user_id: int) -> int:
|
||||
)
|
||||
).scalar_one()
|
||||
return int(total or 0)
|
||||
|
||||
|
||||
# ===== 领券任务流水(coupon_session,admin「领券数据」看板数据源)=====
|
||||
|
||||
def upsert_coupon_session(
|
||||
db: Session,
|
||||
*,
|
||||
trace_id: str,
|
||||
device_id: str,
|
||||
status: str,
|
||||
started_at_ms: int,
|
||||
user_id: int | None = None,
|
||||
platforms: list[str] | None = None,
|
||||
origin_package: str | None = None,
|
||||
device_model: str | None = None,
|
||||
rom: str | None = None,
|
||||
app_env: str | None = None,
|
||||
elapsed_ms: int | None = None,
|
||||
platform_elapsed: dict | None = None,
|
||||
claimed_count: int | None = None,
|
||||
trace_url: str | None = None,
|
||||
) -> None:
|
||||
"""一条领券流水按 trace_id 幂等 upsert(发起 started 建行、收尾终态更新同一行)。
|
||||
|
||||
- 乱序/重复兜底:终态(completed/failed/abandoned)先到也建行;started 重复到不覆盖已有终态
|
||||
(状态只前进,不降级)。
|
||||
- started_at 由客户端墙钟毫秒转;started_date 取其 Asia/Shanghai 自然日(admin 按天聚合/筛选)。
|
||||
- 终态帧补 finished_at=服务端 now;各字段非空才写(避免 started 帧的 None 抹掉收尾值,反之亦然)。
|
||||
并发 IntegrityError 回滚忽略(本就幂等)。
|
||||
"""
|
||||
started_at = datetime.fromtimestamp(started_at_ms / 1000, tz=timezone.utc)
|
||||
started_date = started_at.astimezone(_CN_TZ).date()
|
||||
is_terminal = status in ("completed", "failed", "abandoned")
|
||||
|
||||
row = db.execute(
|
||||
select(CouponSession).where(CouponSession.trace_id == trace_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if row is None:
|
||||
db.add(CouponSession(
|
||||
trace_id=trace_id,
|
||||
device_id=device_id,
|
||||
user_id=user_id,
|
||||
status=status,
|
||||
app_env=app_env,
|
||||
platforms=platforms,
|
||||
origin_package=origin_package,
|
||||
device_model=device_model,
|
||||
rom=rom,
|
||||
started_at=started_at,
|
||||
started_date=started_date,
|
||||
finished_at=datetime.now(timezone.utc) if is_terminal else None,
|
||||
elapsed_ms=elapsed_ms,
|
||||
platform_elapsed=platform_elapsed,
|
||||
claimed_count=claimed_count,
|
||||
trace_url=trace_url,
|
||||
))
|
||||
else:
|
||||
# 状态只前进:started 帧重复到(如 START_STICKY 重启)不把已有终态降级回 started。
|
||||
if not (status == "started" and row.status in ("completed", "failed", "abandoned")):
|
||||
row.status = status
|
||||
if is_terminal:
|
||||
row.finished_at = datetime.now(timezone.utc)
|
||||
if user_id is not None:
|
||||
row.user_id = user_id
|
||||
if platforms is not None:
|
||||
row.platforms = platforms
|
||||
if origin_package is not None:
|
||||
row.origin_package = origin_package
|
||||
if device_model is not None:
|
||||
row.device_model = device_model
|
||||
if rom is not None:
|
||||
row.rom = rom
|
||||
if app_env is not None:
|
||||
row.app_env = app_env
|
||||
if elapsed_ms is not None:
|
||||
row.elapsed_ms = elapsed_ms
|
||||
if platform_elapsed is not None:
|
||||
row.platform_elapsed = platform_elapsed
|
||||
if claimed_count is not None:
|
||||
row.claimed_count = claimed_count
|
||||
if trace_url is not None:
|
||||
row.trace_url = trace_url
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# 并发下另一请求刚插了同 trace_id → 唯一约束撞,回滚忽略(本就幂等)。
|
||||
db.rollback()
|
||||
|
||||
@@ -17,20 +17,12 @@ def create_feedback(
|
||||
content: str,
|
||||
contact: str,
|
||||
images: list[str] | None,
|
||||
app_version: str | None = None,
|
||||
device_model: str | None = None,
|
||||
rom_name: str | None = None,
|
||||
android_version: str | None = None,
|
||||
) -> Feedback:
|
||||
fb = Feedback(
|
||||
user_id=user_id,
|
||||
content=content,
|
||||
contact=contact,
|
||||
images=images or None,
|
||||
app_version=app_version or None,
|
||||
device_model=device_model or None,
|
||||
rom_name=rom_name or None,
|
||||
android_version=android_version or None,
|
||||
status="pending",
|
||||
created_at=datetime.now(CN_TZ).replace(tzinfo=None),
|
||||
)
|
||||
|
||||
+28
-120
@@ -1,14 +1,13 @@
|
||||
"""好友邀请 CRUD(注册即生效,绑定只建归因关系、双方都不发钱)。
|
||||
|
||||
发奖规则(v3,冰 2026-06-26 拍板):
|
||||
- 绑定时双方都不发钱(被邀请人无奖励、邀请人不发金币)。
|
||||
- 邀请人的钱由 try_reward_on_compare 在好友"成功比价一次"后发 2 元邀请奖励金(防刷)。
|
||||
- v1 的"绑定双方各发金币"已废;invite_relation 的 inviter_coin/invitee_coin 列保留恒 0(待清)。
|
||||
"""好友邀请 CRUD(注册即生效,邀请人 + 被邀请人各发金币)。
|
||||
|
||||
防重复发奖三道(仿 ad_reward / 提现的资金安全思路):
|
||||
1. invitee_user_id 唯一 → 一个被邀请人只能被绑定一次(幂等键)。
|
||||
2. 自邀屏蔽 → inviter == invitee 直接拒。
|
||||
3. 现成的手机号唯一(每个被邀请人 = 一个真实手机号账号)= 天然限制刷量规模。
|
||||
|
||||
发金币复用 wallet.grant_coins(grant 只 flush 不 commit),与建关系记录在**同一事务**
|
||||
commit,保证"建关系 + 双方加金币"原子。奖励额 = rewards.INVITE_INVITER_COINS /
|
||||
INVITE_INVITEE_COINS。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -35,30 +34,6 @@ def _gen_code() -> str:
|
||||
return "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LEN))
|
||||
|
||||
|
||||
# ===== v2 邀请倒计时(7 天 1 轮,锚点=注册日,自然日差,东八区)=====
|
||||
# 中国不用夏令时,固定 +8 偏移即可(不依赖 tzdata,Windows 本地联调也稳)。
|
||||
_CST = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def compute_invite_countdown(register_at: datetime) -> tuple[int, bool, str]:
|
||||
"""按 7 天 1 轮算邀请页倒计时。
|
||||
|
||||
锚点 = 用户注册日(user.created_at),按东八区自然日差算
|
||||
(跨自然日才减、同日多次登录不减,天然满足)。返回:
|
||||
(本轮剩余天数 1..7, 是否刚进入新一轮[非首轮第1天], 展示文案)。
|
||||
"""
|
||||
reg = register_at if register_at.tzinfo else register_at.replace(tzinfo=timezone.utc)
|
||||
days_since = max(0, (datetime.now(_CST).date() - reg.astimezone(_CST).date()).days)
|
||||
day_in_cycle = days_since % 7 # 0..6(本轮第几天,0-based)
|
||||
days_left = 7 - day_in_cycle # 7..1(第1天=7、第7天=1)
|
||||
is_fresh_round = days_since >= 7 and day_in_cycle == 0 # 非首轮的第1天
|
||||
if is_fresh_round:
|
||||
text = "恭喜您进入新一轮邀请!\n距离本轮结束还有7天"
|
||||
else:
|
||||
text = f"距本轮结束还有{days_left}天"
|
||||
return days_left, is_fresh_round, text
|
||||
|
||||
|
||||
def ensure_code(db: Session, user: User) -> str:
|
||||
"""保证 user 有邀请码(懒生成),返回它。唯一约束碰撞则换码重试。
|
||||
|
||||
@@ -115,17 +90,15 @@ def _is_new_user(user: User) -> bool:
|
||||
class BindResult:
|
||||
status: str # success / already_bound / invalid_code / self_invite / not_eligible
|
||||
relation: InviteRelation | None = None
|
||||
invitee_coin: int = 0 # v3 起恒 0(绑定不再发金币);保留字段兼容响应
|
||||
invitee_coin: int = 0 # 本次给被邀请人发的金币(success 时 >0)
|
||||
|
||||
|
||||
def bind(
|
||||
db: Session, *, invitee: User, invite_code: str, channel: str = "clipboard"
|
||||
) -> BindResult:
|
||||
"""把 invitee 绑定到 invite_code 对应的邀请人,注册即生效。
|
||||
"""把 invitee 绑定到 invite_code 对应的邀请人,注册即生效 + 双方发金币。
|
||||
|
||||
v3 发奖(冰 2026-06-26 拍板):绑定双方都不发钱——被邀请人无奖励,邀请人改"好友成功比价一次
|
||||
才发 2 元邀请奖励金"(见 try_reward_on_compare,防刷)。绑定只建归因关系 + 跑防刷闸。
|
||||
幂等:已绑过 → already_bound。
|
||||
幂等:invitee 已被绑过 → already_bound(不重复发奖)。
|
||||
"""
|
||||
# 幂等:已绑过直接返回(不重复发奖)
|
||||
existing = _relation_of_invitee(db, invitee.id)
|
||||
@@ -141,18 +114,27 @@ def bind(
|
||||
if not _is_new_user(invitee):
|
||||
return BindResult("not_eligible")
|
||||
|
||||
# v3(冰 2026-06-26 拍板):绑定双方都不发钱。被邀请人不再发新人金币(去掉拉新即时激励);
|
||||
# 邀请人的钱由 try_reward_on_compare 在好友成功比价后发 2 元邀请奖励金(防刷)。
|
||||
inviter_coin = rewards.INVITE_INVITER_COINS
|
||||
invitee_coin = rewards.INVITE_INVITEE_COINS
|
||||
|
||||
rel = InviteRelation(
|
||||
inviter_user_id=inviter.id,
|
||||
invitee_user_id=invitee.id,
|
||||
channel=(channel or "clipboard")[:16],
|
||||
status="effective",
|
||||
inviter_coin=0, # v1 金币线已停用,列保留恒 0(待清)
|
||||
invitee_coin=0, # v3:被邀请人绑定不再发金币
|
||||
inviter_coin=inviter_coin,
|
||||
invitee_coin=invitee_coin,
|
||||
)
|
||||
db.add(rel)
|
||||
# 绑定不发任何金币(邀请人改比价发现金、被邀请人无奖励),仅建归因关系。
|
||||
# 双方发金币(同事务,与建关系一起 commit)。ref_id 互指对方便于对账。
|
||||
crud_wallet.grant_coins(
|
||||
db, inviter.id, inviter_coin,
|
||||
biz_type="invite_inviter", ref_id=str(invitee.id), remark="邀请好友奖励",
|
||||
)
|
||||
crud_wallet.grant_coins(
|
||||
db, invitee.id, invitee_coin,
|
||||
biz_type="invite_invitee", ref_id=str(inviter.id), remark="新人受邀奖励",
|
||||
)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
@@ -163,74 +145,20 @@ def bind(
|
||||
return BindResult("already_bound", existing)
|
||||
raise
|
||||
except Exception:
|
||||
# 其它 commit 失败(DB 故障 / PG 序列化冲突等):显式回滚,保证建关系原子,
|
||||
# 不依赖 get_db 关闭时的隐式回滚,语义更硬。
|
||||
# 其它 commit 失败(DB 故障 / PG 序列化冲突等):显式回滚,保证"建关系 + 双方发币"
|
||||
# 原子(要么全成要么全无),不依赖 get_db 关闭时的隐式回滚,语义更硬。
|
||||
db.rollback()
|
||||
raise
|
||||
db.refresh(rel)
|
||||
return BindResult("success", rel)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompareRewardResult:
|
||||
status: str # granted / no_relation / already_granted / inviter_inactive
|
||||
inviter_user_id: int | None = None
|
||||
reward_cents: int = 0
|
||||
|
||||
|
||||
def try_reward_on_compare(db: Session, invitee_user_id: int) -> CompareRewardResult:
|
||||
"""被邀请人完成一次成功比价时调用:若其有邀请关系且尚未发过比价奖,给【邀请人】发邀请奖励金。
|
||||
|
||||
v2 发奖规则核心(替代 v1 的"注册即发金币"):好友"下载+登录+比价一次"→ 邀请人得 2 元现金。
|
||||
幂等:compare_reward_granted 标记保证好友比价多次只发一次。无邀请关系 / 已发过 / 邀请人失效
|
||||
→ 空操作。发奖(grant_invite_cash 入账独立账户)+ 置标记同事务 commit,保证原子。
|
||||
"""
|
||||
rel = _relation_of_invitee(db, invitee_user_id)
|
||||
if rel is None:
|
||||
return CompareRewardResult("no_relation")
|
||||
if rel.compare_reward_granted:
|
||||
return CompareRewardResult("already_granted", rel.inviter_user_id)
|
||||
|
||||
inviter = db.get(User, rel.inviter_user_id)
|
||||
if inviter is None or inviter.status != "active":
|
||||
# 邀请人注销 / 封禁:本次不发、不置标记,待其恢复后下次比价再试(保守,不吞奖励)
|
||||
return CompareRewardResult("inviter_inactive", rel.inviter_user_id)
|
||||
|
||||
reward = rewards.INVITE_COMPARE_REWARD_CENTS
|
||||
rel.compare_reward_granted = True
|
||||
rel.compare_reward_cents = reward
|
||||
rel.compare_rewarded_at = datetime.now(timezone.utc)
|
||||
# 发邀请奖励金到邀请人的独立账户(与金币隔离),ref_id 指向被邀请人便于对账
|
||||
crud_wallet.grant_invite_cash(
|
||||
db, inviter.id, reward,
|
||||
biz_type="invite_reward", ref_id=str(invitee_user_id), remark="好友比价奖励",
|
||||
)
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
return CompareRewardResult("granted", inviter.id, reward)
|
||||
return BindResult("success", rel, invitee_coin)
|
||||
|
||||
|
||||
def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
|
||||
"""返回 (已成功邀请人数, 累计从邀请获得的金币)。
|
||||
|
||||
"已邀请好友数"口径 = 完成一次比价(已触发邀请奖励金)的被邀请人数,即 compare_reward_granted=True。
|
||||
不再数"仅绑定未比价"的关系——否则会出现"已邀请 1、可提现余额 0"(好友下载登录但没比价),
|
||||
与产品口径"已邀请好友数 × 2元 = 累计提现 + 可提现余额"对不上。过滤后该恒等式天然成立
|
||||
(每个计入的好友都恰好发过 1 笔 2 元,钱要么在余额要么已提现)。
|
||||
|
||||
金币口径(inviter_coin 之和)自 v3 起恒 0(邀请人收益改走邀请奖励金,见 get_reward_stats /
|
||||
try_reward_on_compare);保留返回位兼容旧响应字段 coins_earned。
|
||||
"""
|
||||
"""返回 (已成功邀请人数, 累计从邀请获得的金币)。"""
|
||||
count = db.execute(
|
||||
select(func.count())
|
||||
.select_from(InviteRelation)
|
||||
.where(
|
||||
InviteRelation.inviter_user_id == inviter_id,
|
||||
InviteRelation.compare_reward_granted.is_(True),
|
||||
)
|
||||
.where(InviteRelation.inviter_user_id == inviter_id)
|
||||
).scalar_one()
|
||||
coins = db.execute(
|
||||
select(func.coalesce(func.sum(InviteRelation.inviter_coin), 0))
|
||||
@@ -239,26 +167,6 @@ def get_stats(db: Session, inviter_id: int) -> tuple[int, int]:
|
||||
return int(count), int(coins)
|
||||
|
||||
|
||||
def get_reward_stats(db: Session, inviter_id: int) -> tuple[int, int]:
|
||||
"""v2 邀请奖励金战绩:(可提现余额/分, 累计提现成功/分)。
|
||||
|
||||
余额 = coin_account.invite_cash_balance_cents;累计提现 = 该用户 source=invite_cash 且
|
||||
status=success 的提现单金额之和(成功打款才算)。供 /invite/me 提现板块展示。
|
||||
"""
|
||||
from app.models.wallet import CoinAccount, WithdrawOrder
|
||||
balance = db.execute(
|
||||
select(CoinAccount.invite_cash_balance_cents).where(CoinAccount.user_id == inviter_id)
|
||||
).scalar_one_or_none()
|
||||
withdrawn = db.execute(
|
||||
select(func.coalesce(func.sum(WithdrawOrder.amount_cents), 0)).where(
|
||||
WithdrawOrder.user_id == inviter_id,
|
||||
WithdrawOrder.source == "invite_cash",
|
||||
WithdrawOrder.status == "success",
|
||||
)
|
||||
).scalar_one()
|
||||
return int(balance or 0), int(withdrawn)
|
||||
|
||||
|
||||
def _mask_phone(phone: str) -> str:
|
||||
"""手机号脱敏:138****8888。前端拿不到完整号,展示被邀请人时在此兜底名字。
|
||||
|
||||
@@ -302,7 +210,7 @@ def get_invitees(
|
||||
items.append({
|
||||
"display_name": u.nickname or u.wechat_nickname or _mask_phone(u.phone),
|
||||
"avatar_url": u.avatar_url or u.wechat_avatar_url or None,
|
||||
"coins": rel.inviter_coin, # v3 起恒 0(邀请人收益改走邀请奖励金)
|
||||
"coins": rel.inviter_coin,
|
||||
"invited_at": rel.created_at,
|
||||
})
|
||||
has_more = offset + len(rows) < int(total)
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.launch_confirm_sample import LaunchConfirmSample
|
||||
@@ -35,27 +33,3 @@ def insert_sample(db: Session, payload: LaunchConfirmSampleIn) -> int:
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row.id
|
||||
|
||||
|
||||
def list_samples(
|
||||
db: Session,
|
||||
*,
|
||||
exec_success: Optional[bool] = None,
|
||||
host_package: Optional[str] = None,
|
||||
since: Optional[datetime] = None,
|
||||
limit: int = 1000,
|
||||
) -> list[LaunchConfirmSample]:
|
||||
"""按条件查样本(pricebot 的 distill_launch_confirm.py 沉淀工具读)。created_at 升序。
|
||||
|
||||
过滤项都可空:exec_success(只看放行成功的)/ host_package(只看某宿主包)/
|
||||
since(>= created_at)。limit 兜底防一次拉爆全表。
|
||||
"""
|
||||
stmt = select(LaunchConfirmSample)
|
||||
if exec_success is not None:
|
||||
stmt = stmt.where(LaunchConfirmSample.exec_success == exec_success)
|
||||
if host_package:
|
||||
stmt = stmt.where(LaunchConfirmSample.host_package == host_package)
|
||||
if since is not None:
|
||||
stmt = stmt.where(LaunchConfirmSample.created_at >= since)
|
||||
stmt = stmt.order_by(LaunchConfirmSample.created_at.asc()).limit(limit)
|
||||
return list(db.execute(stmt).scalars().all())
|
||||
|
||||
+33
-91
@@ -24,7 +24,6 @@ from app.models.wallet import (
|
||||
CashTransaction,
|
||||
CoinAccount,
|
||||
CoinTransaction,
|
||||
InviteCashTransaction,
|
||||
WechatTransferAuthorization,
|
||||
WithdrawOrder,
|
||||
)
|
||||
@@ -167,36 +166,6 @@ def grant_cash(
|
||||
return acc, txn
|
||||
|
||||
|
||||
def grant_invite_cash(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
amount_cents: int,
|
||||
*,
|
||||
biz_type: str,
|
||||
ref_id: str | None = None,
|
||||
remark: str | None = None,
|
||||
) -> tuple[CoinAccount, InviteCashTransaction]:
|
||||
"""邀请奖励金变动入口(正数入账 / 负数出账)。更新 invite_cash_balance_cents + 写
|
||||
invite_cash_transaction,不 commit。与金币兑换的 cash_balance_cents **物理隔离**
|
||||
(产品红线:邀请奖励金 ≠ 金币现金,两本账不可累加)。返回 (account, transaction),
|
||||
调用方负责 commit。不在此校验扣成负——由调用方按业务保护。"""
|
||||
acc = get_or_create_account(db, user_id, commit=False)
|
||||
acc.invite_cash_balance_cents += amount_cents
|
||||
|
||||
txn = InviteCashTransaction(
|
||||
user_id=user_id,
|
||||
amount_cents=amount_cents,
|
||||
balance_after_cents=acc.invite_cash_balance_cents,
|
||||
biz_type=biz_type,
|
||||
ref_id=ref_id,
|
||||
remark=remark,
|
||||
created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
|
||||
)
|
||||
db.add(txn)
|
||||
db.flush()
|
||||
return acc, txn
|
||||
|
||||
|
||||
def list_coin_transactions(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
@@ -442,43 +411,33 @@ def refund_reviewing_withdraws_on_unbind(db: Session, user_id: int) -> int:
|
||||
_OUT_BILL_NO_RE = re.compile(r"^[0-9A-Za-z_-]{8,32}$")
|
||||
|
||||
|
||||
def _balance_col(source: str):
|
||||
"""提现账户来源 → CoinAccount 余额列。invite_cash=邀请奖励金;否则金币兑换的现金。
|
||||
两账户物理隔离,提现扣款/退款都按 source 走对应列,互不串。"""
|
||||
return (
|
||||
CoinAccount.invite_cash_balance_cents
|
||||
if source == "invite_cash"
|
||||
else CoinAccount.cash_balance_cents
|
||||
)
|
||||
def _try_deduct_cash(db: Session, user_id: int, amount_cents: int) -> bool:
|
||||
"""原子扣减现金:仅当余额足够时扣,返回是否成功。
|
||||
|
||||
|
||||
def _try_deduct_cash(db: Session, user_id: int, amount_cents: int, source: str = "coin_cash") -> bool:
|
||||
"""原子扣减指定账户余额:仅当余额足够时扣,返回是否成功。
|
||||
|
||||
用带条件的 UPDATE(`WHERE <col> >= amount`)避免"读-判断-写"竞态——并发/重试时不会两次
|
||||
都通过余额检查导致超额扣款(SQLite 串行写、Postgres 行级,均安全)。source 决定扣
|
||||
cash_balance_cents(coin_cash) 还是 invite_cash_balance_cents(invite_cash)。
|
||||
用带条件的 UPDATE(`WHERE cash_balance_cents >= amount`)避免"读-判断-写"竞态——
|
||||
并发/重试时不会两次都通过余额检查导致超额扣款(SQLite 串行写、Postgres 行级,均安全)。
|
||||
"""
|
||||
col = _balance_col(source)
|
||||
res = db.execute(
|
||||
update(CoinAccount)
|
||||
.where(CoinAccount.user_id == user_id, col >= amount_cents)
|
||||
.values({col: col - amount_cents})
|
||||
.where(
|
||||
CoinAccount.user_id == user_id,
|
||||
CoinAccount.cash_balance_cents >= amount_cents,
|
||||
)
|
||||
.values(cash_balance_cents=CoinAccount.cash_balance_cents - amount_cents)
|
||||
)
|
||||
return res.rowcount == 1
|
||||
|
||||
|
||||
def _add_cash(db: Session, user_id: int, amount_cents: int, source: str = "coin_cash") -> int:
|
||||
"""原子增加指定账户余额(退款用),返回加后余额。source 决定退回哪个账户(两账户隔离)。"""
|
||||
col = _balance_col(source)
|
||||
def _add_cash(db: Session, user_id: int, amount_cents: int) -> int:
|
||||
"""原子增加现金(退款用),返回加后余额。"""
|
||||
db.execute(
|
||||
update(CoinAccount)
|
||||
.where(CoinAccount.user_id == user_id)
|
||||
.values({col: col + amount_cents})
|
||||
.values(cash_balance_cents=CoinAccount.cash_balance_cents + amount_cents)
|
||||
)
|
||||
db.flush()
|
||||
bal = db.execute(
|
||||
select(col).where(CoinAccount.user_id == user_id)
|
||||
select(CoinAccount.cash_balance_cents).where(CoinAccount.user_id == user_id)
|
||||
).scalar_one()
|
||||
return bal
|
||||
|
||||
@@ -497,15 +456,11 @@ def _refund_withdraw(
|
||||
"""
|
||||
if order.status in ("failed", "rejected"):
|
||||
return # 防重复退款(并发/对账与查单/重复拒绝同时触发)
|
||||
# 账户隔离:按 order.source 退回对应账户 + 写对应退款流水表
|
||||
is_invite = order.source == "invite_cash"
|
||||
txn_model = InviteCashTransaction if is_invite else CashTransaction
|
||||
refund_biz = "invite_withdraw_refund" if is_invite else "withdraw_refund"
|
||||
refunded_txn_id = db.execute(
|
||||
select(txn_model.id).where(
|
||||
txn_model.user_id == order.user_id,
|
||||
txn_model.biz_type == refund_biz,
|
||||
txn_model.ref_id == order.out_bill_no,
|
||||
select(CashTransaction.id).where(
|
||||
CashTransaction.user_id == order.user_id,
|
||||
CashTransaction.biz_type == "withdraw_refund",
|
||||
CashTransaction.ref_id == order.out_bill_no,
|
||||
).limit(1)
|
||||
).scalar_one_or_none()
|
||||
if refunded_txn_id is not None:
|
||||
@@ -513,13 +468,13 @@ def _refund_withdraw(
|
||||
order.fail_reason = reason[:256]
|
||||
db.commit()
|
||||
return
|
||||
bal = _add_cash(db, order.user_id, order.amount_cents, order.source)
|
||||
bal = _add_cash(db, order.user_id, order.amount_cents)
|
||||
db.add(
|
||||
txn_model(
|
||||
CashTransaction(
|
||||
user_id=order.user_id,
|
||||
amount_cents=order.amount_cents,
|
||||
balance_after_cents=bal,
|
||||
biz_type=refund_biz,
|
||||
biz_type="withdraw_refund",
|
||||
ref_id=order.out_bill_no,
|
||||
# 用户可见文案区分"未成功(自动退)"vs"审核未通过";技术原因记在 order.fail_reason
|
||||
remark=(
|
||||
@@ -537,13 +492,13 @@ def _refund_withdraw(
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# 并发退款兜底:唯一退款流水已被另一事务写入时,回滚本事务的加钱和流水,
|
||||
# 再把订单状态补到终态。这样无论拒绝/查单/对账怎么并发,金额最多退一次。
|
||||
# 再把订单状态补到终态。这样无论拒绝/查单/对账怎么并发,现金最多退一次。
|
||||
db.rollback()
|
||||
refunded_txn_id = db.execute(
|
||||
select(txn_model.id).where(
|
||||
txn_model.user_id == user_id,
|
||||
txn_model.biz_type == refund_biz,
|
||||
txn_model.ref_id == out_bill_no,
|
||||
select(CashTransaction.id).where(
|
||||
CashTransaction.user_id == user_id,
|
||||
CashTransaction.biz_type == "withdraw_refund",
|
||||
CashTransaction.ref_id == out_bill_no,
|
||||
).limit(1)
|
||||
).scalar_one_or_none()
|
||||
if refunded_txn_id is None:
|
||||
@@ -607,10 +562,8 @@ def create_withdraw(
|
||||
user_id: int,
|
||||
amount_cents: int,
|
||||
*,
|
||||
source: str = "coin_cash",
|
||||
user_name: str | None = None,
|
||||
out_bill_no: str | None = None,
|
||||
allow_sub_min: bool = False,
|
||||
) -> WithdrawOrder:
|
||||
"""发起提现:原子扣款 + 建单 reviewing(待人工审核),**不打款**。
|
||||
|
||||
@@ -620,13 +573,8 @@ def create_withdraw(
|
||||
重复发起多笔提现(审核拒绝再退回)。
|
||||
#2 out_bill_no 客户端幂等键:同号重试返回该单现状(reviewing 等审核),不重复扣款建单。
|
||||
实名 user_name 在此存下(WithdrawOrder.user_name),供异步审核打款时传给微信(达额需实名)。
|
||||
|
||||
allow_sub_min:放行低于"提现最低额"的小额(用于 0.01 元调试提现)。仅由 endpoint 在
|
||||
`skip_review and not is_prod`(debug 包 + 非生产双闸)时置 True;仍受 schema gt=0 与 max 上限约束。
|
||||
生产恒为 False → 最低额校验照常,绝不可能提 0.01。
|
||||
"""
|
||||
min_c = 0 if allow_sub_min else rewards.get_withdraw_min_cents(db)
|
||||
if amount_cents < min_c or amount_cents > rewards.get_withdraw_max_cents(db):
|
||||
if amount_cents < rewards.get_withdraw_min_cents(db) or amount_cents > rewards.get_withdraw_max_cents(db):
|
||||
raise InvalidWithdrawAmountError
|
||||
|
||||
# 提现即要求已绑微信:否则审核通过也打不了款,提前拦更友好
|
||||
@@ -660,23 +608,20 @@ def create_withdraw(
|
||||
# 账户须存在(原子扣款的 UPDATE 不会建账户)
|
||||
get_or_create_account(db, user_id, commit=True)
|
||||
|
||||
# #1 原子扣款:余额不足时影响行数为 0(按 source 扣对应账户)
|
||||
if not _try_deduct_cash(db, user_id, amount_cents, source):
|
||||
# #1 原子扣款:余额不足时影响行数为 0
|
||||
if not _try_deduct_cash(db, user_id, amount_cents):
|
||||
db.rollback()
|
||||
raise InsufficientCashError
|
||||
|
||||
is_invite = source == "invite_cash"
|
||||
txn_model = InviteCashTransaction if is_invite else CashTransaction
|
||||
withdraw_biz = "invite_withdraw" if is_invite else "withdraw"
|
||||
bal = db.execute(
|
||||
select(_balance_col(source)).where(CoinAccount.user_id == user_id)
|
||||
select(CoinAccount.cash_balance_cents).where(CoinAccount.user_id == user_id)
|
||||
).scalar_one()
|
||||
db.add(
|
||||
txn_model(
|
||||
CashTransaction(
|
||||
user_id=user_id,
|
||||
amount_cents=-amount_cents,
|
||||
balance_after_cents=bal,
|
||||
biz_type=withdraw_biz,
|
||||
biz_type="withdraw",
|
||||
ref_id=out_bill_no,
|
||||
remark="提现到微信零钱(待审核)",
|
||||
created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
|
||||
@@ -686,7 +631,6 @@ def create_withdraw(
|
||||
user_id=user_id,
|
||||
out_bill_no=out_bill_no,
|
||||
amount_cents=amount_cents,
|
||||
source=source,
|
||||
user_name=user_name,
|
||||
status="reviewing",
|
||||
)
|
||||
@@ -1063,12 +1007,10 @@ def reconcile_pending_withdraws(db: Session, *, older_than_minutes: int = 15) ->
|
||||
|
||||
|
||||
def list_withdraw_orders(
|
||||
db: Session, user_id: int, *, source: str | None = None, limit: int = 20, cursor: int | None = None
|
||||
db: Session, user_id: int, *, limit: int = 20, cursor: int | None = None
|
||||
) -> tuple[list[WithdrawOrder], int | None]:
|
||||
"""提现单分页(按 id 倒序,游标式)。source 非空时只返回该账户来源的单(coin_cash / invite_cash)。"""
|
||||
"""提现单分页(按 id 倒序,游标式)。"""
|
||||
stmt = select(WithdrawOrder).where(WithdrawOrder.user_id == user_id)
|
||||
if source is not None:
|
||||
stmt = stmt.where(WithdrawOrder.source == source)
|
||||
if cursor is not None:
|
||||
stmt = stmt.where(WithdrawOrder.id < cursor)
|
||||
stmt = stmt.order_by(WithdrawOrder.id.desc()).limit(limit)
|
||||
|
||||
@@ -57,12 +57,6 @@ class EcpmReportIn(BaseModel):
|
||||
)
|
||||
adn: str | None = Field(None, description="实际投放 ADN(getSdkName),如 pangle")
|
||||
slot_id: str | None = Field(None, description="实际展示代码位(底层 mediation rit)")
|
||||
feed_scene: str | None = Field(
|
||||
None,
|
||||
max_length=16,
|
||||
description="点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页);"
|
||||
"比价与领券共用同一 Draw 代码位,需客户端在各调用点显式标注,供收益报表区分比价/领券;激励视频为空",
|
||||
)
|
||||
app_env: str | None = Field(
|
||||
None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)"
|
||||
)
|
||||
@@ -134,11 +128,6 @@ class FeedRewardIn(BaseModel):
|
||||
"""
|
||||
|
||||
client_event_id: str = Field(..., min_length=8, max_length=64, description="客户端生成的幂等事件 id")
|
||||
ad_type: str = Field(
|
||||
"feed",
|
||||
max_length=16,
|
||||
description="广告类型:feed(信息流) / draw(Draw 信息流);默认 feed 兼容旧客户端",
|
||||
)
|
||||
ad_session_id: str | None = Field(
|
||||
None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id"
|
||||
)
|
||||
@@ -167,11 +156,6 @@ class FeedRewardIn(BaseModel):
|
||||
aborted: bool = Field(
|
||||
False, description="用户中途 ✕ 关闭广告(未走完比价):整场不发,记 closed_early"
|
||||
)
|
||||
display_coin: int = Field(
|
||||
0, ge=0,
|
||||
description="客户端金币小球**本条显示**的金币(所见即所得):后端直接发这个数,钳到本条最大 1 份"
|
||||
"满额防刷。缺省 0 = 旧客户端不传,退回服务端「看满 10 秒发整份」",
|
||||
)
|
||||
|
||||
|
||||
class FeedRewardOut(BaseModel):
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"""客户端埋点上报 schema(批量)。
|
||||
|
||||
客户端把「设备固定维度」(device_id / user_id / oem / os / model / app_ver / channel / sent_at)
|
||||
放批次外层只传一次,events 列表里每条只带「事件维度」(event / client_ts / session_id / page /
|
||||
network / props);服务端展开成多行 AnalyticsEvent 落库(见 app/repositories/analytics.py)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AnalyticsEventIn(BaseModel):
|
||||
"""单条事件维度。"""
|
||||
|
||||
event: str = Field(max_length=64)
|
||||
client_ts: int = Field(description="端事件发生时间 epoch ms")
|
||||
session_id: str | None = Field(default=None, max_length=64)
|
||||
page: str | None = Field(default=None, max_length=64)
|
||||
network: str | None = Field(default=None, max_length=16)
|
||||
props: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AnalyticsBatchIn(BaseModel):
|
||||
"""一批上报:公共维度 + 事件列表。"""
|
||||
|
||||
# Who(整批共享)
|
||||
device_id: str = Field(max_length=64)
|
||||
user_id: int | None = None
|
||||
# When(本批上报时刻 epoch ms)
|
||||
sent_at: int | None = None
|
||||
# How(设备固定维度,整批共享)
|
||||
oem: str | None = Field(default=None, max_length=32)
|
||||
os: str | None = Field(default=None, max_length=32)
|
||||
model: str | None = Field(default=None, max_length=64)
|
||||
app_ver: str | None = Field(default=None, max_length=32)
|
||||
channel: str | None = Field(default=None, max_length=32)
|
||||
events: list[AnalyticsEventIn] = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
class AnalyticsIngestOut(BaseModel):
|
||||
ok: bool = True
|
||||
received: int
|
||||
@@ -71,10 +71,6 @@ class JverifyLoginRequest(BaseModel):
|
||||
|
||||
class SmsSendRequest(BaseModel):
|
||||
phone: str = Field(..., min_length=11, max_length=11, pattern=r"^1\d{10}$")
|
||||
device_id: str = Field(
|
||||
"", max_length=64,
|
||||
description="硬件级设备标识(Android ANDROID_ID),用于发码防刷按 设备+IP 限流;空=按 IP 聚一桶",
|
||||
)
|
||||
|
||||
|
||||
class SmsSendResponse(BaseModel):
|
||||
|
||||
@@ -11,7 +11,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# ===== 上报请求 =====
|
||||
@@ -21,36 +21,8 @@ class ComparisonItemIn(BaseModel):
|
||||
|
||||
name: str
|
||||
qty: int = 1
|
||||
# specs 仅供比价记录展示(admin-web 详情 / app 记录页都按字符串数组渲染并 join)。
|
||||
# pricebot(2026-06-27 嵌套规格统一)起 calibration 的 specs 可能是规格对象
|
||||
# [{name, qty, sub_specs}] 而非字符串 → 下面的 before-validator 统一拍平成可读字符串,
|
||||
# 兼容新旧两种输入、保持 list[str] 契约不变(下游零改动)。
|
||||
# ⚠️ 两个坑都踩过, 必须"拍平"而非别的: ① 直接声明 list[str] 不拍平 → 对象 specs 整条
|
||||
# 422 被拒、不入库(同下方 platform_results list→dict 同类事故); ② 仅放宽成裸 list 又会让
|
||||
# 下游 join 出 "[object Object]"/对象 toString 的乱码。
|
||||
specs: list[str] | None = None
|
||||
|
||||
@field_validator("specs", mode="before")
|
||||
@classmethod
|
||||
def _flatten_specs(cls, v: object) -> object:
|
||||
"""pricebot 规格对象 [{name, qty, sub_specs}] → 可读字符串数组; 字符串元素原样保留;
|
||||
非 list 原样交还(让 pydantic 照常报类型错)。嵌套规格拼成 '主项(子1,子2)'。"""
|
||||
if not isinstance(v, list):
|
||||
return v
|
||||
out: list[str] = []
|
||||
for it in v:
|
||||
if isinstance(it, str):
|
||||
s = it
|
||||
elif isinstance(it, dict):
|
||||
name = str(it.get("name") or "").strip()
|
||||
subs = [str(x).strip() for x in (it.get("sub_specs") or []) if str(x).strip()]
|
||||
s = f"{name}({','.join(subs)})" if name and subs else (name or ",".join(subs))
|
||||
else:
|
||||
continue
|
||||
if s:
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
class AppliedCouponIn(BaseModel):
|
||||
"""单笔已用优惠(来自 comparison_results[].applied_coupons)。amount 单位:元、正数。"""
|
||||
@@ -123,7 +95,6 @@ class ComparisonRecordIn(BaseModel):
|
||||
# pricebot done.params.trace_url 原样上报,落库供记录页「复制调试链接」(dir 名含落盘
|
||||
# 时分秒前端拼不出,必须由后端透传)。
|
||||
trace_url: str | None = Field(None, description="本次比价公网调试链接")
|
||||
total_ms: int | None = Field(None, description="整场比价墙钟耗时(ms)")
|
||||
|
||||
# ===== debug 维度(客户端采集上报;旧客户端不带 → None。仅 admin 比价记录页用)=====
|
||||
# 必须显式声明,否则 model_dump() 落 raw_payload 时被 pydantic 静默丢弃(同上面 coupon_saved 的坑)。
|
||||
@@ -173,7 +144,6 @@ class ComparisonRecordOut(BaseModel):
|
||||
items: list = []
|
||||
comparison_results: list = []
|
||||
skipped_dish_names: list = []
|
||||
total_ms: int | None = None
|
||||
# 「已下单」(店级):该店名在该用户真实下单(source='compare')里出现过即 True。
|
||||
# 由 list_records 动态算出挂在 ORM 实例上(非 DB 列),from_attributes 读出;缺省 False。
|
||||
ordered: bool = False
|
||||
|
||||
@@ -49,28 +49,3 @@ class CouponStatsOut(BaseModel):
|
||||
"""
|
||||
|
||||
coupon_count: int
|
||||
|
||||
|
||||
class CouponSessionIn(BaseModel):
|
||||
"""客户端领券流水上报体(admin「领券数据」看板数据源,POST /api/v1/coupon/session)。
|
||||
|
||||
一次领券两段上报,按 trace_id upsert 到 coupon_session:
|
||||
- 发起(status=started):带勾选平台 + 机型/ROM/app_env + started_at_ms(发起墙钟毫秒)。
|
||||
- 收尾(completed/failed/abandoned):带 elapsed_ms(全程耗时)+ platform_elapsed(各平台耗时)+ claimed_count。
|
||||
不鉴权(同领券循环 MVP,按 device_id/trace_id),user_id 登录态带上做留痕(可空)。
|
||||
"""
|
||||
|
||||
trace_id: str
|
||||
device_id: str
|
||||
status: str # started / completed / failed / abandoned
|
||||
started_at_ms: int # 发起墙钟毫秒(客户端 System.currentTimeMillis)
|
||||
user_id: int | None = None
|
||||
platforms: list[str] | None = None
|
||||
origin_package: str | None = None
|
||||
device_model: str | None = None
|
||||
rom: str | None = None
|
||||
app_env: str | None = None
|
||||
elapsed_ms: int | None = None
|
||||
platform_elapsed: dict[str, int] | None = None
|
||||
claimed_count: int | None = None
|
||||
trace_url: str | None = None
|
||||
|
||||
@@ -10,12 +10,7 @@ class InviteInfoOut(BaseModel):
|
||||
invite_code: str # 我的邀请码
|
||||
share_url: str # 落地页链接(含 ?ref=),前端据此生成二维码 + 复制分享
|
||||
invited_count: int # 已成功邀请人数
|
||||
coins_earned: int # 累计从邀请获得的金币(v1 口径;v2 邀请人改发奖励金)
|
||||
reward_balance_cents: int = 0 # v2 可提现邀请奖励金(分)
|
||||
reward_withdrawn_cents: int = 0 # v2 累计提现成功的邀请奖励金(分)
|
||||
countdown_days_left: int = 7 # v2 本轮剩余天数(7 天 1 轮)
|
||||
countdown_is_fresh_round: bool = False # 是否刚进入新一轮(非首轮第1天)
|
||||
countdown_text: str = "" # 倒计时展示文案(前端直接显示,新轮含换行)
|
||||
coins_earned: int # 累计从邀请获得的金币
|
||||
|
||||
|
||||
class LandingTrackIn(BaseModel):
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"""启动确认窗兜底样本的内部上报模型(pricebot → app-server)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LaunchConfirmSampleIn(BaseModel):
|
||||
@@ -26,20 +24,3 @@ class LaunchConfirmSampleOut(BaseModel):
|
||||
"""落库结果。"""
|
||||
|
||||
id: int
|
||||
|
||||
|
||||
class LaunchConfirmSampleRow(BaseModel):
|
||||
"""单条样本(列表读出;沉淀脚本 distill 用,payload 原样带出)。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
created_at: datetime
|
||||
trace_id: str | None = None
|
||||
device_id: str | None = None
|
||||
host_package: str | None = None
|
||||
target_app: str | None = None
|
||||
system_locale: str | None = None
|
||||
exec_success: bool = False
|
||||
dialog_title: str | None = None
|
||||
payload: dict | None = None
|
||||
|
||||
@@ -38,8 +38,8 @@ class AdConfigPublicOut(BaseModel):
|
||||
|
||||
app_id: str # 穿山甲应用ID(改了客户端需冷启才生效,SDK init 一次性读)
|
||||
reward_code_id: str # 福利页激励视频位
|
||||
compare_draw_code_id: str # 比价 Draw 代码位
|
||||
coupon_draw_code_id: str # 领券 Draw 代码位(比价/领券共用同一位,靠 feed_scene 区分收益)
|
||||
compare_feed_code_id: str # 比价信息流位
|
||||
coupon_feed_code_id: str # 领券信息流位
|
||||
reward_enabled: bool # 福利激励视频开关
|
||||
compare_ad_enabled: bool # 比价广告开关
|
||||
coupon_ad_enabled: bool # 领券广告开关
|
||||
|
||||
@@ -16,7 +16,6 @@ class CoinAccountOut(BaseModel):
|
||||
|
||||
coin_balance: int = Field(..., description="当前金币余额")
|
||||
cash_balance_cents: int = Field(..., description="当前现金余额(分)")
|
||||
invite_cash_balance_cents: int = Field(0, description="邀请奖励金余额(分,与现金隔离)")
|
||||
total_coin_earned: int = Field(..., description="累计赚取金币")
|
||||
|
||||
|
||||
@@ -124,7 +123,6 @@ class UnbindWechatResultOut(BaseModel):
|
||||
|
||||
class WithdrawRequest(BaseModel):
|
||||
amount_cents: int = Field(..., gt=0, description="提现金额(分)")
|
||||
source: str = Field("coin_cash", description="提现账户:coin_cash(金币现金) / invite_cash(邀请奖励金)")
|
||||
user_name: str | None = Field(None, description="实名(达额时微信要求,可空)")
|
||||
out_bill_no: str | None = Field(
|
||||
None, description="客户端幂等键(商户单号):同号重试不重复转账。不传则服务端生成"
|
||||
@@ -168,7 +166,6 @@ class WithdrawOrderOut(BaseModel):
|
||||
id: int
|
||||
out_bill_no: str
|
||||
amount_cents: int
|
||||
source: str = Field("coin_cash", description="提现账户:coin_cash / invite_cash")
|
||||
status: str = Field(..., description="reviewing(待审核) / pending / success / failed / rejected")
|
||||
wechat_state: str | None = None
|
||||
fail_reason: str | None = None
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 847 KiB |
+117
-233
@@ -8,231 +8,122 @@
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
|
||||
html,body { height:100%; }
|
||||
button { border:0; background:none; color:inherit; font:inherit; cursor:pointer; }
|
||||
body {
|
||||
min-height:100vh;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
background:#000;
|
||||
font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Helvetica Neue",sans-serif;
|
||||
font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
|
||||
background:linear-gradient(165deg,#FF7A3D 0%,#FF3B30 52%,#E0245E 100%);
|
||||
color:#fff; min-height:100%; display:flex; flex-direction:column;
|
||||
align-items:center; justify-content:center; padding:40px 26px; text-align:center;
|
||||
overflow-x:hidden;
|
||||
}
|
||||
/* 设备框:桌面预览成 375×667 卡片;真机(≤430)铺满全屏 */
|
||||
.device {
|
||||
position:relative; flex:0 0 auto;
|
||||
width:375px; height:667px;
|
||||
overflow:hidden; border-radius:32px;
|
||||
background:#FFF4CD;
|
||||
box-shadow:0 20px 60px rgba(0,0,0,.5);
|
||||
color:#fff;
|
||||
.logo {
|
||||
width:104px; height:104px; border-radius:26px; background:#fff;
|
||||
display:flex; align-items:center; justify-content:center; font-size:52px;
|
||||
box-shadow:0 14px 34px rgba(0,0,0,.22); margin-bottom:24px;
|
||||
}
|
||||
.download-landing {
|
||||
position:absolute; inset:0; z-index:0; overflow:hidden;
|
||||
background:
|
||||
url('coupon-page-bg.png') center center / cover no-repeat,
|
||||
#FFF4CD;
|
||||
color:#1A1A1A; text-align:center;
|
||||
}
|
||||
.download-content {
|
||||
position:relative; z-index:1; height:100%;
|
||||
padding:82px 20px 0;
|
||||
display:flex; flex-direction:column; align-items:center; overflow:hidden;
|
||||
}
|
||||
.brand-lockup {
|
||||
display:flex; align-items:center; justify-content:center; gap:16px;
|
||||
}
|
||||
.ad-logo {
|
||||
width:53px; height:53px; border-radius:13px; display:block;
|
||||
box-shadow:0 10px 24px rgba(255,179,0,.24);
|
||||
}
|
||||
.ad-title {
|
||||
color:#1A1A1A; font-size:32px; font-weight:800; line-height:1.15;
|
||||
letter-spacing:0; white-space:nowrap;
|
||||
}
|
||||
.ad-subtitle {
|
||||
margin-top:22px; max-width:100%;
|
||||
color:#000; font-size:18px; font-weight:400; line-height:1.25;
|
||||
display:flex; align-items:center; justify-content:center; gap:10px; white-space:nowrap;
|
||||
}
|
||||
.ad-subtitle::before, .ad-subtitle::after {
|
||||
content:""; width:5px; height:5px; border-radius:50%; background:#000; flex:0 0 auto;
|
||||
}
|
||||
.bottom-area {
|
||||
position:relative; z-index:2; width:100%; max-width:266px;
|
||||
margin-top:11px;
|
||||
display:grid; grid-template-columns:1fr; justify-items:stretch; align-content:center; gap:10px;
|
||||
}
|
||||
.download-btn {
|
||||
width:100%; height:39px; border-radius:22px; color:#1A1A1A;
|
||||
font-family:inherit; font-size:14px; line-height:1; font-weight:700; letter-spacing:0;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
.download-btn.primary {
|
||||
background:linear-gradient(180deg,#FFE066 0%,#FFC400 100%);
|
||||
box-shadow:inset 0 1px 0 rgba(255,255,255,.82), 0 7px 18px rgba(255,179,0,.24);
|
||||
}
|
||||
.download-btn.secondary {
|
||||
background:#fff; border:1px solid #DDD; color:#1A1A1A; font-size:13px;
|
||||
box-shadow:0 2px 8px rgba(122,79,0,.08);
|
||||
}
|
||||
.download-btn:active { transform:translateY(1px); }
|
||||
/* 底部两条卖点文案,压在背景插画两张卡片下方 */
|
||||
/* 卖点卡:CSS 实体卡片(白底+图标),不再靠底图死框,字自适应(对齐 WeChat.html PR siyi 改版)*/
|
||||
.download-feature-card {
|
||||
position:absolute; z-index:2; pointer-events:none;
|
||||
height:62px; padding:0 10px; border-radius:22px;
|
||||
background:#FFFAEE;
|
||||
box-shadow:inset 0 1px 0 rgba(255,255,255,.86), 0 6px 14px rgba(122,79,0,.08);
|
||||
display:flex; align-items:center; gap:8px; color:#1A1A1A;
|
||||
}
|
||||
.download-feature-card.left { left:24px; top:576px; width:146px; }
|
||||
.download-feature-card.right { left:199px; top:576px; width:156px; }
|
||||
.download-feature-icon {
|
||||
width:27px; height:27px; flex:0 0 auto; display:block; color:#FFAE00;
|
||||
}
|
||||
.download-feature-icon svg {
|
||||
display:block; width:100%; height:100%; filter:drop-shadow(0 1px 0 rgba(255,255,255,.7));
|
||||
}
|
||||
.download-feature-copy {
|
||||
min-width:0; flex:1 1 auto; text-align:left; white-space:nowrap; letter-spacing:0;
|
||||
}
|
||||
.download-feature-title {
|
||||
display:block; font-size:13px; font-weight:800; line-height:1.12; letter-spacing:0;
|
||||
}
|
||||
.download-feature-desc {
|
||||
display:block; margin-top:5px; color:#5A3A00; font-size:10px; font-weight:400; line-height:1.1;
|
||||
h1 { font-size:30px; font-weight:800; letter-spacing:1px; }
|
||||
.slogan { margin-top:12px; font-size:16px; line-height:1.7; opacity:.95; max-width:300px; }
|
||||
.feats { margin-top:26px; display:flex; flex-direction:column; gap:12px; width:100%; max-width:320px; }
|
||||
.feat { background:rgba(255,255,255,.16); border-radius:14px; padding:13px 16px; font-size:15px; display:flex; align-items:center; gap:10px; }
|
||||
.feat b { font-weight:700; }
|
||||
.btn {
|
||||
margin-top:34px; width:100%; max-width:320px; border:none; cursor:pointer;
|
||||
background:#fff; color:#FF3B30; font-size:19px; font-weight:800;
|
||||
padding:17px 0; border-radius:999px; box-shadow:0 10px 26px rgba(0,0,0,.22);
|
||||
display:flex; align-items:center; justify-content:center; gap:9px;
|
||||
}
|
||||
.btn:active { transform:translateY(1px); opacity:.92; }
|
||||
.hint { margin-top:16px; font-size:13px; opacity:.85; }
|
||||
.foot { margin-top:30px; font-size:12px; opacity:.6; line-height:1.6; max-width:320px; }
|
||||
|
||||
/* 微信内"去浏览器打开"引导蒙层 */
|
||||
.download-guide-layer {
|
||||
position:absolute; inset:0; z-index:30; display:none;
|
||||
background:rgba(0,0,0,.85); color:#fff; /* 半透明黑:透出底层(新版)下载页,隐约可见 */
|
||||
}
|
||||
.download-guide-layer.show { display:block; }
|
||||
.download-guide-arrow {
|
||||
position:absolute; top:17px; right:9px; width:80px; height:60px;
|
||||
}
|
||||
.download-guide-arrow-svg {
|
||||
display:block; width:100%; height:100%; overflow:hidden; shape-rendering:geometricPrecision;
|
||||
}
|
||||
.download-guide-title {
|
||||
position:absolute; top:129px; right:16px; width:218px; margin:0;
|
||||
color:#fff; font-size:17px; font-weight:800; line-height:1.32; letter-spacing:0;
|
||||
text-align:right; text-shadow:0 2px 8px rgba(0,0,0,.36);
|
||||
}
|
||||
.download-guide-title .guide-dots { color:#FFD95A; letter-spacing:4px; }
|
||||
.download-guide-title .guide-highlight { color:#FFD95A; white-space:nowrap; }
|
||||
.download-guide-title .guide-final {
|
||||
display:block; margin-top:10px; color:rgba(255,255,255,.94);
|
||||
font-size:13px; font-weight:700; line-height:1.38;
|
||||
}
|
||||
.download-guide-title .guide-target { color:#FFD95A; white-space:nowrap; }
|
||||
.guide-dismiss {
|
||||
position:absolute; bottom:30px; left:0; right:0; text-align:center;
|
||||
font-size:14px; color:#fff; opacity:.75;
|
||||
}
|
||||
.toast {
|
||||
position:absolute; left:50%; bottom:118px; z-index:20;
|
||||
transform:translateX(-50%) translateY(12px);
|
||||
padding:9px 14px; border-radius:12px; background:rgba(0,0,0,.78);
|
||||
color:#fff; font-size:14px; opacity:0; pointer-events:none;
|
||||
transition:opacity .2s ease, transform .2s ease;
|
||||
}
|
||||
.toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
|
||||
@media (max-width:430px) {
|
||||
/* 真机:宽满屏、高按 375:667 锁比例(不拉伸变形),顶对齐、底部留白用底色填。
|
||||
这样底图(含价格卡)与卖点卡片同处 667 坐标基准,卡片用回 top:576,不再相互错位/遮挡。 */
|
||||
body { align-items:flex-start; background:#FFF4CD; }
|
||||
.device { width:100vw; height:calc(100vw * 667 / 375); border-radius:0; box-shadow:none; }
|
||||
#wxmask {
|
||||
display:none; position:fixed; inset:0; z-index:9999;
|
||||
background:rgba(0,0,0,.86); padding:18px;
|
||||
}
|
||||
#wxmask.show { display:block; }
|
||||
.arrow { position:absolute; top:8px; right:14px; width:120px; }
|
||||
.wxtip { position:absolute; top:150px; right:18px; left:18px; text-align:right; }
|
||||
.wxtip .big { font-size:21px; font-weight:800; line-height:1.5; }
|
||||
.wxtip .big em { color:#FFD24D; font-style:normal; }
|
||||
.wxtip .sub { margin-top:14px; font-size:15px; line-height:1.8; opacity:.9; }
|
||||
.wxsteps { margin-top:26px; text-align:left; background:rgba(255,255,255,.1); border-radius:14px; padding:18px 18px; font-size:15px; line-height:2; }
|
||||
.wxsteps .n { display:inline-block; width:22px; height:22px; line-height:22px; text-align:center; border-radius:50%; background:#FFD24D; color:#333; font-weight:800; font-size:13px; margin-right:8px; }
|
||||
.closebar { position:absolute; bottom:30px; left:0; right:0; text-align:center; font-size:14px; opacity:.7; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="device" aria-label="傻瓜比价下载页">
|
||||
<section class="download-landing" aria-label="傻瓜比价下载页">
|
||||
<div class="download-content">
|
||||
<div class="brand-lockup">
|
||||
<img class="ad-logo" src="sb-brand.png" alt="傻瓜比价">
|
||||
<h1 class="ad-title">傻瓜比价</h1>
|
||||
</div>
|
||||
<p class="ad-subtitle">跨平台比价,用傻瓜</p>
|
||||
<div class="bottom-area" aria-label="下载入口">
|
||||
<button class="download-btn primary" id="dlbtn" type="button">应用商店下载</button>
|
||||
<button class="download-btn secondary" id="dlbtn2" type="button">官网下载</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="download-feature-card left" aria-label="优惠券轻松领,羊毛全都不错过">
|
||||
<span class="download-feature-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 32" focusable="false">
|
||||
<path d="M5 5h30a3 3 0 0 1 3 3v5.2a4.8 4.8 0 0 0 0 9.6V24a3 3 0 0 1-3 3H5a3 3 0 0 1-3-3v-1.2a4.8 4.8 0 0 0 0-9.6V8a3 3 0 0 1 3-3Z" fill="currentColor"/>
|
||||
<path d="M20 10v12" fill="none" stroke="#FFF7CF" stroke-width="3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="download-feature-copy">
|
||||
<span class="download-feature-title">优惠券轻松领</span>
|
||||
<span class="download-feature-desc">羊毛全都不错过</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="download-feature-card right" aria-label="一键全网找底价,再也不用费力切屏">
|
||||
<span class="download-feature-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 32" focusable="false">
|
||||
<rect x="5" y="16" width="8" height="11" rx="2" fill="currentColor"/>
|
||||
<rect x="16" y="9" width="8" height="18" rx="2" fill="currentColor"/>
|
||||
<rect x="27" y="3" width="8" height="24" rx="2" fill="currentColor"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="download-feature-copy">
|
||||
<span class="download-feature-title">一键全网找底价</span>
|
||||
<span class="download-feature-desc">再也不用费力切屏</span>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
<div class="logo">🛒</div>
|
||||
<h1>傻瓜比价</h1>
|
||||
<div class="slogan">买什么都先比一比<br>自动帮你找全网最低价</div>
|
||||
|
||||
<!-- 微信内引导:跳出微信去浏览器 -->
|
||||
<div class="download-guide-layer" id="wxGuide" role="dialog" aria-modal="true" aria-labelledby="wxGuideTitle">
|
||||
<div class="download-guide-arrow" aria-hidden="true">
|
||||
<svg class="download-guide-arrow-svg" viewBox="0 0 80 60" focusable="false">
|
||||
<path d="M0 60 C16 32 39 15 66 15" fill="none" stroke="#FFD95A" stroke-width="5.5" stroke-linecap="round"/>
|
||||
<path d="M59 5 L77 14 L63 31" fill="none" stroke="#FFD95A" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="download-guide-title" id="wxGuideTitle">
|
||||
点击右上角 <span class="guide-dots">···</span><br>
|
||||
选择「<span class="guide-highlight">在浏览器打开</span>」
|
||||
<span class="guide-final">在浏览器里按提示<span class="guide-target">去应用商店下载</span></span>
|
||||
</h2>
|
||||
<!-- <div class="guide-dismiss" id="wxGuideDismiss">我知道了 ✕</div> -->
|
||||
<div class="feats">
|
||||
<div class="feat">🍔 <span>点外卖前一键比价,<b>美团/京东/淘宝</b>到手价一目了然</span></div>
|
||||
<div class="feat">🎟️ <span>自动领遍各平台<b>红包券</b>,能省的一分不漏</span></div>
|
||||
<div class="feat">💰 <span>省下的钱看得见,还能<b>赚金币提现</b></span></div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||
</main>
|
||||
<button class="btn" id="dlbtn">🏪 打开应用商店下载</button>
|
||||
<div class="hint" id="hint">Android 安卓版 · 应用商店安全下载</div>
|
||||
|
||||
<div class="foot">
|
||||
将前往应用商店下载,安全放心。<br>
|
||||
本页为内部测试页。
|
||||
</div>
|
||||
|
||||
<!-- 微信内引导:跳出微信去浏览器 -->
|
||||
<div id="wxmask">
|
||||
<svg class="arrow" viewBox="0 0 120 130" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M30 120 C 30 70, 55 40, 95 28" stroke="#FFD24D" stroke-width="6" stroke-linecap="round" fill="none" stroke-dasharray="2 13"/>
|
||||
<path d="M95 28 L 78 30 M95 28 L 92 46" stroke="#FFD24D" stroke-width="6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div class="wxtip">
|
||||
<div class="big">点击右上角 <em>···</em><br>选择「<em>在浏览器打开</em>」</div>
|
||||
<div class="sub">微信里无法直接下载安装包<br>需在系统浏览器中完成下载</div>
|
||||
<div class="wxsteps">
|
||||
<div><span class="n">1</span>点右上角的 ··· 菜单</div>
|
||||
<div><span class="n">2</span>选择「在浏览器打开」</div>
|
||||
<div><span class="n">3</span>在浏览器里按提示去应用商店下载</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="closebar" id="wxclose">我知道了 ✕</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ===== 应用商店跳转:用包名(App 唯一标识)定位到傻瓜比价的商店下载页 =====
|
||||
// 主:market:// 唤起手机自带应用市场(华为/小米/OV);兜底:应用宝网页(任何浏览器都能开)。
|
||||
// 应用商店跳转:用包名(App 唯一标识 = 身份证号)定位到傻瓜比价的商店下载页。
|
||||
// 主:market:// 唤起手机自带应用市场(华为/小米/OV);兜底:应用宝网页(任何浏览器都能开)。
|
||||
var PKG = "com.jishisongfu.shaguabijia";
|
||||
var MARKET_URL = "market://details?id=" + PKG;
|
||||
var YYB_URL = "https://a.app.qq.com/o/simple.jsp?pkgname=" + PKG;
|
||||
var ua = navigator.userAgent || "";
|
||||
var isWeChat = /MicroMessenger/i.test(ua);
|
||||
var isIOS = /iPhone|iPad|iPod/i.test(ua);
|
||||
var ref = new URLSearchParams(location.search).get("ref"); // 邀请码(来自二维码 URL ?ref=)
|
||||
var isAndroid = /Android/i.test(ua);
|
||||
var ref = new URLSearchParams(location.search).get("ref"); // 邀请码(来自二维码 URL ?ref=)
|
||||
|
||||
// ===== 指纹归因兜底:页面加载即上报访问者指纹,后端存 invite_fingerprint 表 =====
|
||||
// 当 APK 首启读剪贴板失败(被覆盖)时,客户端用 (IP+屏幕+UA 机型) 反查 7 天内最近一条 → 撞出原邀请人。
|
||||
// 任何失败都 silent,不影响下载主流程。
|
||||
// 【任务 3】指纹归因兜底:页面加载即上报访问者指纹,后端存 invite_fingerprint 表。
|
||||
// 当 APK 首启读剪贴板失败(被覆盖)时,客户端会用 (IP+屏幕+UA 解析的手机型号) 反查
|
||||
// 本表 7 天内最近一条匹配 → 撞库出原邀请人 → 走原 bind 流程。
|
||||
// 任何失败都 silent(不影响下载主流程);后端 invalid_code/no_ip 也只返 200。
|
||||
if (ref) {
|
||||
// screen 报【物理像素】= CSS 像素 × devicePixelRatio,跟 Android dm.widthPixels 对齐,否则撞不上库。
|
||||
// screen 报【物理像素】= CSS 像素 × devicePixelRatio,跟 Android dm.widthPixels(物理像素)对齐。
|
||||
// 不同设备 DPR 不同(常见 2/2.5/3/3.5),CSS 像素直接报会跟客户端不对齐 → 撞不上库。
|
||||
var _dpr = window.devicePixelRatio || 1;
|
||||
var _sw = Math.round(screen.width * _dpr);
|
||||
var _sh = Math.round(screen.height * _dpr);
|
||||
fetch("/api/v1/invite/landing-track", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ref: ref, screen: _sw + "x" + _sh }),
|
||||
}).catch(function () {}); // silent,绝不阻断下载
|
||||
body: JSON.stringify({
|
||||
ref: ref,
|
||||
screen: _sw + "x" + _sh,
|
||||
// IP / UA 服务端从 HTTP 头自动拿,无需 JS 上报
|
||||
}),
|
||||
}).catch(function () {}); // silent,绝不阻断下载
|
||||
}
|
||||
|
||||
// ===== 把邀请码写进剪贴板,APK 首启读出完成归因(deferred deeplink)=====
|
||||
// 浏览器要求:必须在用户点击手势里调用 + HTTPS 下才允许写。
|
||||
function legacyCopy(payload) { // 老 webview / 无 clipboard API 兜底
|
||||
// 把邀请码写进剪贴板,APK 首启时读出来完成归因(deferred deeplink 的关键一步)。
|
||||
// 浏览器要求:必须在用户点击手势里调用 + HTTPS 下才允许写。
|
||||
function legacyCopy(payload) { // 老 webview / 无 clipboard API 兜底
|
||||
try {
|
||||
var ta = document.createElement("textarea");
|
||||
ta.value = payload; ta.style.position = "fixed"; ta.style.top = "-1000px"; ta.style.opacity = "0";
|
||||
@@ -240,57 +131,50 @@
|
||||
document.execCommand("copy"); document.body.removeChild(ta);
|
||||
} catch (e) {}
|
||||
}
|
||||
function copyInviteCode() { // 返回 Promise,完成后才下载,避免异步写入被打断
|
||||
function copyInviteCode() { // 返回 Promise(完成后才下载,避免异步写入被打断)
|
||||
if (!ref) return Promise.resolve();
|
||||
var payload = "SGBJ_INVITE:" + ref;
|
||||
legacyCopy(payload); // 同步兜底:在用户手势内立刻 execCommand 写一次(最可靠)
|
||||
legacyCopy(payload); // 同步兜底:在用户手势内立刻 execCommand 写一次(最可靠)
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
return navigator.clipboard.writeText(payload).catch(function () {});
|
||||
return navigator.clipboard.writeText(payload).catch(function () {}); // 现代 API 锦上添花,失败无妨
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
// 跳应用商店:先 market:// 唤起自带商店;2.5s 内页面没切后台 → 兜底跳应用宝网页,不让用户卡死。
|
||||
// 跳应用商店:先尝试 market:// 唤起手机自带商店;若 2.5s 内页面没切到后台
|
||||
//(= 没有商店接管 market://),兜底跳应用宝网页下载页,不让用户卡死。
|
||||
function openStore() {
|
||||
var jumped = false;
|
||||
document.addEventListener("visibilitychange", function () { if (document.hidden) jumped = true; });
|
||||
window.addEventListener("pagehide", function () { jumped = true; });
|
||||
window.addEventListener("blur", function () { jumped = true; });
|
||||
window.location.href = MARKET_URL;
|
||||
setTimeout(function () { if (!jumped) window.location.href = YYB_URL; }, 2500);
|
||||
function markJumped() { jumped = true; } // 页面切到后台 = 商店已唤起
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (document.hidden) jumped = true;
|
||||
});
|
||||
window.addEventListener("pagehide", markJumped);
|
||||
window.addEventListener("blur", markJumped);
|
||||
window.location.href = MARKET_URL; // 唤起自带应用市场
|
||||
setTimeout(function () {
|
||||
if (!jumped) window.location.href = YYB_URL; // 没唤起 → 应用宝网页兜底
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
// ===== 微信内引导蒙层 =====
|
||||
var wxGuide = document.getElementById("wxGuide");
|
||||
function showWxGuide() { wxGuide.classList.add("show"); }
|
||||
function hideWxGuide() { wxGuide.classList.remove("show"); }
|
||||
// document.getElementById("wxGuideDismiss").addEventListener("click", hideWxGuide); // 「我知道了 ✕」已注释隐藏
|
||||
if (isWeChat) showWxGuide(); // 微信里一进页面就提示去浏览器(微信内下载必被拦)
|
||||
var hint = document.getElementById("hint");
|
||||
if (isIOS) hint.textContent = "检测到 iPhone · iOS 版请前往 App Store";
|
||||
|
||||
function showToast(text) {
|
||||
var toast = document.getElementById("toast");
|
||||
toast.textContent = text; toast.classList.add("show");
|
||||
clearTimeout(showToast.timer);
|
||||
showToast.timer = setTimeout(function () { toast.classList.remove("show"); }, 1400);
|
||||
}
|
||||
var mask = document.getElementById("wxmask");
|
||||
function showMask(){ mask.classList.add("show"); }
|
||||
function hideMask(){ mask.classList.remove("show"); }
|
||||
document.getElementById("wxclose").addEventListener("click", hideMask);
|
||||
|
||||
// ===== 应用商店下载:微信内引导去浏览器;iOS 提示;安卓写邀请码 + 跳应用商店 =====
|
||||
function handleDownload() {
|
||||
if (isWeChat) { showWxGuide(); return; }
|
||||
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
|
||||
copyInviteCode(); // 先把邀请码写进剪贴板(供 App 首启归因),链路丢了还有 landing-track 指纹兜底
|
||||
// 微信里一进页面就提示去浏览器打开(下载在微信内必被拦)
|
||||
if (isWeChat) showMask();
|
||||
|
||||
document.getElementById("dlbtn").addEventListener("click", function(){
|
||||
if (isWeChat) { showMask(); return; } // 微信内:引导去浏览器
|
||||
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
|
||||
// 安卓浏览器:先把邀请码写进剪贴板(legacyCopy 同步写、最可靠),再跳应用商店。
|
||||
// 剪贴板供 App 首启归因;链路长易丢时由指纹兜底(已上报 landing-track)接住。
|
||||
copyInviteCode();
|
||||
openStore();
|
||||
}
|
||||
// ===== 官网下载:微信/iOS 同上引导;安卓直接跳 APK 直链 → 弹系统下载弹窗 =====
|
||||
// APK_URL 跟随页面 host:本地走 LAN、生产走 app-api.shaguabijia.com(见邀请功能文档约定)。
|
||||
var APK_URL = location.origin + "/media/shaguabijia.apk";
|
||||
function handleWebsiteDownload() {
|
||||
if (isWeChat) { showWxGuide(); return; }
|
||||
if (isIOS) { alert("iOS 版即将上线,请前往 App Store 搜索「傻瓜比价」"); return; }
|
||||
copyInviteCode(); // 同样先写邀请码进剪贴板(供 App 首启归因)
|
||||
window.location.href = APK_URL;
|
||||
}
|
||||
document.getElementById("dlbtn").addEventListener("click", handleDownload);
|
||||
document.getElementById("dlbtn2").addEventListener("click", handleWebsiteDownload);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB |
@@ -1,67 +0,0 @@
|
||||
# 穿山甲 GroMore 收益拉取 定时任务 — 运维手册
|
||||
|
||||
> 对象:维护「每天拉穿山甲后台收益入库」这套定时任务的同事。
|
||||
> 🔒 服务器登录信息见**私密交接清单**,不入库。
|
||||
|
||||
## 它是什么
|
||||
admin「广告收益报表」里的「穿山甲后台收益(T+1)」读的是**本地表 `ad_pangle_daily_revenue` 的快照,不是实时查穿山甲**。穿山甲只通过 GroMore 数据 API 给数、且 **T+1**(次日约 10:00 出昨天的数),所以每天得拉一次入库,报表才会往前走。
|
||||
|
||||
- 每天 10:30 跑一轮 `scripts/sync_pangle_revenue.py`,默认 `--days 3` 回补近 3 天。
|
||||
- 维度 = 日期 × 应用(site_id)× 广告位(ad_unit_id);指标 = `revenue`(预估)+ `api_revenue`(结算口径)。
|
||||
- **幂等 upsert**:同一(日期×应用×代码位)重跑只覆盖、不重复,故回补 / 重跑 / catch-up 都安全。
|
||||
- 穿山甲无用户/设备维度 → 只能落「汇总/趋势级」,报表带 user_id 过滤时这块收益置空(显示「-」)。
|
||||
|
||||
## 文件
|
||||
| 项 | 路径 |
|
||||
|---|---|
|
||||
| 脚本入口 | `scripts/sync_pangle_revenue.py` |
|
||||
| 拉取 / 签名 | `app/integrations/pangle_report.py`(签名=参数字典序拼接+secure_key 后 MD5) |
|
||||
| 入库表 | `ad_pangle_daily_revenue`(读写在 `app/repositories/ad_pangle_revenue.py`) |
|
||||
| 凭证 | `.env` 的 `PANGLE_REPORT_USER_ID` / `PANGLE_REPORT_ROLE_ID` / `PANGLE_REPORT_SECURITY_KEY` |
|
||||
| 应用映射 | `.env` 的 `PANGLE_REPORT_SITE_ID_PROD`(5830519)/ `PANGLE_REPORT_SITE_ID_TEST`(5832303) |
|
||||
| systemd 单元 | `deploy/pangle-revenue.{service,timer}` |
|
||||
|
||||
## 上线前置(只做一次)
|
||||
1. **填凭证**:后台「接入中心 → GroMore-API」领 user_id / role_id / Security Key(`secure_key`,**≠ 发奖 m-key**),填进线上 `.env` 的 `PANGLE_REPORT_*`。三项填齐脚本才工作,缺任一 → no-op 退出。
|
||||
2. **子账号要授权**:线上若用子账号(role_id ≠ user_id),需主账号给它授「查看全部数据」,否则接口报 **118**(无权限)。本人自查自己(user_id=role_id)无此问题。
|
||||
3. 凭证泄露:后台「重置 key 值」即可,改完同步线上 `.env` 重跑。
|
||||
|
||||
## 部署(Linux 服务器,需 root)
|
||||
```bash
|
||||
sudo cp deploy/pangle-revenue.{service,timer} /etc/systemd/system/
|
||||
sudo systemctl daemon-reload && sudo systemctl enable --now pangle-revenue.timer
|
||||
systemctl list-timers pangle-revenue.timer # 确认下次触发时间(应是次日 10:30)
|
||||
```
|
||||
|
||||
## 怎么看健康 / 手动跑一次
|
||||
```bash
|
||||
sudo systemctl start pangle-revenue.service # 立即手动跑一轮(不等 10:30)
|
||||
journalctl -u pangle-revenue -n 30 --no-pager # 看日志:拉取区间 / 入库行数 / 新增更新 / 预估收益合计
|
||||
```
|
||||
成功日志形如:`✅ 完成:接口 N 行 → 入库 M 行(跳过 x),新增 a / 更新 b;预估收益合计 ¥19.42`。
|
||||
> 看不到收益、提示 `PANGLE_REPORT_* 未配置`→ 回「上线前置」补 `.env`;报 118 → 子账号没授「查看全部数据」。
|
||||
|
||||
## 本机 Windows 开发(无 systemd)
|
||||
直接手动跑:
|
||||
```
|
||||
.venv\Scripts\python -m scripts.sync_pangle_revenue # 拉昨天
|
||||
.venv\Scripts\python -m scripts.sync_pangle_revenue --days 3 # 回补近 3 天
|
||||
.venv\Scripts\python -m scripts.sync_pangle_revenue --date 2026-06-27 # 指定单天
|
||||
.venv\Scripts\python -m scripts.sync_pangle_revenue --start 2026-06-01 --end 2026-06-27 # 区间回补
|
||||
```
|
||||
|
||||
## 脚本参数
|
||||
- 无参:拉**昨天**(北京时间)。timer 用的是 `--days 3`。
|
||||
- `--days N`:从昨天起往前回补 N 天(含昨天)。
|
||||
- `--date YYYY-MM-DD`:指定单天。
|
||||
- `--start / --end`:指定闭区间(跨度 ≤ 31 天,接口上限 1 个月,超了报 114)。
|
||||
|
||||
## 注意事项
|
||||
- **触发时间**:`OnCalendar=*-*-* 10:30:00`。穿山甲 ~10:00 出数,故别早于 10:00 跑(会拉到空/不全)。
|
||||
- **catch-up**:`Persistent=true` 补跑错过的那一轮;叠加 `--days 3`,漏一两天重新触发即自愈。
|
||||
- **今天 / 今天以前要分开查**:脚本默认只拉昨天及更早,不混查今天(接口约束),无需关心。
|
||||
- **join key 是 `ad_unit_id`(我们配的 104xxx)不是 `code_id`**:`code_id` 是底层各 ADN 代码位,对不上口径;`ad_unit_id='-1'` 是未归因桶。改维度时务必注意(详见脚本头注释)。
|
||||
- **`api_revenue` 很稀疏**:测试应用 ADN 没配 Reporting → 全 0,仅 prod 个别位有;`revenue`(预估)才是稳的主力。
|
||||
- **DB 无关**:sqlite / postgres 均可(upsert 逐行 select-then-write,不像美团 ETL 需要 PG)。
|
||||
- **别和别的触发方式双跑**:本 systemd timer 与「手动 cron / 进程内任务」二选一,虽幂等不会重复入库,纯属多余。
|
||||
- **改脚本 / 改部署**:走 git + PR,由有 root 的人部署。
|
||||
@@ -1,38 +0,0 @@
|
||||
# 每天拉穿山甲 GroMore T+1 天级收益入库 —— 单轮跑,由 pangle-revenue.timer 每天 10:30 触发。
|
||||
# 落 ad_pangle_daily_revenue 表,供 admin 广告收益报表的「穿山甲后台收益(T+1)」区块。
|
||||
#
|
||||
# 仅用于 Linux 服务器;本机 Windows 开发无 systemd,直接手动跑脚本即可:
|
||||
# .venv\Scripts\python -m scripts.sync_pangle_revenue # 拉昨天(北京时间)
|
||||
# .venv\Scripts\python -m scripts.sync_pangle_revenue --days 3 # 回补近 3 天
|
||||
#
|
||||
# 部署(服务器):
|
||||
# sudo cp deploy/pangle-revenue.{service,timer} /etc/systemd/system/
|
||||
# sudo systemctl daemon-reload && sudo systemctl enable --now pangle-revenue.timer
|
||||
# # 手动跑一次验证: sudo systemctl start pangle-revenue.service && journalctl -u pangle-revenue -n 30
|
||||
#
|
||||
# 前置:.env 配好 PANGLE_REPORT_USER_ID / PANGLE_REPORT_ROLE_ID / PANGLE_REPORT_SECURITY_KEY
|
||||
# (后台「接入中心 → GroMore-API」领;子账号需主账号授「查看全部数据」否则接口 118)。
|
||||
# 未配齐这三项 → 脚本自动 no-op 退出,免动 timer。
|
||||
[Unit]
|
||||
Description=Sync Pangle GroMore daily revenue (T+1, one-shot, driven by timer)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
WorkingDirectory=/opt/shaguabijia-app-server
|
||||
Environment="PATH=/opt/shaguabijia-app-server/.venv/bin:/usr/bin:/bin"
|
||||
EnvironmentFile=/opt/shaguabijia-app-server/.env
|
||||
# 默认拉昨天;--days 3 回补近 3 天(幂等 upsert,应对偶发漏跑 + 穿山甲对历史数据订正,重跑无害)。
|
||||
ExecStart=/opt/shaguabijia-app-server/.venv/bin/python -m scripts.sync_pangle_revenue --days 3
|
||||
SyslogIdentifier=pangle-revenue
|
||||
# 仅几个 HTTP 请求 + 小批量入库,通常数秒;给 10min 硬超时防穿山甲接口卡死。
|
||||
TimeoutStartSec=600
|
||||
|
||||
# 与主服务 shaguabijia-app-server.service 同款加固。
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/opt/shaguabijia-app-server
|
||||
ProtectHome=true
|
||||
@@ -1,14 +0,0 @@
|
||||
# 每天 10:30 触发一次穿山甲 GroMore T+1 收益拉取入库(Linux 服务器用)。
|
||||
# 见 pangle-revenue.service 顶部注释的部署步骤。
|
||||
[Unit]
|
||||
Description=Run Pangle GroMore daily revenue sync at 10:30
|
||||
|
||||
[Timer]
|
||||
# 穿山甲 T+1、次日约 10:00 出数;10:30 触发留 30min 余量。要错开整点扎堆可微调到 10:35。
|
||||
OnCalendar=*-*-* 10:30:00
|
||||
# 服务器宕机/重启后,补跑错过的那一轮(而不是干等次日);叠加 --days 3 回补,漏一两天能自愈。
|
||||
Persistent=true
|
||||
AccuracySec=1min
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -27,11 +27,8 @@
|
||||
| `date_to` | string | =`date_from` | 结束日 北京时间 `YYYY-MM-DD`,**闭区间**;单日时与 `date_from` 相同 |
|
||||
| `user_id` | int | 全部 | 只看某用户;不传=所有用户 |
|
||||
| `ad_type` | string | 全部 | `reward_video` / `feed` / `draw`;不传=全部类型 |
|
||||
| `feed_scene` | string | 全部 | `comparison`(比价)/ `coupon`(领券)/ `welfare`(福利);**全局筛选**,同时作用于明细 / 合计 / `daily`·`hourly` 趋势;不传=全部场景 |
|
||||
| `granularity` | string | `day` | `day`=按天 / `hour`=按小时(聚合键再加北京时间小时 0–23);**区间>1 天建议用 day** |
|
||||
| `limit` | int(1~1000) | 500 | **每页条数**(分页大小);`total`/`total_*`/`daily`/`hourly` 按全量统计不受分页影响 |
|
||||
| `offset` | int(≥0) | 0 | 分页偏移(已跳过条数)=(页码−1)×`limit` |
|
||||
| `sort` | string | `time` | 明细排序:`time`=按时间倒序(新→旧) / `ecpm`=按 eCPM 数值倒序 |
|
||||
| `limit` | int(1~1000) | 500 | **展示**明细组数(截断;`total`/`total_*`/`daily` 按全量统计不受影响) |
|
||||
|
||||
约束:`date_to` 不早于 `date_from`、区间最长 **92 天**、日期须 `YYYY-MM-DD`,否则 `422`。
|
||||
|
||||
@@ -39,48 +36,25 @@
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `date_from` / `date_to` | string | 报表起止日期(闭区间) |
|
||||
| `daily` | `AdRevenueDaily[]` | 按日期汇总序列(全量,供按天趋势图;不受分页影响) |
|
||||
| `hourly` | `AdRevenueHourly[]` | 按小时汇总序列(全量,供按小时趋势图;**仅 `granularity=hour` 时非空**;不受分页影响) |
|
||||
| `type_stats` | `{[ad_type]: AdRevenueTypeStat}` | 按广告类型(`ad_type`)小计(全量);前端取 `draw` / `reward_video` 做分类大盘 |
|
||||
| `dau` | int \| null | 今日活跃用户数(复用大盘口径 `last_login_at`,今日登录过);**仅查询=今日单天时有值**,历史/多天为 `null` |
|
||||
| `total` | int | 当前筛选下的**分页总条数**(全量,不受分页影响;= 前端分页器 total) |
|
||||
| `truncated` | bool | 当前页之后是否还有更多事件(`len(events) > offset + limit`) |
|
||||
| `daily` | `AdRevenueDaily[]` | 按日期汇总序列(全量,供按天趋势图;不受 `limit` 影响) |
|
||||
| `total` | int | 聚合组**总数**(全量,不受 `limit` 影响) |
|
||||
| `truncated` | bool | 明细是否被 `limit` 截断 |
|
||||
| `total_impressions` | int | 全量展示条数合计 |
|
||||
| `total_revenue_yuan` | float | 全量**客户端预估**收益合计(元;eCPM 折算) |
|
||||
| `total_pangle_revenue_yuan` | float \| null | 全量**穿山甲后台预估**收益合计(元;GroMore `revenue`)。穿山甲无用户/类型/场景维度,**仅全量视图**(未按 `user_id`/`ad_type`/`feed_scene` 过滤)有值,否则 `null` |
|
||||
| `total_pangle_api_revenue_yuan` | float \| null | 全量**穿山甲收益Api**合计(元;GroMore `api_revenue`,各 ADN 回传更接近结算);未配 Reporting / 查当天 / 非全量视图为 `null` |
|
||||
| `pangle_revenue_available` | bool | 本次是否带穿山甲后台收益(=全量视图且对应日期已同步到 `ad_pangle_daily_revenue`) |
|
||||
| `total_revenue_yuan` | float | 全量收益合计(元) |
|
||||
| `total_expected_coin` | int | 全量应发金币合计 |
|
||||
| `total_actual_coin` | int | 全量实发金币合计 |
|
||||
| `mismatch_count` | int | 应发≠实发的组数(=0 说明全部按公式发放) |
|
||||
| `items` | `AdRevenueRow[]` | 逐条广告事件(**按时间倒序:新→旧**);`limit`/`offset` 对全量做分页切片,返回当前页 |
|
||||
| `items` | `AdRevenueRow[]` | 聚合明细(按 日期→用户→类型→代码位 排序) |
|
||||
|
||||
### AdRevenueDaily(`daily[]` — 按天趋势)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `date` | string | 北京时间 `YYYY-MM-DD` |
|
||||
| `impressions` | int | 当天展示条数合计 |
|
||||
| `revenue_yuan` | float | 当天客户端预估收益合计(元;eCPM 折算) |
|
||||
| `pangle_revenue_yuan` | float \| null | 当天穿山甲后台预估收益(元;GroMore `revenue`);非全量视图 / 无数据为 `null` |
|
||||
| `pangle_api_revenue_yuan` | float \| null | 当天穿山甲收益Api(元;GroMore `api_revenue`);未配 / 当天 / 无数据为 `null` |
|
||||
| `revenue_yuan` | float | 当天预估收益合计(元) |
|
||||
| `expected_coin` | int | 当天应发金币合计 |
|
||||
| `actual_coin` | int | 当天实发金币合计 |
|
||||
|
||||
### AdRevenueHourly(`hourly[]` — 按小时趋势,仅 `granularity=hour` 时非空)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `hour` | int | 北京时间小时 0–23 |
|
||||
| `impressions` | int | 该小时展示条数合计 |
|
||||
| `revenue_yuan` | float | 该小时预估收益合计(元) |
|
||||
| `expected_coin` | int | 该小时应发金币合计 |
|
||||
| `actual_coin` | int | 该小时实发金币合计 |
|
||||
|
||||
### AdRevenueTypeStat(`type_stats[ad_type]` — 分广告类型小计,供大盘第二行)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `impressions` | int | 该类型展示条数合计 |
|
||||
| `revenue_yuan` | float | 该类型预估收益合计(元);eCPM 由前端用 收益÷展示×1000 算 |
|
||||
|
||||
### AdRevenueRow(`items[]`)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
@@ -130,6 +104,5 @@
|
||||
- **展示 vs 发奖分离**:信息流轮播一会话可展示多条(都计入 `impressions`),但发奖仍按现规则(一会话发一次),`coin` 不因展示条数变化——这是有意设计(用户中途关只记展示不发奖)。
|
||||
- **历史 Draw 不可拆**:迁移(Draw→普通信息流)前,Draw 发奖混在 `ad_feed_reward_record` 且无类型标记,金币侧统一记 `feed`;迁移后 Draw 不再产生新数据。展示侧 `ad_type` 由客户端上报区分,故 `draw` 桶基本为空。
|
||||
- **来源字段从上线起齐全**:`app_env`/`our_code_id` 是本期新增列,历史记录为 NULL(报表来源列留空)。
|
||||
- **逐条/明细的收益是预估**:`items[].revenue_yuan` 基于客户端上报的 eCPM 折算,非穿山甲后台结算值。
|
||||
- **穿山甲后台收益(汇总/趋势级)**:`total_pangle_revenue_yuan`(预估 `revenue`)与 `total_pangle_api_revenue_yuan`(收益Api `api_revenue`,更接近结算)来自穿山甲 **GroMore 数据 API**(`integrations/pangle_report` + `scripts/sync_pangle_revenue` 按天 T+1 拉取入 [ad_pangle_daily_revenue](../database/ad_pangle_daily_revenue.md))。穿山甲**不提供分用户/设备/类型/场景维度**(官方明确),最细到 日期×应用×代码位,故只用于汇总与按天趋势的对照,**不挂到逐条事件行**;且仅在全量视图(未按 user/类型/场景过滤)展示。配置见 `.env` 的 `PANGLE_REPORT_*`。
|
||||
- **收益是预估**:基于客户端上报的 eCPM,非穿山甲后台结算值;以后台报表为结算权威。
|
||||
- **对账聚合级 + 逐条下钻**:行级 `matched` 给出该组(用户×类型×应用×代码位)应发是否==实发;**展开 `records` 即可看该组逐条明细**(eCPM/因子1/份数/LT/因子2/应发/实发/一致)定位到具体记录。独立逐条审计接口 [admin-ad-coin-audit](./admin-ad-coin-audit.md) 仍保留(同一复算口径,可全局按场景/只看不符筛选)。
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# ad_pangle_daily_revenue — 穿山甲 GroMore 天级收益
|
||||
|
||||
> 模型:[app/models/ad_pangle_revenue.py](../../app/models/ad_pangle_revenue.py) | 读写:[app/repositories/ad_pangle_revenue.py](../../app/repositories/ad_pangle_revenue.py) | 拉取:[app/integrations/pangle_report.py](../../app/integrations/pangle_report.py) + [scripts/sync_pangle_revenue.py](../../scripts/sync_pangle_revenue.py)
|
||||
|
||||
存放从穿山甲 **GroMore 数据 API**(聚合数据报告 API,天级、T+1)按天拉取的收益,供 admin
|
||||
[广告收益报表](../api/admin-ad-revenue-report.md) 的「穿山甲后台收益」做汇总/趋势级展示,
|
||||
与客户端自报 eCPM 折算的预估互为对照(看 gap)。
|
||||
|
||||
**粒度 = 日期 × 应用(app_env) × 代码位(our_code_id) × 广告源(adn)**。穿山甲**不提供分用户/设备维度**
|
||||
(官方明确),故本表无 `user_id`,也无法挂到逐条广告事件;报表逐条行仍用客户端预估。
|
||||
|
||||
## 数据流
|
||||
|
||||
- **写**:`scripts/sync_pangle_revenue`(线上每天 ~10:30 由 systemd timer 跑)调
|
||||
`pangle_report.fetch_daily_report` 拉昨天(可 `--days N` 回补),维度 `date,site_id,ad_unit_id`,
|
||||
映射 `site_id→app_env`、`ad_unit_id→our_code_id` 后 `upsert_daily_rows` 落库。按唯一键幂等,T+1 订正可重跑。
|
||||
⚠️ 用 `ad_unit_id`(广告位ID=104xxx)而非 `code_id`:实测 `code_id` 返回底层各 ADN 代码位(如 `983674557`),对不上我们的口径。
|
||||
- **读**:`ad_pangle_revenue.aggregate_by_date` 按日期汇总 → admin 报表
|
||||
`total_pangle_revenue_yuan` / `total_pangle_api_revenue_yuan` / `daily[].pangle_*`。
|
||||
|
||||
## 字段
|
||||
|
||||
| 列 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | int PK | |
|
||||
| `report_date` | str(10) idx | 北京时间 `YYYY-MM-DD`(拉取 `time_zone=8`),与 `ad_ecpm_record.report_date` 同口径可 join |
|
||||
| `app_env` | str(16) | `prod`(傻瓜比价正式)/ `test`(测试);由 `site_id` 经 `settings.pangle_report_site_id_to_env` 映射;未映射记 `site:<id>` |
|
||||
| `site_id` | str(32) \| null | 原始 GroMore AppId(留痕) |
|
||||
| `our_code_id` | str(64) idx | 广告位 ID(= 接口 `ad_unit_id` = 客户端配的 104xxx = `ad_ecpm_record.our_code_id`),join key。⚠️ **非** `code_id`(那是底层各 ADN 代码位,对不上);`-1` 为 GroMore 未归因桶 |
|
||||
| `adn` | str(16) | 广告源(接口 `network`);当前默认口径不分广告源(按 `ad_unit_id` 汇总),统一 `""` |
|
||||
| `revenue_yuan` | float | 预估收益(元)← 接口 `revenue`(排序价×展示/1000,所有 ADN) |
|
||||
| `api_revenue_yuan` | float \| null | 收益Api(元)← 接口 `api_revenue`(各 ADN 经 Reporting 回传、更接近结算);未配 / 当天为 `null` |
|
||||
| `ecpm` | str(32) \| null | 预估 eCPM 原值(接口 `ecpm`,单位**元/千次**,与客户端 getEcpm 的「分」不同) |
|
||||
| `impressions` | int | 展示次数 ← 接口 `imp_cnt` |
|
||||
| `currency` | str(8) | 货币(接口 `currency`,正常 `cny`) |
|
||||
| `synced_at` | datetime | 最近同步写入时间(可被回补覆盖) |
|
||||
|
||||
唯一约束 `uq_ad_pangle_daily (report_date, app_env, our_code_id, adn)`;`adn` 用 `""` 而非
|
||||
NULL,避免 NULL 在唯一约束里互不相同导致 upsert 重复。
|
||||
|
||||
## 局限
|
||||
|
||||
- **T+1**:当天数据穿山甲不出 `api_revenue`,`revenue` 也次日才稳;报表「今天」此两列多为空。
|
||||
- **`revenue` vs `api_revenue`**:前者所有 ADN 都有(预估口径);后者需后台为该 ADN 配置 Reporting
|
||||
才回传,部分 ADN 可能长期为空。结算仍以各 ADN 结算单 / 穿山甲后台为准。
|
||||
@@ -1,820 +0,0 @@
|
||||
# 傻瓜比价 数据表字典(产品参考)
|
||||
|
||||
> 面向产品/运营的全量数据表说明,按业务域分组。来源:`app/models/` 下全部 ORM 模型(共 **40 张表**)。
|
||||
> 字段名为数据库真实列名;本文档由模型源码整理,后端改表后以模型为准。
|
||||
|
||||
## 通用约定(先读)
|
||||
|
||||
1. **金额单位**:所有 `*_cents` 字段单位是「**分**」,展示时 ÷100 为「元」。金币(coin)是「个数」,另有自己的兑换比例。
|
||||
2. **时间**:存储统一为带时区的 UTC;展示按北京时间。部分「按天聚合」字段直接存北京日期串 `'YYYY-MM-DD'` 或日期类型,避免跨时区比较。
|
||||
3. **JSON 字段**:线上 PostgreSQL 用 JSONB、本地/测试 SQLite 用普通 JSON,业务无差别。
|
||||
4. **device_id 有两种含义**(重要):
|
||||
- **per-install**(存手机本地,卸载重装会变):领券、比价、设备存活用。
|
||||
- **硬件级稳定**(ANDROID_ID,卸载重装不变):新手引导用。
|
||||
5. **审计字段**:多数表有 `created_at`(创建时间)/ `updated_at`(更新时间),下文仅在有特殊含义时展开。
|
||||
6. **约束标记**:说明里 **主键** / **唯一** / **外键→表** / **可空** 表示该列的关键约束。
|
||||
|
||||
---
|
||||
|
||||
## 表总览
|
||||
|
||||
| 业务域 | 表名 | 中文名 | 一句话用途 |
|
||||
|---|---|---|---|
|
||||
| 用户 | `user` | 用户 | App 用户主表 |
|
||||
| 钱包 | `coin_account` | 金币账户 | 每用户余额快照(金币/现金) |
|
||||
| 钱包 | `coin_transaction` | 金币流水 | 金币每笔变动账本 |
|
||||
| 钱包 | `cash_transaction` | 现金流水 | 现金(分)每笔变动账本 |
|
||||
| 钱包 | `withdraw_order` | 提现单 | 现金→微信零钱提现 |
|
||||
| 钱包 | `wechat_transfer_authorization` | 微信转账免确认授权 | 用户授权后转账免逐笔确认 |
|
||||
| 激励 | `signin_record` | 签到记录 | 每日签到 |
|
||||
| 激励 | `signin_boost_record` | 签到膨胀记录 | 签到后看广告翻倍补发 |
|
||||
| 激励 | `user_task` | 一次性任务完成 | 只能领一次的任务 |
|
||||
| 激励 | `comparison_milestone_claim` | 比价战绩领取 | 比价次数里程碑奖励 |
|
||||
| 比价 | `comparison_record` | 比价记录 | 用户视角「我的比价记录」 |
|
||||
| 比价 | `savings_record` | 省钱记录 | 「累计帮你省了」数据源 |
|
||||
| 比价 | `price_observation` | 价格观测 | 平台/门店价格事实沉淀(后端) |
|
||||
| 比价 | `store_mapping` | 平台店铺映射 | 跨平台同店身份映射(后端) |
|
||||
| 比价 | `price_report` | 上报更低价 | 用户举报更低价、审核奖励 |
|
||||
| 广告 | `ad_reward_record` | 激励视频发奖 | 穿山甲 S2S 回调发金币 |
|
||||
| 广告 | `ad_ecpm_record` | 广告 eCPM 上报 | 展示收益统计/对账 |
|
||||
| 广告 | `ad_feed_reward_record` | 信息流广告奖励 | 比价等待/领券信息流广告金币 |
|
||||
| 广告 | `ad_watch_log` | 看广告时长 | 观看秒数记录(旧版/排查) |
|
||||
| 领券 | `coupon_claim_record` | 领券记录 | App 内自动领券每张券结果 |
|
||||
| 领券 | `coupon_daily_completion` | 领券每日完成 | 今日是否跑完整轮领券(置灰源) |
|
||||
| 领券 | `coupon_prompt_engagement` | 领券弹窗频控 | 控制领券引导窗弹不弹 |
|
||||
| CPS | `cps_group` | CPS 推广群 | 微信群(渠道追踪位 sid) |
|
||||
| CPS | `cps_activity` | CPS 活动池 | 可推广的券(美团/淘宝/京东) |
|
||||
| CPS | `cps_link` | CPS 群发短链 | `/c/{code}` 短链 |
|
||||
| CPS | `cps_click` | CPS 点击事件 | 落地页点击/复制 |
|
||||
| CPS | `cps_order` | CPS 对账订单 | 美团订单/佣金 |
|
||||
| CPS | `cps_wx_user` | CPS 落地页微信用户 | 群发落地页授权用户 |
|
||||
| CPS | `meituan_coupon` | 美团 CPS 券缓存 | 定时抓券本地缓存排序 |
|
||||
| 邀请 | `invite_relation` | 邀请关系 | 邀请绑定与发奖 |
|
||||
| 邀请 | `invite_fingerprint` | 邀请指纹归因 | 剪贴板失效时的兜底归因 |
|
||||
| 设备 | `device_liveness` | 设备无障碍存活 | 无障碍掉线检测+推送提醒 |
|
||||
| 设备 | `onboarding_completion` | 新手引导完成 | 引导只跑一次 |
|
||||
| 反馈 | `feedback` | 用户反馈 | 帮助与反馈 |
|
||||
| 后台 | `admin_user` | 管理员账号 | 后台账号(角色) |
|
||||
| 后台 | `admin_audit_log` | 管理员操作审计 | 后台写操作留痕 |
|
||||
| 配置 | `app_config` | 运营可配置项 | 把硬编码规则挪到 DB |
|
||||
| 配置 | `ops_marquee_seed` | 首页轮播种子 | 滚动条兜底假数据规则 |
|
||||
| 配置 | `ops_stat_config` | 首页三统计配置 | 门面数字展示模式 |
|
||||
| 技术 | `launch_confirm_sample` | 启动确认窗样本 | 比价 agent 兜底样本(内部) |
|
||||
|
||||
---
|
||||
|
||||
# 一、用户
|
||||
|
||||
## `user` — 用户表
|
||||
|
||||
App 用户主表。两种登录(极光一键 / 短信验证码)都映射到同一行,按 `phone` 唯一;注册即登录。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| phone | 字符串 | 手机号,**唯一**,登录主键 |
|
||||
| username | 字符串 | 对外展示账号 ID:11 位纯数字、首位非 1、**唯一**、创建时随机生成、不可变、不参与登录 |
|
||||
| register_channel | 字符串 | 注册渠道:jverify(极光)/ sms |
|
||||
| nickname | 字符串 | 昵称,可空 |
|
||||
| avatar_url | 字符串 | 头像 URL,可空 |
|
||||
| invite_code | 字符串 | 邀请码,每用户一个稳定短码(懒生成),**唯一**、可空 |
|
||||
| wechat_openid | 字符串 | 微信 openid(提现到零钱用),**唯一**、可空 |
|
||||
| wechat_nickname | 字符串 | 微信昵称(提现绑定卡展示),与通用 nickname 分开 |
|
||||
| wechat_avatar_url | 字符串 | 微信头像,同上 |
|
||||
| status | 字符串 | 账号状态:active / disabled / deleted |
|
||||
| debug_trace_enabled | 布尔 | 调试链接权限:开了的用户能看到「复制调试链接」按钮,默认 false |
|
||||
| created_at | 时间 | 注册时间 |
|
||||
| last_login_at | 时间 | 最后登录时间 |
|
||||
|
||||
---
|
||||
|
||||
# 二、钱包
|
||||
|
||||
## `coin_account` — 金币账户
|
||||
|
||||
一个用户一行,余额快照(读取用)。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| user_id | 整数 | **主键 + 外键→user** |
|
||||
| coin_balance | 整数 | 当前金币余额 |
|
||||
| cash_balance_cents | 金额(分) | 当前现金余额 |
|
||||
| total_coin_earned | 整数 | 累计赚取金币(只增不减),用于「历史总收益」展示 |
|
||||
| updated_at | 时间 | 更新时间 |
|
||||
|
||||
## `coin_transaction` — 金币流水
|
||||
|
||||
金币每笔变动账本,每次余额变动写一笔并记变动后余额,可逐笔对账。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| user_id | 整数 | **外键→user** |
|
||||
| amount | 整数 | 变动额:正=入账(赚),负=出账(花/兑换) |
|
||||
| balance_after | 整数 | 本笔变动后金币余额(对账用) |
|
||||
| biz_type | 字符串 | 业务类型:signin / task_<key> / exchange_out / … |
|
||||
| ref_id | 字符串 | 关联业务 id(签到日期、任务 key 等),可空;可重复任务按它去重防双发 |
|
||||
| remark | 字符串 | 备注,可空 |
|
||||
| created_at | 时间 | 时间 |
|
||||
|
||||
## `cash_transaction` — 现金流水
|
||||
|
||||
现金(分)每笔变动账本。金币兑现金、提现记这里。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| user_id | 整数 | **外键→user** |
|
||||
| amount_cents | 金额(分) | 正=入账(兑入),负=出账(提现) |
|
||||
| balance_after_cents | 金额(分) | 本笔后现金余额 |
|
||||
| biz_type | 字符串 | 业务类型:exchange_in(兑入)/ withdraw(提现)/ withdraw_refund(提现退回) |
|
||||
| ref_id | 字符串 | 关联业务 id,可空 |
|
||||
| remark | 字符串 | 备注,可空 |
|
||||
| created_at | 时间 | 时间 |
|
||||
|
||||
## `withdraw_order` — 提现单
|
||||
|
||||
现金 → 微信零钱。状态机:reviewing(待审核)→ pending(打款在途)→ success / failed;reviewing →(拒绝)→ rejected(已退款)。同一用户同时只能有一笔进行中。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| user_id | 整数 | **外键→user** |
|
||||
| out_bill_no | 字符串 | 商户单号(我方生成,微信查单用),**唯一** |
|
||||
| amount_cents | 金额(分) | 提现金额 |
|
||||
| user_name | 字符串 | 提现实名(微信达额转账要求),可空 |
|
||||
| status | 字符串 | reviewing / pending / success / failed / rejected |
|
||||
| wechat_state | 字符串 | 微信侧原始状态(WAIT_USER_CONFIRM / SUCCESS / FAIL / CANCELLED…),可空 |
|
||||
| transfer_bill_no | 字符串 | 微信转账单号,可空 |
|
||||
| package_info | 字符串 | 待用户确认时给 App 拉起确认页的参数,可空 |
|
||||
| fail_reason | 字符串 | 失败原因,可空 |
|
||||
| created_at / updated_at | 时间 | 创建 / 更新时间 |
|
||||
|
||||
## `wechat_transfer_authorization` — 微信转账免确认授权
|
||||
|
||||
用户授权「免确认收款」后,后续转账免逐笔确认。一个用户一条。状态机:pending(待用户确认)→ active(已生效可免确认)→ closed(已关闭需重开)。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| user_id | 整数 | **主键 + 外键→user** |
|
||||
| openid | 字符串 | 用户微信 openid |
|
||||
| out_authorization_no | 字符串 | 商户侧授权单号(我方生成),**唯一** |
|
||||
| authorization_id | 字符串 | 微信侧授权单号(生效后返回,免确认转账要用),可空 |
|
||||
| state | 字符串 | pending / active / closed |
|
||||
| created_at / updated_at | 时间 | 创建 / 更新时间 |
|
||||
|
||||
---
|
||||
|
||||
# 三、激励(签到 · 任务 · 比价奖励)
|
||||
|
||||
## `signin_record` — 签到记录
|
||||
|
||||
每次签到一行,(user_id, signin_date) 唯一,天然防一天签两次。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| user_id | 整数 | **外键→user** |
|
||||
| signin_date | 日期 | 签到日期(北京时间),与 user_id 组成**唯一** |
|
||||
| cycle_day | 整数 | 1..7,7 天循环里今天第几档(决定发多少金币),断签重置回 1 |
|
||||
| streak | 整数 | 连续签到天数(不封顶),断签重置回 1 |
|
||||
| coin_awarded | 整数 | 本次发放金币 |
|
||||
| created_at | 时间 | 时间 |
|
||||
|
||||
## `signin_boost_record` — 签到膨胀记录
|
||||
|
||||
签到后看广告「膨胀」翻倍,一天最多一次,补发金额=当天签到原始奖励。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| user_id | 整数 | **外键→user** |
|
||||
| signin_date | 日期 | 与 user_id 组成**唯一**(防并发重复补发) |
|
||||
| coin_awarded | 整数 | 补发金币 |
|
||||
| ad_ref_id | 字符串 | 广告会话/交易号,可空 |
|
||||
| created_at | 时间 | 时间 |
|
||||
|
||||
## `user_task` — 一次性任务完成记录
|
||||
|
||||
「打开消息提醒」这类只能领一次的任务,完成写一行,(user_id, task_key) 唯一。可循环任务(签到)走专表。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| user_id | 整数 | **外键→user** |
|
||||
| task_key | 字符串 | 任务标识,与 user_id 组成**唯一** |
|
||||
| status | 字符串 | 状态,默认 completed |
|
||||
| coin_awarded | 整数 | 发放金币 |
|
||||
| completed_at | 时间 | 完成时间 |
|
||||
|
||||
## `comparison_milestone_claim` — 比价战绩里程碑领取
|
||||
|
||||
「记录比价战绩」每档(第 1~6 次)只能领一次。解锁进度由成功比价条数决定,本表只记「哪几档已领」。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| user_id | 整数 | **外键→user** |
|
||||
| milestone | 整数 | 档位序号(1-based),与 user_id 组成**唯一** |
|
||||
| coin_awarded | 整数 | 发放金币 |
|
||||
| claimed_at | 时间 | 领取时间 |
|
||||
|
||||
---
|
||||
|
||||
# 四、比价 · 省钱
|
||||
|
||||
## `comparison_record` — 比价记录
|
||||
|
||||
用户视角的「我的比价记录」,每完成一次比价客户端登录后上报一条。(user_id, trace_id) 唯一(重试幂等)。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| user_id | 整数 | **外键→user** |
|
||||
| device_id | 字符串 | 设备号,可空 |
|
||||
| business_type | 字符串 | food(外卖,当前唯一)/ ecom(电商)/ coupon(领券) |
|
||||
| trace_id | 字符串 | 比价追踪号,与 user_id 组成**唯一** |
|
||||
| trace_url | 字符串 | 公网调试链接(按用户 debug 权限决定是否返回),可空 |
|
||||
| source_platform_id / _name | 字符串 | 源平台(发起比价那家)id / 名 |
|
||||
| source_package | 字符串 | 源平台 App 包名 |
|
||||
| source_price_cents | 金额(分) | 源平台价 |
|
||||
| best_platform_id / _name | 字符串 | 最优(最便宜)平台 id / 名 |
|
||||
| best_price_cents | 金额(分) | 最优价 |
|
||||
| best_deeplink | 字符串 | 最优平台深链(「再次比价」直达),可空 |
|
||||
| saved_amount_cents | 金额(分) | 省下金额=源价−最优价(可为 0/负) |
|
||||
| is_source_best | 布尔 | 源平台就是最便宜(这次没省到),可空 |
|
||||
| store_name | 字符串 | 店名 |
|
||||
| total_dish_count / skipped_dish_count | 整数 | 下单菜数 / 跳过菜数 |
|
||||
| status | 字符串 | success(有效对比)/ failed |
|
||||
| information | 字符串 | done 文案:成功的到手价说明 / 失败原因 |
|
||||
| items | JSON | 下单菜品明细 |
|
||||
| comparison_results | JSON | 逐平台对比明细(含优惠/名次) |
|
||||
| skipped_dish_names | JSON | 未找到跳过的菜名 |
|
||||
| raw_payload | JSON | 客户端原始上报全量(取数兜底),可空 |
|
||||
| device_model / device_manufacturer | 字符串 | 机型 / 厂商(排障),可空 |
|
||||
| rom_vendor / rom_name / rom_version | 字符串/整数 | ROM 厂商/名/版本(排障),可空 |
|
||||
| android_version / android_sdk | 字符串/整数 | 安卓版本,可空 |
|
||||
| app_version / app_version_code | 字符串/整数 | 我方 App 版本,可空 |
|
||||
| source_app_version | 字符串 | 源平台 App 版本(排适配失效),可空 |
|
||||
| longitude / latitude | 小数 | 比价时定位,可空 |
|
||||
| total_ms / step_count | 整数 | 整场耗时 / agent 步数(排障),可空 |
|
||||
| llm_call_count / retry_count | 整数 | LLM 调用 / 重试次数,可空 |
|
||||
| input_tokens / output_tokens | 整数 | LLM 累计 token,可空 |
|
||||
| llm_calls | JSON | 每次 LLM 调用明细,可空 |
|
||||
| created_at | 时间 | 时间 |
|
||||
|
||||
## `savings_record` — 省钱记录
|
||||
|
||||
profile「累计帮你省了」「省钱战绩」的唯一数据源。(user_id, client_event_id) 唯一(真实上报幂等)。当前由 demo seeder 灌入,比价真接入后改上报写入。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| user_id | 整数 | **外键→user** |
|
||||
| order_amount_cents | 金额(分) | 订单实付金额 |
|
||||
| saved_amount_cents | 金额(分) | 省下金额 |
|
||||
| platform | 字符串 | 平台,可空 |
|
||||
| title | 字符串 | 标题,可空 |
|
||||
| shop_name | 字符串 | 店铺名(订单卡标题),可空 |
|
||||
| dishes | JSON | 菜品名列表(前 2 道直接展示) |
|
||||
| source | 字符串 | demo(演示)/ compare(真实比价上报) |
|
||||
| original_price_cents | 金额(分) | 源平台原价,可空(真实上报才有) |
|
||||
| compared_price_cents | 金额(分) | 我们给出的比价价,可空 |
|
||||
| pay_channel | 字符串 | 支付渠道:wechat / alipay,可空 |
|
||||
| platform_package | 字符串 | 实际下单平台包名,可空 |
|
||||
| source_platform_name | 字符串 | 源平台展示名(如「美团」),可空 |
|
||||
| source_deeplink | 字符串 | 源平台重进链接(预留),可空 |
|
||||
| client_event_id | 字符串 | 客户端幂等键(UUID),demo 行为空 |
|
||||
| device_id | 字符串 | 设备号,可空 |
|
||||
| created_at | 时间 | 时间 |
|
||||
|
||||
## `price_observation` — 价格观测(后端数据资产)
|
||||
|
||||
平台/门店视角的「价格事实」。比价跑到 done 后由服务端无条件沉淀(与登录无关、匿名也记),是未来「查过同店秒回价」的源头。(trace_id, platform, scope) 唯一。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| observed_at | 时间 | 观测时刻(≈比价 done 时) |
|
||||
| trace_id | 字符串 | 比价追踪号(溯源+幂等) |
|
||||
| business_type | 字符串 | food / ecom |
|
||||
| platform | 字符串 | 平台 |
|
||||
| platform_store_id | 字符串 | 平台内门店 ID,可空 |
|
||||
| store_name / city | 字符串 | 门店名 / 城市,可空 |
|
||||
| geohash / lng / lat | 字符串/小数 | 地理位置,可空 |
|
||||
| is_source | 布尔 | 是否源平台 |
|
||||
| scope | 字符串 | order(整单到手价,当前唯一)/ dish(单菜价) |
|
||||
| price_cents | 金额(分) | 该口径到手价;空=采集失败/打烊 |
|
||||
| coupon_saved_cents | 金额(分) | 优惠额,可空 |
|
||||
| coupon_name | 字符串 | 优惠名,可空 |
|
||||
| store_closed | 字符串 | 打烊原因(非空时无有效价),可空 |
|
||||
| rank | 整数 | 本次比价全局名次(1=最便宜),可空 |
|
||||
| dishes | JSON | 菜篮明细,可空 |
|
||||
| attrs | JSON | 灵活字段兜底,可空 |
|
||||
| source_device_id / source_user_id | 字符串/整数 | 来源设备 / 用户(不鉴权时为空) |
|
||||
| confidence | 小数 | 置信度,默认 1.0 |
|
||||
| created_at | 时间 | 时间 |
|
||||
|
||||
## `store_mapping` — 平台店铺映射(后端数据资产)
|
||||
|
||||
跨平台「同一家店」在淘宝/美团/京东各自的店铺 id 与店名映射。每次淘宝比价沉淀一行。trace_id 唯一。⚠️ 数据来自 LLM 店铺匹配,可能匹配错。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| id_taobao / name_taobao | 字符串 | 淘宝店铺 id(shopId)/ 名,可空 |
|
||||
| id_meituan / name_meituan | 字符串 | 美团店铺 id(预留稳定 poi)/ 名,可空 |
|
||||
| id_jd / name_jd | 字符串 | 京东店铺 id(storeId)/ 名,可空 |
|
||||
| city / geohash / lng / lat | 字符串/小数 | 地理位置,可空 |
|
||||
| taobao_address | 字符串 | 淘宝门店地址,可空 |
|
||||
| source_platform | 字符串 | 源平台,可空 |
|
||||
| business_type | 字符串 | 业务类型,默认 food |
|
||||
| trace_id | 字符串 | 比价追踪号(溯源+**唯一**) |
|
||||
| source_device_id / source_user_id | 字符串/整数 | 来源设备 / 用户,可空 |
|
||||
| taobao_share_url / taobao_resolved_url / taobao_deeplink | 字符串/文本 | 淘宝短链 / 解析 URL / 深链 |
|
||||
| taobao_deeplink_invalid_at | 时间 | 淘宝深链失效标记(空=有效) |
|
||||
| meituan_poi_id_str | 字符串 | 美团一次性票据(非稳定主键),可空 |
|
||||
| meituan_share_url / meituan_resolved_url / meituan_deeplink | 字符串/文本 | 美团短链 / 解析 URL / 深链 |
|
||||
| jd_vender_id | 字符串 | 京东商家 id(深链要用),可空 |
|
||||
| jd_share_url / jd_resolved_url / jd_deeplink | 字符串/文本 | 京东短链 / 解析 URL / 深链 |
|
||||
| jd_deeplink_invalid_at | 时间 | 京东深链失效标记(空=有效) |
|
||||
| attrs | JSON | 灵活字段兜底,可空 |
|
||||
| created_at | 时间 | 时间 |
|
||||
|
||||
## `price_report` — 上报更低价
|
||||
|
||||
用户在比价记录里举报「某平台有更低价」+ 截图,人工审核通过奖励金币。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| user_id | 整数 | **外键→user** |
|
||||
| comparison_record_id | 整数 | **外键→comparison_record**,关联的比价记录,可空(记录删后保留上报) |
|
||||
| store_name / dish_summary | 字符串 | 比价记录快照:店名 / 菜品摘要,可空 |
|
||||
| original_platform_id / _name | 字符串 | 原最低价平台快照 |
|
||||
| original_price_cents | 金额(分) | 原最低价快照 |
|
||||
| reported_platform_id / _name | 字符串 | 用户上报的平台 |
|
||||
| reported_price_cents | 金额(分) | 用户上报的更低价 |
|
||||
| images | JSON | 截图 URL 列表 |
|
||||
| status | 字符串 | pending(审核中)/ approved / rejected |
|
||||
| reject_reason | 字符串 | 驳回原因,可空 |
|
||||
| reward_coins | 整数 | 通过奖励金币,可空 |
|
||||
| reviewed_at | 时间 | 审核时间,可空 |
|
||||
| created_at | 时间 | 提交时间 |
|
||||
|
||||
---
|
||||
|
||||
# 五、广告变现
|
||||
|
||||
> 看广告有三条并列数据流:`ad_reward_record`(后端 S2S 发金币)、`ad_ecpm_record`(前端报展示收益)、`ad_watch_log`(前端报观看时长);外加点位 2 的 `ad_feed_reward_record`(信息流)。
|
||||
|
||||
## `ad_reward_record` — 激励视频发奖记录
|
||||
|
||||
每条=穿山甲/GroMore 一次服务端激励回调。`trans_id` 唯一做幂等键。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| trans_id | 字符串 | 穿山甲交易号,**唯一**(幂等键) |
|
||||
| user_id | 整数 | **外键→user** |
|
||||
| coin | 整数 | 实发金币(超限为 0) |
|
||||
| status | 字符串 | granted(已发)/ capped(当日超限)/ ecpm_missing(缺 eCPM) |
|
||||
| reward_scene | 字符串 | reward_video(福利页看视频)/ signin_boost(签到膨胀) |
|
||||
| ad_session_id | 字符串 | 广告会话 id,可空 |
|
||||
| ecpm_raw | 字符串 | 本次发奖采用的 eCPM 原始值,可空 |
|
||||
| app_env | 字符串 | 应用环境 prod/test(回填),可空 |
|
||||
| our_code_id | 字符串 | 我方配置代码位 104xxx(回填),可空 |
|
||||
| reward_date | 字符串 | 北京日期串,按它统计当日发奖次数 |
|
||||
| reward_name | 字符串 | 穿山甲上报奖励名(参考),可空 |
|
||||
| raw | 字符串 | 回调原始参数(审计),可空 |
|
||||
| created_at | 时间 | 时间 |
|
||||
|
||||
## `ad_ecpm_record` — 广告 eCPM 上报
|
||||
|
||||
每条=客户端一次广告展示后读到的 eCPM。`ad_session_id` 唯一,把展示收益与奖励完成绑定。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| user_id | 整数 | **外键→user** |
|
||||
| ad_type | 字符串 | reward_video / draw 等 |
|
||||
| ad_session_id | 字符串 | 广告会话 id,**唯一**、可空 |
|
||||
| adn | 字符串 | 实际 ADN(pangle / gdt…),可空 |
|
||||
| slot_id | 字符串 | 底层代码位 rit,可空 |
|
||||
| app_env | 字符串 | 应用环境 prod/test,可空 |
|
||||
| our_code_id | 字符串 | 我方配置代码位 104xxx,可空 |
|
||||
| ecpm_raw | 字符串 | eCPM 原始字符串(单位:分/千次展示) |
|
||||
| report_date | 字符串 | 北京日期串(按天聚合) |
|
||||
| created_at | 时间 | 时间 |
|
||||
|
||||
## `ad_feed_reward_record` — 信息流广告奖励
|
||||
|
||||
点位 2:比价等待 / 领券信息流广告,每满 10 秒累计一份,视频完成一次性入账。`client_event_id` 唯一。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| client_event_id | 字符串 | 客户端事件 id,**唯一**(幂等) |
|
||||
| ad_session_id | 字符串 | 广告会话 id,可空 |
|
||||
| user_id | 整数 | **外键→user** |
|
||||
| reward_date | 字符串 | 北京日期串 |
|
||||
| duration_seconds | 整数 | 本次展示秒数 |
|
||||
| unit_count | 整数 | 累计份数 |
|
||||
| ecpm_raw | 字符串 | eCPM 原始值 |
|
||||
| adn / slot_id | 字符串 | ADN / 代码位,可空 |
|
||||
| feed_scene | 字符串 | comparison(比价等待)/ coupon(领券)/ welfare(福利页),可空 |
|
||||
| trace_id | 字符串 | 比价场景的 trace_id(归属本场比价金币),可空 |
|
||||
| app_env / our_code_id | 字符串 | 应用环境 / 代码位,可空 |
|
||||
| coin | 整数 | 实发金币 |
|
||||
| status | 字符串 | 状态,默认 granted |
|
||||
| created_at | 时间 | 时间 |
|
||||
|
||||
## `ad_watch_log` — 看广告时长记录
|
||||
|
||||
每条=客户端上报的一次激励视频观看秒数。当前发奖以次数上限为准,本表保留作旧客户端兼容/排查。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| user_id | 整数 | **外键→user** |
|
||||
| watch_seconds | 整数 | 本次观看秒数(服务端已夹区间) |
|
||||
| watch_date | 字符串 | 北京日期串(按天聚合总时长) |
|
||||
| created_at | 时间 | 时间 |
|
||||
|
||||
---
|
||||
|
||||
# 六、领券(App 内自动领券)
|
||||
|
||||
> 判断维度是 **device_id**(per-install,重装会变);日期为北京自然日。
|
||||
|
||||
## `coupon_claim_record` — 领券记录
|
||||
|
||||
按 (设备, 券, 自然日) 记每张券领取结果,纯沉淀(资产/画像/排查),当前不参与「要不要领」判断。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| device_id | 字符串 | 设备号(与 coupon_id、claim_date 组成**唯一**) |
|
||||
| user_id | 整数 | 登录态用户,可空(不进唯一键) |
|
||||
| coupon_id | 字符串 | 券 id |
|
||||
| claim_date | 日期 | 北京自然日 |
|
||||
| status | 字符串 | success / already_claimed / failed / skipped |
|
||||
| vendor | 字符串 | 券来源,可空 |
|
||||
| coupon_name | 字符串 | 券名,可空 |
|
||||
| claimed_count | 整数 | 领到几张,可空 |
|
||||
| trace_id | 字符串 | 哪次任务领的(排查),可空 |
|
||||
| reason | 字符串 | 失败/跳过原因,可空 |
|
||||
| extra | JSON | 券结构化信息兜底,可空 |
|
||||
| created_at / updated_at | 时间 | 创建 / 更新时间 |
|
||||
|
||||
## `coupon_daily_completion` — 领券每日完成
|
||||
|
||||
按 (设备, 自然日) 记「今天是否跑完整轮领券(到 done)」——首页「去领取」卡置灰源。到 done 即算(不管单券成败)。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| device_id | 字符串 | 设备号(与 complete_date 组成**唯一**) |
|
||||
| user_id | 整数 | 登录态用户,可空 |
|
||||
| complete_date | 日期 | 北京自然日 |
|
||||
| trace_id | 字符串 | 哪次任务跑到 done(排查),可空 |
|
||||
| created_at / updated_at | 时间 | 创建 / 更新时间 |
|
||||
|
||||
## `coupon_prompt_engagement` — 领券弹窗频控
|
||||
|
||||
按 (设备, App, 自然日) 记「今天这个 App 是否对领券引导窗表达过意向」——弹窗频控源。美团/淘宝/京东各自独立。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| device_id | 字符串 | 设备号(与 package、engage_date 组成**唯一**) |
|
||||
| package | 字符串 | 目标 App 包名(频控维度,各 App 独立) |
|
||||
| user_id | 整数 | 登录态用户,可空 |
|
||||
| engage_date | 日期 | 北京自然日 |
|
||||
| engage_type | 字符串 | claim_started(点一键领取)/ dismissed(拒绝/关闭)/ shown(自动弹出) |
|
||||
| created_at / updated_at | 时间 | 创建 / 更新时间 |
|
||||
|
||||
---
|
||||
|
||||
# 七、CPS 群发分发(微信群发券)
|
||||
|
||||
## `cps_group` — CPS 推广群
|
||||
|
||||
每个微信群一条;`sid` 是美团联盟渠道追踪位(仅美团需要)。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| sid | 字符串 | 渠道追踪位,**唯一**、可空(纯淘宝/京东群无) |
|
||||
| name | 字符串 | 群名 |
|
||||
| platforms | JSON | 该群发哪些平台:["meituan","taobao","jd"] |
|
||||
| member_count | 整数 | 群人数(转化率分母),可空 |
|
||||
| status | 字符串 | active / archived |
|
||||
| remark | 字符串 | 备注,可空 |
|
||||
| created_at | 时间 | 时间 |
|
||||
|
||||
## `cps_activity` — CPS 活动池
|
||||
|
||||
可推广的券(运营预存)。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| platform | 字符串 | meituan / taobao / jd |
|
||||
| name | 字符串 | 活动名 |
|
||||
| act_id | 字符串 | 美团活动物料 ID,可空 |
|
||||
| product_view_sign | 字符串 | 美团商品券标识(与 act_id 二选一),可空 |
|
||||
| payload | 文本 | 淘宝整段淘口令 / 京东推广链接,可空 |
|
||||
| image_url | 字符串 | 淘宝落地页主视觉图(绝对 URL),可空 |
|
||||
| status | 字符串 | active / archived |
|
||||
| remark | 字符串 | 备注,可空 |
|
||||
| created_at | 时间 | 时间 |
|
||||
|
||||
## `cps_link` — CPS 群发短链
|
||||
|
||||
群发的 `/c/{code}` 短链,每个活动每次生成一条。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| code | 字符串 | 短码(`/c/{code}`),**唯一** |
|
||||
| group_id | 整数 | 所属群 id |
|
||||
| activity_id | 整数 | 所属活动 id |
|
||||
| sid | 字符串 | 渠道追踪位(仅美团),可空 |
|
||||
| platform | 字符串 | meituan / taobao / jd |
|
||||
| target_url | 字符串 | 跳转目标:美团短链 / 京东链接 / 淘宝淘口令 |
|
||||
| created_at | 时间 | 时间 |
|
||||
|
||||
## `cps_click` — CPS 点击事件
|
||||
|
||||
落地页点击/复制事件,统计 PV/UV 及用户级领券。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| link_id | 整数 | 所属短链 id |
|
||||
| group_id | 整数 | 所属群 id(冗余,按群聚合快) |
|
||||
| sid | 字符串 | 渠道追踪位(冗余),可空 |
|
||||
| event_type | 字符串 | visit(进页/被跳转)/ copy(淘宝点复制口令) |
|
||||
| ip / ua | 字符串 | 客户端 IP / UA(UV 近似去重),可空 |
|
||||
| openid | 字符串 | 微信授权用户标识(非微信/未授权为空) |
|
||||
| clicked_at | 时间 | 点击时间 |
|
||||
|
||||
## `cps_order` — CPS 对账订单
|
||||
|
||||
从美团联盟拉回、按 sid 归群的订单明细。`order_id` 唯一(upsert 幂等)。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| order_id | 字符串 | 美团订单号,**唯一** |
|
||||
| sid | 字符串 | 渠道追踪位(按它归群),可空 |
|
||||
| act_id | 字符串 | 活动物料 id,可空 |
|
||||
| biz_line | 整数 | 业务线(1=外卖),可空 |
|
||||
| trade_type | 整数 | 1=cps 2=cpa,可空 |
|
||||
| pay_price_cents | 金额(分) | 付款金额,可空 |
|
||||
| commission_cents | 金额(分) | 预估佣金,可空 |
|
||||
| commission_rate | 字符串 | 佣金率("300"=3%),可空 |
|
||||
| refund_price_cents / refund_profit_cents | 金额(分) | 退款金额 / 退款佣金,可空 |
|
||||
| mt_status | 字符串 | 美团状态:2 付款 3 完成 4 取消 5 风控 6 结算 |
|
||||
| invalid_reason | 字符串 | 失效原因,可空 |
|
||||
| product_name | 字符串 | 商品名,可空 |
|
||||
| pay_time / mt_update_time | 时间 | 付款 / 美团更新时间,可空 |
|
||||
| raw | JSON | 原始订单返回 |
|
||||
| first_seen / updated_at | 时间 | 首次拉到 / 更新时间 |
|
||||
|
||||
## `cps_wx_user` — CPS 落地页微信用户
|
||||
|
||||
群发落地页经服务号网页授权拿到的用户。`openid` 唯一。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| openid | 字符串 | 服务号下用户唯一标识,**唯一** |
|
||||
| unionid | 字符串 | 开放平台 unionid,可空 |
|
||||
| nickname / headimgurl | 字符串 | 昵称 / 头像(userinfo 授权后才有),可空 |
|
||||
| first_code | 字符串 | 首次进入来源短码,可空 |
|
||||
| first_group_id | 整数 | 首次授权来源群,可空 |
|
||||
| first_seen / last_seen | 时间 | 首次 / 最近出现时间 |
|
||||
|
||||
## `meituan_coupon` — 美团 CPS 券本地缓存
|
||||
|
||||
定时把美团联盟券抓进本地库,供本地排序(销量/佣金)。(source, product_view_sign) 唯一;查询用 `dedup_key` 跨源去重。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| source | 字符串 | search_waimai / search_meishi / store_supply(与 product_view_sign 组成**唯一**) |
|
||||
| platform | 整数 | 1 到家/外卖,2 到店 |
|
||||
| biz_line | 整数 | 到店细分:1 到餐 2 到综 3 酒店 4 门票,可空 |
|
||||
| city_id | 字符串 | 城市 id |
|
||||
| product_view_sign | 字符串 | 召回标识(换推广链用) |
|
||||
| sku_view_id | 字符串 | sku 标识,可空 |
|
||||
| name / brand_name | 字符串 | 商品名 / 品牌名,可空 |
|
||||
| sell_price_cents / original_price_cents | 金额(分) | 售价 / 原价,可空 |
|
||||
| head_url | 字符串 | 头图 URL,可空 |
|
||||
| image_size / image_type | 整数/字符串 | 头图字节大小 / MIME(分析用),可空 |
|
||||
| sale_volume | 字符串 | 销量档位文本(如「热销 1w+」),可空 |
|
||||
| sale_volume_num | 整数 | 销量排序用数值,可空 |
|
||||
| commission_percent | 小数 | 佣金率(1.4=1.4%),可空 |
|
||||
| commission_amount_cents | 金额(分) | 佣金额,可空 |
|
||||
| poi_name | 字符串 | 门店名,可空 |
|
||||
| available_poi_num | 整数 | 可用门店数,可空 |
|
||||
| delivery_distance_m | 小数 | 配送距离(米),可空 |
|
||||
| dedup_key | 字符串 | 跨源去重键 md5(brand\|name\|price) |
|
||||
| raw | JSON | 原始返回 |
|
||||
| first_seen / last_seen / updated_at | 时间 | 首次 / 最近 / 更新时间 |
|
||||
|
||||
---
|
||||
|
||||
# 八、邀请裂变
|
||||
|
||||
## `invite_relation` — 邀请关系
|
||||
|
||||
一行=一次成功邀请绑定。`invitee_user_id` 唯一(一人只能被邀一次,防重复发奖)。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| inviter_user_id | 整数 | **外键→user**,邀请人 |
|
||||
| invitee_user_id | 整数 | **外键→user**,被邀请人,**唯一** |
|
||||
| channel | 字符串 | clipboard(剪贴板自动)/ manual(手动填码)/ fingerprint(指纹兜底) |
|
||||
| status | 字符串 | effective(注册即生效),预留 pending |
|
||||
| inviter_coin / invitee_coin | 整数 | 各自发放金币 |
|
||||
| created_at | 时间 | 时间 |
|
||||
|
||||
## `invite_fingerprint` — 邀请指纹归因
|
||||
|
||||
剪贴板归因失效时的兜底:落地页访问留指纹,装机后用 (IP, 屏幕, 机型) 7 天内反查。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| inviter_user_id | 整数 | **外键→user**,邀请人 |
|
||||
| ip | 字符串 | 落地页访问者 IP |
|
||||
| device_model | 字符串 | 机型(Build.MODEL / UA 解析) |
|
||||
| screen | 字符串 | 屏幕分辨率(如 1080x2400) |
|
||||
| user_agent | 文本 | 完整 UA(debug 用) |
|
||||
| created_at | 时间 | 时间 |
|
||||
|
||||
---
|
||||
|
||||
# 九、设备 · 新手引导
|
||||
|
||||
## `device_liveness` — 设备无障碍存活监控
|
||||
|
||||
每条=一个用户的一台设备。无障碍存活时上报心跳,掉线超时则极光推送提醒。(user_id, device_id) 唯一。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| user_id | 整数 | **外键→user** |
|
||||
| device_id | 字符串 | per-install 设备号(与 user_id 组成**唯一**) |
|
||||
| registration_id | 字符串 | 极光推送目标 id,可空 |
|
||||
| platform | 字符串 | 平台,默认 android |
|
||||
| app_version | 字符串 | App 版本,可空 |
|
||||
| ever_protected | 布尔 | 是否开过无障碍(功能对它有意义) |
|
||||
| last_heartbeat_at | 时间 | 最近心跳时间(超时=掉线),可空 |
|
||||
| last_report_protection_on | 布尔 | 最近上报的无障碍开关状态 |
|
||||
| liveness_state | 字符串 | unknown / alive / silent / notified |
|
||||
| notified_at | 时间 | 最近推送告警时间,可空 |
|
||||
| kill_alert_pending | 布尔 | 掉线告警待客户端提醒标记 |
|
||||
| created_at / updated_at | 时间 | 创建 / 更新时间 |
|
||||
|
||||
## `onboarding_completion` — 新手引导完成
|
||||
|
||||
同一台设备+同一账号,新手引导只跑一次。device_id 用**硬件级稳定标识**(重装不变)。(user_id, device_id) 唯一。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| user_id | 整数 | 登录态用户(与 device_id 组成**唯一**) |
|
||||
| device_id | 字符串 | 硬件级稳定设备号(ANDROID_ID) |
|
||||
| completed_at | 时间 | 完成时间 |
|
||||
|
||||
---
|
||||
|
||||
# 十、反馈
|
||||
|
||||
## `feedback` — 用户反馈
|
||||
|
||||
帮助与反馈,人工审核可奖励金币。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| user_id | 整数 | **外键→user** |
|
||||
| content | 文本 | 反馈内容(必填) |
|
||||
| contact | 字符串 | 联系方式(改版后客户端不再采集,新数据存空串) |
|
||||
| images | JSON | 截图 URL 列表,可空 |
|
||||
| status | 字符串 | pending(审核中)/ adopted(已采纳)/ rejected |
|
||||
| reject_reason | 字符串 | 驳回原因,可空 |
|
||||
| reward_coins | 整数 | 采纳奖励金币,可空 |
|
||||
| review_note | 字符串 | 审核批注,可空 |
|
||||
| reviewed_by_admin_id | 整数 | **外键→admin_user**,审核人,可空 |
|
||||
| reviewed_at | 时间 | 审核时间,可空 |
|
||||
| created_at | 时间 | 提交时间 |
|
||||
|
||||
---
|
||||
|
||||
# 十一、运营后台 · 配置
|
||||
|
||||
## `admin_user` — 管理员账号
|
||||
|
||||
后台账号,与 App 用户完全隔离(独立 JWT)。账号密码(bcrypt)登录。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| username | 字符串 | 用户名,**唯一** |
|
||||
| password_hash | 字符串 | bcrypt 密码哈希 |
|
||||
| role | 字符串 | super_admin(全权)/ finance(钱)/ operator(用户+反馈+大盘) |
|
||||
| status | 字符串 | active / disabled |
|
||||
| created_at | 时间 | 创建时间 |
|
||||
| last_login_at | 时间 | 最后登录时间,可空 |
|
||||
|
||||
## `admin_audit_log` — 管理员操作审计
|
||||
|
||||
每个后台写操作落一条,记前后值,不可删,用于追溯「谁在何时改了谁的钱/状态」。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| admin_id | 整数 | **外键→admin_user** |
|
||||
| admin_username | 字符串 | 操作者用户名(冗余存,改名后仍可追溯) |
|
||||
| action | 字符串 | 操作类型(如 user.coins.grant / withdraw.refresh) |
|
||||
| target_type | 字符串 | 被操作对象类型 |
|
||||
| target_id | 字符串 | 被操作对象 id(字符串以兼容非整型主键),可空 |
|
||||
| detail | JSON | 上下文 + 前后值,可空 |
|
||||
| ip | 字符串 | 操作 IP,可空 |
|
||||
| created_at | 时间 | 时间 |
|
||||
|
||||
## `app_config` — 运营可配置项
|
||||
|
||||
把硬编码规则(奖励数值等)挪到 DB,运营后台可改。空表=现有行为完全不变(fallback 到代码默认)。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| key | 字符串 | **主键**,配置标识 |
|
||||
| value | JSON | 配置值(任意 int/list/dict) |
|
||||
| updated_by_admin_id | 整数 | 最后修改管理员,可空 |
|
||||
| updated_at | 时间 | 更新时间 |
|
||||
|
||||
## `ops_marquee_seed` — 首页轮播种子
|
||||
|
||||
首页「用户 xxx 比价省了 xx 元」滚动条的兜底假数据**规则**:真实记录不够时用它补齐混播。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| masked_user | 字符串 | 脱敏用户名;留空则展示时随机合成,可空 |
|
||||
| min_cents / max_cents | 金额(分) | 节省金额区间(展示时区间内随机取值) |
|
||||
| enabled | 布尔 | 是否启用(停用不参与混播) |
|
||||
| sort_order | 整数 | 仅后台列表排序(feed 公平随机,不看此字段) |
|
||||
| created_at | 时间 | 时间 |
|
||||
|
||||
## `ops_stat_config` — 首页三统计配置
|
||||
|
||||
首页门面三数字(帮助用户/完成比价/累计节省),每指标独立选展示模式。一行一指标。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| metric | 字符串 | **主键**:help_users / total_compares / total_saved |
|
||||
| mode | 字符串 | real(真实)/ manual(手填固定值)/ random(只增不减伪增长) |
|
||||
| manual_value | 整数 | manual 模式固定值(total_saved 为分),可空 |
|
||||
| random_mult_min / random_mult_max | 整数 | random 倍率区间(千分比,≥1000 只增不减) |
|
||||
| random_tick_seconds | 整数 | random 刷新周期(秒) |
|
||||
| random_anchor_minutes | 整数 | 刷新触发时刻对齐偏移(距北京 0 点分钟数) |
|
||||
| random_current | 整数 | random 当前值,可空 |
|
||||
| random_last_tick_at | 时间 | 上次 tick 时刻,可空 |
|
||||
| random_kind | 字符串 | mult(×倍率)/ add(+绝对增量) |
|
||||
| random_step_min / random_step_max | 整数 | add 模式增量区间 |
|
||||
| real_offset | 整数 | real 模式保底值(展示=max(真实,保底)) |
|
||||
| allow_decrease | 布尔 | 是否允许下调(默认只增不减) |
|
||||
| updated_by_admin_id | 整数 | 最后修改管理员,可空 |
|
||||
| updated_at | 时间 | 更新时间 |
|
||||
|
||||
---
|
||||
|
||||
# 十二、技术沉淀(内部)
|
||||
|
||||
## `launch_confirm_sample` — 启动确认窗样本
|
||||
|
||||
比价 agent 用 LLM 兜底放行跨 App 启动确认窗时落的样本,研发人工沉淀进静态规则。内部排障用,不去重。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | 整数 | **主键** |
|
||||
| created_at | 时间 | 时间 |
|
||||
| trace_id | 字符串 | 比价追踪号(溯源),可空 |
|
||||
| device_id | 字符串 | 设备号,可空 |
|
||||
| host_package | 字符串 | 弹窗宿主包(聚合键),可空 |
|
||||
| target_app | 字符串 | 被打开的目标 App 包名,可空 |
|
||||
| system_locale | 字符串 | 系统语言地区(zh-TW / en-US…),可空 |
|
||||
| exec_success | 布尔 | 本次兜底是否成功放行(研发只沉淀 true 的) |
|
||||
| dialog_title | 字符串 | 弹窗标题全文,可空 |
|
||||
| payload | JSON | 完整样本(弹窗树 + LLM plan + 设备信息),可空 |
|
||||
|
||||
---
|
||||
|
||||
*文档生成基于 `app/models/` 全量模型。如需单表更详细的索引/迁移信息,见 `docs/database/` 下对应同名文件。*
|
||||
@@ -37,7 +37,7 @@ B 安装并首启 App
|
||||
└─ POST /api/v1/invite/bind { invite_code, channel="clipboard" }
|
||||
↓
|
||||
后端 repositories/invite.py bind()
|
||||
└─ 过四道防线 → 建 invite_relation(邀请金币已下线,不写金币流水)
|
||||
└─ 过四道防线 → 建 invite_relation + 给 A、B 各发金币(同事务原子提交)
|
||||
```
|
||||
|
||||
手动填码这条:B 在邀请页输码 → `InviteRepository.bindManual()` → `POST /bind { channel="manual" }` → 同一个 `bind()`。
|
||||
@@ -52,20 +52,20 @@ B 安装并首启 App
|
||||
|---|---|
|
||||
| 端点 | `app/api/v1/invite.py`:`GET /api/v1/invite/me`(返回 `invite_code` + `share_url` + 战绩)、`POST /api/v1/invite/bind`(绑定,`channel` = `clipboard` / `manual`)。**均需 Bearer 鉴权**。 |
|
||||
| share_url 构造 | `invite.py` 的 `my_invite`:`settings.INVITE_LANDING_URL + "?ref=" + code`。`INVITE_LANDING_URL` 在 `app/core/config.py`(默认 `https://app-api.shaguabijia.com/media/dl.html`)。 |
|
||||
| 业务逻辑 | `app/repositories/invite.py`:`ensure_code`(懒生成 6 位邀请码,去混淆字符集,唯一约束碰撞则换码)/ `resolve_inviter`(邀请码→邀请人,大小写不敏感)/ `bind`(下面详述)/ `get_stats`(已邀人数 + 兼容累计金币字段,当前恒为 0)。 |
|
||||
| 业务逻辑 | `app/repositories/invite.py`:`ensure_code`(懒生成 6 位邀请码,去混淆字符集,唯一约束碰撞则换码)/ `resolve_inviter`(邀请码→邀请人,大小写不敏感)/ `bind`(下面详述)/ `get_stats`(已邀人数 + 累计金币)。 |
|
||||
| 数据模型 | `app/models/invite.py` 的 `InviteRelation`(`inviter_user_id` / `invitee_user_id` / `channel` / `status` / `inviter_coin` / `invitee_coin` / `created_at`)+ `app/models/user.py` 的 `User.invite_code` 列。 |
|
||||
| 迁移 | `alembic/versions/invite_code_and_relation.py`:给 `user` 加 `invite_code`(唯一索引)+ 建 `invite_relation` 表。`down_revision = 11a1d08c6f55`。 |
|
||||
| 收发模型 | `app/schemas/invite.py`:`InviteInfoOut` / `BindInviteIn` / `BindInviteOut`。 |
|
||||
| 新人窗口 | `app/core/rewards.py`:`INVITE_NEW_USER_WINDOW_HOURS`(72)。邀请金币已下线,不再配置邀请金币常量。 |
|
||||
| 奖励常量 | `app/core/rewards.py`:`INVITE_INVITER_COINS` / `INVITE_INVITEE_COINS`(各 10000 = 1 元)、`INVITE_NEW_USER_WINDOW_HOURS`(72)。 |
|
||||
|
||||
**`bind()` 的四道防线(防重复 / 防刷,看 `repositories/invite.py`):**
|
||||
|
||||
1. **被邀请人唯一**:`invitee_user_id` 唯一约束 → 一个 B 只能被绑一次(幂等键,重复返回 `already_bound`,不重复绑定)。
|
||||
1. **被邀请人唯一**:`invitee_user_id` 唯一约束 → 一个 B 只能被绑一次(幂等键,重复返回 `already_bound`,不重复发奖)。
|
||||
2. **自邀屏蔽**:`inviter == invitee` → `self_invite`。
|
||||
3. **新人闸**:`_is_new_user`(B 的 `created_at` 在 `INVITE_NEW_USER_WINDOW_HOURS` = 72h 内)才生效,挡存量老用户互相填码刷关系 → 否则 `not_eligible`。
|
||||
3. **新人闸**:`_is_new_user`(B 的 `created_at` 在 `INVITE_NEW_USER_WINDOW_HOURS` = 72h 内)才发奖,挡存量老用户互相填码薅羊毛 → 否则 `not_eligible`。
|
||||
4. **手机号唯一**(天然限量):每个 B = 一个真实手机号账号。
|
||||
|
||||
邀请金币已下线:`bind()` 只记录绑定关系,不再写 `coin_transaction`;响应里的金币字段保留兼容旧客户端,当前恒为 0。
|
||||
发金币复用 `repositories/wallet.py` 的 `grant_coins`,与建关系记录在**同一事务**提交,保证"建关系 + 双方加金币"原子。
|
||||
|
||||
### 3.2 前端(shaguabijia-app-android)
|
||||
|
||||
@@ -120,7 +120,7 @@ B 安装并首启 App
|
||||
### 4.4 测试硬约束 / 坑(都是机制,不是 bug)
|
||||
|
||||
- **B 必须用新手机号**:`invitee_user_id` 唯一,一个 B 只能绑一次;反复测要换号(或手删 `invite_relation` 那行 + 回滚金币)。
|
||||
- **72h 新人闸**:B 注册后 72 小时内绑定才生效(刚注册肯定满足)。
|
||||
- **72h 新人闸**:B 注册后 72 小时内绑才发奖(刚注册肯定满足)。
|
||||
- **A ≠ B**:自邀被屏蔽。
|
||||
- **B 从点下载到首启 App 之间别复制别的东西**:剪贴板会被覆盖 → 归因丢(剪贴板 deferred deeplink 的固有脆弱性)。
|
||||
- **笔记本 IP 别变**:debug 包把 `BASE_URL` 的 IP 烧死在编译期,DHCP 一换就连不上 → 给笔记本固定个 LAN IP。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user