style:use restful api
This commit is contained in:
@@ -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。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user