diff --git a/Cargo.lock b/Cargo.lock index bb83c78..adf5ef9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1614,6 +1614,7 @@ dependencies = [ "tracing", "utoipa", "uuid", + "validator", "wasmtime", "wasmtime-wasi", ] diff --git a/apps/miniprogram/src/services/auth.ts b/apps/miniprogram/src/services/auth.ts index af99db0..069f09a 100644 --- a/apps/miniprogram/src/services/auth.ts +++ b/apps/miniprogram/src/services/auth.ts @@ -32,6 +32,7 @@ export async function credentialLogin(username: string, password: string, tenant username, password, tenant_id: tenantId, + client_type: 'miniprogram', }); } diff --git a/apps/web/src/components/ai/AiAnalysisCard.tsx b/apps/web/src/components/ai/AiAnalysisCard.tsx index 03a9b6d..a975f77 100644 --- a/apps/web/src/components/ai/AiAnalysisCard.tsx +++ b/apps/web/src/components/ai/AiAnalysisCard.tsx @@ -69,14 +69,15 @@ export function AiAnalysisCard({ }, []); const TriggerButton = permission ? ( - } - loading={state === 'loading'} - onClick={handleStart} - size="small" - > - {triggerLabel} + + } + loading={state === 'loading'} + onClick={handleStart} + size="small" + > + {triggerLabel} + ) : ( - } - loading={analyzingTrend} - onClick={handleTrendAnalysis} - size="small" - > - AI 趋势分析 + + } + loading={analyzingTrend} + onClick={handleTrendAnalysis} + size="small" + > + AI 趋势分析 + diff --git a/crates/erp-auth/src/dto.rs b/crates/erp-auth/src/dto.rs index 06c0314..3366718 100644 --- a/crates/erp-auth/src/dto.rs +++ b/crates/erp-auth/src/dto.rs @@ -11,8 +11,11 @@ use erp_core::sanitize::{sanitize_option, sanitize_string}; pub struct LoginReq { #[validate(length(min = 1, message = "用户名不能为空"))] pub username: String, - #[validate(length(min = 1, message = "密码不能为空"))] + #[validate(length(min = 1, max = 128, message = "密码长度需在1-128之间"))] pub password: String, + /// 客户端类型: "miniprogram" 允许患者角色登录 + #[serde(default)] + pub client_type: Option, } #[derive(Debug, Serialize, ToSchema)] @@ -110,11 +113,15 @@ impl CreateUserReq { } } -#[derive(Debug, Deserialize, ToSchema)] +#[derive(Debug, Deserialize, Validate, ToSchema)] pub struct UpdateUserReq { + #[validate(email)] pub email: Option, + #[validate(length(max = 20))] pub phone: Option, + #[validate(length(max = 100))] pub display_name: Option, + #[validate(length(min = 1, max = 20))] pub status: Option, pub version: i32, } @@ -149,15 +156,17 @@ pub struct CreateRoleReq { pub description: Option, } -#[derive(Debug, Deserialize, ToSchema)] +#[derive(Debug, Deserialize, Validate, ToSchema)] pub struct UpdateRoleReq { + #[validate(length(min = 1, max = 50))] pub name: Option, pub description: Option, pub version: i32, } -#[derive(Debug, Deserialize, ToSchema)] +#[derive(Debug, Deserialize, Validate, ToSchema)] pub struct AssignRolesReq { + #[validate(length(min = 1, message = "至少需要分配一个角色"))] pub role_ids: Vec, } @@ -173,8 +182,9 @@ pub struct PermissionResp { pub description: Option, } -#[derive(Debug, Deserialize, ToSchema)] +#[derive(Debug, Deserialize, Validate, ToSchema)] pub struct AssignPermissionsReq { + #[validate(length(min = 1, message = "至少需要分配一个权限"))] pub permission_ids: Vec, } @@ -286,6 +296,7 @@ mod tests { let req = LoginReq { username: "admin".to_string(), password: "password123".to_string(), + client_type: None, }; assert!(req.validate().is_ok()); } @@ -295,6 +306,7 @@ mod tests { let req = LoginReq { username: "".to_string(), password: "password123".to_string(), + client_type: None, }; let result = req.validate(); assert!(result.is_err()); @@ -341,6 +353,7 @@ mod tests { let req = LoginReq { username: "admin".to_string(), password: "".to_string(), + client_type: None, }; assert!(req.validate().is_err()); } diff --git a/crates/erp-auth/src/handler/auth_handler.rs b/crates/erp-auth/src/handler/auth_handler.rs index 070a694..99a32de 100644 --- a/crates/erp-auth/src/handler/auth_handler.rs +++ b/crates/erp-auth/src/handler/auth_handler.rs @@ -71,6 +71,7 @@ where &jwt_config, &state.event_bus, Some(&req_info), + req.client_type.as_deref(), ) .await?; diff --git a/crates/erp-auth/src/service/auth_service.rs b/crates/erp-auth/src/service/auth_service.rs index 9728eff..6131e5e 100644 --- a/crates/erp-auth/src/service/auth_service.rs +++ b/crates/erp-auth/src/service/auth_service.rs @@ -42,6 +42,7 @@ impl AuthService { /// 6. Sign JWT tokens /// 7. Update last_login_at /// 8. Publish login event + #[allow(clippy::too_many_arguments)] pub async fn login( tenant_id: Uuid, username: &str, @@ -50,6 +51,7 @@ impl AuthService { jwt: &JwtConfig<'_>, event_bus: &EventBus, req_info: Option<&RequestInfo>, + client_type: Option<&str>, ) -> AuthResult { // 1. Find user by tenant_id + username let user_model = match user::Entity::find() @@ -115,11 +117,13 @@ impl AuthService { let roles: Vec = TokenService::get_user_roles(user_model.id, tenant_id, db).await?; // 纯患者角色不允许登录管理端(同时拥有医护角色则放行) + // 小程序端 (client_type=miniprogram) 允许患者登录 let medical_roles = ["doctor", "nurse", "admin", "health_manager", "operator"]; let is_pure_patient = roles.iter().all(|r| r == "patient") && roles.iter().any(|r| r == "patient"); let has_medical_role = roles.iter().any(|r| medical_roles.contains(&r.as_str())); - if is_pure_patient && !has_medical_role { + let is_miniprogram = client_type == Some("miniprogram"); + if is_pure_patient && !has_medical_role && !is_miniprogram { return Err(AuthError::Forbidden("患者账号请使用小程序登录".to_string())); } diff --git a/crates/erp-config/src/dto.rs b/crates/erp-config/src/dto.rs index 5bff8f5..9164435 100644 --- a/crates/erp-config/src/dto.rs +++ b/crates/erp-config/src/dto.rs @@ -37,8 +37,9 @@ pub struct CreateDictionaryReq { pub description: Option, } -#[derive(Debug, Deserialize, ToSchema)] +#[derive(Debug, Deserialize, Validate, ToSchema)] pub struct UpdateDictionaryReq { + #[validate(length(min = 1, max = 100, message = "字典名称不能为空且不超过100字符"))] pub name: Option, pub description: Option, pub version: i32, @@ -54,9 +55,11 @@ pub struct CreateDictionaryItemReq { pub color: Option, } -#[derive(Debug, Deserialize, ToSchema)] +#[derive(Debug, Deserialize, Validate, ToSchema)] pub struct UpdateDictionaryItemReq { + #[validate(length(min = 1, max = 100, message = "标签不能为空且不超过100字符"))] pub label: Option, + #[validate(length(min = 1, max = 100, message = "值不能为空且不超过100字符"))] pub value: Option, pub sort_order: Option, pub color: Option, @@ -99,8 +102,9 @@ pub struct CreateMenuReq { pub role_ids: Option>, } -#[derive(Debug, Deserialize, ToSchema)] +#[derive(Debug, Deserialize, Validate, ToSchema)] pub struct UpdateMenuReq { + #[validate(length(min = 1, max = 100, message = "菜单标题不能为空且不超过100字符"))] pub title: Option, pub path: Option, pub icon: Option, @@ -198,8 +202,9 @@ pub struct CreateNumberingRuleReq { pub reset_cycle: Option, } -#[derive(Debug, Deserialize, ToSchema)] +#[derive(Debug, Deserialize, Validate, ToSchema)] pub struct UpdateNumberingRuleReq { + #[validate(length(min = 1, max = 100, message = "规则名称不能为空且不超过100字符"))] pub name: Option, pub prefix: Option, pub date_format: Option, @@ -252,9 +257,10 @@ pub struct LanguageResp { pub is_active: bool, } -#[derive(Debug, Deserialize, ToSchema)] +#[derive(Debug, Deserialize, Validate, ToSchema)] pub struct UpdateLanguageReq { pub is_active: bool, + #[validate(length(min = 1, max = 100, message = "语言名称不能为空且不超过100字符"))] pub name: Option, } diff --git a/crates/erp-health/src/oauth/dto.rs b/crates/erp-health/src/oauth/dto.rs index 5ecde23..8abeff6 100644 --- a/crates/erp-health/src/oauth/dto.rs +++ b/crates/erp-health/src/oauth/dto.rs @@ -1,11 +1,15 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; +use validator::Validate; /// RFC 6749 §4.4 Client Credentials Grant 请求 -#[derive(Debug, Deserialize, ToSchema)] +#[derive(Debug, Deserialize, Validate, ToSchema)] pub struct TokenRequest { + #[validate(length(min = 1, max = 50, message = "grant_type 不能为空"))] pub grant_type: String, + #[validate(length(min = 1, max = 128, message = "client_id 不能为空"))] pub client_id: String, + #[validate(length(min = 1, max = 128, message = "client_secret 不能为空"))] pub client_secret: String, #[serde(default)] pub scope: Option, @@ -51,14 +55,18 @@ impl TokenErrorResponse { } /// 合作方创建请求 -#[derive(Debug, Deserialize, ToSchema)] +#[derive(Debug, Deserialize, Validate, ToSchema)] pub struct CreateApiClientReq { + #[validate(length(min = 1, max = 100, message = "客户端名称不能为空且不超过100字符"))] pub client_name: String, + #[validate(length(min = 1, message = "至少需要一个权限范围"))] pub scopes: Vec, pub allowed_patient_ids: Option>, #[serde(default = "default_rate_limit")] + #[validate(range(min = 1, max = 10000, message = "速率限制需在1-10000之间"))] pub rate_limit_per_minute: i32, #[serde(default = "default_token_lifetime")] + #[validate(range(min = 60, max = 86400, message = "令牌有效期需在60-86400秒之间"))] pub token_lifetime_seconds: i32, } @@ -100,13 +108,17 @@ pub struct ApiClientListItem { } /// 更新合作方请求 -#[derive(Debug, Deserialize, ToSchema)] +#[derive(Debug, Deserialize, Validate, ToSchema)] pub struct UpdateApiClientReq { + #[validate(length(min = 1, max = 100, message = "客户端名称不能为空且不超过100字符"))] pub client_name: Option, + #[validate(length(min = 1, message = "至少需要一个权限范围"))] pub scopes: Option>, pub allowed_patient_ids: Option>>, + #[validate(range(min = 1, max = 10000, message = "速率限制需在1-10000之间"))] pub rate_limit_per_minute: Option, pub is_active: Option, + #[validate(range(min = 60, max = 86400, message = "令牌有效期需在60-86400秒之间"))] pub token_lifetime_seconds: Option, pub version: i32, } diff --git a/crates/erp-health/src/oauth/handler.rs b/crates/erp-health/src/oauth/handler.rs index 93d23fa..2844de8 100644 --- a/crates/erp-health/src/oauth/handler.rs +++ b/crates/erp-health/src/oauth/handler.rs @@ -7,6 +7,7 @@ use erp_core::error::AppError; use erp_core::rbac::require_permission; use erp_core::types::TenantContext; use uuid::Uuid; +use validator::Validate; use crate::oauth::dto::*; use crate::oauth::error::OAuthError; @@ -18,6 +19,12 @@ pub async fn token( State(state): State, Json(req): Json, ) -> Result<(StatusCode, Json), (StatusCode, Json)> { + if let Err(e) = req.validate() { + return Err(( + StatusCode::BAD_REQUEST, + Json(TokenErrorResponse::invalid_grant(&e.to_string())), + )); + } let jwt_secret = &state.jwt_secret; match OAuthService::token(&state.db, &req, jwt_secret).await { @@ -59,6 +66,8 @@ pub async fn create_client( Json(req): Json, ) -> Result, AppError> { require_permission(&tenant_ctx, "health.oauth.manage")?; + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; OAuthService::create_client(&state.db, tenant_ctx.tenant_id, &req, tenant_ctx.user_id) .await .map_err(AppError::from) @@ -85,6 +94,8 @@ pub async fn update_client( Json(req): Json, ) -> Result, AppError> { require_permission(&tenant_ctx, "health.oauth.manage")?; + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; OAuthService::update_client( &state.db, tenant_ctx.tenant_id, diff --git a/crates/erp-message/src/dto.rs b/crates/erp-message/src/dto.rs index 4a5d617..01595bb 100644 --- a/crates/erp-message/src/dto.rs +++ b/crates/erp-message/src/dto.rs @@ -158,7 +158,9 @@ pub struct UpdateTemplateReq { pub title_template: Option, #[validate(length(min = 1, message = "内容模板不能为空"))] pub body_template: Option, + #[validate(length(min = 1, max = 10, message = "语言代码无效"))] pub language: Option, + #[validate(custom(function = "validate_channel"))] pub channel: Option, pub version: i32, } @@ -182,12 +184,14 @@ pub struct MessageSubscriptionResp { } /// 更新消息订阅偏好请求 -#[derive(Debug, Deserialize, ToSchema)] +#[derive(Debug, Deserialize, Validate, ToSchema)] pub struct UpdateSubscriptionReq { pub notification_types: Option, pub channel_preferences: Option, pub dnd_enabled: Option, + #[validate(length(max = 8, message = "免打扰开始时间格式无效"))] pub dnd_start: Option, + #[validate(length(max = 8, message = "免打扰结束时间格式无效"))] pub dnd_end: Option, pub version: i32, } diff --git a/crates/erp-plugin/Cargo.toml b/crates/erp-plugin/Cargo.toml index 8ead83d..eeb78d3 100644 --- a/crates/erp-plugin/Cargo.toml +++ b/crates/erp-plugin/Cargo.toml @@ -27,3 +27,4 @@ moka = { version = "0.12", features = ["sync"] } regex = "1" csv = { workspace = true } rust_xlsxwriter = { workspace = true } +validator = { workspace = true } diff --git a/crates/erp-plugin/src/data_dto.rs b/crates/erp-plugin/src/data_dto.rs index bf6acbc..9fb70a4 100644 --- a/crates/erp-plugin/src/data_dto.rs +++ b/crates/erp-plugin/src/data_dto.rs @@ -1,8 +1,9 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; /// 插件数据记录响应 -#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct PluginDataResp { pub id: String, pub data: serde_json::Value, @@ -12,27 +13,27 @@ pub struct PluginDataResp { } /// 创建插件数据请求 -#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct CreatePluginDataReq { pub data: serde_json::Value, } /// 更新插件数据请求(全量替换) -#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct UpdatePluginDataReq { pub data: serde_json::Value, pub version: i32, } /// 部分更新请求(PATCH — 只合并提供的字段) -#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct PatchPluginDataReq { pub data: serde_json::Value, pub version: i32, } /// 插件数据列表查询参数 -#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)] +#[derive(Debug, Serialize, Deserialize, IntoParams)] pub struct PluginDataListParams { pub page: Option, pub page_size: Option, @@ -47,7 +48,7 @@ pub struct PluginDataListParams { } /// 聚合查询响应项 -#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct AggregateItem { /// 分组键(字段值) pub key: String, @@ -56,7 +57,7 @@ pub struct AggregateItem { } /// 多聚合查询响应项 -#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct AggregateMultiRow { /// 分组键 pub key: String, @@ -68,7 +69,7 @@ pub struct AggregateMultiRow { } /// 聚合查询参数 -#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)] +#[derive(Debug, Serialize, Deserialize, IntoParams)] pub struct AggregateQueryParams { /// 分组字段名 pub group_by: String, @@ -77,7 +78,7 @@ pub struct AggregateQueryParams { } /// 多聚合查询请求体 -#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct AggregateMultiReq { /// 分组字段名 pub group_by: String, @@ -88,7 +89,7 @@ pub struct AggregateMultiReq { } /// 单个聚合定义 -#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct AggregateDefDto { /// 聚合函数: count, sum, avg, min, max pub func: String, @@ -97,7 +98,7 @@ pub struct AggregateDefDto { } /// 统计查询参数 -#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)] +#[derive(Debug, Serialize, Deserialize, IntoParams)] pub struct CountQueryParams { /// 搜索关键词 pub search: Option, @@ -106,7 +107,7 @@ pub struct CountQueryParams { } /// 批量操作请求 -#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct BatchActionReq { /// 操作类型: "batch_delete" 或 "batch_update" pub action: String, @@ -117,7 +118,7 @@ pub struct BatchActionReq { } /// 时间序列查询参数 -#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)] +#[derive(Debug, Serialize, Deserialize, IntoParams)] pub struct TimeseriesParams { /// 时间字段名 pub time_field: String, @@ -130,7 +131,7 @@ pub struct TimeseriesParams { } /// 时间序列数据项 -#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct TimeseriesItem { /// 时间周期 pub period: String, @@ -141,14 +142,14 @@ pub struct TimeseriesItem { // ─── 跨插件引用 DTO ────────────────────────────────────────────────── /// 批量标签解析请求 -#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct ResolveLabelsReq { /// 字段名 → UUID 列表 pub fields: std::collections::HashMap>, } /// 批量标签解析响应 -#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct ResolveLabelsResp { /// 字段名 → { uuid: label } 映射 pub labels: serde_json::Value, @@ -157,7 +158,7 @@ pub struct ResolveLabelsResp { } /// 公开实体信息(实体注册表查询响应) -#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct PublicEntityResp { pub manifest_id: String, pub plugin_id: String, @@ -168,7 +169,7 @@ pub struct PublicEntityResp { // ─── 导入导出 DTO ────────────────────────────────────────────────── /// 数据导出参数 -#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)] +#[derive(Debug, Serialize, Deserialize, IntoParams)] pub struct ExportParams { /// JSON 格式过滤: {"field":"value"} pub filter: Option, @@ -190,14 +191,14 @@ pub enum ExportPayload { } /// 数据导入请求 -#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct ImportReq { /// 导入数据行列表,每行是一个 JSON 对象 pub rows: Vec, } /// 数据导入结果 -#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct ImportResult { /// 成功导入行数 pub success_count: usize, @@ -209,7 +210,7 @@ pub struct ImportResult { } /// 单行导入错误 -#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct ImportRowError { /// 行号(0-based) pub row: usize, @@ -220,7 +221,7 @@ pub struct ImportRowError { // ─── 市场目录 DTO ────────────────────────────────────────────────── /// 市场条目列表查询参数 -#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)] +#[derive(Debug, Serialize, Deserialize, IntoParams)] pub struct MarketListParams { pub page: Option, pub page_size: Option, @@ -229,7 +230,7 @@ pub struct MarketListParams { } /// 市场条目响应(不含二进制数据) -#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct MarketEntryResp { pub id: String, pub plugin_id: String, @@ -252,7 +253,7 @@ pub struct MarketEntryResp { } /// 市场条目详情响应(含完整信息) -#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct MarketEntryDetailResp { #[serde(flatten)] pub entry: MarketEntryResp, @@ -261,7 +262,7 @@ pub struct MarketEntryDetailResp { } /// 提交评分/评论请求 -#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct SubmitReviewReq { /// 评分 1-5 pub rating: i32, @@ -270,7 +271,7 @@ pub struct SubmitReviewReq { } /// 评论响应 -#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct MarketReviewResp { pub id: String, pub user_id: String, @@ -283,7 +284,7 @@ pub struct MarketReviewResp { // ─── 对账扫描 DTO ────────────────────────────────────────────────── /// 对账报告 -#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct ReconciliationReport { /// 有效引用数 pub valid_count: i64, @@ -294,7 +295,7 @@ pub struct ReconciliationReport { } /// 悬空引用详情 -#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct DanglingRef { /// 实体名 pub entity: String, @@ -309,7 +310,7 @@ pub struct DanglingRef { // ─── 自定义视图 DTO ────────────────────────────────────────────────── /// 用户视图配置请求 -#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct UserViewReq { pub view_name: String, pub view_config: serde_json::Value, @@ -317,7 +318,7 @@ pub struct UserViewReq { } /// 用户视图响应 -#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct UserViewResp { pub id: String, pub plugin_id: String, diff --git a/crates/erp-plugin/src/dto.rs b/crates/erp-plugin/src/dto.rs index 0749391..54ec9dd 100644 --- a/crates/erp-plugin/src/dto.rs +++ b/crates/erp-plugin/src/dto.rs @@ -1,6 +1,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; +use validator::Validate; /// 插件信息响应 #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] @@ -49,17 +50,19 @@ pub struct PluginHealthResp { } /// 更新插件配置请求 -#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct UpdatePluginConfigReq { pub config: serde_json::Value, pub version: i32, } /// 插件列表查询参数 -#[derive(Debug, Serialize, Deserialize, utoipa::IntoParams)] +#[derive(Debug, Serialize, Deserialize, Validate, utoipa::IntoParams)] pub struct PluginListParams { pub page: Option, pub page_size: Option, + #[validate(length(max = 20, message = "状态值无效"))] pub status: Option, + #[validate(length(max = 100, message = "搜索关键词过长"))] pub search: Option, } diff --git a/crates/erp-plugin/src/handler/plugin_handler.rs b/crates/erp-plugin/src/handler/plugin_handler.rs index 3cabc9f..fc6ddbc 100644 --- a/crates/erp-plugin/src/handler/plugin_handler.rs +++ b/crates/erp-plugin/src/handler/plugin_handler.rs @@ -2,6 +2,7 @@ use axum::Extension; use axum::extract::{FromRef, Multipart, Path, Query, State}; use axum::response::Json; use uuid::Uuid; +use validator::Validate; use erp_core::error::AppError; use erp_core::rbac::require_permission; @@ -391,6 +392,8 @@ where S: Clone + Send + Sync + 'static, { require_permission(&ctx, "plugin.admin")?; + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; let result = PluginService::update_config( id, ctx.tenant_id, diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index 4b44bf6..7e6ded4 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -413,7 +413,8 @@ async fn main() -> anyhow::Result<()> { // 恢复运行中的插件(服务器重启后自动重新加载) match plugin_engine.recover_plugins(&db).await { Ok(recovered) => { - tracing::info!(count = recovered.len(), "Plugins recovered"); + let count: usize = recovered.len(); + tracing::info!(count, "Plugins recovered"); } Err(e) => { tracing::error!(error = %e, "Failed to recover plugins"); diff --git a/crates/erp-workflow/src/dto.rs b/crates/erp-workflow/src/dto.rs index 1bc7dd6..0d932c5 100644 --- a/crates/erp-workflow/src/dto.rs +++ b/crates/erp-workflow/src/dto.rs @@ -45,12 +45,14 @@ pub struct NodePosition { } /// ServiceTask HTTP 调用配置 -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, Validate, ToSchema)] pub struct ServiceTaskConfig { - /// 请求 URL + /// 请求 URL(仅允许 http/https 协议,禁止内网地址) + #[validate(length(min = 1, max = 2048), custom(function = "validate_service_url"))] pub url: String, /// HTTP 方法(GET / POST),默认 GET #[serde(default = "default_method")] + #[validate(custom(function = "validate_http_method"))] pub method: String, /// POST body 模板(支持从流程变量替换 ${var_name}) #[serde(skip_serializing_if = "Option::is_none")] @@ -61,6 +63,23 @@ fn default_method() -> String { "GET".to_string() } +fn validate_service_url(value: &str) -> Result<(), validator::ValidationError> { + if !value.starts_with("https://") && !value.starts_with("http://") { + return Err(validator::ValidationError::new("invalid_url_scheme")); + } + if value.contains("127.0.0.1") || value.contains("localhost") || value.contains("0.0.0.0") { + return Err(validator::ValidationError::new("ssrf_blocked")); + } + Ok(()) +} + +fn validate_http_method(value: &str) -> Result<(), validator::ValidationError> { + match value { + "GET" | "POST" => Ok(()), + _ => Err(validator::ValidationError::new("invalid_http_method")), + } +} + /// 流程图连线定义 #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct EdgeDef {