Files
huheng-research/apps/api/src/handlers.rs

434 lines
12 KiB
Rust

use axum::extract::{Path, Query, State};
use axum::Json;
use serde::Serialize;
use crate::error::{ApiError, ApiResult};
use crate::models::{
AreaScore, AreaScoreSummary, CreateWatchlistItem, MarketOverview, MonthQuery, Neighborhood,
NeighborhoodQuery, NeighborhoodScore, NeighborhoodScoreQuery, UpdateWatchlistItem,
WatchlistItem,
};
use crate::state::AppState;
#[derive(Debug, Serialize)]
pub struct HealthResponse {
pub status: &'static str,
pub service: &'static str,
}
pub async fn health() -> Json<HealthResponse> {
Json(HealthResponse {
status: "ok",
service: "shanghai-housing-api",
})
}
pub async fn ready(State(state): State<AppState>) -> ApiResult<Json<HealthResponse>> {
sqlx::query("select 1").execute(&state.pool).await?;
Ok(Json(HealthResponse {
status: "ok",
service: "shanghai-housing-api",
}))
}
pub async fn list_area_scores(
State(state): State<AppState>,
Query(query): Query<MonthQuery>,
) -> ApiResult<Json<Vec<AreaScore>>> {
validate_month(&query.month)?;
let scores = fetch_area_scores(&state, &query.month).await?;
Ok(Json(scores))
}
pub async fn market_overview(
State(state): State<AppState>,
Query(query): Query<MonthQuery>,
) -> ApiResult<Json<MarketOverview>> {
validate_month(&query.month)?;
let scores = fetch_area_scores(&state, &query.month).await?;
let area_count = scores.len();
let average_investment_score = average(scores.iter().map(|score| score.investment_score));
let average_rent_yield_pct = average(scores.iter().map(|score| score.annual_rent_yield_pct));
let top_area = scores.first().map(AreaScoreSummary::from);
let weakest_area = scores.last().map(AreaScoreSummary::from);
let highest_supply_risk_area = scores
.iter()
.max_by(|left, right| {
left.supply_risk_score
.partial_cmp(&right.supply_risk_score)
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(AreaScoreSummary::from);
Ok(Json(MarketOverview {
month: query.month,
area_count,
average_investment_score,
average_rent_yield_pct,
top_area,
weakest_area,
highest_supply_risk_area,
}))
}
pub async fn list_neighborhoods(
State(state): State<AppState>,
Query(query): Query<NeighborhoodQuery>,
) -> ApiResult<Json<Vec<Neighborhood>>> {
let neighborhoods = sqlx::query_as::<_, Neighborhood>(
r#"
SELECT
n.neighborhood_id,
n.area_id,
a.name AS area_name,
a.district,
n.name,
n.built_year,
n.property_type,
n.metro_distance_m,
n.school_quality,
n.notes
FROM silver.neighborhoods n
JOIN silver.areas a ON a.area_id = n.area_id
WHERE ($1::text IS NULL OR n.area_id = $1)
ORDER BY a.district, a.name, n.name
"#,
)
.bind(query.area_id)
.fetch_all(&state.pool)
.await?;
Ok(Json(neighborhoods))
}
pub async fn get_neighborhood(
State(state): State<AppState>,
Path(neighborhood_id): Path<String>,
) -> ApiResult<Json<Neighborhood>> {
let neighborhood = sqlx::query_as::<_, Neighborhood>(
r#"
SELECT
n.neighborhood_id,
n.area_id,
a.name AS area_name,
a.district,
n.name,
n.built_year,
n.property_type,
n.metro_distance_m,
n.school_quality,
n.notes
FROM silver.neighborhoods n
JOIN silver.areas a ON a.area_id = n.area_id
WHERE n.neighborhood_id = $1
"#,
)
.bind(neighborhood_id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| ApiError::BadRequest("neighborhood not found".to_string()))?;
Ok(Json(neighborhood))
}
pub async fn list_neighborhood_scores(
State(state): State<AppState>,
Query(query): Query<NeighborhoodScoreQuery>,
) -> ApiResult<Json<Vec<NeighborhoodScore>>> {
validate_month(&query.month)?;
let scores = sqlx::query_as::<_, NeighborhoodScore>(
r#"
SELECT
neighborhood_id,
area_id,
name,
area_name,
district,
month,
investment_score,
recommendation,
liquidity_score,
rent_support_score,
price_safety_score,
location_score,
building_age_score,
transaction_count,
transaction_price_psm,
listing_count,
listing_price_psm,
rent_price_psm,
median_days_on_market,
annual_rent_yield_pct,
discount_pct,
available_units
FROM gold.neighborhood_scores
WHERE month = $1
AND ($2::text IS NULL OR area_id = $2)
ORDER BY investment_score DESC, neighborhood_id ASC
"#,
)
.bind(query.month)
.bind(query.area_id)
.fetch_all(&state.pool)
.await?;
Ok(Json(scores))
}
pub async fn list_watchlist_items(
State(state): State<AppState>,
) -> ApiResult<Json<Vec<WatchlistItem>>> {
let items = sqlx::query_as::<_, WatchlistItem>(
r#"
SELECT
watchlist_item_id,
neighborhood_id,
area_id,
target_price_psm,
status,
notes
FROM app.watchlist_items
WHERE status <> 'archived'
ORDER BY updated_at DESC, watchlist_item_id DESC
"#,
)
.fetch_all(&state.pool)
.await?;
Ok(Json(items))
}
pub async fn create_watchlist_item(
State(state): State<AppState>,
Json(payload): Json<CreateWatchlistItem>,
) -> ApiResult<Json<WatchlistItem>> {
payload
.validate()
.map_err(|message| ApiError::BadRequest(message.to_string()))?;
let item = sqlx::query_as::<_, WatchlistItem>(
r#"
INSERT INTO app.watchlist_items (
neighborhood_id,
area_id,
target_price_psm,
notes
)
VALUES ($1, $2, $3, COALESCE($4, ''))
RETURNING
watchlist_item_id,
neighborhood_id,
area_id,
target_price_psm,
status,
notes
"#,
)
.bind(payload.neighborhood_id)
.bind(payload.area_id)
.bind(payload.target_price_psm)
.bind(payload.notes)
.fetch_one(&state.pool)
.await?;
Ok(Json(item))
}
pub async fn update_watchlist_item(
State(state): State<AppState>,
Path(watchlist_item_id): Path<i64>,
Json(payload): Json<UpdateWatchlistItem>,
) -> ApiResult<Json<WatchlistItem>> {
payload
.validate()
.map_err(|message| ApiError::BadRequest(message.to_string()))?;
let item = sqlx::query_as::<_, WatchlistItem>(
r#"
UPDATE app.watchlist_items
SET target_price_psm = COALESCE($2, target_price_psm),
status = COALESCE($3, status),
notes = COALESCE($4, notes),
updated_at = now()
WHERE watchlist_item_id = $1
RETURNING
watchlist_item_id,
neighborhood_id,
area_id,
target_price_psm,
status,
notes
"#,
)
.bind(watchlist_item_id)
.bind(payload.target_price_psm)
.bind(payload.status)
.bind(payload.notes)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| ApiError::BadRequest("watchlist item not found".to_string()))?;
Ok(Json(item))
}
pub async fn archive_watchlist_item(
State(state): State<AppState>,
Path(watchlist_item_id): Path<i64>,
) -> ApiResult<Json<WatchlistItem>> {
let item = sqlx::query_as::<_, WatchlistItem>(
r#"
UPDATE app.watchlist_items
SET status = 'archived',
updated_at = now()
WHERE watchlist_item_id = $1
RETURNING
watchlist_item_id,
neighborhood_id,
area_id,
target_price_psm,
status,
notes
"#,
)
.bind(watchlist_item_id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| ApiError::BadRequest("watchlist item not found".to_string()))?;
Ok(Json(item))
}
async fn fetch_area_scores(state: &AppState, month: &str) -> Result<Vec<AreaScore>, sqlx::Error> {
sqlx::query_as::<_, AreaScore>(
r#"
SELECT
area_id,
name,
district,
segment,
month,
investment_score,
recommendation,
liquidity_score,
momentum_score,
rent_support_score,
safety_margin_score,
credit_support_score,
supply_risk_score,
valuation_pressure_score,
transaction_count,
transaction_price_psm,
listing_count,
listing_price_psm,
rent_price_psm,
median_days_on_market,
annual_rent_yield_pct,
listing_pressure_ratio,
discount_pct,
price_momentum_pct,
volume_momentum_pct
FROM gold.area_scores
WHERE month = $1
ORDER BY investment_score DESC, area_id ASC
"#,
)
.bind(month)
.fetch_all(&state.pool)
.await
}
fn average(values: impl Iterator<Item = f64>) -> f64 {
let (sum, count) = values.fold((0.0, 0usize), |(sum, count), value| {
(sum + value, count + 1)
});
if count == 0 {
0.0
} else {
(sum / count as f64 * 10.0).round() / 10.0
}
}
fn validate_month(month: &str) -> ApiResult<()> {
let valid = month.len() == 7
&& month.as_bytes()[4] == b'-'
&& month[..4].chars().all(|item| item.is_ascii_digit())
&& month[5..].chars().all(|item| item.is_ascii_digit())
&& matches!(month[5..].parse::<u8>(), Ok(value) if (1..=12).contains(&value));
if valid {
Ok(())
} else {
Err(ApiError::BadRequest(
"month must use YYYY-MM format".to_string(),
))
}
}
#[cfg(test)]
mod tests {
use crate::models::{CreateWatchlistItem, UpdateWatchlistItem};
use super::validate_month;
#[test]
fn accepts_valid_month() {
assert!(validate_month("2026-05").is_ok());
}
#[test]
fn rejects_invalid_month() {
assert!(validate_month("2026-5").is_err());
assert!(validate_month("2026-13").is_err());
assert!(validate_month("abcd-05").is_err());
}
#[test]
fn validates_watchlist_create_payload() {
assert!(CreateWatchlistItem {
neighborhood_id: None,
area_id: None,
target_price_psm: None,
notes: None,
}
.validate()
.is_err());
assert!(CreateWatchlistItem {
neighborhood_id: None,
area_id: Some("zhangjiang".to_string()),
target_price_psm: Some(80_000.0),
notes: None,
}
.validate()
.is_ok());
}
#[test]
fn validates_watchlist_update_payload() {
assert!(UpdateWatchlistItem {
target_price_psm: Some(-1.0),
status: None,
notes: None,
}
.validate()
.is_err());
assert!(UpdateWatchlistItem {
target_price_psm: None,
status: Some("unknown".to_string()),
notes: None,
}
.validate()
.is_err());
assert!(UpdateWatchlistItem {
target_price_psm: Some(80_000.0),
status: Some("active".to_string()),
notes: Some("跟踪产业兑现和挂牌变化".to_string()),
}
.validate()
.is_ok());
}
}