use axum::extract::{Path, Query, State}; use axum::Json; use serde::Serialize; use crate::error::{ApiError, ApiResult}; use crate::import_templates::{execute_import_payload, validate_import_payload}; use crate::models::{ AreaComparison, AreaDetail, AreaDiagnostics, AreaDiagnosticsQuery, AreaMonthlyMetric, AreaProfile, AreaScore, AreaScoreLineage, AreaScoreLineageQuery, AreaScoreModelRunResponse, AreaScoreRunDiff, AreaScoreRunDiffItem, AreaScoreSummary, CompareQuery, ComparisonMetric, ComparisonMetricValue, CreateAreaScoreModelRun, CreateDataSource, CreateIngestionRun, CreateRawArtifact, CreateReport, CreateReportGenerationRun, CreateScenarioRun, CreateWatchlistEvent, CreateWatchlistItem, DataSource, DiagnosticMetric, FinishIngestionRun, ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest, ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun, ModelRunDiffQuery, ModelRunQuery, MonthQuery, Neighborhood, NeighborhoodComparison, NeighborhoodComparisonItem, NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery, NeighborhoodScore, NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, Report, ReportGenerationRun, ReportGenerationRunQuery, ReportGenerationRunResponse, ReportQuery, ReportTemplate, ScenarioAreaResult, ScenarioRun, ScenarioRunQuery, ScenarioRunResponse, ScenarioVariant, SimilarNeighborhood, SimilarNeighborhoodCandidate, SimilarNeighborhoodQuery, SimilarNeighborhoodResponse, UpdateWatchlistItem, WatchlistEvent, WatchlistItem, WatchlistQuery, }; use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore}; 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 validate_import( Json(payload): Json, ) -> ApiResult> { let result = validate_import_payload(&payload).map_err(ApiError::BadRequest)?; Ok(Json(result)) } pub async fn execute_import( State(state): State, Json(payload): Json, ) -> ApiResult> { let mut tx = state.pool.begin().await?; let result = execute_import_payload(&mut tx, &payload).await; match result { Ok(response) => { sqlx::query( r#" UPDATE audit.ingestion_runs SET status = 'succeeded', finished_at = now(), row_count = $2, raw_artifact_id = $3, error_message = NULL WHERE run_id = $1 "#, ) .bind(payload.run_id) .bind(response.row_count as i32) .bind(payload.raw_artifact_id) .execute(&mut *tx) .await?; tx.commit().await?; Ok(Json(response)) } Err(message) => { tx.rollback().await?; sqlx::query( r#" UPDATE audit.ingestion_runs SET status = 'failed', finished_at = now(), error_message = $2 WHERE run_id = $1 "#, ) .bind(payload.run_id) .bind(message.clone()) .execute(&state.pool) .await?; Err(ApiError::BadRequest(message)) } } } 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 compare_areas( State(state): State, Query(query): Query, ) -> ApiResult> { validate_month(&query.month)?; let requested_ids = parse_compare_ids(&query.ids)?; let scores = 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 AND area_id = ANY($2) "#, ) .bind(&query.month) .bind(&requested_ids) .fetch_all(&state.pool) .await?; let items = sort_by_requested_order(scores, &requested_ids, |score| &score.area_id); let missing_ids = missing_ids(&requested_ids, &items, |score| &score.area_id); let metrics = area_comparison_metrics(&items); Ok(Json(AreaComparison { month: query.month, requested_ids, missing_ids, items, metrics, })) } pub async fn compare_neighborhoods( State(state): State, Query(query): Query, ) -> ApiResult> { validate_month(&query.month)?; let requested_ids = parse_compare_ids(&query.ids)?; let scores = sqlx::query_as::<_, NeighborhoodComparisonItem>( r#" SELECT s.neighborhood_id, s.area_id, s.name, s.area_name, s.district, n.built_year, n.property_type, n.metro_distance_m, n.school_quality, s.month, s.investment_score, s.recommendation, s.liquidity_score, s.rent_support_score, s.price_safety_score, s.location_score, s.building_age_score, s.transaction_count, s.transaction_price_psm, s.listing_count, s.listing_price_psm, s.rent_price_psm, s.median_days_on_market, s.annual_rent_yield_pct, s.discount_pct, s.available_units FROM gold.neighborhood_scores s JOIN silver.neighborhoods n ON n.neighborhood_id = s.neighborhood_id WHERE s.month = $1 AND s.neighborhood_id = ANY($2) "#, ) .bind(&query.month) .bind(&requested_ids) .fetch_all(&state.pool) .await?; let items = sort_by_requested_order(scores, &requested_ids, |score| &score.neighborhood_id); let missing_ids = missing_ids(&requested_ids, &items, |score| &score.neighborhood_id); let metrics = neighborhood_comparison_metrics(&items); Ok(Json(NeighborhoodComparison { month: query.month, requested_ids, missing_ids, items, metrics, })) } pub async fn list_model_runs( State(state): State, Query(query): Query, ) -> ApiResult>> { if let Some(month) = query.target_month.as_deref() { validate_month(month)?; } if let Some(model_name) = query.model_name.as_deref() { if has_sensitive_text(model_name) { return Err(ApiError::BadRequest( "model_name contains sensitive text".to_string(), )); } } let runs = sqlx::query_as::<_, ModelRun>( r#" SELECT model_run_id, model_name, model_version, target_month, status, parameters, started_at::text AS started_at, finished_at::text AS finished_at, row_count, error_message FROM gold.model_runs WHERE ($1::text IS NULL OR model_name = $1) AND ($2::text IS NULL OR target_month = $2) ORDER BY started_at DESC, model_run_id DESC LIMIT 100 "#, ) .bind(query.model_name) .bind(query.target_month) .fetch_all(&state.pool) .await?; Ok(Json(runs)) } pub async fn diff_area_score_model_runs( State(state): State, Query(query): Query, ) -> ApiResult> { if query.base_run_id == query.candidate_run_id { return Err(ApiError::BadRequest( "base_run_id and candidate_run_id must be different".to_string(), )); } let base_run = fetch_model_run(&state, query.base_run_id) .await? .ok_or_else(|| ApiError::BadRequest("base model run not found".to_string()))?; let candidate_run = fetch_model_run(&state, query.candidate_run_id) .await? .ok_or_else(|| ApiError::BadRequest("candidate model run not found".to_string()))?; if base_run.model_name != "area_scores" || candidate_run.model_name != "area_scores" { return Err(ApiError::BadRequest( "both model runs must be area_scores runs".to_string(), )); } let items = sqlx::query_as::<_, AreaScoreRunDiffItem>( r#" SELECT COALESCE(base.area_id, candidate.area_id) AS area_id, COALESCE(candidate.name, base.name) AS name, COALESCE(candidate.district, base.district) AS district, base.investment_score AS base_score, candidate.investment_score AS candidate_score, CASE WHEN base.investment_score IS NULL OR candidate.investment_score IS NULL THEN NULL ELSE candidate.investment_score - base.investment_score END AS score_delta, base.recommendation AS base_recommendation, candidate.recommendation AS candidate_recommendation FROM ( SELECT area_id, name, district, investment_score, recommendation FROM gold.area_score_snapshots WHERE model_run_id = $1 ) base FULL JOIN ( SELECT area_id, name, district, investment_score, recommendation FROM gold.area_score_snapshots WHERE model_run_id = $2 ) candidate ON candidate.area_id = base.area_id ORDER BY ABS( COALESCE(candidate.investment_score, 0) - COALESCE(base.investment_score, 0) ) DESC, area_id ASC "#, ) .bind(query.base_run_id) .bind(query.candidate_run_id) .fetch_all(&state.pool) .await?; Ok(Json(AreaScoreRunDiff { base_run, candidate_run, items, })) } pub async fn create_area_score_model_run( State(state): State, Json(payload): Json, ) -> ApiResult> { validate_month(&payload.month)?; let model_version = payload .model_version .clone() .unwrap_or_else(|| "area-score-rust-v1".to_string()); if has_sensitive_text(&model_version) { return Err(ApiError::BadRequest( "model_version contains sensitive text".to_string(), )); } let mut tx = state.pool.begin().await?; let model_run = sqlx::query_as::<_, ModelRun>( r#" INSERT INTO gold.model_runs ( model_name, model_version, target_month, status, parameters ) VALUES ('area_scores', $1, $2, 'running', $3) RETURNING model_run_id, model_name, model_version, target_month, status, parameters, started_at::text AS started_at, finished_at::text AS finished_at, row_count, error_message "#, ) .bind(&model_version) .bind(&payload.month) .bind(serde_json::json!({ "source_table": "silver.area_monthly_metrics", "weights": { "liquidity": 0.25, "momentum": 0.20, "rent_support": 0.20, "safety_margin": 0.15, "credit_support": 0.10, "supply_risk_inverse": 0.10 } })) .fetch_one(&mut *tx) .await?; let result = recompute_area_scores_in_tx( &mut tx, &payload.month, &model_version, model_run.model_run_id, ) .await; match result { Ok(row_count) => { let completed_run = finish_model_run( &mut tx, model_run.model_run_id, "succeeded", Some(row_count), None, ) .await?; tx.commit().await?; let scores = fetch_area_scores(&state, &payload.month).await?; Ok(Json(AreaScoreModelRunResponse { model_run: completed_run, scores, })) } Err(message) => { let _ = finish_model_run( &mut tx, model_run.model_run_id, "failed", None, Some(&message), ) .await; tx.commit().await?; Err(ApiError::BadRequest(message)) } } } pub async fn get_area_score_lineage( State(state): State, Path(area_id): Path, Query(query): Query, ) -> ApiResult> { validate_month(&query.month)?; let lineage = fetch_area_score_lineage(&state, &area_id, &query.month) .await? .ok_or_else(|| ApiError::BadRequest("area score lineage not found".to_string()))?; Ok(Json(lineage)) } async fn fetch_area_score_lineage( state: &AppState, area_id: &str, month: &str, ) -> Result, sqlx::Error> { sqlx::query_as::<_, AreaScoreLineage>( r#" SELECT s.area_id, a.name AS area_name, s.month, g.investment_score, g.model_run_id, g.model_version, s.ingestion_run_id, s.raw_artifact_id, ra.raw_uri, ra.sha256, ds.name AS source_name, s.updated_at::text AS metric_updated_at, g.computed_at::text AS score_computed_at FROM gold.area_scores g JOIN silver.area_monthly_metrics s ON s.area_id = g.area_id AND s.month = g.month JOIN silver.areas a ON a.area_id = s.area_id LEFT JOIN audit.raw_artifacts ra ON ra.artifact_id = s.raw_artifact_id LEFT JOIN audit.data_sources ds ON ds.source_id = ra.source_id WHERE s.area_id = $1 AND s.month = $2 "#, ) .bind(area_id) .bind(month) .fetch_optional(&state.pool) .await } pub async fn get_area_detail( State(state): State, Path(area_id): Path, Query(query): Query, ) -> ApiResult> { validate_month(&query.month)?; let profile = sqlx::query_as::<_, AreaProfile>( r#" SELECT area_id, name, district, segment, notes FROM silver.areas WHERE area_id = $1 "#, ) .bind(&area_id) .fetch_optional(&state.pool) .await? .ok_or_else(|| ApiError::BadRequest("area not found".to_string()))?; let current_score = 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 area_id = $1 AND month = $2 "#, ) .bind(&area_id) .bind(&query.month) .fetch_optional(&state.pool) .await?; let score_lineage = fetch_area_score_lineage(&state, &area_id, &query.month).await?; let monthly_metrics = sqlx::query_as::<_, AreaMonthlyMetric>( r#" SELECT area_id, month, transaction_count, transaction_price_psm, listing_count, listing_price_psm, median_days_on_market, rent_price_psm, new_supply_units, land_residential_gfa_sqm, mortgage_rate_pct, policy_signal, source, ingestion_run_id, raw_artifact_id FROM silver.area_monthly_metrics WHERE area_id = $1 AND month <= $2 ORDER BY month DESC LIMIT 12 "#, ) .bind(&area_id) .bind(&query.month) .fetch_all(&state.pool) .await?; let diagnostics = fetch_area_diagnostics(&state, &area_id, &query.month).await?; let neighborhood_scores = fetch_neighborhood_scores_for_area(&state, &area_id, &query.month).await?; Ok(Json(AreaDetail { profile, current_score, score_lineage, monthly_metrics, diagnostics, neighborhood_scores, })) } pub async fn get_area_diagnostics( State(state): State, Path(area_id): Path, Query(query): Query, ) -> ApiResult> { validate_month(&query.month)?; let diagnostics = fetch_area_diagnostics(&state, &area_id, &query.month) .await? .ok_or_else(|| ApiError::BadRequest("area diagnostics not found".to_string()))?; Ok(Json(diagnostics)) } async fn fetch_area_diagnostics( state: &AppState, area_id: &str, month: &str, ) -> Result, sqlx::Error> { let rows = sqlx::query_as::<_, AreaMonthlyMetric>( r#" SELECT area_id, month, transaction_count, transaction_price_psm, listing_count, listing_price_psm, median_days_on_market, rent_price_psm, new_supply_units, land_residential_gfa_sqm, mortgage_rate_pct, policy_signal, source, ingestion_run_id, raw_artifact_id FROM silver.area_monthly_metrics WHERE area_id = $1 AND month <= $2 ORDER BY month ASC "#, ) .bind(area_id) .bind(month) .fetch_all(&state.pool) .await?; let Some(current) = rows.iter().find(|row| row.month == month) else { return Ok(None); }; let transaction_price_values = rows .iter() .map(|row| row.transaction_price_psm) .collect::>(); let transaction_count_values = rows .iter() .map(|row| row.transaction_count as f64) .collect::>(); let rent_yield_values = rows .iter() .map(|row| safe_percentage(row.rent_price_psm * 12.0, row.transaction_price_psm)) .collect::>(); let inventory_pressure_values = rows .iter() .map(|row| safe_ratio(row.listing_count as f64, row.transaction_count as f64)) .collect::>(); let metrics = vec![ diagnostic_metric( "transaction_price_psm", "成交均价", "元/㎡", current.transaction_price_psm, &transaction_price_values, ), diagnostic_metric( "transaction_count", "成交量", "套", current.transaction_count as f64, &transaction_count_values, ), diagnostic_metric( "annual_rent_yield_pct", "租金收益率", "%", safe_percentage(current.rent_price_psm * 12.0, current.transaction_price_psm), &rent_yield_values, ), diagnostic_metric( "inventory_pressure_ratio", "库存压力", "倍", safe_ratio( current.listing_count as f64, current.transaction_count as f64, ), &inventory_pressure_values, ), ]; let anomaly_count = metrics.iter().filter(|metric| metric.anomaly).count(); Ok(Some(AreaDiagnostics { area_id: area_id.to_string(), month: month.to_string(), metrics, anomaly_count, })) } async fn fetch_neighborhood_scores_for_area( state: &AppState, area_id: &str, month: &str, ) -> Result, sqlx::Error> { 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 area_id = $1 AND month = $2 ORDER BY investment_score DESC, neighborhood_id ASC "#, ) .bind(area_id) .bind(month) .fetch_all(&state.pool) .await } 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 = fetch_neighborhood_profile(&state, &neighborhood_id) .await? .ok_or_else(|| ApiError::BadRequest("neighborhood not found".to_string()))?; Ok(Json(neighborhood)) } pub async fn get_neighborhood_detail( State(state): State, Path(neighborhood_id): Path, Query(query): Query, ) -> ApiResult> { validate_month(&query.month)?; let profile = fetch_neighborhood_profile(&state, &neighborhood_id) .await? .ok_or_else(|| ApiError::BadRequest("neighborhood not found".to_string()))?; let current_score = 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 neighborhood_id = $1 AND month = $2 "#, ) .bind(&neighborhood_id) .bind(&query.month) .fetch_optional(&state.pool) .await?; let monthly_metrics = sqlx::query_as::<_, NeighborhoodMonthlyMetric>( r#" SELECT neighborhood_id, month, transaction_count, transaction_price_psm, listing_count, listing_price_psm, rent_price_psm, median_days_on_market, available_units, source, ingestion_run_id, raw_artifact_id FROM silver.neighborhood_monthly_metrics WHERE neighborhood_id = $1 AND month <= $2 ORDER BY month DESC LIMIT 12 "#, ) .bind(&neighborhood_id) .bind(&query.month) .fetch_all(&state.pool) .await?; let watchlist_items = sqlx::query_as::<_, WatchlistItem>( r#" SELECT watchlist_item_id, neighborhood_id, area_id, target_price_psm, status, label, priority, trigger_operator, trigger_price_psm, notes, created_at::text AS created_at, updated_at::text AS updated_at, last_reviewed_at::text AS last_reviewed_at FROM app.watchlist_items WHERE neighborhood_id = $1 AND status <> 'archived' ORDER BY updated_at DESC, watchlist_item_id DESC "#, ) .bind(&neighborhood_id) .fetch_all(&state.pool) .await?; let similar_neighborhoods = fetch_similar_neighborhoods(&state, &neighborhood_id, &query.month, 6) .await? .map(|response| response.items) .unwrap_or_default(); Ok(Json(NeighborhoodDetail { profile, current_score, monthly_metrics, similar_neighborhoods, watchlist_items, })) } pub async fn get_similar_neighborhoods( State(state): State, Path(neighborhood_id): Path, Query(query): Query, ) -> ApiResult> { validate_month(&query.month)?; let limit = query.limit.unwrap_or(8).clamp(1, 10); let response = fetch_similar_neighborhoods(&state, &neighborhood_id, &query.month, limit) .await? .ok_or_else(|| ApiError::BadRequest("neighborhood score not found".to_string()))?; Ok(Json(response)) } async fn fetch_similar_neighborhoods( state: &AppState, neighborhood_id: &str, month: &str, limit: i64, ) -> Result, sqlx::Error> { let candidates = sqlx::query_as::<_, SimilarNeighborhoodCandidate>( r#" SELECT s.neighborhood_id, s.area_id, s.name, s.area_name, s.district, n.built_year, n.property_type, n.metro_distance_m, n.school_quality, s.month, s.investment_score, s.recommendation, s.transaction_price_psm, s.annual_rent_yield_pct, s.liquidity_score, s.location_score, s.building_age_score FROM gold.neighborhood_scores s JOIN silver.neighborhoods n ON n.neighborhood_id = s.neighborhood_id WHERE s.month = $1 "#, ) .bind(month) .fetch_all(&state.pool) .await?; let Some(target) = candidates .iter() .find(|candidate| candidate.neighborhood_id == neighborhood_id) .cloned() else { return Ok(None); }; let mut items = candidates .into_iter() .filter(|candidate| candidate.neighborhood_id != neighborhood_id) .map(|candidate| similar_neighborhood(&target, candidate)) .collect::>(); items.sort_by(|left, right| { right .similarity_score .partial_cmp(&left.similarity_score) .unwrap_or(std::cmp::Ordering::Equal) }); items.truncate(limit as usize); Ok(Some(SimilarNeighborhoodResponse { target, month: month.to_string(), items, })) } fn similar_neighborhood( target: &SimilarNeighborhoodCandidate, candidate: SimilarNeighborhoodCandidate, ) -> SimilarNeighborhood { let mut similarity_score = 0.0; let mut reasons = Vec::new(); if candidate.property_type == target.property_type { similarity_score += 22.0; reasons.push("property_type_match".to_string()); } if candidate.area_id == target.area_id { similarity_score += 20.0; reasons.push("same_area".to_string()); } else if candidate.district == target.district { similarity_score += 10.0; reasons.push("same_district".to_string()); } let price_gap_pct = safe_percentage( (candidate.transaction_price_psm - target.transaction_price_psm).abs(), target.transaction_price_psm, ); similarity_score += closeness_score(price_gap_pct, 30.0) * 24.0; reasons.push(format!("price_gap_pct:{:.1}", round_to(price_gap_pct, 1))); if let (Some(candidate_year), Some(target_year)) = (candidate.built_year, target.built_year) { let age_gap = (candidate_year - target_year).abs() as f64; similarity_score += closeness_score(age_gap, 20.0) * 18.0; reasons.push(format!("building_age_gap_years:{}", age_gap.round() as i32)); } if let (Some(candidate_distance), Some(target_distance)) = (candidate.metro_distance_m, target.metro_distance_m) { let metro_gap = (candidate_distance - target_distance).abs() as f64; similarity_score += closeness_score(metro_gap, 1500.0) * 16.0; reasons.push(format!("metro_gap_m:{}", metro_gap.round() as i32)); } SimilarNeighborhood { neighborhood_id: candidate.neighborhood_id, area_id: candidate.area_id, name: candidate.name, area_name: candidate.area_name, district: candidate.district, built_year: candidate.built_year, property_type: candidate.property_type, metro_distance_m: candidate.metro_distance_m, school_quality: candidate.school_quality, investment_score: candidate.investment_score, recommendation: candidate.recommendation, transaction_price_psm: candidate.transaction_price_psm, annual_rent_yield_pct: candidate.annual_rent_yield_pct, similarity_score: round_to(similarity_score.min(100.0), 1), relative_price_pct: round_to( safe_percentage( candidate.transaction_price_psm - target.transaction_price_psm, target.transaction_price_psm, ), 1, ), reasons, } } fn closeness_score(gap: f64, max_gap: f64) -> f64 { if max_gap <= 0.0 { return 0.0; } (1.0 - gap / max_gap).clamp(0.0, 1.0) } async fn fetch_neighborhood_profile( state: &AppState, neighborhood_id: &str, ) -> Result, sqlx::Error> { 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 } 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_data_sources(State(state): State) -> ApiResult>> { let sources = sqlx::query_as::<_, DataSource>( r#" SELECT source_id, name, source_type, url, cadence, reliability, notes, created_at::text AS created_at FROM audit.data_sources ORDER BY name ASC "#, ) .fetch_all(&state.pool) .await?; Ok(Json(sources)) } pub async fn create_data_source( State(state): State, Json(payload): Json, ) -> ApiResult> { payload .validate() .map_err(|message| ApiError::BadRequest(message.to_string()))?; let source = sqlx::query_as::<_, DataSource>( r#" INSERT INTO audit.data_sources ( name, source_type, url, cadence, reliability, notes ) VALUES ($1, $2, $3, $4, COALESCE($5, 'unknown'), COALESCE($6, '')) ON CONFLICT (name) DO UPDATE SET source_type = EXCLUDED.source_type, url = EXCLUDED.url, cadence = EXCLUDED.cadence, reliability = EXCLUDED.reliability, notes = EXCLUDED.notes RETURNING source_id, name, source_type, url, cadence, reliability, notes, created_at::text AS created_at "#, ) .bind(payload.name.trim().to_string()) .bind(payload.source_type.trim().to_string()) .bind(payload.url) .bind(payload.cadence) .bind(payload.reliability) .bind(payload.notes) .fetch_one(&state.pool) .await?; Ok(Json(source)) } pub async fn list_ingestion_runs( State(state): State, Query(query): Query, ) -> ApiResult>> { let runs = sqlx::query_as::<_, IngestionRun>( r#" SELECT r.run_id, r.source_id, s.name AS source_name, r.raw_artifact_id, r.status, r.started_at::text AS started_at, r.finished_at::text AS finished_at, r.raw_uri, r.row_count, r.error_message FROM audit.ingestion_runs r LEFT JOIN audit.data_sources s ON s.source_id = r.source_id WHERE ($1::bigint IS NULL OR r.source_id = $1) ORDER BY r.started_at DESC, r.run_id DESC LIMIT 100 "#, ) .bind(query.source_id) .fetch_all(&state.pool) .await?; Ok(Json(runs)) } pub async fn create_ingestion_run( State(state): State, Json(payload): Json, ) -> ApiResult> { payload .validate() .map_err(|message| ApiError::BadRequest(message.to_string()))?; let run = sqlx::query_as::<_, IngestionRun>( r#" INSERT INTO audit.ingestion_runs ( source_id, status, raw_uri, raw_artifact_id, row_count, error_message ) VALUES ($1, COALESCE($2, 'running'), $3, $4, $5, $6) RETURNING run_id, source_id, ( SELECT name FROM audit.data_sources WHERE source_id = audit.ingestion_runs.source_id ) AS source_name, raw_artifact_id, status, started_at::text AS started_at, finished_at::text AS finished_at, raw_uri, row_count, error_message "#, ) .bind(payload.source_id) .bind(payload.status) .bind(payload.raw_uri) .bind(payload.raw_artifact_id) .bind(payload.row_count) .bind(payload.error_message) .fetch_one(&state.pool) .await?; Ok(Json(run)) } pub async fn finish_ingestion_run( State(state): State, Path(run_id): Path, Json(payload): Json, ) -> ApiResult> { payload .validate() .map_err(|message| ApiError::BadRequest(message.to_string()))?; let run = sqlx::query_as::<_, IngestionRun>( r#" UPDATE audit.ingestion_runs SET status = $2, finished_at = now(), row_count = COALESCE($3, row_count), error_message = $4 WHERE run_id = $1 RETURNING run_id, source_id, ( SELECT name FROM audit.data_sources WHERE source_id = audit.ingestion_runs.source_id ) AS source_name, raw_artifact_id, status, started_at::text AS started_at, finished_at::text AS finished_at, raw_uri, row_count, error_message "#, ) .bind(run_id) .bind(payload.status) .bind(payload.row_count) .bind(payload.error_message) .fetch_optional(&state.pool) .await? .ok_or_else(|| ApiError::BadRequest("ingestion run not found".to_string()))?; Ok(Json(run)) } pub async fn list_raw_artifacts( State(state): State, Query(query): Query, ) -> ApiResult>> { if let Some(sha256) = query.sha256.as_deref() { validate_sha256(sha256)?; } let artifacts = sqlx::query_as::<_, RawArtifact>( r#" SELECT a.artifact_id, a.run_id, a.source_id, s.name AS source_name, a.original_filename, a.raw_uri, a.sha256, a.size_bytes, a.mime_type, a.uploaded_by, a.notes, a.created_at::text AS created_at FROM audit.raw_artifacts a LEFT JOIN audit.data_sources s ON s.source_id = a.source_id WHERE ($1::bigint IS NULL OR a.source_id = $1) AND ( $2::bigint IS NULL OR a.run_id = $2 OR EXISTS ( SELECT 1 FROM audit.ingestion_runs r WHERE r.run_id = $2 AND r.raw_artifact_id = a.artifact_id ) ) AND ($3::text IS NULL OR a.sha256 = $3) ORDER BY a.created_at DESC, a.artifact_id DESC LIMIT 100 "#, ) .bind(query.source_id) .bind(query.run_id) .bind(query.sha256) .fetch_all(&state.pool) .await?; Ok(Json(artifacts)) } pub async fn create_raw_artifact( State(state): State, Json(payload): Json, ) -> ApiResult> { payload .validate() .map_err(|message| ApiError::BadRequest(message.to_string()))?; let mut tx = state.pool.begin().await?; let artifact = sqlx::query_as::<_, RawArtifact>( r#" INSERT INTO audit.raw_artifacts ( run_id, source_id, original_filename, raw_uri, sha256, size_bytes, mime_type, uploaded_by, notes ) VALUES ( $1, COALESCE( $2, ( SELECT source_id FROM audit.ingestion_runs WHERE run_id = $1 ) ), $3, $4, $5, $6, $7, COALESCE($8, 'system'), COALESCE($9, '') ) ON CONFLICT (sha256) DO UPDATE SET run_id = COALESCE(audit.raw_artifacts.run_id, EXCLUDED.run_id), source_id = COALESCE(audit.raw_artifacts.source_id, EXCLUDED.source_id), notes = CASE WHEN audit.raw_artifacts.notes = '' THEN EXCLUDED.notes ELSE audit.raw_artifacts.notes END RETURNING artifact_id, run_id, source_id, ( SELECT name FROM audit.data_sources WHERE source_id = audit.raw_artifacts.source_id ) AS source_name, original_filename, raw_uri, sha256, size_bytes, mime_type, uploaded_by, notes, created_at::text AS created_at "#, ) .bind(payload.run_id) .bind(payload.source_id) .bind(payload.original_filename.trim().to_string()) .bind(payload.raw_uri.trim().to_string()) .bind(payload.sha256.trim().to_string()) .bind(payload.size_bytes) .bind(payload.mime_type) .bind(payload.uploaded_by) .bind(payload.notes) .fetch_one(&mut *tx) .await?; if let Some(run_id) = payload.run_id { sqlx::query( r#" UPDATE audit.ingestion_runs SET raw_artifact_id = $2, raw_uri = COALESCE(raw_uri, $3) WHERE run_id = $1 "#, ) .bind(run_id) .bind(artifact.artifact_id) .bind(&artifact.raw_uri) .execute(&mut *tx) .await?; } tx.commit().await?; Ok(Json(artifact)) } pub async fn list_watchlist_items( State(state): State, Query(query): Query, ) -> ApiResult>> { let items = sqlx::query_as::<_, WatchlistItem>( r#" SELECT watchlist_item_id, neighborhood_id, area_id, target_price_psm, status, label, priority, trigger_operator, trigger_price_psm, notes, created_at::text AS created_at, updated_at::text AS updated_at, last_reviewed_at::text AS last_reviewed_at FROM app.watchlist_items WHERE ($1::text IS NULL OR status = $1) AND ($1::text IS NOT NULL OR status <> 'archived') AND ($2::text IS NULL OR area_id = $2) AND ($3::text IS NULL OR neighborhood_id = $3) AND ($4::text IS NULL OR label = $4) ORDER BY updated_at DESC, watchlist_item_id DESC "#, ) .bind(query.status) .bind(query.area_id) .bind(query.neighborhood_id) .bind(query.label) .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 mut tx = state.pool.begin().await?; let item = sqlx::query_as::<_, WatchlistItem>( r#" INSERT INTO app.watchlist_items ( neighborhood_id, area_id, target_price_psm, label, priority, trigger_operator, trigger_price_psm, notes ) VALUES ( $1, $2, $3, COALESCE($4, 'general'), COALESCE($5, 'medium'), COALESCE($6, 'lte'), $7, COALESCE($8, '') ) RETURNING watchlist_item_id, neighborhood_id, area_id, target_price_psm, status, label, priority, trigger_operator, trigger_price_psm, notes, created_at::text AS created_at, updated_at::text AS updated_at, last_reviewed_at::text AS last_reviewed_at "#, ) .bind(payload.neighborhood_id) .bind(payload.area_id) .bind(payload.target_price_psm) .bind(payload.label) .bind(payload.priority) .bind(payload.trigger_operator) .bind(payload.trigger_price_psm) .bind(payload.notes) .fetch_one(&mut *tx) .await?; insert_watchlist_event(&mut tx, item.watchlist_item_id, "created", "创建观察标的").await?; tx.commit().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 mut tx = state.pool.begin().await?; let item = sqlx::query_as::<_, WatchlistItem>( r#" UPDATE app.watchlist_items SET target_price_psm = COALESCE($2, target_price_psm), status = COALESCE($3, status), label = COALESCE($4, label), priority = COALESCE($5, priority), trigger_operator = COALESCE($6, trigger_operator), trigger_price_psm = COALESCE($7, trigger_price_psm), notes = COALESCE($8, notes), last_reviewed_at = CASE WHEN $9 THEN now() ELSE last_reviewed_at END, updated_at = now() WHERE watchlist_item_id = $1 RETURNING watchlist_item_id, neighborhood_id, area_id, target_price_psm, status, label, priority, trigger_operator, trigger_price_psm, notes, created_at::text AS created_at, updated_at::text AS updated_at, last_reviewed_at::text AS last_reviewed_at "#, ) .bind(watchlist_item_id) .bind(payload.target_price_psm) .bind(payload.status.clone()) .bind(payload.label) .bind(payload.priority) .bind(payload.trigger_operator) .bind(payload.trigger_price_psm) .bind(payload.notes) .bind(payload.mark_reviewed.unwrap_or(false)) .fetch_optional(&mut *tx) .await? .ok_or_else(|| ApiError::BadRequest("watchlist item not found".to_string()))?; insert_watchlist_event( &mut tx, item.watchlist_item_id, if payload.mark_reviewed.unwrap_or(false) { "reviewed" } else { "updated" }, if payload.mark_reviewed.unwrap_or(false) { "完成一次人工复核" } else { "更新观察标的" }, ) .await?; tx.commit().await?; Ok(Json(item)) } pub async fn archive_watchlist_item( State(state): State, Path(watchlist_item_id): Path, ) -> ApiResult> { let mut tx = state.pool.begin().await?; 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, label, priority, trigger_operator, trigger_price_psm, notes, created_at::text AS created_at, updated_at::text AS updated_at, last_reviewed_at::text AS last_reviewed_at "#, ) .bind(watchlist_item_id) .fetch_optional(&mut *tx) .await? .ok_or_else(|| ApiError::BadRequest("watchlist item not found".to_string()))?; insert_watchlist_event(&mut tx, item.watchlist_item_id, "archived", "归档观察标的").await?; tx.commit().await?; Ok(Json(item)) } pub async fn list_watchlist_events( State(state): State, Path(watchlist_item_id): Path, ) -> ApiResult>> { let events = sqlx::query_as::<_, WatchlistEvent>( r#" SELECT event_id, watchlist_item_id, event_type, summary, created_at::text AS created_at FROM app.watchlist_events WHERE watchlist_item_id = $1 ORDER BY created_at DESC, event_id DESC LIMIT 50 "#, ) .bind(watchlist_item_id) .fetch_all(&state.pool) .await?; Ok(Json(events)) } pub async fn create_watchlist_event( State(state): State, Path(watchlist_item_id): Path, Json(payload): Json, ) -> ApiResult> { payload .validate() .map_err(|message| ApiError::BadRequest(message.to_string()))?; let event = sqlx::query_as::<_, WatchlistEvent>( r#" INSERT INTO app.watchlist_events ( watchlist_item_id, event_type, summary ) VALUES ($1, $2, $3) RETURNING event_id, watchlist_item_id, event_type, summary, created_at::text AS created_at "#, ) .bind(watchlist_item_id) .bind(payload.event_type.trim().to_string()) .bind(payload.summary.trim().to_string()) .fetch_one(&state.pool) .await?; Ok(Json(event)) } pub async fn list_scenario_runs( State(state): State, Query(query): Query, ) -> ApiResult>> { if let Some(month) = query.target_month.as_deref() { validate_month(month)?; } if let Some(area_id) = query.area_id.as_deref() { validate_identifier(area_id)?; } let runs = sqlx::query_as::<_, ScenarioRun>( r#" SELECT scenario_run_id, name, target_month, area_id, interest_rate_delta_bps, policy_signal_delta, supply_delta_pct, rent_delta_pct, notes, created_at::text AS created_at FROM app.scenario_runs WHERE ($1::text IS NULL OR target_month = $1) AND ($2::text IS NULL OR area_id = $2) ORDER BY scenario_run_id DESC LIMIT 50 "#, ) .bind(query.target_month) .bind(query.area_id) .fetch_all(&state.pool) .await?; Ok(Json(runs)) } pub async fn create_scenario_run( State(state): State, Json(payload): Json, ) -> ApiResult> { validate_month(&payload.target_month)?; payload .validate() .map_err(|message| ApiError::BadRequest(message.to_string()))?; if let Some(area_id) = payload.area_id.as_deref() { validate_identifier(area_id)?; } let mut tx = state.pool.begin().await?; let run = sqlx::query_as::<_, ScenarioRun>( r#" INSERT INTO app.scenario_runs ( name, target_month, area_id, interest_rate_delta_bps, policy_signal_delta, supply_delta_pct, rent_delta_pct, notes ) VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE($8, '')) RETURNING scenario_run_id, name, target_month, area_id, interest_rate_delta_bps, policy_signal_delta, supply_delta_pct, rent_delta_pct, notes, created_at::text AS created_at "#, ) .bind(payload.name.trim().to_string()) .bind(payload.target_month.trim().to_string()) .bind(payload.area_id.clone()) .bind(payload.interest_rate_delta_bps) .bind(payload.policy_signal_delta) .bind(payload.supply_delta_pct) .bind(payload.rent_delta_pct) .bind(payload.notes.clone()) .fetch_one(&mut *tx) .await?; let variants = build_scenario_variants(&mut tx, &run) .await .map_err(ApiError::BadRequest)?; tx.commit().await?; Ok(Json(ScenarioRunResponse { run, variants })) } pub async fn list_reports( State(state): State, Query(query): Query, ) -> ApiResult>> { validate_report_query(&query)?; let reports = sqlx::query_as::<_, Report>( report_select_sql( r#" WHERE ($1::text IS NULL OR r.report_type = $1) AND ($2::text IS NULL OR r.target_month = $2) AND ($3::text IS NULL OR r.area_id = $3) AND ($4::text IS NULL OR r.neighborhood_id = $4) AND ($5::text IS NULL OR r.status = $5) ORDER BY r.updated_at DESC, r.report_id DESC LIMIT 80 "#, ) .as_str(), ) .bind(query.report_type) .bind(query.target_month) .bind(query.area_id) .bind(query.neighborhood_id) .bind(query.status) .fetch_all(&state.pool) .await?; Ok(Json(reports)) } pub async fn get_report( State(state): State, Path(report_id): Path, ) -> ApiResult> { let report = sqlx::query_as::<_, Report>(report_select_sql("WHERE r.report_id = $1").as_str()) .bind(report_id) .fetch_optional(&state.pool) .await? .ok_or_else(|| ApiError::BadRequest("report not found".to_string()))?; Ok(Json(report)) } pub async fn create_report( State(state): State, Json(payload): Json, ) -> ApiResult> { validate_create_report(&payload)?; let linked_metrics = payload .linked_metrics .clone() .unwrap_or_else(|| serde_json::json!({})); if !linked_metrics.is_object() { return Err(ApiError::BadRequest( "linked_metrics must be a JSON object".to_string(), )); } let mut tx = state.pool.begin().await?; let report = insert_report_from_payload( &mut tx, CreateReport { linked_metrics: Some(linked_metrics), ..payload }, ) .await?; tx.commit().await?; Ok(Json(report)) } pub async fn list_report_templates( State(state): State, ) -> ApiResult>> { let templates = sqlx::query_as::<_, ReportTemplate>( r#" SELECT template_id, template_key, name, report_type, cadence, description, body_template, created_at::text AS created_at, updated_at::text AS updated_at FROM app.report_templates ORDER BY cadence, template_key "#, ) .fetch_all(&state.pool) .await?; Ok(Json(templates)) } pub async fn list_report_generation_runs( State(state): State, Query(query): Query, ) -> ApiResult>> { validate_report_generation_query(&query)?; let runs = sqlx::query_as::<_, ReportGenerationRun>( r#" SELECT r.generation_run_id, r.template_id, t.template_key, t.name AS template_name, r.target_month, r.status, r.report_id, p.title AS report_title, r.parameters, r.error_message, r.started_at::text AS started_at, r.finished_at::text AS finished_at FROM app.report_generation_runs r JOIN app.report_templates t ON t.template_id = r.template_id LEFT JOIN app.reports p ON p.report_id = r.report_id WHERE ($1::text IS NULL OR r.target_month = $1) AND ($2::text IS NULL OR t.template_key = $2) AND ($3::text IS NULL OR r.status = $3) ORDER BY r.generation_run_id DESC LIMIT 80 "#, ) .bind(query.target_month) .bind(query.template_key) .bind(query.status) .fetch_all(&state.pool) .await?; Ok(Json(runs)) } pub async fn create_report_generation_run( State(state): State, Json(payload): Json, ) -> ApiResult> { validate_month(&payload.target_month)?; payload .validate() .map_err(|message| ApiError::BadRequest(message.to_string()))?; validate_identifier(&payload.template_key)?; let parameters = payload .parameters .clone() .unwrap_or_else(|| serde_json::json!({})); let mut tx = state.pool.begin().await?; let template = fetch_report_template(&mut tx, &payload.template_key) .await? .ok_or_else(|| ApiError::BadRequest("report template not found".to_string()))?; let generation_run_id = sqlx::query_scalar::<_, i64>( r#" INSERT INTO app.report_generation_runs ( template_id, target_month, status, parameters ) VALUES ($1, $2, 'running', $3) RETURNING generation_run_id "#, ) .bind(template.template_id) .bind(payload.target_month.trim().to_string()) .bind(parameters.clone()) .fetch_one(&mut *tx) .await?; let generated = build_generated_report(&mut tx, &template, &payload.target_month, ¶meters).await; match generated { Ok(create_report_payload) => { let report = insert_report_from_payload(&mut tx, create_report_payload).await?; finish_report_generation_run( &mut tx, generation_run_id, "succeeded", Some(report.report_id), None, ) .await?; let generation_run = fetch_report_generation_run_by_id(&mut tx, generation_run_id) .await? .ok_or_else(|| ApiError::BadRequest("generation run not found".to_string()))?; tx.commit().await?; Ok(Json(ReportGenerationRunResponse { generation_run, report: Some(report), })) } Err(message) => { finish_report_generation_run( &mut tx, generation_run_id, "failed", None, Some(&message), ) .await?; let generation_run = fetch_report_generation_run_by_id(&mut tx, generation_run_id) .await? .ok_or_else(|| ApiError::BadRequest("generation run not found".to_string()))?; tx.commit().await?; Ok(Json(ReportGenerationRunResponse { generation_run, report: None, })) } } } async fn insert_watchlist_event( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, watchlist_item_id: i64, event_type: &str, summary: &str, ) -> Result<(), sqlx::Error> { sqlx::query( r#" INSERT INTO app.watchlist_events ( watchlist_item_id, event_type, summary ) VALUES ($1, $2, $3) "#, ) .bind(watchlist_item_id) .bind(event_type) .bind(summary) .execute(&mut **tx) .await?; Ok(()) } fn report_select_sql(where_clause: &str) -> String { format!( r#" SELECT r.report_id, r.report_type, r.title, r.target_month, r.area_id, a.name AS area_name, r.neighborhood_id, n.name AS neighborhood_name, r.summary, r.content_markdown, r.linked_metrics, r.status, r.created_at::text AS created_at, r.updated_at::text AS updated_at FROM app.reports r LEFT JOIN silver.areas a ON a.area_id = r.area_id LEFT JOIN silver.neighborhoods n ON n.neighborhood_id = r.neighborhood_id {where_clause} "# ) } async fn fetch_report_by_id( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, report_id: i64, ) -> Result, sqlx::Error> { sqlx::query_as::<_, Report>(report_select_sql("WHERE r.report_id = $1").as_str()) .bind(report_id) .fetch_optional(&mut **tx) .await } async fn fetch_report_template( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, template_key: &str, ) -> Result, sqlx::Error> { sqlx::query_as::<_, ReportTemplate>( r#" SELECT template_id, template_key, name, report_type, cadence, description, body_template, created_at::text AS created_at, updated_at::text AS updated_at FROM app.report_templates WHERE template_key = $1 "#, ) .bind(template_key) .fetch_optional(&mut **tx) .await } async fn fetch_report_generation_run_by_id( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, generation_run_id: i64, ) -> Result, sqlx::Error> { sqlx::query_as::<_, ReportGenerationRun>( r#" SELECT r.generation_run_id, r.template_id, t.template_key, t.name AS template_name, r.target_month, r.status, r.report_id, p.title AS report_title, r.parameters, r.error_message, r.started_at::text AS started_at, r.finished_at::text AS finished_at FROM app.report_generation_runs r JOIN app.report_templates t ON t.template_id = r.template_id LEFT JOIN app.reports p ON p.report_id = r.report_id WHERE r.generation_run_id = $1 "#, ) .bind(generation_run_id) .fetch_optional(&mut **tx) .await } async fn finish_report_generation_run( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, generation_run_id: i64, status: &str, report_id: Option, error_message: Option<&str>, ) -> Result<(), sqlx::Error> { sqlx::query( r#" UPDATE app.report_generation_runs SET status = $2, report_id = $3, error_message = $4, finished_at = now() WHERE generation_run_id = $1 "#, ) .bind(generation_run_id) .bind(status) .bind(report_id) .bind(error_message) .execute(&mut **tx) .await?; Ok(()) } async fn insert_report_from_payload( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, payload: CreateReport, ) -> Result { let report_id = sqlx::query_scalar::<_, i64>( r#" INSERT INTO app.reports ( report_type, title, target_month, area_id, neighborhood_id, summary, content_markdown, linked_metrics, status ) VALUES ($1, $2, $3, $4, $5, COALESCE($6, ''), $7, $8, COALESCE($9, 'draft')) RETURNING report_id "#, ) .bind(payload.report_type.trim().to_string()) .bind(payload.title.trim().to_string()) .bind(payload.target_month.clone()) .bind(payload.area_id.clone()) .bind(payload.neighborhood_id.clone()) .bind(payload.summary.clone()) .bind(payload.content_markdown.trim().to_string()) .bind( payload .linked_metrics .unwrap_or_else(|| serde_json::json!({})), ) .bind(payload.status.clone()) .fetch_one(&mut **tx) .await?; fetch_report_by_id(tx, report_id) .await? .ok_or(sqlx::Error::RowNotFound) } async fn build_generated_report( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, template: &ReportTemplate, target_month: &str, parameters: &serde_json::Value, ) -> Result { match template.template_key.as_str() { "monthly_market" => { build_monthly_market_report(tx, template, target_month, parameters).await } "weekly_watchlist" => { build_weekly_watchlist_report(tx, template, target_month, parameters).await } other => Err(format!("template '{other}' is not supported by generator")), } } async fn build_monthly_market_report( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, template: &ReportTemplate, target_month: &str, parameters: &serde_json::Value, ) -> Result { let scores = fetch_area_scores_in_tx(tx, target_month) .await .map_err(|error| format!("failed to fetch area scores: {error}"))?; if scores.is_empty() { return Err(format!("no area scores found for month {target_month}")); } let top = scores .first() .ok_or_else(|| "no top area found".to_string())?; let weakest = scores .last() .ok_or_else(|| "no weakest area found".to_string())?; let high_supply = scores .iter() .max_by(|left, right| { left.supply_risk_score .partial_cmp(&right.supply_risk_score) .unwrap_or(std::cmp::Ordering::Equal) }) .ok_or_else(|| "no supply risk area found".to_string())?; let avg_score = average(scores.iter().map(|score| score.investment_score)); let avg_yield = average(scores.iter().map(|score| score.annual_rent_yield_pct)); let market_summary = format!( "## 核心结论\n\n- 综合评分最高板块:{}({},{})。\n- 综合评分最低板块:{}({},{})。\n- 供应压力最高板块:{}(供应风险 {})。\n- 样本平均投资观察评分:{};样本平均年化租金收益率:{}%。", top.name, format_score(top.investment_score), top.recommendation, weakest.name, format_score(weakest.investment_score), weakest.recommendation, high_supply.name, format_score(high_supply.supply_risk_score), format_score(avg_score), round_to(avg_yield, 2) ); let area_table = monthly_area_table(&scores); let risk_section = format!( "## 风险解释\n\n- {} 的供应风险最高,需要跟踪新增供应和挂牌去化。\n- {} 综合评分最低,进入谨慎复核列表。\n- 自动报告只汇总结构化指标,具体买卖动作仍需人工复核。", high_supply.name, weakest.name ); let next_actions = "## 下月跟踪\n\n- 复核高分板块内小区分化。\n- 跟踪观察池价格触发和成交量变化。\n- 对政策和利率变化进行情景推演。"; let content = template .body_template .replace("{{month}}", target_month) .replace("{{market_summary}}", &market_summary) .replace("{{area_table}}", &area_table) .replace("{{risk_section}}", &risk_section) .replace("{{next_actions}}", next_actions); Ok(CreateReport { report_type: template.report_type.clone(), title: format!("上海房市投资研究月报 {target_month}"), target_month: Some(target_month.to_string()), area_id: None, neighborhood_id: None, summary: Some(format!( "最高评分 {},最低评分 {},供应压力最高 {}。", top.name, weakest.name, high_supply.name )), content_markdown: content, linked_metrics: Some(serde_json::json!({ "template_key": template.template_key, "month": target_month, "top_area_id": top.area_id, "weakest_area_id": weakest.area_id, "highest_supply_risk_area_id": high_supply.area_id, "area_count": scores.len(), "parameters": parameters })), status: Some("published".to_string()), }) } async fn build_weekly_watchlist_report( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, template: &ReportTemplate, target_month: &str, parameters: &serde_json::Value, ) -> Result { let items = fetch_watchlist_items_in_tx(tx) .await .map_err(|error| format!("failed to fetch watchlist items: {error}"))?; let active_count = items.iter().filter(|item| item.status == "active").count(); let paused_count = items.iter().filter(|item| item.status == "paused").count(); let high_priority_count = items.iter().filter(|item| item.priority == "high").count(); let watchlist_summary = format!( "## 观察池概览\n\n- 当前观察标的 {} 个,其中 active {} 个、paused {} 个。\n- 高优先级标的 {} 个。\n- 本周重点复核价格触发、成交量变化和最新备注。", items.len(), active_count, paused_count, high_priority_count ); let watchlist_table = watchlist_report_table(&items); let next_actions = "## 下一步动作\n\n- 对 high 优先级标的补充人工复核记录。\n- 检查触发价附近标的是否需要调整观察价。\n- 将异常价格变化同步到预警模块。"; let content = template .body_template .replace("{{month}}", target_month) .replace("{{watchlist_summary}}", &watchlist_summary) .replace("{{watchlist_table}}", &watchlist_table) .replace("{{next_actions}}", next_actions); Ok(CreateReport { report_type: template.report_type.clone(), title: format!("上海房市观察池周报 {target_month}"), target_month: Some(target_month.to_string()), area_id: None, neighborhood_id: None, summary: Some(format!( "观察标的 {} 个,高优先级 {} 个。", items.len(), high_priority_count )), content_markdown: content, linked_metrics: Some(serde_json::json!({ "template_key": template.template_key, "month": target_month, "watchlist_count": items.len(), "active_count": active_count, "high_priority_count": high_priority_count, "parameters": parameters })), status: Some("published".to_string()), }) } async fn fetch_area_scores_in_tx( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, 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(&mut **tx) .await } async fn fetch_watchlist_items_in_tx( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, ) -> Result, sqlx::Error> { sqlx::query_as::<_, WatchlistItem>( r#" SELECT watchlist_item_id, neighborhood_id, area_id, target_price_psm, status, label, priority, trigger_operator, trigger_price_psm, notes, created_at::text AS created_at, updated_at::text AS updated_at, last_reviewed_at::text AS last_reviewed_at FROM app.watchlist_items WHERE status <> 'archived' ORDER BY priority DESC, updated_at DESC, watchlist_item_id DESC LIMIT 100 "#, ) .fetch_all(&mut **tx) .await } fn monthly_area_table(scores: &[AreaScore]) -> String { let mut lines = vec![ "## 板块评分".to_string(), String::new(), "| 排名 | 板块 | 行政区 | 综合分 | 建议 | 成交均价/㎡ | 成交套数 | 供应风险 |".to_string(), "| --- | --- | --- | ---: | --- | ---: | ---: | ---: |".to_string(), ]; for (index, score) in scores.iter().enumerate() { lines.push(format!( "| {} | {} | {} | {} | {} | {:.0} | {} | {} |", index + 1, score.name, score.district, format_score(score.investment_score), score.recommendation, score.transaction_price_psm, score.transaction_count, format_score(score.supply_risk_score) )); } lines.join("\n") } fn watchlist_report_table(items: &[WatchlistItem]) -> String { let mut lines = vec![ "## 观察池明细".to_string(), String::new(), "| ID | 类型 | 状态 | 优先级 | 目标价/㎡ | 触发价/㎡ | 标签 |".to_string(), "| ---: | --- | --- | --- | ---: | ---: | --- |".to_string(), ]; for item in items.iter().take(30) { lines.push(format!( "| {} | {} | {} | {} | {} | {} | {} |", item.watchlist_item_id, item.neighborhood_id .as_deref() .or(item.area_id.as_deref()) .unwrap_or("-"), item.status, item.priority, format_optional_price(item.target_price_psm), format_optional_price(item.trigger_price_psm), item.label )); } lines.join("\n") } fn format_score(value: f64) -> String { format!("{:.1}", round_to(value, 1)) } fn format_optional_price(value: Option) -> String { value .map(|item| format!("{:.0}", item)) .unwrap_or_else(|| "-".to_string()) } fn validate_report_query(query: &ReportQuery) -> ApiResult<()> { if let Some(report_type) = query.report_type.as_deref() { validate_report_type(report_type)?; } if let Some(status) = query.status.as_deref() { validate_report_status(status)?; } if let Some(month) = query.target_month.as_deref() { validate_month(month)?; } if let Some(area_id) = query.area_id.as_deref() { validate_identifier(area_id)?; } if let Some(neighborhood_id) = query.neighborhood_id.as_deref() { validate_identifier(neighborhood_id)?; } Ok(()) } fn validate_create_report(payload: &CreateReport) -> ApiResult<()> { payload .validate() .map_err(|message| ApiError::BadRequest(message.to_string()))?; validate_report_type(&payload.report_type)?; if let Some(status) = payload.status.as_deref() { validate_report_status(status)?; } if let Some(month) = payload.target_month.as_deref() { validate_month(month)?; } if let Some(area_id) = payload.area_id.as_deref() { validate_identifier(area_id)?; } if let Some(neighborhood_id) = payload.neighborhood_id.as_deref() { validate_identifier(neighborhood_id)?; } Ok(()) } fn validate_report_generation_query(query: &ReportGenerationRunQuery) -> ApiResult<()> { if let Some(month) = query.target_month.as_deref() { validate_month(month)?; } if let Some(template_key) = query.template_key.as_deref() { validate_identifier(template_key)?; } if let Some(status) = query.status.as_deref() { validate_generation_status(status)?; } Ok(()) } 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 } async fn build_scenario_variants( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, run: &ScenarioRun, ) -> Result, String> { let current_rows = fetch_area_metric_rows(tx, &run.target_month) .await .map_err(|error| format!("failed to fetch current area metrics: {error}"))?; if current_rows.is_empty() { return Err(format!( "no area metrics found for month {}", run.target_month )); } let target_rows = current_rows .iter() .filter(|row| { run.area_id .as_deref() .map(|area_id| row.area_id == area_id) .unwrap_or(true) }) .cloned() .collect::>(); if target_rows.is_empty() { return Err("no area metrics found for scenario area".to_string()); } let prior_month = previous_month(&run.target_month).ok_or_else(|| "invalid month".to_string())?; let prior_rows = fetch_area_metric_rows(tx, &prior_month) .await .map_err(|error| format!("failed to fetch prior area metrics: {error}"))?; let price_history = fetch_area_price_history(tx, &run.target_month) .await .map_err(|error| format!("failed to fetch area price history: {error}"))?; let baseline_scores = compute_area_scores(¤t_rows, &prior_rows, &price_history); Ok(vec![ build_scenario_variant( "downside", "谨慎", 1.35, &target_rows, &prior_rows, &price_history, &baseline_scores, run, ), build_scenario_variant( "base", "基准", 1.0, &target_rows, &prior_rows, &price_history, &baseline_scores, run, ), build_scenario_variant( "upside", "乐观", 0.65, &target_rows, &prior_rows, &price_history, &baseline_scores, run, ), ]) } fn build_scenario_variant( key: &str, name: &str, multiplier: f64, rows: &[AreaMetricRow], prior_rows: &[AreaMetricRow], price_history: &std::collections::HashMap>, baseline_scores: &[ComputedAreaScore], run: &ScenarioRun, ) -> ScenarioVariant { let adjusted_rows = rows .iter() .map(|row| adjusted_area_metric_row(row, run, multiplier)) .collect::>(); let projected_scores = compute_area_scores(&adjusted_rows, prior_rows, price_history); let baseline_by_area = baseline_scores .iter() .map(|score| (score.area_id.as_str(), score)) .collect::>(); let adjusted_by_area = adjusted_rows .iter() .map(|row| (row.area_id.as_str(), row)) .collect::>(); let items = projected_scores .into_iter() .filter_map(|projected| { let baseline = baseline_by_area.get(projected.area_id.as_str())?; let adjusted = adjusted_by_area.get(projected.area_id.as_str())?; Some(scenario_area_result( baseline, &projected, adjusted, run, multiplier, )) }) .collect::>(); ScenarioVariant { key: key.to_string(), name: name.to_string(), assumptions: scenario_assumptions(run, multiplier), items, } } fn adjusted_area_metric_row( row: &AreaMetricRow, run: &ScenarioRun, multiplier: f64, ) -> AreaMetricRow { let price_delta_pct = scenario_price_delta_pct(run, multiplier); let transaction_delta_pct = scenario_transaction_delta_pct(run, multiplier); let listing_delta_pct = (run.supply_delta_pct * multiplier + run.interest_rate_delta_bps.max(0.0) * 0.08 - (run.policy_signal_delta.max(0) as f64) * 3.0) .clamp(-60.0, 180.0); AreaMetricRow { transaction_count: adjusted_i32(row.transaction_count, transaction_delta_pct, 1), transaction_price_psm: adjusted_f64(row.transaction_price_psm, price_delta_pct, 1.0), listing_count: adjusted_i32(row.listing_count, listing_delta_pct, 0), listing_price_psm: adjusted_f64(row.listing_price_psm, price_delta_pct * 0.85, 1.0), median_days_on_market: adjusted_f64( row.median_days_on_market, -transaction_delta_pct * 0.45 + listing_delta_pct * 0.25, 1.0, ), rent_price_psm: adjusted_f64(row.rent_price_psm, run.rent_delta_pct * multiplier, 0.1), new_supply_units: adjusted_i32(row.new_supply_units, run.supply_delta_pct * multiplier, 0), mortgage_rate_pct: (row.mortgage_rate_pct + run.interest_rate_delta_bps * multiplier / 100.0) .max(0.0), policy_signal: (row.policy_signal + ((run.policy_signal_delta as f64) * multiplier).round() as i32) .clamp(-2, 2), ..row.clone() } } fn scenario_area_result( baseline: &ComputedAreaScore, projected: &ComputedAreaScore, adjusted: &AreaMetricRow, run: &ScenarioRun, multiplier: f64, ) -> ScenarioAreaResult { ScenarioAreaResult { area_id: projected.area_id.clone(), name: projected.name.clone(), district: projected.district.clone(), baseline_score: baseline.investment_score, projected_score: projected.investment_score, score_delta: round_to(projected.investment_score - baseline.investment_score, 1), baseline_price_psm: baseline.transaction_price_psm, projected_price_psm: projected.transaction_price_psm, price_delta_pct: round_to( safe_percentage( projected.transaction_price_psm - baseline.transaction_price_psm, baseline.transaction_price_psm, ), 1, ), projected_rent_yield_pct: projected.annual_rent_yield_pct, projected_liquidity_score: projected.liquidity_score, projected_supply_risk_score: projected.supply_risk_score, recommendation: projected.recommendation.clone(), risk_notes: scenario_risk_notes(projected, adjusted, run, multiplier), } } fn scenario_assumptions(run: &ScenarioRun, multiplier: f64) -> Vec { vec![ format!("利率变动 {:.0}bp", run.interest_rate_delta_bps * multiplier), format!( "政策信号 {}", ((run.policy_signal_delta as f64) * multiplier).round() as i32 ), format!("新增供应 {:.1}%", run.supply_delta_pct * multiplier), format!("租金变化 {:.1}%", run.rent_delta_pct * multiplier), format!("价格冲击 {:.1}%", scenario_price_delta_pct(run, multiplier)), ] } fn scenario_risk_notes( projected: &ComputedAreaScore, adjusted: &AreaMetricRow, run: &ScenarioRun, multiplier: f64, ) -> Vec { let mut notes = Vec::new(); if projected.supply_risk_score >= 70.0 { notes.push("供应压力偏高,需跟踪新增挂牌与去化速度".to_string()); } if projected.liquidity_score < 45.0 { notes.push("流动性承压,议价周期可能拉长".to_string()); } if projected.annual_rent_yield_pct < 1.7 { notes.push("租金收益率偏低,安全边际不足".to_string()); } if adjusted.mortgage_rate_pct >= 4.5 { notes.push("按揭利率处于偏紧区间,购买力敏感".to_string()); } if run.policy_signal_delta < 0 && multiplier >= 1.0 { notes.push("政策假设偏谨慎,估值修复可能延后".to_string()); } if notes.is_empty() { notes.push("核心风险未显著恶化,适合继续跟踪量价验证".to_string()); } notes } fn scenario_price_delta_pct(run: &ScenarioRun, multiplier: f64) -> f64 { let rate_effect = -0.018 * run.interest_rate_delta_bps; let policy_effect = run.policy_signal_delta as f64 * 1.35; let supply_effect = -0.035 * run.supply_delta_pct; let rent_effect = 0.08 * run.rent_delta_pct; ((rate_effect + policy_effect + supply_effect + rent_effect) * multiplier).clamp(-35.0, 35.0) } fn scenario_transaction_delta_pct(run: &ScenarioRun, multiplier: f64) -> f64 { let rate_effect = -0.045 * run.interest_rate_delta_bps; let policy_effect = run.policy_signal_delta as f64 * 4.5; let supply_effect = 0.08 * run.supply_delta_pct; let rent_effect = 0.04 * run.rent_delta_pct; ((rate_effect + policy_effect + supply_effect + rent_effect) * multiplier).clamp(-70.0, 160.0) } fn adjusted_f64(value: f64, pct_delta: f64, minimum: f64) -> f64 { (value * (1.0 + pct_delta / 100.0)).max(minimum) } fn adjusted_i32(value: i32, pct_delta: f64, minimum: i32) -> i32 { ((value as f64) * (1.0 + pct_delta / 100.0)) .round() .max(minimum as f64) as i32 } async fn recompute_area_scores_in_tx( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, month: &str, model_version: &str, model_run_id: i64, ) -> Result { let current_rows = fetch_area_metric_rows(tx, month) .await .map_err(|error| format!("failed to fetch current area metrics: {error}"))?; if current_rows.is_empty() { return Err(format!("no area metrics found for month {month}")); } let prior_month = previous_month(month).ok_or_else(|| "invalid month".to_string())?; let prior_rows = fetch_area_metric_rows(tx, &prior_month) .await .map_err(|error| format!("failed to fetch prior area metrics: {error}"))?; let price_history = fetch_area_price_history(tx, month) .await .map_err(|error| format!("failed to fetch area price history: {error}"))?; let scores = compute_area_scores(¤t_rows, &prior_rows, &price_history); for score in &scores { upsert_area_score(tx, score, model_version, model_run_id) .await .map_err(|error| format!("failed to upsert area score {}: {error}", score.area_id))?; } Ok(scores.len() as i32) } async fn fetch_area_metric_rows( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, month: &str, ) -> Result, sqlx::Error> { sqlx::query_as::<_, AreaMetricRow>( r#" SELECT a.area_id, a.name, a.district, a.segment, m.month, m.transaction_count, m.transaction_price_psm, m.listing_count, m.listing_price_psm, m.median_days_on_market, m.rent_price_psm, m.new_supply_units, m.mortgage_rate_pct, m.policy_signal FROM silver.area_monthly_metrics m JOIN silver.areas a ON a.area_id = m.area_id WHERE m.month = $1 ORDER BY a.district, a.name "#, ) .bind(month) .fetch_all(&mut **tx) .await } async fn fetch_area_price_history( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, through_month: &str, ) -> Result>, sqlx::Error> { let rows = sqlx::query_as::<_, (String, f64)>( r#" SELECT area_id, transaction_price_psm FROM silver.area_monthly_metrics WHERE month <= $1 ORDER BY area_id, month "#, ) .bind(through_month) .fetch_all(&mut **tx) .await?; let mut history = std::collections::HashMap::>::new(); for (area_id, price) in rows { history.entry(area_id).or_default().push(price); } Ok(history) } async fn upsert_area_score( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, score: &ComputedAreaScore, model_version: &str, model_run_id: i64, ) -> Result<(), sqlx::Error> { sqlx::query( r#" INSERT INTO gold.area_scores ( 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, model_version, model_run_id ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27 ) ON CONFLICT (area_id, month) DO UPDATE SET name = EXCLUDED.name, district = EXCLUDED.district, segment = EXCLUDED.segment, investment_score = EXCLUDED.investment_score, recommendation = EXCLUDED.recommendation, liquidity_score = EXCLUDED.liquidity_score, momentum_score = EXCLUDED.momentum_score, rent_support_score = EXCLUDED.rent_support_score, safety_margin_score = EXCLUDED.safety_margin_score, credit_support_score = EXCLUDED.credit_support_score, supply_risk_score = EXCLUDED.supply_risk_score, valuation_pressure_score = EXCLUDED.valuation_pressure_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, listing_pressure_ratio = EXCLUDED.listing_pressure_ratio, discount_pct = EXCLUDED.discount_pct, price_momentum_pct = EXCLUDED.price_momentum_pct, volume_momentum_pct = EXCLUDED.volume_momentum_pct, model_version = EXCLUDED.model_version, model_run_id = EXCLUDED.model_run_id, computed_at = now() "#, ) .bind(&score.area_id) .bind(&score.name) .bind(&score.district) .bind(&score.segment) .bind(&score.month) .bind(score.investment_score) .bind(&score.recommendation) .bind(score.liquidity_score) .bind(score.momentum_score) .bind(score.rent_support_score) .bind(score.safety_margin_score) .bind(score.credit_support_score) .bind(score.supply_risk_score) .bind(score.valuation_pressure_score) .bind(score.transaction_count) .bind(score.transaction_price_psm) .bind(score.listing_count) .bind(score.listing_price_psm) .bind(score.rent_price_psm) .bind(score.median_days_on_market) .bind(score.annual_rent_yield_pct) .bind(score.listing_pressure_ratio) .bind(score.discount_pct) .bind(score.price_momentum_pct) .bind(score.volume_momentum_pct) .bind(model_version) .bind(model_run_id) .execute(&mut **tx) .await?; sqlx::query( r#" INSERT INTO gold.area_score_snapshots ( model_run_id, 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, model_version ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27 ) ON CONFLICT (model_run_id, area_id) DO UPDATE SET name = EXCLUDED.name, district = EXCLUDED.district, segment = EXCLUDED.segment, investment_score = EXCLUDED.investment_score, recommendation = EXCLUDED.recommendation, liquidity_score = EXCLUDED.liquidity_score, momentum_score = EXCLUDED.momentum_score, rent_support_score = EXCLUDED.rent_support_score, safety_margin_score = EXCLUDED.safety_margin_score, credit_support_score = EXCLUDED.credit_support_score, supply_risk_score = EXCLUDED.supply_risk_score, valuation_pressure_score = EXCLUDED.valuation_pressure_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, listing_pressure_ratio = EXCLUDED.listing_pressure_ratio, discount_pct = EXCLUDED.discount_pct, price_momentum_pct = EXCLUDED.price_momentum_pct, volume_momentum_pct = EXCLUDED.volume_momentum_pct, model_version = EXCLUDED.model_version, computed_at = now() "#, ) .bind(model_run_id) .bind(&score.area_id) .bind(&score.name) .bind(&score.district) .bind(&score.segment) .bind(&score.month) .bind(score.investment_score) .bind(&score.recommendation) .bind(score.liquidity_score) .bind(score.momentum_score) .bind(score.rent_support_score) .bind(score.safety_margin_score) .bind(score.credit_support_score) .bind(score.supply_risk_score) .bind(score.valuation_pressure_score) .bind(score.transaction_count) .bind(score.transaction_price_psm) .bind(score.listing_count) .bind(score.listing_price_psm) .bind(score.rent_price_psm) .bind(score.median_days_on_market) .bind(score.annual_rent_yield_pct) .bind(score.listing_pressure_ratio) .bind(score.discount_pct) .bind(score.price_momentum_pct) .bind(score.volume_momentum_pct) .bind(model_version) .execute(&mut **tx) .await?; Ok(()) } async fn finish_model_run( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, model_run_id: i64, status: &str, row_count: Option, error_message: Option<&str>, ) -> Result { sqlx::query_as::<_, ModelRun>( r#" UPDATE gold.model_runs SET status = $2, finished_at = now(), row_count = COALESCE($3, row_count), error_message = $4 WHERE model_run_id = $1 RETURNING model_run_id, model_name, model_version, target_month, status, parameters, started_at::text AS started_at, finished_at::text AS finished_at, row_count, error_message "#, ) .bind(model_run_id) .bind(status) .bind(row_count) .bind(error_message) .fetch_one(&mut **tx) .await } async fn fetch_model_run( state: &AppState, model_run_id: i64, ) -> Result, sqlx::Error> { sqlx::query_as::<_, ModelRun>( r#" SELECT model_run_id, model_name, model_version, target_month, status, parameters, started_at::text AS started_at, finished_at::text AS finished_at, row_count, error_message FROM gold.model_runs WHERE model_run_id = $1 "#, ) .bind(model_run_id) .fetch_optional(&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 diagnostic_metric( key: &str, label: &str, unit: &str, current_value: f64, values: &[f64], ) -> DiagnosticMetric { let sample_size = values.len(); let historical_min = values.iter().copied().fold(f64::INFINITY, f64::min); let historical_max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max); let percentile = percentile_rank(current_value, values); let anomaly = sample_size >= 3 && !(10.0..=90.0).contains(&percentile); let anomaly_direction = if !anomaly { None } else if percentile > 90.0 { Some("high".to_string()) } else if percentile < 10.0 { Some("low".to_string()) } else { None }; let severity = if sample_size < 3 { "insufficient_history" } else if !(5.0..=95.0).contains(&percentile) { "high" } else if anomaly { "medium" } else { "normal" }; DiagnosticMetric { key: key.to_string(), label: label.to_string(), unit: unit.to_string(), current_value: round_to(current_value, 2), percentile: round_to(percentile, 1), historical_min: round_to(historical_min, 2), historical_max: round_to(historical_max, 2), sample_size, anomaly, anomaly_direction, severity: severity.to_string(), } } fn percentile_rank(current: f64, values: &[f64]) -> f64 { if values.is_empty() { return 50.0; } let below_or_equal = values.iter().filter(|value| **value <= current).count(); below_or_equal as f64 / values.len() as f64 * 100.0 } fn safe_ratio(numerator: f64, denominator: f64) -> f64 { if denominator == 0.0 { 0.0 } else { numerator / denominator } } fn safe_percentage(numerator: f64, denominator: f64) -> f64 { safe_ratio(numerator, denominator) * 100.0 } fn round_to(value: f64, decimals: i32) -> f64 { let factor = 10_f64.powi(decimals); (value * factor).round() / factor } fn parse_compare_ids(ids: &str) -> ApiResult> { let mut parsed = Vec::new(); for id in ids.split(',').map(str::trim).filter(|id| !id.is_empty()) { if has_sensitive_text(id) || id.len() > 80 { return Err(ApiError::BadRequest("invalid comparison id".to_string())); } if !parsed.iter().any(|existing| existing == id) { parsed.push(id.to_string()); } } if !(2..=4).contains(&parsed.len()) { return Err(ApiError::BadRequest( "ids must include 2 to 4 unique values".to_string(), )); } Ok(parsed) } fn sort_by_requested_order(mut items: Vec, requested_ids: &[String], id_fn: F) -> Vec where F: Fn(&T) -> &str, { items.sort_by_key(|item| { requested_ids .iter() .position(|id| id == id_fn(item)) .unwrap_or(usize::MAX) }); items } fn missing_ids(requested_ids: &[String], items: &[T], id_fn: F) -> Vec where F: Fn(&T) -> &str, { requested_ids .iter() .filter(|id| !items.iter().any(|item| id_fn(item) == id.as_str())) .cloned() .collect() } fn area_comparison_metrics(items: &[AreaScore]) -> Vec { vec![ comparison_metric( "investment_score", "综合分", "分", "higher", items, |item| Some(item.investment_score), ), comparison_metric( "transaction_price_psm", "成交均价/㎡", "元/㎡", "neutral", items, |item| Some(item.transaction_price_psm), ), comparison_metric( "transaction_count", "成交套数", "套", "higher", items, |item| Some(item.transaction_count as f64), ), comparison_metric( "liquidity_score", "流动性", "分", "higher", items, |item| Some(item.liquidity_score), ), comparison_metric( "rent_support_score", "租金支撑", "分", "higher", items, |item| Some(item.rent_support_score), ), comparison_metric( "annual_rent_yield_pct", "租金收益率", "%", "higher", items, |item| Some(item.annual_rent_yield_pct), ), comparison_metric( "supply_risk_score", "供应风险", "分", "lower", items, |item| Some(item.supply_risk_score), ), comparison_metric( "valuation_pressure_score", "估值压力", "分", "lower", items, |item| Some(item.valuation_pressure_score), ), ] } fn neighborhood_comparison_metrics(items: &[NeighborhoodComparisonItem]) -> Vec { vec![ comparison_metric( "investment_score", "综合分", "分", "higher", items, |item| Some(item.investment_score), ), comparison_metric( "transaction_price_psm", "成交均价/㎡", "元/㎡", "neutral", items, |item| Some(item.transaction_price_psm), ), comparison_metric( "transaction_count", "成交套数", "套", "higher", items, |item| Some(item.transaction_count as f64), ), comparison_metric( "liquidity_score", "流动性", "分", "higher", items, |item| Some(item.liquidity_score), ), comparison_metric( "rent_support_score", "租金支撑", "分", "higher", items, |item| Some(item.rent_support_score), ), comparison_metric( "building_age_score", "楼龄", "分", "higher", items, |item| Some(item.building_age_score), ), comparison_metric( "metro_distance_m", "地铁距离", "米", "lower", items, |item| item.metro_distance_m.map(|value| value as f64), ), comparison_metric( "available_units", "可售套数", "套", "neutral", items, |item| Some(item.available_units as f64), ), ] } fn comparison_metric( key: &str, label: &str, unit: &str, direction: &str, items: &[T], value_fn: F, ) -> ComparisonMetric where F: Fn(&T) -> Option, T: ComparisonId, { ComparisonMetric { key: key.to_string(), label: label.to_string(), unit: unit.to_string(), direction: direction.to_string(), values: items .iter() .map(|item| ComparisonMetricValue { id: item.comparison_id().to_string(), value: value_fn(item), }) .collect(), } } trait ComparisonId { fn comparison_id(&self) -> &str; } impl ComparisonId for AreaScore { fn comparison_id(&self) -> &str { &self.area_id } } impl ComparisonId for NeighborhoodComparisonItem { fn comparison_id(&self) -> &str { &self.neighborhood_id } } 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(), )) } } fn validate_sha256(value: &str) -> ApiResult<()> { let valid = value.len() == 64 && value .chars() .all(|item| item.is_ascii_hexdigit() && !item.is_ascii_uppercase()); if valid { Ok(()) } else { Err(ApiError::BadRequest( "sha256 must be a lowercase 64-character hex digest".to_string(), )) } } fn validate_identifier(value: &str) -> ApiResult<()> { let valid = !value.is_empty() && value.len() <= 80 && value .chars() .all(|item| item.is_ascii_alphanumeric() || item == '_' || item == '-') && !has_sensitive_text(value); if valid { Ok(()) } else { Err(ApiError::BadRequest("invalid identifier".to_string())) } } fn validate_report_type(value: &str) -> ApiResult<()> { if matches!( value, "monthly" | "weekly" | "area_special" | "neighborhood_memo" ) { Ok(()) } else { Err(ApiError::BadRequest( "report_type must be monthly, weekly, area_special, or neighborhood_memo".to_string(), )) } } fn validate_report_status(value: &str) -> ApiResult<()> { if matches!(value, "draft" | "published" | "archived") { Ok(()) } else { Err(ApiError::BadRequest( "status must be draft, published, or archived".to_string(), )) } } fn validate_generation_status(value: &str) -> ApiResult<()> { if matches!(value, "running" | "succeeded" | "failed") { Ok(()) } else { Err(ApiError::BadRequest( "status must be running, succeeded, or failed".to_string(), )) } } fn has_sensitive_text(value: &str) -> bool { let lower = value.to_ascii_lowercase(); lower.contains("password") || lower.contains("secret") || lower.contains("postgres://") || lower.contains("root_") || lower.contains("database_url") || value.starts_with("/Users/") || value.starts_with("/private/") } #[cfg(test)] mod tests { use crate::models::{ CreateDataSource, CreateIngestionRun, CreateRawArtifact, CreateReport, CreateReportGenerationRun, CreateScenarioRun, CreateWatchlistItem, FinishIngestionRun, ScenarioRun, SimilarNeighborhoodCandidate, UpdateWatchlistItem, }; use super::{ parse_compare_ids, scenario_assumptions, scenario_price_delta_pct, scenario_transaction_delta_pct, similar_neighborhood, validate_month, validate_sha256, }; #[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, label: None, priority: None, trigger_operator: None, trigger_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), label: Some("核心".to_string()), priority: Some("high".to_string()), trigger_operator: Some("lte".to_string()), trigger_price_psm: Some(76_000.0), notes: None, } .validate() .is_ok()); } #[test] fn validates_watchlist_update_payload() { assert!(UpdateWatchlistItem { target_price_psm: Some(-1.0), status: None, label: None, priority: None, trigger_operator: None, trigger_price_psm: None, notes: None, mark_reviewed: None, } .validate() .is_err()); assert!(UpdateWatchlistItem { target_price_psm: None, status: Some("unknown".to_string()), label: None, priority: None, trigger_operator: None, trigger_price_psm: None, notes: None, mark_reviewed: None, } .validate() .is_err()); assert!(UpdateWatchlistItem { target_price_psm: Some(80_000.0), status: Some("active".to_string()), label: Some("核心".to_string()), priority: Some("high".to_string()), trigger_operator: Some("lte".to_string()), trigger_price_psm: Some(76_000.0), notes: Some("跟踪产业兑现和挂牌变化".to_string()), mark_reviewed: Some(true), } .validate() .is_ok()); } #[test] fn validates_data_source_payload() { assert!(CreateDataSource { name: "".to_string(), source_type: "official".to_string(), url: None, cadence: None, reliability: None, notes: None, } .validate() .is_err()); assert!(CreateDataSource { name: "上海市统计局".to_string(), source_type: "official".to_string(), url: Some("https://tjj.sh.gov.cn/".to_string()), cadence: Some("monthly".to_string()), reliability: Some("high".to_string()), notes: None, } .validate() .is_ok()); assert!(CreateDataSource { name: "unsafe".to_string(), source_type: "manual".to_string(), url: None, cadence: None, reliability: None, notes: Some("contains password marker".to_string()), } .validate() .is_err()); } #[test] fn validates_ingestion_run_payloads() { assert!(CreateIngestionRun { source_id: Some(1), status: Some("running".to_string()), raw_uri: Some("raw/official/2026-05.csv".to_string()), raw_artifact_id: None, row_count: Some(10), error_message: None, } .validate() .is_ok()); assert!(CreateIngestionRun { source_id: Some(1), status: Some("unknown".to_string()), raw_uri: None, raw_artifact_id: None, row_count: None, error_message: None, } .validate() .is_err()); assert!(FinishIngestionRun { status: "succeeded".to_string(), row_count: Some(10), error_message: None, } .validate() .is_ok()); assert!(FinishIngestionRun { status: "running".to_string(), row_count: None, error_message: None, } .validate() .is_err()); } #[test] fn validates_raw_artifact_payload() { assert!(CreateRawArtifact { run_id: None, source_id: None, original_filename: "area.csv".to_string(), raw_uri: "raw://sha256/area.csv".to_string(), sha256: "a".repeat(64), size_bytes: 128, mime_type: Some("text/csv".to_string()), uploaded_by: Some("researcher".to_string()), notes: None, } .validate() .is_ok()); assert!(CreateRawArtifact { run_id: None, source_id: None, original_filename: "area.csv".to_string(), raw_uri: "raw://sha256/area.csv".to_string(), sha256: "A".repeat(64), size_bytes: 128, mime_type: None, uploaded_by: None, notes: None, } .validate() .is_err()); } #[test] fn validates_report_payload() { assert!(CreateReport { report_type: "monthly".to_string(), title: "2026-05 月报".to_string(), target_month: Some("2026-05".to_string()), area_id: None, neighborhood_id: None, summary: Some("核心结论".to_string()), content_markdown: "# 月报\n\n正文".to_string(), linked_metrics: Some(serde_json::json!({"month": "2026-05"})), status: Some("published".to_string()), } .validate() .is_ok()); assert!(CreateReport { report_type: "unknown".to_string(), title: "bad".to_string(), target_month: None, area_id: None, neighborhood_id: None, summary: None, content_markdown: "# bad".to_string(), linked_metrics: None, status: None, } .validate() .is_err()); assert!(CreateReport { report_type: "area_special".to_string(), title: "bad".to_string(), target_month: None, area_id: Some("zhangjiang".to_string()), neighborhood_id: None, summary: Some("contains password marker".to_string()), content_markdown: "# bad".to_string(), linked_metrics: None, status: None, } .validate() .is_err()); } #[test] fn validates_report_generation_payload() { assert!(CreateReportGenerationRun { template_key: "monthly_market".to_string(), target_month: "2026-05".to_string(), parameters: Some(serde_json::json!({"scope": "all"})), } .validate() .is_ok()); assert!(CreateReportGenerationRun { template_key: "".to_string(), target_month: "2026-05".to_string(), parameters: None, } .validate() .is_err()); assert!(CreateReportGenerationRun { template_key: "monthly_market".to_string(), target_month: "2026-05".to_string(), parameters: Some(serde_json::json!(["bad"])), } .validate() .is_err()); } #[test] fn validates_scenario_run_payload() { assert!(CreateScenarioRun { name: "利率上行压力测试".to_string(), target_month: "2026-05".to_string(), area_id: Some("zhangjiang".to_string()), interest_rate_delta_bps: 50.0, policy_signal_delta: -1, supply_delta_pct: 20.0, rent_delta_pct: 3.0, notes: Some("手工假设".to_string()), } .validate() .is_ok()); assert!(CreateScenarioRun { name: "bad".to_string(), target_month: "2026-05".to_string(), area_id: None, interest_rate_delta_bps: 500.0, policy_signal_delta: 0, supply_delta_pct: 0.0, rent_delta_pct: 0.0, notes: None, } .validate() .is_err()); } #[test] fn validates_sha256_query_value() { assert!(validate_sha256(&"0".repeat(64)).is_ok()); assert!(validate_sha256(&"G".repeat(64)).is_err()); assert!(validate_sha256("short").is_err()); } #[test] fn validates_compare_ids() { assert_eq!( parse_compare_ids("qiantan, zhangjiang,qiantan").unwrap(), vec!["qiantan".to_string(), "zhangjiang".to_string()] ); assert!(parse_compare_ids("qiantan").is_err()); assert!(parse_compare_ids("a,b,c,d,e").is_err()); assert!(parse_compare_ids("a,password,b").is_err()); } #[test] fn scores_similar_neighborhoods_with_explainable_price_delta() { let target = neighborhood_candidate("target", "zhangjiang", "浦东新区", 2020, 500, 89_000.0); let close_candidate = neighborhood_candidate("close", "zhangjiang", "浦东新区", 2018, 650, 86_000.0); let distant_candidate = neighborhood_candidate("distant", "hongqiao", "闵行区", 2002, 2400, 120_000.0); let close = similar_neighborhood(&target, close_candidate); let distant = similar_neighborhood(&target, distant_candidate); assert!(close.similarity_score > distant.similarity_score); assert_eq!(close.relative_price_pct, -3.4); assert!(close.reasons.contains(&"same_area".to_string())); assert!(close .reasons .iter() .any(|reason| reason.starts_with("price_gap_pct:"))); } fn neighborhood_candidate( neighborhood_id: &str, area_id: &str, district: &str, built_year: i32, metro_distance_m: i32, transaction_price_psm: f64, ) -> SimilarNeighborhoodCandidate { SimilarNeighborhoodCandidate { neighborhood_id: neighborhood_id.to_string(), area_id: area_id.to_string(), name: neighborhood_id.to_string(), area_name: area_id.to_string(), district: district.to_string(), built_year: Some(built_year), property_type: "商品住宅".to_string(), metro_distance_m: Some(metro_distance_m), school_quality: Some("normal".to_string()), month: "2026-05".to_string(), investment_score: 70.0, recommendation: "观察池".to_string(), transaction_price_psm, annual_rent_yield_pct: 2.0, liquidity_score: 80.0, location_score: 75.0, building_age_score: 85.0, } } #[test] fn scenario_shocks_have_expected_direction() { let run = ScenarioRun { scenario_run_id: 1, name: "压力测试".to_string(), target_month: "2026-05".to_string(), area_id: None, interest_rate_delta_bps: 50.0, policy_signal_delta: -1, supply_delta_pct: 30.0, rent_delta_pct: 5.0, notes: "".to_string(), created_at: "2026-06-24".to_string(), }; assert!(scenario_price_delta_pct(&run, 1.0) < 0.0); assert!(scenario_transaction_delta_pct(&run, 1.0) < 0.0); assert_eq!(scenario_assumptions(&run, 1.0).len(), 5); } }