FastAPI backend for the hs-data platform. MVP delivers one module: the 拼团 (group-buy) funnel (启动 → 曝光 → 拼团详情 → 下单 → 成功) backed by two pre-aggregated tables:
ads_trd_group_funnel_daily — single day, keeps full history (daily
incremental insert of a new dt). Backs period=day.ads_trd_group_funnel_rolling — rolling 7d/30d, only one row (daily
overwrite, no history). Backs period=last_7d / last_30d.Data is T+1: today's data is not computed yet, so the max queryable day is always yesterday.
Python 3.11+, FastAPI, Pydantic v2, SQLAlchemy 2.x (async) + asyncpg, Alembic, pytest.
cd apps/api
python -m venv .venv
. .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[dev]"
Two environment variables (see .env.example):
# true -> serve realistic in-memory data (no DB needed). DEFAULT for now.
# false -> read the real group-buy funnel tables from Postgres.
USE_FAKE_DATA=true
# Async SQLAlchemy URL (only used when USE_FAKE_DATA=false).
DATABASE_URL=postgresql+asyncpg://hsdata:hsdata@localhost:5432/hsdata
So the page can be seen before any Postgres exists, USE_FAKE_DATA defaults to
true. In that mode the funnel API serves realistic data through the same
repository interface as the real source, so route and service code is
identical:
snapshot_dt values return
different data. A snapshot_dt with no matching row → missing.Set USE_FAKE_DATA=false to query the real tables.
No infrastructure (fake data, default):
uvicorn app.main:app --reload --port 8000
Against a real Postgres:
export USE_FAKE_DATA=false # Windows: $env:USE_FAKE_DATA="false"
export DATABASE_URL=postgresql+asyncpg://hsdata:hsdata@localhost:5432/hsdata
alembic upgrade head
python -m scripts.seed # insert daily history + rolling row
uvicorn app.main:app --reload --port 8000
OpenAPI docs at http://localhost:8000/docs. Health probe at /health.
alembic upgrade head # creates the daily + rolling tables
Inserts ~10 historical daily rows (ending yesterday) into the daily table plus one rolling row as-of yesterday, all with clean descending group-buy funnels:
python -m scripts.seed
python -m scripts.export_openapi # writes apps/api/openapi.json
POST /api/funnels/query
Request:
{ "period": "day", "snapshot_dt": "2026-06-20" }
period ∈ day | last_7d | last_30d. Any other value → HTTP 422.snapshot_dt (optional ISO YYYY-MM-DD): only meaningful for day.
Omitted → latest daily row (yesterday); given → that historical day. Must be
≤ yesterday (T+1); today/future → HTTP 422. Ignored for last_7d / last_30d.Response:
{
"period": "day",
"snapshot_dt": "20260620",
"results": [
{ "step_index": 1, "name": "启动", "event_key": "start", "uv": 10000, "conversion_rate": null, "dropoff_rate": null },
{ "step_index": 2, "name": "曝光", "event_key": "show", "uv": 8200, "conversion_rate": 0.82, "dropoff_rate": 0.18 },
{ "step_index": 3, "name": "拼团详情", "event_key": "detail", "uv": 5100, "conversion_rate": 0.62, "dropoff_rate": 0.38 },
{ "step_index": 4, "name": "下单", "event_key": "order", "uv": 2200, "conversion_rate": 0.43, "dropoff_rate": 0.57 },
{ "step_index": 5, "name": "成功", "event_key": "paid", "uv": 1800, "conversion_rate": 0.82, "dropoff_rate": 0.18 }
],
"data_status": "ready"
}
period=day → ads_trd_group_funnel_daily. Given snapshot_dt → WHERE
dt=:dt; otherwise latest ORDER BY dt DESC LIMIT 1. Columns
uv_start/show/detail/order/paid.period=last_7d → ads_trd_group_funnel_rolling (single row), columns uv_*_7d.period=last_30d → same rolling row, columns uv_*_30d.snapshot_dt (response) is the dt (yyyyMMdd) of the row actually used: the
day for day, the rolling row's as-of dt for 7d/30d. null when missing.step_index starts at 1. Step 1 has null conversion/dropoff rates.conversion_rate[i] = uv[i] / uv[i-1]; dropoff_rate[i] = 1 - conversion_rate[i].
If uv[i-1] == 0, both are null (no division by zero).data_status ∈ {ready, missing}: ready when the target row exists and
the period's columns are non-null; missing when there is no row OR the period
columns are NULL. Missing data is never silently zero-filled.pytest
Period validation, day vs rolling routing, snapshot_dt selection/validation
(today/future → 422; non-existent dt → missing), conversion math, data_status,
the fake data source (daily history + rolling), the SQLAlchemy repo against
in-memory SQLite, and the exact API response shape are all tested without
Postgres.