feat: add watchlist api
This commit is contained in:
@@ -57,6 +57,10 @@ GET /health
|
|||||||
GET /ready
|
GET /ready
|
||||||
GET /api/v1/areas/scores?month=2026-05
|
GET /api/v1/areas/scores?month=2026-05
|
||||||
GET /api/v1/market/overview?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}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 研究目标
|
## 研究目标
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
use axum::extract::{Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::error::{ApiError, ApiResult};
|
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;
|
use crate::state::AppState;
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -67,6 +70,129 @@ pub async fn market_overview(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_watchlist_items(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> ApiResult<Json<Vec<WatchlistItem>>> {
|
||||||
|
let items = sqlx::query_as::<_, WatchlistItem>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
watchlist_item_id,
|
||||||
|
neighborhood_id,
|
||||||
|
area_id,
|
||||||
|
target_price_psm,
|
||||||
|
status,
|
||||||
|
notes
|
||||||
|
FROM app.watchlist_items
|
||||||
|
WHERE status <> 'archived'
|
||||||
|
ORDER BY updated_at DESC, watchlist_item_id DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_watchlist_item(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<CreateWatchlistItem>,
|
||||||
|
) -> ApiResult<Json<WatchlistItem>> {
|
||||||
|
payload
|
||||||
|
.validate()
|
||||||
|
.map_err(|message| ApiError::BadRequest(message.to_string()))?;
|
||||||
|
|
||||||
|
let item = sqlx::query_as::<_, WatchlistItem>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO app.watchlist_items (
|
||||||
|
neighborhood_id,
|
||||||
|
area_id,
|
||||||
|
target_price_psm,
|
||||||
|
notes
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, COALESCE($4, ''))
|
||||||
|
RETURNING
|
||||||
|
watchlist_item_id,
|
||||||
|
neighborhood_id,
|
||||||
|
area_id,
|
||||||
|
target_price_psm,
|
||||||
|
status,
|
||||||
|
notes
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(payload.neighborhood_id)
|
||||||
|
.bind(payload.area_id)
|
||||||
|
.bind(payload.target_price_psm)
|
||||||
|
.bind(payload.notes)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_watchlist_item(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(watchlist_item_id): Path<i64>,
|
||||||
|
Json(payload): Json<UpdateWatchlistItem>,
|
||||||
|
) -> ApiResult<Json<WatchlistItem>> {
|
||||||
|
payload
|
||||||
|
.validate()
|
||||||
|
.map_err(|message| ApiError::BadRequest(message.to_string()))?;
|
||||||
|
|
||||||
|
let item = sqlx::query_as::<_, WatchlistItem>(
|
||||||
|
r#"
|
||||||
|
UPDATE app.watchlist_items
|
||||||
|
SET target_price_psm = COALESCE($2, target_price_psm),
|
||||||
|
status = COALESCE($3, status),
|
||||||
|
notes = COALESCE($4, notes),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE watchlist_item_id = $1
|
||||||
|
RETURNING
|
||||||
|
watchlist_item_id,
|
||||||
|
neighborhood_id,
|
||||||
|
area_id,
|
||||||
|
target_price_psm,
|
||||||
|
status,
|
||||||
|
notes
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(watchlist_item_id)
|
||||||
|
.bind(payload.target_price_psm)
|
||||||
|
.bind(payload.status)
|
||||||
|
.bind(payload.notes)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::BadRequest("watchlist item not found".to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn archive_watchlist_item(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(watchlist_item_id): Path<i64>,
|
||||||
|
) -> ApiResult<Json<WatchlistItem>> {
|
||||||
|
let item = sqlx::query_as::<_, WatchlistItem>(
|
||||||
|
r#"
|
||||||
|
UPDATE app.watchlist_items
|
||||||
|
SET status = 'archived',
|
||||||
|
updated_at = now()
|
||||||
|
WHERE watchlist_item_id = $1
|
||||||
|
RETURNING
|
||||||
|
watchlist_item_id,
|
||||||
|
neighborhood_id,
|
||||||
|
area_id,
|
||||||
|
target_price_psm,
|
||||||
|
status,
|
||||||
|
notes
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(watchlist_item_id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::BadRequest("watchlist item not found".to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(item))
|
||||||
|
}
|
||||||
|
|
||||||
async fn fetch_area_scores(state: &AppState, month: &str) -> Result<Vec<AreaScore>, sqlx::Error> {
|
async fn fetch_area_scores(state: &AppState, month: &str) -> Result<Vec<AreaScore>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, AreaScore>(
|
sqlx::query_as::<_, AreaScore>(
|
||||||
r#"
|
r#"
|
||||||
@@ -135,6 +261,8 @@ fn validate_month(month: &str) -> ApiResult<()> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use crate::models::{CreateWatchlistItem, UpdateWatchlistItem};
|
||||||
|
|
||||||
use super::validate_month;
|
use super::validate_month;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -148,4 +276,52 @@ mod tests {
|
|||||||
assert!(validate_month("2026-13").is_err());
|
assert!(validate_month("2026-13").is_err());
|
||||||
assert!(validate_month("abcd-05").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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,3 +66,54 @@ pub struct MarketOverview {
|
|||||||
pub weakest_area: Option<AreaScoreSummary>,
|
pub weakest_area: Option<AreaScoreSummary>,
|
||||||
pub highest_supply_risk_area: Option<AreaScoreSummary>,
|
pub highest_supply_risk_area: Option<AreaScoreSummary>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||||
|
pub struct WatchlistItem {
|
||||||
|
pub watchlist_item_id: i64,
|
||||||
|
pub neighborhood_id: Option<String>,
|
||||||
|
pub area_id: Option<String>,
|
||||||
|
pub target_price_psm: Option<f64>,
|
||||||
|
pub status: String,
|
||||||
|
pub notes: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateWatchlistItem {
|
||||||
|
pub neighborhood_id: Option<String>,
|
||||||
|
pub area_id: Option<String>,
|
||||||
|
pub target_price_psm: Option<f64>,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<f64>,
|
||||||
|
pub status: Option<String>,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
use axum::routing::get;
|
use axum::routing::{get, patch};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use tower_http::cors::CorsLayer;
|
use tower_http::cors::CorsLayer;
|
||||||
use tower_http::trace::TraceLayer;
|
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;
|
use crate::state::AppState;
|
||||||
|
|
||||||
pub fn build_router(state: AppState) -> Router {
|
pub fn build_router(state: AppState) -> Router {
|
||||||
@@ -14,7 +17,15 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
"/api/v1",
|
"/api/v1",
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/areas/scores", get(list_area_scores))
|
.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(TraceLayer::new_for_http())
|
||||||
.layer(CorsLayer::permissive())
|
.layer(CorsLayer::permissive())
|
||||||
|
|||||||
Reference in New Issue
Block a user