feat: add comparison workspace

This commit is contained in:
2026-06-24 13:56:39 +08:00
parent ff6e41a6e5
commit f8fb09d2a9
6 changed files with 871 additions and 15 deletions

View File

@@ -5,14 +5,15 @@ use serde::Serialize;
use crate::error::{ApiError, ApiResult};
use crate::import_templates::{execute_import_payload, validate_import_payload};
use crate::models::{
AreaDetail, AreaMonthlyMetric, AreaProfile, AreaScore, AreaScoreLineage, AreaScoreLineageQuery,
AreaScoreModelRunResponse, AreaScoreSummary, CreateAreaScoreModelRun, CreateDataSource,
AreaComparison, AreaDetail, AreaMonthlyMetric, AreaProfile, AreaScore, AreaScoreLineage,
AreaScoreLineageQuery, AreaScoreModelRunResponse, AreaScoreSummary, CompareQuery,
ComparisonMetric, ComparisonMetricValue, CreateAreaScoreModelRun, CreateDataSource,
CreateIngestionRun, CreateRawArtifact, CreateWatchlistItem, DataSource, FinishIngestionRun,
ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest,
ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun,
MonthQuery, Neighborhood, NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery,
NeighborhoodScore, NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem,
WatchlistItem,
MonthQuery, Neighborhood, NeighborhoodComparison, NeighborhoodComparisonItem,
NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery, NeighborhoodScore,
NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, UpdateWatchlistItem, WatchlistItem,
};
use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore};
use crate::state::AppState;
@@ -133,6 +134,122 @@ pub async fn market_overview(
}))
}
pub async fn compare_areas(
State(state): State<AppState>,
Query(query): Query<CompareQuery>,
) -> ApiResult<Json<AreaComparison>> {
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<AppState>,
Query(query): Query<CompareQuery>,
) -> ApiResult<Json<NeighborhoodComparison>> {
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 create_area_score_model_run(
State(state): State<AppState>,
Json(payload): Json<CreateAreaScoreModelRun>,
@@ -1377,6 +1494,231 @@ fn average(values: impl Iterator<Item = f64>) -> f64 {
}
}
fn parse_compare_ids(ids: &str) -> ApiResult<Vec<String>> {
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<T, F>(mut items: Vec<T>, requested_ids: &[String], id_fn: F) -> Vec<T>
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<T, F>(requested_ids: &[String], items: &[T], id_fn: F) -> Vec<String>
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<ComparisonMetric> {
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<ComparisonMetric> {
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<T, F>(
key: &str,
label: &str,
unit: &str,
direction: &str,
items: &[T],
value_fn: F,
) -> ComparisonMetric
where
F: Fn(&T) -> Option<f64>,
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'-'
@@ -1426,7 +1768,7 @@ mod tests {
FinishIngestionRun, UpdateWatchlistItem,
};
use super::{validate_month, validate_sha256};
use super::{parse_compare_ids, validate_month, validate_sha256};
#[test]
fn accepts_valid_month() {
@@ -1602,4 +1944,15 @@ mod tests {
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());
}
}

View File

@@ -25,6 +25,12 @@ pub struct NeighborhoodScoreQuery {
pub area_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct CompareQuery {
pub month: String,
pub ids: String,
}
#[derive(Debug, Deserialize)]
pub struct IngestionRunQuery {
pub source_id: Option<i64>,
@@ -287,6 +293,69 @@ pub struct NeighborhoodScore {
pub available_units: i32,
}
#[derive(Debug, Serialize)]
pub struct ComparisonMetricValue {
pub id: String,
pub value: Option<f64>,
}
#[derive(Debug, Serialize)]
pub struct ComparisonMetric {
pub key: String,
pub label: String,
pub unit: String,
pub direction: String,
pub values: Vec<ComparisonMetricValue>,
}
#[derive(Debug, Serialize)]
pub struct AreaComparison {
pub month: String,
pub requested_ids: Vec<String>,
pub missing_ids: Vec<String>,
pub items: Vec<AreaScore>,
pub metrics: Vec<ComparisonMetric>,
}
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct NeighborhoodComparisonItem {
pub neighborhood_id: String,
pub area_id: String,
pub name: String,
pub area_name: String,
pub district: String,
pub built_year: Option<i32>,
pub property_type: String,
pub metro_distance_m: Option<i32>,
pub school_quality: Option<String>,
pub month: String,
pub investment_score: f64,
pub recommendation: String,
pub liquidity_score: f64,
pub rent_support_score: f64,
pub price_safety_score: f64,
pub location_score: f64,
pub building_age_score: f64,
pub transaction_count: i32,
pub transaction_price_psm: f64,
pub listing_count: i32,
pub listing_price_psm: f64,
pub rent_price_psm: f64,
pub median_days_on_market: f64,
pub annual_rent_yield_pct: f64,
pub discount_pct: f64,
pub available_units: i32,
}
#[derive(Debug, Serialize)]
pub struct NeighborhoodComparison {
pub month: String,
pub requested_ids: Vec<String>,
pub missing_ids: Vec<String>,
pub items: Vec<NeighborhoodComparisonItem>,
pub metrics: Vec<ComparisonMetric>,
}
#[derive(Debug, Serialize)]
pub struct NeighborhoodDetail {
pub profile: Neighborhood,

View File

@@ -4,12 +4,12 @@ use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
use crate::handlers::{
archive_watchlist_item, create_area_score_model_run, create_data_source, create_ingestion_run,
create_raw_artifact, create_watchlist_item, execute_import, finish_ingestion_run,
get_area_detail, get_area_score_lineage, get_neighborhood, get_neighborhood_detail, health,
list_area_scores, list_data_sources, list_ingestion_runs, list_neighborhood_scores,
list_neighborhoods, list_raw_artifacts, list_watchlist_items, market_overview, ready,
update_watchlist_item, validate_import,
archive_watchlist_item, compare_areas, compare_neighborhoods, create_area_score_model_run,
create_data_source, create_ingestion_run, create_raw_artifact, create_watchlist_item,
execute_import, finish_ingestion_run, get_area_detail, get_area_score_lineage,
get_neighborhood, get_neighborhood_detail, health, list_area_scores, list_data_sources,
list_ingestion_runs, list_neighborhood_scores, list_neighborhoods, list_raw_artifacts,
list_watchlist_items, market_overview, ready, update_watchlist_item, validate_import,
};
use crate::state::AppState;
@@ -39,6 +39,8 @@ pub fn build_router(state: AppState) -> Router {
)
.route("/imports/validate", axum::routing::post(validate_import))
.route("/imports/execute", axum::routing::post(execute_import))
.route("/compare/areas", get(compare_areas))
.route("/compare/neighborhoods", get(compare_neighborhoods))
.route("/market/overview", get(market_overview))
.route(
"/model-runs/area-scores",