Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c01cc15bb0 | |||
| ea22a11ef9 | |||
| 2edebfad83 | |||
| 3dca126411 | |||
| acdb10b516 | |||
| b1849834f7 | |||
| 8008366abc | |||
| 43f28d358b | |||
| 128db7fb86 | |||
| c222137b2a | |||
| d8c7a6d1ef | |||
| 4a9d3f1d1a | |||
| b07ccb4bf5 | |||
| 2164155a23 |
@@ -39,6 +39,15 @@ SMS_MOCK=true
|
||||
SMS_CODE_TTL_SEC=300
|
||||
SMS_SEND_INTERVAL_SEC=60
|
||||
|
||||
# ===== 测试账号(release 包全流程联调用)=====
|
||||
# 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】
|
||||
# (real 模式下也跳过校验)、每次登录【都重走新手引导】,并有【每日登录上限】防被人猜到号后脚本刷。
|
||||
# 逻辑见 app/core/test_account.py,与其他业务解耦。
|
||||
# ⚠️ 留空 = 关闭整功能(生产默认);要启用才填号(如 11111111111)。改完重启生效,随时可清空停用。
|
||||
TEST_ACCOUNT_PHONE=
|
||||
# 该测试号每日最多登录次数,当日超过即拒绝(429),次日归零。
|
||||
TEST_ACCOUNT_DAILY_LIMIT=500
|
||||
|
||||
# ===== 美团联盟 CPS =====
|
||||
# 美团联盟后台 → 媒体管理 拿到的 appkey 和密钥
|
||||
MT_CPS_APP_KEY=
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""meituan_coupon image size & type
|
||||
|
||||
给 meituan_coupon 增加 image_size(字节)/ image_type(MIME)两列,采集时 HEAD 头图得到,
|
||||
供读取侧按图片大小决定是否拼缩放参数提速。两列均 nullable,不动存量行;下一轮全量 ETL
|
||||
upsert 自动回填,无需手动 backfill。
|
||||
|
||||
Revision ID: meituan_coupon_image_meta
|
||||
Revises: feedback_review_fields
|
||||
Create Date: 2026-06-23 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "meituan_coupon_image_meta"
|
||||
down_revision: str | Sequence[str] | None = "feedback_review_fields"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("meituan_coupon", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("image_size", sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column("image_type", sa.String(length=32), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("meituan_coupon", schema=None) as batch_op:
|
||||
batch_op.drop_column("image_type")
|
||||
batch_op.drop_column("image_size")
|
||||
@@ -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)
|
||||
|
||||
@@ -67,6 +67,7 @@ def _reward_video_rows(
|
||||
"scene": "reward_video",
|
||||
"record_id": rec.id,
|
||||
"user_id": rec.user_id,
|
||||
"ad_session_id": rec.ad_session_id,
|
||||
"app_env": rec.app_env,
|
||||
"our_code_id": rec.our_code_id,
|
||||
"created_at": rec.created_at,
|
||||
@@ -88,6 +89,7 @@ def _reward_video_rows(
|
||||
"scene": "reward_video",
|
||||
"record_id": rec.id,
|
||||
"user_id": rec.user_id,
|
||||
"ad_session_id": rec.ad_session_id,
|
||||
"app_env": rec.app_env,
|
||||
"our_code_id": rec.our_code_id,
|
||||
"created_at": rec.created_at,
|
||||
@@ -154,6 +156,7 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
|
||||
"scene": "feed",
|
||||
"record_id": rec.id,
|
||||
"user_id": rec.user_id,
|
||||
"ad_session_id": rec.ad_session_id,
|
||||
"app_env": rec.app_env,
|
||||
"our_code_id": rec.our_code_id,
|
||||
"created_at": rec.created_at,
|
||||
@@ -174,6 +177,7 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
|
||||
"scene": "feed",
|
||||
"record_id": rec.id,
|
||||
"user_id": rec.user_id,
|
||||
"ad_session_id": rec.ad_session_id,
|
||||
"app_env": rec.app_env,
|
||||
"our_code_id": rec.our_code_id,
|
||||
"created_at": rec.created_at,
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
"""admin 广告收益报表:按 用户 / 日期 / 广告类型 / 应用 / 代码位 聚合(单表含发奖对账)。
|
||||
"""admin 广告收益报表:**逐条广告事件**列表(每行一次广告,含展示 + 发奖对账)。
|
||||
|
||||
只读。聚合键 = user_id × ad_type × app_env × our_code_id;每组一行同时给出:
|
||||
- 展示条数 + 收益:`ad_ecpm_record`(每行 = 客户端一次广告展示;收益 = Σ eCPM元 ÷ 1000)。
|
||||
激励视频每次展示上报一行;信息流轮播每条展示各上报一行(每条独立 id,不复用会话)。
|
||||
- 应发金币 / 实发金币:复用金币审计的**逐条复算**(`ad_audit.audit_rows`,与正式发奖同一公式口径,
|
||||
不另写公式),把每条发奖记录的 expected/actual 按同维度求和;`matched` = 组内**逐条**全部一致
|
||||
(任一条不符该组即不符,不用「应发和==实发和」以免互相抵消掩盖错误)。**不改发奖逻辑**,只读复算。
|
||||
只读。每行 = 一次广告事件(不再按用户聚合):
|
||||
- **激励视频**:一次观看 = 1 条展示(ad_ecpm)+ 1 条发奖(ad_reward),按 ad_session_id 合并成一行,
|
||||
直接给出 eCPM / 收益 + 状态 / 应发 / 实发 / 一致;点开看该条金币复算因子。
|
||||
- **信息流**:轮播每条展示各一行(impressionId 各自独立);整场发奖(ad_feed_reward,client_event_id)
|
||||
与逐条展示无法对应,单独成「纯发奖」行。
|
||||
- 兜底:有展示无发奖(中途关 / 未达发奖)、有发奖无展示(未上报 eCPM)都各自成行。
|
||||
|
||||
展示与发奖来自不同表,做并集:有展示无发奖(用户中途关 / 未达发奖)、有发奖无展示
|
||||
(未上报 eCPM)都各自成行。app_env/our_code_id 旧数据为 NULL → 归到「来源未知」组。
|
||||
展示与收益来自 ad_ecpm_record(收益 = eCPM元 ÷ 1000);应发 / 实发金币复用金币审计逐条复算
|
||||
(ad_audit.audit_rows,与正式发奖同一公式口径,不另写公式)。合计与对账在全量上统计,
|
||||
不受 limit(只截断 items)影响。
|
||||
|
||||
⚠️ 局限:① 历史 Draw 发奖混在 ad_feed_reward_record 无类型标记,金币侧统一记 `feed`(迁移后 Draw
|
||||
不再产生新数据)。② 聚合级只能看出「某组应发≠实发」,定位到具体哪条仍需逐条审计接口(ad-coin-audit)。
|
||||
⚠️ 局限:① 历史 Draw 发奖混在 ad_feed_reward_record 无类型标记,金币侧统一记 feed。
|
||||
② 跨天 S2S 回调:同一次广告的展示与发奖偶尔落相邻日,各自按 report_date / reward_date 归日。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -23,6 +24,7 @@ from sqlalchemy.orm import Session
|
||||
from app.admin.repositories import ad_audit
|
||||
from app.core import rewards
|
||||
from app.models.ad_ecpm import AdEcpmRecord
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def _cn_hour(dt: datetime) -> int:
|
||||
@@ -32,17 +34,6 @@ def _cn_hour(dt: datetime) -> int:
|
||||
return dt.astimezone(rewards.CN_TZ).hour
|
||||
|
||||
|
||||
def _key(
|
||||
report_date: str,
|
||||
user_id: int,
|
||||
ad_type: str,
|
||||
app_env: str | None,
|
||||
our_code_id: str | None,
|
||||
hour: int | None,
|
||||
) -> tuple:
|
||||
return (report_date, user_id, ad_type, app_env or None, our_code_id or None, hour)
|
||||
|
||||
|
||||
def _date_range(date_from: str, date_to: str) -> list[str]:
|
||||
"""闭区间内逐日 'YYYY-MM-DD' 串(含首尾)。date_from > date_to 时返回空。"""
|
||||
d0 = _date.fromisoformat(date_from)
|
||||
@@ -58,6 +49,18 @@ def _date_range(date_from: str, date_to: str) -> list[str]:
|
||||
# 审计行的 scene 与报表 ad_type 一一对应
|
||||
_SCENE_TO_AD_TYPE = {"reward_video": "reward_video", "feed": "feed"}
|
||||
|
||||
# 发奖复算明细字段(展开下钻看「金币怎么算出来的」)——从 audit 行原样取这些 key。
|
||||
_REWARD_DETAIL_KEYS = (
|
||||
"record_id", "created_at", "status", "ecpm", "ecpm_factor", "units",
|
||||
"lt_index_start", "lt_index_end", "lt_factor_start", "lt_factor_end",
|
||||
"expected_coin", "actual_coin", "matched",
|
||||
)
|
||||
|
||||
|
||||
def _reward_detail(row: dict) -> dict:
|
||||
"""从 audit 行抽出发奖复算明细(给前端展开行渲染因子1/因子2/份数/LT/应发实发)。"""
|
||||
return {k: row[k] for k in _REWARD_DETAIL_KEYS}
|
||||
|
||||
|
||||
def ad_revenue_report(
|
||||
db: Session,
|
||||
@@ -69,44 +72,42 @@ def ad_revenue_report(
|
||||
granularity: str = "day",
|
||||
limit: int = 500,
|
||||
) -> dict:
|
||||
"""日期区间(北京时间,闭区间)广告收益聚合 + 发奖对账。单日时 date_from==date_to。
|
||||
"""日期区间(北京时间,闭区间)**逐条广告事件**列表 + 发奖对账。单日时 date_from==date_to。
|
||||
|
||||
聚合键含**日期**:report_date × user × ad_type × app_env × our_code_id(× 北京小时,granularity=hour)。
|
||||
ad_type: None=全部 / reward_video / feed / draw。
|
||||
granularity: "day"=按天 / "hour"=按小时(聚合键再加北京小时 0–23,每组一行)。
|
||||
limit 只截断展示明细,total 与 total_* / daily 在全量上统计(不受 limit 影响),数字始终可信。
|
||||
|
||||
返回额外含 `daily`(按日期汇总的展示/收益/应发/实发,供前端按天趋势图;不受 limit 影响)。
|
||||
|
||||
注:按小时下,展示按 ecpm 记录的小时、金币按发奖记录的小时各自归桶——S2S 回调可能比展示晚
|
||||
一会儿,故同一次广告的展示与金币偶尔落相邻小时(按天则一致)。
|
||||
每个 item = 一次广告事件(展示与发奖按 ad_session_id 合并;信息流展示 / 发奖各自成行)。
|
||||
ad_type: None=全部 / reward_video / feed / draw。granularity=hour 时每行带北京小时(由各自时间算)。
|
||||
limit 只截断 items(事件明细),total 与 total_* / daily 在全量上统计,数字始终可信。
|
||||
"""
|
||||
by_hour = granularity == "hour"
|
||||
groups: dict[tuple, dict] = {}
|
||||
|
||||
def _grp(key: tuple) -> dict:
|
||||
g = groups.get(key)
|
||||
if g is None:
|
||||
rdate, uid, atype, app_env, code_id, hour = key
|
||||
g = {
|
||||
"report_date": rdate,
|
||||
"user_id": uid,
|
||||
"ad_type": atype,
|
||||
"app_env": app_env,
|
||||
"our_code_id": code_id,
|
||||
"hour": hour,
|
||||
"impressions": 0,
|
||||
"revenue_yuan": 0.0,
|
||||
"expected_coin": 0,
|
||||
"actual_coin": 0,
|
||||
"adns": set(),
|
||||
"impression_records": [], # 该组逐条展示明细(展开下钻用)
|
||||
"records": [], # 该组逐条发奖复算明细(展开下钻用)
|
||||
}
|
||||
groups[key] = g
|
||||
return g
|
||||
# 1) 发奖行(逐日 audit 复算):建 (user_id, ad_session_id) → [行] 映射用于和展示合并;
|
||||
# 同时保留全量列表,未被展示合并的成「纯发奖」事件。
|
||||
reward_by_session: dict[tuple[int, str], list[dict]] = {}
|
||||
all_reward_rows: list[dict] = []
|
||||
audit_scene = _SCENE_TO_AD_TYPE.get(ad_type) if ad_type is not None else None
|
||||
if ad_type is None or audit_scene is not None:
|
||||
for d in _date_range(date_from, date_to):
|
||||
for row in ad_audit.audit_rows(db, date=d, user_id=user_id, scene=audit_scene):
|
||||
row["_report_date"] = d
|
||||
all_reward_rows.append(row)
|
||||
sid = row.get("ad_session_id")
|
||||
if sid:
|
||||
reward_by_session.setdefault((row["user_id"], sid), []).append(row)
|
||||
|
||||
# 1) 展示条数 + 收益 ← ad_ecpm_record(report_date 闭区间;字符串 YYYY-MM-DD 字典序即日期序)
|
||||
used_reward_ids: set[int] = set()
|
||||
events: list[dict] = []
|
||||
|
||||
def _pop_reward(uid: int, sid: str | None) -> dict | None:
|
||||
"""取一条与 (uid, sid) 匹配且未被用过的发奖行(激励视频展示↔发奖按会话 1:1 合并)。"""
|
||||
if not sid:
|
||||
return None
|
||||
for r in reward_by_session.get((uid, sid), ()):
|
||||
if r["record_id"] not in used_reward_ids:
|
||||
used_reward_ids.add(r["record_id"])
|
||||
return r
|
||||
return None
|
||||
|
||||
# 2) 展示记录(ad_ecpm):每条一个事件;能匹配到发奖则合并成「展示 + 发奖」一行。
|
||||
stmt = select(AdEcpmRecord).where(
|
||||
AdEcpmRecord.report_date >= date_from,
|
||||
AdEcpmRecord.report_date <= date_to,
|
||||
@@ -116,124 +117,116 @@ def ad_revenue_report(
|
||||
if ad_type is not None:
|
||||
stmt = stmt.where(AdEcpmRecord.ad_type == ad_type)
|
||||
for rec in db.execute(stmt).scalars():
|
||||
hour = _cn_hour(rec.created_at) if by_hour else None
|
||||
g = _grp(_key(rec.report_date, rec.user_id, rec.ad_type, rec.app_env, rec.our_code_id, hour))
|
||||
g["impressions"] += 1
|
||||
# 单次展示收益(元) = eCPM元 ÷ 1000(每千次→单次);用与发奖同源的解析,口径一致。
|
||||
rev = rewards.parse_ecpm_yuan(rec.ecpm_raw) / 1000.0
|
||||
g["revenue_yuan"] += rev
|
||||
if rec.adn:
|
||||
g["adns"].add(rec.adn)
|
||||
g["impression_records"].append({
|
||||
"id": rec.id,
|
||||
rwd = _pop_reward(rec.user_id, rec.ad_session_id)
|
||||
ev = {
|
||||
"event_key": f"imp-{rec.id}",
|
||||
"report_date": rec.report_date,
|
||||
"user_id": rec.user_id,
|
||||
"ad_type": rec.ad_type,
|
||||
"app_env": rec.app_env,
|
||||
"our_code_id": rec.our_code_id,
|
||||
"created_at": rec.created_at,
|
||||
"hour": _cn_hour(rec.created_at) if by_hour else None,
|
||||
"has_impression": True,
|
||||
"impressions": 1,
|
||||
"ecpm": rec.ecpm_raw,
|
||||
"revenue_yuan": round(rev, 6),
|
||||
# 单次展示收益(元)= eCPM元 ÷ 1000(每千次→单次);与发奖同源解析,口径一致。
|
||||
"revenue_yuan": round(rewards.parse_ecpm_yuan(rec.ecpm_raw) / 1000.0, 6),
|
||||
"adn": rec.adn,
|
||||
"slot_id": rec.slot_id,
|
||||
}
|
||||
if rwd is not None:
|
||||
ev.update({
|
||||
"has_reward": True,
|
||||
"status": rwd["status"],
|
||||
"expected_coin": int(rwd["expected_coin"]),
|
||||
"actual_coin": int(rwd["actual_coin"]),
|
||||
"matched": bool(rwd["matched"]),
|
||||
"reward_detail": _reward_detail(rwd),
|
||||
})
|
||||
else:
|
||||
# 纯展示(信息流逐条展示、激励视频缺发奖记录):不计对账,matched=True。
|
||||
ev.update({
|
||||
"has_reward": False, "status": None,
|
||||
"expected_coin": 0, "actual_coin": 0, "matched": True,
|
||||
"reward_detail": None,
|
||||
})
|
||||
events.append(ev)
|
||||
|
||||
# 3) 未被展示合并的发奖行 → 「纯发奖」事件(信息流整场发奖 / 有发奖无展示)。
|
||||
# 收益恒 0(收益只算展示侧,避免与展示行重复计)。
|
||||
for row in all_reward_rows:
|
||||
if row["record_id"] in used_reward_ids:
|
||||
continue
|
||||
events.append({
|
||||
"event_key": f"rwd-{row['record_id']}",
|
||||
"report_date": row["_report_date"],
|
||||
"user_id": row["user_id"],
|
||||
"ad_type": _SCENE_TO_AD_TYPE.get(row["scene"], row["scene"]),
|
||||
"app_env": row.get("app_env"),
|
||||
"our_code_id": row.get("our_code_id"),
|
||||
"created_at": row["created_at"],
|
||||
"hour": _cn_hour(row["created_at"]) if by_hour else None,
|
||||
"has_impression": False,
|
||||
"impressions": 0,
|
||||
"ecpm": row["ecpm"],
|
||||
"revenue_yuan": 0.0,
|
||||
"adn": None,
|
||||
"slot_id": None,
|
||||
"has_reward": True,
|
||||
"status": row["status"],
|
||||
"expected_coin": int(row["expected_coin"]),
|
||||
"actual_coin": int(row["actual_coin"]),
|
||||
"matched": bool(row["matched"]),
|
||||
"reward_detail": _reward_detail(row),
|
||||
})
|
||||
|
||||
# 2) 应发 / 实发金币 ← 复用金币审计逐条复算(同一公式口径),按同维度求和。
|
||||
# audit_rows 是单日的,区间逐日调用,每天的行归到当天 report_date(语义与单日报表完全一致)。
|
||||
# ad_type=draw 时审计无对应记录(scene 只有 reward_video/feed),金币侧自然为空。
|
||||
audit_scene = _SCENE_TO_AD_TYPE.get(ad_type) if ad_type is not None else None
|
||||
if ad_type is None or audit_scene is not None:
|
||||
for d in _date_range(date_from, date_to):
|
||||
for row in ad_audit.audit_rows(db, date=d, user_id=user_id, scene=audit_scene):
|
||||
atype = _SCENE_TO_AD_TYPE.get(row["scene"], row["scene"])
|
||||
hour = _cn_hour(row["created_at"]) if by_hour else None
|
||||
g = _grp(_key(d, row["user_id"], atype, row.get("app_env"), row.get("our_code_id"), hour))
|
||||
g["expected_coin"] += int(row["expected_coin"])
|
||||
g["actual_coin"] += int(row["actual_coin"])
|
||||
# 逐条明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致)——前端展开该组时下钻展示。
|
||||
g["records"].append({
|
||||
"record_id": row["record_id"],
|
||||
"created_at": row["created_at"],
|
||||
"status": row["status"],
|
||||
"ecpm": row["ecpm"],
|
||||
"ecpm_factor": row["ecpm_factor"],
|
||||
"units": row["units"],
|
||||
"lt_index_start": row["lt_index_start"],
|
||||
"lt_index_end": row["lt_index_end"],
|
||||
"lt_factor_start": row["lt_factor_start"],
|
||||
"lt_factor_end": row["lt_factor_end"],
|
||||
"expected_coin": row["expected_coin"],
|
||||
"actual_coin": row["actual_coin"],
|
||||
"matched": row["matched"],
|
||||
})
|
||||
events.sort(key=lambda e: (e["report_date"], e["user_id"], e["created_at"]))
|
||||
|
||||
rows = list(groups.values())
|
||||
rows.sort(
|
||||
key=lambda r: (
|
||||
r["report_date"],
|
||||
r["user_id"],
|
||||
r["hour"] if r["hour"] is not None else -1,
|
||||
r["ad_type"] or "",
|
||||
r["our_code_id"] or "",
|
||||
)
|
||||
)
|
||||
# 补手机号(admin 展示用,完整不脱敏,与用户 / 钱包 / 比价记录页一致):批量一次查,避免 N+1。
|
||||
uids = {e["user_id"] for e in events}
|
||||
phone_map: dict[int, str] = {}
|
||||
if uids:
|
||||
phone_map = {
|
||||
uid: phone
|
||||
for uid, phone in db.execute(
|
||||
select(User.id, User.phone).where(User.id.in_(uids))
|
||||
).all()
|
||||
}
|
||||
for e in events:
|
||||
e["user_phone"] = phone_map.get(e["user_id"])
|
||||
|
||||
total_impressions = sum(r["impressions"] for r in rows)
|
||||
total_expected_coin = sum(r["expected_coin"] for r in rows)
|
||||
total_actual_coin = sum(r["actual_coin"] for r in rows)
|
||||
total_revenue_yuan = round(sum(r["revenue_yuan"] for r in rows), 6)
|
||||
total_impressions = sum(e["impressions"] for e in events)
|
||||
total_revenue_yuan = round(sum(e["revenue_yuan"] for e in events), 6)
|
||||
total_expected_coin = sum(e["expected_coin"] for e in events)
|
||||
total_actual_coin = sum(e["actual_coin"] for e in events)
|
||||
mismatch_count = sum(1 for e in events if e["has_reward"] and not e["matched"])
|
||||
|
||||
# 按日期汇总(全量,不受 limit):供前端按天趋势图。
|
||||
daily_map: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
d = daily_map.get(r["report_date"])
|
||||
for e in events:
|
||||
d = daily_map.get(e["report_date"])
|
||||
if d is None:
|
||||
d = {
|
||||
"date": r["report_date"],
|
||||
"impressions": 0,
|
||||
"revenue_yuan": 0.0,
|
||||
"expected_coin": 0,
|
||||
"actual_coin": 0,
|
||||
}
|
||||
daily_map[r["report_date"]] = d
|
||||
d["impressions"] += r["impressions"]
|
||||
d["revenue_yuan"] += r["revenue_yuan"]
|
||||
d["expected_coin"] += r["expected_coin"]
|
||||
d["actual_coin"] += r["actual_coin"]
|
||||
d = {"date": e["report_date"], "impressions": 0, "revenue_yuan": 0.0,
|
||||
"expected_coin": 0, "actual_coin": 0}
|
||||
daily_map[e["report_date"]] = d
|
||||
d["impressions"] += e["impressions"]
|
||||
d["revenue_yuan"] += e["revenue_yuan"]
|
||||
d["expected_coin"] += e["expected_coin"]
|
||||
d["actual_coin"] += e["actual_coin"]
|
||||
daily = [
|
||||
{**d, "revenue_yuan": round(d["revenue_yuan"], 6)}
|
||||
for d in sorted(daily_map.values(), key=lambda x: x["date"])
|
||||
]
|
||||
|
||||
items = [
|
||||
{
|
||||
"report_date": r["report_date"],
|
||||
"user_id": r["user_id"],
|
||||
"ad_type": r["ad_type"],
|
||||
"app_env": r["app_env"],
|
||||
"our_code_id": r["our_code_id"],
|
||||
"hour": r["hour"],
|
||||
"impressions": r["impressions"],
|
||||
"revenue_yuan": round(r["revenue_yuan"], 6),
|
||||
"expected_coin": r["expected_coin"],
|
||||
"actual_coin": r["actual_coin"],
|
||||
# 组内**逐条**全部一致才记一致——不能用「应发和==实发和」,否则一条多发+一条少发会互相
|
||||
# 抵消、求和相等被误判为 ✓,掩盖真实发奖错误。纯展示无发奖记录的组 all([]) → True。
|
||||
"matched": all(rec["matched"] for rec in r["records"]),
|
||||
"adns": sorted(r["adns"]),
|
||||
"impression_records": sorted(
|
||||
r["impression_records"], key=lambda x: (x["created_at"], x["id"])
|
||||
),
|
||||
"records": sorted(r["records"], key=lambda x: (x["created_at"], x["record_id"])),
|
||||
}
|
||||
for r in rows[:limit]
|
||||
]
|
||||
|
||||
return {
|
||||
"total": len(rows),
|
||||
"truncated": len(rows) > limit,
|
||||
"total": len(events),
|
||||
"truncated": len(events) > limit,
|
||||
"total_impressions": total_impressions,
|
||||
"total_revenue_yuan": total_revenue_yuan,
|
||||
"total_expected_coin": total_expected_coin,
|
||||
"total_actual_coin": total_actual_coin,
|
||||
"mismatch_count": sum(
|
||||
1 for r in rows if not all(rec["matched"] for rec in r["records"])
|
||||
),
|
||||
"mismatch_count": mismatch_count,
|
||||
"daily": daily,
|
||||
"items": items,
|
||||
"items": events[:limit],
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""广告收益报表 schemas。
|
||||
|
||||
按 用户 / 日期 / 广告类型 / 应用 / 代码位 聚合的只读报表:展示条数、收益(元)、金币、来源。
|
||||
字段 snake_case;收益按元(float),金币按整数。
|
||||
**逐条广告事件**只读报表:每行一次广告(激励视频展示+发奖按会话合并;信息流展示/发奖各自成行),
|
||||
含 展示条数、收益(元)、应发/实发金币、对账。字段 snake_case;收益按元(float),金币按整数。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -50,27 +50,33 @@ class AdRevenueDaily(BaseModel):
|
||||
|
||||
|
||||
class AdRevenueRow(BaseModel):
|
||||
"""一个聚合组(report_date × user × ad_type × app_env × our_code_id)的汇总。"""
|
||||
"""一次广告事件(逐条一行):激励视频展示与发奖按 ad_session_id 合并;信息流展示 / 发奖各自成行。"""
|
||||
|
||||
report_date: str = Field(..., description="该组所属日期(北京时间 YYYY-MM-DD)")
|
||||
event_key: str = Field(..., description="该事件稳定唯一键(imp-{ecpm_id} / rwd-{reward_id});前端 rowKey 用")
|
||||
report_date: str = Field(..., description="该事件所属日期(北京时间 YYYY-MM-DD)")
|
||||
user_id: int
|
||||
user_phone: str | None = Field(None, description="用户手机号(admin 展示用,完整;用户已删 / 查不到为空)")
|
||||
ad_type: str = Field(..., description="reward_video(激励视频) / feed(信息流) / draw(历史 Draw 信息流)")
|
||||
app_env: str | None = Field(None, description="我们的应用:prod(傻瓜比价正式) / test(测试应用);旧数据为空")
|
||||
our_code_id: str | None = Field(None, description="我们后台配置的代码位 ID(104xxx);旧数据为空")
|
||||
hour: int | None = Field(None, description="北京时间小时 0–23(granularity=hour 时有值;按天为 null)")
|
||||
impressions: int = Field(..., description="展示条数(每条广告展示一条;轮播每条各计一次)")
|
||||
revenue_yuan: float = Field(..., description="收益(元)= Σ(eCPM元 ÷ 1000);测试应用多为 0")
|
||||
expected_coin: int = Field(..., description="应发金币(按公式复算,与金币审计同源)")
|
||||
actual_coin: int = Field(..., description="实发金币(实际入账,按现发奖算法)")
|
||||
matched: bool = Field(..., description="该组应发==实发(组内任一条不符则 false)")
|
||||
adns: list[str] = Field(default_factory=list, description="实际填充的底层 ADN 子渠道集合(如 pangle/gdt)")
|
||||
impression_records: list[AdRevenueImpression] = Field(
|
||||
default_factory=list,
|
||||
description="该组逐条展示明细(时间/eCPM/收益/adn);展开下钻用,无发奖也有(只要有展示)",
|
||||
)
|
||||
records: list[AdRevenueRecord] = Field(
|
||||
default_factory=list,
|
||||
description="该组逐条发奖复算明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致);展开下钻用,纯展示无发奖记录的组为空",
|
||||
created_at: datetime = Field(..., description="事件时间(有展示=展示时间,纯发奖=发奖时间)")
|
||||
# ── 展示侧 ──
|
||||
has_impression: bool = Field(..., description="是否有广告展示(信息流逐条展示=True,纯发奖行=False)")
|
||||
impressions: int = Field(..., description="本行展示条数:有展示=1 / 纯发奖=0(供日汇总、趋势图复用)")
|
||||
ecpm: str | None = Field(None, description="eCPM 原始值(分/千次);展示行取展示值,纯发奖行取发奖采用值")
|
||||
revenue_yuan: float = Field(..., description="本次展示预估收益(元)= eCPM元 ÷ 1000;纯发奖行=0")
|
||||
adn: str | None = Field(None, description="实际填充 ADN 子渠道(pangle/gdt…);纯发奖行为空")
|
||||
slot_id: str | None = Field(None, description="底层 mediation rit(非我们配置的广告位 ID);纯发奖行为空")
|
||||
# ── 发奖侧 ──
|
||||
has_reward: bool = Field(..., description="是否有发奖记录(激励视频合并行 / 信息流整场发奖行=True;纯展示=False)")
|
||||
status: str | None = Field(None, description="发奖状态 granted/closed_early/too_short/…;纯展示为空")
|
||||
expected_coin: int = Field(..., description="应发金币(公式复算,与金币审计同源);纯展示=0")
|
||||
actual_coin: int = Field(..., description="实发金币(实际入账);纯展示=0")
|
||||
matched: bool = Field(..., description="本条应发==实发;纯展示恒 True(不计对账)")
|
||||
reward_detail: AdRevenueRecord | None = Field(
|
||||
None,
|
||||
description="发奖复算明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致);点行展开下钻用,纯展示为空",
|
||||
)
|
||||
|
||||
|
||||
@@ -80,11 +86,11 @@ class AdRevenueReportOut(BaseModel):
|
||||
date_from: str = Field(..., description="报表起始日期(北京时间 YYYY-MM-DD)")
|
||||
date_to: str = Field(..., description="报表结束日期(北京时间 YYYY-MM-DD,闭区间;单日时与 date_from 相同)")
|
||||
daily: list[AdRevenueDaily] = Field(..., description="按日期汇总序列(全量,供按天趋势图)")
|
||||
total: int = Field(..., description="聚合组总数(全量,不受 limit 影响)")
|
||||
total: int = Field(..., description="广告事件总数(全量,不受 limit 影响)")
|
||||
truncated: bool = Field(..., description="明细是否被 limit 截断")
|
||||
total_impressions: int = Field(..., description="全量展示条数合计")
|
||||
total_revenue_yuan: float = Field(..., description="全量收益合计(元)")
|
||||
total_expected_coin: int = Field(..., description="全量应发金币合计")
|
||||
total_actual_coin: int = Field(..., description="全量实发金币合计")
|
||||
mismatch_count: int = Field(..., description="应发≠实发的组数(=0 说明全部按公式发放)")
|
||||
items: list[AdRevenueRow] = Field(..., description="聚合明细(按 用户→类型→代码位 排序)")
|
||||
mismatch_count: int = Field(..., description="应发≠实发的发奖条数(=0 说明全部按公式发放)")
|
||||
items: list[AdRevenueRow] = Field(..., description="逐条广告事件(按 日期→用户→时间 排序)")
|
||||
|
||||
@@ -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)
|
||||
+63
-6
@@ -12,10 +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.ratelimit import rate_limit
|
||||
from app.core import test_account
|
||||
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
|
||||
@@ -37,14 +38,24 @@ 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) -> TokenWithUser:
|
||||
"""登录类接口共用的响应组装。onboarding_completed 据登录请求的 device_id 算好后传入。"""
|
||||
|
||||
def _login_response(
|
||||
user, *, onboarding_completed: bool, force_onboarding: bool = False
|
||||
) -> TokenWithUser:
|
||||
"""登录类接口共用的响应组装。onboarding_completed 据登录请求的 device_id 算好后传入;
|
||||
force_onboarding 仅测试号置 True(让应用内重新登录也重走引导)。"""
|
||||
tokens = issue_token_pair(user.id)
|
||||
return TokenWithUser(
|
||||
**tokens,
|
||||
user=UserOut.model_validate(user),
|
||||
onboarding_completed=onboarding_completed,
|
||||
force_onboarding=force_onboarding,
|
||||
)
|
||||
|
||||
|
||||
@@ -81,7 +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:
|
||||
@@ -98,7 +128,34 @@ 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="测试账号今日使用次数已达上限,请明天再试")
|
||||
user = user_repo.upsert_user_for_login(db, phone=req.phone, register_channel="sms")
|
||||
if user.status != "active":
|
||||
raise HTTPException(status_code=403, detail="account disabled")
|
||||
logger.info("sms_login test_account ok user_id=%d phone=%s", user.id, mask_phone(req.phone))
|
||||
# onboarding 恒 false + force_onboarding=true:每次登录都重走引导(含应用内退出后重登),
|
||||
# 不读/不写 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"],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -84,6 +84,19 @@ class Settings(BaseSettings):
|
||||
SMS_DAILY_LIMIT_PER_PHONE: int = 10 # 单手机号每日发送上限(防刷 + 控费)
|
||||
SMS_MAX_VERIFY_ATTEMPTS: int = 5 # 单个验证码最多校验失败次数,超过即作废(防爆破)
|
||||
|
||||
# ===== 测试账号(release 包全流程联调用)=====
|
||||
# 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】
|
||||
# (real 模式下也跳过校验)、每次登录【强制重走新手引导】,并设【每日使用次数上限】防被人
|
||||
# 猜到号后脚本滥用。两个值都能随时改 .env。逻辑全在 app/core/test_account.py,与其他业务解耦。
|
||||
# ⚠️ TEST_ACCOUNT_PHONE 留空 = 整个功能关闭(生产默认安全;要启用才显式填号)。
|
||||
TEST_ACCOUNT_PHONE: str = "" # 测试手机号(11 位,如 11111111111);空=关闭整功能
|
||||
TEST_ACCOUNT_DAILY_LIMIT: int = 500 # 该测试号每日最多登录次数,当日超过即拒绝登录
|
||||
|
||||
@property
|
||||
def test_account_phone(self) -> str:
|
||||
"""规整后的测试手机号(去空白);空串=功能关闭。"""
|
||||
return self.TEST_ACCOUNT_PHONE.strip()
|
||||
|
||||
# ===== 美团联盟 CPS =====
|
||||
# 未配置时所有 /api/v1/meituan/* 接口 200 返空(优雅降级),不影响登录/领券等其他业务。
|
||||
MT_CPS_APP_KEY: str = ""
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""测试账号:免验证码登录 + 每次重走新手引导 + 每日使用上限。
|
||||
|
||||
为打通 **release 包全流程**(无 SIM 卡、不走极光一键登录)而设的一个固定测试手机号。
|
||||
对这个号(且仅这个号)放开三件事:
|
||||
1. **免短信验证码**:`/sms/send` 不真发、`/sms/login` 跳过 `verify_code`,real 模式下同样生效
|
||||
—— 测试时直接填任意验证码即可登入,不用等真短信。
|
||||
2. **每次都走新手引导**:登录响应 `onboarding_completed` 恒为 false,不读/不依赖
|
||||
onboarding_completion 表,所以反复登录都能体验引导。
|
||||
3. **每日使用次数上限**:防被人猜到这个号后写脚本一直刷。当天登录数超过上限即拒绝(429),
|
||||
次日自动归零。
|
||||
|
||||
手机号与上限都在 .env 配(`TEST_ACCOUNT_PHONE` / `TEST_ACCOUNT_DAILY_LIMIT`),随时可改。
|
||||
`TEST_ACCOUNT_PHONE` 留空 = 整个功能关闭(生产默认态),`is_test_account()` 对任何号都返回
|
||||
False,登录/短信回到原逻辑,零影响。
|
||||
|
||||
计数存**进程内存**(单 worker 够用,与 sms.py 同款约定):重启清零、多 worker 不共享。作为
|
||||
一个测试号的粗粒度防滥用闸够用;且因所有登录都落同一个 phone → 同一个 user,滥用面天然只
|
||||
限于这一个账号。要严格持久化/跨进程再迁 DB 或 Redis(同 sms.py 的技术债)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from threading import Lock
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger("shagua.test_account")
|
||||
|
||||
# 进程内每日计数:(date_str, 当日已登录次数)。单 worker 有效,重启清零(见模块 docstring)。
|
||||
_lock = Lock()
|
||||
_usage: tuple[str, int] = ("", 0)
|
||||
|
||||
|
||||
def _today() -> str:
|
||||
return datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
"""功能总开关:配了 TEST_ACCOUNT_PHONE 才启用(空=关闭)。"""
|
||||
return bool(settings.test_account_phone)
|
||||
|
||||
|
||||
def is_test_account(phone: str) -> bool:
|
||||
"""该手机号是否为配置的测试账号。功能关闭时对任何号都返回 False。"""
|
||||
return is_enabled() and phone == settings.test_account_phone
|
||||
|
||||
|
||||
def try_consume_quota() -> bool:
|
||||
"""测试账号登录时调:当日计数 +1。
|
||||
|
||||
Returns:
|
||||
True —— 未超当日上限,本次放行(计数已 +1);
|
||||
False —— 已达上限,本次拒绝(计数不变)。
|
||||
|
||||
线程安全;跨天自动归零。上限取自 settings.TEST_ACCOUNT_DAILY_LIMIT(可在 .env 改)。
|
||||
"""
|
||||
global _usage
|
||||
limit = settings.TEST_ACCOUNT_DAILY_LIMIT
|
||||
with _lock:
|
||||
today = _today()
|
||||
day, cnt = _usage
|
||||
if day != today: # 跨天归零
|
||||
cnt = 0
|
||||
if cnt >= limit:
|
||||
_usage = (today, cnt) # 已满,保持不变
|
||||
logger.warning(
|
||||
"测试账号 %s 今日登录数已达上限 %d,拒绝", settings.test_account_phone, limit
|
||||
)
|
||||
return False
|
||||
_usage = (today, cnt + 1)
|
||||
logger.info("测试账号 %s 第 %d/%d 次登录", settings.test_account_phone, cnt + 1, limit)
|
||||
return True
|
||||
|
||||
|
||||
def _reset_for_test() -> None:
|
||||
"""仅供单测:清空进程内计数,隔离用例间状态。"""
|
||||
global _usage
|
||||
with _lock:
|
||||
_usage = ("", 0)
|
||||
@@ -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}>"
|
||||
@@ -55,6 +55,10 @@ class MeituanCoupon(Base):
|
||||
sell_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
original_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
head_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
# 头图字节大小 / MIME 类型:采集时 HEAD head_url 得到(Content-Length / Content-Type)。
|
||||
# 读取侧已统一压缩(不再按大小判定),这两列保留用于分析/监控(GIF 占比、体积分布等)。
|
||||
image_size: Mapped[int | None] = mapped_column(Integer, nullable=True) # 字节
|
||||
image_type: Mapped[str | None] = mapped_column(String(32), nullable=True) # 如 image/jpeg
|
||||
|
||||
# ===== 销量(美团只给粗档位:热销1w+;num=排序用的下界数值,如 1w+ → 10000) =====
|
||||
sale_volume: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
|
||||
@@ -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, # 提现激励视频开关(关=直接放行提现,不挡用户的钱)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ feed 取最近的成功且省到钱的比价记录(脱敏用户名);不足 N 条
|
||||
种子是「生成规则」:用户名可空(空→随机合成脱敏名,避开同屏撞名)、金额是区间(每次随机取值)、
|
||||
feed 公平随机抽取(不看 sort_order),所有启用种子都有机会露出。
|
||||
|
||||
脱敏名规则(全 feed 统一,按字符算、中英文皆适用):
|
||||
- 有昵称 → 昵称脱敏:≥3 字「首+隐藏字数个星+末」(省钱小能手→省***手 / SaveKing→S******g),
|
||||
2 字「首+星」(阿强→阿*),1 字「用户+该字」(喵→用户喵);
|
||||
- 没昵称 → 合成一个假名(见下)再脱敏,少数露「用户+id 后 3 位」(用户********618)。
|
||||
真实条:设过昵称的按真实昵称脱敏;当前多数用户没设昵称,直接展示「用户****」会清一色,故给它们合成
|
||||
假名——后期真实昵称多了,真实条会直接用真实昵称,合成占比自然下降。种子 / 兜底同样合成、避开同屏撞名;
|
||||
种子留空 / 旧「用户****xxx」模板名一律重新合成(自愈历史种子,无需迁移)。
|
||||
脱敏名规则(对齐 PRD「用户标识打码规则」,按字符数算、中英文皆适用):
|
||||
- 有昵称 → 昵称脱敏:n≥5「首+***+末」(AAA省钱小能手→A***手 / Micky_ 毛→M***毛),
|
||||
n=4「首+**+末」(你好不好→你**好),n≤3「首+**」(小确呀→小** / 阿强→阿** / 喵→喵**);
|
||||
- 没昵称(真实用户)→「用户」+5星+id 后 2 位(用户*****08),按 user_id 稳定。
|
||||
真实条按上述 PRD 规则脱敏。种子 / 兜底无真实用户(无昵称/无 id,套不上「用户*****+id后2位」),
|
||||
仍本地合成多样假名再走同一套昵称脱敏(避开同屏撞名),让横条不至清一色;种子留空 / 旧「用户****xxx」
|
||||
模板名一律重新合成(自愈历史种子,无需迁移)。
|
||||
假名合成:纯本地语料**组合生成**(中文姓池×名字池 拼真名 / 中文网络昵称 / 英文昵称 / 英文名带数字尾),
|
||||
组合空间上万、中文为主英文为辅,脱敏后像真实异质用户群。**真实条按 `user_id` 播种确定性合成**(同一用户
|
||||
每次展示恒定、不同用户各异),刷新/翻页不变脸;种子 / 兜底用运行时随机源出多样填充。不依赖外部库。
|
||||
@@ -44,12 +44,12 @@ _REAL_ROWS_TTL_SECONDS = 30
|
||||
_REAL_ROWS_FETCH_CAP = 600 # 一次多取些,够 limit≤30 去重后取数;命中缓存后复用
|
||||
_real_rows_cache: dict = {"at": None, "rows": None}
|
||||
|
||||
# ===== 用户标识脱敏 + 没真实昵称时的假名合成 =====
|
||||
# 脱敏规则(中英文皆适用,按字符算):有昵称→≥3 字「首+隐藏字数个星+末」(省钱小能手→省***手 /
|
||||
# SaveKing→S******g)、2 字「首+星」(阿强→阿*)、1 字「用户+该字」(喵→用户喵);没昵称→用户+8星+id 后 3 位。
|
||||
# 没真实昵称时(当前多数用户 / 种子 / 兜底)合成假名:本地姓/名字池组合出真名 + 网络昵称语料(中/英)
|
||||
# 混播再走同一套脱敏,像真实异质用户群。后期真实昵称多了,真实条直接用真实昵称,这里占比自然下降。
|
||||
_ANON_STARS = "*" * 8 # 无昵称匿名串固定 8 星(用户********xxx)
|
||||
# ===== 用户标识脱敏(对齐 PRD) + 种子无真实昵称时的假名合成 =====
|
||||
# 脱敏规则(按字符数,中英文皆适用):有昵称→n≥5「首+***+末」、n=4「首+**+末」、n≤3「首+**」;
|
||||
# 没昵称(真实用户)→「用户」+5星+id 后 2 位(用户*****08)。
|
||||
# 种子/兜底没有真实用户(无昵称/无 id),本地用姓/名字池组合真名 + 网络昵称语料(中/英)合成假名,
|
||||
# 再走同一套昵称脱敏,让横条像真实异质用户群、不清一色(PRD 只约束真实用户,种子是运营填充)。
|
||||
_ANON_STARS = "*" * 5 # 「用户」匿名串固定 5 星(用户*****xx,对齐 PRD)
|
||||
|
||||
# 中文真名组合池:姓(百家姓常见 ~100)× 名字单字(~120)拼「姓 + 1~2 字」,组合空间上万。
|
||||
# 脱敏后只露「姓*」或「姓*末」,故无需真实姓名库——组合够多即看着各异(替代原 Faker zh_CN)。
|
||||
@@ -86,19 +86,23 @@ _NICK_EN = [
|
||||
|
||||
|
||||
def _mask_nickname(nick: str) -> str:
|
||||
"""昵称脱敏(按字符,中英文皆可):≥3 字→首+(隐藏字数个)星+末(省钱小能手→省***手 / SaveKing→S******g);
|
||||
2 字→首+星(阿强→阿*);1 字→「用户」+该字(喵→用户喵)。调用方需保证 nick 已 strip 且非空。"""
|
||||
"""昵称脱敏(按字符数,中英文皆可,对齐 PRD「用户标识打码规则」):
|
||||
n≥5 → 首+***+末(AAA省钱小能手→A***手 / Micky_ 毛→M***毛);
|
||||
n=4 → 首+**+末(你好不好→你**好 / Lady→L**y);
|
||||
n≤3 → 首+**(小确呀→小** / 阿强→阿** / 喵→喵** / Lay→L**)。
|
||||
调用方需保证 nick 已 strip 且非空。"""
|
||||
n = len(nick)
|
||||
if n >= 3:
|
||||
return nick[0] + "*" * (n - 2) + nick[-1]
|
||||
if n == 2:
|
||||
return nick[0] + "*"
|
||||
return "用户" + nick
|
||||
if n >= 5:
|
||||
return nick[0] + "***" + nick[-1]
|
||||
if n == 4:
|
||||
return nick[0] + "**" + nick[-1]
|
||||
return nick[0] + "**"
|
||||
|
||||
|
||||
def _anon_by_id(user_id: int) -> str:
|
||||
"""没昵称匿名:固定「用户」+ 8 星 + id 后 3 位(不足 3 位前补 0):用户********618。"""
|
||||
return "用户" + _ANON_STARS + f"{user_id % 1000:03d}"
|
||||
def _mask_anon(user_id: int) -> str:
|
||||
"""没昵称(真实用户,对齐 PRD):「用户」+ 5 星 + id 后 2 位(不足 2 位前补 0):用户*****08。
|
||||
注:user_id 为整数,「id 后 2 位」取末两位数字(PRD 示例「用户*****x8」的 x 为占位符)。"""
|
||||
return "用户" + _ANON_STARS + f"{user_id % 100:02d}"
|
||||
|
||||
|
||||
def _synth_full_name(rng: random.Random) -> str:
|
||||
@@ -118,27 +122,24 @@ def _synth_full_name(rng: random.Random) -> str:
|
||||
|
||||
|
||||
def _synth_masked_name(rng: random.Random) -> str:
|
||||
"""合成一条「已脱敏」假名(供没真实昵称的真实用户 / 种子 / 兜底用)。用传入 rng 决定一切随机。"""
|
||||
"""合成一条「已脱敏」假名(供种子 / 兜底用,真实用户已改走 PRD 规则)。用传入 rng 决定一切随机。"""
|
||||
full = _synth_full_name(rng).strip()
|
||||
return _mask_nickname(full) if full else "用户" + _ANON_STARS + f"{rng.randint(0, 999):03d}"
|
||||
return _mask_nickname(full) if full else "用户" + _ANON_STARS + f"{rng.randint(0, 99):02d}"
|
||||
|
||||
|
||||
def _mask_real(nickname: str | None, user_id: int) -> str:
|
||||
"""真实用户脱敏:设过昵称→昵称脱敏(中英文皆可);没昵称→**按 user_id 播种确定性合成假名**再脱敏
|
||||
(同一用户每次展示恒定、刷新不变脸,不同用户各异),避免无昵称用户清一色「用户****xxx」;
|
||||
少数(~15%)露「用户+真实 id 后 3 位」。当前多数用户没昵称,后期真实昵称多了占比下降。"""
|
||||
"""真实用户脱敏(对齐 PRD「用户标识打码规则」):设过昵称→昵称脱敏(中英文皆可);
|
||||
没昵称→「用户」+5星+id 后 2 位(用户*****08),按 user_id 稳定、刷新不变脸。"""
|
||||
nick = (nickname or "").strip()
|
||||
if nick:
|
||||
return _mask_nickname(nick)
|
||||
# 关键:按 user_id 播种本地 rng → 同一用户的合成名跨请求/刷新恒定(0.15 抽签也用它)。
|
||||
seeded = random.Random(user_id)
|
||||
return _anon_by_id(user_id) if seeded.random() < 0.15 else _synth_masked_name(seeded)
|
||||
return _mask_anon(user_id)
|
||||
|
||||
|
||||
def _synth_name(rng: random.Random) -> str:
|
||||
"""合成一条脱敏名(种子留空 / 旧模板名 / 兜底用):绝大多数=假名脱敏,少数=用户+随机 3 位(用户********618)。"""
|
||||
"""合成一条脱敏名(种子留空 / 旧模板名 / 兜底用):绝大多数=假名脱敏,少数=用户+随机 2 位(用户*****08)。"""
|
||||
if rng.random() < 0.15:
|
||||
return "用户" + _ANON_STARS + f"{rng.randint(0, 999):03d}"
|
||||
return "用户" + _ANON_STARS + f"{rng.randint(0, 99):02d}"
|
||||
return _synth_masked_name(rng)
|
||||
|
||||
|
||||
@@ -148,7 +149,7 @@ def _unique_name(used: set[str]) -> str:
|
||||
n = _synth_name(_rng)
|
||||
if n not in used:
|
||||
return n
|
||||
return "用户" + _ANON_STARS + f"{_rng.randint(0, 999):03d}" # 兜底:数字尾号天然好去重
|
||||
return "用户" + _ANON_STARS + f"{_rng.randint(0, 99):02d}" # 兜底:数字尾号天然好去重
|
||||
|
||||
|
||||
def _validate_amount(min_cents: int, max_cents: int) -> None:
|
||||
|
||||
@@ -22,7 +22,8 @@ _USERNAME_FIRST = "23456789"
|
||||
_USERNAME_DIGITS = "0123456789"
|
||||
_USERNAME_LEN = 11
|
||||
|
||||
# 默认昵称 = 9 位大小写字母 + 数字随机串(创建时给,用户可后续改;不要求唯一)。
|
||||
# 默认昵称 = "用户" + 9 位大小写字母 + 数字随机串(创建时给,用户可后续改;不要求唯一)。
|
||||
_NICKNAME_PREFIX = "用户"
|
||||
_NICKNAME_ALPHABET = string.ascii_letters + string.digits
|
||||
_NICKNAME_LEN = 9
|
||||
|
||||
@@ -35,8 +36,10 @@ def _gen_username() -> str:
|
||||
|
||||
|
||||
def _gen_nickname() -> str:
|
||||
"""随机一个 9 位字母+数字的默认昵称。"""
|
||||
return "".join(secrets.choice(_NICKNAME_ALPHABET) for _ in range(_NICKNAME_LEN))
|
||||
"""默认昵称 = "用户" + 9 位字母+数字随机串。"""
|
||||
return _NICKNAME_PREFIX + "".join(
|
||||
secrets.choice(_NICKNAME_ALPHABET) for _ in range(_NICKNAME_LEN)
|
||||
)
|
||||
|
||||
|
||||
def get_user_by_username(db: Session, username: str) -> User | None:
|
||||
|
||||
@@ -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
|
||||
@@ -48,6 +48,12 @@ class TokenWithUser(TokenPair):
|
||||
description="该 设备+账号 是否已走完新手引导(据登录请求里的 device_id 计算)。"
|
||||
"true → 客户端登录后直接进首页,跳过引导。",
|
||||
)
|
||||
force_onboarding: bool = Field(
|
||||
False,
|
||||
description="是否强制本次登录走新手引导(仅测试号置 true)。客户端据此让【应用内重新登录】"
|
||||
"(退出后再登)也重走引导,普通号为 false、按原逻辑回原页/首页。与 onboarding_completed "
|
||||
"分工:后者管 App 启动路由,本字段管应用内登录后的去向。",
|
||||
)
|
||||
|
||||
|
||||
# ===== 极光一键登录 =====
|
||||
@@ -65,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):
|
||||
|
||||
+16
-1
@@ -13,6 +13,21 @@ from pydantic import BaseModel, Field
|
||||
FeedStatus = Literal["ok", "empty", "degraded"]
|
||||
|
||||
|
||||
# ───────────────── 头图传输优化(统一缩放 + 转 WebP) ─────────────────
|
||||
# 美团图片 CDN 支持在 URL 后拼 @<w>w_<h>h_1e_1c[.webp] 让服务端缩放/转码。feed 卡片只占
|
||||
# 124dp(≈372px@3x),而库里 head_url 是原图(实测中位 137KB、长尾到 17MB、GIF 均 4.8MB),
|
||||
# 是首页 feed 图片加载偏慢的来源。按产品要求,头图**统一**缩到 124dp + 转 WebP 再下发
|
||||
# (全部压缩、不设大小阈值),由美团 CDN 服务端缩放/转码,实测省 76–99% 体积。
|
||||
FEED_THUMB_PARAM = "@375w_375h_1e_1c.webp" # 124dp@3x 缩略 + 转 WebP
|
||||
|
||||
|
||||
def feed_image_url(head_url: str) -> str:
|
||||
"""统一给头图 URL 拼缩放参数(缩到 124dp + 转 WebP),让美团 CDN 出小图;空 URL 原样返回。"""
|
||||
if not head_url:
|
||||
return head_url
|
||||
return head_url.split("@")[0] + FEED_THUMB_PARAM
|
||||
|
||||
|
||||
# ───────────────── 券卡片(归一化后给客户端) ─────────────────
|
||||
|
||||
class CouponCard(BaseModel):
|
||||
@@ -94,7 +109,7 @@ class CouponCard(BaseModel):
|
||||
platform=platform,
|
||||
biz_line=biz_line,
|
||||
name=cpd.get("name") or "",
|
||||
head_image_url=head_url,
|
||||
head_image_url=feed_image_url(head_url),
|
||||
brand_name=brand.get("brandName"),
|
||||
brand_logo_url=brand.get("brandLogoUrl"),
|
||||
sell_price=sell,
|
||||
|
||||
@@ -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,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# 美团券头图大小/类型回填 —— 全量重跑一轮 ETL,把 meituan_coupon.image_size / image_type 灌满。
|
||||
# 上线本 PR 后在服务器手动跑一次,立即回填存量券(不必等定时器逐轮 upsert 自然补齐)。
|
||||
#
|
||||
# 用法(服务器, root):
|
||||
# cd /opt/shaguabijia-app-server
|
||||
# sudo bash deploy/backfill_image_meta.sh
|
||||
# # 全量一轮 ~70min,建议在 tmux/screen 里跑,防 SSH 断开中断
|
||||
#
|
||||
# 前提:已拉到含本 PR 的 main、已跑 `alembic upgrade head`(脚本会自检列是否存在)、
|
||||
# 已 restart shaguabijia-app-server.service。服务器 MT_CPS_PROXY 留空(直连 CDN)。
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="${APP_DIR:-/opt/shaguabijia-app-server}"
|
||||
PY="$APP_DIR/.venv/bin/python"
|
||||
cd "$APP_DIR"
|
||||
mkdir -p data
|
||||
LOG="data/etl_backfill_$(date +%Y%m%d_%H%M%S).log"
|
||||
|
||||
# 退出时务必恢复定时器(即使 ETL 中途失败,也不把 timer 留在停用态)
|
||||
restore_timer() { systemctl start meituan-etl.timer 2>/dev/null || true; }
|
||||
trap restore_timer EXIT
|
||||
|
||||
echo "[0/3] 自检 image_size/image_type 列(缺列说明没迁移,先跑 alembic upgrade head)..."
|
||||
"$PY" -c "from sqlalchemy import inspect; from app.db.session import engine; \
|
||||
cols=[c['name'] for c in inspect(engine).get_columns('meituan_coupon')]; \
|
||||
import sys; (('image_size' in cols) and ('image_type' in cols)) or sys.exit(' ❌ 缺列,请先: sudo .venv/bin/python -m alembic upgrade head'); \
|
||||
print(' ✓ 列已就绪')"
|
||||
|
||||
echo "[1/3] 停定时器,防与定时轮次并发占锁..."
|
||||
systemctl stop meituan-etl.timer 2>/dev/null || true
|
||||
|
||||
echo "[2/3] 全量重跑 ETL(~70min;日志 $LOG)..."
|
||||
# 不走 systemctl:meituan-etl.service 有 20min 硬超时,全量一轮跑不完会被掐断。
|
||||
"$PY" -m scripts.pull_meituan_coupons --once --prune-hours 24 2>&1 | tee "$LOG"
|
||||
|
||||
echo "[3/3] 验证填充率..."
|
||||
"$PY" - <<'PYEOF'
|
||||
from sqlalchemy import text
|
||||
from app.db.session import engine
|
||||
engine.echo = False
|
||||
with engine.connect() as c:
|
||||
tot = c.execute(text("SELECT count(*) FROM meituan_coupon")).scalar()
|
||||
enr = c.execute(text("SELECT count(*) FROM meituan_coupon WHERE image_size IS NOT NULL")).scalar()
|
||||
gif = c.execute(text("SELECT count(*) FROM meituan_coupon WHERE image_type='image/gif'")).scalar()
|
||||
print(f" 总 {tot} 行 | 有 image_size {enr} ({enr*100//max(tot,1)}%) | GIF {gif} 张")
|
||||
PYEOF
|
||||
echo "✅ 回填完成(定时器已恢复)。"
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
| [api/](./api/) | **API 接口文档**:"索引 + 一接口一文件"(组织方式见下)。想查"某个接口的协议"看这里。 |
|
||||
| [database/](./database/) | **数据库文档**:每张表一个文件(表结构/字段/索引/约定) + 两份迁移指南——[数据库迁移.md](./database/数据库迁移.md)(Alembic 操作:clone 后建表/日常升级/新增迁移/多 head 排查) + [postgres-migration.md](./database/postgres-migration.md)(SQLite → PostgreSQL 切换步骤,配套 `scripts/init_postgres.py`)。想"把库跑起来 / 改表结构 / 查某张表"看这里。 |
|
||||
| [integrations/](./integrations/) | **集成层实现文档**:穿山甲验签 / 微信支付 / 极光 / 短信 / 美团 CPS 等 SDK 集成的签名、加解密、协议细节与踩坑。想知道"接外部服务那块到底怎么实现"看这里。 |
|
||||
| [guides/](./guides/) | **开发 / 上线 / 功能 指南**:[待办与技术债.md](./guides/待办与技术债.md)(跨前后端 backlog,P1 鉴权/用户绑定、引擎移植待办等,"还欠什么、以后要补什么"看这份) + [看广告赚金币上线清单.md](./guides/看广告赚金币上线清单.md)(看广告发奖上线 checklist) + [邀请功能-实现原理与本地测试.md](./guides/邀请功能-实现原理与本地测试.md)(invite-mvp 实现原理 + 本地内网全链路测试)。 |
|
||||
| [guides/](./guides/) | **开发 / 上线 / 功能 指南**:[待办与技术债.md](./guides/待办与技术债.md)(跨前后端 backlog,P1 鉴权/用户绑定、引擎移植待办等,"还欠什么、以后要补什么"看这份) + [看广告赚金币上线清单.md](./guides/看广告赚金币上线清单.md)(看广告发奖上线 checklist) + [邀请功能-实现原理与本地测试.md](./guides/邀请功能-实现原理与本地测试.md)(invite-mvp 实现原理 + 本地内网全链路测试) + [CPS发券分发与微信授权.md](./guides/CPS发券分发与微信授权.md)(**CPS 发券系统交接文档**:设群/建活动/生成落地页短链/美团对账/统计 + 微信网页授权拿 openid 做用户级统计;含数据流/表/平台差异/端点/配置/代码地图/排障/技术债)。 |
|
||||
|
||||
## api/ 目录是怎么组织的(传送门式)
|
||||
|
||||
|
||||
+46
-4
@@ -3,7 +3,7 @@
|
||||
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
|
||||
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
|
||||
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
|
||||
> 最后更新:2026-06-18(补录比价透传端点 `intent/precoupon/step`、`intent/step`、`trace/finalize` 与 `meituan/top-sales` 到总览;上一次功能更新 2026-06-07 金币数值体系一期)
|
||||
> 最后更新:2026-06-23(补全此前缺整族的端点:device 无障碍存活监控、internal 内部回写、CPS 短链落地、report/invite/wxpay,及 platform/coupon/feedback/user 的零散读端点;device/internal/cps-redirect 三族新建单文件文档。上一次 2026-06-18 补录比价透传端点与 `meituan/top-sales`)
|
||||
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
|
||||
|
||||
---
|
||||
@@ -19,7 +19,14 @@
|
||||
| 5 | `POST /api/v1/auth/refresh` | 无 | [详情](./auth-refresh.md) |
|
||||
| 6 | `GET /api/v1/auth/me` | Bearer | [详情](./auth-me.md) |
|
||||
| 7 | `POST /api/v1/auth/logout` | Bearer | [详情](./auth-logout.md) |
|
||||
| 8 | `POST /api/v1/coupon/step` | 无 | [详情](./coupon-step.md) |
|
||||
| 8 | `POST /api/v1/coupon/step` | 无 | [详情](./coupon-step.md)(透传 pricebot + best-effort 写 `coupon_*` 三表) |
|
||||
| 8a | `POST /api/v1/coupon/prompt/shown` | 无 | 引导窗弹出即上报(按 device+package+日记 `shown`,今天这个 App 不再自动弹)(无单独文档) |
|
||||
| 8b | `POST /api/v1/coupon/prompt/dismiss` | 无 | 用户拒绝/关闭引导窗(透传链路看不到拒绝,客户端通知)(无单独文档) |
|
||||
| 8c | `GET /api/v1/coupon/prompt/should-show` | 无 | 切到外卖 App 时是否还应弹引导窗(`device_id`+`package`)(无单独文档) |
|
||||
| 8d | `POST /api/v1/coupon/prompt/reset` | 无 | 重置今日引导窗 engagement(开发测频控用)(无单独文档) |
|
||||
| 8e | `GET /api/v1/coupon/completed-today` | 无 | 这台设备今天是否已跑完整轮领券(首页「去领取」卡置灰源)(无单独文档) |
|
||||
| 8f | `POST /api/v1/coupon/completed-today/reset` | 无 | 重置今日已完成(开发用)(无单独文档) |
|
||||
| 8g | `GET /api/v1/coupon/stats` | Bearer | 累计领券数(「我的」页战绩卡;按 user_id 聚合,**鉴权**)(无单独文档) |
|
||||
| 9 | `POST /api/v1/meituan/coupons` | 无 | [详情](./meituan-coupons.md) |
|
||||
| 10 | `POST /api/v1/meituan/feed` | 无 | [详情](./meituan-feed.md) |
|
||||
| 11 | `POST /api/v1/meituan/referral-link` | 无 | [详情](./meituan-referral-link.md) |
|
||||
@@ -38,6 +45,19 @@
|
||||
| **比价战绩里程碑**(前缀 `/api/v1/compare`;福利页「记录比价战绩」,按成功比价数解锁逐档发金币) |||
|
||||
| 12d | `GET /api/v1/compare/milestones` | Bearer | [详情](./compare-milestones.md) |
|
||||
| 12e | `POST /api/v1/compare/milestones/{milestone}/claim` | Bearer | [详情](./compare-milestone-claim.md) |
|
||||
| **设备 / 无障碍存活监控**(前缀 `/api/v1/device`;心跳超时检出 + 掉线召回,#65) |||
|
||||
| D1 | `POST /api/v1/device/register` | Bearer | [详情](./device-liveness.md)(注册设备/更新极光 push token) |
|
||||
| D2 | `POST /api/v1/device/heartbeat` | Bearer | [详情](./device-liveness.md)(无障碍服务存活心跳,心跳也能自注册) |
|
||||
| D3 | `GET /api/v1/device/liveness` | Bearer | [详情](./device-liveness.md)(进 App 查本机是否被判掉线过) |
|
||||
| D4 | `POST /api/v1/device/liveness/ack` | Bearer | [详情](./device-liveness.md)(确认已弹引导,清掉线告警) |
|
||||
| **上报更低价**(前缀 `/api/v1/report`;众包纠偏,人工审核发奖) |||
|
||||
| R1 | `POST /api/v1/report` | Bearer | 提交上报(multipart:`comparison_record_id`/`reported_platform_id`/`reported_price`(元) + 1~4 张截图;原最低价反查 `comparison_record.best_*` 校验须更低)(无单独文档) |
|
||||
| R2 | `GET /api/v1/report/records` | Bearer | 上报记录列表(`?status=` pending/approved/rejected 可选筛选)(无单独文档) |
|
||||
| **好友邀请**(前缀 `/api/v1/invite`;注册即生效,双方各发 1 万金币) |||
|
||||
| I1 | `GET /api/v1/invite/me` | Bearer | 我的邀请码 + 分享链接 + 已邀人数/已得金币(无单独文档) |
|
||||
| I2 | `GET /api/v1/invite/invitees` | Bearer | 我邀请的人列表(`limit`/`offset` 分页)(无单独文档) |
|
||||
| I3 | `POST /api/v1/invite/landing-track` | 无 | 落地页 `dl.html` 访问上报指纹(剪贴板归因兜底;浏览器无 token)(无单独文档) |
|
||||
| I4 | `POST /api/v1/invite/bind` | Bearer | 绑定邀请人;支持 clipboard/manual 邀请码 + fingerprint 指纹反查三种归因(无单独文档) |
|
||||
| **钱包 / 我的资产**(前缀 `/api/v1/wallet`) |||
|
||||
| 14 | `GET /api/v1/wallet/account` | Bearer | [详情](./wallet-account.md) |
|
||||
| 15 | `GET /api/v1/wallet/coin-transactions` | Bearer | [详情](./wallet-coin-transactions.md) |
|
||||
@@ -68,15 +88,37 @@
|
||||
| 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad-ecpm-report.md) |
|
||||
| 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad-feed-reward.md) |
|
||||
| 35b | `POST /api/v1/ad/reward-noshow` | Bearer | [详情](./ad-reward-noshow.md)(激励视频提前关闭/未发奖留痕,只记原因不发币) |
|
||||
| 35c | `GET /api/v1/ad/feed-reward/units` | Bearer | 信息流广告今日已发份数/上限(配合 `feed-reward` 看进度)(无单独文档) |
|
||||
| **用户资料**(前缀 `/api/v1/user`) |||
|
||||
| 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user-profile.md) |
|
||||
| 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user-avatar.md) |
|
||||
| 36a | `POST /api/v1/user/onboarding/complete` | Bearer | 标记新手引导完成(按 账号+device_id 幂等,跨卸载重装持久)(无单独文档) |
|
||||
| 36b | `GET /api/v1/user/onboarding/status` | Bearer | 查该 (账号,设备) 是否走过引导(运营在 admin 删记录即触发重走)(无单独文档) |
|
||||
| 37 | `DELETE /api/v1/user` | Bearer | [详情](./user-delete.md) |
|
||||
| **帮助与反馈**(前缀 `/api/v1/feedback`) |||
|
||||
| 38 | `POST /api/v1/feedback` | Bearer | [详情](./feedback.md) |
|
||||
| **首页门面数据**(前缀 `/api/v1/platform`;全平台展示数字,登录前可读) |||
|
||||
| 38a | `GET /api/v1/feedback/config` | Bearer | 反馈页「加群二维码」卡配置(开关 + 二维码图 + 三行文案)(无单独文档) |
|
||||
| 38b | `GET /api/v1/feedback/records` | Bearer | 我的反馈历史(pending/adopted/rejected)(无单独文档) |
|
||||
| **首页门面数据 / 客户端配置**(前缀 `/api/v1/platform`;全平台展示数字 + 运营开关,**全部不鉴权**,登录前可读) |||
|
||||
| 39 | `GET /api/v1/platform/stats` | 无 | [详情](./platform-stats.md) |
|
||||
| 40 | `GET /api/v1/platform/savings-feed` | 无 | [详情](./platform-savings-feed.md) |
|
||||
| 40a | `GET /api/v1/platform/flags` | 无 | 客户端运营 feature flag(比价/领券期广告开关等),拉取后缓存(无单独文档) |
|
||||
| 40b | `GET /api/v1/platform/ad-config` | 无 | 客户端拉广告配置(穿山甲 app_id + 各位 ID + 各场景开关;不含验签密钥)(无单独文档) |
|
||||
| 40c | `GET /api/v1/platform/app-version` | 无 | 最新 App 版本(OTA 检查更新;与本机 versionCode 比)(无单独文档) |
|
||||
| **微信支付回调**(前缀 `/api/v1/wxpay`) |||
|
||||
| W1 | `POST /api/v1/wxpay/transfer-auth-notify` | 无 | 免确认收款授权结果通知(一期 stub:仅应答 200 不验签不改账,授权状态靠主动查询兜底)(无单独文档) |
|
||||
| **CPS 群发短链落地**(**无前缀**,挂域名根;公网不鉴权) |||
|
||||
| C1 | `GET /c/{code}` | 无 | [详情](./cps-redirect.md)(短链落地:微信授权拿 openid + 记点击 + 302 跳/淘宝 H5 落地页) |
|
||||
| C2 | `POST /c/{code}/copy` | 无 | [详情](./cps-redirect.md)(淘宝落地页点「复制口令」记 `copy`) |
|
||||
| C3 | `GET /wx/oauth/cb` | 无 | [详情](./cps-redirect.md)(微信网页授权回调;upsert `cps_wx_user` + 种 cookie,`include_in_schema=False`) |
|
||||
| C4 | `GET /MP_verify_*.txt` | 无 | [详情](./cps-redirect.md)(微信「网页授权域名」归属校验文件,`include_in_schema=False`) |
|
||||
| **内部回写端点**(前缀 `/internal`;pricebot/发布流程→app-server,**`X-Internal-Secret` 头**,非客户端接口) |||
|
||||
| N1 | `POST /internal/price-observation` | 内部密钥 | [详情](./internal.md)(比价价格事实批量落 `price_observation`) |
|
||||
| N2 | `GET /internal/store-mapping/lookup` | 内部密钥 | [详情](./internal.md)(按源平台店名反查目标平台已沉淀店铺 id/deeplink) |
|
||||
| N3 | `POST /internal/store-mapping` | 内部密钥 | [详情](./internal.md)(跨平台店铺身份映射落 `store_mapping`) |
|
||||
| N4 | `POST /internal/store-mapping/invalidate` | 内部密钥 | [详情](./internal.md)(标记某平台 shopId 缓存 deeplink 失效) |
|
||||
| N5 | `POST /internal/launch-confirm-sample` | 内部密钥 | [详情](./internal.md)(启动确认窗兜底样本落 `launch_confirm_sample`) |
|
||||
| N6 | `POST /internal/app-version` | 内部密钥 | [详情](./internal.md)(发布流程写最新 App 版本,落 `app_config`) |
|
||||
| **静态资源**(StaticFiles 挂载,见下方 `/media` 静态服务) |||
|
||||
| - | `GET /media/avatars/<file>` | 无 | 用户头像;返回二进制图片 |
|
||||
| - | `GET /media/feedback/<file>` | 无 | 反馈截图;返回二进制图片 |
|
||||
@@ -173,7 +215,7 @@
|
||||
| `platform` | int | `1`=外卖/到家, `2`=到店 |
|
||||
| `biz_line` | int \| null | 到店子类:1到餐 2到综 3酒店 4门票 |
|
||||
| `name` | string | 商品名 |
|
||||
| `head_image_url` | string | 头图 |
|
||||
| `head_image_url` | string | 头图;后端已**统一**拼缩放参数 `@375w_375h_1e_1c.webp`(美团 CDN 服务端缩到 124dp + 转 WebP,实测省 76–99%),客户端直接用 |
|
||||
| `brand_name` | string \| null | 品牌名 |
|
||||
| `brand_logo_url` | string \| null | 品牌 logo |
|
||||
| `sell_price` | string | 现价 |
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# CPS 群发短链落地(cps-redirect 族)
|
||||
|
||||
> 所属:cps-redirect 组(**无前缀**,挂域名根;源 `app/api/v1/cps_redirect.py`) | 鉴权:**公网无鉴权**(群里任何人点都要能跳/能领) | [← 返回 API 索引](./README.md)
|
||||
>
|
||||
> 落库:点击落 [`cps_click`](../database/cps_click.md)、微信落地页用户落 [`cps_wx_user`](../database/cps_wx_user.md);短链由 [`cps_link`](../database/cps_link.md) 解析。
|
||||
|
||||
运营把 `/c/{code}` 短链发到微信群,用户点击 → 记点击 → 按平台 302 跳转(美团/京东)或返回 H5 淘宝落地页。**记点击/写用户失败绝不阻断用户跳转/领券**(try/except 吞掉)。
|
||||
|
||||
## GET /c/{code} — 短链落地
|
||||
- 微信内 + 还没拿到 openid + 配了服务号 → 先 `snsapi_base` 静默授权拿 openid,再 302 回本页。
|
||||
- 非微信 / 已有 openid cookie / 未配服务号 → 直接走原逻辑(openid 可能为 None,**绝不阻断**)。
|
||||
- 记 `visit`(写 `cps_click`)后:**美团/京东** 302 跳 `target_url`;**淘宝** 返回 H5 落地页(主视觉图 + 「复制口令」按钮)。
|
||||
- `code` 不存在/失效 → 302 跳兜底(`https://www.meituan.com/`)。
|
||||
|
||||
**入参**:路径 `code`(短链码)。**出参**:`302 Redirect` 或 `200 HTML`(淘宝)。
|
||||
|
||||
## POST /c/{code}/copy — 淘宝落地页点了「复制口令」
|
||||
淘宝落地页是 H5,用户点「复制口令」才算转化 → 记一条 `copy` 事件(带 openid cookie)。
|
||||
|
||||
**入参**:路径 `code`;body 空。**出参**:`{ "ok": true }`。
|
||||
|
||||
## GET /wx/oauth/cb — 微信网页授权回调(`include_in_schema=False`)
|
||||
`state='base:{code}'`(静默拿 openid)或 `'uinfo:{code}'`(补昵称头像)。换 openid(+userinfo)→ upsert [`cps_wx_user`](../database/cps_wx_user.md) → 种 cookie(`wx_openid` / `wx_uinfo`,30 天)→ 302 回落地页。**任何失败兜底回落地页,绝不阻断领券**。`uinfo` 回流带 `?authed=1`,落地页据此自动触发复制(把「点领券→授权→复制」衔接成一步)。
|
||||
|
||||
## GET /MP_verify_*.txt — 微信「网页授权域名」归属校验(`include_in_schema=False`)
|
||||
微信请求 `coupon.shaguabijia.com/MP_verify_xxx.txt` 验证域名归属;端点放域名根、返回文件名核心串(纯文本)。为微信网页授权(拿 openid)接入做准备。
|
||||
|
||||
## 说明
|
||||
- **两阶授权**:`base` 静默拿 openid(统计主力、不打扰);`userinfo` 在用户点「领券」时触发(交互触发避免微信快照页),拿昵称/头像/unionid。
|
||||
- **`cps_click` 不阻断**:落地/跳转优先级高于统计——丢几条点击可接受,挡住用户领券不可接受。
|
||||
@@ -0,0 +1,52 @@
|
||||
# 设备 / 无障碍存活监控(device 族)
|
||||
|
||||
> 所属:device 组(前缀 `/api/v1/device`,源 `app/api/v1/device.py`) | 鉴权:**全部 Bearer**(设备绑登录用户) | [← 返回 API 索引](./README.md)
|
||||
>
|
||||
> 落库:[`device_liveness`](../database/device_liveness.md) 表;后台 `heartbeat_monitor_worker` 据此检出心跳超时的设备并召回。设计见 spec `accessibility-liveness-push.md`(推送版)+ `accessibility-liveness-pull-prompt.md`(后置 pull 提醒版)。#65 新增。
|
||||
|
||||
无障碍服务存活监控链路:客户端无障碍服务存活时周期上报心跳,后端发现「曾保护过、现已超时」的设备 → 召回(推送 / 客户端进 App 弹「开启自启动」引导)。
|
||||
|
||||
## POST /register — 注册设备 / 更新推送 token
|
||||
App 前台、拿到极光 push token 时调。按 `(user_id, device_id)` upsert。
|
||||
|
||||
**入参(JSON)**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `device_id` | string | ✅ | 客户端 `DeviceId.get()` 生成的 per-install id |
|
||||
| `registration_id` | string \| null | ❌ | 极光推送 registration id(`JCollectionAuth` 同意后才有) |
|
||||
| `platform` | string | ❌ | 默认 `android` |
|
||||
| `app_version` | string \| null | ❌ | App 版本 |
|
||||
|
||||
**出参**:`DeviceOut`(设备当前状态)。
|
||||
|
||||
## POST /heartbeat — 上报心跳
|
||||
无障碍服务存活时周期调(**心跳也能自注册**)。`accessibility_enabled=true` 时刷新 `last_heartbeat_at`、状态机置 `alive`、清掉线告警。
|
||||
|
||||
**入参(JSON)**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `device_id` | string | ✅ | 同上 |
|
||||
| `accessibility_enabled` | bool | ✅ | 本机无障碍开关当前是否开 |
|
||||
| `registration_id` | string \| null | ❌ | 顺带更新 push token |
|
||||
|
||||
**出参**:`{ "ok": true }`。
|
||||
|
||||
## GET /liveness — 查本机掉线告警(后置检测)
|
||||
客户端进 App 时拉:本机是否被判定掉线过(`kill_alert_pending`)。命中 → 客户端弹「开启自启动」引导,之后调 `/liveness/ack` 清。设备从未注册过 → 返回默认(无告警)。
|
||||
|
||||
**入参(query)**:`device_id`(string,必填)。
|
||||
**出参**:`LivenessOut`(含 `kill_alert_pending` 等)。
|
||||
|
||||
## POST /liveness/ack — 确认已提醒(清掉线告警)
|
||||
客户端已弹过引导 → 清 `kill_alert_pending`(幂等;下次真掉线 worker 再置)。
|
||||
|
||||
**入参(JSON)**:`{ device_id }`。
|
||||
**出参**:`{ "ok": true }`。
|
||||
|
||||
## 错误
|
||||
- `401` 未鉴权 / token 失效。
|
||||
- `422` 缺 `device_id` 或字段类型不符。
|
||||
|
||||
## 说明
|
||||
- **`kill_alert_pending` 与 `liveness_state` 解耦**:worker 检出掉线置 True,心跳恢复(`/heartbeat`)只重置 state、不动此标记,只由 `/liveness/ack` 清 → 规避「服务随 App 重启先发心跳把 state 重置回 alive、客户端进 App 漏看这次掉线」竞态。
|
||||
- 心跳超时阈值由 worker 配置决定,不在接口里。
|
||||
@@ -0,0 +1,31 @@
|
||||
# 内部回写端点(internal 族,pricebot / 发布流程 → app-server)
|
||||
|
||||
> 所属:internal 组(前缀 `/internal`,源 `app/api/internal/`) | 鉴权:**`X-Internal-Secret` 头**(== `settings.INTERNAL_API_SECRET`) | [← 返回 API 索引](./README.md)
|
||||
|
||||
**不是给客户端的接口**:不走用户 JWT。`§6` 是 app-server 透传给 pricebot,这里反过来——pricebot(及发布流程)把少量数据 server→server 回写 app-server 落库。
|
||||
|
||||
## 鉴权(所有 internal 端点共用)
|
||||
请求头带 `X-Internal-Secret: <密钥>`,服务端常量时间比对 `settings.INTERNAL_API_SECRET`:
|
||||
- 密钥**未配置**(默认空)→ `503`(挡裸奔写端点)。
|
||||
- 不匹配 / 缺失 → `401`。
|
||||
- 需 pricebot 与 app-server 两边 `.env` 配**同一** `INTERNAL_API_SECRET` 才生效(未配则 pricebot 侧跳过上报、不影响比价/领券)。
|
||||
|
||||
## 端点
|
||||
|
||||
| 方法 + 路径 | 落库 | 说明 |
|
||||
|---|---|---|
|
||||
| `POST /internal/price-observation` | [`price_observation`](../database/price_observation.md) | 比价 done 帧整批价格事实上报;`(trace_id, platform, scope)` 幂等,返回 `{inserted, skipped}` |
|
||||
| `GET /internal/store-mapping/lookup` | (只读 [`store_mapping`](../database/store_mapping.md)) | 比价前按 `source_platform`+`name`(+`lat`/`lng`)反查各目标平台已沉淀的店铺 id/deeplink;命中→pricebot 直接 deeplink 省现场搜店 |
|
||||
| `POST /internal/store-mapping` | `store_mapping` | 跨平台「同一家店」身份映射上报;`trace_id` 幂等、填空合并,返回 `{inserted, row_id}` |
|
||||
| `POST /internal/store-mapping/invalidate` | `store_mapping` | 标记某平台 `shop_id` 的缓存 deeplink 失效(pricebot 撞错误页回退时报);当前支持 `taobao`/`jd`,其它平台 no-op,返回 `{ok, affected}` |
|
||||
| `POST /internal/launch-confirm-sample` | [`launch_confirm_sample`](../database/launch_confirm_sample.md) | 启动确认窗 LLM 兜底放行后回写样本(host 包 + 弹窗树 + plan + locale);**都上报、不去重**,返回 `{id}` |
|
||||
| `POST /internal/app-version` | [`app_config`](../database/app_config.md)(key=`latest_app_version`) | **发布流程**(非 pricebot)出 APK 后写最新版本号/下载链接/sha256;客户端再 `GET /api/v1/platform/app-version` 读做 OTA。也是应急改版本信息(紧急下线/改 `apk_url`)入口 |
|
||||
|
||||
## 错误
|
||||
- `401` 密钥不匹配 / 缺失。
|
||||
- `503` `INTERNAL_API_SECRET` 未配置。
|
||||
- `422` 请求体字段校验失败。
|
||||
|
||||
## 说明
|
||||
- 与不鉴权的比价透传(`compare.py` / `coupon.py`)无关:那两个是**客户端→server** 的透传壳,这里是 **server→server** 的内部写库。
|
||||
- `launch-confirm-sample` 的样本供研发**人工**定期沉淀进 pricebot 的 `launch_confirm.PROFILES` 静态规则(让以后同款启动窗回到快路径,不必再调 LLM)。
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 跨表视角。单表字段级细节看同目录 `<表名>.md`(索引见 [README](./README.md))。
|
||||
> 本文专门回答三件「跨表」的事:**① 每块 App 功能用到哪些表 ② 什么操作往哪张表写 ③ 表和表怎么连(join key,含没有外键约束、靠业务字段对齐的语义关联)**。
|
||||
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **37 张业务表** + `alembic_version`(框架的迁移版本指针)。注意「比价/领券**过程**」始终在 pricebot 内存态跑、**不落库**——只有**结果**回 app-server 才落库:领券结果落 `coupon_*` 三张今日状态表;比价结果分两路——客户端带 JWT 上报「我的记录」落 `comparison_record`,pricebot 另经 `app/api/internal/` server→server 把客观价格/门店事实落 `price_observation`/`store_mapping`(不鉴权、匿名也记)。此外好友邀请(`invite_*` 2 张)与美团 CPS 群发联盟(`cps_*` 5 张)是两个独立子系统。
|
||||
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **40 张业务表** + `alembic_version`(框架的迁移版本指针)。注意「比价/领券**过程**」始终在 pricebot 内存态跑、**不落库**——只有**结果**回 app-server 才落库:领券结果落 `coupon_*` 三张今日状态表;比价结果分两路——客户端带 JWT 上报「我的记录」落 `comparison_record`,pricebot 另经 `app/api/internal/` server→server 把客观价格/门店事实落 `price_observation`/`store_mapping`(不鉴权、匿名也记)+ 启动确认窗兜底样本落 `launch_confirm_sample`。此外好友邀请(`invite_*` 2 张)与美团 CPS 群发联盟(`cps_*` 6 张,含落地页微信身份 `cps_wx_user`)是两个独立子系统。无障碍存活监控 `device_liveness`(#65)按用户设备维度记心跳、检掉线召回。
|
||||
|
||||
---
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
| 「上报更低价」提交 / 列表 | [`price_report`](./price_report.md) | 众包纠偏:用户举证某平台更便宜,人工审核发奖 |
|
||||
| (无 App UI)比价 done 后客观价格沉淀 | [`price_observation`](./price_observation.md) | **pricebot server→server 内部上报**;平台/门店视角的到手价事实,匿名也记,与 `comparison_record`(用户视角)互补 |
|
||||
| (无 App UI)跨平台门店身份映射 | [`store_mapping`](./store_mapping.md) | **pricebot 内部上报**;同店在淘宝/美团/京东的 id/名/deeplink,供下次比价 `lookup` 反查省掉现场搜店 |
|
||||
| (无 App UI)启动确认窗兜底样本 | [`launch_confirm_sample`](./launch_confirm_sample.md) | **pricebot 内部上报**;LLM 兜底放行「想要打开 XX」跨 App 启动窗后回写样本(host 包 + 弹窗树 + plan + locale),供研发人工沉淀进 pricebot 规则 |
|
||||
|
||||
### 领券(每日领券联动 · 今日状态)
|
||||
| App 位置 / 动作 | 表 | 说明 |
|
||||
@@ -47,6 +48,7 @@
|
||||
| 登录(极光/短信)/ 改资料 / 注销 | [`user`](./user.md) | 登录主体,注册即登录 |
|
||||
| 新手引导是否再展示 | [`onboarding_completion`](./onboarding_completion.md) | 按 设备+账号 去重;登录响应回 `onboarding_completed`,走完引导时标记,跨卸载重装 |
|
||||
| 帮助与反馈 | [`feedback`](./feedback.md) | 含截图,后台人工处理 |
|
||||
| 无障碍存活监控 / 掉线召回 | [`device_liveness`](./device_liveness.md) | 无障碍服务周期心跳;worker 检出心跳超时 → 推送/客户端进 App 弹「开启自启动」引导 |
|
||||
|
||||
### 好友邀请(注册增长)
|
||||
| App 位置 / 动作 | 表 | 说明 |
|
||||
@@ -61,6 +63,7 @@
|
||||
| 运营建微信推广群 | [`cps_group`](./cps_group.md) | 一群一行,`sid`=美团二级渠道追踪位 |
|
||||
| 后台批量生成群发短链 | [`cps_link`](./cps_link.md) | 群×活动 → `/c/{code}`,运营复制到微信群 |
|
||||
| 用户点短链(落地/复制口令) | [`cps_click`](./cps_click.md) | 记 visit/copy 事件,统计 PV/UV |
|
||||
| 微信内打开落地页授权 | [`cps_wx_user`](./cps_wx_user.md) | 服务号网页授权拿 openid(base 静默)/ 昵称头像 unionid(userinfo,点领券触发),记首次来源群 |
|
||||
| 定时拉美团联盟订单对账 | [`cps_order`](./cps_order.md) | `query_order` 按 sid 归群,串成点击→下单→佣金漏斗 |
|
||||
|
||||
### 运营后台 admin(独立子应用 `app/admin/`,端口 8771,独立鉴权)
|
||||
@@ -95,6 +98,9 @@
|
||||
| 看广告时长上报 `POST /ad/watch-report` | `ad_watch_log`(C) | |
|
||||
| 广告 eCPM 上报 `POST /ad/ecpm-report` | `ad_ecpm_record`(C) | |
|
||||
| 信息流广告结算 `POST /ad/feed-reward` | `ad_feed_reward_record`(C)+ granted→`coin_account`(U)+`coin_transaction`(C `feed_ad_reward`) | `client_event_id` 幂等 |
|
||||
| 注册设备 / 更新 push token `POST /device/register` | `device_liveness`(C/U upsert) | `(user_id, device_id)` 幂等;只在传入非空时更新 `registration_id`/版本 |
|
||||
| 无障碍服务心跳 `POST /device/heartbeat` | `device_liveness`(C/U) | 心跳也能自注册;`accessibility_enabled=true` 时刷 `last_heartbeat_at`、置 `alive`、清 `notified_at` |
|
||||
| 客户端 ack 掉线提醒 `POST /device/liveness/ack` | `device_liveness`(U) | 清 `kill_alert_pending`(幂等;下次真掉线 worker 再置) |
|
||||
| 比价 done 上报 `POST /compare/record` | `comparison_record`(C 或 U) | `(user_id, trace_id)` 幂等覆盖 |
|
||||
| 领里程碑 `POST /compare/milestone/claim` | `comparison_milestone_claim`(C) | **当前不发币**(coin_awarded=0) |
|
||||
| 支付归因上报 `POST /order/report` | `savings_record`(C `source=compare`) | `(user_id, client_event_id)` 幂等 |
|
||||
@@ -130,22 +136,25 @@
|
||||
| 运营后台建活动 / 建群 | `cps_activity`(C/U) / `cps_group`(C/U) | `app/admin/routers/cps.py`;群含美团平台才分配 `sid` |
|
||||
| 后台批量生成短链 `POST /admin/api/cps/referral-links` | `cps_link`(C) | 每 群×活动 一条;美团经 `sid` 转链拿 `target_url`(同群同活动重复生成产生多条) |
|
||||
| 用户点群发短链 `GET /c/{code}` / `POST /c/{code}/copy` | `cps_click`(C `visit`/`copy`) | 公开端点不鉴权;`group_id`/`sid` 从 link 冗余进来免 join |
|
||||
| 微信落地页授权回调 `GET /wx/oauth/cb` | `cps_wx_user`(C/U upsert) | 按 `openid` 幂等;base 拿 openid,userinfo 补昵称/unionid(非 None 才覆盖);任何失败兜底回落地页不阻断领券 |
|
||||
| 定时拉美团联盟订单对账 | `cps_order`(C/U upsert) | `query_order` 按 `sid` 归群;`order_id` 幂等(状态会变,重复拉则更新) |
|
||||
| pricebot 比价 done 内部上报 `POST /internal/price-observation` | `price_observation`(C 批量) | `(trace_id,platform,scope)` 幂等;**不走 JWT、靠 `X-Internal-Secret`**(未配→503) |
|
||||
| pricebot 比价 done 内部上报 `POST /internal/store-mapping` | `store_mapping`(C/U 填空合并) | `trace_id` 幂等;另有 `lookup` 反查 + `invalidate` deeplink 失效标记(淘宝/京东) |
|
||||
| pricebot LLM 兜底放行启动确认窗后上报 `POST /internal/launch-confirm-sample` | `launch_confirm_sample`(C) | **都上报、不去重**;靠 `X-Internal-Secret`(未配→503) |
|
||||
| 后台 worker 检出心跳超时 `heartbeat_monitor_worker` | `device_liveness`(U `mark_notified`) | 置 `notified` + `notified_at` + `kill_alert_pending=True`;非 C 端、非 admin,定时扫 `list_overdue` |
|
||||
|
||||
> CPS 5 表与本 App `user` **无关**(被推广群的用户在美团下单,不是本 App 注册用户);`price_observation`/`store_mapping` 的 `source_user_id` 是可空旁路(链路不鉴权,匿名也记)。
|
||||
> CPS 6 表与本 App `user` **无关**(被推广群的用户在美团下单 / 在微信落地页授权,不是本 App 注册用户;`cps_wx_user` 是微信 `openid` 维度的另一套用户身份);`price_observation`/`store_mapping`/`launch_confirm_sample` 的 `source_user_id`/`device_id` 是可空旁路(链路不鉴权,匿名也记)。
|
||||
|
||||
---
|
||||
|
||||
## 三、表间关系 & Join Key
|
||||
|
||||
### 硬外键(数据库 FK 约束)
|
||||
- **17 张用户维度表 `.user_id` → `user.id`**:`coin_account`(同时是 PK)、`coin_transaction`、`cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`(同时是 PK)、`signin_record`、`signin_boost_record`、`user_task`、`comparison_record`、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`ad_feed_reward_record`、`price_report`、`feedback`。
|
||||
- **18 张用户维度表 `.user_id` → `user.id`**:`coin_account`(同时是 PK)、`coin_transaction`、`cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`(同时是 PK)、`signin_record`、`signin_boost_record`、`user_task`、`comparison_record`、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`ad_feed_reward_record`、`price_report`、`feedback`、`device_liveness`。
|
||||
- `admin_audit_log.admin_id` → `admin_user.id`。
|
||||
- `price_report.comparison_record_id` → `comparison_record.id`(可空:关联记录被删后仍留上报历史)。
|
||||
- **邀请两表** → `user.id`:`invite_relation.inviter_user_id`、`invite_relation.invitee_user_id`(唯一)、`invite_fingerprint.inviter_user_id`——注意 FK 列名是 `inviter`/`invitee_user_id`,不是 `user_id`。
|
||||
- **CPS 5 表 + 比价沉淀 2 表均无硬 FK**(全靠 `sid`/`trace_id` 语义对齐,见下)。
|
||||
- **CPS 6 表 + 比价沉淀 2 表 + `launch_confirm_sample` 均无硬 FK**(全靠 `sid`/`trace_id`/`openid` 语义对齐,见下)。
|
||||
|
||||
### 语义 join key(无 FK 约束,靠业务字段对齐 —— 排障/对账必看)
|
||||
- **`coin_transaction.ref_id` 指向随 `biz_type` 变**:
|
||||
@@ -172,7 +181,7 @@
|
||||
- **里程碑解锁进度不存库**:`comparison_milestone_claim` 只记「哪几档已领」;进度 = `comparison_record` 里 `status='success'` 的 `count`。
|
||||
- **领券三表无硬 FK,全靠软关联**:`coupon_prompt_engagement` / `coupon_daily_completion` / `coupon_claim_record` 的 `user_id` **软指** `user.id`(可空、有登录态才记、不进唯一键、不阻塞判断);`trace_id` **软指** pricebot work_logs(排查回指);唯一键都以 `device_id` + 北京自然日为主(详见 [`coupon_state.md`](./coupon_state.md))。
|
||||
- **`onboarding_completion.(user_id, device_id)`**:`user_id` 语义关联 `user.id`(无硬 FK,同 `coupon_*` 设备表),`device_id` = 客户端硬件级 `ANDROID_ID`(≠ 领券 per-install `device_id`)。登录读、走完引导写,决定是否再展示新手引导。
|
||||
- **CPS 群发 5 表全靠 `sid` / id 语义串联(无硬 FK)**:`cps_link.group_id`→`cps_group.id`、`cps_link.activity_id`→`cps_activity.id`、`cps_click.link_id`→`cps_link.id`;**点击与订单无法对到单笔**,只在群维度(`sid`)汇合——`cps_order.sid` ≈ `cps_group.sid` ≈ `cps_link.sid` ≈ `cps_click.sid`(仅美团有 sid,淘宝/京东无)。统计按群聚合,故 `group_id`/`sid` 冗余进 `cps_click` 免 join。
|
||||
- **CPS 群发 6 表全靠 `sid` / id / `openid` 语义串联(无硬 FK)**:`cps_link.group_id`→`cps_group.id`、`cps_link.activity_id`→`cps_activity.id`、`cps_click.link_id`→`cps_link.id`、`cps_wx_user.first_group_id`→`cps_group.id`;**点击与订单无法对到单笔**,只在群维度(`sid`)汇合——`cps_order.sid` ≈ `cps_group.sid` ≈ `cps_link.sid` ≈ `cps_click.sid`(仅美团有 sid,淘宝/京东无)。统计按群聚合,故 `group_id`/`sid` 冗余进 `cps_click` 免 join。`cps_wx_user` 按 `openid` 自成用户身份维度,与 `cps_click`/`cps_order` 无 id 级 join。
|
||||
- **比价沉淀两表(`price_observation` / `store_mapping`)**:`trace_id` 软指 pricebot work_logs(与 `comparison_record.trace_id` 同源但不互 join,各存各视角);`source_user_id` / `source_device_id` 软指用户/设备(可空,匿名也记)。`store_mapping.lookup` 靠**店名字符串精确相等** + geo 取最近,非 id 级 join。
|
||||
|
||||
### ER 关系(文字版)
|
||||
@@ -182,7 +191,8 @@ user ─1:1─ wechat_transfer_authorization
|
||||
user ─1:N─ { coin_transaction, cash_transaction, withdraw_order, signin_record,
|
||||
signin_boost_record, user_task, comparison_record, comparison_milestone_claim,
|
||||
savings_record, ad_reward_record, ad_watch_log, ad_ecpm_record, ad_feed_reward_record,
|
||||
price_report, feedback }
|
||||
price_report, feedback, device_liveness }
|
||||
(device_liveness 硬 FK; (user_id,device_id) 唯一)
|
||||
user ─1:N─ onboarding_completion (user_id, 无硬 FK; (user_id,device_id) 去重)
|
||||
comparison_record ─1:N─ price_report (comparison_record_id, 可空)
|
||||
admin_user ─1:N─ admin_audit_log
|
||||
@@ -192,8 +202,10 @@ coupon_prompt_engagement / coupon_daily_completion / coupon_claim_record
|
||||
user ─1:N─ invite_relation (inviter_user_id 硬 FK); invitee_user_id ─1:1─ user (唯一硬 FK)
|
||||
user ─1:N─ invite_fingerprint (inviter_user_id 硬 FK)
|
||||
cps_activity / cps_group ──语义(无FK)──▶ cps_link ─1:N─ cps_click
|
||||
(CPS 5 表自成子系统; cps_order 经 sid 归群对账, 与本 App user 无关)
|
||||
(CPS 6 表自成子系统; cps_order 经 sid 归群对账, 与本 App user 无关)
|
||||
cps_group ◀──语义(无FK)── cps_wx_user (first_group_id; openid 维度的微信落地页用户身份)
|
||||
price_observation / store_mapping (独立, 无硬 FK; 维度=trace_id, source_user/device 仅软关联; pricebot 内部上报)
|
||||
launch_confirm_sample (独立, 无硬 FK; 都上报不去重; pricebot 内部上报启动确认窗兜底样本)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
> 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。
|
||||
> ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。
|
||||
> 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)`。
|
||||
> 最后更新:2026-06-17(补全 9 张此前缺文档的表:CPS 群发 5 张 `cps_*` + 好友邀请 2 张 `invite_*` + 比价沉淀 `price_observation`/`store_mapping`;并全表 review 对齐 model——`signin_record` 改 7 天循环、`coupon_prompt_engagement` 加 `package` 列按 App 频控、`user` 补 `username`/`invite_code`/`debug_trace_enabled` 等;含 [OVERVIEW 总览](./OVERVIEW.md))
|
||||
> 最后更新:2026-06-23(补 3 张表文档:`device_liveness`(#65 无障碍存活监控)、`cps_wx_user`(CPS 落地页微信身份)、`launch_confirm_sample`(启动确认窗兜底样本);`comparison_record` 补 `input_tokens`/`output_tokens`。上一次 2026-06-17 补全 CPS 群发 5 张 `cps_*` + 好友邀请 2 张 `invite_*` + 比价沉淀 `price_observation`/`store_mapping` 并全表 review 对齐 model;含 [OVERVIEW 总览](./OVERVIEW.md))
|
||||
|
||||
> 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。
|
||||
|
||||
---
|
||||
|
||||
## 表总览(37 张业务表 + `alembic_version` 框架表)
|
||||
## 表总览(40 张业务表 + `alembic_version` 框架表)
|
||||
|
||||
### 账号 / 反馈
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
@@ -17,6 +17,7 @@
|
||||
| `user` | 用户(登录主体);几乎所有表的外键宿主 | `models/user.py` | [详情](./user.md) |
|
||||
| `onboarding_completion` | 新手引导完成标记(按 设备+账号 去重,登录时据此跳过引导) | `models/onboarding.py` | [详情](./onboarding_completion.md) |
|
||||
| `feedback` | 用户帮助与反馈(含截图) | `models/feedback.py` | [详情](./feedback.md) |
|
||||
| `device_liveness` | 无障碍存活监控(心跳超时检出 + 掉线召回;`(user_id, device_id)` 唯一) | `models/device.py` | [详情](./device_liveness.md) |
|
||||
|
||||
### 好友邀请(注册增长)
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
@@ -53,6 +54,7 @@
|
||||
|---|---|---|---|
|
||||
| `price_observation` | 价格观测(平台/门店视角的到手价事实,匿名也记;`comparison_record` 的客观对照) | `models/price_observation.py` | [详情](./price_observation.md) |
|
||||
| `store_mapping` | 跨平台「同一家店」id/名/deeplink 身份映射(淘宝/美团/京东,供 lookup 反查省现场搜店) | `models/store_mapping.py` | [详情](./store_mapping.md) |
|
||||
| `launch_confirm_sample` | 启动确认窗 agent 兜底样本(pricebot LLM 放行跨 App 启动窗后回写,供研发人工沉淀规则) | `models/launch_confirm_sample.py` | [详情](./launch_confirm_sample.md) |
|
||||
|
||||
### 领券(每日领券联动 · 今日状态)
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
@@ -73,6 +75,7 @@
|
||||
| `cps_group` | 推广微信群(`sid`=美团二级渠道追踪位,订单按 sid 归群对账) | `models/cps_group.py` | [详情](./cps_group.md) |
|
||||
| `cps_link` | 群发短链 `/c/{code}`(群×活动生成,302 跳/淘宝落地页) | `models/cps_link.py` | [详情](./cps_link.md) |
|
||||
| `cps_click` | 短链点击事件流(visit/copy,PV/UV 漏斗源) | `models/cps_link.py` | [详情](./cps_click.md) |
|
||||
| `cps_wx_user` | CPS 落地页微信用户身份(openid 维度;网页授权拿 openid/昵称/unionid,记首次来源群) | `models/cps_wx_user.py` | [详情](./cps_wx_user.md) |
|
||||
| `cps_order` | 美团联盟 query_order 拉回的对账订单(按 sid 归群算佣金) | `models/cps_order.py` | [详情](./cps_order.md) |
|
||||
|
||||
### 首页门面数据
|
||||
@@ -93,7 +96,7 @@
|
||||
## 通用约定
|
||||
|
||||
- **主键**:`id` Integer autoincrement;例外:`coin_account` / `wechat_transfer_authorization` 的 `user_id` 既是主键也是外键(一用户一行),`app_config` 用 `key` 作主键。
|
||||
- **外键**:所有用户维度表 `user_id` → `user.id` 且建 index;`admin_audit_log.admin_id` → `admin_user.id`;`price_report.comparison_record_id` → `comparison_record.id`。
|
||||
- **外键**:所有用户维度表 `user_id` → `user.id` 且建 index(含 `device_liveness`);`admin_audit_log.admin_id` → `admin_user.id`;`price_report.comparison_record_id` → `comparison_record.id`。
|
||||
- **金额**:整数;金币计数,现金/价格存「分」(`*_cents`)。汇率 `10000 金币 = 1 元 = 100 分`。
|
||||
- **时间**:`created_at` 等用 `DateTime(timezone=True)` + `server_default=func.now()`;业务"今天"按**北京时间**(`core/rewards.cn_today`),跨天计数用「日期串」列(`reward_date` / `watch_date` / `report_date`,`String(10)` 存 `YYYY-MM-DD`)等值查,**不在 SQL 里做跨时区 date 比较**(SQLite 不可靠)。
|
||||
- **JSON 列**:`JSON().with_variant(JSONB(), "postgresql")` —— PG 上 JSONB(可建 GIN 索引),SQLite 退化为通用 JSON(否则 `create_all` 编译报错);`feedback.images` 例外,用通用 `JSON`。
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
| `comparison_results` | JSON(PG: JSONB) | NOT NULL, default [] | 逐平台对比 `[{platform_id,platform_name,package,price(元),is_source,rank,coupon_saved(元),coupon_name,applied_coupons}]`;`coupon_saved`=该平台主优惠额(美团红包/淘宝平台红包/京东百亿补贴,只取一笔),`coupon_name`=优惠来源名(展示用),`applied_coupons`=`[{name,amount}]` 多券明细 |
|
||||
| `skipped_dish_names` | JSON(PG: JSONB) | NOT NULL, default [] | 被跳过的菜名 |
|
||||
| `raw_payload` | JSON(PG: JSONB) | nullable | 客户端原始上报全量(calibration + done.params),取数兜底 |
|
||||
| `input_tokens` | Integer | nullable | 本次 LLM 累计输入 token = Σ `llm_calls[].usage.prompt_tokens`(server 收上报后从 `llm_calls` 累加;旧记录/未采集为 null) |
|
||||
| `output_tokens` | Integer | nullable | 本次 LLM 累计输出 token = Σ `llm_calls[].usage.completion_tokens`(同上) |
|
||||
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
|
||||
|
||||
> `ordered`(已下单)是**瞬态字段**,不在表里:`list_records` 读取时按 `store_name ∈ 该用户 source='compare' 的 savings_record.shop_name 集合` 现挂到实例上供出参用。
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# cps_wx_user — CPS 落地页微信用户身份(openid 维度)
|
||||
|
||||
> 模型 `app/models/cps_wx_user.py`(`CpsWxUser`) · 仓库 `app/repositories/cps_wx_user.py`(`get_by_openid` / `upsert`) · 接口 `GET /wx/oauth/cb`(微信网页授权回调,`app/api/v1/cps_redirect.py`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
用户在微信内打开群发短链落地页 [`/c/{code}`](./cps_link.md),经服务号网页授权拿到的微信身份,按 `openid` 唯一存一行。在 CPS 群发漏斗里,它给点击/领券补上**「是谁」**这一维度(此前 [`cps_click`](./cps_click.md) 只有 PV/UV 近似)。授权分两阶:`snsapi_base` 静默拿 `openid`(统计主力);用户点「领券」触发 `snsapi_userinfo` 才补 `nickname`/`headimgurl`/`unionid`。
|
||||
|
||||
> 注意:**下单归因到人**需 user-level sid(另一期),本表只承载身份 + 领券/点击侧,**不与 `cps_order` 关联**。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C / U(upsert)**:`upsert`(`get_by_openid` 查 → 无则建、有则补),由 `wx_oauth_cb`(`GET /wx/oauth/cb`)在微信授权回调里调。首次建行记来源 `first_code`/`first_group_id`;已存在则**仅在传入非 None 时**更新 `nickname`/`headimgurl`/`unionid`(`base` 阶段为 None,不覆盖已有画像),并显式刷新 `last_seen`(`onupdate` 仅字段有变更才触发,故 base-only 回访也要手动 set)。任何授权失败兜底回落地页,**绝不阻断领券**。
|
||||
- **R**:`get_by_openid`(upsert 内部按 openid 查)。
|
||||
- **D**:无。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `openid` | String(64) | **UNIQUE**, index, NOT NULL | 服务号下用户唯一标识(网页授权拿到);upsert 主键 |
|
||||
| `unionid` | String(64) | index, nullable | 开放平台 unionid(服务号绑开放平台 + `scope=userinfo` 才有);跨 App/服务号统一用户 |
|
||||
| `nickname` | String(128) | nullable | 昵称;`userinfo` 授权后才有(base 阶段为空) |
|
||||
| `headimgurl` | String(512) | nullable | 头像 URL;同 `nickname`,userinfo 阶段才有 |
|
||||
| `first_code` | String(16) | nullable | 首次进入来源短链 code(从哪个群的链接授权进来) |
|
||||
| `first_group_id` | Integer | index, nullable | 首次来源群 = `cps_group.id`(语义关联,非 FK);群内用户级统计用 |
|
||||
| `first_seen` | DateTime(tz) | server_default now() | 首次授权时间 |
|
||||
| `last_seen` | DateTime(tz) | server_default now(), onupdate now() | 最近一次落地(upsert 时显式刷新) |
|
||||
|
||||
## 关系 / Join Key
|
||||
- `first_group_id` → `cps_group.id`(**语义关联,无 ForeignKey**;记首次来源群)。
|
||||
- 与 [`cps_click`](./cps_click.md) **无 id 级 join**:点击事件流目前不回写 openid 维度的用户统计,两者各按各的维度看(cps_click 看 PV/UV,本表看「谁」)。
|
||||
- 与本 App `user` 表**无关**:微信落地页用户不是本 App 注册用户(同 CPS 其它表)。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;UNIQUE index `openid`;index `unionid`、`first_group_id`。
|
||||
|
||||
## 注意
|
||||
- **两阶授权**:`base`(静默)只能拿 `openid`,不弹窗、不打扰,用作统计主力;`userinfo`(弹授权页)拿昵称头像 + unionid,只在用户点「领券」这种有交互的时刻触发(避免微信快照页),授权回流带 `?authed=1` 自动衔接复制口令。详见 `cps_redirect.py` 落地页逻辑。
|
||||
- **`unionid` 不一定有**:需服务号已绑微信开放平台 + 走到 `userinfo` 授权;base 阶段、未绑开放平台时为 None。
|
||||
@@ -0,0 +1,43 @@
|
||||
# device_liveness — 无障碍存活监控(心跳 + 掉线召回)
|
||||
|
||||
> 模型 `app/models/device.py`(`DeviceLiveness`) · 仓库 `app/repositories/device.py`(`register_or_update` / `touch_heartbeat` / `list_overdue` / `mark_notified` / `get_device` / `ack_kill_alert`) · 接口 用户 `POST /api/v1/device/register`、`POST /api/v1/device/heartbeat`、`GET /api/v1/device/liveness`、`POST /api/v1/device/liveness/ack`(`app/api/v1/device.py`);后台 worker `heartbeat_monitor_worker` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
每行 = 一个用户的一台设备(per-install,`(user_id, device_id)` 唯一)。客户端无障碍服务存活时周期上报心跳刷新 `last_heartbeat_at`;App 前台/登录拿到极光 push token 时上报 `registration_id`。后端 `heartbeat_monitor_worker` 扫「曾保护过、现已心跳超时」的设备,推送(或本期仅终端打印)提醒用户重开无障碍。**表名不叫 `device`**:它存的不是设备信息(品牌/型号),而是**无障碍存活状态**。#65 新增。
|
||||
|
||||
> 设计见 spec `spec/accessibility-liveness-push.md`(推送版)+ `spec/accessibility-liveness-pull-prompt.md`(后置 pull 提醒版)。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C / U(upsert)**:`register_or_update`(`POST /device/register`,App 拿到 push token 时调)按 `(user_id, device_id)` upsert,只在传入非空时更新 `registration_id`/`platform`/`app_version`;`touch_heartbeat`(`POST /device/heartbeat`,无障碍服务存活时周期调,**心跳也能自注册**)在 `accessibility_enabled=true` 时刷 `last_heartbeat_at`、置 `ever_protected=true`、状态机重置回 `alive`、清 `notified_at`(掉线恢复→下次再断才再推一条)。
|
||||
- **U(worker)**:`mark_notified`(`heartbeat_monitor_worker` 检出掉线后)置 `liveness_state='notified'` + `notified_at` + **`kill_alert_pending=True`**;`ack_kill_alert`(`POST /device/liveness/ack`,客户端弹过引导后)清 `kill_alert_pending`(幂等)。
|
||||
- **R**:`list_overdue`(worker 扫描:`ever_protected=true` + `liveness_state='alive'` + `last_heartbeat_at` 早于 `now - timeout`)→ 掉线设备列表;`get_device`(`GET /device/liveness`,客户端进 App 拉本机是否被判掉线过)。
|
||||
- **D**:无。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `user_id` | Integer | **FK→user.id**, index, NOT NULL | 归属用户 |
|
||||
| `device_id` | String(128) | index, NOT NULL | 客户端 `DeviceId.get()` 生成的 per-install id(如 `device_Pixel_ab12cd34`) |
|
||||
| `registration_id` | String(64) | nullable | 极光推送 registration id;`JCollectionAuth` 同意后才下发,拿到才填 |
|
||||
| `platform` | String(16) | NOT NULL, default `android` | |
|
||||
| `app_version` | String(32) | nullable | 上报时 App 版本 |
|
||||
| `ever_protected` | Boolean | NOT NULL, default false | 收到过 service 心跳即 true(=该设备开过无障碍,功能对它有意义)。`list_overdue` 的过滤前提 |
|
||||
| `last_heartbeat_at` | DateTime(tz) | index, nullable | 最近一次 service 心跳时间(存活证明);超时即视为保护掉线 |
|
||||
| `last_report_protection_on` | Boolean | NOT NULL, default false | 最近一次上报的无障碍开关状态(观测用) |
|
||||
| `liveness_state` | String(16) | NOT NULL, default `unknown` | 状态机:`unknown` → `alive`(收到 service 心跳)→ `silent`/`notified`(扫描发现超时并已推送);心跳恢复 handler 重置回 `alive` |
|
||||
| `notified_at` | DateTime(tz) | nullable | 最近一次推送告警时间 |
|
||||
| `kill_alert_pending` | Boolean | NOT NULL, default false | 掉线告警「待客户端提醒」标记(后置 pull 版)。**与 `liveness_state` 解耦**:worker 检出掉线即置 True;`touch_heartbeat`(心跳恢复)不动它;只由客户端 ack 清 |
|
||||
| `created_at` | DateTime(tz) | server_default now() | |
|
||||
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | |
|
||||
|
||||
## 关系 / Join Key
|
||||
- `user_id` → `user.id`(多对一,**硬外键**)。
|
||||
- 与 `comparison_record.device_id` 等表的 `device_id` **同源**(都来自客户端 per-install id),但本表无 id 级 join。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;index `user_id`、`device_id`、`last_heartbeat_at`;UNIQUE(`user_id`, `device_id`) = `uq_device_liveness_user_device`(一用户一设备一行,upsert 幂等键)。
|
||||
|
||||
## 注意
|
||||
- **`kill_alert_pending` 为什么和 `liveness_state` 解耦**:服务随 App 重启会先发心跳把 `state` 重置回 `alive`,若复用 `state` 判「待提醒」,客户端进 App 这一刻可能恰好已被重置 → 漏看这次掉线。故另设一个只由 worker 置、只由客户端 ack 清的标记,规避竞态。
|
||||
- **`list_overdue` 本期不要求有 `registration_id`**:本期只做终端打印检测、未真推送,没接极光 token 的设备也要检出。
|
||||
- 心跳超时阈值由 worker 的 `timeout_minutes` 决定(不在表里)。
|
||||
@@ -0,0 +1,36 @@
|
||||
# launch_confirm_sample — 启动确认窗 agent 兜底样本
|
||||
|
||||
> 模型 `app/models/launch_confirm_sample.py`(`LaunchConfirmSample`) · 仓库 `app/repositories/launch_confirm_sample.py`(`insert_sample`) · 接口 `POST /internal/launch-confirm-sample`(pricebot→app-server,`app/api/internal/launch_confirm.py`,`X-Internal-Secret` 校验) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
pricebot 的 `launch_confirm_agent` 每次「静态 PROFILES 没认出、靠 LLM 兜底放行」一个跨 App 启动确认窗(繁体 / 英文 / 小语种 / ROM 改版的「想要打开 XX」弹窗)时,把完整样本 server→server 落这里。研发定期**人工**把 `exec_success=true` 的样本沉淀进 pricebot 的 `launch_confirm.PROFILES`(静态快路径),让以后同款窗不必再调 LLM(回到快路径)。
|
||||
|
||||
> **不是给客户端的接口**:与登录无关、不鉴权,靠共享密钥头 `X-Internal-Secret`(== `settings.INTERNAL_API_SECRET`)校验,未配置则 503。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(插入)**:`insert_sample`(`POST /internal/launch-confirm-sample`)。**都上报、不去重**(by design):同一种窗多台机器上报多条,研发按 `host_package`/`system_locale` 聚合后人工筛。大字段截断到列长上限(繁体长标题等),空串入库为 None。
|
||||
- **R / U / D**:**无**(研发查库靠 SQL 直接看 + 人工沉淀,不经本服务接口)。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `created_at` | DateTime(tz) | server_default now(), index | 上报时间 |
|
||||
| `trace_id` | String(64) | index, nullable | pricebot 侧 trace_id:回指原始 trace,可去 `price.shaguabijia.com` 查完整上下文 |
|
||||
| `device_id` | String(64) | index, nullable | 上报设备 |
|
||||
| `host_package` | String(128) | index, nullable | 弹窗宿主包(= 触发判据,也是研发沉淀进 `PROFILES.host_packages` 的键)。**按它聚合人工筛** |
|
||||
| `target_app` | String(128) | nullable | 被打开的目标 App 包名(标题里「想要打开 X」的 X 是变量,据此把它从 sign 里挖空) |
|
||||
| `system_locale` | String(32) | index, nullable | 系统语言地区(`zh-TW`/`en-US`…)—— 多语言问题的核心维度。⚠️ 前端 `AgentStepRequest` 暂未上报设备信息 → **现恒 None**,待前端加 `device_info` 字段后回填 |
|
||||
| `exec_success` | Boolean | NOT NULL, default false | 这次兜底是否成功放行(弹窗消失=true;放弃 / LLM 没认出=false)。**研发只沉淀 true 的** |
|
||||
| `dialog_title` | String(256) | nullable | 弹窗标题全文(研发据此提 `launch_sign`),如「『傻瓜比价』想要開啟『京東』」 |
|
||||
| `payload` | JSON(PG: JSONB) | nullable | 完整样本大 JSON:`{is_launch_confirm, llm_model, llm_reason, plan, dialog_tree, device_info:{screen,locale,brand,model,android_version,rom_version}}`。加字段不必迁移 schema |
|
||||
|
||||
## 关系 / Join Key
|
||||
- 无外键。`trace_id` 软指 pricebot trace(回指排查),`device_id` 软指设备;均不与本服务其它表 join。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;index `created_at`、`trace_id`、`device_id`、`host_package`、`system_locale`。
|
||||
- **无唯一约束**(by design 不去重,见上)。
|
||||
|
||||
## 注意
|
||||
- **PG 上 `payload` 用 JSONB**(可建 GIN 索引),SQLite(本地/测试)退化为通用 `JSON`(同 `price_observation`、`comparison_record`)。
|
||||
- **为什么大字段塞 `payload` 而非拆列**:弹窗树快照 + LLM plan + 设备信息都是「研发偶尔人工看」的兜底数据,拆列会让每次扩样本字段都要迁移 schema;只把聚合/筛选要用的维度(`host_package`/`system_locale`/`exec_success`)提成结构化列建索引。
|
||||
+11
-17
@@ -92,23 +92,15 @@ alembic upgrade head
|
||||
- 生成后**手动把文件名前缀去掉**(内部 id 别动);或
|
||||
- 在 `alembic.ini` 配 `file_template = %%(slug)s`(注意 ini 里 `%` 要写成 `%%`)让以后直接生成无前缀文件名(缺点:同名 slug 会冲突,描述写具体点)。
|
||||
|
||||
## 6. 当前迁移链(12 条,含合并迁移)
|
||||
```
|
||||
init_user_table ← 起点(down_revision=None)
|
||||
→ welfare_tables_coin_account_coin_txn 金币账户/金币流水/签到/任务表
|
||||
→ cash_transaction_table 现金流水表
|
||||
→ savings_record_table 省钱明细表
|
||||
→ withdraw_order_and_user_openid 提现单表 + user.wechat_openid
|
||||
→ user_wechat_nickname_avatar user 微信昵称/头像字段
|
||||
→ unique_wechat_openid user.wechat_openid 唯一约束
|
||||
→ savings_shop_dishes 省钱明细 shop_name + dishes
|
||||
→ ad_reward_record 看广告发奖记录表
|
||||
→ (此处分叉,两条并行 head)
|
||||
├→ feedback_table 帮助与反馈表(PR #4)
|
||||
└→ ef96beb47b1e_convert_savings_record_dishes_from_json dishes 列改 JSONB(PR #6)
|
||||
→ f01db5d77dac_merge_pg_jsonb_and_feedback_heads (head,最新) 合并两条分叉
|
||||
```
|
||||
> 顺序以 `alembic history` 输出为准(按 `down_revision` 链,不是文件名字母序)。
|
||||
## 6. 当前迁移链(规模与当前 head)
|
||||
- `alembic/versions/` 当前约 **78 个迁移文件**(含 14 个合并迁移),从 `init_user_table`(`down_revision=None`)起一路接到当前单一 head。**别背具体链**——它随每次改表增长,看实时输出:
|
||||
```bash
|
||||
ls alembic/versions/*.py | wc -l # 迁移文件数
|
||||
alembic history # 完整链(按 down_revision,不是文件名字母序)
|
||||
alembic heads # 当前 head(正常只 1 个)
|
||||
```
|
||||
- **当前 head**(2026-06-23):`4dc2af7ebe74`(文件 `4dc2af7ebe74_merge_device_65_and_comparison_token_.py`)——它合并了今日两条并行链:`comparison_token_cols`(#a584e1f,`comparison_record` 加 `input_tokens`/`output_tokens`)与 #65 device 链末端(`device_liveness` 表 + `ad_feed_reward_trace_id`)。即 `down_revision = ('comparison_token_cols', 'ad_feed_reward_trace_id')`。
|
||||
- 频繁多 head 是常态(多人/多分支并行改表),靠合并迁移收敛回单 head,详见下面「踩坑 → 多 head 分叉」。
|
||||
|
||||
## 7. 踩坑
|
||||
|
||||
@@ -128,6 +120,8 @@ please specify a specific target revision, '<branchname>@head'...
|
||||
- PR #6 (PG/JSONB) 加了 `ef96beb47b1e`,`down_revision='c8d9e0f1a2b3'`(也是 ad_reward) ← 跟 #4 同源,撞车
|
||||
- 两个 PR 都合进 main 后,`alembic heads` 出现两个
|
||||
|
||||
> 此后这种合并是常态,每次都用 `alembic merge` 收敛。最近一次(2026-06-23):#65 device 链(`device_table` → `device_kill_alert_pending`,经 `ad_feed_reward_trace_id`)与 `comparison_token_cols`(`comparison_record` 加 token 列)并行,用 `4dc2af7ebe74_merge_device_65_and_comparison_token_.py` 合成单一 head。
|
||||
|
||||
**修复**:
|
||||
```bash
|
||||
alembic heads # 列出冲突的两个 head id
|
||||
|
||||
@@ -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/` 下对应同名文件。*
|
||||
@@ -0,0 +1,194 @@
|
||||
# CPS 发券分发 + 微信授权 系统说明(交接文档)
|
||||
|
||||
> 给接手人。读完应能:看懂系统、带运营走完"设群→建活动→生成链接→看统计"、接微信授权、排障、知道边界。
|
||||
> 代码引用一律用「文件:符号」(不写行号,重构后符号比行号稳)。
|
||||
|
||||
---
|
||||
|
||||
## 一、这是什么 / 业务目标
|
||||
|
||||
傻瓜比价的 **CPS 群发分发**系统:运营在 admin 后台建「群」、建「活动」(美团/淘宝/京东的券)、生成带渠道追踪的**落地页短链 `/c/{code}`** 发到各微信群;用户点链接 → 落地页 → 跳转下单或复制淘口令领券 → 后端记点击、(美团)拉订单对账、(微信内)拿 openid 做用户级统计。
|
||||
|
||||
**目标**:把券发出去 + 把「点击 → 下单 → 佣金」漏斗、「哪个群/哪个微信用户领了券」统计出来,指导投放。
|
||||
|
||||
---
|
||||
|
||||
## 二、两套入口 / 两个域名(先建立全局)
|
||||
|
||||
| | 域名 | 反代 | 代码 | 鉴权 |
|
||||
|---|---|---|---|---|
|
||||
| 运营后台 | admin-web.shaguabijia.com | admin 子应用 :8771 | `admin/routers/cps.py`,前缀 `/admin/api/cps` | admin JWT(operator/finance) |
|
||||
| 用户落地页 | **coupon.shaguabijia.com** | app-server :8770 | `api/v1/cps_redirect.py`,**无前缀** | **公网无鉴权**(群里谁都能点) |
|
||||
|
||||
> 两个域名都全反代到 app-server/admin。落地页域名 `coupon.shaguabijia.com` 由 `settings.CPS_REDIRECT_BASE` 配置,生成短链时拼前缀。
|
||||
|
||||
---
|
||||
|
||||
## 三、数据流
|
||||
|
||||
```
|
||||
[admin] 建群(cps_group) → 建活动(cps_activity) → 生成链接(cps_link, 得 /c/{code})
|
||||
│ 复制发到微信群
|
||||
▼
|
||||
[用户点 /c/{code}] cps_landing:
|
||||
├─ (微信内+开关开+无cookie) 302 跳微信授权 → 回调换 openid → 存 cps_wx_user + 种 cookie → 跳回
|
||||
├─ 记一条点击 cps_click(visit, 带 openid)
|
||||
└─ 美团/京东:302 跳 target_url | 淘宝:返回 H5 落地页(图+复制淘口令按钮)
|
||||
│ (淘宝)点复制 → POST /c/{code}/copy 记 copy
|
||||
▼
|
||||
[美团] 运营在 admin 点「刷新对账」→ query_order 拉订单 → cps_order(按 sid 归群)
|
||||
▼
|
||||
[admin 统计] 对账统计页 + 群详情(折线图 / 每日明细 / 群内微信用户)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、数据表(6 张,均在 `app/models/cps_*`)
|
||||
|
||||
| 表 | 模型 | 职责 / 关键字段 |
|
||||
|---|---|---|
|
||||
| cps_group | `CpsGroup` | 群。`name` / `platforms`(多选 meituan/taobao/jd) / `sid`(美团渠道追踪位,仅美团群有;纯淘宝/京东为空) / `member_count` / `status`(active/archived) |
|
||||
| cps_activity | `CpsActivity` | 可推广活动池。`platform` / `name` / `act_id`(美团活动物料ID) / `product_view_sign`(美团商品券) / `payload`(淘宝整段淘口令 / 京东链接) / `image_url`(淘宝落地页主视觉图,绝对URL) |
|
||||
| cps_link | `CpsLink` | 群发短链。`code`(短码,`/c/{code}`) / `group_id` / `activity_id` / `sid` / `target_url`(美团短链/京东链接/淘宝淘口令) / `platform` |
|
||||
| cps_click | `CpsClick` | 点击事件。`link_id` / `group_id` / `sid` / `event_type`(**visit**=进页/被跳转,**copy**=淘宝点复制口令) / `ip` / `ua` / **`openid`**(微信授权拿到,非微信/未授权为空) |
|
||||
| cps_order | `CpsOrder` | 美团订单(query_order 拉回)。`order_id`(唯一,upsert) / `sid`(按它归群) / `pay_price_cents` / `commission_cents`(预估佣金) / `refund_profit_cents`(退款佣金) / `mt_status`(2付款3完成4取消5风控6结算) / `pay_time` |
|
||||
| cps_wx_user | `CpsWxUser` | 微信用户。`openid`(唯一) / `unionid` / `nickname` / `headimgurl` / `first_group_id`(首次授权来源群) |
|
||||
|
||||
> 金额**全程存「分」**,前端 ÷100 显示「元」。时间统一 tz-aware UTC,展示按北京。
|
||||
|
||||
---
|
||||
|
||||
## 五、三平台差异(最关键的一张表)
|
||||
|
||||
| | 美团 | 淘宝 | 京东 |
|
||||
|---|---|---|---|
|
||||
| 链接生成 | 调 `meituan.get_referral_link` 转链(**需群 sid**) | 直接用活动 `payload`(淘口令) | 直接用活动 `payload`(链接) |
|
||||
| 落地页行为 | 302 跳美团短链 | **H5 落地页**(主视觉图 + 复制淘口令按钮) | 302 跳京东链接 |
|
||||
| 对账 | ✓ `query_order` 拉订单/佣金 | ✗ 无开放 API | ✗ 无开放 API |
|
||||
| 能统计到 | 点击 + 下单 + 佣金 | 点击 + 复制次数 | 点击 |
|
||||
|
||||
> 淘宝/京东订单类指标(有效单/成交额/佣金)在统计页显示「-」(无法对账)。只有美团群(有 sid)有订单数据。
|
||||
|
||||
---
|
||||
|
||||
## 六、运营操作流程(带新人就照这个)
|
||||
|
||||
1. **建群**:CPS 分发 → 群管理 → 新建群。选这个群发哪些平台的券(可多选);含美团时可填 sid(留空自动 `g<id>`)。
|
||||
2. **建活动**:活动管理 → 新建活动。按平台填:美团填 `actId`(联盟「我要推广-活动推广」第一列) 或 商品券 `productViewSign`;淘宝填整段淘口令 + **必须上传落地页图**;京东填推广链接。
|
||||
3. **生成链接**:群管理 → 某群 → 「生成链接」→ 勾选要发的活动(只列该群平台范围内的)→ 每个活动出一条 `https://coupon.shaguabijia.com/c/{code}` → 复制发到群里。
|
||||
4. **对账(仅美团,finance 角色)**:对账统计 → 「刷新对账(拉美团订单)」→ 拉近 N 天订单入库。**非实时,要手动点**。
|
||||
5. **看统计**:对账统计页看各群点击/独立/复制/有效单/取消风控/成交额/预估佣金/结算佣金;**点群名进群详情** → 折线图(点击趋势,天/小时)+ 每日明细大表格(点击+订单按天,含退款/净佣金)+ 群内微信用户(头像/昵称/领券次数)。
|
||||
|
||||
---
|
||||
|
||||
## 七、微信授权(用户级统计)
|
||||
|
||||
### 目的
|
||||
用户在微信内打开落地页时,拿到 `openid`(识别这个人) + 昵称头像,做「每个微信用户在每个群领没领券」的用户级统计。
|
||||
|
||||
### 链路
|
||||
```
|
||||
/c/{code} (微信内 + 开关开 + 无 wx_openid cookie)
|
||||
→ 302 跳微信 base 授权(snsapi_base,静默不打断)
|
||||
→ 微信回调 /wx/oauth/cb?code=..&state=base:{code}
|
||||
→ wx_oauth.exchange_code 换 openid → cps_wx_user_repo.upsert 存用户 + 关联群 → 种 wx_openid cookie(30天)
|
||||
→ 302 跳回 /c/{code}(这次有 cookie,正常展示,记 visit 带 openid)
|
||||
[淘宝群] 用户点「复制口令」→ 若还没昵称头像 → 跳 snsapi_userinfo 授权(用户点击=交互触发,避免微信"快照页")
|
||||
→ 回调拉 userinfo 补 nickname/headimgurl → 跳回落地页自动复制
|
||||
```
|
||||
代码:`cps_redirect.py:cps_landing` / `cps_redirect.py:wx_oauth_cb` / `cps_redirect.py:_taobao_landing_html`(注入 userinfo 授权链接);微信调用封装 `integrations/wx_oauth.py`。
|
||||
|
||||
### 三个硬前提
|
||||
1. **必须是已认证服务号**(订阅号、未认证都不支持网页授权 → 报 10005)。
|
||||
2. **网页授权域名** = `coupon.shaguabijia.com`,在服务号后台「设置与开发→公众号设置→功能设置→网页授权域名」配置 + 放校验文件。校验文件由本服务端点 serve(见 `cps_redirect.py` 里 `MP_verify_*.txt` 路由,换号/换文件时改这里返回的串)。
|
||||
3. **凭证 + 开关**:`.env` 配 `WX_MP_APPID` / `WX_MP_SECRET`(服务号的,**≠** 微信支付那个 `WECHAT_APP_ID`);总开关 `WX_MP_OAUTH_ENABLED=true`。开关默认关 → 落地页走原逻辑、不拿 openid(认证未就绪时用)。判定见 `config.py:Settings.wx_oauth_active`。
|
||||
|
||||
### 群内微信用户怎么算(重要,踩过坑)
|
||||
`admin/repositories/cps.py:group_wx_users` 按「**该群有带 openid 的点击**」(cps_click.group_id + openid)算群内用户,再关联 cps_wx_user 拿画像。**不是**按 `cps_wx_user.first_group_id`——因为一个用户(cookie 已存)会去多个群,first_group_id 只记首次授权群,会漏算。
|
||||
- **美团/京东群只有 openid、没有昵称头像**:它们是 302 跳转、没有"点复制领券"那一步,触发不了 userinfo。只有**淘宝群**点复制口令时才补昵称头像。
|
||||
|
||||
---
|
||||
|
||||
## 八、端点清单
|
||||
|
||||
### admin(`/admin/api/cps`,operator/finance 鉴权)— `admin/routers/cps.py`
|
||||
- 群:`GET /groups` `POST /groups` `PATCH /groups/{id}` `DELETE /groups/{id}`
|
||||
- 活动:`GET /activities` `POST /activities` `PATCH /activities/{id}` `DELETE /activities/{id}` `POST /upload-image` `GET /activity-images`
|
||||
- 生成链接:`POST /referral-links`(批量,按 activity_ids,美团转链)
|
||||
- 对账:`POST /orders/reconcile`(拉美团订单) `GET /orders`(明细)
|
||||
- 统计:`GET /stats`(按群对账统计) `GET /groups/{id}/timeseries`(折线) `GET /groups/{id}/daily`(每日明细) `GET /groups/{id}/wx-users`(群内微信用户)
|
||||
|
||||
### 落地页(coupon 域,公网无鉴权)— `api/v1/cps_redirect.py`
|
||||
- `GET /c/{code}`(落地页:授权+记点击+跳转/H5)
|
||||
- `POST /c/{code}/copy`(淘宝点复制,记 copy)
|
||||
- `GET /wx/oauth/cb`(微信授权回调)
|
||||
- `GET /MP_verify_*.txt`(微信域名归属校验文件)
|
||||
|
||||
> 删除/编辑都不级联删已生成的 cps_link——**已发出去的短链永远要能用**,落地页只查 link 本身,不依赖 group/activity 是否还在(删了淘宝活动,其落地页图回退默认图)。
|
||||
|
||||
---
|
||||
|
||||
## 九、配置项(`.env`,见 `app/core/config.py`)
|
||||
|
||||
| 配置 | 说明 |
|
||||
|---|---|
|
||||
| `CPS_REDIRECT_BASE` | 落地页域名,如 `https://coupon.shaguabijia.com`。生成 `/c/{code}` 拼前缀;也是图/回调的绝对 URL 基址 |
|
||||
| `MT_CPS_APP_KEY` / `MT_CPS_APP_SECRET` | 美团联盟凭证(转链 + 对账)。缺则美团相关优雅降级 |
|
||||
| `MT_CPS_DEFAULT_SID` | 美团默认渠道位(群没 sid 时兜底) |
|
||||
| `WX_MP_APPID` / `WX_MP_SECRET` | **微信服务号**网页授权凭证(≠ `WECHAT_APP_ID` 那个 App 支付用的) |
|
||||
| `WX_MP_OAUTH_ENABLED` | 微信授权总开关。默认 `false`;认证就绪后置 `true` 重启启用 |
|
||||
|
||||
---
|
||||
|
||||
## 十、代码地图
|
||||
|
||||
| 关注点 | 位置 |
|
||||
|---|---|
|
||||
| admin 端点 | `admin/routers/cps.py` |
|
||||
| admin 业务逻辑(群/活动/订单/统计/群内用户) | `admin/repositories/cps.py`(`group_stats` / `group_click_timeseries` / `group_order_daily` / `group_wx_users` / `reconcile_orders`) |
|
||||
| admin 出入参 | `admin/schemas/cps.py` |
|
||||
| 落地页 + 微信授权 | `api/v1/cps_redirect.py` |
|
||||
| 短码生成 / 记点击 / 点击聚合 | `repositories/cps_link.py`(`create_link` / `record_click` / `click_stats_by_group`) |
|
||||
| 微信用户 upsert | `repositories/cps_wx_user.py` |
|
||||
| 美团集成(转链/拉单) | `integrations/meituan.py`(`get_referral_link` / `query_order`) |
|
||||
| 微信网页授权集成 | `integrations/wx_oauth.py`(`build_authorize_url` / `exchange_code` / `get_userinfo`) |
|
||||
| 数据模型 | `app/models/cps_*.py` |
|
||||
| admin 前端 | shaguabijia-admin-web `src/app/(main)/cps/`(列表页 `page.tsx` + 群详情 `groups/[id]/page.tsx`) |
|
||||
|
||||
---
|
||||
|
||||
## 十一、部署 / 运维
|
||||
|
||||
- 部署:`ssh ecs1` → `deploy server`(app-server + admin 后端)/ `deploy web`(admin-web 前端)。机制 = git reset 到 origin/main + 装依赖 + alembic 迁移 + 重启 + health 自检。
|
||||
- 改 `.env`(如开微信开关、换服务号凭证)后:`systemctl restart shaguabijia-app-server shaguabijia-admin` 或重跑 `deploy server`。
|
||||
- 数据库迁移:CPS 相关迁移在 `alembic/versions/cps_*`(建表 `cps_tables`/`cps_link_tables`、多平台 `cps_v2_platforms`、活动图 `cps_activity_image`、微信用户 `cps_wx_user`)。
|
||||
|
||||
---
|
||||
|
||||
## 十二、排障速查
|
||||
|
||||
| 现象 | 原因 / 处理 |
|
||||
|---|---|
|
||||
| 微信授权报 **10005**(没有这些 scope 权限) | 账号不是已认证服务号,或「网页授权获取用户基本信息」接口未获得。确认账号类型 + 接口权限 |
|
||||
| 微信授权报 **10003**(redirect_uri 域名不一致) | 服务号后台「网页授权域名」没填 `coupon.shaguabijia.com` 或没保存成功 |
|
||||
| 换了服务号后授权失败 | 换 `.env` 的 `WX_MP_APPID/SECRET` + 在**新服务号**后台重配网页授权域名(校验文件可能是新的,要换 `MP_verify_*` 端点返回值) |
|
||||
| 落地页在微信里不跳授权 | 开关 `WX_MP_OAUTH_ENABLED` 没开,或不是微信 UA(外部浏览器不跳,正常) |
|
||||
| 群详情「群内微信用户」看不到人 | 已按"本群点击"算(`group_wx_users`)。确认该群 cps_click 有带 openid 的记录;美团/京东群昵称头像本就为空 |
|
||||
| 统计页订单/佣金是「-」 | 淘宝/京东群无对账(正常);美团群需先点「刷新对账」拉单 |
|
||||
| 成交额/佣金不更新 | 对账非实时,要 finance 手动「刷新对账」 |
|
||||
| 账号验证 appid/账号类型 | 可用基础 token 调 `cgi-bin/account/getaccountbasicinfo` 查(account_type / 认证 / 主体) |
|
||||
|
||||
---
|
||||
|
||||
## 十三、已知边界 / 技术债(接手前必读)
|
||||
|
||||
1. **下单归因只到「群」级,做不到「人」级**:美团 `query_order` 返回的订单只带 `sid`(群级渠道位),不带 openid。要统计"某个微信用户下没下单/下单额",需把 sid 体系改成**用户级**(每个用户进落地页时用其 openid 实时转链),是较大改动,**未做**。当前微信侧只能统计到"用户级**领券/点击**",下单仍是群级。
|
||||
2. **美团/京东群拿不到昵称头像**:无 userinfo 触发点(直接 302 跳转)。只有淘宝群(H5 落地页点复制)能补昵称头像。
|
||||
3. **淘宝/京东无对账 API**:只能统计点击/复制,拿不到下单/佣金。
|
||||
4. **落地页公网无鉴权**:群发场景,链接谁拿到都能点(刻意如此)。点击 UV 按 (ip, ua) 近似去重,非精确。
|
||||
5. **对账非实时**:`cps_order` 是上次「刷新对账」的快照,不点不更新。
|
||||
6. **激励奖励金额未统计**:美团订单里 `incentiveOrder` 标记了是否参与激励活动,但 `query_order` 不给激励奖励金额,需另接「奖励活动报表」API(未做)。
|
||||
|
||||
---
|
||||
|
||||
*本系统由本轮从零搭建:CPS 分发(群/活动/链接/对账/统计)+ 群详情可视化 + 微信网页授权用户级统计。后端在 shaguabijia-app-server,前端在 shaguabijia-admin-web。*
|
||||
+27
-27
@@ -3,7 +3,7 @@
|
||||
> 域名:`app-api.shaguabijia.com`(HTTPS,nginx 反代)
|
||||
> 仓库:`shaguabijia-app-server`
|
||||
> 接口协议详见 [`docs/api/`](./api/)(索引 + 一接口一文件)
|
||||
> 最后更新:2026-05-31
|
||||
> 最后更新:2026-06-23(§7 数据模型改为指向 OVERVIEW、§5 美团 CPS 对齐多 tab feed/4 接口、§6 厘清 coupon/step 有写库副作用 ≠ 纯透传壳、§6.5 补 `/internal/app-version`、Alembic 迁移数更新)
|
||||
|
||||
---
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
| **看广告发奖** | 穿山甲 GroMore 激励视频 S2S 回调(SHA256 验签)+ 4 态 CTA 冷却 |
|
||||
| **帮助与反馈** | 用户提交反馈(含截图)+ 静态 `/media` 服务 |
|
||||
|
||||
**本服务自身没有"比价"实现**——比价/领券业务的核心都在 pricebot-backend(另一个 repo),本服务对 step 类接口做"透传壳"。无爬虫、无 LLM,但**有钱包/福利等业务模型**(数据模型从早期 1 张 `user` 表已扩展到 10 张,见 §7)。
|
||||
**本服务自身没有"比价"实现**——比价/领券业务的核心都在 pricebot-backend(另一个 repo),本服务对 step 类接口做"透传壳"。无爬虫、无 LLM,但**有钱包/福利等业务模型**(数据模型从早期 1 张 `user` 表已扩展到 **40 张业务表**,见 §7 / [database/OVERVIEW.md](./database/OVERVIEW.md))。
|
||||
|
||||
---
|
||||
|
||||
@@ -188,32 +188,44 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
|
||||
|
||||
> 美团请求字段 `listTopiId`(少个 c)是官方拼写,非笔误。经纬度 `*1_000_000` 取整。
|
||||
|
||||
### 5.2 三个接口
|
||||
### 5.2 对外四个接口
|
||||
|
||||
- `query_coupon`:底层取数,被 `coupons` 路由和 `feed` 路由共用。
|
||||
- `coupons` 路由:对外的搜索/榜单接口,**客户端暂未接入**。
|
||||
- `feed` 路由:首页推荐流,每页并发拉外卖(platform=1)+ 到店(platform=2,到餐)各一次,按 **2 外卖 + 1 到店** 交叉去重(`_interleave`);榜单组合写死 3 页(`_TOPIC_ROUNDS`:爆款 / 今日必推 / 外卖精选+到店限时),第 3 页 `has_next=false`、第 4 页返空。
|
||||
> 接口级入参/出参/各 tab 行为详见 [api/meituan-feed.md](./api/meituan-feed.md) 等,本节只讲后端形态。
|
||||
|
||||
- `coupons`:对外的搜索/榜单接口(底层 `query_coupon`),**客户端暂未接入**。
|
||||
- `feed`:首页推荐流,**已是多 tab**(入参 `tab`):
|
||||
- `rec` 智能推荐:走**离线库 `meituan_coupon`**(筛佣金率≥3%、`DISTINCT ON` 去重、按销量降序分页),**纯库查询、不打美团、不依赖 MT 凭证**(实测同城热销中位佣金 ~0.8%,实时筛≥3% 每页剩 0–1 条又撞 402,故从库出)。不显示距离。
|
||||
- `distance` 距离最近:实时拉外卖+到店两路,按用户坐标由近及远。
|
||||
- 默认(空 tab):旧的逐轮分页混合 feed(2 外卖 + 1 到店交叉,写死 3 页爆款/今日必推/精选+限时,第 4 页返空),**仅老客户端兼容**。
|
||||
- 任何失败场景返 `200` + 空 `items` + `status=degraded`(不抛 5xx)。
|
||||
- `top-sales`:独立的销量榜接口,离线库 `meituan_coupon` 按销量降序 + 跨源去重,**不实时打美团**。
|
||||
- `referral-link`:换推广链接,客户端取 `link_map["3"]`(deeplink)优先跳美团 App。
|
||||
- `query_coupon`:底层取数(被 `coupons`/`distance` feed 共用),不直接对外。
|
||||
|
||||
### 5.3 风险点
|
||||
|
||||
- **三接口无鉴权**:任何人可调,消耗你的美团 appkey 能力/配额。
|
||||
- **对外接口无鉴权**:任何人可调,消耗你的美团 appkey 能力/配额。
|
||||
- **`sid` 可被客户端覆盖**:他人可借接口刷自己渠道分佣,建议服务端锁定。
|
||||
- **`feed` 静默吞异常**:`_fetch_topic` 对美团错误 `except 返回 []` 且不打日志,导致美团失败时 `feed` 返回"200 + 空 items"无声失败。排查时改用 `coupons`(它以 502 暴露错误)。
|
||||
- **`feed` 失败返 `status=degraded`**:任何失败场景返 `200` + 空 `items` + `status=degraded`(不抛 5xx,避免前端弹报错 toast)。排查时看 `status` 字段区分 `empty`(真没数据)与 `degraded`(上游/库查询失败);或改用 `coupons`(它以 502 暴露错误)。`rec`/`top-sales` 走离线库,prod 刚部署 ETL 未灌则返 `empty`——开内测前先手动跑一次 ETL。
|
||||
|
||||
---
|
||||
|
||||
## 6. 领券透传(coupon/step)
|
||||
|
||||
产品"一键领券"的核心接入点,但**真正的领券逻辑不在本服务**——在另一个 repo `pricebot-backend`(GoalEngine + 事件驱动)。本服务只做"透传壳"。
|
||||
产品"一键领券"的核心接入点,**真正的领券逻辑不在本服务**——在另一个 repo `pricebot-backend`(GoalEngine + 事件驱动)。但 `coupon/step` **不是纯透传壳**:它在转发 pricebot 之余,还**best-effort 写库**(纯透传壳是 `compare.py` 的 `intent/recognize`、`price/step`,它们只转发不写库)。
|
||||
|
||||
```
|
||||
客户端 → POST /api/v1/coupon/step (任意 JSON body,含 device_id/trace_id/step)
|
||||
→ coupon.py:不做 schema 校验,仅读 device_id/trace_id/step 打日志
|
||||
→ async httpx 把 body 原样 POST 到 PRICEBOT_BASE_URL/api/coupon/step
|
||||
→ coupon.py:不做 schema 校验,仅读 device_id/trace_id/step
|
||||
→ [副作用1] step==0 → 写 coupon_prompt_engagement(claim_started)
|
||||
→ async httpx 把 body 原样 POST 到 PRICEBOT_BASE_URL/api/coupon/step(按 trace_id 一致性 hash 选实例)
|
||||
→ [副作用2] 从响应抽券结果(去重)→ 写 coupon_claim_record
|
||||
→ [副作用3] action.command==done → 写 coupon_daily_completion(首页「去领取」卡置灰源)
|
||||
→ 把 pricebot 的响应原样返回
|
||||
```
|
||||
|
||||
> 三处写库(`_mark_engagement_blocking` / `_record_claims_blocking` / `_mark_completed_blocking`)全 **best-effort**:各自独立 session、经 `run_in_threadpool` 不阻塞事件循环,写失败只 `logger.warning`、绝不连累领券返回。判断维度是 `device_id`(`user_id` 可空旁路,仅资产留痕)。落库细节见 [database/coupon_state.md](./database/coupon_state.md)。
|
||||
|
||||
- **MVP 不鉴权**:已去掉 `CurrentUser` 依赖。`device_id` 透传给 pricebot 区分设备,但后端拿不到 `user_id` → 领券行为暂时绑不到登录用户(采集不到用户级画像)。待补 JWT,见 `待办与技术债.md` P1。
|
||||
- **不做 schema 校验**:body 原样透传,pricebot 自己校验。
|
||||
- **错误**:body 非合法 JSON → 400;pricebot 不可达或返回 5xx → 502。
|
||||
@@ -228,27 +240,15 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
|
||||
|
||||
**启动确认窗兜底样本**(2026-06):国产 ROM 打开别的 App 时弹"想要打开 XX"确认窗,pricebot 对没见过文案(繁体/英文/ROM 改版)的窗用 LLM 兜底放行后,把样本 POST 到 [`/internal/launch-confirm-sample`](../app/api/internal/launch_confirm.py) 落 `launch_confirm_sample` 表(host 包 + 弹窗树 + LLM plan + 设备 locale/机型),供研发定期人工沉淀回 pricebot 的规则 yaml(回到快路径)。**需 pricebot 与 app-server 两边 `.env` 配同一 `INTERNAL_API_SECRET` 才生效**(未配则 pricebot 侧跳过上报、不影响比价/领券)。
|
||||
|
||||
> 同类内部回写端点还有 `/internal/price-observation`(价格观测)、`/internal/store-mapping`(跨平台店铺映射)等 pricebot 比价资产沉淀。
|
||||
> 同类内部回写端点(同走 `X-Internal-Secret`)还有:`/internal/price-observation`(价格观测)、`/internal/store-mapping` + `/store-mapping/lookup` + `/store-mapping/invalidate`(跨平台店铺映射沉淀/反查/失效)等 pricebot 比价资产沉淀;以及 **`/internal/app-version`(OTA)**——**发布流程**(非 pricebot)出 APK 后写最新版本号/下载链接/sha256 落 `app_config`,客户端再 `GET /api/v1/platform/app-version` 读做检查更新。完整 6 个内部端点见 [api/internal.md](./api/internal.md)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 数据模型
|
||||
|
||||
业务表(下表)+ `alembic_version` 框架表。生产 PG / 开发可回退 SQLite。
|
||||
**完整表清单不在本文维护**(避免双份漂移)——共 **40 张业务表** + `alembic_version` 框架表,逐表字段级说明 + 跨表关系/写入路径见 **[database/OVERVIEW.md](./database/OVERVIEW.md)**(总览)与 [database/README.md](./database/README.md)(一表一文件索引)。生产 PG / 开发可回退 SQLite。
|
||||
|
||||
| 表 | models 文件 | 说明 |
|
||||
|---|---|---|
|
||||
| `user` | `models/user.py` | 用户(phone 唯一索引;注销时改成 `deleted_<id>` 释放约束) |
|
||||
| `feedback` | `models/feedback.py` | 用户反馈(content/contact/images JSON 列/status) |
|
||||
| `coin_account` | `models/wallet.py` | 用户的金币余额(`balance_cents` 单位:分) |
|
||||
| `coin_transaction` | `models/wallet.py` | 金币流水(签到/任务/兑换/广告;`type` + `amount_cents`) |
|
||||
| `cash_transaction` | `models/wallet.py` | 现金流水(兑换/提现) |
|
||||
| `withdraw_order` | `models/wallet.py` | 提现单(`status` + `wxpay_batch_id`,微信支付商家转账) |
|
||||
| `signin_record` | `models/signin.py` | 签到记录(每日一行,索引 `(user_id, signin_date)`) |
|
||||
| `user_task` | `models/task.py` | 一次性任务领取记录 |
|
||||
| `savings_record` | `models/savings.py` | 省钱明细(`dishes` 在 PG 上为 JSONB,SQLite 上是 TEXT) |
|
||||
| `comparison_record` | `models/comparison.py` | 比价记录(每次比价完整明细;`(user_id, trace_id)` 唯一幂等;4 个 JSON(B) 列含 `raw_payload`;独立于 savings) |
|
||||
| `ad_reward_record` | `models/ad_reward.py` | 看广告发奖(`trans_id` 唯一,幂等) |
|
||||
本文只保留分层与高频维度的速查:数据访问统一在 `repositories/`,ORM 表结构在 `models/`(钱包/福利在 `wallet.py`/`signin.py`/`task.py`、比价在 `comparison.py`、领券今日状态在 `coupon_state.py`、CPS 群发在 `cps_*.py`、pricebot 内部沉淀在 `price_observation.py`/`store_mapping.py`/`launch_confirm_sample.py`、无障碍存活在 `device.py`)。下面单列最常用的 `user` 表字段。
|
||||
|
||||
**user 表字段速查**:
|
||||
| 字段 | 类型 | 说明 |
|
||||
@@ -265,7 +265,7 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
|
||||
|
||||
`upsert_user_for_login`:phone 存在则更新 `last_login_at`,不存在则注册(注册即登录)。
|
||||
|
||||
**Alembic 迁移**:`alembic/versions/` 当前 11 个迁移 + 1 个合并迁移(`f01db5d77dac_merge_pg_jsonb_and_feedback_heads.py`,2026-05-29 创建——实习生 PG 分支和 user-profile-feedback 分支并行开发时 down_revision 都挂在 `c8d9e0f1a2b3` 上,导致多 head,合并迁移把两条链合并)。详见 [数据库迁移.md](./database/数据库迁移.md)。
|
||||
**Alembic 迁移**:`alembic/versions/` 当前约 **78 个迁移文件**(含 14 个合并迁移),当前单一 head `4dc2af7ebe74`(2026-06-23 合并 #65 device 链 + comparison token 列两条并行链)。多人/多分支并行改表频繁产生多 head,靠 `alembic merge` 收敛回单 head。规模随改表增长,**别背具体链**,以 `alembic history`/`alembic heads` 实时输出为准。详见 [数据库迁移.md](./database/数据库迁移.md)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -52,6 +52,7 @@ try:
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
import httpx # noqa: E402
|
||||
from sqlalchemy import delete, func, select # noqa: E402
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert # noqa: E402
|
||||
|
||||
@@ -85,6 +86,13 @@ DEFAULT_CONCURRENCY = 12 # 并发城市数(实测 15 并发 402 占 3% 可退
|
||||
STARTUP_STAGGER = 0.3 # 首批城市启动错峰间隔秒(削平瞬时峰值,实测能压低 402)
|
||||
PRUNE_FAIL_RATIO_MAX = 0.05 # 失败城占比超此值则本轮跳过 prune(避免大面积抓取失败误删库)
|
||||
|
||||
# 头图大小/类型富集:每城抓完后,按 head_url 去重并发 HEAD 取 Content-Length / Content-Type,
|
||||
# 写进 image_size / image_type。图片走美团图片 CDN(p*.meituan.net / img.meituan.net),直连可达、
|
||||
# 与 CPS 网关 402 限流无关;任何失败 → None,不阻断入库。城市级已 12 并发,这里每城再开 8,
|
||||
# 叠加峰值 ~96 个 HEAD,CDN 扛得住(实测单进程 50 并发稳)。
|
||||
IMAGE_META_WORKERS = 8 # 每城 HEAD 并发数
|
||||
IMAGE_META_TIMEOUT = 8.0 # 单图 HEAD 超时秒
|
||||
|
||||
SOURCES = [
|
||||
{"code": "search_waimai", "label": "外卖·搜外卖", "kind": "search", "platform": 1, "keyword": "外卖"},
|
||||
{"code": "search_meishi", "label": "外卖·搜美食", "kind": "search", "platform": 1, "keyword": "美食"},
|
||||
@@ -256,6 +264,9 @@ def _parse_item(item: dict, source: dict, city_id: str) -> dict | None:
|
||||
"sell_price_cents": price_cents,
|
||||
"original_price_cents": _to_cents(cpd.get("originalPrice")),
|
||||
"head_url": ((cpd.get("headUrl") or "").split("@")[0][:512] or None),
|
||||
# 占位;由 _fill_image_meta 抓完本城后并发 HEAD 回填(键必须在,upsert 才会带上这两列)
|
||||
"image_size": None,
|
||||
"image_type": None,
|
||||
"sale_volume": cpd.get("saleVolume"),
|
||||
"sale_volume_num": _sale_volume_num(cpd.get("saleVolume")),
|
||||
"commission_percent": comm_pct,
|
||||
@@ -268,6 +279,38 @@ def _parse_item(item: dict, source: dict, city_id: str) -> dict | None:
|
||||
})
|
||||
|
||||
|
||||
def _head_image_meta(client: httpx.Client, url: str) -> tuple[int | None, str | None]:
|
||||
"""HEAD 单张头图,取 (字节大小, MIME 类型);任何失败返回 (None, None),不阻断入库。"""
|
||||
try:
|
||||
r = client.head(url, timeout=IMAGE_META_TIMEOUT, follow_redirects=True)
|
||||
if r.status_code >= 400:
|
||||
return None, None
|
||||
cl = r.headers.get("content-length")
|
||||
size = int(cl) if cl and cl.isdigit() else None
|
||||
ctype = (r.headers.get("content-type") or "").split(";")[0].strip()[:32] or None
|
||||
return size, ctype
|
||||
except Exception: # noqa: BLE001
|
||||
return None, None
|
||||
|
||||
|
||||
def _fill_image_meta(rows: list[dict]) -> None:
|
||||
"""就地为本城 rows 填 image_size / image_type:按 head_url 去重后并发 HEAD,再回填每行。
|
||||
trust_env=False 直连图片 CDN(绕本机代理,CDN 无需走 CPS 代理)。"""
|
||||
urls = {r["head_url"] for r in rows if r.get("head_url")}
|
||||
if not urls:
|
||||
return
|
||||
meta: dict[str, tuple[int | None, str | None]] = {}
|
||||
with httpx.Client(trust_env=False, timeout=IMAGE_META_TIMEOUT) as client:
|
||||
with ThreadPoolExecutor(max_workers=IMAGE_META_WORKERS) as pool:
|
||||
futs = {pool.submit(_head_image_meta, client, u): u for u in urls}
|
||||
for f in as_completed(futs):
|
||||
meta[futs[f]] = f.result()
|
||||
for r in rows:
|
||||
m = meta.get(r.get("head_url"))
|
||||
if m:
|
||||
r["image_size"], r["image_type"] = m
|
||||
|
||||
|
||||
def _pull_one_city(city: dict, index: int, concurrency: int, stagger: float) -> list[dict]:
|
||||
"""抓单个城市的 3 路券并解析。worker 线程内执行(只抓取+解析,不碰 DB)。
|
||||
|
||||
@@ -286,6 +329,7 @@ def _pull_one_city(city: dict, index: int, concurrency: int, stagger: float) ->
|
||||
p = _parse_item(it, src, cid)
|
||||
if p:
|
||||
parsed.append(p)
|
||||
_fill_image_meta(parsed) # 抓完本城 → 并发 HEAD 回填头图 image_size/image_type
|
||||
return parsed
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
+71
-2
@@ -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"
|
||||
@@ -211,7 +278,7 @@ def test_sms_gc_purges_stale_only(monkeypatch) -> None:
|
||||
# ============================ 用户名 / 默认昵称 ============================
|
||||
|
||||
def test_login_assigns_username_and_nickname(client) -> None:
|
||||
"""新用户创建即分配:11 位纯数字 username(首位非 0/1,与手机号天然区分)+ 9 位字母数字默认昵称。"""
|
||||
"""新用户创建即分配:11 位纯数字 username(首位非 0/1,与手机号天然区分)+ "用户"+9 位字母数字默认昵称。"""
|
||||
phone = "13600136000"
|
||||
client.post("/api/v1/auth/sms/send", json={"phone": phone})
|
||||
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
|
||||
@@ -223,7 +290,9 @@ def test_login_assigns_username_and_nickname(client) -> None:
|
||||
assert uname[0] not in ("0", "1") # 无前导 0、与手机号(均以 1 开头)区分
|
||||
|
||||
nick = user["nickname"]
|
||||
assert nick and len(nick) == 9 and nick.isalnum() # 9 位字母+数字
|
||||
assert nick.startswith("用户") # "用户" 前缀
|
||||
suffix = nick.removeprefix("用户")
|
||||
assert len(suffix) == 9 and suffix.isalnum() # 后接 9 位字母+数字
|
||||
|
||||
|
||||
def test_username_stable_and_unique_on_relogin() -> None:
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,213 @@
|
||||
"""测试账号(app/core/test_account.py)行为测试。
|
||||
|
||||
覆盖四件事:
|
||||
- 免验证码登录(mock 与 real 模式都跳过校验);
|
||||
- 每次登录都重走新手引导(onboarding_completed 恒 false,即便完成表已有记录);
|
||||
- 每日登录上限(超过即 429),且只算 login、不算 send;
|
||||
- 功能关闭(TEST_ACCOUNT_PHONE 留空)时对任何号都不生效,回到原逻辑。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core import test_account
|
||||
from app.integrations import sms
|
||||
|
||||
# 测试自注入的固定号:各用例用 monkeypatch 把它塞进 settings.TEST_ACCOUNT_PHONE,
|
||||
# 再断言行为。它与 .env 配的值【相互独立】——测试不读 .env(保证确定性 / CI 无 .env 也能跑),
|
||||
# 写成和 .env 同值只为直观;换任意 11 位号测试照样全过,二者无需保持一致。
|
||||
TEST_PHONE = "11111111111"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def enabled(monkeypatch):
|
||||
"""启用测试账号功能并清空当日计数;monkeypatch 与 _reset 保证用例间隔离。"""
|
||||
monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_PHONE", TEST_PHONE)
|
||||
test_account._reset_for_test()
|
||||
yield
|
||||
test_account._reset_for_test()
|
||||
|
||||
|
||||
# ============================ 开关 / 识别 ============================
|
||||
|
||||
def test_disabled_when_phone_unset(monkeypatch) -> None:
|
||||
"""TEST_ACCOUNT_PHONE 留空 = 整功能关闭,对测试号也不特殊处理。"""
|
||||
monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_PHONE", "")
|
||||
assert test_account.is_enabled() is False
|
||||
assert test_account.is_test_account(TEST_PHONE) is False
|
||||
|
||||
|
||||
def test_only_configured_phone_is_test_account(enabled) -> None:
|
||||
assert test_account.is_test_account(TEST_PHONE) is True
|
||||
assert test_account.is_test_account("13800138000") is False
|
||||
|
||||
|
||||
def test_phone_is_whitespace_trimmed(monkeypatch) -> None:
|
||||
""".env 里手机号前后带空格也能正确识别。"""
|
||||
monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_PHONE", f" {TEST_PHONE} ")
|
||||
assert test_account.is_test_account(TEST_PHONE) is True
|
||||
|
||||
|
||||
# ============================ 免验证码 + 新手引导 ============================
|
||||
|
||||
def test_login_skips_code_and_returns_tokens(client, enabled) -> None:
|
||||
"""测试号任意验证码即可登入(不用等真短信),拿到 JWT,onboarding 为 false。"""
|
||||
r = client.post(
|
||||
"/api/v1/auth/sms/login",
|
||||
json={"phone": TEST_PHONE, "code": "000000", "device_id": "dev-a"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert data["user"]["phone"] == TEST_PHONE
|
||||
assert data["user"]["register_channel"] == "sms"
|
||||
assert data["onboarding_completed"] is False
|
||||
assert data["force_onboarding"] is True # 测试号:应用内重登也强制重走引导
|
||||
assert "access_token" in data and "refresh_token" in data
|
||||
|
||||
|
||||
def test_onboarding_always_false_even_if_completed(client, enabled) -> None:
|
||||
"""即便 onboarding_completion 表已有 (该用户, 该设备) 记录,测试号再登仍返回 false。"""
|
||||
from app.db.session import SessionLocal
|
||||
from app.repositories import onboarding as onboarding_repo
|
||||
|
||||
device = "dev-onb"
|
||||
r = client.post(
|
||||
"/api/v1/auth/sms/login",
|
||||
json={"phone": TEST_PHONE, "code": "123456", "device_id": device},
|
||||
)
|
||||
uid = r.json()["user"]["id"]
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
onboarding_repo.mark_completed(db, user_id=uid, device_id=device)
|
||||
assert onboarding_repo.is_completed(db, user_id=uid, device_id=device) is True
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
r2 = client.post(
|
||||
"/api/v1/auth/sms/login",
|
||||
json={"phone": TEST_PHONE, "code": "123456", "device_id": device},
|
||||
)
|
||||
assert r2.json()["onboarding_completed"] is False # 仍重走引导
|
||||
|
||||
|
||||
# ============================ real 模式跳过 ============================
|
||||
|
||||
def test_login_skips_verify_in_real_mode(client, enabled, monkeypatch) -> None:
|
||||
"""real 模式(SMS_MOCK=false)普通号要真校验;测试号必须跳过 → 任意码仍 200。"""
|
||||
monkeypatch.setattr(sms.settings, "SMS_MOCK", False)
|
||||
r = client.post(
|
||||
"/api/v1/auth/sms/login",
|
||||
json={"phone": TEST_PHONE, "code": "9999"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_send_short_circuits_in_real_mode(client, enabled, monkeypatch) -> None:
|
||||
"""real 模式下测试号 /sms/send 不真打极光(否则缺号/缺 key 会 503),直接 200 放行进填码界面。"""
|
||||
monkeypatch.setattr(sms.settings, "SMS_MOCK", False)
|
||||
r = client.post("/api/v1/auth/sms/send", json={"phone": TEST_PHONE})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["sent"] is True
|
||||
assert body["cooldown_sec"] == 0
|
||||
|
||||
|
||||
# ============================ 每日上限 ============================
|
||||
|
||||
def test_daily_limit_blocks_after_threshold(client, enabled, monkeypatch) -> None:
|
||||
"""当日登录数到上限后再登 → 429。"""
|
||||
monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_DAILY_LIMIT", 2)
|
||||
test_account._reset_for_test()
|
||||
|
||||
for _ in range(2):
|
||||
r = client.post("/api/v1/auth/sms/login", json={"phone": TEST_PHONE, "code": "123456"})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
r = client.post("/api/v1/auth/sms/login", json={"phone": TEST_PHONE, "code": "123456"})
|
||||
assert r.status_code == 429
|
||||
assert "上限" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_send_does_not_consume_quota(client, enabled, monkeypatch) -> None:
|
||||
"""额度只算 login、不算 send:狂发 send 后那唯一一次 login 仍能成功。"""
|
||||
monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_DAILY_LIMIT", 1)
|
||||
test_account._reset_for_test()
|
||||
|
||||
for _ in range(3):
|
||||
assert client.post("/api/v1/auth/sms/send", json={"phone": TEST_PHONE}).status_code == 200
|
||||
|
||||
assert client.post("/api/v1/auth/sms/login", json={"phone": TEST_PHONE, "code": "1234"}).status_code == 200
|
||||
# 第二次 login 才超限
|
||||
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:
|
||||
"""跨天自动归零。"""
|
||||
monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_DAILY_LIMIT", 1)
|
||||
monkeypatch.setattr(test_account, "_today", lambda: "2026-06-23")
|
||||
assert test_account.try_consume_quota() is True
|
||||
assert test_account.try_consume_quota() is False # 当天超限
|
||||
|
||||
monkeypatch.setattr(test_account, "_today", lambda: "2026-06-24")
|
||||
assert test_account.try_consume_quota() is True # 次日归零放行
|
||||
|
||||
|
||||
def test_non_test_phone_unaffected_when_enabled(client, enabled) -> None:
|
||||
"""功能开启时,普通号走原 mock 流程(任意 6 位通过),不受测试号逻辑影响。"""
|
||||
phone = "13812341234"
|
||||
client.post("/api/v1/auth/sms/send", json={"phone": phone})
|
||||
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456", "device_id": "d1"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["user"]["phone"] == phone
|
||||
assert r.json()["force_onboarding"] is False # 普通号不强制重走,按原逻辑
|
||||
Reference in New Issue
Block a user