434 lines
12 KiB
Rust
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());
|
|
}
|
|
}
|