feat: add report generation runs

This commit is contained in:
2026-06-24 18:00:25 +08:00
parent 049d6a711e
commit 66e2d66055
8 changed files with 1034 additions and 58 deletions

View File

@@ -0,0 +1,62 @@
CREATE TABLE IF NOT EXISTS app.report_templates (
template_id BIGSERIAL PRIMARY KEY,
template_key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
report_type TEXT NOT NULL CHECK (report_type IN ('monthly', 'area_special', 'neighborhood_memo')),
cadence TEXT NOT NULL CHECK (cadence IN ('weekly', 'monthly', 'manual')),
description TEXT NOT NULL DEFAULT '',
body_template TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS app.report_generation_runs (
generation_run_id BIGSERIAL PRIMARY KEY,
template_id BIGINT NOT NULL REFERENCES app.report_templates(template_id),
target_month TEXT NOT NULL CHECK (target_month ~ '^[0-9]{4}-[0-9]{2}$'),
status TEXT NOT NULL CHECK (status IN ('running', 'succeeded', 'failed')),
report_id BIGINT REFERENCES app.reports(report_id),
parameters JSONB NOT NULL DEFAULT '{}'::jsonb,
error_message TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finished_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_report_generation_runs_month
ON app.report_generation_runs(target_month, generation_run_id DESC);
CREATE INDEX IF NOT EXISTS idx_report_generation_runs_template
ON app.report_generation_runs(template_id, generation_run_id DESC);
INSERT INTO app.report_templates (
template_key,
name,
report_type,
cadence,
description,
body_template
)
VALUES
(
'monthly_market',
'市场月报',
'monthly',
'monthly',
'生成市场总览、板块评分、关键风险和下月跟踪事项。',
'# 上海房市投资研究月报 {{month}}\n\n{{market_summary}}\n\n{{area_table}}\n\n{{risk_section}}\n\n{{next_actions}}'
),
(
'weekly_watchlist',
'观察池周报',
'monthly',
'weekly',
'生成观察池状态、价格触发和待复核事项摘要。',
'# 上海房市观察池周报 {{month}}\n\n{{watchlist_summary}}\n\n{{watchlist_table}}\n\n{{next_actions}}'
)
ON CONFLICT (template_key) DO UPDATE
SET name = EXCLUDED.name,
report_type = EXCLUDED.report_type,
cadence = EXCLUDED.cadence,
description = EXCLUDED.description,
body_template = EXCLUDED.body_template,
updated_at = now();

View File

@@ -0,0 +1,23 @@
ALTER TABLE app.reports
DROP CONSTRAINT IF EXISTS reports_report_type_check;
ALTER TABLE app.reports
ADD CONSTRAINT reports_report_type_check
CHECK (report_type IN ('monthly', 'weekly', 'area_special', 'neighborhood_memo'));
ALTER TABLE app.report_templates
DROP CONSTRAINT IF EXISTS report_templates_report_type_check;
ALTER TABLE app.report_templates
ADD CONSTRAINT report_templates_report_type_check
CHECK (report_type IN ('monthly', 'weekly', 'area_special', 'neighborhood_memo'));
UPDATE app.report_templates
SET report_type = 'weekly',
updated_at = now()
WHERE template_key = 'weekly_watchlist';
UPDATE app.reports
SET report_type = 'weekly',
updated_at = now()
WHERE linked_metrics->>'template_key' = 'weekly_watchlist';

View File

@@ -9,16 +9,18 @@ use crate::models::{
AreaProfile, AreaScore, AreaScoreLineage, AreaScoreLineageQuery, AreaScoreModelRunResponse, AreaProfile, AreaScore, AreaScoreLineage, AreaScoreLineageQuery, AreaScoreModelRunResponse,
AreaScoreRunDiff, AreaScoreRunDiffItem, AreaScoreSummary, CompareQuery, ComparisonMetric, AreaScoreRunDiff, AreaScoreRunDiffItem, AreaScoreSummary, CompareQuery, ComparisonMetric,
ComparisonMetricValue, CreateAreaScoreModelRun, CreateDataSource, CreateIngestionRun, ComparisonMetricValue, CreateAreaScoreModelRun, CreateDataSource, CreateIngestionRun,
CreateRawArtifact, CreateReport, CreateScenarioRun, CreateWatchlistEvent, CreateWatchlistItem, CreateRawArtifact, CreateReport, CreateReportGenerationRun, CreateScenarioRun,
DataSource, DiagnosticMetric, FinishIngestionRun, ImportExecutionRequest, CreateWatchlistEvent, CreateWatchlistItem, DataSource, DiagnosticMetric, FinishIngestionRun,
ImportExecutionResponse, ImportValidationRequest, ImportValidationResponse, IngestionRun, ImportExecutionRequest, ImportExecutionResponse, ImportValidationRequest,
IngestionRunQuery, MarketOverview, ModelRun, ModelRunDiffQuery, ModelRunQuery, MonthQuery, ImportValidationResponse, IngestionRun, IngestionRunQuery, MarketOverview, ModelRun,
Neighborhood, NeighborhoodComparison, NeighborhoodComparisonItem, NeighborhoodDetail, ModelRunDiffQuery, ModelRunQuery, MonthQuery, Neighborhood, NeighborhoodComparison,
NeighborhoodMonthlyMetric, NeighborhoodQuery, NeighborhoodScore, NeighborhoodScoreQuery, NeighborhoodComparisonItem, NeighborhoodDetail, NeighborhoodMonthlyMetric, NeighborhoodQuery,
RawArtifact, RawArtifactQuery, Report, ReportQuery, ScenarioAreaResult, ScenarioRun, NeighborhoodScore, NeighborhoodScoreQuery, RawArtifact, RawArtifactQuery, Report,
ScenarioRunQuery, ScenarioRunResponse, ScenarioVariant, SimilarNeighborhood, ReportGenerationRun, ReportGenerationRunQuery, ReportGenerationRunResponse, ReportQuery,
SimilarNeighborhoodCandidate, SimilarNeighborhoodQuery, SimilarNeighborhoodResponse, ReportTemplate, ScenarioAreaResult, ScenarioRun, ScenarioRunQuery, ScenarioRunResponse,
UpdateWatchlistItem, WatchlistEvent, WatchlistItem, WatchlistQuery, ScenarioVariant, SimilarNeighborhood, SimilarNeighborhoodCandidate, SimilarNeighborhoodQuery,
SimilarNeighborhoodResponse, UpdateWatchlistItem, WatchlistEvent, WatchlistItem,
WatchlistQuery,
}; };
use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore}; use crate::scoring::{compute_area_scores, previous_month, AreaMetricRow, ComputedAreaScore};
use crate::state::AppState; use crate::state::AppState;
@@ -1907,42 +1909,162 @@ pub async fn create_report(
} }
let mut tx = state.pool.begin().await?; let mut tx = state.pool.begin().await?;
let report_id = sqlx::query_scalar::<_, i64>( let report = insert_report_from_payload(
r#" &mut tx,
INSERT INTO app.reports ( CreateReport {
report_type, linked_metrics: Some(linked_metrics),
title, ..payload
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?; .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?; tx.commit().await?;
Ok(Json(report)) Ok(Json(report))
} }
pub async fn list_report_templates(
State(state): State<AppState>,
) -> ApiResult<Json<Vec<ReportTemplate>>> {
let templates = sqlx::query_as::<_, ReportTemplate>(
r#"
SELECT
template_id,
template_key,
name,
report_type,
cadence,
description,
body_template,
created_at::text AS created_at,
updated_at::text AS updated_at
FROM app.report_templates
ORDER BY cadence, template_key
"#,
)
.fetch_all(&state.pool)
.await?;
Ok(Json(templates))
}
pub async fn list_report_generation_runs(
State(state): State<AppState>,
Query(query): Query<ReportGenerationRunQuery>,
) -> ApiResult<Json<Vec<ReportGenerationRun>>> {
validate_report_generation_query(&query)?;
let runs = sqlx::query_as::<_, ReportGenerationRun>(
r#"
SELECT
r.generation_run_id,
r.template_id,
t.template_key,
t.name AS template_name,
r.target_month,
r.status,
r.report_id,
p.title AS report_title,
r.parameters,
r.error_message,
r.started_at::text AS started_at,
r.finished_at::text AS finished_at
FROM app.report_generation_runs r
JOIN app.report_templates t ON t.template_id = r.template_id
LEFT JOIN app.reports p ON p.report_id = r.report_id
WHERE ($1::text IS NULL OR r.target_month = $1)
AND ($2::text IS NULL OR t.template_key = $2)
AND ($3::text IS NULL OR r.status = $3)
ORDER BY r.generation_run_id DESC
LIMIT 80
"#,
)
.bind(query.target_month)
.bind(query.template_key)
.bind(query.status)
.fetch_all(&state.pool)
.await?;
Ok(Json(runs))
}
pub async fn create_report_generation_run(
State(state): State<AppState>,
Json(payload): Json<CreateReportGenerationRun>,
) -> ApiResult<Json<ReportGenerationRunResponse>> {
validate_month(&payload.target_month)?;
payload
.validate()
.map_err(|message| ApiError::BadRequest(message.to_string()))?;
validate_identifier(&payload.template_key)?;
let parameters = payload
.parameters
.clone()
.unwrap_or_else(|| serde_json::json!({}));
let mut tx = state.pool.begin().await?;
let template = fetch_report_template(&mut tx, &payload.template_key)
.await?
.ok_or_else(|| ApiError::BadRequest("report template not found".to_string()))?;
let generation_run_id = sqlx::query_scalar::<_, i64>(
r#"
INSERT INTO app.report_generation_runs (
template_id,
target_month,
status,
parameters
)
VALUES ($1, $2, 'running', $3)
RETURNING generation_run_id
"#,
)
.bind(template.template_id)
.bind(payload.target_month.trim().to_string())
.bind(parameters.clone())
.fetch_one(&mut *tx)
.await?;
let generated =
build_generated_report(&mut tx, &template, &payload.target_month, &parameters).await;
match generated {
Ok(create_report_payload) => {
let report = insert_report_from_payload(&mut tx, create_report_payload).await?;
finish_report_generation_run(
&mut tx,
generation_run_id,
"succeeded",
Some(report.report_id),
None,
)
.await?;
let generation_run = fetch_report_generation_run_by_id(&mut tx, generation_run_id)
.await?
.ok_or_else(|| ApiError::BadRequest("generation run not found".to_string()))?;
tx.commit().await?;
Ok(Json(ReportGenerationRunResponse {
generation_run,
report: Some(report),
}))
}
Err(message) => {
finish_report_generation_run(
&mut tx,
generation_run_id,
"failed",
None,
Some(&message),
)
.await?;
let generation_run = fetch_report_generation_run_by_id(&mut tx, generation_run_id)
.await?
.ok_or_else(|| ApiError::BadRequest("generation run not found".to_string()))?;
tx.commit().await?;
Ok(Json(ReportGenerationRunResponse {
generation_run,
report: None,
}))
}
}
}
async fn insert_watchlist_event( async fn insert_watchlist_event(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
watchlist_item_id: i64, watchlist_item_id: i64,
@@ -2003,6 +2125,406 @@ async fn fetch_report_by_id(
.await .await
} }
async fn fetch_report_template(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
template_key: &str,
) -> Result<Option<ReportTemplate>, sqlx::Error> {
sqlx::query_as::<_, ReportTemplate>(
r#"
SELECT
template_id,
template_key,
name,
report_type,
cadence,
description,
body_template,
created_at::text AS created_at,
updated_at::text AS updated_at
FROM app.report_templates
WHERE template_key = $1
"#,
)
.bind(template_key)
.fetch_optional(&mut **tx)
.await
}
async fn fetch_report_generation_run_by_id(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
generation_run_id: i64,
) -> Result<Option<ReportGenerationRun>, sqlx::Error> {
sqlx::query_as::<_, ReportGenerationRun>(
r#"
SELECT
r.generation_run_id,
r.template_id,
t.template_key,
t.name AS template_name,
r.target_month,
r.status,
r.report_id,
p.title AS report_title,
r.parameters,
r.error_message,
r.started_at::text AS started_at,
r.finished_at::text AS finished_at
FROM app.report_generation_runs r
JOIN app.report_templates t ON t.template_id = r.template_id
LEFT JOIN app.reports p ON p.report_id = r.report_id
WHERE r.generation_run_id = $1
"#,
)
.bind(generation_run_id)
.fetch_optional(&mut **tx)
.await
}
async fn finish_report_generation_run(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
generation_run_id: i64,
status: &str,
report_id: Option<i64>,
error_message: Option<&str>,
) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
UPDATE app.report_generation_runs
SET status = $2,
report_id = $3,
error_message = $4,
finished_at = now()
WHERE generation_run_id = $1
"#,
)
.bind(generation_run_id)
.bind(status)
.bind(report_id)
.bind(error_message)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn insert_report_from_payload(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
payload: CreateReport,
) -> Result<Report, sqlx::Error> {
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(
payload
.linked_metrics
.unwrap_or_else(|| serde_json::json!({})),
)
.bind(payload.status.clone())
.fetch_one(&mut **tx)
.await?;
fetch_report_by_id(tx, report_id)
.await?
.ok_or(sqlx::Error::RowNotFound)
}
async fn build_generated_report(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
template: &ReportTemplate,
target_month: &str,
parameters: &serde_json::Value,
) -> Result<CreateReport, String> {
match template.template_key.as_str() {
"monthly_market" => {
build_monthly_market_report(tx, template, target_month, parameters).await
}
"weekly_watchlist" => {
build_weekly_watchlist_report(tx, template, target_month, parameters).await
}
other => Err(format!("template '{other}' is not supported by generator")),
}
}
async fn build_monthly_market_report(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
template: &ReportTemplate,
target_month: &str,
parameters: &serde_json::Value,
) -> Result<CreateReport, String> {
let scores = fetch_area_scores_in_tx(tx, target_month)
.await
.map_err(|error| format!("failed to fetch area scores: {error}"))?;
if scores.is_empty() {
return Err(format!("no area scores found for month {target_month}"));
}
let top = scores
.first()
.ok_or_else(|| "no top area found".to_string())?;
let weakest = scores
.last()
.ok_or_else(|| "no weakest area found".to_string())?;
let high_supply = scores
.iter()
.max_by(|left, right| {
left.supply_risk_score
.partial_cmp(&right.supply_risk_score)
.unwrap_or(std::cmp::Ordering::Equal)
})
.ok_or_else(|| "no supply risk area found".to_string())?;
let avg_score = average(scores.iter().map(|score| score.investment_score));
let avg_yield = average(scores.iter().map(|score| score.annual_rent_yield_pct));
let market_summary = format!(
"## 核心结论\n\n- 综合评分最高板块:{}{}{})。\n- 综合评分最低板块:{}{}{})。\n- 供应压力最高板块:{}(供应风险 {})。\n- 样本平均投资观察评分:{};样本平均年化租金收益率:{}%。",
top.name,
format_score(top.investment_score),
top.recommendation,
weakest.name,
format_score(weakest.investment_score),
weakest.recommendation,
high_supply.name,
format_score(high_supply.supply_risk_score),
format_score(avg_score),
round_to(avg_yield, 2)
);
let area_table = monthly_area_table(&scores);
let risk_section = format!(
"## 风险解释\n\n- {} 的供应风险最高,需要跟踪新增供应和挂牌去化。\n- {} 综合评分最低,进入谨慎复核列表。\n- 自动报告只汇总结构化指标,具体买卖动作仍需人工复核。",
high_supply.name, weakest.name
);
let next_actions = "## 下月跟踪\n\n- 复核高分板块内小区分化。\n- 跟踪观察池价格触发和成交量变化。\n- 对政策和利率变化进行情景推演。";
let content = template
.body_template
.replace("{{month}}", target_month)
.replace("{{market_summary}}", &market_summary)
.replace("{{area_table}}", &area_table)
.replace("{{risk_section}}", &risk_section)
.replace("{{next_actions}}", next_actions);
Ok(CreateReport {
report_type: template.report_type.clone(),
title: format!("上海房市投资研究月报 {target_month}"),
target_month: Some(target_month.to_string()),
area_id: None,
neighborhood_id: None,
summary: Some(format!(
"最高评分 {},最低评分 {},供应压力最高 {}",
top.name, weakest.name, high_supply.name
)),
content_markdown: content,
linked_metrics: Some(serde_json::json!({
"template_key": template.template_key,
"month": target_month,
"top_area_id": top.area_id,
"weakest_area_id": weakest.area_id,
"highest_supply_risk_area_id": high_supply.area_id,
"area_count": scores.len(),
"parameters": parameters
})),
status: Some("published".to_string()),
})
}
async fn build_weekly_watchlist_report(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
template: &ReportTemplate,
target_month: &str,
parameters: &serde_json::Value,
) -> Result<CreateReport, String> {
let items = fetch_watchlist_items_in_tx(tx)
.await
.map_err(|error| format!("failed to fetch watchlist items: {error}"))?;
let active_count = items.iter().filter(|item| item.status == "active").count();
let paused_count = items.iter().filter(|item| item.status == "paused").count();
let high_priority_count = items.iter().filter(|item| item.priority == "high").count();
let watchlist_summary = format!(
"## 观察池概览\n\n- 当前观察标的 {} 个,其中 active {} 个、paused {} 个。\n- 高优先级标的 {} 个。\n- 本周重点复核价格触发、成交量变化和最新备注。",
items.len(),
active_count,
paused_count,
high_priority_count
);
let watchlist_table = watchlist_report_table(&items);
let next_actions = "## 下一步动作\n\n- 对 high 优先级标的补充人工复核记录。\n- 检查触发价附近标的是否需要调整观察价。\n- 将异常价格变化同步到预警模块。";
let content = template
.body_template
.replace("{{month}}", target_month)
.replace("{{watchlist_summary}}", &watchlist_summary)
.replace("{{watchlist_table}}", &watchlist_table)
.replace("{{next_actions}}", next_actions);
Ok(CreateReport {
report_type: template.report_type.clone(),
title: format!("上海房市观察池周报 {target_month}"),
target_month: Some(target_month.to_string()),
area_id: None,
neighborhood_id: None,
summary: Some(format!(
"观察标的 {} 个,高优先级 {} 个。",
items.len(),
high_priority_count
)),
content_markdown: content,
linked_metrics: Some(serde_json::json!({
"template_key": template.template_key,
"month": target_month,
"watchlist_count": items.len(),
"active_count": active_count,
"high_priority_count": high_priority_count,
"parameters": parameters
})),
status: Some("published".to_string()),
})
}
async fn fetch_area_scores_in_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
month: &str,
) -> Result<Vec<AreaScore>, sqlx::Error> {
sqlx::query_as::<_, AreaScore>(
r#"
SELECT
area_id,
name,
district,
segment,
month,
investment_score,
recommendation,
liquidity_score,
momentum_score,
rent_support_score,
safety_margin_score,
credit_support_score,
supply_risk_score,
valuation_pressure_score,
transaction_count,
transaction_price_psm,
listing_count,
listing_price_psm,
rent_price_psm,
median_days_on_market,
annual_rent_yield_pct,
listing_pressure_ratio,
discount_pct,
price_momentum_pct,
volume_momentum_pct
FROM gold.area_scores
WHERE month = $1
ORDER BY investment_score DESC, area_id ASC
"#,
)
.bind(month)
.fetch_all(&mut **tx)
.await
}
async fn fetch_watchlist_items_in_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<Vec<WatchlistItem>, sqlx::Error> {
sqlx::query_as::<_, WatchlistItem>(
r#"
SELECT
watchlist_item_id,
neighborhood_id,
area_id,
target_price_psm,
status,
label,
priority,
trigger_operator,
trigger_price_psm,
notes,
created_at::text AS created_at,
updated_at::text AS updated_at,
last_reviewed_at::text AS last_reviewed_at
FROM app.watchlist_items
WHERE status <> 'archived'
ORDER BY priority DESC, updated_at DESC, watchlist_item_id DESC
LIMIT 100
"#,
)
.fetch_all(&mut **tx)
.await
}
fn monthly_area_table(scores: &[AreaScore]) -> String {
let mut lines = vec![
"## 板块评分".to_string(),
String::new(),
"| 排名 | 板块 | 行政区 | 综合分 | 建议 | 成交均价/㎡ | 成交套数 | 供应风险 |".to_string(),
"| --- | --- | --- | ---: | --- | ---: | ---: | ---: |".to_string(),
];
for (index, score) in scores.iter().enumerate() {
lines.push(format!(
"| {} | {} | {} | {} | {} | {:.0} | {} | {} |",
index + 1,
score.name,
score.district,
format_score(score.investment_score),
score.recommendation,
score.transaction_price_psm,
score.transaction_count,
format_score(score.supply_risk_score)
));
}
lines.join("\n")
}
fn watchlist_report_table(items: &[WatchlistItem]) -> String {
let mut lines = vec![
"## 观察池明细".to_string(),
String::new(),
"| ID | 类型 | 状态 | 优先级 | 目标价/㎡ | 触发价/㎡ | 标签 |".to_string(),
"| ---: | --- | --- | --- | ---: | ---: | --- |".to_string(),
];
for item in items.iter().take(30) {
lines.push(format!(
"| {} | {} | {} | {} | {} | {} | {} |",
item.watchlist_item_id,
item.neighborhood_id
.as_deref()
.or(item.area_id.as_deref())
.unwrap_or("-"),
item.status,
item.priority,
format_optional_price(item.target_price_psm),
format_optional_price(item.trigger_price_psm),
item.label
));
}
lines.join("\n")
}
fn format_score(value: f64) -> String {
format!("{:.1}", round_to(value, 1))
}
fn format_optional_price(value: Option<f64>) -> String {
value
.map(|item| format!("{:.0}", item))
.unwrap_or_else(|| "-".to_string())
}
fn validate_report_query(query: &ReportQuery) -> ApiResult<()> { fn validate_report_query(query: &ReportQuery) -> ApiResult<()> {
if let Some(report_type) = query.report_type.as_deref() { if let Some(report_type) = query.report_type.as_deref() {
validate_report_type(report_type)?; validate_report_type(report_type)?;
@@ -2042,6 +2564,19 @@ fn validate_create_report(payload: &CreateReport) -> ApiResult<()> {
Ok(()) Ok(())
} }
fn validate_report_generation_query(query: &ReportGenerationRunQuery) -> ApiResult<()> {
if let Some(month) = query.target_month.as_deref() {
validate_month(month)?;
}
if let Some(template_key) = query.template_key.as_deref() {
validate_identifier(template_key)?;
}
if let Some(status) = query.status.as_deref() {
validate_generation_status(status)?;
}
Ok(())
}
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#"
@@ -3032,11 +3567,14 @@ fn validate_identifier(value: &str) -> ApiResult<()> {
} }
fn validate_report_type(value: &str) -> ApiResult<()> { fn validate_report_type(value: &str) -> ApiResult<()> {
if matches!(value, "monthly" | "area_special" | "neighborhood_memo") { if matches!(
value,
"monthly" | "weekly" | "area_special" | "neighborhood_memo"
) {
Ok(()) Ok(())
} else { } else {
Err(ApiError::BadRequest( Err(ApiError::BadRequest(
"report_type must be monthly, area_special, or neighborhood_memo".to_string(), "report_type must be monthly, weekly, area_special, or neighborhood_memo".to_string(),
)) ))
} }
} }
@@ -3051,6 +3589,16 @@ fn validate_report_status(value: &str) -> ApiResult<()> {
} }
} }
fn validate_generation_status(value: &str) -> ApiResult<()> {
if matches!(value, "running" | "succeeded" | "failed") {
Ok(())
} else {
Err(ApiError::BadRequest(
"status must be running, succeeded, or failed".to_string(),
))
}
}
fn has_sensitive_text(value: &str) -> bool { fn has_sensitive_text(value: &str) -> bool {
let lower = value.to_ascii_lowercase(); let lower = value.to_ascii_lowercase();
lower.contains("password") lower.contains("password")
@@ -3065,9 +3613,9 @@ fn has_sensitive_text(value: &str) -> bool {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::models::{ use crate::models::{
CreateDataSource, CreateIngestionRun, CreateRawArtifact, CreateReport, CreateScenarioRun, CreateDataSource, CreateIngestionRun, CreateRawArtifact, CreateReport,
CreateWatchlistItem, FinishIngestionRun, ScenarioRun, SimilarNeighborhoodCandidate, CreateReportGenerationRun, CreateScenarioRun, CreateWatchlistItem, FinishIngestionRun,
UpdateWatchlistItem, ScenarioRun, SimilarNeighborhoodCandidate, UpdateWatchlistItem,
}; };
use super::{ use super::{
@@ -3311,6 +3859,33 @@ mod tests {
.is_err()); .is_err());
} }
#[test]
fn validates_report_generation_payload() {
assert!(CreateReportGenerationRun {
template_key: "monthly_market".to_string(),
target_month: "2026-05".to_string(),
parameters: Some(serde_json::json!({"scope": "all"})),
}
.validate()
.is_ok());
assert!(CreateReportGenerationRun {
template_key: "".to_string(),
target_month: "2026-05".to_string(),
parameters: None,
}
.validate()
.is_err());
assert!(CreateReportGenerationRun {
template_key: "monthly_market".to_string(),
target_month: "2026-05".to_string(),
parameters: Some(serde_json::json!(["bad"])),
}
.validate()
.is_err());
}
#[test] #[test]
fn validates_scenario_run_payload() { fn validates_scenario_run_payload() {
assert!(CreateScenarioRun { assert!(CreateScenarioRun {

View File

@@ -74,6 +74,13 @@ pub struct ReportQuery {
pub status: Option<String>, pub status: Option<String>,
} }
#[derive(Debug, Deserialize)]
pub struct ReportGenerationRunQuery {
pub target_month: Option<String>,
pub template_key: Option<String>,
pub status: Option<String>,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct RawArtifactQuery { pub struct RawArtifactQuery {
pub source_id: Option<i64>, pub source_id: Option<i64>,
@@ -627,10 +634,69 @@ pub struct CreateReport {
pub status: Option<String>, pub status: Option<String>,
} }
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct ReportTemplate {
pub template_id: i64,
pub template_key: String,
pub name: String,
pub report_type: String,
pub cadence: String,
pub description: String,
pub body_template: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct ReportGenerationRun {
pub generation_run_id: i64,
pub template_id: i64,
pub template_key: String,
pub template_name: String,
pub target_month: String,
pub status: String,
pub report_id: Option<i64>,
pub report_title: Option<String>,
pub parameters: Value,
pub error_message: Option<String>,
pub started_at: String,
pub finished_at: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct CreateReportGenerationRun {
pub template_key: String,
pub target_month: String,
pub parameters: Option<Value>,
}
impl CreateReportGenerationRun {
pub fn validate(&self) -> Result<(), &'static str> {
if self.template_key.trim().is_empty() {
return Err("template_key is required");
}
if has_sensitive_text(&self.template_key) {
return Err("payload contains sensitive text");
}
if let Some(parameters) = &self.parameters {
if !parameters.is_object() {
return Err("parameters must be a JSON object");
}
}
Ok(())
}
}
#[derive(Debug, Serialize)]
pub struct ReportGenerationRunResponse {
pub generation_run: ReportGenerationRun,
pub report: Option<Report>,
}
impl CreateReport { impl CreateReport {
pub fn validate(&self) -> Result<(), &'static str> { pub fn validate(&self) -> Result<(), &'static str> {
if !is_report_type(&self.report_type) { if !is_report_type(&self.report_type) {
return Err("report_type must be monthly, area_special, or neighborhood_memo"); return Err("report_type must be monthly, weekly, area_special, or neighborhood_memo");
} }
if self.title.trim().is_empty() { if self.title.trim().is_empty() {
return Err("title is required"); return Err("title is required");
@@ -1033,7 +1099,7 @@ fn is_watchlist_trigger_operator(operator: &str) -> bool {
fn is_report_type(report_type: &str) -> bool { fn is_report_type(report_type: &str) -> bool {
matches!( matches!(
report_type, report_type,
"monthly" | "area_special" | "neighborhood_memo" "monthly" | "weekly" | "area_special" | "neighborhood_memo"
) )
} }

View File

@@ -6,11 +6,12 @@ use tower_http::trace::TraceLayer;
use crate::handlers::{ use crate::handlers::{
archive_watchlist_item, compare_areas, compare_neighborhoods, create_area_score_model_run, archive_watchlist_item, compare_areas, compare_neighborhoods, create_area_score_model_run,
create_data_source, create_ingestion_run, create_raw_artifact, create_report, 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, create_report_generation_run, create_scenario_run, create_watchlist_event,
execute_import, finish_ingestion_run, get_area_detail, get_area_diagnostics, create_watchlist_item, diff_area_score_model_runs, execute_import, finish_ingestion_run,
get_area_score_lineage, get_neighborhood, get_neighborhood_detail, get_report, get_area_detail, get_area_diagnostics, get_area_score_lineage, get_neighborhood,
get_similar_neighborhoods, health, list_area_scores, list_data_sources, list_ingestion_runs, get_neighborhood_detail, get_report, get_similar_neighborhoods, health, list_area_scores,
list_model_runs, list_neighborhood_scores, list_neighborhoods, list_raw_artifacts, list_data_sources, list_ingestion_runs, list_model_runs, list_neighborhood_scores,
list_neighborhoods, list_raw_artifacts, list_report_generation_runs, list_report_templates,
list_reports, list_scenario_runs, list_watchlist_events, list_watchlist_items, market_overview, list_reports, list_scenario_runs, list_watchlist_events, list_watchlist_items, market_overview,
ready, update_watchlist_item, validate_import, ready, update_watchlist_item, validate_import,
}; };
@@ -88,7 +89,12 @@ pub fn build_router(state: AppState) -> Router {
get(list_scenario_runs).post(create_scenario_run), get(list_scenario_runs).post(create_scenario_run),
) )
.route("/reports", get(list_reports).post(create_report)) .route("/reports", get(list_reports).post(create_report))
.route("/reports/{report_id}", get(get_report)), .route("/reports/{report_id}", get(get_report))
.route("/report-templates", get(list_report_templates))
.route(
"/report-generation-runs",
get(list_report_generation_runs).post(create_report_generation_run),
),
) )
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive()) .layer(CorsLayer::permissive())

View File

@@ -37,6 +37,7 @@ import {
createRawArtifact, createRawArtifact,
createAreaScoreModelRun, createAreaScoreModelRun,
createReport, createReport,
createReportGenerationRun,
createScenarioRun, createScenarioRun,
createWatchlistEvent, createWatchlistEvent,
createWatchlistItem, createWatchlistItem,
@@ -53,6 +54,8 @@ import {
fetchNeighborhoodScores, fetchNeighborhoodScores,
fetchRawArtifacts, fetchRawArtifacts,
fetchReport, fetchReport,
fetchReportGenerationRuns,
fetchReportTemplates,
fetchReports, fetchReports,
fetchScenarioRuns, fetchScenarioRuns,
fetchWatchlistEvents, fetchWatchlistEvents,
@@ -71,6 +74,7 @@ import type {
CreateIngestionRun, CreateIngestionRun,
CreateRawArtifact, CreateRawArtifact,
CreateReport, CreateReport,
CreateReportGenerationRun,
CreateScenarioRun, CreateScenarioRun,
CreateWatchlistEvent, CreateWatchlistEvent,
CreateWatchlistItem, CreateWatchlistItem,
@@ -83,6 +87,8 @@ import type {
NeighborhoodScore, NeighborhoodScore,
RawArtifact, RawArtifact,
Report, Report,
ReportGenerationRun,
ReportTemplate,
ScenarioRun, ScenarioRun,
ScenarioRunResponse, ScenarioRunResponse,
SimilarNeighborhood, SimilarNeighborhood,
@@ -196,6 +202,14 @@ export function MarketDashboard() {
queryFn: () => fetchReport(selectedReportId ?? 0), queryFn: () => fetchReport(selectedReportId ?? 0),
enabled: selectedReportId !== null, enabled: selectedReportId !== null,
}); });
const reportTemplates = useQuery({
queryKey: ["report-templates"],
queryFn: fetchReportTemplates,
});
const reportGenerationRuns = useQuery({
queryKey: ["report-generation-runs", MONTH],
queryFn: () => fetchReportGenerationRuns({ target_month: MONTH }),
});
const dataSources = useQuery({ const dataSources = useQuery({
queryKey: ["data-sources"], queryKey: ["data-sources"],
queryFn: fetchDataSources, queryFn: fetchDataSources,
@@ -274,6 +288,21 @@ export function MarketDashboard() {
]); ]);
}, },
}); });
const createReportGenerationMutation = useMutation({
mutationFn: createReportGenerationRun,
onSuccess: async (response) => {
if (response.report) {
setSelectedReportId(response.report.report_id);
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["reports"] }),
queryClient.invalidateQueries({ queryKey: ["report-generation-runs"] }),
response.report
? queryClient.invalidateQueries({ queryKey: ["report", response.report.report_id] })
: Promise.resolve(),
]);
},
});
const createDataSourceMutation = useMutation({ const createDataSourceMutation = useMutation({
mutationFn: createDataSource, mutationFn: createDataSource,
onSuccess: async () => { onSuccess: async () => {
@@ -364,6 +393,8 @@ export function MarketDashboard() {
scenarioRuns.isLoading || scenarioRuns.isLoading ||
reports.isLoading || reports.isLoading ||
selectedReport.isLoading || selectedReport.isLoading ||
reportTemplates.isLoading ||
reportGenerationRuns.isLoading ||
dataSources.isLoading || dataSources.isLoading ||
ingestionRuns.isLoading; ingestionRuns.isLoading;
const importLoading = ingestionRuns.isLoading || rawArtifacts.isLoading; const importLoading = ingestionRuns.isLoading || rawArtifacts.isLoading;
@@ -382,6 +413,8 @@ export function MarketDashboard() {
scenarioRuns.isError || scenarioRuns.isError ||
reports.isError || reports.isError ||
selectedReport.isError || selectedReport.isError ||
reportTemplates.isError ||
reportGenerationRuns.isError ||
dataSources.isError || dataSources.isError ||
ingestionRuns.isError || ingestionRuns.isError ||
rawArtifacts.isError; rawArtifacts.isError;
@@ -417,6 +450,8 @@ export function MarketDashboard() {
void scenarioRuns.refetch(); void scenarioRuns.refetch();
void reports.refetch(); void reports.refetch();
void selectedReport.refetch(); void selectedReport.refetch();
void reportTemplates.refetch();
void reportGenerationRuns.refetch();
}} }}
disabled={isLoading} disabled={isLoading}
aria-label="刷新" aria-label="刷新"
@@ -597,17 +632,26 @@ export function MarketDashboard() {
areaScores={scores.data ?? []} areaScores={scores.data ?? []}
neighborhoodScores={neighborhoodScores.data ?? []} neighborhoodScores={neighborhoodScores.data ?? []}
reports={reports.data ?? []} reports={reports.data ?? []}
templates={reportTemplates.data ?? []}
generationRuns={reportGenerationRuns.data ?? []}
selectedReport={selectedReport.data ?? null} selectedReport={selectedReport.data ?? null}
selectedReportId={selectedReportId} selectedReportId={selectedReportId}
reportTypeFilter={reportTypeFilter} reportTypeFilter={reportTypeFilter}
loading={reports.isLoading || selectedReport.isLoading} loading={
reports.isLoading ||
selectedReport.isLoading ||
reportTemplates.isLoading ||
reportGenerationRuns.isLoading
}
creating={createReportMutation.isPending} creating={createReportMutation.isPending}
generating={createReportGenerationMutation.isPending}
onReportTypeFilterChange={(value) => { onReportTypeFilterChange={(value) => {
setReportTypeFilter(value); setReportTypeFilter(value);
setSelectedReportId(null); setSelectedReportId(null);
}} }}
onSelectReport={setSelectedReportId} onSelectReport={setSelectedReportId}
onCreate={(payload) => createReportMutation.mutate(payload)} onCreate={(payload) => createReportMutation.mutate(payload)}
onGenerate={(payload) => createReportGenerationMutation.mutate(payload)}
/> />
<ModelRunPanel <ModelRunPanel
@@ -2561,26 +2605,34 @@ function ReportCenter({
areaScores, areaScores,
neighborhoodScores, neighborhoodScores,
reports, reports,
templates,
generationRuns,
selectedReport, selectedReport,
selectedReportId, selectedReportId,
reportTypeFilter, reportTypeFilter,
loading, loading,
creating, creating,
generating,
onReportTypeFilterChange, onReportTypeFilterChange,
onSelectReport, onSelectReport,
onCreate, onCreate,
onGenerate,
}: { }: {
areaScores: AreaScore[]; areaScores: AreaScore[];
neighborhoodScores: NeighborhoodScore[]; neighborhoodScores: NeighborhoodScore[];
reports: Report[]; reports: Report[];
templates: ReportTemplate[];
generationRuns: ReportGenerationRun[];
selectedReport: Report | null; selectedReport: Report | null;
selectedReportId: number | null; selectedReportId: number | null;
reportTypeFilter: string; reportTypeFilter: string;
loading: boolean; loading: boolean;
creating: boolean; creating: boolean;
generating: boolean;
onReportTypeFilterChange: (value: string) => void; onReportTypeFilterChange: (value: string) => void;
onSelectReport: (reportId: number) => void; onSelectReport: (reportId: number) => void;
onCreate: (payload: CreateReport) => void; onCreate: (payload: CreateReport) => void;
onGenerate: (payload: CreateReportGenerationRun) => void;
}) { }) {
const [draftType, setDraftType] = useState<CreateReport["report_type"]>("monthly"); const [draftType, setDraftType] = useState<CreateReport["report_type"]>("monthly");
const [draftAreaId, setDraftAreaId] = useState(""); const [draftAreaId, setDraftAreaId] = useState("");
@@ -2629,7 +2681,7 @@ function ReportCenter({
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
{(["monthly", "area_special", "neighborhood_memo"] as const).map((type) => ( {(["monthly", "weekly", "area_special", "neighborhood_memo"] as const).map((type) => (
<Button <Button
key={type} key={type}
variant={reportTypeFilter === type ? "default" : "outline"} variant={reportTypeFilter === type ? "default" : "outline"}
@@ -2642,6 +2694,14 @@ function ReportCenter({
</CardHeader> </CardHeader>
<CardContent className="grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]"> <CardContent className="grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
<div className="grid gap-4"> <div className="grid gap-4">
<ReportGenerationPanel
templates={templates}
runs={generationRuns}
loading={loading}
generating={generating}
onGenerate={onGenerate}
onSelectReport={onSelectReport}
/>
<div className="rounded-md border border-[var(--border)] p-4"> <div className="rounded-md border border-[var(--border)] p-4">
<p className="text-sm font-semibold text-slate-950"></p> <p className="text-sm font-semibold text-slate-950"></p>
<div className="mt-3 grid gap-3"> <div className="mt-3 grid gap-3">
@@ -2789,6 +2849,115 @@ function ReportList({
); );
} }
function ReportGenerationPanel({
templates,
runs,
loading,
generating,
onGenerate,
onSelectReport,
}: {
templates: ReportTemplate[];
runs: ReportGenerationRun[];
loading: boolean;
generating: boolean;
onGenerate: (payload: CreateReportGenerationRun) => void;
onSelectReport: (reportId: number) => void;
}) {
const [templateKey, setTemplateKey] = useState("");
const selectedTemplate = templates.find((template) => template.template_key === templateKey);
useEffect(() => {
if (!templateKey && templates.length > 0) {
setTemplateKey(templates[0].template_key);
}
}, [templateKey, templates]);
return (
<div className="rounded-md border border-[var(--border)] p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-slate-950"></p>
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
/
</p>
</div>
<Badge variant="muted">{runs.length}</Badge>
</div>
<div className="mt-3 grid gap-3">
<label className="grid gap-1 text-sm">
<span className="font-medium text-slate-950"></span>
<select
value={templateKey}
onChange={(event) => setTemplateKey(event.target.value)}
className="h-9 rounded-md border border-[var(--border)] px-3"
disabled={templates.length === 0}
>
{templates.map((template) => (
<option key={template.template_key} value={template.template_key}>
{template.name}
</option>
))}
</select>
</label>
{selectedTemplate ? (
<p className="text-xs text-[var(--muted-foreground)]">
{selectedTemplate.cadence} · {selectedTemplate.description}
</p>
) : null}
<Button
onClick={() =>
onGenerate({
template_key: templateKey,
target_month: MONTH,
parameters: { source: "dashboard" },
})
}
disabled={generating || loading || !templateKey}
>
{generating ? "生成中" : "生成报告"}
</Button>
</div>
{runs.length > 0 ? (
<div className="mt-4 overflow-x-auto rounded-md border border-[var(--border)]">
<Table>
<TableBody>
{runs.slice(0, 5).map((run) => (
<TableRow key={run.generation_run_id}>
<TableCell>
<span className="block font-medium text-slate-950">{run.template_name}</span>
<span className="block text-xs text-[var(--muted-foreground)]">
{run.target_month} · {run.status}
</span>
{run.error_message ? (
<span className="block text-xs text-rose-700">{run.error_message}</span>
) : null}
</TableCell>
<TableCell className="text-right">
{run.report_id ? (
<Button
variant="outline"
className="h-8 px-2"
onClick={() => onSelectReport(run.report_id ?? 0)}
>
</Button>
) : (
<Badge variant={run.status === "failed" ? "danger" : "muted"}>
{run.status}
</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : null}
</div>
);
}
function ReportViewer({ report, loading }: { report: Report | null; loading: boolean }) { function ReportViewer({ report, loading }: { report: Report | null; loading: boolean }) {
if (loading && !report) { if (loading && !report) {
return <div className="h-[520px] rounded-md bg-[var(--muted)]" />; return <div className="h-[520px] rounded-md bg-[var(--muted)]" />;
@@ -2854,6 +3023,9 @@ function reportTypeLabel(type: string) {
if (type === "monthly") { if (type === "monthly") {
return "月报"; return "月报";
} }
if (type === "weekly") {
return "周报";
}
if (type === "area_special") { if (type === "area_special") {
return "板块专题"; return "板块专题";
} }

View File

@@ -486,7 +486,7 @@ export type Report = {
}; };
export type CreateReport = { export type CreateReport = {
report_type: "monthly" | "area_special" | "neighborhood_memo"; report_type: "monthly" | "weekly" | "area_special" | "neighborhood_memo";
title: string; title: string;
target_month?: string; target_month?: string;
area_id?: string; area_id?: string;
@@ -505,6 +505,44 @@ export type ReportFilters = {
status?: string; status?: string;
}; };
export type ReportTemplate = {
template_id: number;
template_key: string;
name: string;
report_type: string;
cadence: string;
description: string;
body_template: string;
created_at: string;
updated_at: string;
};
export type ReportGenerationRun = {
generation_run_id: number;
template_id: number;
template_key: string;
template_name: string;
target_month: string;
status: string;
report_id: number | null;
report_title: string | null;
parameters: Record<string, unknown>;
error_message: string | null;
started_at: string;
finished_at: string | null;
};
export type CreateReportGenerationRun = {
template_key: string;
target_month: string;
parameters?: Record<string, unknown>;
};
export type ReportGenerationRunResponse = {
generation_run: ReportGenerationRun;
report: Report | null;
};
export type ComparisonMetricValue = { export type ComparisonMetricValue = {
id: string; id: string;
value: number | null; value: number | null;
@@ -798,6 +836,34 @@ export async function createReport(payload: CreateReport): Promise<Report> {
}); });
} }
export async function fetchReportTemplates(): Promise<ReportTemplate[]> {
return fetchJson("/api/v1/report-templates");
}
export async function fetchReportGenerationRuns(filters: {
target_month?: string;
template_key?: string;
status?: string;
} = {}): Promise<ReportGenerationRun[]> {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filters)) {
if (value) {
params.set(key, value);
}
}
const query = params.toString();
return fetchJson(`/api/v1/report-generation-runs${query ? `?${query}` : ""}`);
}
export async function createReportGenerationRun(
payload: CreateReportGenerationRun,
): Promise<ReportGenerationRunResponse> {
return fetchJson("/api/v1/report-generation-runs", {
method: "POST",
body: JSON.stringify(payload),
});
}
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> { async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, { const response = await fetch(`${API_BASE_URL}${path}`, {
...init, ...init,

View File

@@ -363,20 +363,26 @@
### M4.2 自动周报/月报 ### M4.2 自动周报/月报
状态:已完成。
目标: 目标:
- 定时生成市场周报和月报。 - 定时生成市场周报和月报。
交付物: 交付物:
- 报告任务 - `app.report_templates` 报告模板表
- 报告模板 - `app.report_generation_runs` 报告生成记录表
- 报告生成记录 - `GET /api/v1/report-templates`
- `GET /api/v1/report-generation-runs`
- `POST /api/v1/report-generation-runs` 手工触发市场月报和观察池周报生成。
- 前端报告中心支持选择模板、触发生成、查看生成状态和产出报告。
验收标准: 验收标准:
- 可手工触发。 - 可手工触发。
- 可查看生成状态和错误信息。 - 可查看生成状态和错误信息。
- 当前完成可手工触发的任务化生成,后续定时调度可复用同一 API。
### M4.3 观察池预警 ### M4.3 观察池预警