fix: DTO 输入校验补全 + 编译修复 + AuthButton 类型修复

- erp-auth/config/workflow/message/plugin/health: 44 处 DTO 校验缺失修复
- erp-plugin/data_dto: utoipa derive 宏 import 修复
- erp-server/main: tracing 宏类型推断修复
- web AuthButton: AiAnalysisCard/VitalSignsTab Button 包裹在 children 内

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
iven
2026-05-20 06:58:54 +08:00
parent d74c7a61de
commit f3bf8b3b1d
17 changed files with 149 additions and 66 deletions

1
Cargo.lock generated
View File

@@ -1614,6 +1614,7 @@ dependencies = [
"tracing",
"utoipa",
"uuid",
"validator",
"wasmtime",
"wasmtime-wasi",
]

View File

@@ -32,6 +32,7 @@ export async function credentialLogin(username: string, password: string, tenant
username,
password,
tenant_id: tenantId,
client_type: 'miniprogram',
});
}

View File

@@ -69,14 +69,15 @@ export function AiAnalysisCard({
}, []);
const TriggerButton = permission ? (
<AuthButton
code={permission}
icon={<ThunderboltOutlined />}
loading={state === 'loading'}
onClick={handleStart}
size="small"
>
{triggerLabel}
<AuthButton code={permission}>
<Button
icon={<ThunderboltOutlined />}
loading={state === 'loading'}
onClick={handleStart}
size="small"
>
{triggerLabel}
</Button>
</AuthButton>
) : (
<Button

View File

@@ -225,14 +225,15 @@ export function VitalSignsTab({ patientId }: Props) {
{/* 趋势图 */}
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
<AuthButton
code="ai.analysis.manage"
icon={<ThunderboltOutlined />}
loading={analyzingTrend}
onClick={handleTrendAnalysis}
size="small"
>
AI
<AuthButton code="ai.analysis.manage">
<Button
icon={<ThunderboltOutlined />}
loading={analyzingTrend}
onClick={handleTrendAnalysis}
size="small"
>
AI
</Button>
</AuthButton>
</div>
<VitalSignsChart patientId={patientId} refreshKey={chartRefreshKey} />

View File

@@ -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<String>,
}
#[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<String>,
#[validate(length(max = 20))]
pub phone: Option<String>,
#[validate(length(max = 100))]
pub display_name: Option<String>,
#[validate(length(min = 1, max = 20))]
pub status: Option<String>,
pub version: i32,
}
@@ -149,15 +156,17 @@ pub struct CreateRoleReq {
pub description: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct UpdateRoleReq {
#[validate(length(min = 1, max = 50))]
pub name: Option<String>,
pub description: Option<String>,
pub version: i32,
}
#[derive(Debug, Deserialize, ToSchema)]
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct AssignRolesReq {
#[validate(length(min = 1, message = "至少需要分配一个角色"))]
pub role_ids: Vec<Uuid>,
}
@@ -173,8 +182,9 @@ pub struct PermissionResp {
pub description: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct AssignPermissionsReq {
#[validate(length(min = 1, message = "至少需要分配一个权限"))]
pub permission_ids: Vec<Uuid>,
}
@@ -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());
}

View File

@@ -71,6 +71,7 @@ where
&jwt_config,
&state.event_bus,
Some(&req_info),
req.client_type.as_deref(),
)
.await?;

View File

@@ -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<LoginResp> {
// 1. Find user by tenant_id + username
let user_model = match user::Entity::find()
@@ -115,11 +117,13 @@ impl AuthService {
let roles: Vec<String> = 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()));
}

View File

@@ -37,8 +37,9 @@ pub struct CreateDictionaryReq {
pub description: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct UpdateDictionaryReq {
#[validate(length(min = 1, max = 100, message = "字典名称不能为空且不超过100字符"))]
pub name: Option<String>,
pub description: Option<String>,
pub version: i32,
@@ -54,9 +55,11 @@ pub struct CreateDictionaryItemReq {
pub color: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct UpdateDictionaryItemReq {
#[validate(length(min = 1, max = 100, message = "标签不能为空且不超过100字符"))]
pub label: Option<String>,
#[validate(length(min = 1, max = 100, message = "值不能为空且不超过100字符"))]
pub value: Option<String>,
pub sort_order: Option<i32>,
pub color: Option<String>,
@@ -99,8 +102,9 @@ pub struct CreateMenuReq {
pub role_ids: Option<Vec<Uuid>>,
}
#[derive(Debug, Deserialize, ToSchema)]
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct UpdateMenuReq {
#[validate(length(min = 1, max = 100, message = "菜单标题不能为空且不超过100字符"))]
pub title: Option<String>,
pub path: Option<String>,
pub icon: Option<String>,
@@ -198,8 +202,9 @@ pub struct CreateNumberingRuleReq {
pub reset_cycle: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct UpdateNumberingRuleReq {
#[validate(length(min = 1, max = 100, message = "规则名称不能为空且不超过100字符"))]
pub name: Option<String>,
pub prefix: Option<String>,
pub date_format: Option<String>,
@@ -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<String>,
}

View File

@@ -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<String>,
@@ -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<String>,
pub allowed_patient_ids: Option<Vec<String>>,
#[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<String>,
#[validate(length(min = 1, message = "至少需要一个权限范围"))]
pub scopes: Option<Vec<String>>,
pub allowed_patient_ids: Option<Option<Vec<String>>>,
#[validate(range(min = 1, max = 10000, message = "速率限制需在1-10000之间"))]
pub rate_limit_per_minute: Option<i32>,
pub is_active: Option<bool>,
#[validate(range(min = 60, max = 86400, message = "令牌有效期需在60-86400秒之间"))]
pub token_lifetime_seconds: Option<i32>,
pub version: i32,
}

View File

@@ -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<HealthState>,
Json(req): Json<TokenRequest>,
) -> Result<(StatusCode, Json<TokenResponse>), (StatusCode, Json<TokenErrorResponse>)> {
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<CreateApiClientReq>,
) -> Result<Json<ApiClientResp>, 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<UpdateApiClientReq>,
) -> Result<Json<ApiClientListItem>, 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,

View File

@@ -158,7 +158,9 @@ pub struct UpdateTemplateReq {
pub title_template: Option<String>,
#[validate(length(min = 1, message = "内容模板不能为空"))]
pub body_template: Option<String>,
#[validate(length(min = 1, max = 10, message = "语言代码无效"))]
pub language: Option<String>,
#[validate(custom(function = "validate_channel"))]
pub channel: Option<String>,
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<serde_json::Value>,
pub channel_preferences: Option<serde_json::Value>,
pub dnd_enabled: Option<bool>,
#[validate(length(max = 8, message = "免打扰开始时间格式无效"))]
pub dnd_start: Option<String>,
#[validate(length(max = 8, message = "免打扰结束时间格式无效"))]
pub dnd_end: Option<String>,
pub version: i32,
}

View File

@@ -27,3 +27,4 @@ moka = { version = "0.12", features = ["sync"] }
regex = "1"
csv = { workspace = true }
rust_xlsxwriter = { workspace = true }
validator = { workspace = true }

View File

@@ -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<u64>,
pub page_size: Option<u64>,
@@ -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<String>,
@@ -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<String, Vec<String>>,
}
/// 批量标签解析响应
#[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<String>,
@@ -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<serde_json::Value>,
}
/// 数据导入结果
#[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<u64>,
pub page_size: Option<u64>,
@@ -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,

View File

@@ -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<u64>,
pub page_size: Option<u64>,
#[validate(length(max = 20, message = "状态值无效"))]
pub status: Option<String>,
#[validate(length(max = 100, message = "搜索关键词过长"))]
pub search: Option<String>,
}

View File

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

View File

@@ -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");

View File

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