feat: 拆分 worker 架构并增强 assistant 安全保护

- 新增 daemon/channel/assistant/function/scheduler/logging worker 架构和 JetStream 消息协议

- 修复邮件通知误入 assistant、迁移 checksum、日志 subject 和工具调用无限循环风险

- 新增 assistant 工具轮次熔断、工具结果截断和提示词软收敛规则

- 补充邮件推送关键字、可信发件人配置、升级日志和验证测试
This commit is contained in:
2026-07-10 00:41:36 +08:00
parent 7deae286ca
commit af6cfdaa83
70 changed files with 5953 additions and 1608 deletions
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "ias-channel"
version = "0.1.0"
edition = "2024"
[dependencies]
ias-common = { path = "../ias-common" }
ias-context = { path = "../ias-context" }
ias-http = { path = "../ias-http" }
ias-mail = { path = "../ias-mail" }
ias-wechat = { path = "../ias-wechat" }
anyhow = "1"
chrono = { version = "0.4", features = ["serde"] }
serde_json = "1"
sqlx = { version = "0.9.0", features = [
"runtime-tokio",
"tls-rustls",
"postgres",
"chrono",
] }
tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "sync", "time"] }
uuid = { version = "1", features = ["v4", "serde"] }
+602
View File
@@ -0,0 +1,602 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use chrono::{Duration as ChronoDuration, Utc};
use ias_common::mq::{
ConsumerSpec, InboundPayload, JetStreamBus, JetStreamConfig, MessageEnvelope,
OutboundCommandKind, OutboundCommandPayload, StreamKind, StreamNames, inbound_subject,
new_trace_id, outbound_subject,
};
use ias_context::repository::ContextRepository;
use ias_http::web::{internal_url_for_path, public_url_for_path};
use ias_mail::repository::MailRepository;
use ias_mail::{MailNotification, MailPollerConfig, start_mail_polling};
use ias_wechat::manager::{WeChatAccountConfig, WeChatEvent, WeChatMultiAccountManager};
use ias_wechat::types::WeixinMessage;
use sqlx::PgPool;
use tokio::sync::{Mutex, mpsc};
use tokio::task::JoinHandle;
use uuid::Uuid;
type TypingSessions = Arc<Mutex<HashMap<String, String>>>;
const PROJECT_VERSION: &str = include_str!("../../VERSION");
fn current_version() -> &'static str {
PROJECT_VERSION.trim()
}
#[derive(Debug, sqlx::FromRow)]
struct WechatAccountRow {
account_id: Option<String>,
token: Option<String>,
base_url: Option<String>,
updates_buf: Option<String>,
}
pub async fn run_wechat_worker(pool: PgPool, account_ids: Vec<String>) -> anyhow::Result<()> {
let config = JetStreamConfig::from_env();
loop {
match run_wechat_once(pool.clone(), account_ids.clone(), config.clone()).await {
Ok(()) => return Ok(()),
Err(error) => {
eprintln!("WeChat Worker 运行失败: {error:#}");
tokio::time::sleep(Duration::from_secs(5)).await;
}
}
}
}
pub async fn run_mail_worker(pool: PgPool) -> anyhow::Result<()> {
let config = JetStreamConfig::from_env();
loop {
match run_mail_once(pool.clone(), config.clone()).await {
Ok(()) => return Ok(()),
Err(error) => {
eprintln!("Mail Worker 运行失败: {error:#}");
tokio::time::sleep(Duration::from_secs(5)).await;
}
}
}
}
async fn run_wechat_once(
pool: PgPool,
account_ids: Vec<String>,
config: JetStreamConfig,
) -> anyhow::Result<()> {
let bus = JetStreamBus::connect(config.clone()).await?;
bus.ensure_streams().await?;
let accounts = load_wechat_accounts(&pool, &account_ids).await?;
let (mut manager, event_rx) = WeChatMultiAccountManager::new(1024);
for account in accounts {
let Some(account_id) = account.account_id else {
continue;
};
manager
.start_account(WeChatAccountConfig {
token: account.token.unwrap_or_default(),
base_url: account.base_url.unwrap_or_default(),
account_id,
updates_buf: account.updates_buf.unwrap_or_default(),
})
.await
.map_err(|error| anyhow::anyhow!(error))?;
}
let manager = Arc::new(manager);
let event_task = tokio::spawn(handle_wechat_events(pool.clone(), bus.clone(), event_rx));
let startup_notice_task = tokio::spawn(send_startup_notices(pool.clone(), manager.clone()));
let typing_sessions: TypingSessions = Arc::new(Mutex::new(HashMap::new()));
let names = StreamNames::new(config.stream_prefix.clone());
let spec = ConsumerSpec {
stream: names.name(StreamKind::Outbound),
durable_name: "wechat-worker-outbound".to_string(),
filter_subject: "outbound.wechat.>".to_string(),
ack_wait: config.ack_wait(),
max_deliver: config.max_deliver,
};
let outbound_bus = bus.clone();
let consume_result = bus
.consume_envelopes::<OutboundCommandPayload, _, _>(spec, move |envelope| {
let manager = manager.clone();
let typing_sessions = typing_sessions.clone();
let bus = outbound_bus.clone();
async move { handle_outbound_command(bus, manager, typing_sessions, envelope).await }
})
.await;
event_task.abort();
startup_notice_task.abort();
consume_result
}
async fn handle_wechat_events(
pool: PgPool,
bus: JetStreamBus,
mut event_rx: mpsc::Receiver<WeChatEvent>,
) {
while let Some(event) = event_rx.recv().await {
match event {
WeChatEvent::UpdatesBuf {
account_id,
updates_buf,
} => {
persist_updates_buf(&pool, &account_id, &updates_buf).await;
}
WeChatEvent::Message {
account_id,
message,
updates_buf,
} => {
persist_updates_buf(&pool, &account_id, &updates_buf).await;
if message.msg_type != Some(WeixinMessage::TYPE_USER) {
continue;
}
let Some(from_user_id) = message.from_user_id.as_deref() else {
continue;
};
let Some(text) = message.text_content() else {
continue;
};
let payload = InboundPayload {
channel: "wechat".to_string(),
account_id: account_id.clone(),
from_user_id: from_user_id.to_string(),
content: text.to_string(),
context: message.context_token,
external_message_id: message.message_id.map(|id| id.to_string()),
session_id: message.session_id,
group_id: message.group_id,
create_time_ms: message.create_time_ms,
};
let subject = inbound_subject("wechat", &account_id);
let message_id = payload.external_message_id.clone().unwrap_or_else(|| {
format!(
"{}:{}:{}",
from_user_id,
payload.create_time_ms.unwrap_or_default(),
Uuid::new_v4()
)
});
let envelope = MessageEnvelope::new(
subject.as_str(),
format!("wechat/{account_id}/{message_id}"),
new_trace_id(),
payload,
);
if let Err(error) = bus.publish_envelope(subject.as_str(), &envelope).await {
eprintln!("发布微信 inbound 消息失败: {error:#}");
}
}
WeChatEvent::PollError { account_id, error } => {
eprintln!("微信账号轮询失败 account_id={account_id}: {error}");
}
}
}
}
async fn handle_outbound_command(
_bus: JetStreamBus,
manager: Arc<WeChatMultiAccountManager>,
typing_sessions: TypingSessions,
envelope: MessageEnvelope<OutboundCommandPayload>,
) -> anyhow::Result<()> {
let command = envelope.payload;
if command.channel != "wechat" {
return Ok(());
}
let key = typing_key(&command);
match command.kind {
OutboundCommandKind::Text => {
let text = command.content.unwrap_or_default();
let client_id = typing_sessions.lock().await.remove(&key);
match client_id {
Some(client_id) => {
manager
.send_text_with_client_id(
&command.account_id,
&command.to_user_id,
&text,
command.context.as_deref(),
&client_id,
)
.await
.map_err(|error| anyhow::anyhow!(error))?;
let _ = manager
.cancel_typing(
&command.account_id,
&command.to_user_id,
command.context.as_deref(),
)
.await;
}
None => {
manager
.send_text(
&command.account_id,
&command.to_user_id,
&text,
command.context.as_deref(),
)
.await
.map_err(|error| anyhow::anyhow!(error))?;
}
}
}
OutboundCommandKind::TypingStart => {
let client_id = manager
.send_typing(
&command.account_id,
&command.to_user_id,
command.context.as_deref(),
)
.await
.map_err(|error| anyhow::anyhow!(error))?;
typing_sessions.lock().await.insert(key, client_id);
}
OutboundCommandKind::TypingRefresh => {
let client_id = typing_sessions.lock().await.get(&key).cloned();
if let Some(client_id) = client_id {
manager
.send_typing_with_client_id(
&command.account_id,
&command.to_user_id,
command.context.as_deref(),
&client_id,
)
.await
.map_err(|error| anyhow::anyhow!(error))?;
}
}
OutboundCommandKind::TypingStop => {
typing_sessions.lock().await.remove(&key);
manager
.cancel_typing(
&command.account_id,
&command.to_user_id,
command.context.as_deref(),
)
.await
.map_err(|error| anyhow::anyhow!(error))?;
}
}
Ok(())
}
async fn run_mail_once(pool: PgPool, config: JetStreamConfig) -> anyhow::Result<()> {
let bus = JetStreamBus::connect(config).await?;
bus.ensure_streams().await?;
let mut configs = match MailRepository::list_enabled_accounts(&pool).await {
Ok(accounts) => accounts
.into_iter()
.map(MailPollerConfig::from_account)
.collect::<Vec<_>>(),
Err(error) => {
eprintln!("读取数据库邮件账户失败:{error}");
Vec::new()
}
};
if configs.is_empty()
&& let Some(config) = MailPollerConfig::from_env()?
{
configs.push(config);
}
if configs.is_empty() {
println!("Mail Worker 未启动轮询:未配置邮箱账户");
}
let (notification_tx, mut notification_rx) = mpsc::channel::<MailNotification>(64);
let mut tasks: Vec<JoinHandle<()>> = configs
.into_iter()
.map(|config| start_mail_polling(pool.clone(), config, notification_tx.clone()))
.collect();
while let Some(notification) = notification_rx.recv().await {
if let Err(error) = publish_mail_notification(&bus, notification).await {
eprintln!("发布邮件通知消息失败,继续处理后续通知: {error:#}");
}
}
for task in tasks.drain(..) {
task.abort();
}
Ok(())
}
async fn publish_mail_notification(
bus: &JetStreamBus,
notification: MailNotification,
) -> anyhow::Result<()> {
let trace_id = new_trace_id();
let outbound = OutboundCommandPayload::text(
"wechat",
notification.account_id.clone(),
notification.from_user_id.clone(),
notification.text.clone(),
None,
None,
);
let outbound_subject = outbound_subject("wechat", &notification.account_id);
let outbound_envelope = MessageEnvelope::new(
outbound_subject.as_str(),
outbound.command_id.clone(),
trace_id,
outbound,
);
bus.publish_envelope(outbound_subject.as_str(), &outbound_envelope)
.await?;
Ok(())
}
async fn send_startup_notices(pool: PgPool, manager: Arc<WeChatMultiAccountManager>) {
send_online_notices(&pool, manager.as_ref()).await;
send_version_update_notice(&pool, manager.as_ref()).await;
}
async fn send_online_notices(pool: &PgPool, manager: &WeChatMultiAccountManager) {
if !online_notice_enabled() {
return;
}
let text = online_notice_text();
let lookback_hours = read_i64_env("IA_ONLINE_NOTICE_LOOKBACK_HOURS", 168);
let limit = read_i64_env("IA_ONLINE_NOTICE_LIMIT", 50);
let since = Utc::now() - ChronoDuration::hours(lookback_hours);
for account_id in manager.account_ids() {
let recipients =
match ContextRepository::recent_recipients(pool, "wechat", &account_id, since, limit)
.await
{
Ok(recipients) => recipients,
Err(error) => {
eprintln!("查询上线提醒收件人失败 account_id={account_id}: {error}");
continue;
}
};
for recipient in recipients {
if let Err(error) = manager
.send_text(&account_id, &recipient.from_user_id, &text, None)
.await
{
eprintln!(
"发送上线提醒失败 account_id={} to={}: {}",
account_id, recipient.from_user_id, error
);
}
}
}
}
async fn send_version_update_notice(pool: &PgPool, manager: &WeChatMultiAccountManager) {
if !version_notice_enabled() {
return;
}
let version = current_version();
match version_notice_already_sent(pool, version).await {
Ok(true) => return,
Ok(false) => {}
Err(error) => {
eprintln!("查询版本更新通知状态失败: {error}");
return;
}
}
let title = match upgrade_log_title(pool, version).await {
Ok(Some(title)) => title,
Ok(None) => "服务更新".to_string(),
Err(error) => {
eprintln!("查询版本更新日志标题失败: {error}");
"服务更新".to_string()
}
};
let url = public_url_for_path(&format!("/updates/{version}"));
let internal_url = internal_url_for_path(&format!("/updates/{version}"));
let text =
format!("已更新到 v{version}{title}\n[公网地址]({url})\n[内网地址]({internal_url})");
let lookback_hours = read_i64_env("IA_VERSION_NOTICE_LOOKBACK_HOURS", 168);
let limit = read_i64_env("IA_VERSION_NOTICE_LIMIT", 50);
let since = Utc::now() - ChronoDuration::hours(lookback_hours);
let mut sent_count = 0;
for account_id in manager.account_ids() {
let recipients =
match ContextRepository::recent_recipients(pool, "wechat", &account_id, since, limit)
.await
{
Ok(recipients) => recipients,
Err(error) => {
eprintln!("查询版本更新通知收件人失败 account_id={account_id}: {error}");
continue;
}
};
for recipient in recipients {
match manager
.send_text(&account_id, &recipient.from_user_id, &text, None)
.await
{
Ok(_) => sent_count += 1,
Err(error) => {
eprintln!(
"发送版本更新通知失败 account_id={} to={}: {}",
account_id, recipient.from_user_id, error
);
}
}
}
}
if sent_count > 0
&& let Err(error) = mark_version_notice_sent(pool, version, sent_count).await
{
eprintln!("记录版本更新通知状态失败: {error}");
}
}
async fn version_notice_already_sent(pool: &PgPool, version: &str) -> anyhow::Result<bool> {
let exists = sqlx::query_scalar::<_, i32>(
r#"
SELECT 1
FROM ias_version_notifications
WHERE version = $1
"#,
)
.bind(version)
.fetch_optional(pool)
.await?;
Ok(exists.is_some())
}
async fn upgrade_log_title(pool: &PgPool, version: &str) -> anyhow::Result<Option<String>> {
let title = sqlx::query_scalar::<_, String>(
r#"
SELECT title
FROM ias_upgrade_logs
WHERE version = $1
"#,
)
.bind(version)
.fetch_optional(pool)
.await?;
Ok(title)
}
async fn mark_version_notice_sent(
pool: &PgPool,
version: &str,
recipient_count: i32,
) -> anyhow::Result<()> {
sqlx::query(
r#"
INSERT INTO ias_version_notifications (version, recipient_count)
VALUES ($1, $2)
ON CONFLICT (version) DO UPDATE SET
notified_at = NOW(),
recipient_count = EXCLUDED.recipient_count
"#,
)
.bind(version)
.bind(recipient_count)
.execute(pool)
.await?;
Ok(())
}
fn online_notice_enabled() -> bool {
read_bool_env("IA_ONLINE_NOTICE_ENABLED", true)
}
fn version_notice_enabled() -> bool {
read_bool_env("IA_VERSION_NOTICE_ENABLED", true)
}
fn online_notice_text() -> String {
std::env::var("IA_ONLINE_NOTICE_TEXT")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "我又上线啦,现在可以继续帮你处理消息了。".to_string())
}
fn read_bool_env(name: &str, default: bool) -> bool {
std::env::var(name)
.ok()
.map(|value| {
let value = value.trim().to_ascii_lowercase();
!matches!(value.as_str(), "0" | "false" | "no" | "off")
})
.unwrap_or(default)
}
fn read_i64_env(name: &str, default: i64) -> i64 {
std::env::var(name)
.ok()
.and_then(|value| value.trim().parse::<i64>().ok())
.filter(|value| *value > 0)
.unwrap_or(default)
}
async fn load_wechat_accounts(
pool: &PgPool,
account_ids: &[String],
) -> anyhow::Result<Vec<WechatAccountRow>> {
if account_ids.is_empty() {
return Ok(sqlx::query_as::<_, WechatAccountRow>(
r#"
SELECT account_id, token, base_url, updates_buf
FROM ias_wechat_accounts
WHERE account_id IS NOT NULL
ORDER BY id ASC
"#,
)
.fetch_all(pool)
.await?);
}
Ok(sqlx::query_as::<_, WechatAccountRow>(
r#"
SELECT account_id, token, base_url, updates_buf
FROM ias_wechat_accounts
WHERE account_id = ANY($1)
ORDER BY id ASC
"#,
)
.bind(account_ids)
.fetch_all(pool)
.await?)
}
async fn persist_updates_buf(pool: &PgPool, account_id: &str, buf: &str) {
if let Err(error) = sqlx::query(
r#"
UPDATE ias_wechat_accounts
SET updates_buf = $1
WHERE account_id = $2
"#,
)
.bind(buf)
.bind(account_id)
.execute(pool)
.await
{
eprintln!("持久化微信 updates_buf 失败 account_id={account_id}: {error:#}");
}
}
fn typing_key(command: &OutboundCommandPayload) -> String {
format!(
"{}:{}:{}:{}",
command.account_id,
command.to_user_id,
command.context.as_deref().unwrap_or_default(),
command.turn_id.as_deref().unwrap_or_default()
)
}
#[cfg(test)]
mod tests {
use super::typing_key;
use ias_common::mq::{OutboundCommandKind, OutboundCommandPayload};
#[test]
fn typing_key_includes_turn_for_idempotent_typing_session() {
let command = OutboundCommandPayload::typing(
OutboundCommandKind::TypingStart,
"wechat",
"acct",
"user",
Some("ctx".to_string()),
None,
Some("turn-1".to_string()),
);
assert_eq!(typing_key(&command), "acct:user:ctx:turn-1");
}
}