From 154af21bfb541c4e87811d76afb1c2a8a05f8eb3 Mon Sep 17 00:00:00 2001 From: shay7sev Date: Tue, 23 Jun 2026 09:47:40 +0800 Subject: [PATCH] feat: add watchlist api --- README.md | 4 + apps/api/src/handlers.rs | 180 ++++++++++++++++++++++++++++++++++++++- apps/api/src/models.rs | 51 +++++++++++ apps/api/src/routes.rs | 17 +++- 4 files changed, 247 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2f503d2..0a78a6d 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,10 @@ GET /health GET /ready GET /api/v1/areas/scores?month=2026-05 GET /api/v1/market/overview?month=2026-05 +GET /api/v1/watchlist +POST /api/v1/watchlist +PATCH /api/v1/watchlist/{watchlist_item_id} +DELETE /api/v1/watchlist/{watchlist_item_id} ``` ## 研究目标 diff --git a/apps/api/src/handlers.rs b/apps/api/src/handlers.rs index ffb4ae5..32bbf36 100644 --- a/apps/api/src/handlers.rs +++ b/apps/api/src/handlers.rs @@ -1,9 +1,12 @@ -use axum::extract::{Query, State}; +use axum::extract::{Path, Query, State}; use axum::Json; use serde::Serialize; use crate::error::{ApiError, ApiResult}; -use crate::models::{AreaScore, AreaScoreSummary, MarketOverview, MonthQuery}; +use crate::models::{ + AreaScore, AreaScoreSummary, CreateWatchlistItem, MarketOverview, MonthQuery, + UpdateWatchlistItem, WatchlistItem, +}; use crate::state::AppState; #[derive(Debug, Serialize)] @@ -67,6 +70,129 @@ pub async fn market_overview( })) } +pub async fn list_watchlist_items( + State(state): State, +) -> ApiResult>> { + let items = sqlx::query_as::<_, WatchlistItem>( + r#" + SELECT + watchlist_item_id, + neighborhood_id, + area_id, + target_price_psm, + status, + notes + FROM app.watchlist_items + WHERE status <> 'archived' + ORDER BY updated_at DESC, watchlist_item_id DESC + "#, + ) + .fetch_all(&state.pool) + .await?; + + Ok(Json(items)) +} + +pub async fn create_watchlist_item( + State(state): State, + Json(payload): Json, +) -> ApiResult> { + payload + .validate() + .map_err(|message| ApiError::BadRequest(message.to_string()))?; + + let item = sqlx::query_as::<_, WatchlistItem>( + r#" + INSERT INTO app.watchlist_items ( + neighborhood_id, + area_id, + target_price_psm, + notes + ) + VALUES ($1, $2, $3, COALESCE($4, '')) + RETURNING + watchlist_item_id, + neighborhood_id, + area_id, + target_price_psm, + status, + notes + "#, + ) + .bind(payload.neighborhood_id) + .bind(payload.area_id) + .bind(payload.target_price_psm) + .bind(payload.notes) + .fetch_one(&state.pool) + .await?; + + Ok(Json(item)) +} + +pub async fn update_watchlist_item( + State(state): State, + Path(watchlist_item_id): Path, + Json(payload): Json, +) -> ApiResult> { + payload + .validate() + .map_err(|message| ApiError::BadRequest(message.to_string()))?; + + let item = sqlx::query_as::<_, WatchlistItem>( + r#" + UPDATE app.watchlist_items + SET target_price_psm = COALESCE($2, target_price_psm), + status = COALESCE($3, status), + notes = COALESCE($4, notes), + updated_at = now() + WHERE watchlist_item_id = $1 + RETURNING + watchlist_item_id, + neighborhood_id, + area_id, + target_price_psm, + status, + notes + "#, + ) + .bind(watchlist_item_id) + .bind(payload.target_price_psm) + .bind(payload.status) + .bind(payload.notes) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| ApiError::BadRequest("watchlist item not found".to_string()))?; + + Ok(Json(item)) +} + +pub async fn archive_watchlist_item( + State(state): State, + Path(watchlist_item_id): Path, +) -> ApiResult> { + let item = sqlx::query_as::<_, WatchlistItem>( + r#" + UPDATE app.watchlist_items + SET status = 'archived', + updated_at = now() + WHERE watchlist_item_id = $1 + RETURNING + watchlist_item_id, + neighborhood_id, + area_id, + target_price_psm, + status, + notes + "#, + ) + .bind(watchlist_item_id) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| ApiError::BadRequest("watchlist item not found".to_string()))?; + + Ok(Json(item)) +} + async fn fetch_area_scores(state: &AppState, month: &str) -> Result, sqlx::Error> { sqlx::query_as::<_, AreaScore>( r#" @@ -135,6 +261,8 @@ fn validate_month(month: &str) -> ApiResult<()> { #[cfg(test)] mod tests { + use crate::models::{CreateWatchlistItem, UpdateWatchlistItem}; + use super::validate_month; #[test] @@ -148,4 +276,52 @@ mod tests { assert!(validate_month("2026-13").is_err()); assert!(validate_month("abcd-05").is_err()); } + + #[test] + fn validates_watchlist_create_payload() { + assert!(CreateWatchlistItem { + neighborhood_id: None, + area_id: None, + target_price_psm: None, + notes: None, + } + .validate() + .is_err()); + + assert!(CreateWatchlistItem { + neighborhood_id: None, + area_id: Some("zhangjiang".to_string()), + target_price_psm: Some(80_000.0), + notes: None, + } + .validate() + .is_ok()); + } + + #[test] + fn validates_watchlist_update_payload() { + assert!(UpdateWatchlistItem { + target_price_psm: Some(-1.0), + status: None, + notes: None, + } + .validate() + .is_err()); + + assert!(UpdateWatchlistItem { + target_price_psm: None, + status: Some("unknown".to_string()), + notes: None, + } + .validate() + .is_err()); + + assert!(UpdateWatchlistItem { + target_price_psm: Some(80_000.0), + status: Some("active".to_string()), + notes: Some("跟踪产业兑现和挂牌变化".to_string()), + } + .validate() + .is_ok()); + } } diff --git a/apps/api/src/models.rs b/apps/api/src/models.rs index ca849d0..2fd351f 100644 --- a/apps/api/src/models.rs +++ b/apps/api/src/models.rs @@ -66,3 +66,54 @@ pub struct MarketOverview { pub weakest_area: Option, pub highest_supply_risk_area: Option, } + +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct WatchlistItem { + pub watchlist_item_id: i64, + pub neighborhood_id: Option, + pub area_id: Option, + pub target_price_psm: Option, + pub status: String, + pub notes: String, +} + +#[derive(Debug, Deserialize)] +pub struct CreateWatchlistItem { + pub neighborhood_id: Option, + pub area_id: Option, + pub target_price_psm: Option, + pub notes: Option, +} + +impl CreateWatchlistItem { + pub fn validate(&self) -> Result<(), &'static str> { + if self.neighborhood_id.is_none() && self.area_id.is_none() { + return Err("either neighborhood_id or area_id is required"); + } + if matches!(self.target_price_psm, Some(value) if value < 0.0) { + return Err("target_price_psm must be non-negative"); + } + Ok(()) + } +} + +#[derive(Debug, Deserialize)] +pub struct UpdateWatchlistItem { + pub target_price_psm: Option, + pub status: Option, + pub notes: Option, +} + +impl UpdateWatchlistItem { + pub fn validate(&self) -> Result<(), &'static str> { + if matches!(self.target_price_psm, Some(value) if value < 0.0) { + return Err("target_price_psm must be non-negative"); + } + if let Some(status) = &self.status { + if !matches!(status.as_str(), "active" | "paused" | "archived") { + return Err("status must be active, paused, or archived"); + } + } + Ok(()) + } +} diff --git a/apps/api/src/routes.rs b/apps/api/src/routes.rs index 3791ce9..c54f1bf 100644 --- a/apps/api/src/routes.rs +++ b/apps/api/src/routes.rs @@ -1,9 +1,12 @@ -use axum::routing::get; +use axum::routing::{get, patch}; use axum::Router; use tower_http::cors::CorsLayer; use tower_http::trace::TraceLayer; -use crate::handlers::{health, list_area_scores, market_overview, ready}; +use crate::handlers::{ + archive_watchlist_item, create_watchlist_item, health, list_area_scores, list_watchlist_items, + market_overview, ready, update_watchlist_item, +}; use crate::state::AppState; pub fn build_router(state: AppState) -> Router { @@ -14,7 +17,15 @@ pub fn build_router(state: AppState) -> Router { "/api/v1", Router::new() .route("/areas/scores", get(list_area_scores)) - .route("/market/overview", get(market_overview)), + .route("/market/overview", get(market_overview)) + .route( + "/watchlist", + get(list_watchlist_items).post(create_watchlist_item), + ) + .route( + "/watchlist/{watchlist_item_id}", + patch(update_watchlist_item).delete(archive_watchlist_item), + ), ) .layer(TraceLayer::new_for_http()) .layer(CorsLayer::permissive())