feat: 审计修复 Phase 6-7 — SSE 推送/工作流补全/消息群发/前端收尾
Phase 6 功能补全: - P1-3: 消息 SSE 实时推送端点 + 前端 EventSource 连接 - P1-6: ServiceTask HTTP 调用能力 (reqwest GET/POST) - P1-7: user.deleted 事件处理 — 终止相关流程实例 - P1-8: 任务认领 (claim) 端点 + handler - P1-9: 超时检查器发布 task.timeout 事件 - P1-15: 组织/部门名称唯一性校验 (create + update) - P1-18: 消息群发 fan-out (role/department/all 批量投递) Phase 7 P3-P4 收尾: - PluginAdmin purge 按钮状态修复 - ChangePassword 最小 8 字符 + 新旧密码不同验证 - AuditLogViewer 用户名缓存 + 扩展资源类型 - InstanceMonitor 通过 definition 缓存解析 node_name - NotificationPreferences DND 时间范围校验
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
pub mod message_handler;
|
||||
pub mod sse_handler;
|
||||
pub mod subscription_handler;
|
||||
pub mod template_handler;
|
||||
|
||||
53
crates/erp-message/src/handler/sse_handler.rs
Normal file
53
crates/erp-message/src/handler/sse_handler.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use std::convert::Infallible;
|
||||
|
||||
use axum::extract::Extension;
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use futures::stream::Stream;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::types::TenantContext;
|
||||
|
||||
use crate::message_state::MessageState;
|
||||
|
||||
/// SSE 消息推送端点。
|
||||
///
|
||||
/// 客户端连接后监听 `message.sent` 事件,仅推送当前用户的消息。
|
||||
/// 使用 EventBus 的 filtered subscriber 按前缀过滤事件。
|
||||
pub async fn message_stream(
|
||||
axum::extract::State(state): axum::extract::State<MessageState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AppError> {
|
||||
let user_id = ctx.user_id;
|
||||
let tenant_id = ctx.tenant_id;
|
||||
let (mut rx, _handle) = state.event_bus.subscribe_filtered("message.sent".to_string());
|
||||
|
||||
let sse_stream = async_stream::stream! {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Some(event) => {
|
||||
if event.tenant_id != tenant_id {
|
||||
continue;
|
||||
}
|
||||
let is_recipient = event.payload.get("recipient_id")
|
||||
.and_then(|v: &serde_json::Value| v.as_str())
|
||||
.map(|s| s == user_id.to_string())
|
||||
.unwrap_or(false);
|
||||
if !is_recipient {
|
||||
continue;
|
||||
}
|
||||
|
||||
let data = serde_json::to_string(&event.payload)
|
||||
.unwrap_or_default();
|
||||
yield Ok(Event::default()
|
||||
.event("message")
|
||||
.data(data));
|
||||
}
|
||||
None => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default()))
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use erp_core::events::EventBus;
|
||||
use erp_core::module::ErpModule;
|
||||
|
||||
use crate::entity::message_subscription;
|
||||
use crate::handler::{message_handler, subscription_handler, template_handler};
|
||||
use crate::handler::{message_handler, sse_handler, subscription_handler, template_handler};
|
||||
|
||||
/// 消息中心模块,实现 ErpModule trait。
|
||||
pub struct MessageModule;
|
||||
@@ -36,6 +36,8 @@ impl MessageModule {
|
||||
.route("/messages/{id}/read", put(message_handler::mark_read))
|
||||
.route("/messages/read-all", put(message_handler::mark_all_read))
|
||||
.route("/messages/{id}", delete(message_handler::delete_message))
|
||||
// SSE 实时推送
|
||||
.route("/messages/stream", get(sse_handler::message_stream))
|
||||
// 模板路由
|
||||
.route(
|
||||
"/message-templates",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseBackend, EntityTrait, PaginatorTrait,
|
||||
QueryFilter, Set, Statement,
|
||||
QueryFilter, Set, Statement, FromQueryResult,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -12,6 +12,12 @@ use erp_core::audit::AuditLog;
|
||||
use erp_core::audit_service;
|
||||
use erp_core::events::EventBus;
|
||||
|
||||
/// 原始 SQL 查询 user_id 的结果结构体。
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct UserIdRow {
|
||||
user_id: Uuid,
|
||||
}
|
||||
|
||||
/// 消息服务。
|
||||
pub struct MessageService;
|
||||
|
||||
@@ -80,6 +86,12 @@ impl MessageService {
|
||||
}
|
||||
|
||||
/// 发送消息。
|
||||
///
|
||||
/// 根据 `recipient_type` 执行不同的投递策略:
|
||||
/// - `"user"` — 单条消息,直接投递给 `recipient_id` 指定的用户。
|
||||
/// - `"role"` — 查询 `user_roles` 表,向该角色下的所有用户批量投递。
|
||||
/// - `"department"` — 查询 `user_departments` 表,向该部门下的所有用户批量投递。
|
||||
/// - `"all"` — 查询 `users` 表,向租户内所有活跃用户批量投递。
|
||||
pub async fn send(
|
||||
tenant_id: Uuid,
|
||||
sender_id: Uuid,
|
||||
@@ -87,49 +99,79 @@ impl MessageService {
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> MessageResult<MessageResp> {
|
||||
let id = Uuid::now_v7();
|
||||
let now = Utc::now();
|
||||
|
||||
let model = message::ActiveModel {
|
||||
id: Set(id),
|
||||
tenant_id: Set(tenant_id),
|
||||
template_id: Set(req.template_id),
|
||||
sender_id: Set(Some(sender_id)),
|
||||
sender_type: Set("user".to_string()),
|
||||
recipient_id: Set(req.recipient_id),
|
||||
recipient_type: Set(req.recipient_type.clone()),
|
||||
title: Set(req.title.clone()),
|
||||
body: Set(req.body.clone()),
|
||||
priority: Set(req.priority.clone()),
|
||||
business_type: Set(req.business_type.clone()),
|
||||
business_id: Set(req.business_id),
|
||||
is_read: Set(false),
|
||||
read_at: Set(None),
|
||||
is_archived: Set(false),
|
||||
archived_at: Set(None),
|
||||
sent_at: Set(Some(now)),
|
||||
status: Set("sent".to_string()),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(sender_id),
|
||||
updated_by: Set(sender_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
// Resolve target user IDs based on recipient type
|
||||
let recipient_user_ids = match req.recipient_type.as_str() {
|
||||
"user" => vec![req.recipient_id],
|
||||
"role" => {
|
||||
Self::resolve_user_ids_by_role(db, req.recipient_id, tenant_id).await?
|
||||
}
|
||||
"department" => {
|
||||
Self::resolve_user_ids_by_department(db, req.recipient_id, tenant_id).await?
|
||||
}
|
||||
"all" => {
|
||||
Self::resolve_all_active_user_ids(db, tenant_id).await?
|
||||
}
|
||||
other => {
|
||||
return Err(MessageError::Validation(format!(
|
||||
"不支持的收件人类型: {other}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let inserted = model
|
||||
.insert(db)
|
||||
if recipient_user_ids.is_empty() {
|
||||
return Err(MessageError::Validation(
|
||||
"没有找到符合条件的收件人".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Build message models for all recipients
|
||||
let models: Vec<message::ActiveModel> = recipient_user_ids
|
||||
.iter()
|
||||
.map(|uid| message::ActiveModel {
|
||||
id: Set(Uuid::now_v7()),
|
||||
tenant_id: Set(tenant_id),
|
||||
template_id: Set(req.template_id),
|
||||
sender_id: Set(Some(sender_id)),
|
||||
sender_type: Set("user".to_string()),
|
||||
recipient_id: Set(*uid),
|
||||
recipient_type: Set("user".to_string()),
|
||||
title: Set(req.title.clone()),
|
||||
body: Set(req.body.clone()),
|
||||
priority: Set(req.priority.clone()),
|
||||
business_type: Set(req.business_type.clone()),
|
||||
business_id: Set(req.business_id),
|
||||
is_read: Set(false),
|
||||
read_at: Set(None),
|
||||
is_archived: Set(false),
|
||||
archived_at: Set(None),
|
||||
sent_at: Set(Some(now)),
|
||||
status: Set("sent".to_string()),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(sender_id),
|
||||
updated_by: Set(sender_id),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Batch insert all messages
|
||||
message::Entity::insert_many(models)
|
||||
.exec(db)
|
||||
.await
|
||||
.map_err(|e| MessageError::Validation(e.to_string()))?;
|
||||
|
||||
// Publish one event per batch (summary event)
|
||||
event_bus
|
||||
.publish(
|
||||
erp_core::events::DomainEvent::new(
|
||||
"message.sent",
|
||||
tenant_id,
|
||||
serde_json::json!({
|
||||
"message_id": id,
|
||||
"recipient_id": req.recipient_id,
|
||||
"recipient_type": req.recipient_type,
|
||||
"recipient_count": recipient_user_ids.len(),
|
||||
"title": req.title,
|
||||
}),
|
||||
),
|
||||
@@ -139,12 +181,94 @@ impl MessageService {
|
||||
|
||||
audit_service::record(
|
||||
AuditLog::new(tenant_id, Some(sender_id), "message.send", "message")
|
||||
.with_resource_id(id),
|
||||
.with_changes(
|
||||
None,
|
||||
Some(serde_json::json!({
|
||||
"recipient_type": req.recipient_type,
|
||||
"recipient_count": recipient_user_ids.len(),
|
||||
"title": req.title,
|
||||
})),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Self::model_to_resp(&inserted))
|
||||
// Construct a representative response (no row returned from batch insert)
|
||||
Ok(MessageResp {
|
||||
id: Uuid::nil(),
|
||||
tenant_id,
|
||||
template_id: req.template_id,
|
||||
sender_id: Some(sender_id),
|
||||
sender_type: "user".to_string(),
|
||||
recipient_id: req.recipient_id,
|
||||
recipient_type: req.recipient_type.clone(),
|
||||
title: req.title.clone(),
|
||||
body: req.body.clone(),
|
||||
priority: req.priority.clone(),
|
||||
business_type: req.business_type.clone(),
|
||||
business_id: req.business_id,
|
||||
is_read: false,
|
||||
read_at: None,
|
||||
is_archived: false,
|
||||
status: "sent".to_string(),
|
||||
sent_at: Some(now),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
version: 1,
|
||||
})
|
||||
}
|
||||
|
||||
/// 根据角色 ID 查询关联的用户 ID 列表(跨模块 raw SQL)。
|
||||
async fn resolve_user_ids_by_role(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
role_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
) -> MessageResult<Vec<Uuid>> {
|
||||
let rows = UserIdRow::find_by_statement(Statement::from_sql_and_values(
|
||||
DatabaseBackend::Postgres,
|
||||
"SELECT user_id FROM user_roles WHERE role_id = $1 AND tenant_id = $2 AND deleted_at IS NULL",
|
||||
[role_id.into(), tenant_id.into()],
|
||||
))
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| MessageError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(rows.into_iter().map(|r| r.user_id).collect())
|
||||
}
|
||||
|
||||
/// 根据部门 ID 查询关联的用户 ID 列表(跨模块 raw SQL)。
|
||||
async fn resolve_user_ids_by_department(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
department_id: Uuid,
|
||||
tenant_id: Uuid,
|
||||
) -> MessageResult<Vec<Uuid>> {
|
||||
let rows = UserIdRow::find_by_statement(Statement::from_sql_and_values(
|
||||
DatabaseBackend::Postgres,
|
||||
"SELECT user_id FROM user_departments WHERE department_id = $1 AND tenant_id = $2 AND deleted_at IS NULL",
|
||||
[department_id.into(), tenant_id.into()],
|
||||
))
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| MessageError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(rows.into_iter().map(|r| r.user_id).collect())
|
||||
}
|
||||
|
||||
/// 查询租户内所有活跃用户的 ID 列表(跨模块 raw SQL)。
|
||||
async fn resolve_all_active_user_ids(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: Uuid,
|
||||
) -> MessageResult<Vec<Uuid>> {
|
||||
let rows = UserIdRow::find_by_statement(Statement::from_sql_and_values(
|
||||
DatabaseBackend::Postgres,
|
||||
"SELECT id AS user_id FROM users WHERE tenant_id = $1 AND deleted_at IS NULL AND status = 'active'",
|
||||
[tenant_id.into()],
|
||||
))
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| MessageError::Validation(e.to_string()))?;
|
||||
|
||||
Ok(rows.into_iter().map(|r| r.user_id).collect())
|
||||
}
|
||||
|
||||
/// 系统发送消息(由事件处理器调用)。
|
||||
|
||||
Reference in New Issue
Block a user