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 { Json(HealthResponse { status: "ok", service: "shanghai-housing-api", }) } pub async fn ready(State(state): State) -> ApiResult> { 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, Query(query): Query, ) -> ApiResult>> { validate_month(&query.month)?; let scores = fetch_area_scores(&state, &query.month).await?; Ok(Json(scores)) } pub async fn market_overview( State(state): State, Query(query): Query, ) -> ApiResult> { 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, Query(query): Query, ) -> ApiResult>> { 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, Path(neighborhood_id): Path, ) -> ApiResult> { 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, Query(query): Query, ) -> ApiResult>> { 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, ) -> ApiResult>> { 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, Json(payload): Json, ) -> ApiResult> { 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, Path(watchlist_item_id): Path, Json(payload): Json, ) -> ApiResult> { 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, Path(watchlist_item_id): Path, ) -> ApiResult> { 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, 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) -> 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::(), 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()); } }