Compare commits

..

4 Commits

Author SHA1 Message Date
zzhyyyyy c01cc15bb0 feat(analytics): 埋点事件接收接口 + admin 查询接口 + 埋点表
新手引导埋点的服务端:
- 表 analytics_event(五维硬性列 + props JSON 扩展字段;event/device_id/user_id/session_id/created_at 带索引)
- POST /api/v1/analytics/events:客户端批量上报(不鉴权、body 读可选 user_id、补 client_ip + server_at)
- admin GET /admin/api/event-logs:列表 + 按 事件/设备/用户/会话/时间 筛选(offset 分页,照 list_feedbacks)
- alembic migration 建表(autogenerate 顺带检出的 ad/cps 历史索引漂移已手动剔除)

app 主后端 :8770 与 admin :8771 共用同一 SQLite,admin 同库直接查、无需跨库。
配套客户端五维上报 + admin 日志页(另两仓库 PR)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:11:23 +08:00
zhuzihao ea22a11ef9 fix(ad): 激励视频发奖按环境隔离,堵测试包看广告给生产同 id 用户串号发币 (#77)
## 问题
激励视频发奖走穿山甲 S2S 回调 /api/v1/ad/pangle-callback,发币时只认回调里的裸数字
user_id(grant_ad_reward 仅校验「本库存在该 id」),无任何环境归属校验。

同一 user_id 在测试库(本地 localhost)与正式库(线上)是两个不同的人;穿山甲 S2S 回调
地址只配一个(指向生产),测试包(dev)看广告的回调也会打到生产 → 生产把奖发给「本库里
同 user_id 的另一个真人」,造成跨库串号:测试看一次广告,正式库同号用户也被发币(线上
广告收益页因此多出一条本不该有的发奖记录)。

## 修复(服务端,按环境隔离)
pangle_callback 解析激励视频 mediaExtra 透传的 srv_env(dev/prod),与本服 settings.APP_ENV
不符则受理但不发币、不写记录(is_verify=true 防穿山甲重试)。不带 srv_env 的旧客户端按本
环境放行(兼容,不误伤存量正式用户)。

- 新增回归测试 test_callback_foreign_env_skipped(srv_env=prod 打到 APP_ENV=dev 的服务 → 不发)
- sim_pangle_callback 增加 --srv-env 便于本地手验

客户端配套(mediaExtra 写入 srv_env)见 app-android 对应 PR。

## 生效前提(三者缺一仍漏)
1. 生产 app-server 部署本代码并重启
2. 生产 .env 为 APP_ENV=prod
3. app 重新构建(旧包不带 srv_env 仍走兼容放行)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #77
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-26 13:17:27 +08:00
zhuzihao 2edebfad83 feat(auth): 短信登录防刷 — 同一设备id同IP 每小时限 5 次 (#78)
Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #78
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-26 13:17:07 +08:00
guke 3dca126411 fix(dev): 启动脚本钉定 .venv 解释器 + watchfiles 降噪 (#75)
微信优惠券增加美团/京东中间页,领券前获取微信头像授权(主功能)

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #75
Co-authored-by: guke <guke@wonderable.ai>
Co-committed-by: guke <guke@wonderable.ai>
2026-06-25 17:47:30 +08:00
28 changed files with 2128 additions and 40 deletions
+116
View File
@@ -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 ###
+52 -27
View File
@@ -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")
+2
View File
@@ -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)
+39
View File
@@ -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(
+52
View File
@@ -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,
)
+33
View File
@@ -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
+15
View File
@@ -120,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(
+31
View File
@@ -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
View File
@@ -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")
+93 -5
View File
@@ -94,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"]
@@ -139,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:
@@ -156,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))
)
@@ -210,3 +235,66 @@ if(location.search.indexOf('authed=1')>=0){doCopy();history.replaceState(null,''
</script>
</body>
</html>"""
def _coupon_auth_landing_html(target_url: str, code: str) -> str:
"""美团/京东「领券前先授权头像」插页(单按钮)。
唯一按钮「立即领取优惠券」→ snsapi_userinfo 授权(用户点击=交互手势,避免微信快照页)。
- 点「允许」→ 回调拿昵称头像 + 种 wx_uinfo cookie → 经 /c/{code}?authed=1 自动 302 跳券。
- 点「取消」→ 微信退回本插页,前端 pageshow/visibilitychange 检测到"刚点过领取"即直接
转跳 target(不再设单独的"暂不授权"出口),保证拒绝也能拿到券(对齐「绝不阻断领券」)。
AUTH/TARGET 经 json.dumps 安全嵌入 JS 字符串(URL 里的 & 不会被 HTML 转义,直跳更稳)。
"""
redirect_uri = f"{settings.CPS_REDIRECT_BASE.rstrip('/')}/wx/oauth/cb"
auth_url = wx_oauth.build_authorize_url(redirect_uri, "snsapi_userinfo", f"uinfo:{code}")
return (
_COUPON_AUTH_HTML
.replace("__AUTH_URL__", _js_str(auth_url))
.replace("__TARGET_URL__", _js_str(target_url))
)
_COUPON_AUTH_HTML = """<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>领取优惠券</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
body{font-family:-apple-system,"PingFang SC",sans-serif;background:#fff0ef;color:#333;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
.card{width:100%;max-width:340px;background:#fff;border-radius:18px;padding:34px 24px;text-align:center;box-shadow:0 8px 24px rgba(255,59,59,.12)}
.title{font-size:22px;font-weight:800;color:#ff3b3b;margin-bottom:10px}
.sub{font-size:14px;color:#999;line-height:1.6;margin-bottom:28px}
.btn{display:block;width:100%;background:linear-gradient(90deg,#ff5b5b,#ff3b3b);color:#fff;font-size:18px;font-weight:800;font-family:inherit;text-align:center;padding:15px;border:none;border-radius:28px;cursor:pointer;box-shadow:0 6px 16px rgba(255,59,59,.4)}
.btn:active{transform:scale(.98)}
</style>
</head>
<body>
<div class="card">
<div class="title">🎁 优惠券待领取</div>
<div class="sub">点击下方按钮,领取你的专属优惠券</div>
<button class="btn" onclick="claim()">立即领取优惠券</button>
</div>
<script>
var AUTH=__AUTH_URL__,TARGET=__TARGET_URL__;
function claim(){
try{sessionStorage.setItem('mt_auth_ts',String(Date.now()))}catch(e){}
location.href=AUTH;
}
// 从授权页返回(点了「取消」/未完成授权):若刚点过领取(3 分钟内)→ 直接进目标券页,
// 保证拒绝也能拿到券(绝不阻断领券)。pageshow 覆盖 BFCache 与整页重载,visibilitychange
// 兜底"页面保活只切可见性"的情况;靠 sessionStorage 时间戳防陈旧/误触发。
function maybeForward(){
var ts=0;try{ts=parseInt(sessionStorage.getItem('mt_auth_ts')||'0',10)}catch(e){}
if(ts&&Date.now()-ts<180000){
try{sessionStorage.removeItem('mt_auth_ts')}catch(e){}
location.href=TARGET;
}
}
window.addEventListener('pageshow',maybeForward);
document.addEventListener('visibilitychange',function(){if(document.visibilityState==='visible')maybeForward()});
</script>
</body>
</html>"""
+4
View File
@@ -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
+26
View File
@@ -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,
)
+2
View File
@@ -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)
+1
View File
@@ -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
+60
View File
@@ -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}>"
+34
View File
@@ -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)
+42
View File
@@ -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
+4
View File
@@ -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):
@@ -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 / failedreviewing →(拒绝)→ 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 | 字符串 | 淘宝店铺 idshopId)/ 名,可空 |
| id_meituan / name_meituan | 字符串 | 美团店铺 id(预留稳定 poi)/ 名,可空 |
| id_jd / name_jd | 字符串 | 京东店铺 idstoreId/ 名,可空 |
| 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 | 字符串 | 实际 ADNpangle / 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 | 文本 | 完整 UAdebug 用) |
| 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/` 下对应同名文件。*
+8 -2
View File
@@ -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
+10 -2
View File
@@ -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
+337
View File
@@ -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()
+9
View File
@@ -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)
+34
View File
@@ -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")
+67
View File
@@ -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"
+87
View File
@@ -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"
+48
View File
@@ -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: