chore: bootstrap housing research platform

This commit is contained in:
2026-06-23 09:43:56 +08:00
commit 479e3f16d4
34 changed files with 4339 additions and 0 deletions

39
apps/api/src/config.rs Normal file
View File

@@ -0,0 +1,39 @@
use std::env;
use std::net::SocketAddr;
use anyhow::{Context, Result};
#[derive(Debug, Clone)]
pub struct AppConfig {
pub database_url: String,
pub host: String,
pub port: u16,
pub run_migrations: bool,
}
impl AppConfig {
pub fn from_env() -> Result<Self> {
let database_url = env::var("DATABASE_URL").context("DATABASE_URL is required")?;
let host = env::var("API_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port = env::var("API_PORT")
.unwrap_or_else(|_| "8080".to_string())
.parse::<u16>()
.context("API_PORT must be a valid port")?;
let run_migrations = env::var("RUN_MIGRATIONS")
.map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
.unwrap_or(false);
Ok(Self {
database_url,
host,
port,
run_migrations,
})
}
pub fn bind_addr(&self) -> SocketAddr {
format!("{}:{}", self.host, self.port)
.parse()
.expect("validated API host and port")
}
}

13
apps/api/src/db.rs Normal file
View File

@@ -0,0 +1,13 @@
use anyhow::{Context, Result};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use std::time::Duration;
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
PgPoolOptions::new()
.max_connections(8)
.acquire_timeout(Duration::from_secs(5))
.connect(database_url)
.await
.context("failed to connect to PostgreSQL")
}

33
apps/api/src/error.rs Normal file
View File

@@ -0,0 +1,33 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Serialize;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ApiError {
#[error("bad request: {0}")]
BadRequest(String),
#[error("database error")]
Database(#[from] sqlx::Error),
}
#[derive(Debug, Serialize)]
struct ErrorBody {
error: String,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = match self {
ApiError::BadRequest(_) => StatusCode::BAD_REQUEST,
ApiError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
let body = Json(ErrorBody {
error: self.to_string(),
});
(status, body).into_response()
}
}
pub type ApiResult<T> = Result<T, ApiError>;

151
apps/api/src/handlers.rs Normal file
View 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());
}
}

75
apps/api/src/main.rs Normal file
View File

@@ -0,0 +1,75 @@
mod config;
mod db;
mod error;
mod handlers;
mod models;
mod routes;
mod state;
use anyhow::Context;
use tokio::net::TcpListener;
use tracing::info;
use crate::config::AppConfig;
use crate::db::create_pool;
use crate::routes::build_router;
use crate::state::AppState;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "shanghai_housing_api=info,tower_http=info".into()),
)
.init();
let config = AppConfig::from_env()?;
let pool = create_pool(&config.database_url).await?;
if config.run_migrations {
info!("running database migrations");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.context("failed to run database migrations")?;
}
let state = AppState { pool };
let app = build_router(state);
let listener = TcpListener::bind(config.bind_addr())
.await
.context("failed to bind API listener")?;
info!("listening on http://{}", listener.local_addr()?);
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.context("server failed")?;
Ok(())
}
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
}

68
apps/api/src/models.rs Normal file
View File

@@ -0,0 +1,68 @@
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
#[derive(Debug, Deserialize)]
pub struct MonthQuery {
pub month: String,
}
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct AreaScore {
pub area_id: String,
pub name: String,
pub district: String,
pub segment: String,
pub month: String,
pub investment_score: f64,
pub recommendation: String,
pub liquidity_score: f64,
pub momentum_score: f64,
pub rent_support_score: f64,
pub safety_margin_score: f64,
pub credit_support_score: f64,
pub supply_risk_score: f64,
pub valuation_pressure_score: f64,
pub transaction_count: i32,
pub transaction_price_psm: f64,
pub listing_count: i32,
pub listing_price_psm: f64,
pub rent_price_psm: f64,
pub median_days_on_market: f64,
pub annual_rent_yield_pct: f64,
pub listing_pressure_ratio: f64,
pub discount_pct: f64,
pub price_momentum_pct: f64,
pub volume_momentum_pct: f64,
}
#[derive(Debug, Serialize)]
pub struct AreaScoreSummary {
pub area_id: String,
pub name: String,
pub district: String,
pub investment_score: f64,
pub recommendation: String,
}
impl From<&AreaScore> for AreaScoreSummary {
fn from(score: &AreaScore) -> Self {
Self {
area_id: score.area_id.clone(),
name: score.name.clone(),
district: score.district.clone(),
investment_score: score.investment_score,
recommendation: score.recommendation.clone(),
}
}
}
#[derive(Debug, Serialize)]
pub struct MarketOverview {
pub month: String,
pub area_count: usize,
pub average_investment_score: f64,
pub average_rent_yield_pct: f64,
pub top_area: Option<AreaScoreSummary>,
pub weakest_area: Option<AreaScoreSummary>,
pub highest_supply_risk_area: Option<AreaScoreSummary>,
}

22
apps/api/src/routes.rs Normal file
View File

@@ -0,0 +1,22 @@
use axum::routing::get;
use axum::Router;
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
use crate::handlers::{health, list_area_scores, market_overview, ready};
use crate::state::AppState;
pub fn build_router(state: AppState) -> Router {
Router::new()
.route("/health", get(health))
.route("/ready", get(ready))
.nest(
"/api/v1",
Router::new()
.route("/areas/scores", get(list_area_scores))
.route("/market/overview", get(market_overview)),
)
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive())
.with_state(state)
}

6
apps/api/src/state.rs Normal file
View File

@@ -0,0 +1,6 @@
use sqlx::PgPool;
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
}