feat: 拆分 worker 架构并增强 assistant 安全保护
- 新增 daemon/channel/assistant/function/scheduler/logging worker 架构和 JetStream 消息协议 - 修复邮件通知误入 assistant、迁移 checksum、日志 subject 和工具调用无限循环风险 - 新增 assistant 工具轮次熔断、工具结果截断和提示词软收敛规则 - 补充邮件推送关键字、可信发件人配置、升级日志和验证测试
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
use ias_common::mq::{
|
||||
ConsumerSpec, DeadLetterPayload, InboundPayload, JetStreamBus, JetStreamConfig,
|
||||
MessageEnvelope, StreamKind, StreamNames, inbound_subject,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a running NATS JetStream server from docker-compose.nats.yml"]
|
||||
async fn jetstream_publish_and_ack_roundtrip() -> anyhow::Result<()> {
|
||||
let config = JetStreamConfig::from_env();
|
||||
let bus = JetStreamBus::connect(config.clone()).await?;
|
||||
bus.ensure_streams().await?;
|
||||
let subject = inbound_subject("wechat", "integration");
|
||||
let payload = InboundPayload {
|
||||
channel: "wechat".to_string(),
|
||||
account_id: "integration".to_string(),
|
||||
from_user_id: "tester".to_string(),
|
||||
content: "ping".to_string(),
|
||||
context: None,
|
||||
external_message_id: Some("integration-msg-1".to_string()),
|
||||
session_id: None,
|
||||
group_id: None,
|
||||
create_time_ms: None,
|
||||
};
|
||||
let envelope = MessageEnvelope::new(
|
||||
subject.as_str(),
|
||||
payload.idempotency_key(),
|
||||
"trace-integration",
|
||||
payload,
|
||||
);
|
||||
bus.publish_envelope(subject.as_str(), &envelope).await?;
|
||||
|
||||
let names = StreamNames::new(config.stream_prefix.clone());
|
||||
let spec = ConsumerSpec {
|
||||
stream: names.name(StreamKind::Inbound),
|
||||
durable_name: "integration-ack-test".to_string(),
|
||||
filter_subject: subject.to_string(),
|
||||
ack_wait: config.ack_wait(),
|
||||
max_deliver: config.max_deliver,
|
||||
};
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel(1);
|
||||
let consumer_bus = bus.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
consumer_bus
|
||||
.consume_envelopes::<InboundPayload, _, _>(spec, move |message| {
|
||||
let tx = tx.clone();
|
||||
async move {
|
||||
tx.send(message.message_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.await
|
||||
});
|
||||
|
||||
let message_id = tokio::time::timeout(std::time::Duration::from_secs(10), rx.recv())
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("consumer closed before receiving test message"))?;
|
||||
handle.abort();
|
||||
|
||||
assert_eq!(message_id, "wechat/integration/integration-msg-1");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a running NATS JetStream server from docker-compose.nats.yml"]
|
||||
async fn jetstream_redelivery_reaches_dlq_after_max_deliver() -> anyhow::Result<()> {
|
||||
let mut config = JetStreamConfig::from_env();
|
||||
config.ack_wait_secs = 1;
|
||||
config.max_deliver = 2;
|
||||
let bus = JetStreamBus::connect(config.clone()).await?;
|
||||
bus.ensure_streams().await?;
|
||||
let names = StreamNames::new(config.stream_prefix.clone());
|
||||
let unique = uuid::Uuid::new_v4().to_string();
|
||||
let account_id = format!("dlq-{unique}");
|
||||
let subject = inbound_subject("wechat", &account_id);
|
||||
let payload = InboundPayload {
|
||||
channel: "wechat".to_string(),
|
||||
account_id: account_id.clone(),
|
||||
from_user_id: "tester".to_string(),
|
||||
content: "force retry".to_string(),
|
||||
context: None,
|
||||
external_message_id: Some(unique.clone()),
|
||||
session_id: None,
|
||||
group_id: None,
|
||||
create_time_ms: None,
|
||||
};
|
||||
let message_id = format!("wechat/{account_id}/{unique}");
|
||||
let envelope = MessageEnvelope::new(
|
||||
subject.as_str(),
|
||||
message_id.clone(),
|
||||
format!("trace-{unique}"),
|
||||
payload,
|
||||
);
|
||||
|
||||
let (dlq_tx, mut dlq_rx) = tokio::sync::mpsc::channel(1);
|
||||
let dlq_bus = bus.clone();
|
||||
let dlq_stream = names.name(StreamKind::Dlq);
|
||||
let dlq_ack_wait = config.ack_wait();
|
||||
let dlq_max_deliver = config.max_deliver;
|
||||
let dlq_handle = tokio::spawn(async move {
|
||||
dlq_bus
|
||||
.consume_envelopes::<DeadLetterPayload, _, _>(
|
||||
ConsumerSpec {
|
||||
stream: dlq_stream,
|
||||
durable_name: format!("integration-dlq-{unique}"),
|
||||
filter_subject: "dlq.>".to_string(),
|
||||
ack_wait: dlq_ack_wait,
|
||||
max_deliver: dlq_max_deliver,
|
||||
},
|
||||
move |message| {
|
||||
let dlq_tx = dlq_tx.clone();
|
||||
let expected = message_id.clone();
|
||||
async move {
|
||||
if message.payload.original_message_id.as_deref() == Some(&expected) {
|
||||
dlq_tx.send(message.payload.original_message_id).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
let retry_bus = bus.clone();
|
||||
let retry_spec = ConsumerSpec {
|
||||
stream: StreamNames::new(config.stream_prefix.clone()).name(StreamKind::Inbound),
|
||||
durable_name: format!("integration-redelivery-{account_id}"),
|
||||
filter_subject: subject.to_string(),
|
||||
ack_wait: config.ack_wait(),
|
||||
max_deliver: config.max_deliver,
|
||||
};
|
||||
let retry_handle = tokio::spawn(async move {
|
||||
retry_bus
|
||||
.consume_envelopes::<InboundPayload, _, _>(retry_spec, |_message| async move {
|
||||
anyhow::bail!("force redelivery")
|
||||
})
|
||||
.await
|
||||
});
|
||||
|
||||
bus.publish_envelope(subject.as_str(), &envelope).await?;
|
||||
let observed = tokio::time::timeout(std::time::Duration::from_secs(20), dlq_rx.recv())
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("dlq consumer closed before receiving test message"))?;
|
||||
retry_handle.abort();
|
||||
dlq_handle.abort();
|
||||
|
||||
assert_eq!(observed.as_deref(), Some(envelope.message_id.as_str()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires a running NATS JetStream server from docker-compose.nats.yml"]
|
||||
async fn jetstream_concurrent_dlq_does_not_lose_messages() -> anyhow::Result<()> {
|
||||
let mut config = JetStreamConfig::from_env();
|
||||
config.ack_wait_secs = 1;
|
||||
config.max_deliver = 2;
|
||||
config.consumer_concurrency = 4;
|
||||
let bus = JetStreamBus::connect(config.clone()).await?;
|
||||
bus.ensure_streams().await?;
|
||||
let names = StreamNames::new(config.stream_prefix.clone());
|
||||
let batch = uuid::Uuid::new_v4().to_string();
|
||||
let count = 8usize;
|
||||
let account_id = format!("cdlq-{batch}");
|
||||
|
||||
// 发布 count 条不同 message_id 的消息
|
||||
let mut message_ids = Vec::new();
|
||||
for index in 0..count {
|
||||
let external = format!("{batch}-{index}");
|
||||
let subject = inbound_subject("wechat", &account_id);
|
||||
let payload = InboundPayload {
|
||||
channel: "wechat".to_string(),
|
||||
account_id: account_id.clone(),
|
||||
from_user_id: "tester".to_string(),
|
||||
content: "force retry".to_string(),
|
||||
context: None,
|
||||
external_message_id: Some(external.clone()),
|
||||
session_id: None,
|
||||
group_id: None,
|
||||
create_time_ms: None,
|
||||
};
|
||||
let message_id = format!("wechat/{account_id}/{external}");
|
||||
message_ids.push(message_id.clone());
|
||||
let envelope = MessageEnvelope::new(
|
||||
subject.as_str(),
|
||||
message_id,
|
||||
format!("trace-{batch}-{index}"),
|
||||
payload,
|
||||
);
|
||||
bus.publish_envelope(subject.as_str(), &envelope).await?;
|
||||
}
|
||||
|
||||
// DLQ consumer 收集 original_message_id
|
||||
let (dlq_tx, mut dlq_rx) = tokio::sync::mpsc::channel(count);
|
||||
let dlq_bus = bus.clone();
|
||||
let dlq_stream = names.name(StreamKind::Dlq);
|
||||
let dlq_ack_wait = config.ack_wait();
|
||||
let dlq_max_deliver = config.max_deliver;
|
||||
let dlq_durable = format!("integration-cdlq-{batch}");
|
||||
let dlq_handle = tokio::spawn(async move {
|
||||
dlq_bus
|
||||
.consume_envelopes::<DeadLetterPayload, _, _>(
|
||||
ConsumerSpec {
|
||||
stream: dlq_stream,
|
||||
durable_name: dlq_durable,
|
||||
filter_subject: "dlq.>".to_string(),
|
||||
ack_wait: dlq_ack_wait,
|
||||
max_deliver: dlq_max_deliver,
|
||||
},
|
||||
move |message| {
|
||||
let dlq_tx = dlq_tx.clone();
|
||||
async move {
|
||||
let _ = dlq_tx.send(message.payload.original_message_id).await;
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
// 失败 consumer(总是失败,触发 redelivery -> DLQ)
|
||||
let retry_bus = bus.clone();
|
||||
let retry_spec = ConsumerSpec {
|
||||
stream: names.name(StreamKind::Inbound),
|
||||
durable_name: format!("integration-cdlq-retry-{batch}"),
|
||||
filter_subject: format!("inbound.wechat.{account_id}"),
|
||||
ack_wait: config.ack_wait(),
|
||||
max_deliver: config.max_deliver,
|
||||
};
|
||||
let retry_handle = tokio::spawn(async move {
|
||||
retry_bus
|
||||
.consume_envelopes::<InboundPayload, _, _>(retry_spec, |_| async move {
|
||||
anyhow::bail!("force redelivery")
|
||||
})
|
||||
.await
|
||||
});
|
||||
|
||||
// 收集 count 条 DLQ 消息,验证无碰撞丢失
|
||||
let mut received = Vec::new();
|
||||
for _ in 0..count {
|
||||
let id = tokio::time::timeout(std::time::Duration::from_secs(30), dlq_rx.recv())
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("dlq consumer closed early"))?;
|
||||
if let Some(id) = id {
|
||||
received.push(id);
|
||||
}
|
||||
}
|
||||
retry_handle.abort();
|
||||
dlq_handle.abort();
|
||||
|
||||
received.sort();
|
||||
message_ids.sort();
|
||||
assert_eq!(received.len(), count, "DLQ 丢失了消息");
|
||||
for id in &message_ids {
|
||||
assert!(received.contains(id), "DLQ 缺少消息 {id}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user