feat: add report center

This commit is contained in:
2026-06-24 17:26:04 +08:00
parent f0ac47e040
commit 049d6a711e
7 changed files with 841 additions and 21 deletions

View File

@@ -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 {