chore: bootstrap housing research platform
This commit is contained in:
151
apps/api/src/handlers.rs
Normal file
151
apps/api/src/handlers.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
use axum::extract::{Query, State};
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::models::{AreaScore, AreaScoreSummary, MarketOverview, MonthQuery};
|
||||
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,
|
||||
}))
|
||||
}
|
||||
|
||||
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 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user