From a333b3673f122a50417863d6d9439c4f61678422 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 17 Apr 2026 11:01:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugin):=20timeseries=20=E8=81=9A=E5=90=88?= =?UTF-8?q?=20API=20=E2=80=94=20date=5Ftrunc=20=E6=97=B6=E9=97=B4=E5=BA=8F?= =?UTF-8?q?=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/erp-plugin/src/data_dto.rs | 22 ++++ crates/erp-plugin/src/data_service.rs | 46 +++++++ crates/erp-plugin/src/dynamic_table.rs | 118 ++++++++++++++++++ crates/erp-plugin/src/handler/data_handler.rs | 43 ++++++- crates/erp-plugin/src/module.rs | 5 + 5 files changed, 233 insertions(+), 1 deletion(-) diff --git a/crates/erp-plugin/src/data_dto.rs b/crates/erp-plugin/src/data_dto.rs index def405a..31e146e 100644 --- a/crates/erp-plugin/src/data_dto.rs +++ b/crates/erp-plugin/src/data_dto.rs @@ -83,3 +83,25 @@ pub struct BatchActionReq { /// batch_update 时的更新数据 pub data: Option, } + +/// 时间序列查询参数 +#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)] +pub struct TimeseriesParams { + /// 时间字段名 + pub time_field: String, + /// 时间粒度: "day" / "week" / "month" + pub time_grain: String, + /// 开始日期 (ISO) + pub start: Option, + /// 结束日期 (ISO) + pub end: Option, +} + +/// 时间序列数据项 +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct TimeseriesItem { + /// 时间周期 + pub period: String, + /// 计数 + pub count: i64, +} diff --git a/crates/erp-plugin/src/data_service.rs b/crates/erp-plugin/src/data_service.rs index c8e4ae0..c859a0c 100644 --- a/crates/erp-plugin/src/data_service.rs +++ b/crates/erp-plugin/src/data_service.rs @@ -581,6 +581,52 @@ impl PluginDataService { // TODO: 未来版本添加 Redis 缓存层 Self::aggregate(plugin_id, entity_name, tenant_id, db, group_by_field, filter).await } + + /// 时间序列聚合 — 按时间字段截断为 day/week/month 统计计数 + pub async fn timeseries( + plugin_id: Uuid, + entity_name: &str, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + time_field: &str, + time_grain: &str, + start: Option, + end: Option, + ) -> AppResult> { + let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?; + + let (sql, values) = DynamicTableManager::build_timeseries_sql( + &info.table_name, + tenant_id, + time_field, + time_grain, + start.as_deref(), + end.as_deref(), + ) + .map_err(|e| AppError::Validation(e))?; + + #[derive(FromQueryResult)] + struct TsRow { + period: Option, + count: i64, + } + + let rows = TsRow::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + values, + )) + .all(db) + .await?; + + Ok(rows + .into_iter() + .map(|r| crate::data_dto::TimeseriesItem { + period: r.period.unwrap_or_default(), + count: r.count, + }) + .collect()) + } } /// 从 plugins 表解析 manifest metadata.id(如 "erp-crm") diff --git a/crates/erp-plugin/src/dynamic_table.rs b/crates/erp-plugin/src/dynamic_table.rs index 8956a69..ffa341e 100644 --- a/crates/erp-plugin/src/dynamic_table.rs +++ b/crates/erp-plugin/src/dynamic_table.rs @@ -783,6 +783,61 @@ impl DynamicTableManager { Ok((sql, values)) } + + /// 构建时间序列查询 SQL + pub fn build_timeseries_sql( + table_name: &str, + tenant_id: Uuid, + time_field: &str, + time_grain: &str, + start: Option<&str>, + end: Option<&str>, + ) -> Result<(String, Vec), String> { + let clean_field = sanitize_identifier(time_field); + let grain = match time_grain { + "day" | "week" | "month" => time_grain, + _ => return Err(format!("不支持的 time_grain: {}", time_grain)), + }; + + let mut conditions = vec![ + "\"tenant_id\" = $1".to_string(), + "\"deleted_at\" IS NULL".to_string(), + ]; + let mut values: Vec = vec![tenant_id.into()]; + let mut param_idx = 2; + + if let Some(s) = start { + conditions.push(format!( + "(data->>'{}')::timestamp >= ${}", + clean_field, param_idx + )); + values.push(Value::String(Some(Box::new(s.to_string())))); + param_idx += 1; + } + if let Some(e) = end { + conditions.push(format!( + "(data->>'{}')::timestamp < ${}", + clean_field, param_idx + )); + values.push(Value::String(Some(Box::new(e.to_string())))); + } + + let sql = format!( + "SELECT to_char(date_trunc('{}', (data->>'{}')::timestamp), 'YYYY-MM-DD') as period, \ + COUNT(*) as count \ + FROM \"{}\" WHERE {} \ + GROUP BY date_trunc('{}', (data->>'{}')::timestamp) \ + ORDER BY period", + grain, + clean_field, + table_name, + conditions.join(" AND "), + grain, + clean_field, + ); + + Ok((sql, values)) + } } #[cfg(test)] @@ -1375,4 +1430,67 @@ mod tests { sql ); } + + // ===== build_timeseries_sql 测试 ===== + + #[test] + fn test_build_timeseries_sql_day_grain() { + let (sql, values) = DynamicTableManager::build_timeseries_sql( + "plugin_test", + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + "occurred_at", + "day", + None, + None, + ) + .unwrap(); + assert!(sql.contains("date_trunc('day'"), "应有 day 粒度"); + assert!(sql.contains("GROUP BY"), "应有 GROUP BY"); + assert!(sql.contains("ORDER BY period"), "应按 period 排序"); + assert_eq!(values.len(), 1, "仅 tenant_id"); + } + + #[test] + fn test_build_timeseries_sql_month_grain() { + let (sql, _) = DynamicTableManager::build_timeseries_sql( + "plugin_test", + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + "created_date", + "month", + None, + None, + ) + .unwrap(); + assert!(sql.contains("date_trunc('month'"), "应有 month 粒度"); + } + + #[test] + fn test_build_timeseries_sql_with_date_range() { + let (sql, values) = DynamicTableManager::build_timeseries_sql( + "plugin_test", + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + "occurred_at", + "week", + Some("2026-01-01"), + Some("2026-04-01"), + ) + .unwrap(); + assert!(sql.contains("date_trunc('week'"), "应有 week 粒度"); + assert!(sql.contains(">="), "应有 start 条件"); + assert!(sql.contains("<"), "应有 end 条件"); + assert_eq!(values.len(), 3, "tenant_id + start + end"); + } + + #[test] + fn test_build_timeseries_sql_invalid_grain() { + let result = DynamicTableManager::build_timeseries_sql( + "plugin_test", + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + "occurred_at", + "hour", + None, + None, + ); + assert!(result.is_err(), "不支持的 grain 应报错"); + } } diff --git a/crates/erp-plugin/src/handler/data_handler.rs b/crates/erp-plugin/src/handler/data_handler.rs index 6217e83..f89a464 100644 --- a/crates/erp-plugin/src/handler/data_handler.rs +++ b/crates/erp-plugin/src/handler/data_handler.rs @@ -9,7 +9,8 @@ use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; use crate::data_dto::{ AggregateItem, AggregateQueryParams, BatchActionReq, CountQueryParams, CreatePluginDataReq, - PatchPluginDataReq, PluginDataListParams, PluginDataResp, UpdatePluginDataReq, + PatchPluginDataReq, PluginDataListParams, PluginDataResp, TimeseriesItem, TimeseriesParams, + UpdatePluginDataReq, }; use crate::data_service::{PluginDataService, resolve_manifest_id}; use crate::state::PluginState; @@ -457,3 +458,43 @@ where Ok(Json(ApiResponse::ok(items))) } + +#[utoipa::path( + get, + path = "/api/v1/plugins/{plugin_id}/{entity}/timeseries", + params(TimeseriesParams), + responses( + (status = 200, description = "时间序列数据", body = ApiResponse>), + ), + security(("bearer_auth" = [])), + tag = "插件数据" +)] +/// GET /api/v1/plugins/{plugin_id}/{entity}/timeseries — 时间序列聚合 +pub async fn get_plugin_timeseries( + State(state): State, + Extension(ctx): Extension, + Path((plugin_id, entity)): Path<(Uuid, String)>, + Query(params): Query, +) -> Result>>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?; + let fine_perm = compute_permission_code(&manifest_id, &entity, "list"); + require_permission(&ctx, &fine_perm)?; + + let result = PluginDataService::timeseries( + plugin_id, + &entity, + ctx.tenant_id, + &state.db, + ¶ms.time_field, + ¶ms.time_grain, + params.start, + params.end, + ) + .await?; + + Ok(Json(ApiResponse::ok(result))) +} diff --git a/crates/erp-plugin/src/module.rs b/crates/erp-plugin/src/module.rs index 3f8aff0..6b08494 100644 --- a/crates/erp-plugin/src/module.rs +++ b/crates/erp-plugin/src/module.rs @@ -91,6 +91,11 @@ impl PluginModule { .route( "/plugins/{plugin_id}/{entity}/batch", post(crate::handler::data_handler::batch_plugin_data::), + ) + // 时间序列路由 + .route( + "/plugins/{plugin_id}/{entity}/timeseries", + get(crate::handler::data_handler::get_plugin_timeseries::), ); admin_routes.merge(data_routes)