Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c01cc15bb0 | |||
| ea22a11ef9 | |||
| 2edebfad83 | |||
| 3dca126411 | |||
| acdb10b516 | |||
| b1849834f7 | |||
| 8008366abc |
@@ -0,0 +1,116 @@
|
||||
# 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
|
||||
@@ -0,0 +1,65 @@
|
||||
"""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 ###
|
||||
@@ -0,0 +1,26 @@
|
||||
"""merge meituan_coupon_image_meta and comparison/ad heads
|
||||
|
||||
Revision ID: bcfcaf07152b
|
||||
Revises: 4dc2af7ebe74, meituan_coupon_image_meta
|
||||
Create Date: 2026-06-24 20:34:09.475641
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'bcfcaf07152b'
|
||||
down_revision: Union[str, Sequence[str], None] = ('4dc2af7ebe74', 'meituan_coupon_image_meta')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -23,39 +23,64 @@ branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 活动:淘宝淘口令 / 京东链接
|
||||
op.add_column("cps_activity", sa.Column("payload", sa.Text(), nullable=True))
|
||||
def _has_column(insp: sa.Inspector, table: str, column: str) -> bool:
|
||||
return any(c["name"] == column for c in insp.get_columns(table))
|
||||
|
||||
# 群:多平台(现有群都是美团,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"),
|
||||
),
|
||||
|
||||
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\"]'")
|
||||
)
|
||||
op.alter_column("cps_group", "sid", existing_type=sa.String(64), nullable=True)
|
||||
|
||||
# 活动:淘宝淘口令 / 京东链接
|
||||
# 加 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))
|
||||
|
||||
# 群:多平台(现有群都是美团,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)
|
||||
|
||||
# link:sid 可空 + target 加长
|
||||
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))
|
||||
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))
|
||||
|
||||
# click:sid 可空 + 事件类型
|
||||
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"),
|
||||
)
|
||||
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")
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
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")
|
||||
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")
|
||||
|
||||
@@ -24,6 +24,7 @@ from app.admin.routers.config import router as config_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.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
|
||||
@@ -91,6 +92,7 @@ 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)
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.core import rewards
|
||||
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.feedback import Feedback
|
||||
from app.models.onboarding import OnboardingCompletion
|
||||
@@ -412,6 +413,44 @@ def list_feedbacks(
|
||||
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def get_withdraw_by_out_bill_no(db: Session, out_bill_no: str) -> WithdrawOrder | None:
|
||||
"""按商户单号查提现单(admin 重试打款先拿 user_id 用,M3)。"""
|
||||
return db.execute(
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -15,6 +15,7 @@ class AdConfigOut(BaseModel):
|
||||
reward_enabled: bool
|
||||
compare_ad_enabled: bool
|
||||
coupon_ad_enabled: bool
|
||||
withdrawal_ad_enabled: bool
|
||||
|
||||
|
||||
class AdConfigUpdate(BaseModel):
|
||||
@@ -28,3 +29,4 @@ class AdConfigUpdate(BaseModel):
|
||||
reward_enabled: bool | None = None
|
||||
compare_ad_enabled: bool | None = None
|
||||
coupon_ad_enabled: bool | None = None
|
||||
withdrawal_ad_enabled: bool | None = None
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""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
|
||||
@@ -53,6 +53,9 @@ REASON_UNKNOWN_USER = 2 # user_id 不存在(可能伪造)
|
||||
|
||||
REWARD_SCENE_REWARD_VIDEO = "reward_video"
|
||||
REWARD_SCENE_SIGNIN_BOOST = "signin_boost"
|
||||
# 提现看视频:看完才能提现的「硬门槛」广告,**不发金币**,只记一条幂等记录(收益由 eCPM 上报口径
|
||||
# ad_type="withdrawal_video" 单独统计)。故意不放进 SUPPORTED_REWARD_SCENES——它不走发币分支。
|
||||
REWARD_SCENE_WITHDRAWAL_AD = "withdrawal_ad"
|
||||
SUPPORTED_REWARD_SCENES = {REWARD_SCENE_REWARD_VIDEO, REWARD_SCENE_SIGNIN_BOOST}
|
||||
|
||||
|
||||
@@ -117,6 +120,21 @@ 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(
|
||||
@@ -126,6 +144,19 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
|
||||
return PangleCallbackOut(is_verify=True, reason=REASON_OK)
|
||||
|
||||
try:
|
||||
if reward_scene == REWARD_SCENE_WITHDRAWAL_AD:
|
||||
# 提现看视频:已知合法场景,只记一条幂等记录、**不动钱包**(发不发币由 reward_scene 决定)。
|
||||
# is_verify=true 让穿山甲不重试(区别于 unknown_scene 的 false)。
|
||||
crud_ad.record_external_reward(
|
||||
db, user_id, trans_id, coin=0, reward_scene=REWARD_SCENE_WITHDRAWAL_AD,
|
||||
ad_session_id=ad_session_id, ecpm=ecpm,
|
||||
reward_name=params.get("reward_name"), raw=raw[:1024],
|
||||
status="withdrawal_ad",
|
||||
)
|
||||
logger.info(
|
||||
"pangle callback withdrawal_ad user_id=%d trans_id=%s", user_id, trans_id,
|
||||
)
|
||||
return PangleCallbackOut(is_verify=True, reason=REASON_OK)
|
||||
if reward_scene not in SUPPORTED_REWARD_SCENES:
|
||||
rec = crud_ad.record_external_reward(
|
||||
db, user_id, trans_id, coin=0, reward_scene=reward_scene[:32],
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""客户端埋点上报接口。
|
||||
|
||||
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)
|
||||
+37
-4
@@ -12,11 +12,11 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import test_account
|
||||
from app.core.ratelimit import rate_limit
|
||||
from app.core.ratelimit import enforce_rate_limit, 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,6 +38,12 @@ 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 = 10
|
||||
|
||||
|
||||
def _login_response(
|
||||
user, *, onboarding_completed: bool, force_onboarding: bool = False
|
||||
@@ -86,13 +92,26 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser:
|
||||
summary="发送短信验证码",
|
||||
dependencies=[Depends(rate_limit(10, 60, "sms-send"))], # 同 IP 每分钟≤10 次(防一 IP 刷不同号)
|
||||
)
|
||||
def sms_send(req: SmsSendRequest) -> SmsSendResponse:
|
||||
def sms_send(req: SmsSendRequest, request: Request) -> 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:
|
||||
@@ -109,9 +128,10 @@ def sms_send(req: SmsSendRequest) -> SmsSendResponse:
|
||||
summary="手机号+验证码登录",
|
||||
dependencies=[Depends(rate_limit(20, 60, "sms-login"))], # 防撞库爆破(另有单码失败次数上限)
|
||||
)
|
||||
def sms_login(req: SmsLoginRequest, db: DbSession) -> TokenWithUser:
|
||||
def sms_login(req: SmsLoginRequest, request: Request, 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="测试账号今日使用次数已达上限,请明天再试")
|
||||
@@ -123,6 +143,19 @@ def sms_login(req: SmsLoginRequest, db: DbSession) -> TokenWithUser:
|
||||
# 不读/不写 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")
|
||||
|
||||
|
||||
+100
-8
@@ -83,7 +83,10 @@ def cps_landing(code: str, request: Request, db: Session = Depends(get_db)):
|
||||
redirect_uri = f"{settings.CPS_REDIRECT_BASE.rstrip('/')}/wx/oauth/cb"
|
||||
auth_url = wx_oauth.build_authorize_url(redirect_uri, "snsapi_base", f"base:{code}")
|
||||
return RedirectResponse(auth_url, status_code=302)
|
||||
_record(db, link, request, "visit", openid=openid)
|
||||
# ?authed=1 是 userinfo 授权后的自动回跳重载,不是用户主动打开:visit 已在授权前那次
|
||||
# 落地(base 静默授权回来那次)记过,这里再记会让每个走"领券授权"的用户 visit 虚增 1。
|
||||
if request.query_params.get("authed") != "1":
|
||||
_record(db, link, request, "visit", openid=openid)
|
||||
if link.platform == "taobao":
|
||||
activity = db.get(CpsActivity, link.activity_id)
|
||||
image_url = (activity.image_url if activity else None) or _DEFAULT_TAOBAO_IMAGE
|
||||
@@ -91,16 +94,35 @@ 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)
|
||||
)
|
||||
# 美团短链 / 京东链接:直接 302 跳
|
||||
# 美团短链 / 京东链接:微信内 + 已拿 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))
|
||||
return RedirectResponse(link.target_url, status_code=302)
|
||||
|
||||
|
||||
@router.get("/wx/oauth/cb", include_in_schema=False)
|
||||
def wx_oauth_cb(code: str, state: str, db: Session = Depends(get_db)):
|
||||
def wx_oauth_cb(
|
||||
code: str | None = None, state: str | None = None, db: Session = Depends(get_db)
|
||||
):
|
||||
"""微信网页授权回调。state='base:{原code}'(静默拿 openid) 或 'uinfo:{原code}'(补昵称头像)。
|
||||
换 openid(+userinfo)→ upsert 用户 → 种 cookie → 302 回落地页。任何失败兜底回落地页,
|
||||
绝不阻断用户领券。"""
|
||||
kind, _, orig_code = state.partition(":")
|
||||
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
|
||||
)
|
||||
try:
|
||||
token = wx_oauth.exchange_code(code) # {openid, access_token, scope, ...}
|
||||
openid = token["openid"]
|
||||
@@ -136,6 +158,12 @@ 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:
|
||||
@@ -153,8 +181,8 @@ def _taobao_landing_html(
|
||||
return (
|
||||
_TAOBAO_HTML
|
||||
.replace("__IMAGE_URL__", safe_img)
|
||||
.replace("__UINFO_URL__", json.dumps(uinfo_url))
|
||||
.replace("__TOKEN_JS__", json.dumps(token))
|
||||
.replace("__UINFO_URL__", _js_str(uinfo_url))
|
||||
.replace("__TOKEN_JS__", _js_str(token))
|
||||
)
|
||||
|
||||
|
||||
@@ -201,8 +229,72 @@ function copyToken(){
|
||||
if(UINFO_URL){location.href=UINFO_URL;return}
|
||||
doCopy();
|
||||
}
|
||||
// userinfo 授权回流(?authed=1):自动复制,把"点领券→授权→复制"衔接成一步无感
|
||||
if(location.search.indexOf('authed=1')>=0){doCopy()}
|
||||
// userinfo 授权回流(?authed=1):自动复制,把"点领券→授权→复制"衔接成一步无感;
|
||||
// 复制后用 replaceState 清掉 authed=1,避免刷新/微信返回(BFCache)重载时重复触发复制上报。
|
||||
if(location.search.indexOf('authed=1')>=0){doCopy();history.replaceState(null,'',location.pathname)}
|
||||
</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>"""
|
||||
|
||||
@@ -68,6 +68,7 @@ def ad_config(db: DbSession) -> AdConfigPublicOut:
|
||||
reward_enabled=c["reward_enabled"],
|
||||
compare_ad_enabled=c["compare_ad_enabled"],
|
||||
coupon_ad_enabled=c["coupon_ad_enabled"],
|
||||
withdrawal_ad_enabled=c["withdrawal_ad_enabled"],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -87,5 +87,9 @@ 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
|
||||
|
||||
@@ -57,3 +57,29 @@ 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,
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
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
|
||||
@@ -105,6 +106,7 @@ 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)
|
||||
|
||||
@@ -4,6 +4,7 @@ from app.models.ad_feed_reward import AdFeedRewardRecord # 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
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""新手引导(及后续)埋点事件表。
|
||||
|
||||
每行 = 客户端上报的一条行为埋点,按「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}>"
|
||||
@@ -0,0 +1,34 @@
|
||||
"""埋点事件批量落库。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)
|
||||
@@ -109,6 +109,7 @@ _AD_CONFIG_DEFAULTS: dict[str, Any] = {
|
||||
"reward_enabled": True, # 福利激励视频开关
|
||||
"compare_ad_enabled": True, # 比价广告开关
|
||||
"coupon_ad_enabled": True, # 领券广告开关
|
||||
"withdrawal_ad_enabled": True, # 提现激励视频开关(关=直接放行提现,不挡用户的钱)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""客户端埋点上报 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,6 +71,10 @@ 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):
|
||||
|
||||
@@ -43,6 +43,7 @@ class AdConfigPublicOut(BaseModel):
|
||||
reward_enabled: bool # 福利激励视频开关
|
||||
compare_ad_enabled: bool # 比价广告开关
|
||||
coupon_ad_enabled: bool # 领券广告开关
|
||||
withdrawal_ad_enabled: bool # 提现激励视频开关(关=客户端直接放行提现)
|
||||
|
||||
|
||||
class AppVersionOut(BaseModel):
|
||||
|
||||
@@ -0,0 +1,820 @@
|
||||
# 傻瓜比价 数据表字典(产品参考)
|
||||
|
||||
> 面向产品/运营的全量数据表说明,按业务域分组。来源:`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/` 下对应同名文件。*
|
||||
@@ -17,6 +17,12 @@ REM avoids CRLF/encoding fights every time someone bash-runs the .sh on Win.
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
REM Prefer the project virtualenv (.venv) so we never inherit a wrong
|
||||
REM global/conda interpreter. FastAPI<0.115 on Pydantic 2.12 crashes at import
|
||||
REM with "'FieldInfo' object has no attribute 'in_'". Falls back to PATH python.
|
||||
set "PY=python"
|
||||
if exist "%~dp0.venv\Scripts\python.exe" set "PY=%~dp0.venv\Scripts\python.exe"
|
||||
|
||||
if not exist .env (
|
||||
echo [X] Missing .env. Run: copy .env.example .env and fill JWT_SECRET_KEY ^(plus MT_CPS_* if you test Meituan^)
|
||||
exit /b 1
|
||||
@@ -25,11 +31,11 @@ if not exist .env (
|
||||
if not exist data mkdir data
|
||||
|
||||
REM Build/upgrade SQLite schema (idempotent; no-op if already at head)
|
||||
call alembic upgrade head
|
||||
call "%PY%" -m alembic upgrade head
|
||||
if errorlevel 1 (
|
||||
echo [X] alembic upgrade head failed
|
||||
exit /b %errorlevel%
|
||||
)
|
||||
|
||||
REM Long-running foreground process. Ctrl+C to stop.
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8770 --reload
|
||||
"%PY%" -m uvicorn app.main:app --host 0.0.0.0 --port 8770 --reload
|
||||
|
||||
@@ -7,12 +7,20 @@
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# 优先用项目 venv 的解释器,避免误用全局/conda 里不兼容的 FastAPI/Pydantic
|
||||
# (FastAPI<0.115 撞 Pydantic 2.12 会在导入期崩 'FieldInfo' has no attribute 'in_')。
|
||||
PY=python
|
||||
[ -x .venv/bin/python ] && PY=.venv/bin/python
|
||||
[ -x .venv/Scripts/python.exe ] && PY=.venv/Scripts/python.exe
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo "❌ 缺 .env:先 cp .env.example .env 并填值(至少 JWT_SECRET_KEY;测美团再填 MT_CPS_*)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p data # sqlite 文件所在目录
|
||||
alembic upgrade head # 确保表已建(幂等,已是最新则 no-op)
|
||||
"$PY" -m alembic upgrade head # 确保表已建(幂等,已是最新则 no-op)
|
||||
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8770 --reload
|
||||
# --reload 只盯源码目录 app/:别去监视 logs/(日志写入触发"检测→再写日志"回环)和
|
||||
# data/(sqlite 频繁写)。改 alembic/、.env、本脚本后请手动重启。
|
||||
exec "$PY" -m uvicorn app.main:app --host 0.0.0.0 --port 8770 --reload --reload-dir app
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
"""一次性 mock:造 CPS「按群对账统计」+「群内微信用户(领券画像)」所需假数据。
|
||||
|
||||
覆盖两个后台接口:
|
||||
• get_stats (GET /admin/cps/stats) → cps_repo.group_stats
|
||||
• group_wx_users (GET /admin/cps/groups/{id}/wx-users) → cps_repo.group_wx_users
|
||||
|
||||
涉及四张表:
|
||||
- cps_group 群(active 才入统计;sid 仅美团群有)
|
||||
- cps_order 对账订单(按 sid 归群;status 2付款 3完成=有效 / 4取消 5风控=无效 / 6结算)
|
||||
- cps_click 点击(group_stats 按 group_id 聚合 pv/uv;group_wx_users 只看带 openid 的点击)
|
||||
- cps_wx_user 落地页微信用户画像(nickname/headimgurl;userinfo 授权后才有)
|
||||
|
||||
覆盖 group_stats 全部分支:① active 美团群(对账) ② 美团+淘宝多平台 ③ 纯淘宝/京东(无 sid,
|
||||
对账字段 None) ④ archived 群(被过滤) ⑤ 未归群历史 sid(group_id=None 单列)。
|
||||
|
||||
覆盖 group_wx_users 全部分支(挂在淘宝系群 B/C —— 仅淘宝落地页有"复制口令"触发 userinfo):
|
||||
• 有完整画像(昵称+可加载头像)的领券用户(visit+copy)
|
||||
• 只 visit 不 copy 的用户(无领券意向)
|
||||
• 跨群用户(在 C 首次授权、又来 B 活动)→ 验证"按本群点击判定归属,而非 first_group_id"
|
||||
• 有 openid 但无画像(base 静默授权)→ nickname/headimgurl 返回 None(前端走色块兜底)
|
||||
|
||||
头像:本地手写纯色 PNG 落 data/media/cps_wx/,headimgurl 存绝对 URL(经 App 后端 :8770 的
|
||||
/media 托管),后台 <img> 可直接加载。时间均落最近几天(默认窗口内),tz-aware UTC。
|
||||
|
||||
幂等:重跑先按 mock 标识(群名前缀「【MOCK】」/ 订单号前缀「MOCKCPS-」/ openid 前缀
|
||||
「oMOCKcps」)清旧再重建;点击/短链按 mock 群 id 清,头像文件按 glob 清。--clean-only 只清。
|
||||
|
||||
python -m scripts.seed_mock_cps_stats
|
||||
python -m scripts.seed_mock_cps_stats --clean-only
|
||||
|
||||
看数据:后台「CPS / 按群对账统计」页 + 群详情「群内微信用户」,或:
|
||||
GET /admin/cps/stats?days=30
|
||||
GET /admin/cps/groups/{B或C的id}/wx-users
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import struct
|
||||
import sys
|
||||
import zlib
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.cps_group import CpsGroup
|
||||
from app.models.cps_link import CpsClick, CpsLink
|
||||
from app.models.cps_order import CpsOrder
|
||||
from app.models.cps_wx_user import CpsWxUser
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8") # Windows 控制台输出中文/¥
|
||||
|
||||
# ── mock 标识(只动带这些标识的数据,便于幂等清理)──
|
||||
NAME_PREFIX = "【MOCK】" # 群名前缀
|
||||
ORDER_PREFIX = "MOCKCPS-" # 订单号前缀
|
||||
OPENID_PREFIX = "oMOCKcps" # 微信用户 openid 前缀
|
||||
SID_MT_A = "mockcpsmta" # 群A sid(美团);sid 仅字母+数字
|
||||
SID_MT_B = "mockcpsmtb" # 群B sid(美团+淘宝)
|
||||
SID_ARCHIVED = "mockcpsmtz" # 已归档群 sid
|
||||
SID_ORPHAN = "mockcpsold" # 未归群历史 sid(不属于任何群)
|
||||
|
||||
# 头像:本地纯色 PNG 落 media/cps_wx/,headimgurl 存「绝对 URL」(微信场景本就是完整外链,
|
||||
# 后台直接当 <img src>);本地由 App 后端 :8770 托管 /media。
|
||||
APP_MEDIA_BASE = "http://localhost:8770"
|
||||
_AVATAR_DIR = Path(settings.MEDIA_ROOT) / "cps_wx"
|
||||
_AVATAR_GLOB = "mock_wx_*.png"
|
||||
|
||||
# 微信用户画像: (openid 后缀, 昵称|None=无画像, 头像色, 首次授权群 key)
|
||||
WX_PROFILES = [
|
||||
("c1", "省钱小张", (250, 173, 20), "C"),
|
||||
("c2", "薅羊毛阿珍", (82, 196, 26), "C"),
|
||||
("c3", "随便看看", (24, 144, 255), "C"),
|
||||
("sh", "比价小能手", (114, 46, 209), "C"), # 跨群:C 首授权,也来 B
|
||||
("b1", "深夜下单选手", (245, 34, 45), "B"),
|
||||
("b2", "团购队长", (19, 194, 194), "B"),
|
||||
("anon", None, None, "C"), # 无画像(base 静默授权,只有 openid)
|
||||
]
|
||||
# 每个用户在各群的点击活动: (群 key, openid 后缀, visit 次数, copy 次数)
|
||||
WX_ACTIVITY = [
|
||||
("C", "c1", 3, 2), ("C", "c2", 2, 1), ("C", "c3", 1, 0),
|
||||
("C", "sh", 5, 3), ("C", "anon", 2, 1),
|
||||
("B", "b1", 2, 1), ("B", "b2", 3, 0), ("B", "sh", 2, 1), # sh 跨群
|
||||
]
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _openid(suffix: str) -> str:
|
||||
return f"{OPENID_PREFIX}_{suffix}"
|
||||
|
||||
|
||||
def _solid_png(rgb: tuple[int, int, int], size: int = 96) -> bytes:
|
||||
"""纯色 PNG(truecolor RGB)字节,无需 Pillow。色块即可肉眼判断头像加载出来了。"""
|
||||
def _chunk(typ: bytes, data: bytes) -> bytes:
|
||||
body = typ + data
|
||||
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
|
||||
|
||||
ihdr = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0) # 8bit/通道, color type 2 = RGB
|
||||
idat = zlib.compress((b"\x00" + bytes(rgb) * size) * size, 9)
|
||||
return b"\x89PNG\r\n\x1a\n" + _chunk(b"IHDR", ihdr) + _chunk(b"IDAT", idat) + _chunk(b"IEND", b"")
|
||||
|
||||
|
||||
def _write_avatar(suffix: str, rgb: tuple[int, int, int]) -> str:
|
||||
"""写一张 mock 头像到 media/cps_wx/,返回可加载的绝对 URL。"""
|
||||
_AVATAR_DIR.mkdir(parents=True, exist_ok=True)
|
||||
name = f"mock_wx_{suffix}.png"
|
||||
(_AVATAR_DIR / name).write_bytes(_solid_png(rgb))
|
||||
return f"{APP_MEDIA_BASE}{settings.MEDIA_URL_PREFIX}/cps_wx/{name}"
|
||||
|
||||
|
||||
def clean(db) -> dict:
|
||||
"""按 mock 标识清旧数据。点击/短链按 mock 群 id 清,订单按号前缀清。"""
|
||||
mock_gids = list(
|
||||
db.execute(select(CpsGroup.id).where(CpsGroup.name.like(f"{NAME_PREFIX}%"))).scalars()
|
||||
)
|
||||
n_click = n_link = n_group = 0
|
||||
if mock_gids:
|
||||
n_click = db.execute(
|
||||
delete(CpsClick).where(CpsClick.group_id.in_(mock_gids))
|
||||
).rowcount or 0
|
||||
n_link = db.execute(
|
||||
delete(CpsLink).where(CpsLink.group_id.in_(mock_gids))
|
||||
).rowcount or 0
|
||||
n_group = db.execute(
|
||||
delete(CpsGroup).where(CpsGroup.id.in_(mock_gids))
|
||||
).rowcount or 0
|
||||
n_order = db.execute(
|
||||
delete(CpsOrder).where(CpsOrder.order_id.like(f"{ORDER_PREFIX}%"))
|
||||
).rowcount or 0
|
||||
n_wx = db.execute(
|
||||
delete(CpsWxUser).where(CpsWxUser.openid.like(f"{OPENID_PREFIX}%"))
|
||||
).rowcount or 0
|
||||
db.commit()
|
||||
# 删 mock 头像文件
|
||||
if _AVATAR_DIR.exists():
|
||||
for f in _AVATAR_DIR.glob(_AVATAR_GLOB):
|
||||
f.unlink(missing_ok=True)
|
||||
return {"group": n_group, "order": n_order, "click": n_click, "link": n_link, "wx_user": n_wx}
|
||||
|
||||
|
||||
def _add_clicks(db, *, group_id: int, sid: str | None, link_id: int,
|
||||
visit: int, visit_uv: int, copy: int = 0, copy_uv: int = 0,
|
||||
now: datetime) -> None:
|
||||
"""造点击:visit 条 visit 事件(visit_uv 个去重身份循环)+ copy 条 copy 事件。
|
||||
pv = 总条数,uv = distinct(ip,ua) = min(uv_arg, 条数)。clicked_at 摊在最近 6 天。"""
|
||||
def emit(n: int, uv: int, event: str) -> None:
|
||||
uv = max(1, min(uv, n)) if n else 0
|
||||
for k in range(n):
|
||||
ident = k % uv # 循环复用身份 → pv>uv
|
||||
db.add(CpsClick(
|
||||
link_id=link_id, group_id=group_id, sid=sid, event_type=event,
|
||||
ip=f"10.{group_id}.{event[0]}.{ident}",
|
||||
ua=f"MockUA/{event}-{ident}",
|
||||
clicked_at=now - timedelta(days=k % 6, hours=k),
|
||||
))
|
||||
emit(visit, visit_uv, "visit")
|
||||
emit(copy, copy_uv, "copy")
|
||||
|
||||
|
||||
def _add_orders(db, *, sid: str, specs: list[tuple[str, int, int, str]], now: datetime,
|
||||
tag: str) -> None:
|
||||
"""specs: [(mt_status, pay_price_cents, commission_cents, product_name), ...]"""
|
||||
for i, (status, pay, comm, name) in enumerate(specs):
|
||||
db.add(CpsOrder(
|
||||
order_id=f"{ORDER_PREFIX}{tag}-{i:02d}",
|
||||
sid=sid, act_id="100001", biz_line=1, trade_type=1,
|
||||
pay_price_cents=pay, commission_cents=comm, commission_rate="500",
|
||||
refund_price_cents=None, refund_profit_cents=None,
|
||||
mt_status=status, product_name=name,
|
||||
pay_time=now - timedelta(days=i % 6, hours=i),
|
||||
mt_update_time=now - timedelta(days=i % 6),
|
||||
raw={"mock": True, "status": status},
|
||||
))
|
||||
|
||||
|
||||
def seed(db) -> dict:
|
||||
now = _now()
|
||||
|
||||
# ── 1) 建群(覆盖 active 美团 / 美团+淘宝 / 纯淘宝 / 纯京东 / archived)──
|
||||
groups = [
|
||||
CpsGroup(sid=SID_MT_A, name=f"{NAME_PREFIX}美团外卖群A",
|
||||
platforms=["meituan"], member_count=480, status="active",
|
||||
remark="mock-纯美团,走对账"),
|
||||
CpsGroup(sid=SID_MT_B, name=f"{NAME_PREFIX}美团+淘宝群B",
|
||||
platforms=["meituan", "taobao"], member_count=320, status="active",
|
||||
remark="mock-多平台,对账+复制口令"),
|
||||
CpsGroup(sid=None, name=f"{NAME_PREFIX}淘宝券群C",
|
||||
platforms=["taobao"], member_count=210, status="active",
|
||||
remark="mock-无 sid,对账字段 None"),
|
||||
CpsGroup(sid=None, name=f"{NAME_PREFIX}京东券群D",
|
||||
platforms=["jd"], member_count=150, status="active",
|
||||
remark="mock-无 sid,对账字段 None"),
|
||||
CpsGroup(sid=SID_ARCHIVED, name=f"{NAME_PREFIX}已归档美团群E",
|
||||
platforms=["meituan"], member_count=90, status="archived",
|
||||
remark="mock-archived,应被统计排除"),
|
||||
]
|
||||
db.add_all(groups)
|
||||
db.flush() # 拿 group.id
|
||||
g_a, g_b, g_c, g_d, g_e = groups
|
||||
|
||||
# ── 2) 每群一条短链(cps_click.link_id 非空;无 FK,activity_id 占位即可)──
|
||||
links = {}
|
||||
for g in groups:
|
||||
link = CpsLink(
|
||||
code=f"MK{g.id:05d}", group_id=g.id, activity_id=0,
|
||||
sid=g.sid, platform=(g.platforms[0] if g.platforms else "meituan"),
|
||||
target_url=f"https://example.com/mock/{g.id}",
|
||||
)
|
||||
db.add(link)
|
||||
links[g.id] = link
|
||||
db.flush() # 拿 link.id
|
||||
|
||||
# ── 3) 点击(pv>uv;淘宝群带 copy 复制口令)──
|
||||
_add_clicks(db, group_id=g_a.id, sid=g_a.sid, link_id=links[g_a.id].id,
|
||||
visit=5, visit_uv=3, now=now)
|
||||
_add_clicks(db, group_id=g_b.id, sid=g_b.sid, link_id=links[g_b.id].id,
|
||||
visit=8, visit_uv=5, copy=3, copy_uv=2, now=now)
|
||||
_add_clicks(db, group_id=g_c.id, sid=None, link_id=links[g_c.id].id,
|
||||
visit=6, visit_uv=4, copy=4, copy_uv=3, now=now)
|
||||
_add_clicks(db, group_id=g_d.id, sid=None, link_id=links[g_d.id].id,
|
||||
visit=4, visit_uv=3, now=now)
|
||||
_add_clicks(db, group_id=g_e.id, sid=g_e.sid, link_id=links[g_e.id].id,
|
||||
visit=3, visit_uv=2, now=now) # archived,统计里看不到
|
||||
|
||||
# ── 3.5) 微信用户画像 + 带 openid 的点击(挂淘宝系群 B/C;这些点击也计入 stats 的 pv/uv)──
|
||||
groups_by_key = {"A": g_a, "B": g_b, "C": g_c, "D": g_d, "E": g_e}
|
||||
# 建画像;无昵称的 anon 不建 CpsWxUser → group_wx_users 里 nickname/headimgurl 为 None
|
||||
for i, (suf, nickname, rgb, fg_key) in enumerate(WX_PROFILES):
|
||||
if nickname is None:
|
||||
continue
|
||||
fg = groups_by_key[fg_key]
|
||||
db.add(CpsWxUser(
|
||||
openid=_openid(suf), nickname=nickname, headimgurl=_write_avatar(suf, rgb),
|
||||
first_code=links[fg.id].code, first_group_id=fg.id,
|
||||
first_seen=now - timedelta(days=3, hours=i), last_seen=now - timedelta(hours=1),
|
||||
))
|
||||
# 逐条 openid 点击:同一 openid 固定一个 (ip,ua) 身份(跨群复用=同人同设备)
|
||||
idx_of = {_openid(suf): i for i, (suf, *_rest) in enumerate(WX_PROFILES)}
|
||||
counter = [0]
|
||||
|
||||
def _wx_clicks(group: CpsGroup, openid: str, visit: int, copy: int) -> None:
|
||||
ident = idx_of[openid]
|
||||
ip, ua = f"100.64.{ident}.{ident}", f"WeChat/8.0 {openid}"
|
||||
for event, n in (("visit", visit), ("copy", copy)):
|
||||
for _ in range(n):
|
||||
counter[0] += 1
|
||||
db.add(CpsClick(
|
||||
link_id=links[group.id].id, group_id=group.id, sid=group.sid,
|
||||
event_type=event, ip=ip, ua=ua, openid=openid,
|
||||
clicked_at=now - timedelta(hours=counter[0]),
|
||||
))
|
||||
|
||||
for gk, suf, visit, copy in WX_ACTIVITY:
|
||||
_wx_clicks(groups_by_key[gk], _openid(suf), visit, copy)
|
||||
|
||||
# ── 4) 订单(按 sid 归群;状态覆盖 有效/无效/结算)──
|
||||
# 群A:有效5(2,3,3,6,6) 结算2 取消2(4,5)
|
||||
_add_orders(db, sid=SID_MT_A, tag="A", now=now, specs=[
|
||||
("2", 3580, 180, "麦当劳 板烧鸡腿堡套餐"),
|
||||
("3", 4200, 210, "瑞幸 生椰拿铁×2"),
|
||||
("3", 2890, 140, "肯德基 早餐组合"),
|
||||
("6", 5100, 255, "海底捞 双人套餐"),
|
||||
("6", 3300, 165, "蜜雪冰城 多杯装"),
|
||||
("4", 2600, 130, "(取消)星巴克 美式"),
|
||||
("5", 1990, 90, "(风控)某外卖单"),
|
||||
])
|
||||
# 群B:有效3(3,6,2) 结算1 取消1(4)
|
||||
_add_orders(db, sid=SID_MT_B, tag="B", now=now, specs=[
|
||||
("3", 6800, 340, "必胜客 双人餐"),
|
||||
("6", 4500, 225, "喜茶 多肉葡萄×3"),
|
||||
("2", 3120, 150, "华莱士 全鸡套餐"),
|
||||
("4", 2200, 110, "(取消)某甜品单"),
|
||||
])
|
||||
# 未归群历史 sid(不属于任何群)→ 结果里 group_id=None 单列
|
||||
_add_orders(db, sid=SID_ORPHAN, tag="OLD", now=now, specs=[
|
||||
("6", 2800, 140, "历史结算单"),
|
||||
("3", 3600, 180, "历史完成单"),
|
||||
])
|
||||
|
||||
db.commit()
|
||||
return {
|
||||
"groups": len(groups), "orders": 7 + 4 + 2,
|
||||
"anon_clicks": 5 + (8 + 3) + (6 + 4) + 4 + 3,
|
||||
"wx_clicks": sum(v + c for *_, v, c in WX_ACTIVITY),
|
||||
"wx_users": len({suf for _, suf, *_ in WX_ACTIVITY}),
|
||||
"wx_profiles": sum(1 for _, nn, *_ in WX_PROFILES if nn is not None),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="造 CPS 按群对账统计 mock 数据(后台 get_stats 用)")
|
||||
parser.add_argument("--clean-only", action="store_true", help="只清理 mock 数据,不重建")
|
||||
args = parser.parse_args()
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
removed = clean(db)
|
||||
if any(removed.values()):
|
||||
print(f"🧹 已清理旧 mock:群{removed['group']} 订单{removed['order']} "
|
||||
f"点击{removed['click']} 短链{removed['link']} 微信用户{removed['wx_user']}")
|
||||
if args.clean_only:
|
||||
print("✅ 仅清理,已完成。")
|
||||
return
|
||||
|
||||
r = seed(db)
|
||||
print(f"\n✅ 已生成:{r['groups']} 群 / {r['orders']} 订单 / "
|
||||
f"{r['anon_clicks'] + r['wx_clicks']} 点击(其中带 openid {r['wx_clicks']}) / "
|
||||
f"{r['wx_users']} 微信用户(含画像 {r['wx_profiles']})\n")
|
||||
print("① 预期 get_stats(days=30)(B/C 的 pv/uv 已含带 openid 的用户点击):")
|
||||
print(" 群A(美团,sid=%s) : 有效5 结算2 取消2 | GMV ¥190.70 预估 ¥9.50 已结算 ¥4.20 | 点击 pv5/uv3"
|
||||
% SID_MT_A)
|
||||
print(" 群B(美团+淘宝,sid=%s): 有效3 结算1 取消1 | GMV ¥144.20 预估 ¥7.15 已结算 ¥2.25 | 点击 pv15/uv8 复制 pv5/uv4"
|
||||
% SID_MT_B)
|
||||
print(" 群C(淘宝,无 sid) : 对账字段 '-' | 点击 pv19/uv9 复制 pv11/uv7")
|
||||
print(" 群D(京东,无 sid) : 对账字段 '-' | 点击 pv4/uv3")
|
||||
print(" 群E(已归档) : 不出现在结果(status 过滤)")
|
||||
print(" 未归群 sid=%s : group_id=None 单列 | 有效2 结算1 | GMV ¥64.00 预估 ¥3.20 已结算 ¥1.40"
|
||||
% SID_ORPHAN)
|
||||
print(" 合计:订单 10 | 预估佣金 ¥19.85 | 已结算佣金 ¥7.85")
|
||||
print("\n② 预期 group_wx_users(按本群首次点击倒序):")
|
||||
print(" 群C(5 人): 省钱小张 v3/c2 | 薅羊毛阿珍 v2/c1 | 随便看看 v1/c0 | "
|
||||
"比价小能手 v5/c3 | (匿名·无画像) v2/c1")
|
||||
print(" 群B(3 人): 深夜下单选手 v2/c1 | 团购队长 v3/c0 | 比价小能手 v2/c1(跨群,首授权在 C)")
|
||||
print(" 群A/D/E : 无带 openid 点击 → 返回 []")
|
||||
print("\n👉 验收:GET /admin/cps/stats?days=30")
|
||||
print(" GET /admin/cps/groups/{群B或C的id}/wx-users(头像经 :8770 /media 加载,App 后端需在跑)")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -12,6 +12,7 @@ FastAPI TestClient 在进程内打**真实** endpoint(`/api/v1/ad/pangle-callbac
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
@@ -56,6 +57,11 @@ def main() -> None:
|
||||
p = argparse.ArgumentParser(description="模拟穿山甲发奖回调")
|
||||
p.add_argument("user_id", nargs="?", type=int, default=None, help="目标 user_id(默认最近注册)")
|
||||
p.add_argument("--trans", default=None, help="交易号(默认随机);同号重跑验幂等")
|
||||
p.add_argument(
|
||||
"--srv-env", default=None,
|
||||
help="模拟客户端 mediaExtra 里的后端环境标记(dev/prod)。与本服 APP_ENV 不符时"
|
||||
"应被环境隔离拦下(Δ0、不串号);留空=不带该字段(旧客户端,照常发)",
|
||||
)
|
||||
args = p.parse_args()
|
||||
|
||||
if not settings.pangle_callback_configured:
|
||||
@@ -75,6 +81,9 @@ def main() -> None:
|
||||
# 镜像生产:后台代码位"奖励数量"应配成 AD_REWARD_COIN,回调据此发金币
|
||||
"reward_amount": str(AD_REWARD_COIN),
|
||||
}
|
||||
# 镜像穿山甲透传 mediaExtra:把后端环境标记放进 gromoreExtra(服务端据此做环境隔离)
|
||||
if args.srv_env:
|
||||
params["gromoreExtra"] = json.dumps({"srv_env": args.srv_env})
|
||||
params["sign"] = pangle.build_sign(trans_id, settings.PANGLE_REWARD_SECRET)
|
||||
|
||||
resp = TestClient(app).get("/api/v1/ad/pangle-callback", params=params)
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.config import settings
|
||||
@@ -179,6 +181,38 @@ def test_callback_unknown_user(client) -> None:
|
||||
assert r.json() == {"is_verify": False, "reason": 2}
|
||||
|
||||
|
||||
def test_callback_foreign_env_skipped(client) -> None:
|
||||
"""回调 srv_env 与本服 APP_ENV 不符(测试包的 S2S 打到生产)→ 受理但不发币、不串号。
|
||||
|
||||
防的是跨库串号:同一 user_id 在测试库 / 正式库是两个人,回调地址只配生产,测试包(dev)看广告
|
||||
的 S2S 也会打到生产。测试环境 APP_ENV=dev:srv_env=prod 视为「别的环境」→ 跳过,余额不变、
|
||||
不写记录;srv_env=dev(本环境)照常发。srv_env 走 gromoreExtra 透传(穿山甲带回 mediaExtra 的字段)。
|
||||
兼容:不带 srv_env 的旧客户端按本环境处理(其余用例已覆盖)。
|
||||
"""
|
||||
phone = "13800003501"
|
||||
token = _login(client, phone)
|
||||
uid = _user_id(phone)
|
||||
|
||||
# 外环境(prod)回调:受理(is_verify=true 防穿山甲重试)但不发币、不留记录
|
||||
foreign = _signed(uid, "trans_foreign_env", ecpm="200",
|
||||
gromoreExtra=json.dumps({"srv_env": "prod"}))
|
||||
r = _callback(client, foreign)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {"is_verify": True, "reason": 0}
|
||||
assert _coin_balance(client, token) == 0
|
||||
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
|
||||
assert st["used_today"] == 0
|
||||
|
||||
# 本环境(dev)回调:照常发币
|
||||
same = _signed(uid, "trans_same_env", ecpm="200",
|
||||
gromoreExtra=json.dumps({"srv_env": "dev"}))
|
||||
r = _callback(client, same)
|
||||
assert r.status_code == 200, r.text
|
||||
assert _coin_balance(client, token) == calculate_ad_reward_coin("200", 1)
|
||||
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
|
||||
assert st["used_today"] == 1
|
||||
|
||||
|
||||
def test_reward_status_requires_auth(client) -> None:
|
||||
"""客户端进度接口需登录。"""
|
||||
r = client.get("/api/v1/ad/reward-status")
|
||||
|
||||
@@ -74,6 +74,73 @@ def test_sms_send_too_frequent(client) -> None:
|
||||
assert client.post("/api/v1/auth/sms/send", json={"phone": phone}).status_code == 429
|
||||
|
||||
|
||||
def test_sms_send_device_ip_rate_limit(client, monkeypatch) -> None:
|
||||
"""发码防刷:同一设备(device_id) + 同一 IP 每小时最多 N 次发码,超出 429。
|
||||
用不同手机号(绕开单号 60s 冷却)证明限流按设备封顶 —— 堵「换号绕开单号冷却/每日上限」的洞。
|
||||
conftest 默认关限流;本用例临时打开 + 调小阈值(避开同 IP 10/分钟那道)+ 清计数隔离。"""
|
||||
from app.api.v1 import auth
|
||||
from app.core import ratelimit
|
||||
|
||||
monkeypatch.setattr(ratelimit.settings, "RATE_LIMIT_ENABLED", True)
|
||||
monkeypatch.setattr(auth, "SMS_SEND_MAX_PER_HOUR_PER_DEVICE", 3)
|
||||
ratelimit._buckets.clear()
|
||||
|
||||
device = "dev-send-A"
|
||||
# 同设备、每次换不同手机号(绕开单号冷却)发码,前 3 次 200
|
||||
for i in range(3):
|
||||
r = client.post(
|
||||
"/api/v1/auth/sms/send",
|
||||
json={"phone": f"137001370{i:02d}", "device_id": device},
|
||||
)
|
||||
assert r.status_code == 200, f"第 {i + 1} 次应放行: {r.text}"
|
||||
# 第 4 次:同设备超限 → 429(即便又换了手机号)
|
||||
r = client.post(
|
||||
"/api/v1/auth/sms/send",
|
||||
json={"phone": "13700137003", "device_id": device},
|
||||
)
|
||||
assert r.status_code == 429, r.text
|
||||
|
||||
# 换个设备 → 独立计数(限流键含 device_id),放行
|
||||
r = client.post(
|
||||
"/api/v1/auth/sms/send",
|
||||
json={"phone": "13700137004", "device_id": "dev-send-B"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_sms_login_device_ip_rate_limit(client, monkeypatch) -> None:
|
||||
"""防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_LOGIN_MAX_PER_HOUR 次登录尝试,超出 429。
|
||||
conftest 默认 RATE_LIMIT_ENABLED=false(内存计数跨用例累加),本用例临时打开并清空计数隔离。"""
|
||||
from app.api.v1 import auth
|
||||
from app.core import ratelimit
|
||||
|
||||
monkeypatch.setattr(ratelimit.settings, "RATE_LIMIT_ENABLED", True)
|
||||
ratelimit._buckets.clear() # 隔离:清掉跨用例累加的内存计数
|
||||
|
||||
device = "dev-rl-A"
|
||||
# 前 N 次故意每次换手机号、固定设备:mock 校验放行 → 200。证明限流按设备而非手机号,同设备共用一个桶
|
||||
for i in range(auth.SMS_LOGIN_MAX_PER_HOUR):
|
||||
r = client.post(
|
||||
"/api/v1/auth/sms/login",
|
||||
json={"phone": f"135331533{i:02d}", "code": "123456", "device_id": device},
|
||||
)
|
||||
assert r.status_code == 200, f"第 {i + 1} 次应放行: {r.text}"
|
||||
# 第 N+1 次:同设备同 IP 超限 → 429(即便又换了个手机号)
|
||||
r = client.post(
|
||||
"/api/v1/auth/sms/login",
|
||||
json={"phone": "13533153399", "code": "123456", "device_id": device},
|
||||
)
|
||||
assert r.status_code == 429, r.text
|
||||
assert "频繁" in r.json()["detail"]
|
||||
|
||||
# 同 IP 换个设备 → 独立计数(限流键含 device_id、非纯 IP/手机号),仍放行
|
||||
r = client.post(
|
||||
"/api/v1/auth/sms/login",
|
||||
json={"phone": "13533153300", "code": "123456", "device_id": "dev-rl-B"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_sms_login_bad_code(client) -> None:
|
||||
"""mock 下校验位数:5 位过 schema 但业务要 6 位 → 400。"""
|
||||
phone = "13700137000"
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""美团/京东 CPS 落地页「领券前授权头像」插页(/c/{code})。
|
||||
|
||||
验证:微信内未授权头像 → 出插页(snsapi_userinfo 按钮 + 「直接领券」出口);
|
||||
已授权(wx_uinfo cookie) / 非微信 → 直接 302 跳 target。
|
||||
插页只渲染 HTML、不真调微信,故无需 mock 微信网络。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import SessionLocal
|
||||
from app.repositories import cps_link as cps_link_repo
|
||||
|
||||
_WECHAT_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) MicroMessenger/8.0.40"
|
||||
_OTHER_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) Safari/605.1"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _wx_oauth_on(monkeypatch):
|
||||
"""打开微信网页授权(插页只在 settings.wx_oauth_active 时出)。"""
|
||||
monkeypatch.setattr(settings, "WX_MP_APPID", "wxtestappid")
|
||||
monkeypatch.setattr(settings, "WX_MP_SECRET", "wxtestsecret")
|
||||
monkeypatch.setattr(settings, "WX_MP_OAUTH_ENABLED", True)
|
||||
monkeypatch.setattr(settings, "CPS_REDIRECT_BASE", "https://coupon.example.com")
|
||||
|
||||
|
||||
def _make_link(platform: str, target: str) -> tuple[str, str]:
|
||||
with SessionLocal() as db:
|
||||
link = cps_link_repo.create_link(
|
||||
db, group_id=1, activity_id=1,
|
||||
sid="g1" if platform == "meituan" else None,
|
||||
target_url=target, platform=platform,
|
||||
)
|
||||
return link.code, link.target_url
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform,target", [
|
||||
("meituan", "https://dpurl.cn/mt-abc"),
|
||||
("jd", "https://u.jd.com/jd-xyz"),
|
||||
])
|
||||
def test_wechat_unauthorized_shows_interstitial(client, _wx_oauth_on, platform, target):
|
||||
"""微信内 + 有 openid + 无 wx_uinfo → 200 插页:userinfo 授权按钮 + 直接领券出口。"""
|
||||
code, tgt = _make_link(platform, target)
|
||||
r = client.get(
|
||||
f"/c/{code}",
|
||||
headers={"user-agent": _WECHAT_UA, "cookie": "wx_openid=o_test"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.text
|
||||
assert "snsapi_userinfo" in body # 唯一按钮走 userinfo 授权(交互手势触发)
|
||||
assert code in body # 授权 state=uinfo:{code}
|
||||
assert tgt in body # 取消授权时前端转跳的目标券页(绝不阻断领券)
|
||||
assert "暂不授权" not in body # 已去掉单独的"暂不授权"出口,改为取消即自动转跳
|
||||
|
||||
|
||||
def test_authorized_redirects_to_target(client, _wx_oauth_on):
|
||||
"""已授权头像(wx_uinfo=1)→ 不再出插页,直接 302 跳 target。"""
|
||||
code, tgt = _make_link("meituan", "https://dpurl.cn/mt-ok")
|
||||
r = client.get(
|
||||
f"/c/{code}",
|
||||
headers={"user-agent": _WECHAT_UA, "cookie": "wx_openid=o_test; wx_uinfo=1"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"] == tgt
|
||||
|
||||
|
||||
def test_non_wechat_redirects_to_target(client, _wx_oauth_on):
|
||||
"""非微信浏览器(拿不了授权)→ 直接 302 跳 target,不出插页。"""
|
||||
code, tgt = _make_link("meituan", "https://dpurl.cn/mt-other")
|
||||
r = client.get(
|
||||
f"/c/{code}",
|
||||
headers={"user-agent": _OTHER_UA, "cookie": "wx_openid=o_test"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"] == tgt
|
||||
|
||||
|
||||
def test_oauth_cb_cancel_no_code_redirects_back(client):
|
||||
"""授权弹窗点「取消」、微信带空 code 回调 → 兜底 302 回落地页(不报 422)。
|
||||
落地页再由前端转跳目标券页,保证拒绝也能拿到券。"""
|
||||
r = client.get("/wx/oauth/cb?state=uinfo:ABC123", follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"] == "/c/ABC123"
|
||||
@@ -142,6 +142,54 @@ def test_send_does_not_consume_quota(client, enabled, monkeypatch) -> None:
|
||||
assert client.post("/api/v1/auth/sms/login", json={"phone": TEST_PHONE, "code": "1234"}).status_code == 429
|
||||
|
||||
|
||||
# ============================ 登录限流豁免 ============================
|
||||
|
||||
def test_exempt_from_device_ip_login_rate_limit(client, enabled, monkeypatch) -> None:
|
||||
"""测试号豁免 (设备+IP) 每小时登录限流。
|
||||
|
||||
普通设备同设备同 IP 到 SMS_LOGIN_MAX_PER_HOUR 次即被拦(见 test_auth.test_sms_login_device_ip_rate_limit);
|
||||
测试号走自己的每日额度、不受这道限流——这里临时打开 RATE_LIMIT_ENABLED,同一设备连登远超该上限仍全 200。
|
||||
回归用:防有人把 enforce_rate_limit 挪到测试账号 early-return 之前而破坏豁免。"""
|
||||
from app.api.v1 import auth
|
||||
from app.core import ratelimit
|
||||
|
||||
monkeypatch.setattr(ratelimit.settings, "RATE_LIMIT_ENABLED", True)
|
||||
ratelimit._buckets.clear() # 隔离:清掉跨用例累加的内存计数
|
||||
|
||||
# 同一设备连登 (每小时上限 + 3) 次,远超普通设备会被拦的阈值;测试号豁免 → 全部放行
|
||||
for i in range(auth.SMS_LOGIN_MAX_PER_HOUR + 3):
|
||||
r = client.post(
|
||||
"/api/v1/auth/sms/login",
|
||||
json={"phone": TEST_PHONE, "code": "000000", "device_id": "dev-test-exempt"},
|
||||
)
|
||||
assert r.status_code == 200, (
|
||||
f"测试号第 {i + 1} 次登录应放行(豁免每小时限流),实际 {r.status_code}: {r.text}"
|
||||
)
|
||||
|
||||
|
||||
def test_exempt_from_device_ip_send_rate_limit(client, enabled, monkeypatch) -> None:
|
||||
"""测试号豁免 (设备+IP) 每小时**发码**限流(与登录那道同样靠早返回豁免)。
|
||||
|
||||
普通设备同设备到 SMS_SEND_MAX_PER_HOUR_PER_DEVICE 次发码即被拦(见 test_auth.test_sms_send_device_ip_rate_limit);
|
||||
测试号发码本就短路(不真发)、且在限流之前早返回 → 同一设备连发远超上限仍全 200。
|
||||
回归用:防有人把发码限流挪到测试账号 early-return 之前而把 QA 联调也风控了。"""
|
||||
from app.api.v1 import auth
|
||||
from app.core import ratelimit
|
||||
|
||||
monkeypatch.setattr(ratelimit.settings, "RATE_LIMIT_ENABLED", True)
|
||||
monkeypatch.setattr(auth, "SMS_SEND_MAX_PER_HOUR_PER_DEVICE", 2) # 调小:不豁免的话第 3 次就该被拦
|
||||
ratelimit._buckets.clear()
|
||||
|
||||
for i in range(auth.SMS_SEND_MAX_PER_HOUR_PER_DEVICE + 2):
|
||||
r = client.post(
|
||||
"/api/v1/auth/sms/send",
|
||||
json={"phone": TEST_PHONE, "device_id": "dev-test-send"},
|
||||
)
|
||||
assert r.status_code == 200, (
|
||||
f"测试号第 {i + 1} 次发码应放行(豁免每小时限流),实际 {r.status_code}: {r.text}"
|
||||
)
|
||||
|
||||
|
||||
# ============================ 计数单元逻辑 ============================
|
||||
|
||||
def test_quota_resets_across_day(enabled, monkeypatch) -> None:
|
||||
|
||||
Reference in New Issue
Block a user