style:use restful api

This commit is contained in:
2026-06-20 12:44:32 +08:00
parent f38814e9bd
commit b785178f37
4 changed files with 248 additions and 47 deletions
+44 -11
View File
@@ -46,13 +46,36 @@ uv run uvicorn app.main:app --reload
## API 端点 ## API 端点
### POST `/data` — 接收气象数据 ### GET `/stations` — 列出所有气象站
获取所有已知气象站的列表(从观测数据中派生)。
成功响应(200):
```json
{
"stations": [
{
"name": "北京站",
"observation_count": 156,
"latest_observation_at": "2026-06-19T11:45:00.654321+00:00"
},
{
"name": "上海站",
"observation_count": 89,
"latest_observation_at": "2026-06-19T10:30:00.123456+00:00"
}
],
"count": 2
}
```
### POST `/stations/{station_name}/observations` — 接收气象数据
请求体(JSON): 请求体(JSON):
```json ```json
{ {
"station_name": "北京站",
"temperature": 26.5, "temperature": 26.5,
"pressure": 1013.2, "pressure": 1013.2,
"relative_humidity": 68.0 "relative_humidity": 68.0
@@ -63,11 +86,12 @@ uv run uvicorn app.main:app --reload
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|------|------|------|------| |------|------|------|------|
| `station_name` | string | 是 | 气象站名称 |
| `temperature` | float | 否 | 温度(摄氏度) | | `temperature` | float | 否 | 温度(摄氏度) |
| `pressure` | float | 否 | 气压(hPa | | `pressure` | float | 否 | 气压(hPa |
| `relative_humidity` | float | 否 | 相对湿度(%0-100 | | `relative_humidity` | float | 否 | 相对湿度(%0-100 |
> 气象站名称从 URL 路径中获取,不再出现在请求体中。
成功响应(201): 成功响应(201):
```json ```json
@@ -83,12 +107,12 @@ uv run uvicorn app.main:app --reload
`received_at` 由服务端在收到数据时自动记录。 `received_at` 由服务端在收到数据时自动记录。
### GET `/data/{station_name}` — 查询最新数据 ### GET `/stations/{station_name}/observations/latest` — 查询最新数据
获取指定气象站的最新一条记录。站名支持中文,直接放在 URL 路径中即可 获取指定气象站的最新一条记录:
``` ```
GET /data/北京站 GET /stations/北京站/observations/latest
``` ```
成功响应(200): 成功响应(200):
@@ -106,35 +130,44 @@ GET /data/北京站
未找到数据时返回 404。 未找到数据时返回 404。
### GET `/data/{station_name}/history` — 查询历史数据 ### GET `/stations/{station_name}/observations` — 查询历史数据
获取指定时间范围内的所有记录。支持两种查询方式: 获取指定时间范围内的所有记录。支持两种查询方式:
**按最近 N 小时查询:** **按最近 N 小时查询:**
``` ```
GET /data/北京站/history?hours=24 GET /stations/北京站/observations?hours=24
``` ```
**按自定义时间范围查询:** **按自定义时间范围查询:**
``` ```
GET /data/北京站/history?start=2026-06-19T00:00:00&end=2026-06-19T12:00:00 GET /stations/北京站/observations?start=2026-06-19T00:00:00&end=2026-06-19T12:00:00
```
**分页查询:**
```
GET /stations/北京站/observations?limit=50&offset=0
``` ```
参数说明: 参数说明:
| 参数 | 类型 | 说明 | | 参数 | 类型 | 说明 |
|------|------|------| |------|------|------|
| `hours` | int | 从现在往前推 N 小时。如果同时指定了 `start`,则仅影响 `start` | | `hours` | int | 从现在往前推 N 小时。如果同时指定了 `start`,则覆盖 `start` |
| `start` | datetime | 时间范围起点(ISO 8601 格式,UTC),不传则不限制 | | `start` | datetime | 时间范围起点(ISO 8601 格式,UTC),不传则不限制 |
| `end` | datetime | 时间范围终点(ISO 8601 格式,UTC),不传则默认到当前时刻 | | `end` | datetime | 时间范围终点(ISO 8601 格式,UTC),不传则默认到当前时刻 |
| `limit` | int | 返回的最大记录数 |
| `offset` | int | 跳过的记录数(用于分页),默认 0 |
成功响应(200): 成功响应(200):
```json ```json
{ {
"station_name": "北京站", "station_name": "北京站",
"count": 2,
"records": [ "records": [
{ {
"id": 42, "id": 42,
@@ -156,7 +189,7 @@ GET /data/北京站/history?start=2026-06-19T00:00:00&end=2026-06-19T12:00:00
} }
``` ```
未找到匹配数据时返回空列表。 未找到匹配数据时返回空列表,气象站不存在时返回 404
--- ---
+78
View File
@@ -123,3 +123,81 @@ async def get_history_by_station(
*params, *params,
) )
return [dict(r) for r in rows] return [dict(r) for r in rows]
async def list_all_stations() -> list[dict]:
"""Return all distinct station names with observation counts and latest timestamps.
Derives stations from weather_data since there is no separate stations table.
"""
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("""
SELECT
station_name AS name,
COUNT(*)::int AS observation_count,
MAX(received_at) AS latest_observation_at
FROM weather_data
GROUP BY station_name
ORDER BY station_name
""")
return [dict(r) for r in rows]
async def get_station_exists(station_name: str) -> bool:
"""Check whether a station has any observations."""
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT 1 FROM weather_data WHERE station_name = $1 LIMIT 1",
station_name,
)
return row is not None
async def get_history_by_station_paginated(
station_name: str,
start_time: datetime | None = None,
end_time: datetime | None = None,
limit: int | None = None,
offset: int = 0,
) -> list[dict]:
"""Return observations for a station within the given time range, with pagination.
Returns an empty list if no records match.
"""
pool = await get_pool()
async with pool.acquire() as conn:
conditions = ["station_name = $1"]
params: list = [station_name]
idx = 2
if start_time is not None:
conditions.append(f"received_at >= ${idx}")
params.append(start_time)
idx += 1
if end_time is not None:
conditions.append(f"received_at <= ${idx}")
params.append(end_time)
idx += 1
where_clause = " AND ".join(conditions)
query = f"""
SELECT id, station_name, temperature, pressure, relative_humidity, received_at
FROM weather_data
WHERE {where_clause}
ORDER BY received_at DESC
"""
if limit is not None:
query += f" LIMIT ${idx}"
params.append(limit)
idx += 1
query += f" OFFSET ${idx}"
params.append(offset)
rows = await conn.fetch(query, *params)
return [dict(r) for r in rows]
+103 -31
View File
@@ -6,12 +6,20 @@ from fastapi import FastAPI, HTTPException, Query
from app.database import ( from app.database import (
close_pool, close_pool,
get_history_by_station, get_history_by_station_paginated,
get_latest_by_station, get_latest_by_station,
get_station_exists,
init_db, init_db,
insert_weather_data, insert_weather_data,
list_all_stations,
)
from app.models import (
ObservationListResponse,
StationInfo,
StationListResponse,
WeatherDataIn,
WeatherRecordOut,
) )
from app.models import WeatherDataIn, WeatherHistoryResponse, WeatherRecordOut
@asynccontextmanager @asynccontextmanager
@@ -25,77 +33,141 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
app = FastAPI( app = FastAPI(
title="Weather Data API", title="Weather Data API",
description="Receive and query weather station observations", description="Receive and query weather station observations",
version="0.1.0", version="0.2.0",
lifespan=lifespan, lifespan=lifespan,
) )
@app.post("/data", response_model=WeatherRecordOut, status_code=201) # ---------------------------------------------------------------------------
async def post_weather_data(payload: WeatherDataIn) -> WeatherRecordOut: # Stations
"""Ingest a weather observation from a station. # ---------------------------------------------------------------------------
The server records the time it was received (`received_at`).
""" @app.get("/stations", response_model=StationListResponse)
record = await insert_weather_data( async def list_stations() -> StationListResponse:
station_name=payload.station_name, """List all known weather stations derived from observation data."""
temperature=payload.temperature, stations = await list_all_stations()
pressure=payload.pressure, return StationListResponse(
relative_humidity=payload.relative_humidity, stations=[StationInfo(**s) for s in stations],
count=len(stations),
) )
return WeatherRecordOut(**record)
@app.get("/data/{station_name}", response_model=WeatherRecordOut) # ---------------------------------------------------------------------------
async def get_latest_weather(station_name: str) -> WeatherRecordOut: # Observations
"""Return the most recent observation for the given station.""" # ---------------------------------------------------------------------------
@app.get(
"/stations/{station_name}/observations/latest",
response_model=WeatherRecordOut,
)
async def get_latest_observation(station_name: str) -> WeatherRecordOut:
"""Return the most recent observation for a station.
Returns 404 if the station has no observations.
"""
record = await get_latest_by_station(station_name) record = await get_latest_by_station(station_name)
if record is None: if record is None:
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
detail=f"No data found for station '{station_name}'", detail=f"No observations found for station '{station_name}'",
) )
return WeatherRecordOut(**record) return WeatherRecordOut(**record)
@app.get("/data/{station_name}/history", response_model=WeatherHistoryResponse) @app.get(
async def get_weather_history( "/stations/{station_name}/observations",
response_model=ObservationListResponse,
)
async def list_observations(
station_name: str, station_name: str,
hours: int | None = Query( hours: int | None = Query(
None, None,
gt=0, gt=0,
description="Look back this many hours from now (e.g. 24 = last 24 hours). " description="Look back this many hours from now (e.g. 24 = last 24 hours). "
"Ignored if both start and end are provided.", "Overrides start if both are provided.",
), ),
start: datetime | None = Query( start: datetime | None = Query(
None, None,
description="Start of the time range (ISO 8601, UTC). Defaults to unbounded.", description="Start of the time range (ISO 8601, UTC, inclusive). "
"Defaults to unbounded.",
), ),
end: datetime | None = Query( end: datetime | None = Query(
None, None,
description="End of the time range (ISO 8601, UTC). Defaults to now.", description="End of the time range (ISO 8601, UTC, inclusive). "
"Defaults to now.",
), ),
) -> WeatherHistoryResponse: limit: int | None = Query(
"""Return all observations for a station within a time range. None,
gt=0,
description="Maximum number of records to return.",
),
offset: int = Query(
0,
ge=0,
description="Number of records to skip (for pagination).",
),
) -> ObservationListResponse:
"""List observations for a station with optional time-range filtering and pagination.
- Use ``hours`` alone to get the last *N* hours of data. - Use ``hours`` alone to get the last *N* hours of data.
- Use ``start`` and/or ``end`` for a custom range. - Use ``start`` and/or ``end`` for a custom range.
- Returns an empty ``records`` list when no data matches. - Use ``limit`` and ``offset`` for pagination.
- Returns 404 if the station has no observations at all.
- Returns 200 with an empty ``records`` list when no data matches the filters.
""" """
if not await get_station_exists(station_name):
raise HTTPException(
status_code=404,
detail=f"Station '{station_name}' not found",
)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
# Resolve start / end from the query parameters
resolved_start: datetime | None = start resolved_start: datetime | None = start
resolved_end: datetime | None = end resolved_end: datetime | None = end
if hours is not None: if hours is not None:
# hours takes precedence for start, but explicit end still wins
resolved_start = now - timedelta(hours=hours) resolved_start = now - timedelta(hours=hours)
if end is None: if end is None:
resolved_end = now resolved_end = now
# Default end to now if neither hours nor end was given and start was specified
if resolved_start is not None and resolved_end is None: if resolved_start is not None and resolved_end is None:
resolved_end = now resolved_end = now
records = await get_history_by_station(station_name, resolved_start, resolved_end) records = await get_history_by_station_paginated(
return WeatherHistoryResponse(station_name=station_name, records=records) station_name,
resolved_start,
resolved_end,
limit=limit,
offset=offset,
)
return ObservationListResponse(
station_name=station_name,
count=len(records),
records=[WeatherRecordOut(**r) for r in records],
)
@app.post(
"/stations/{station_name}/observations",
response_model=WeatherRecordOut,
status_code=201,
)
async def create_observation(
station_name: str,
payload: WeatherDataIn,
) -> WeatherRecordOut:
"""Ingest a weather observation for a station.
The station is identified by the URL path. Stations are created
implicitly when their first observation is posted.
"""
record = await insert_weather_data(
station_name=station_name,
temperature=payload.temperature,
pressure=payload.pressure,
relative_humidity=payload.relative_humidity,
)
return WeatherRecordOut(**record)
+23 -5
View File
@@ -4,9 +4,11 @@ from pydantic import BaseModel, Field
class WeatherDataIn(BaseModel): class WeatherDataIn(BaseModel):
"""Payload accepted by POST /data.""" """Payload accepted by POST /stations/{station_name}/observations.
station_name is taken from the URL path, not the request body.
"""
station_name: str = Field(..., description="Name of the weather station")
temperature: float | None = Field(None, description="Temperature in Celsius") temperature: float | None = Field(None, description="Temperature in Celsius")
pressure: float | None = Field(None, description="Atmospheric pressure in hPa") pressure: float | None = Field(None, description="Atmospheric pressure in hPa")
relative_humidity: float | None = Field( relative_humidity: float | None = Field(
@@ -15,7 +17,7 @@ class WeatherDataIn(BaseModel):
class WeatherRecordOut(BaseModel): class WeatherRecordOut(BaseModel):
"""Record returned by the API (includes server-assigned fields).""" """A single observation record (includes server-assigned fields)."""
id: int id: int
station_name: str station_name: str
@@ -25,8 +27,24 @@ class WeatherRecordOut(BaseModel):
received_at: datetime received_at: datetime
class WeatherHistoryResponse(BaseModel): class ObservationListResponse(BaseModel):
"""Wrapper for history queries — always returns a list (empty if no data).""" """Response for GET /stations/{station_name}/observations."""
station_name: str station_name: str
count: int
records: list[WeatherRecordOut] records: list[WeatherRecordOut]
class StationInfo(BaseModel):
"""Summary of a known weather station."""
name: str
observation_count: int
latest_observation_at: datetime | None
class StationListResponse(BaseModel):
"""Response for GET /stations."""
stations: list[StationInfo]
count: int