feat: add report center
This commit is contained in:
28
apps/api/migrations/202606240007_reports.sql
Normal file
28
apps/api/migrations/202606240007_reports.sql
Normal file
@@ -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);
|
||||
@@ -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<AppState>,
|
||||
Query(query): Query<ReportQuery>,
|
||||
) -> ApiResult<Json<Vec<Report>>> {
|
||||
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<AppState>,
|
||||
Path(report_id): Path<i64>,
|
||||
) -> ApiResult<Json<Report>> {
|
||||
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<AppState>,
|
||||
Json(payload): Json<CreateReport>,
|
||||
) -> ApiResult<Json<Report>> {
|
||||
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<Option<Report>, 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<Vec<AreaScore>, 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 {
|
||||
|
||||
@@ -65,6 +65,15 @@ pub struct ScenarioRunQuery {
|
||||
pub area_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReportQuery {
|
||||
pub report_type: Option<String>,
|
||||
pub target_month: Option<String>,
|
||||
pub area_id: Option<String>,
|
||||
pub neighborhood_id: Option<String>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RawArtifactQuery {
|
||||
pub source_id: Option<i64>,
|
||||
@@ -587,6 +596,78 @@ pub struct ScenarioRunResponse {
|
||||
pub variants: Vec<ScenarioVariant>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct Report {
|
||||
pub report_id: i64,
|
||||
pub report_type: String,
|
||||
pub title: String,
|
||||
pub target_month: Option<String>,
|
||||
pub area_id: Option<String>,
|
||||
pub area_name: Option<String>,
|
||||
pub neighborhood_id: Option<String>,
|
||||
pub neighborhood_name: Option<String>,
|
||||
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<String>,
|
||||
pub area_id: Option<String>,
|
||||
pub neighborhood_id: Option<String>,
|
||||
pub summary: Option<String>,
|
||||
pub content_markdown: String,
|
||||
pub linked_metrics: Option<Value>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user