From 049d6a711e339f4ebc8cdf68badc74ef3f04fc51 Mon Sep 17 00:00:00 2001 From: shay7sev Date: Wed, 24 Jun 2026 17:26:04 +0800 Subject: [PATCH] feat: add report center --- apps/api/migrations/202606240007_reports.sql | 28 ++ apps/api/src/handlers.rs | 255 ++++++++++- apps/api/src/models.rs | 92 ++++ apps/api/src/routes.rs | 19 +- .../components/dashboard/market-dashboard.tsx | 400 ++++++++++++++++++ apps/web/src/lib/api.ts | 59 +++ docs/product_roadmap.md | 9 +- 7 files changed, 841 insertions(+), 21 deletions(-) create mode 100644 apps/api/migrations/202606240007_reports.sql diff --git a/apps/api/migrations/202606240007_reports.sql b/apps/api/migrations/202606240007_reports.sql new file mode 100644 index 0000000..8f8f7de --- /dev/null +++ b/apps/api/migrations/202606240007_reports.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS app.reports ( + report_id BIGSERIAL PRIMARY KEY, + report_type TEXT NOT NULL CHECK (report_type IN ('monthly', 'area_special', 'neighborhood_memo')), + title TEXT NOT NULL, + target_month TEXT CHECK (target_month IS NULL OR target_month ~ '^[0-9]{4}-[0-9]{2}$'), + area_id TEXT REFERENCES silver.areas(area_id), + neighborhood_id TEXT REFERENCES silver.neighborhoods(neighborhood_id), + summary TEXT NOT NULL DEFAULT '', + content_markdown TEXT NOT NULL, + linked_metrics JSONB NOT NULL DEFAULT '{}'::jsonb, + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK (char_length(title) > 0), + CHECK (char_length(content_markdown) > 0) +); + +CREATE INDEX IF NOT EXISTS idx_reports_type_month + ON app.reports(report_type, target_month, report_id DESC); + +CREATE INDEX IF NOT EXISTS idx_reports_area + ON app.reports(area_id, report_id DESC); + +CREATE INDEX IF NOT EXISTS idx_reports_neighborhood + ON app.reports(neighborhood_id, report_id DESC); + +CREATE INDEX IF NOT EXISTS idx_reports_linked_metrics + ON app.reports USING GIN (linked_metrics); diff --git a/apps/api/src/handlers.rs b/apps/api/src/handlers.rs index 35e7d1b..8d37223 100644 --- a/apps/api/src/handlers.rs +++ b/apps/api/src/handlers.rs @@ -9,16 +9,16 @@ use crate::models::{ AreaProfile, AreaScore, AreaScoreLineage, AreaScoreLineageQuery, AreaScoreModelRunResponse, AreaScoreRunDiff, AreaScoreRunDiffItem, AreaScoreSummary, CompareQuery, ComparisonMetric, ComparisonMetricValue, CreateAreaScoreModelRun, CreateDataSource, CreateIngestionRun, - CreateRawArtifact, CreateScenarioRun, CreateWatchlistEvent, CreateWatchlistItem, DataSource, - DiagnosticMetric, FinishIngestionRun, ImportExecutionRequest, ImportExecutionResponse, - ImportValidationRequest, ImportValidationResponse, IngestionRun, IngestionRunQuery, - MarketOverview, ModelRun, ModelRunDiffQuery, ModelRunQuery, MonthQuery, Neighborhood, - NeighborhoodComparison, NeighborhoodComparisonItem, NeighborhoodDetail, + CreateRawArtifact, CreateReport, 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, ScenarioAreaResult, ScenarioRun, ScenarioRunQuery, - ScenarioRunResponse, ScenarioVariant, SimilarNeighborhood, SimilarNeighborhoodCandidate, - SimilarNeighborhoodQuery, SimilarNeighborhoodResponse, UpdateWatchlistItem, WatchlistEvent, - WatchlistItem, WatchlistQuery, + RawArtifact, RawArtifactQuery, Report, ReportQuery, 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; @@ -1848,6 +1848,101 @@ pub async fn create_scenario_run( 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_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(linked_metrics) + .bind(payload.status.clone()) + .fetch_one(&mut *tx) + .await?; + let report = fetch_report_by_id(&mut tx, report_id) + .await? + .ok_or_else(|| ApiError::BadRequest("report not found".to_string()))?; + tx.commit().await?; + + Ok(Json(report)) +} + async fn insert_watchlist_event( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, watchlist_item_id: i64, @@ -1872,6 +1967,81 @@ async fn insert_watchlist_event( 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 +} + +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(()) +} + async fn fetch_area_scores(state: &AppState, month: &str) -> Result, sqlx::Error> { sqlx::query_as::<_, AreaScore>( r#" @@ -2861,6 +3031,26 @@ fn validate_identifier(value: &str) -> ApiResult<()> { } } +fn validate_report_type(value: &str) -> ApiResult<()> { + if matches!(value, "monthly" | "area_special" | "neighborhood_memo") { + Ok(()) + } else { + Err(ApiError::BadRequest( + "report_type must be monthly, 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 has_sensitive_text(value: &str) -> bool { let lower = value.to_ascii_lowercase(); lower.contains("password") @@ -2875,7 +3065,7 @@ fn has_sensitive_text(value: &str) -> bool { #[cfg(test)] mod tests { use crate::models::{ - CreateDataSource, CreateIngestionRun, CreateRawArtifact, CreateScenarioRun, + CreateDataSource, CreateIngestionRun, CreateRawArtifact, CreateReport, CreateScenarioRun, CreateWatchlistItem, FinishIngestionRun, ScenarioRun, SimilarNeighborhoodCandidate, UpdateWatchlistItem, }; @@ -3076,6 +3266,51 @@ mod tests { .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_scenario_run_payload() { assert!(CreateScenarioRun { diff --git a/apps/api/src/models.rs b/apps/api/src/models.rs index 7edf22e..5b46153 100644 --- a/apps/api/src/models.rs +++ b/apps/api/src/models.rs @@ -65,6 +65,15 @@ pub struct ScenarioRunQuery { pub area_id: Option, } +#[derive(Debug, Deserialize)] +pub struct ReportQuery { + pub report_type: Option, + pub target_month: Option, + pub area_id: Option, + pub neighborhood_id: Option, + pub status: Option, +} + #[derive(Debug, Deserialize)] pub struct RawArtifactQuery { pub source_id: Option, @@ -587,6 +596,78 @@ pub struct ScenarioRunResponse { pub variants: Vec, } +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct Report { + pub report_id: i64, + pub report_type: String, + pub title: String, + pub target_month: Option, + pub area_id: Option, + pub area_name: Option, + pub neighborhood_id: Option, + pub neighborhood_name: Option, + pub summary: String, + pub content_markdown: String, + pub linked_metrics: Value, + pub status: String, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Deserialize)] +pub struct CreateReport { + pub report_type: String, + pub title: String, + pub target_month: Option, + pub area_id: Option, + pub neighborhood_id: Option, + pub summary: Option, + pub content_markdown: String, + pub linked_metrics: Option, + pub status: Option, +} + +impl CreateReport { + pub fn validate(&self) -> Result<(), &'static str> { + if !is_report_type(&self.report_type) { + return Err("report_type must be monthly, area_special, or neighborhood_memo"); + } + if self.title.trim().is_empty() { + return Err("title is required"); + } + if self.title.len() > 160 { + return Err("title is too long"); + } + if self.content_markdown.trim().is_empty() { + return Err("content_markdown is required"); + } + if self.content_markdown.len() > 80_000 { + return Err("content_markdown is too long"); + } + if let Some(status) = self.status.as_deref() { + if !is_report_status(status) { + return Err("status must be draft, published, or archived"); + } + } + for value in [ + Some(self.report_type.as_str()), + Some(self.title.as_str()), + self.target_month.as_deref(), + self.area_id.as_deref(), + self.neighborhood_id.as_deref(), + self.summary.as_deref(), + ] + .into_iter() + .flatten() + { + if has_sensitive_text(value) { + return Err("payload contains sensitive text"); + } + } + Ok(()) + } +} + #[derive(Debug, Clone, Serialize, FromRow)] pub struct DataSource { pub source_id: i64, @@ -948,3 +1029,14 @@ fn is_watchlist_priority(priority: &str) -> bool { fn is_watchlist_trigger_operator(operator: &str) -> bool { matches!(operator, "lte" | "gte") } + +fn is_report_type(report_type: &str) -> bool { + matches!( + report_type, + "monthly" | "area_special" | "neighborhood_memo" + ) +} + +fn is_report_status(status: &str) -> bool { + matches!(status, "draft" | "published" | "archived") +} diff --git a/apps/api/src/routes.rs b/apps/api/src/routes.rs index 77e744f..37a0941 100644 --- a/apps/api/src/routes.rs +++ b/apps/api/src/routes.rs @@ -5,13 +5,14 @@ use tower_http::trace::TraceLayer; use crate::handlers::{ archive_watchlist_item, compare_areas, compare_neighborhoods, create_area_score_model_run, - create_data_source, create_ingestion_run, create_raw_artifact, create_scenario_run, - create_watchlist_event, create_watchlist_item, diff_area_score_model_runs, execute_import, - finish_ingestion_run, get_area_detail, get_area_diagnostics, get_area_score_lineage, - get_neighborhood, get_neighborhood_detail, get_similar_neighborhoods, health, list_area_scores, - list_data_sources, list_ingestion_runs, list_model_runs, list_neighborhood_scores, - list_neighborhoods, list_raw_artifacts, list_scenario_runs, list_watchlist_events, - list_watchlist_items, market_overview, ready, update_watchlist_item, validate_import, + create_data_source, create_ingestion_run, create_raw_artifact, create_report, + create_scenario_run, create_watchlist_event, create_watchlist_item, diff_area_score_model_runs, + execute_import, finish_ingestion_run, get_area_detail, get_area_diagnostics, + get_area_score_lineage, get_neighborhood, get_neighborhood_detail, get_report, + get_similar_neighborhoods, health, list_area_scores, list_data_sources, list_ingestion_runs, + list_model_runs, list_neighborhood_scores, list_neighborhoods, list_raw_artifacts, + list_reports, list_scenario_runs, list_watchlist_events, list_watchlist_items, market_overview, + ready, update_watchlist_item, validate_import, }; use crate::state::AppState; @@ -85,7 +86,9 @@ pub fn build_router(state: AppState) -> Router { .route( "/scenario-runs", get(list_scenario_runs).post(create_scenario_run), - ), + ) + .route("/reports", get(list_reports).post(create_report)) + .route("/reports/{report_id}", get(get_report)), ) .layer(TraceLayer::new_for_http()) .layer(CorsLayer::permissive()) diff --git a/apps/web/src/components/dashboard/market-dashboard.tsx b/apps/web/src/components/dashboard/market-dashboard.tsx index 1eeeff7..8f8eb65 100644 --- a/apps/web/src/components/dashboard/market-dashboard.tsx +++ b/apps/web/src/components/dashboard/market-dashboard.tsx @@ -36,6 +36,7 @@ import { createIngestionRun, createRawArtifact, createAreaScoreModelRun, + createReport, createScenarioRun, createWatchlistEvent, createWatchlistItem, @@ -51,6 +52,8 @@ import { fetchNeighborhoodDetail, fetchNeighborhoodScores, fetchRawArtifacts, + fetchReport, + fetchReports, fetchScenarioRuns, fetchWatchlistEvents, fetchWatchlistItems, @@ -67,6 +70,7 @@ import type { CreateDataSource, CreateIngestionRun, CreateRawArtifact, + CreateReport, CreateScenarioRun, CreateWatchlistEvent, CreateWatchlistItem, @@ -78,6 +82,7 @@ import type { NeighborhoodDetail, NeighborhoodScore, RawArtifact, + Report, ScenarioRun, ScenarioRunResponse, SimilarNeighborhood, @@ -114,6 +119,8 @@ export function MarketDashboard() { const [baseModelRunId, setBaseModelRunId] = useState(null); const [candidateModelRunId, setCandidateModelRunId] = useState(null); const [latestScenario, setLatestScenario] = useState(null); + const [reportTypeFilter, setReportTypeFilter] = useState("monthly"); + const [selectedReportId, setSelectedReportId] = useState(null); const overview = useQuery({ queryKey: ["market-overview", MONTH], queryFn: () => fetchMarketOverview(MONTH), @@ -176,6 +183,19 @@ export function MarketDashboard() { queryKey: ["scenario-runs", MONTH], queryFn: () => fetchScenarioRuns({ target_month: MONTH }), }); + const reports = useQuery({ + queryKey: ["reports", reportTypeFilter, MONTH], + queryFn: () => + fetchReports({ + report_type: reportTypeFilter, + target_month: reportTypeFilter === "monthly" ? MONTH : undefined, + }), + }); + const selectedReport = useQuery({ + queryKey: ["report", selectedReportId], + queryFn: () => fetchReport(selectedReportId ?? 0), + enabled: selectedReportId !== null, + }); const dataSources = useQuery({ queryKey: ["data-sources"], queryFn: fetchDataSources, @@ -244,6 +264,16 @@ export function MarketDashboard() { await queryClient.invalidateQueries({ queryKey: ["scenario-runs"] }); }, }); + const createReportMutation = useMutation({ + mutationFn: createReport, + onSuccess: async (report) => { + setSelectedReportId(report.report_id); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["reports"] }), + queryClient.invalidateQueries({ queryKey: ["report", report.report_id] }), + ]); + }, + }); const createDataSourceMutation = useMutation({ mutationFn: createDataSource, onSuccess: async () => { @@ -308,6 +338,17 @@ export function MarketDashboard() { } }, [baseModelRunId, candidateModelRunId, modelRuns.data]); + useEffect(() => { + if (reports.data && reports.data.length > 0) { + const selectedExists = + selectedReportId !== null && + reports.data.some((report) => report.report_id === selectedReportId); + if (!selectedExists) { + setSelectedReportId(reports.data[0].report_id); + } + } + }, [reports.data, selectedReportId]); + const isLoading = overview.isLoading || scores.isLoading || @@ -321,6 +362,8 @@ export function MarketDashboard() { modelRuns.isLoading || areaScoreRunDiff.isLoading || scenarioRuns.isLoading || + reports.isLoading || + selectedReport.isLoading || dataSources.isLoading || ingestionRuns.isLoading; const importLoading = ingestionRuns.isLoading || rawArtifacts.isLoading; @@ -337,6 +380,8 @@ export function MarketDashboard() { modelRuns.isError || areaScoreRunDiff.isError || scenarioRuns.isError || + reports.isError || + selectedReport.isError || dataSources.isError || ingestionRuns.isError || rawArtifacts.isError; @@ -370,6 +415,8 @@ export function MarketDashboard() { void modelRuns.refetch(); void areaScoreRunDiff.refetch(); void scenarioRuns.refetch(); + void reports.refetch(); + void selectedReport.refetch(); }} disabled={isLoading} aria-label="刷新" @@ -546,6 +593,23 @@ export function MarketDashboard() { onCreate={(payload) => createScenarioMutation.mutate(payload)} /> + { + setReportTypeFilter(value); + setSelectedReportId(null); + }} + onSelectReport={setSelectedReportId} + onCreate={(payload) => createReportMutation.mutate(payload)} + /> + void; + onSelectReport: (reportId: number) => void; + onCreate: (payload: CreateReport) => void; +}) { + const [draftType, setDraftType] = useState("monthly"); + const [draftAreaId, setDraftAreaId] = useState(""); + const [draftNeighborhoodId, setDraftNeighborhoodId] = useState(""); + const [draftTitle, setDraftTitle] = useState(`上海房市投资研究月报 ${MONTH}`); + const [draftSummary, setDraftSummary] = useState("核心结论待复核。"); + const [draftContent, setDraftContent] = useState(defaultReportMarkdown("monthly")); + + function submitReport() { + onCreate({ + report_type: draftType, + title: draftTitle, + target_month: MONTH, + area_id: draftAreaId || undefined, + neighborhood_id: draftNeighborhoodId || undefined, + summary: draftSummary, + content_markdown: draftContent, + linked_metrics: { + month: MONTH, + area_id: draftAreaId || null, + neighborhood_id: draftNeighborhoodId || null, + }, + status: "draft", + }); + } + + function changeDraftType(value: CreateReport["report_type"]) { + setDraftType(value); + setDraftTitle(defaultReportTitle(value)); + setDraftContent(defaultReportMarkdown(value)); + if (value !== "area_special") { + setDraftAreaId(""); + } + if (value !== "neighborhood_memo") { + setDraftNeighborhoodId(""); + } + } + + return ( + + +
+ 报告中心 +

+ 月报、板块专题与小区备忘录 +

+
+
+ {(["monthly", "area_special", "neighborhood_memo"] as const).map((type) => ( + + ))} +
+
+ +
+
+

新建报告

+
+ + {draftType === "area_special" ? ( + + ) : null} + {draftType === "neighborhood_memo" ? ( + + ) : null} + +