From b73a89e576d5ab14bcb78b29685ee8266ac27c1e Mon Sep 17 00:00:00 2001 From: shay7sev Date: Tue, 23 Jun 2026 16:39:07 +0800 Subject: [PATCH] feat: add neighborhood asset and scoring api --- README.md | 5 + .../202606230001_neighborhood_scores.sql | 157 ++++++++++++++++++ apps/api/src/handlers.rs | 110 +++++++++++- apps/api/src/models.rs | 51 ++++++ apps/api/src/routes.rs | 8 +- 5 files changed, 327 insertions(+), 4 deletions(-) create mode 100644 apps/api/migrations/202606230001_neighborhood_scores.sql diff --git a/README.md b/README.md index 0a78a6d..952745e 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,11 @@ GET /health GET /ready GET /api/v1/areas/scores?month=2026-05 GET /api/v1/market/overview?month=2026-05 +GET /api/v1/neighborhoods +GET /api/v1/neighborhoods?area_id=zhangjiang +GET /api/v1/neighborhoods/{neighborhood_id} +GET /api/v1/neighborhoods/scores?month=2026-05 +GET /api/v1/neighborhoods/scores?month=2026-05&area_id=zhangjiang GET /api/v1/watchlist POST /api/v1/watchlist PATCH /api/v1/watchlist/{watchlist_item_id} diff --git a/apps/api/migrations/202606230001_neighborhood_scores.sql b/apps/api/migrations/202606230001_neighborhood_scores.sql new file mode 100644 index 0000000..62539c4 --- /dev/null +++ b/apps/api/migrations/202606230001_neighborhood_scores.sql @@ -0,0 +1,157 @@ +CREATE TABLE IF NOT EXISTS gold.neighborhood_scores ( + neighborhood_id TEXT NOT NULL REFERENCES silver.neighborhoods(neighborhood_id), + area_id TEXT NOT NULL REFERENCES silver.areas(area_id), + name TEXT NOT NULL, + area_name TEXT NOT NULL, + district TEXT NOT NULL, + month TEXT NOT NULL CHECK (month ~ '^[0-9]{4}-[0-9]{2}$'), + investment_score DOUBLE PRECISION NOT NULL CHECK (investment_score BETWEEN 0 AND 100), + recommendation TEXT NOT NULL, + liquidity_score DOUBLE PRECISION NOT NULL CHECK (liquidity_score BETWEEN 0 AND 100), + rent_support_score DOUBLE PRECISION NOT NULL CHECK (rent_support_score BETWEEN 0 AND 100), + price_safety_score DOUBLE PRECISION NOT NULL CHECK (price_safety_score BETWEEN 0 AND 100), + location_score DOUBLE PRECISION NOT NULL CHECK (location_score BETWEEN 0 AND 100), + building_age_score DOUBLE PRECISION NOT NULL CHECK (building_age_score BETWEEN 0 AND 100), + transaction_count INTEGER NOT NULL CHECK (transaction_count >= 0), + transaction_price_psm DOUBLE PRECISION NOT NULL CHECK (transaction_price_psm >= 0), + listing_count INTEGER NOT NULL CHECK (listing_count >= 0), + listing_price_psm DOUBLE PRECISION NOT NULL CHECK (listing_price_psm >= 0), + rent_price_psm DOUBLE PRECISION NOT NULL CHECK (rent_price_psm >= 0), + median_days_on_market DOUBLE PRECISION NOT NULL CHECK (median_days_on_market >= 0), + annual_rent_yield_pct DOUBLE PRECISION NOT NULL CHECK (annual_rent_yield_pct >= 0), + discount_pct DOUBLE PRECISION NOT NULL, + available_units INTEGER NOT NULL CHECK (available_units >= 0), + model_version TEXT NOT NULL DEFAULT 'sample-v1', + computed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (neighborhood_id, month) +); + +CREATE INDEX IF NOT EXISTS idx_gold_neighborhood_scores_month + ON gold.neighborhood_scores(month, investment_score DESC); +CREATE INDEX IF NOT EXISTS idx_gold_neighborhood_scores_area_month + ON gold.neighborhood_scores(area_id, month, investment_score DESC); + +INSERT INTO silver.neighborhoods ( + neighborhood_id, + area_id, + name, + built_year, + property_type, + metro_distance_m, + school_quality, + notes +) +VALUES + ('qiantan_a', 'qiantan', '前滩样例小区A', 2018, '商品住宅', 450, 'good', '演示样例'), + ('qiantan_b', 'qiantan', '前滩样例小区B', 2021, '商品住宅', 650, 'normal', '演示样例'), + ('xujiahui_a', 'xujiahui', '徐家汇样例小区A', 2008, '商品住宅', 300, 'excellent', '演示样例'), + ('xujiahui_b', 'xujiahui', '徐家汇样例小区B', 1998, '商品住宅', 520, 'good', '演示样例'), + ('danning_a', 'danning', '大宁样例小区A', 2015, '商品住宅', 600, 'good', '演示样例'), + ('danning_b', 'danning', '大宁样例小区B', 2006, '商品住宅', 850, 'normal', '演示样例'), + ('zhangjiang_a', 'zhangjiang', '张江样例小区A', 2016, '商品住宅', 700, 'normal', '演示样例'), + ('zhangjiang_b', 'zhangjiang', '张江样例小区B', 2020, '商品住宅', 500, 'good', '演示样例'), + ('hongqiao_a', 'hongqiao', '大虹桥样例小区A', 2020, '商品住宅', 900, 'normal', '演示样例'), + ('hongqiao_b', 'hongqiao', '大虹桥样例小区B', 2012, '商品住宅', 1200, 'normal', '演示样例') +ON CONFLICT (neighborhood_id) DO UPDATE +SET area_id = EXCLUDED.area_id, + name = EXCLUDED.name, + built_year = EXCLUDED.built_year, + property_type = EXCLUDED.property_type, + metro_distance_m = EXCLUDED.metro_distance_m, + school_quality = EXCLUDED.school_quality, + notes = EXCLUDED.notes, + updated_at = now(); + +INSERT INTO silver.neighborhood_monthly_metrics ( + neighborhood_id, + month, + transaction_count, + transaction_price_psm, + listing_count, + listing_price_psm, + rent_price_psm, + median_days_on_market, + available_units, + source +) +VALUES + ('qiantan_a', '2026-05', 12, 124000, 38, 128000, 195, 50, 38, 'sample'), + ('qiantan_b', '2026-05', 8, 119500, 42, 124000, 186, 57, 42, 'sample'), + ('xujiahui_a', '2026-05', 8, 131000, 30, 137000, 215, 62, 30, 'sample'), + ('xujiahui_b', '2026-05', 6, 119000, 36, 126500, 198, 78, 36, 'sample'), + ('danning_a', '2026-05', 14, 100000, 45, 104000, 162, 65, 45, 'sample'), + ('danning_b', '2026-05', 11, 93000, 52, 99000, 151, 74, 52, 'sample'), + ('zhangjiang_a', '2026-05', 18, 86000, 58, 90500, 143, 58, 58, 'sample'), + ('zhangjiang_b', '2026-05', 16, 89000, 46, 93000, 148, 52, 46, 'sample'), + ('hongqiao_a', '2026-05', 9, 77000, 76, 83500, 121, 94, 76, 'sample'), + ('hongqiao_b', '2026-05', 7, 72000, 88, 79500, 113, 102, 88, 'sample') +ON CONFLICT (neighborhood_id, month) DO UPDATE +SET transaction_count = EXCLUDED.transaction_count, + transaction_price_psm = EXCLUDED.transaction_price_psm, + listing_count = EXCLUDED.listing_count, + listing_price_psm = EXCLUDED.listing_price_psm, + rent_price_psm = EXCLUDED.rent_price_psm, + median_days_on_market = EXCLUDED.median_days_on_market, + available_units = EXCLUDED.available_units, + source = EXCLUDED.source, + updated_at = now(); + +INSERT INTO gold.neighborhood_scores ( + 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, + model_version +) +VALUES + ('zhangjiang_b', 'zhangjiang', '张江样例小区B', '张江', '浦东新区', '2026-05', 76.8, '重点研究', 84.0, 72.0, 58.0, 78.0, 88.0, 16, 89000, 46, 93000, 148, 52, 2.00, 4.30, 46, 'sample-v1'), + ('zhangjiang_a', 'zhangjiang', '张江样例小区A', '张江', '浦东新区', '2026-05', 73.4, '观察池', 88.0, 70.0, 54.0, 68.0, 76.0, 18, 86000, 58, 90500, 143, 58, 1.99, 4.97, 58, 'sample-v1'), + ('qiantan_a', 'qiantan', '前滩样例小区A', '前滩', '浦东新区', '2026-05', 71.6, '观察池', 75.0, 63.0, 48.0, 82.0, 82.0, 12, 124000, 38, 128000, 195, 50, 1.89, 3.13, 38, 'sample-v1'), + ('danning_a', 'danning', '大宁样例小区A', '大宁', '静安区', '2026-05', 69.2, '观察池', 78.0, 66.0, 52.0, 70.0, 78.0, 14, 100000, 45, 104000, 162, 65, 1.94, 3.85, 45, 'sample-v1'), + ('xujiahui_a', 'xujiahui', '徐家汇样例小区A', '徐家汇', '徐汇区', '2026-05', 66.9, '观察池', 63.0, 68.0, 45.0, 92.0, 58.0, 8, 131000, 30, 137000, 215, 62, 1.97, 4.38, 30, 'sample-v1'), + ('qiantan_b', 'qiantan', '前滩样例小区B', '前滩', '浦东新区', '2026-05', 65.4, '观察池', 62.0, 61.0, 55.0, 72.0, 94.0, 8, 119500, 42, 124000, 186, 57, 1.87, 3.63, 42, 'sample-v1'), + ('danning_b', 'danning', '大宁样例小区B', '大宁', '静安区', '2026-05', 62.5, '中性观望', 60.0, 64.0, 62.0, 58.0, 54.0, 11, 93000, 52, 99000, 151, 74, 1.95, 6.06, 52, 'sample-v1'), + ('xujiahui_b', 'xujiahui', '徐家汇样例小区B', '徐家汇', '徐汇区', '2026-05', 59.8, '中性观望', 48.0, 66.0, 64.0, 76.0, 34.0, 6, 119000, 36, 126500, 198, 78, 2.00, 5.93, 36, 'sample-v1'), + ('hongqiao_a', 'hongqiao', '大虹桥样例小区A', '大虹桥', '闵行区', '2026-05', 54.7, '谨慎等待', 42.0, 61.0, 70.0, 48.0, 90.0, 9, 77000, 76, 83500, 121, 94, 1.89, 7.78, 76, 'sample-v1'), + ('hongqiao_b', 'hongqiao', '大虹桥样例小区B', '大虹桥', '闵行区', '2026-05', 49.6, '谨慎等待', 34.0, 58.0, 76.0, 36.0, 64.0, 7, 72000, 88, 79500, 113, 102, 1.88, 9.43, 88, 'sample-v1') +ON CONFLICT (neighborhood_id, month) DO UPDATE +SET area_id = EXCLUDED.area_id, + name = EXCLUDED.name, + area_name = EXCLUDED.area_name, + district = EXCLUDED.district, + investment_score = EXCLUDED.investment_score, + recommendation = EXCLUDED.recommendation, + liquidity_score = EXCLUDED.liquidity_score, + rent_support_score = EXCLUDED.rent_support_score, + price_safety_score = EXCLUDED.price_safety_score, + location_score = EXCLUDED.location_score, + building_age_score = EXCLUDED.building_age_score, + transaction_count = EXCLUDED.transaction_count, + transaction_price_psm = EXCLUDED.transaction_price_psm, + listing_count = EXCLUDED.listing_count, + listing_price_psm = EXCLUDED.listing_price_psm, + rent_price_psm = EXCLUDED.rent_price_psm, + median_days_on_market = EXCLUDED.median_days_on_market, + annual_rent_yield_pct = EXCLUDED.annual_rent_yield_pct, + discount_pct = EXCLUDED.discount_pct, + available_units = EXCLUDED.available_units, + model_version = EXCLUDED.model_version, + computed_at = now(); diff --git a/apps/api/src/handlers.rs b/apps/api/src/handlers.rs index 32bbf36..b0bea3a 100644 --- a/apps/api/src/handlers.rs +++ b/apps/api/src/handlers.rs @@ -4,8 +4,9 @@ use serde::Serialize; use crate::error::{ApiError, ApiResult}; use crate::models::{ - AreaScore, AreaScoreSummary, CreateWatchlistItem, MarketOverview, MonthQuery, - UpdateWatchlistItem, WatchlistItem, + AreaScore, AreaScoreSummary, CreateWatchlistItem, MarketOverview, MonthQuery, Neighborhood, + NeighborhoodQuery, NeighborhoodScore, NeighborhoodScoreQuery, UpdateWatchlistItem, + WatchlistItem, }; use crate::state::AppState; @@ -70,6 +71,111 @@ pub async fn market_overview( })) } +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>> { diff --git a/apps/api/src/models.rs b/apps/api/src/models.rs index 2fd351f..31ea62a 100644 --- a/apps/api/src/models.rs +++ b/apps/api/src/models.rs @@ -6,6 +6,17 @@ pub struct MonthQuery { pub month: String, } +#[derive(Debug, Deserialize)] +pub struct NeighborhoodQuery { + pub area_id: Option, +} + +#[derive(Debug, Deserialize)] +pub struct NeighborhoodScoreQuery { + pub month: String, + pub area_id: Option, +} + #[derive(Debug, Clone, Serialize, FromRow)] pub struct AreaScore { pub area_id: String, @@ -67,6 +78,46 @@ pub struct MarketOverview { pub highest_supply_risk_area: Option, } +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct Neighborhood { + pub neighborhood_id: String, + pub area_id: String, + pub area_name: String, + pub district: String, + pub name: String, + pub built_year: Option, + pub property_type: String, + pub metro_distance_m: Option, + pub school_quality: Option, + pub notes: String, +} + +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct NeighborhoodScore { + pub neighborhood_id: String, + pub area_id: String, + pub name: String, + pub area_name: String, + pub district: String, + pub month: String, + pub investment_score: f64, + pub recommendation: String, + pub liquidity_score: f64, + pub rent_support_score: f64, + pub price_safety_score: f64, + pub location_score: f64, + pub building_age_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 discount_pct: f64, + pub available_units: i32, +} + #[derive(Debug, Clone, Serialize, FromRow)] pub struct WatchlistItem { pub watchlist_item_id: i64, diff --git a/apps/api/src/routes.rs b/apps/api/src/routes.rs index c54f1bf..a249fa2 100644 --- a/apps/api/src/routes.rs +++ b/apps/api/src/routes.rs @@ -4,8 +4,9 @@ use tower_http::cors::CorsLayer; use tower_http::trace::TraceLayer; use crate::handlers::{ - archive_watchlist_item, create_watchlist_item, health, list_area_scores, list_watchlist_items, - market_overview, ready, update_watchlist_item, + archive_watchlist_item, create_watchlist_item, get_neighborhood, health, list_area_scores, + list_neighborhood_scores, list_neighborhoods, list_watchlist_items, market_overview, ready, + update_watchlist_item, }; use crate::state::AppState; @@ -18,6 +19,9 @@ pub fn build_router(state: AppState) -> Router { Router::new() .route("/areas/scores", get(list_area_scores)) .route("/market/overview", get(market_overview)) + .route("/neighborhoods", get(list_neighborhoods)) + .route("/neighborhoods/scores", get(list_neighborhood_scores)) + .route("/neighborhoods/{neighborhood_id}", get(get_neighborhood)) .route( "/watchlist", get(list_watchlist_items).post(create_watchlist_item),