fix: 全系统审计问题修复 — 安全/数据完整性/功能缺陷/UX (Phase 1-5)
Phase 1 安全热修复: - P0-1: /uploads 文件服务添加 JWT 认证中间件(支持 header + query param) - P0-2: analytics/batch 路由从 public 移到 protected_routes - P0-3: plugin engine SQL 注入修复(format! → 参数化查询) - P0-new: stats_service compute_avg_field 字段白名单 + FLOAT8 类型转换 Phase 2 数据完整性: - P0-4: 组织删除级联检查(添加部门存在性校验) - P0-5: 部门删除级联检查(添加岗位 + 用户存在性校验) - P0-8: workflow on_tenant_deleted 实现 5 实体批量删除 - P0-7: 并行网关 race condition 修复(consumed → completed 原子转换) Phase 3 P1 后端 Bug: - P1-12: plugin host 表名消毒(使用 sanitize_identifier) - P1-10: workflow deprecated 状态转换(published → deprecated) - P1-11: workflow 更新验证条件(nodes/edges 任一变化即验证) - P0-9: 小程序 .gitignore 添加 .env/.env.*/日志 - P1-19: 小程序加密密钥替换为 64 字符强密钥 Phase 4 消息模块: - P1-5: 通知偏好 GET 路由 + handler - P1-4: 消息模板 update/delete CRUD + version - P2-8: mark_all_read SQL 添加 version + 1 - P2-7: markAsRead 改为乐观更新 + 失败回滚 Phase 5 前端修复: - P2-9: 通知面板点击导航到 /messages - P2-1: 随访任务患者名批量 ID 解析(替代 UUID 显示) - P2-5: AppointmentList 分离 patient_id/doctor_id 分别调用 API - P2-17: PluginMarket installed 字段修正(name → id) - P3-3: 路由标题 fallback 改为模式匹配(支持 :id 动态路径) - P2-15: workflow updateDefinition 添加 version 字段 - P3-9: Kanban 版本使用记录实际 version - P2-21: secure-storage 生产环境无密钥时阻止存储 - P3-11: destroyOnHidden → destroyOnClose - P3-13: PendingTasks 深色模式 Tag 颜色适配 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,8 @@ use uuid::Uuid;
|
||||
use crate::dto::{CreateDepartmentReq, DepartmentResp, UpdateDepartmentReq};
|
||||
use crate::entity::department;
|
||||
use crate::entity::organization;
|
||||
use crate::entity::position;
|
||||
use crate::entity::user_department;
|
||||
use crate::error::{AuthError, AuthResult};
|
||||
use erp_core::audit::AuditLog;
|
||||
use erp_core::audit_service;
|
||||
@@ -274,6 +276,34 @@ impl DeptService {
|
||||
));
|
||||
}
|
||||
|
||||
// Check for positions under this department
|
||||
let positions = position::Entity::find()
|
||||
.filter(position::Column::TenantId.eq(tenant_id))
|
||||
.filter(position::Column::DeptId.eq(id))
|
||||
.filter(position::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if positions.is_some() {
|
||||
return Err(AuthError::Validation(
|
||||
"该部门下存在岗位,无法删除".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Check for users assigned to this department
|
||||
let users = user_department::Entity::find()
|
||||
.filter(user_department::Column::TenantId.eq(tenant_id))
|
||||
.filter(user_department::Column::DepartmentId.eq(id))
|
||||
.filter(user_department::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if users.is_some() {
|
||||
return Err(AuthError::Validation(
|
||||
"该部门下存在用户,无法删除".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let current_version = model.version;
|
||||
let mut active: department::ActiveModel = model.into();
|
||||
active.deleted_at = Set(Some(Utc::now()));
|
||||
|
||||
@@ -5,6 +5,7 @@ use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{CreateOrganizationReq, OrganizationResp, UpdateOrganizationReq};
|
||||
use crate::entity::department;
|
||||
use crate::entity::organization;
|
||||
use crate::error::{AuthError, AuthResult};
|
||||
use erp_core::audit::AuditLog;
|
||||
@@ -251,6 +252,20 @@ impl OrgService {
|
||||
));
|
||||
}
|
||||
|
||||
// Check for departments under this organization
|
||||
let depts = department::Entity::find()
|
||||
.filter(department::Column::TenantId.eq(tenant_id))
|
||||
.filter(department::Column::OrgId.eq(id))
|
||||
.filter(department::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| AuthError::Validation(e.to_string()))?;
|
||||
if depts.is_some() {
|
||||
return Err(AuthError::Validation(
|
||||
"该组织下存在部门,无法删除".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let current_version = model.version;
|
||||
let mut active: organization::ActiveModel = model.into();
|
||||
active.deleted_at = Set(Some(Utc::now()));
|
||||
|
||||
@@ -425,8 +425,26 @@ async fn compute_avg_field(
|
||||
tenant_id: uuid::Uuid,
|
||||
field: &str,
|
||||
) -> AppResult<Option<f64>> {
|
||||
const ALLOWED_FIELDS: &[&str] = &[
|
||||
"uf_volume",
|
||||
"uf_rate",
|
||||
"blood_flow_rate",
|
||||
"dialysate_flow_rate",
|
||||
"pre_weight",
|
||||
"post_weight",
|
||||
"pre_bp_systolic",
|
||||
"pre_bp_diastolic",
|
||||
"post_bp_systolic",
|
||||
"post_bp_diastolic",
|
||||
];
|
||||
if !ALLOWED_FIELDS.contains(&field) {
|
||||
return Err(erp_core::error::AppError::Validation(format!(
|
||||
"不允许的字段名: {field}"
|
||||
)));
|
||||
}
|
||||
// field is whitelist-validated, safe to interpolate
|
||||
let sql = format!(
|
||||
"SELECT AVG({field}) AS avg_val FROM dialysis_record \
|
||||
"SELECT AVG({field})::FLOAT8 AS avg_val FROM dialysis_record \
|
||||
WHERE tenant_id = $1 AND deleted_at IS NULL AND {field} IS NOT NULL \
|
||||
AND created_at >= date_trunc('month', NOW())"
|
||||
);
|
||||
|
||||
@@ -113,6 +113,7 @@ pub struct MessageTemplateResp {
|
||||
pub language: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
/// 创建消息模板请求
|
||||
@@ -148,6 +149,20 @@ fn default_language() -> String {
|
||||
"zh-CN".to_string()
|
||||
}
|
||||
|
||||
/// 更新消息模板请求
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct UpdateTemplateReq {
|
||||
#[validate(length(min = 1, max = 100, message = "名称不能为空且不超过100字符"))]
|
||||
pub name: Option<String>,
|
||||
#[validate(length(min = 1, max = 200, message = "标题模板不能为空"))]
|
||||
pub title_template: Option<String>,
|
||||
#[validate(length(min = 1, message = "内容模板不能为空"))]
|
||||
pub body_template: Option<String>,
|
||||
pub language: Option<String>,
|
||||
pub channel: Option<String>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
// ============ 消息订阅偏好 DTO ============
|
||||
|
||||
/// 消息订阅偏好响应
|
||||
|
||||
@@ -9,6 +9,29 @@ use crate::dto::UpdateSubscriptionReq;
|
||||
use crate::message_state::MessageState;
|
||||
use crate::service::subscription_service::SubscriptionService;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/message-subscriptions",
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<crate::dto::MessageSubscriptionResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "消息订阅"
|
||||
)]
|
||||
/// 获取当前用户的消息订阅偏好。
|
||||
pub async fn get_subscription<S>(
|
||||
State(_state): State<MessageState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<crate::dto::MessageSubscriptionResp>>, AppError>
|
||||
where
|
||||
MessageState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let resp = SubscriptionService::get(ctx.tenant_id, ctx.user_id, &_state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/message-subscriptions",
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
use axum::Json;
|
||||
use axum::extract::FromRef;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
||||
use validator::Validate;
|
||||
|
||||
use crate::dto::{CreateTemplateReq, MessageTemplateResp};
|
||||
use crate::dto::{CreateTemplateReq, MessageTemplateResp, UpdateTemplateReq};
|
||||
use crate::message_state::MessageState;
|
||||
use crate::service::template_service::TemplateService;
|
||||
|
||||
@@ -88,3 +89,52 @@ where
|
||||
let resp = TemplateService::create(ctx.tenant_id, ctx.user_id, &req, &_state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/message-templates/{id}",
|
||||
request_body = UpdateTemplateReq,
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<MessageTemplateResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "消息模板"
|
||||
)]
|
||||
/// 更新消息模板。
|
||||
pub async fn update_template<S>(
|
||||
State(_state): State<MessageState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdateTemplateReq>,
|
||||
) -> Result<Json<ApiResponse<MessageTemplateResp>>, AppError>
|
||||
where
|
||||
MessageState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "message.template.manage")?;
|
||||
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let resp = TemplateService::update(id, ctx.tenant_id, ctx.user_id, &req, &_state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
/// 删除消息模板。
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub async fn delete_template<S>(
|
||||
State(_state): State<MessageState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
MessageState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "message.template.manage")?;
|
||||
|
||||
TemplateService::delete(id, ctx.tenant_id, ctx.user_id, &_state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
@@ -41,10 +41,15 @@ impl MessageModule {
|
||||
"/message-templates",
|
||||
get(template_handler::list_templates).post(template_handler::create_template),
|
||||
)
|
||||
.route(
|
||||
"/message-templates/{id}",
|
||||
put(template_handler::update_template).delete(template_handler::delete_template),
|
||||
)
|
||||
// 订阅偏好路由
|
||||
.route(
|
||||
"/message-subscriptions",
|
||||
put(subscription_handler::update_subscription),
|
||||
get(subscription_handler::get_subscription)
|
||||
.put(subscription_handler::update_subscription),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -304,7 +304,7 @@ impl MessageService {
|
||||
let now = Utc::now();
|
||||
db.execute(Statement::from_sql_and_values(
|
||||
DatabaseBackend::Postgres,
|
||||
"UPDATE messages SET is_read = true, read_at = $1, updated_at = $2, updated_by = $3 WHERE tenant_id = $4 AND recipient_id = $5 AND is_read = false AND deleted_at IS NULL",
|
||||
"UPDATE messages SET is_read = true, read_at = $1, updated_at = $2, updated_by = $3, version = version + 1 WHERE tenant_id = $4 AND recipient_id = $5 AND is_read = false AND deleted_at IS NULL",
|
||||
[
|
||||
now.into(),
|
||||
now.into(),
|
||||
|
||||
@@ -2,7 +2,7 @@ use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{CreateTemplateReq, MessageTemplateResp};
|
||||
use crate::dto::{CreateTemplateReq, MessageTemplateResp, UpdateTemplateReq};
|
||||
use crate::entity::message_template;
|
||||
use crate::error::{MessageError, MessageResult};
|
||||
|
||||
@@ -88,6 +88,82 @@ impl TemplateService {
|
||||
Ok(Self::model_to_resp(&inserted))
|
||||
}
|
||||
|
||||
/// 更新消息模板。
|
||||
pub async fn update(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
req: &UpdateTemplateReq,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> MessageResult<MessageTemplateResp> {
|
||||
let model = message_template::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| MessageError::Validation(e.to_string()))?
|
||||
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||
.ok_or_else(|| MessageError::NotFound(format!("模板不存在: {id}")))?;
|
||||
|
||||
let current_version = model.version;
|
||||
let next_ver = erp_core::error::check_version(req.version, current_version)
|
||||
.map_err(|_| MessageError::VersionMismatch)?;
|
||||
|
||||
let mut active: message_template::ActiveModel = model.into();
|
||||
if let Some(name) = &req.name {
|
||||
active.name = Set(name.clone());
|
||||
}
|
||||
if let Some(title) = &req.title_template {
|
||||
active.title_template = Set(title.clone());
|
||||
}
|
||||
if let Some(body) = &req.body_template {
|
||||
active.body_template = Set(body.clone());
|
||||
}
|
||||
if let Some(lang) = &req.language {
|
||||
active.language = Set(lang.clone());
|
||||
}
|
||||
if let Some(channel) = &req.channel {
|
||||
active.channel = Set(channel.clone());
|
||||
}
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(next_ver);
|
||||
|
||||
let updated = active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| MessageError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(Self::model_to_resp(&updated))
|
||||
}
|
||||
|
||||
/// 软删除消息模板。
|
||||
pub async fn delete(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> MessageResult<()> {
|
||||
let model = message_template::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| MessageError::Validation(e.to_string()))?
|
||||
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||
.ok_or_else(|| MessageError::NotFound(format!("模板不存在: {id}")))?;
|
||||
|
||||
let current_version = model.version;
|
||||
let mut active: message_template::ActiveModel = model.into();
|
||||
active.deleted_at = Set(Some(Utc::now()));
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
active.version = Set(current_version + 1);
|
||||
|
||||
active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| MessageError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 使用模板渲染消息内容。
|
||||
/// 支持 {{variable}} 格式的变量插值。
|
||||
pub fn render(template: &str, variables: &std::collections::HashMap<String, String>) -> String {
|
||||
@@ -110,6 +186,7 @@ impl TemplateService {
|
||||
language: m.language.clone(),
|
||||
created_at: m.created_at,
|
||||
updated_at: m.updated_at,
|
||||
version: m.version,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -627,16 +627,13 @@ impl PluginEngine {
|
||||
use sea_orm::FromQueryResult;
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct ConfigRow { config_json: serde_json::Value }
|
||||
let sql = format!(
|
||||
"SELECT config_json FROM plugins WHERE tenant_id = '{}'\n\
|
||||
AND deleted_at IS NULL\n\
|
||||
AND manifest_json->'metadata'->>'id' = '{}'\n\
|
||||
LIMIT 1",
|
||||
tenant_id, pid.replace('\'', "''")
|
||||
);
|
||||
ConfigRow::find_by_statement(Statement::from_string(
|
||||
ConfigRow::find_by_statement(Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
"SELECT config_json FROM plugins WHERE tenant_id = $1\n\
|
||||
AND deleted_at IS NULL\n\
|
||||
AND manifest_json->'metadata'->>'id' = $2\n\
|
||||
LIMIT 1",
|
||||
[tenant_id.into(), pid.into()],
|
||||
))
|
||||
.one(&db)
|
||||
.await
|
||||
|
||||
@@ -335,8 +335,11 @@ impl host_api::Host for HostState {
|
||||
_ => String::new(), // "never" — 不需要周期 key
|
||||
};
|
||||
|
||||
// 序列表名
|
||||
let table_name = format!("plugin_numbering_seq_{}", plugin_id.replace('-', "_"));
|
||||
// 序列表名(使用 sanitize_identifier 防注入)
|
||||
let table_name = format!(
|
||||
"plugin_numbering_seq_{}",
|
||||
crate::dynamic_table::sanitize_identifier(&plugin_id)
|
||||
);
|
||||
|
||||
// 确保序列表存在
|
||||
let create_sql = format!(
|
||||
|
||||
@@ -494,16 +494,15 @@ async fn main() -> anyhow::Result<()> {
|
||||
"/docs/openapi.json",
|
||||
axum::routing::get(handlers::openapi::openapi_spec),
|
||||
)
|
||||
.route(
|
||||
"/analytics/batch",
|
||||
axum::routing::post(handlers::analytics::batch),
|
||||
)
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
middleware::rate_limit::rate_limit_by_ip,
|
||||
))
|
||||
.with_state(state.clone());
|
||||
|
||||
// Clone jwt_secret for upload auth before protected_routes closure moves it
|
||||
let secret_for_uploads = jwt_secret.clone();
|
||||
|
||||
// Protected routes (JWT authentication required)
|
||||
// User-based rate limiting (100 req/min) applied after JWT auth
|
||||
let protected_routes = erp_auth::AuthModule::protected_routes()
|
||||
@@ -522,6 +521,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
"/admin/tenants/{id}/rotate-key",
|
||||
axum::routing::post(handlers::crypto_admin::rotate_tenant_key),
|
||||
)
|
||||
.route(
|
||||
"/analytics/batch",
|
||||
axum::routing::post(handlers::analytics::batch),
|
||||
)
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
middleware::rate_limit::rate_limit_by_user,
|
||||
@@ -540,9 +543,15 @@ async fn main() -> anyhow::Result<()> {
|
||||
// All API routes are nested under /api/v1
|
||||
let cors = build_cors_layer(&state.config.cors.allowed_origins);
|
||||
let upload_dir = state.config.storage.upload_dir.clone();
|
||||
let uploads_router = Router::new()
|
||||
.fallback_service(ServeDir::new(&upload_dir))
|
||||
.layer(axum_middleware::from_fn(move |req, next| {
|
||||
let secret = secret_for_uploads.clone();
|
||||
async move { upload_auth_middleware(secret, req, next).await }
|
||||
}));
|
||||
let app = Router::new()
|
||||
.nest("/api/v1", public_routes.merge(protected_routes))
|
||||
.nest_service("/uploads", ServeDir::new(&upload_dir))
|
||||
.nest("/uploads", uploads_router)
|
||||
.layer(cors);
|
||||
|
||||
let addr = format!("{}:{}", host, port);
|
||||
@@ -560,6 +569,48 @@ async fn main() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// JWT auth middleware for `/uploads` file serving.
|
||||
///
|
||||
/// Accepts token from either `Authorization: Bearer <token>` header
|
||||
/// or `?token=<token>` query parameter (for browser `<img>` / direct downloads).
|
||||
async fn upload_auth_middleware(
|
||||
jwt_secret: String,
|
||||
req: axum::extract::Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> Result<axum::response::Response, erp_core::error::AppError> {
|
||||
use erp_auth::service::token_service::TokenService;
|
||||
|
||||
let token = req
|
||||
.headers()
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
req.uri().query().and_then(|q| {
|
||||
q.split('&').find_map(|pair| {
|
||||
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
|
||||
if k == "token" && !v.is_empty() {
|
||||
Some(v.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
let token = token.ok_or(erp_core::error::AppError::Unauthorized)?;
|
||||
|
||||
let claims = TokenService::decode_token(&token, &jwt_secret)
|
||||
.map_err(|_| erp_core::error::AppError::Unauthorized)?;
|
||||
|
||||
if claims.token_type != "access" {
|
||||
return Err(erp_core::error::AppError::Unauthorized);
|
||||
}
|
||||
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
|
||||
/// Build a CORS layer from the comma-separated allowed origins config.
|
||||
///
|
||||
/// If the config is "*", allows all origins (development mode).
|
||||
|
||||
@@ -406,7 +406,27 @@ impl FlowExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
// 所有分支都完成了,沿出边继续
|
||||
// 所有分支都完成了,先将 consumed tokens 标记为 completed 防止并发重复触发
|
||||
for edge in &incoming {
|
||||
let consumed_tokens = token::Entity::find()
|
||||
.filter(token::Column::InstanceId.eq(instance_id))
|
||||
.filter(token::Column::NodeId.eq(&edge.source))
|
||||
.filter(token::Column::Status.eq("consumed"))
|
||||
.all(txn)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
for mut t in consumed_tokens {
|
||||
let ver = t.version;
|
||||
let mut active: token::ActiveModel = t.into();
|
||||
active.status = Set("completed".to_string());
|
||||
active.version = Set(ver + 1);
|
||||
active.updated_at = Set(chrono::Utc::now());
|
||||
active.update(txn).await.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
// 沿出边继续创建新 token
|
||||
let outgoing = graph.get_outgoing_edges(node_id);
|
||||
let mut new_tokens = Vec::new();
|
||||
for edge in &outgoing {
|
||||
|
||||
@@ -180,3 +180,26 @@ where
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
pub async fn deprecate_definition<S>(
|
||||
State(state): State<WorkflowState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<ProcessDefinitionResp>>, AppError>
|
||||
where
|
||||
WorkflowState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "workflow.publish")?;
|
||||
|
||||
let resp = DefinitionService::deprecate(
|
||||
id,
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
@@ -41,6 +41,10 @@ impl WorkflowModule {
|
||||
"/workflow/definitions/{id}/publish",
|
||||
post(definition_handler::publish_definition),
|
||||
)
|
||||
.route(
|
||||
"/workflow/definitions/{id}/deprecate",
|
||||
post(definition_handler::deprecate_definition),
|
||||
)
|
||||
// Instance routes
|
||||
.route(
|
||||
"/workflow/instances",
|
||||
@@ -147,9 +151,43 @@ impl ErpModule for WorkflowModule {
|
||||
|
||||
async fn on_tenant_deleted(
|
||||
&self,
|
||||
_tenant_id: Uuid,
|
||||
_db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
) -> AppResult<()> {
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
|
||||
// Delete in dependency order: variables → tasks → tokens → instances → definitions
|
||||
// process_variables
|
||||
crate::entity::process_variable::Entity::delete_many()
|
||||
.filter(crate::entity::process_variable::Column::TenantId.eq(tenant_id))
|
||||
.exec(db)
|
||||
.await?;
|
||||
|
||||
// tasks
|
||||
crate::entity::task::Entity::delete_many()
|
||||
.filter(crate::entity::task::Column::TenantId.eq(tenant_id))
|
||||
.exec(db)
|
||||
.await?;
|
||||
|
||||
// tokens
|
||||
crate::entity::token::Entity::delete_many()
|
||||
.filter(crate::entity::token::Column::TenantId.eq(tenant_id))
|
||||
.exec(db)
|
||||
.await?;
|
||||
|
||||
// process_instances
|
||||
crate::entity::process_instance::Entity::delete_many()
|
||||
.filter(crate::entity::process_instance::Column::TenantId.eq(tenant_id))
|
||||
.exec(db)
|
||||
.await?;
|
||||
|
||||
// process_definitions
|
||||
crate::entity::process_definition::Entity::delete_many()
|
||||
.filter(crate::entity::process_definition::Column::TenantId.eq(tenant_id))
|
||||
.exec(db)
|
||||
.await?;
|
||||
|
||||
tracing::info!(%tenant_id, "Workflow data cleaned up for deleted tenant");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -171,11 +171,21 @@ impl DefinitionService {
|
||||
if let Some(description) = &req.description {
|
||||
active.description = Set(Some(description.clone()));
|
||||
}
|
||||
// 当 nodes 或 edges 任一存在时,取最终值验证流程图完整性
|
||||
let final_nodes = req.nodes.as_ref().or_else(|| {
|
||||
serde_json::from_value::<Vec<crate::dto::NodeDef>>(active.nodes.as_ref().clone()).ok().as_ref().map(|_| unreachable!())
|
||||
});
|
||||
// 简化:如果提供了 nodes 或 edges,将两者合并后验证
|
||||
if req.nodes.is_some() || req.edges.is_some() {
|
||||
let nodes_val = req.nodes.as_ref().map(|n| serde_json::to_value(n).unwrap()).unwrap_or(active.nodes.as_ref().clone());
|
||||
let edges_val = req.edges.as_ref().map(|e| serde_json::to_value(e).unwrap()).unwrap_or(active.edges.as_ref().clone());
|
||||
let nodes: Vec<crate::dto::NodeDef> = serde_json::from_value(nodes_val)
|
||||
.map_err(|e| WorkflowError::Validation(format!("节点数据无效: {e}")))?;
|
||||
let edges: Vec<crate::dto::EdgeDef> = serde_json::from_value(edges_val)
|
||||
.map_err(|e| WorkflowError::Validation(format!("连线数据无效: {e}")))?;
|
||||
parser::parse_and_validate(&nodes, &edges)?;
|
||||
}
|
||||
if let Some(nodes) = &req.nodes {
|
||||
// 验证新流程图
|
||||
if let Some(edges) = &req.edges {
|
||||
parser::parse_and_validate(nodes, edges)?;
|
||||
}
|
||||
let nodes_json = serde_json::to_value(nodes)
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
active.nodes = Set(nodes_json);
|
||||
@@ -278,6 +288,65 @@ impl DefinitionService {
|
||||
Ok(Self::model_to_resp(&updated))
|
||||
}
|
||||
|
||||
/// 将已发布的流程定义标记为 deprecated。
|
||||
pub async fn deprecate(
|
||||
id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
operator_id: Uuid,
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> WorkflowResult<ProcessDefinitionResp> {
|
||||
let model = process_definition::Entity::find_by_id(id)
|
||||
.one(db)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?
|
||||
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||
.ok_or_else(|| WorkflowError::NotFound(format!("流程定义不存在: {id}")))?;
|
||||
|
||||
if model.status != "published" {
|
||||
return Err(WorkflowError::InvalidState(
|
||||
"只有 published 状态的流程定义可以废弃".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let current_version = model.version_field;
|
||||
let mut active: process_definition::ActiveModel = model.into();
|
||||
active.status = Set("deprecated".to_string());
|
||||
active.version_field = Set(current_version + 1);
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.updated_by = Set(operator_id);
|
||||
|
||||
let updated = active
|
||||
.update(db)
|
||||
.await
|
||||
.map_err(|e| WorkflowError::Validation(e.to_string()))?;
|
||||
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"process_definition.deprecated",
|
||||
tenant_id,
|
||||
serde_json::json!({ "definition_id": id }),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(
|
||||
tenant_id,
|
||||
Some(operator_id),
|
||||
"process_definition.deprecate",
|
||||
"process_definition",
|
||||
)
|
||||
.with_resource_id(id),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Self::model_to_resp(&updated))
|
||||
}
|
||||
|
||||
/// 软删除流程定义。
|
||||
pub async fn delete(
|
||||
id: Uuid,
|
||||
|
||||
Reference in New Issue
Block a user