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
+2 -29
View File
@@ -1,15 +1,11 @@
[package]
name = "ias-service"
version = "0.1.25"
version = "0.1.27"
edition = "2024"
[dependencies]
# ── 公共模块 ──
ias-common = { path = "../ias-common" }
# ── 上下文管理 ──
ias-context = { path = "../ias-context" }
# ── HTTP 服务与预览页面 ──
ias-http = { path = "../ias-http" }
# ── 邮件轮询 ──
ias-mail = { path = "../ias-mail" }
# ── ai api 调用 ──
@@ -19,28 +15,11 @@ ias-wechat = { path = "../ias-wechat" }
# ── api ──
axum = "0.8.9"
# ── 异步运行时 ──
tokio = { version = "1.0", features = [
"rt-multi-thread",
"macros",
"sync",
"time",
"process",
"signal",
"io-util",
"net",
] } # 异步运行时核心
# ── HTTP 客户端 ──
reqwest = { version = "0.12", default-features = false, features = [
"json",
"stream",
"rustls-tls",
] } # HTTP 请求(微信 API + DeepSeek API + 工具调用)
tokio = { version = "1.0", features = ["fs"] }
# ── 序列化 ──
serde = { version = "1.0", features = ["derive"] } # 序列化/反序列化
serde_json = "1.0" # JSON 处理
serde_yaml = "0.9" # YAML 解析(工具规范文件)
# ── 环境变量 ──
dotenvy = "0.15.7"
# ── 数据库 ──
sqlx = { version = "0.9.0", features = [
"runtime-tokio",
@@ -50,13 +29,7 @@ sqlx = { version = "0.9.0", features = [
] }
# ── 错误处理 ──
anyhow = "1"
thiserror = "2"
# ── 日志管理 ──
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# ── 时间处理 ──
chrono = { version = "0.4", features = ["serde"] } # 日期时间
# ── 唯一标识 ──
uuid = { version = "1", features = ["v4", "serde"] }
# ── Unix 后台进程控制 ──
libc = "0.2"
-78
View File
@@ -1,78 +0,0 @@
use std::sync::Arc;
use crate::{AppState, repository::user_repository::UserRepositor};
use ias_common::queue::{ChannelMessage, QueueMessage};
use ias_wechat::{manager::WeChatEvent, types::WeixinMessage};
use sqlx::PgPool;
use tokio::sync::mpsc::Receiver;
pub async fn start_loop(
state: Arc<AppState>,
mut event_rx: Receiver<WeChatEvent>,
) -> Result<(), String> {
tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
match event {
WeChatEvent::UpdatesBuf {
account_id,
updates_buf,
} => {
state.runtime.record_wechat_event(account_id.clone()).await;
persist_updates_buf(&state.db, account_id, updates_buf).await;
}
WeChatEvent::Message {
account_id,
message,
updates_buf,
} => {
state.runtime.record_wechat_event(account_id.clone()).await;
persist_updates_buf(&state.db, account_id.clone(), 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;
};
println!("Message account_id:{}=>{}", account_id.clone(), text);
state
.sender
.clone()
.send(QueueMessage::Channel(ChannelMessage {
channel: "wechat".to_string(),
account_id,
from_user_id: from_user_id.to_string(),
content: text.to_string(),
context: message.context_token,
turn_id: None,
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,
}))
.await
.unwrap();
}
WeChatEvent::PollError { account_id, error } => {
state
.runtime
.record_wechat_error(account_id.clone(), error.clone())
.await;
println!("微信账号轮询失败 account_id={account_id}: {error}");
}
}
}
});
Ok(())
}
async fn persist_updates_buf(pool: &PgPool, account_id: String, buf: String) {
UserRepositor::update_account_buf(pool, account_id, buf)
.await
.unwrap();
}
-34
View File
@@ -1,44 +1,10 @@
use crate::model::user::{WechatAccount, WechatAccountCreate};
use ias_wechat::client::WeChatClient;
use ias_wechat::manager::{WeChatAccountConfig, WeChatMultiAccountManager};
use sqlx::PgPool;
use std::io;
use std::sync::Arc;
use crate::AppState;
use crate::channel::wechat::looper::start_loop;
use crate::repository::user_repository::UserRepositor;
pub async fn init_wechat(state: Option<AppState>) -> Option<WeChatMultiAccountManager> {
if let Some(state) = state {
println!("init_wechat");
if let Some(accounts) = load_accounts(&state.db).await {
println!("accounts:{}", accounts.len());
let (mut manager, event_rx) = WeChatMultiAccountManager::new(1024);
for account in accounts {
println!("account:{}", account.account_id.clone().unwrap_or_default());
manager
.start_account(WeChatAccountConfig {
token: account.token.unwrap_or_default(),
base_url: account.base_url.unwrap_or_default(),
account_id: account.account_id.unwrap_or_default(),
updates_buf: account.updates_buf.unwrap_or_default(),
})
.await
.unwrap();
}
state
.runtime
.replace_wechat_accounts(manager.account_ids())
.await;
start_loop(Arc::new(state.clone()), event_rx).await.unwrap();
return Some(manager);
}
}
None
}
pub async fn login_user(pool: &PgPool) {
let client = WeChatClient::new(None);
-1
View File
@@ -1,2 +1 @@
pub mod looper;
pub mod manager;
+6
View File
@@ -91,6 +91,8 @@ struct MailConfigItem {
mailbox: String,
notify_account_id: String,
notify_user_id: String,
notification_subject_keywords: Vec<String>,
trusted_senders: Vec<String>,
poll_seconds: i32,
fetch_limit: i32,
accept_invalid_certs: bool,
@@ -359,6 +361,8 @@ fn sanitize_mail_account(account: MailAccount) -> MailConfigItem {
mailbox: account.mailbox,
notify_account_id: account.notify_account_id,
notify_user_id: account.notify_user_id,
notification_subject_keywords: account.notification_subject_keywords,
trusted_senders: account.trusted_senders,
poll_seconds: account.poll_seconds,
fetch_limit: account.fetch_limit,
accept_invalid_certs: account.accept_invalid_certs,
@@ -474,6 +478,8 @@ mod tests {
mailbox: "INBOX".to_string(),
notify_account_id: "wechat-1".to_string(),
notify_user_id: "user-1".to_string(),
notification_subject_keywords: vec!["告警".to_string()],
trusted_senders: vec!["alerts@example.com".to_string()],
poll_seconds: 60,
fetch_limit: 20,
accept_invalid_certs: false,
-142
View File
@@ -1,142 +0,0 @@
use std::path::PathBuf;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::watch;
use crate::core::runtime::{RuntimeSnapshot, RuntimeState};
#[derive(Debug, Clone)]
pub struct RuntimePaths {
pub dir: PathBuf,
pub pid_file: PathBuf,
pub socket_file: PathBuf,
pub state_file: PathBuf,
pub log_file: PathBuf,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ControlResponse {
Status { snapshot: RuntimeSnapshot },
Ack { message: String },
Error { message: String },
}
impl RuntimePaths {
pub fn new() -> Self {
let dir = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| {
let user = std::env::var("USER").unwrap_or_else(|_| "user".to_string());
std::env::temp_dir().join(format!("ias-{user}"))
})
.join("ias");
Self {
pid_file: dir.join("ias.pid"),
socket_file: dir.join("ias.sock"),
state_file: dir.join("ias.state.json"),
log_file: dir.join("ias.log"),
dir,
}
}
pub fn ensure_dir(&self) -> anyhow::Result<()> {
std::fs::create_dir_all(&self.dir)?;
Ok(())
}
}
pub async fn start_control_server(
runtime: Arc<RuntimeState>,
paths: RuntimePaths,
shutdown_tx: watch::Sender<bool>,
mut shutdown_rx: watch::Receiver<bool>,
) -> anyhow::Result<()> {
paths.ensure_dir()?;
if paths.socket_file.exists() {
if UnixStream::connect(&paths.socket_file).await.is_ok() {
anyhow::bail!(
"服务控制 socket 已存在且可连接: {}",
paths.socket_file.display()
);
}
std::fs::remove_file(&paths.socket_file)?;
}
let listener = UnixListener::bind(&paths.socket_file)?;
loop {
tokio::select! {
result = listener.accept() => {
let (stream, _) = result?;
let runtime = runtime.clone();
let shutdown_tx = shutdown_tx.clone();
tokio::spawn(async move {
if let Err(err) = handle_control_stream(stream, runtime, shutdown_tx).await {
eprintln!("处理服务控制命令失败: {err}");
}
});
}
changed = shutdown_rx.changed() => {
if changed.is_err() || *shutdown_rx.borrow() {
break;
}
}
}
}
Ok(())
}
pub async fn request_control(
paths: &RuntimePaths,
command: &str,
) -> anyhow::Result<ControlResponse> {
let mut stream = UnixStream::connect(&paths.socket_file).await?;
stream.write_all(command.as_bytes()).await?;
stream.write_all(b"\n").await?;
let mut reader = BufReader::new(stream);
let mut line = String::new();
reader.read_line(&mut line).await?;
let response = serde_json::from_str(line.trim())?;
Ok(response)
}
async fn handle_control_stream(
stream: UnixStream,
runtime: Arc<RuntimeState>,
shutdown_tx: watch::Sender<bool>,
) -> anyhow::Result<()> {
let mut reader = BufReader::new(stream);
let mut command = String::new();
reader.read_line(&mut command).await?;
let command = command.trim();
let response = match command {
"status" => ControlResponse::Status {
snapshot: runtime.snapshot().await,
},
"shutdown" => {
runtime.mark_stopping().await;
let _ = shutdown_tx.send(true);
ControlResponse::Ack {
message: "服务正在停止".to_string(),
}
}
_ => ControlResponse::Error {
message: format!("未知控制命令: {command}"),
},
};
let response = serde_json::to_string(&response)?;
let stream = reader.get_mut();
stream.write_all(response.as_bytes()).await?;
stream.write_all(b"\n").await?;
stream.flush().await?;
Ok(())
}
-4
View File
@@ -1,4 +0,0 @@
pub mod control;
pub mod queue;
pub mod runtime;
pub mod supervisor;
-561
View File
@@ -1,561 +0,0 @@
use ias_ai::core::send_message;
use ias_common::model::ai::ChatCompletionRequestMessage;
use ias_common::queue::{ChannelMessage, QueueMessage, ToolMessage};
use ias_context::{ContextManager, NewSessionReason};
use ias_http::logs::log_operation;
use ias_wechat::manager::WeChatMultiAccountManager;
use serde_json::json;
use sqlx::PgPool;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{mpsc::Receiver, mpsc::Sender};
use tokio::task::JoinHandle;
use uuid::Uuid;
struct TypingSession {
client_id: String,
refresh_task: JoinHandle<()>,
}
pub async fn create_queue(
sender: Sender<QueueMessage>,
mut receiver: Receiver<QueueMessage>,
manager: Arc<WeChatMultiAccountManager>,
context_manager: ContextManager,
pool: PgPool,
) -> anyhow::Result<JoinHandle<()>> {
let handle = tokio::spawn(async move {
let mut typing_sessions: HashMap<String, TypingSession> = HashMap::new();
let mut active_turns: HashMap<String, String> = HashMap::new();
let mut active_tasks: HashMap<String, JoinHandle<()>> = HashMap::new();
while let Some(msg) = receiver.recv().await {
let queue_sender = sender.clone();
match msg {
QueueMessage::Aissitant(channel, assistant) => {
if !is_active_turn(&active_turns, &channel) {
eprintln!("跳过已被打断的 assistant 结果");
continue;
}
let content = assistant.content.clone();
let reasoning = assistant.reasoning.clone().unwrap_or_default();
let has_tool_calls = assistant
.tool_calls
.as_ref()
.is_some_and(|calls| !calls.is_empty());
println!(
"\n\n收到llm回复:\n{}\n-------\n{}\n工具调用数:{}",
reasoning,
content,
assistant.tool_calls.as_ref().map_or(0, Vec::len)
);
log_operation(
&pool,
"info",
"llm",
&format!("LLM 响应 channel={} account_id={} from_user_id={} 工具调用数={}", channel.channel, channel.account_id, channel.from_user_id, assistant.tool_calls.as_ref().map_or(0, Vec::len)),
json!({
"content_preview": text_preview(&content, 500),
"reasoning_preview": text_preview(&reasoning, 500),
"tool_call_count": assistant.tool_calls.as_ref().map_or(0, Vec::len),
"tool_call_names": assistant.tool_calls.as_ref().map(|calls| calls.iter().filter_map(|c| c.function.as_ref().map(|f| f.name.clone())).collect::<Vec<_>>()),
}),
)
.await;
if has_tool_calls {
refresh_typing(manager.clone(), &typing_sessions, &channel).await;
} else if !content.trim().is_empty() || !reasoning.trim().is_empty() {
let text = if !content.trim().is_empty() {
content.trim()
} else {
reasoning.trim()
};
let session_key = typing_key(&channel);
if let Some(session) = typing_sessions.remove(&session_key) {
session.refresh_task.abort();
let send_result = manager
.send_text_with_client_id(
&channel.account_id,
&channel.from_user_id,
text,
channel.context.as_deref(),
&session.client_id,
)
.await;
if let Err(err) = manager
.cancel_typing(
&channel.account_id,
&channel.from_user_id,
channel.context.as_deref(),
)
.await
{
eprintln!("取消微信输入中状态失败: {}", err);
}
send_result.unwrap();
} else {
manager
.send_text(
&channel.account_id,
&channel.from_user_id,
text,
channel.context.as_deref(),
)
.await
.unwrap();
}
} else if assistant
.tool_calls
.as_ref()
.is_none_or(|calls| calls.is_empty())
{
stop_typing(manager.clone(), &mut typing_sessions, &channel).await;
}
if let Err(err) = context_manager
.record_assistant_message(
&channel,
assistant.content.clone(),
assistant.tool_calls.clone(),
assistant.reasoning.clone(),
assistant.raw_message.clone(),
)
.await
{
eprintln!("记录 assistant 上下文失败: {}", err);
log_operation(
&pool,
"error",
"context",
&format!("记录 assistant 上下文失败 channel={} account_id={} from_user_id={}", channel.channel, channel.account_id, channel.from_user_id),
json!({"error": err.to_string()}),
)
.await;
}
if !has_tool_calls {
let key = operation_key(&channel);
active_turns.remove(&key);
active_tasks.remove(&key);
}
}
QueueMessage::Notice(channel, text) => {
if !is_active_turn(&active_turns, &channel) {
eprintln!("跳过已被打断的工具调用提醒");
continue;
}
let text = text.trim();
if !text.is_empty() {
println!("\n\n发送工具调用提醒:\n{}", text);
if let Err(err) = manager
.send_text(
&channel.account_id,
&channel.from_user_id,
text,
channel.context.as_deref(),
)
.await
{
eprintln!("发送工具调用提醒失败: {}", err);
}
}
}
QueueMessage::Channel(mut bundle) => {
println!(
"\n\n收到来自{}消息:\n{}",
bundle.from_user_id.clone(),
bundle.content.clone()
);
log_operation(
&pool,
"info",
"channel",
&format!(
"收到渠道消息 channel={} account_id={} from_user_id={}",
bundle.channel, bundle.account_id, bundle.from_user_id
),
json!({
"content": bundle.content.clone(),
"context": bundle.context.clone(),
"external_message_id": bundle.external_message_id.clone(),
}),
)
.await;
let op_key = operation_key(&bundle);
if bundle.content.trim().eq_ignore_ascii_case("/new") {
if let Some(task) = active_tasks.remove(&op_key) {
task.abort();
}
active_turns.remove(&op_key);
stop_typing(manager.clone(), &mut typing_sessions, &bundle).await;
let text = match context_manager
.start_new_session(&bundle, NewSessionReason::UserCommand)
.await
{
Ok(_) => "已开启新的上下文。".to_string(),
Err(err) => {
eprintln!("开启新上下文失败: {}", err);
"开启新上下文失败,请稍后再试。".to_string()
}
};
if let Err(err) = manager
.send_text(
&bundle.account_id,
&bundle.from_user_id,
&text,
bundle.context.as_deref(),
)
.await
{
eprintln!("发送新上下文确认失败: {}", err);
}
continue;
}
let turn_id = Uuid::new_v4().to_string();
bundle.turn_id = Some(turn_id.clone());
active_turns.insert(op_key.clone(), turn_id);
start_typing(manager.clone(), &mut typing_sessions, &bundle).await;
let messages = match context_manager.record_user_message(&bundle).await {
Ok(snapshot) => snapshot.messages,
Err(err) => {
eprintln!("记录 user 上下文失败,退回单轮消息: {}", err);
log_operation(
&pool,
"error",
"context",
&format!(
"记录 user 上下文失败 channel={} account_id={} from_user_id={}",
bundle.channel, bundle.account_id, bundle.from_user_id
),
json!({"error": err.to_string()}),
)
.await;
vec![ChatCompletionRequestMessage::new(
"user".to_string(),
bundle.content.clone(),
)]
}
};
let pool_clone = pool.clone();
let task = tokio::spawn(async move {
if let Err(err) = send_message(queue_sender, messages, bundle).await {
eprintln!("发送消息失败: {}", err);
log_operation(
&pool_clone,
"error",
"llm",
&format!("发送消息失败: {}", err),
json!({}),
)
.await;
}
});
active_tasks.insert(op_key, task);
}
QueueMessage::Tool(channel, tool_call_id, name, result) => {
if !is_active_turn(&active_turns, &channel) {
eprintln!("跳过已被打断的单个工具结果");
continue;
}
process_tool_results(
queue_sender,
manager.clone(),
&context_manager,
&typing_sessions,
&mut active_turns,
&mut active_tasks,
&pool,
channel,
vec![ToolMessage {
tool_call_id,
name,
result,
}],
)
.await;
}
QueueMessage::Tools(channel, tools) => {
if !is_active_turn(&active_turns, &channel) {
eprintln!("跳过已被打断的批量工具结果");
continue;
}
process_tool_results(
queue_sender,
manager.clone(),
&context_manager,
&typing_sessions,
&mut active_turns,
&mut active_tasks,
&pool,
channel,
tools,
)
.await;
}
}
}
});
Ok(handle)
}
async fn process_tool_results(
queue_sender: Sender<QueueMessage>,
manager: Arc<WeChatMultiAccountManager>,
context_manager: &ContextManager,
typing_sessions: &HashMap<String, TypingSession>,
active_turns: &mut HashMap<String, String>,
active_tasks: &mut HashMap<String, JoinHandle<()>>,
pool: &PgPool,
channel: ChannelMessage,
tools: Vec<ToolMessage>,
) {
if tools.is_empty() {
eprintln!("工具调用结果列表为空,跳过后续 LLM 请求");
return;
}
refresh_typing(manager, typing_sessions, &channel).await;
let mut messages = None;
for tool in tools {
println!("\n\n调用{}工具的结果:\n{}", tool.name, tool.result);
log_operation(
pool,
"info",
"tool",
&format!(
"工具结果 channel={} account_id={} from_user_id={} tool_name={}",
channel.channel, channel.account_id, channel.from_user_id, tool.name
),
json!({
"tool_name": tool.name,
"tool_call_id": tool.tool_call_id,
"result_preview": text_preview(&tool.result, 1000),
}),
)
.await;
match context_manager
.record_tool_message(
&channel,
tool.tool_call_id.clone(),
tool.name.clone(),
tool.result.clone(),
)
.await
{
Ok(snapshot) => messages = Some(snapshot.messages),
Err(err) => {
eprintln!("记录 tool 上下文失败,停止本轮工具后续请求: {}", err);
log_operation(
pool,
"error",
"context",
&format!("记录 tool 上下文失败 channel={} account_id={} from_user_id={} tool_name={}", channel.channel, channel.account_id, channel.from_user_id, tool.name),
json!({"error": err.to_string(), "tool_name": tool.name}),
)
.await;
return;
}
}
}
let Some(messages) = messages else {
return;
};
let key = operation_key(&channel);
if !is_active_turn(active_turns, &channel) {
eprintln!("工具结果写入后发现 turn 已被打断,跳过后续 LLM 请求");
return;
}
let pool_clone = pool.clone();
let task = tokio::spawn(async move {
if let Err(err) = send_message(queue_sender, messages, channel).await {
eprintln!("发送消息失败: {}", err);
log_operation(
&pool_clone,
"error",
"llm",
&format!("发送消息失败: {}", err),
json!({}),
)
.await;
}
});
active_tasks.insert(key, task);
}
async fn start_typing(
manager: Arc<WeChatMultiAccountManager>,
sessions: &mut HashMap<String, TypingSession>,
channel: &ChannelMessage,
) {
let key = typing_key(channel);
stop_typing(manager.clone(), sessions, channel).await;
let client_id = match manager
.send_typing(
&channel.account_id,
&channel.from_user_id,
channel.context.as_deref(),
)
.await
{
Ok(client_id) => client_id,
Err(err) => {
eprintln!("设置微信输入中状态失败: {}", err);
return;
}
};
let account_id = channel.account_id.clone();
let from_user_id = channel.from_user_id.clone();
let context = channel.context.clone();
let refresh_client_id = client_id.clone();
let refresh_manager = manager.clone();
let refresh_task = tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(4)).await;
if let Err(err) = refresh_manager
.send_typing_with_client_id(
&account_id,
&from_user_id,
context.as_deref(),
&refresh_client_id,
)
.await
{
eprintln!("刷新微信输入中状态失败: {}", err);
return;
}
}
});
sessions.insert(
key,
TypingSession {
client_id,
refresh_task,
},
);
}
async fn refresh_typing(
manager: Arc<WeChatMultiAccountManager>,
sessions: &HashMap<String, TypingSession>,
channel: &ChannelMessage,
) {
let Some(session) = sessions.get(&typing_key(channel)) else {
return;
};
if let Err(err) = manager
.send_typing_with_client_id(
&channel.account_id,
&channel.from_user_id,
channel.context.as_deref(),
&session.client_id,
)
.await
{
eprintln!("刷新微信输入中状态失败: {}", err);
}
}
async fn stop_typing(
manager: Arc<WeChatMultiAccountManager>,
sessions: &mut HashMap<String, TypingSession>,
channel: &ChannelMessage,
) {
if let Some(session) = sessions.remove(&typing_key(channel)) {
session.refresh_task.abort();
if let Err(err) = manager
.cancel_typing(
&channel.account_id,
&channel.from_user_id,
channel.context.as_deref(),
)
.await
{
eprintln!("取消微信输入中状态失败: {}", err);
}
}
}
fn typing_key(channel: &ChannelMessage) -> String {
format!(
"{}:{}:{}",
channel.account_id,
channel.from_user_id,
channel.context.as_deref().unwrap_or_default()
)
}
fn operation_key(channel: &ChannelMessage) -> String {
format!(
"{}:{}:{}",
channel.channel, channel.account_id, channel.from_user_id
)
}
fn is_active_turn(active_turns: &HashMap<String, String>, channel: &ChannelMessage) -> bool {
let Some(turn_id) = channel.turn_id.as_deref() else {
return false;
};
active_turns
.get(&operation_key(channel))
.is_some_and(|active| active == turn_id)
}
fn text_preview(value: &str, max_bytes: usize) -> &str {
if value.len() <= max_bytes {
return value;
}
let mut end = max_bytes;
while !value.is_char_boundary(end) {
end -= 1;
}
&value[..end]
}
#[cfg(test)]
mod tests {
use super::text_preview;
#[test]
fn text_preview_uses_utf8_char_boundary() {
let prefix = "a".repeat(498);
let text = format!("{prefix}");
assert_eq!(text.as_bytes().len(), 501);
assert_eq!(text_preview(&text, 500), prefix);
}
#[test]
fn text_preview_keeps_complete_short_text() {
let text = "中文回复";
assert_eq!(text_preview(text, 500), text);
}
}
// pub async fn send_to_queue(
// State(state): State<Arc<AppState>>,
// Json(body): Json<ChannelMessageBundle>,
// ) -> Json<serde_json::Value> {
// match state
// .sender
// .send(QueueMessage::Channel(body.channel, body.content))
// .await
// {
// Ok(_) => Json(serde_json::json!({
// "success":true,
// "message":"queued"
// })),
// Err(_) => Json(serde_json::json!({
// "success":true,
// "message":"queue closed"
// })),
// }
// }
-195
View File
@@ -1,195 +0,0 @@
use std::collections::HashMap;
use std::path::Path;
use std::process;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ServicePhase {
Starting,
Running,
Stopping,
Stopped,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AccountRuntimePhase {
Running,
Retrying,
Stopped,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountRuntimeStatus {
pub account_id: String,
pub phase: AccountRuntimePhase,
pub last_error: Option<String>,
pub last_event_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuntimeSnapshot {
pub phase: ServicePhase,
pub pid: u32,
pub started_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub http_addr: String,
pub queue_alive: bool,
pub wechat_accounts: Vec<AccountRuntimeStatus>,
pub last_error: Option<String>,
}
struct RuntimeInner {
phase: ServicePhase,
started_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
http_addr: String,
queue_alive: bool,
wechat_accounts: HashMap<String, AccountRuntimeStatus>,
last_error: Option<String>,
}
pub struct RuntimeState {
inner: Mutex<RuntimeInner>,
}
impl RuntimeState {
pub fn new(http_addr: String) -> Self {
let now = Utc::now();
Self {
inner: Mutex::new(RuntimeInner {
phase: ServicePhase::Starting,
started_at: now,
updated_at: now,
http_addr,
queue_alive: false,
wechat_accounts: HashMap::new(),
last_error: None,
}),
}
}
pub async fn mark_running(&self) {
let mut inner = self.inner.lock().await;
inner.phase = ServicePhase::Running;
inner.updated_at = Utc::now();
inner.last_error = None;
}
pub async fn mark_stopping(&self) {
let mut inner = self.inner.lock().await;
inner.phase = ServicePhase::Stopping;
inner.queue_alive = false;
inner.updated_at = Utc::now();
}
pub async fn mark_stopped(&self) {
let mut inner = self.inner.lock().await;
inner.phase = ServicePhase::Stopped;
inner.queue_alive = false;
inner.updated_at = Utc::now();
}
pub async fn mark_failed(&self, error: impl Into<String>) {
let mut inner = self.inner.lock().await;
inner.phase = ServicePhase::Failed;
inner.queue_alive = false;
inner.updated_at = Utc::now();
inner.last_error = Some(error.into());
}
pub async fn set_queue_alive(&self, alive: bool) {
let mut inner = self.inner.lock().await;
inner.queue_alive = alive;
inner.updated_at = Utc::now();
}
pub async fn replace_wechat_accounts(&self, account_ids: Vec<String>) {
let mut inner = self.inner.lock().await;
let now = Utc::now();
inner.wechat_accounts = account_ids
.into_iter()
.map(|account_id| {
let status = AccountRuntimeStatus {
account_id: account_id.clone(),
phase: AccountRuntimePhase::Running,
last_error: None,
last_event_at: Some(now),
};
(account_id, status)
})
.collect();
inner.updated_at = now;
}
pub async fn record_wechat_event(&self, account_id: impl Into<String>) {
self.update_wechat_account(account_id.into(), AccountRuntimePhase::Running, None)
.await;
}
pub async fn record_wechat_error(
&self,
account_id: impl Into<String>,
error: impl Into<String>,
) {
self.update_wechat_account(
account_id.into(),
AccountRuntimePhase::Retrying,
Some(error.into()),
)
.await;
}
async fn update_wechat_account(
&self,
account_id: String,
phase: AccountRuntimePhase,
last_error: Option<String>,
) {
let mut inner = self.inner.lock().await;
let now = Utc::now();
let status = inner
.wechat_accounts
.entry(account_id.clone())
.or_insert_with(|| AccountRuntimeStatus {
account_id,
phase: AccountRuntimePhase::Running,
last_error: None,
last_event_at: None,
});
status.phase = phase;
status.last_error = last_error;
status.last_event_at = Some(now);
inner.updated_at = now;
}
pub async fn snapshot(&self) -> RuntimeSnapshot {
let inner = self.inner.lock().await;
let mut wechat_accounts = inner.wechat_accounts.values().cloned().collect::<Vec<_>>();
wechat_accounts.sort_by(|left, right| left.account_id.cmp(&right.account_id));
RuntimeSnapshot {
phase: inner.phase.clone(),
pid: process::id(),
started_at: inner.started_at,
updated_at: inner.updated_at,
http_addr: inner.http_addr.clone(),
queue_alive: inner.queue_alive,
wechat_accounts,
last_error: inner.last_error.clone(),
}
}
pub async fn write_snapshot(&self, path: &Path) -> anyhow::Result<()> {
let snapshot = self.snapshot().await;
let data = serde_json::to_vec_pretty(&snapshot)?;
std::fs::write(path, data)?;
Ok(())
}
}
-294
View File
@@ -1,294 +0,0 @@
use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use crate::core::control::{ControlResponse, RuntimePaths, request_control};
use crate::core::runtime::{RuntimeSnapshot, ServicePhase};
pub async fn start_service() -> anyhow::Result<()> {
let paths = RuntimePaths::new();
paths.ensure_dir()?;
if let Ok(ControlResponse::Status { snapshot }) = request_control(&paths, "status").await {
println!("服务已经在运行,PID: {}", snapshot.pid);
print_snapshot(&snapshot);
return Ok(());
}
cleanup_stale_files(&paths)?;
let mut log = OpenOptions::new()
.create(true)
.append(true)
.open(&paths.log_file)?;
writeln!(log, "\n===== 启动后台服务 =====")?;
let exe = std::env::current_exe()?;
let stdout = log.try_clone()?;
let stderr = log;
let mut command = Command::new(exe);
command
.arg("service")
.arg("run")
.env("IAS_BACKGROUND", "1")
.stdin(Stdio::null())
.stdout(Stdio::from(stdout))
.stderr(Stdio::from(stderr));
detach_background_child(&mut command);
let child = command.spawn()?;
println!("后台服务启动中,PID: {}", child.id());
wait_until_ready(&paths, Duration::from_secs(8)).await?;
println!("日志文件: {}", paths.log_file.display());
Ok(())
}
pub async fn stop_service() -> anyhow::Result<()> {
let paths = RuntimePaths::new();
paths.ensure_dir()?;
match request_control(&paths, "shutdown").await {
Ok(ControlResponse::Ack { message }) => println!("{message}"),
Ok(ControlResponse::Error { message }) => println!("停止失败: {message}"),
Ok(ControlResponse::Status { .. }) => println!("停止失败: 控制端返回了意外状态响应"),
Err(_) => {
if let Some(pid) = read_pid(&paths)? {
if process_is_alive(pid) {
println!("控制 socket 不可用,发送 SIGTERM 到 PID: {pid}");
send_sigterm(pid)?;
} else {
println!("服务未运行,清理过期运行文件");
cleanup_stale_files(&paths)?;
return Ok(());
}
} else {
println!("服务未运行");
return Ok(());
}
}
}
wait_until_stopped(&paths, Duration::from_secs(10)).await?;
cleanup_stale_files(&paths)?;
println!("服务已停止");
Ok(())
}
pub async fn restart_service() -> anyhow::Result<()> {
stop_service().await?;
start_service().await
}
pub async fn status_service() -> anyhow::Result<()> {
let paths = RuntimePaths::new();
paths.ensure_dir()?;
match request_control(&paths, "status").await {
Ok(ControlResponse::Status { snapshot }) => print_snapshot(&snapshot),
Ok(ControlResponse::Error { message }) => println!("状态查询失败: {message}"),
Ok(ControlResponse::Ack { message }) => println!("{message}"),
Err(_) => print_offline_status(&paths)?,
}
Ok(())
}
pub fn print_logs(lines: usize) -> anyhow::Result<()> {
let paths = RuntimePaths::new();
if !paths.log_file.exists() {
println!("日志文件不存在: {}", paths.log_file.display());
return Ok(());
}
let mut file = File::open(&paths.log_file)?;
let mut content = String::new();
file.read_to_string(&mut content)?;
let mut tail = content.lines().rev().take(lines).collect::<Vec<_>>();
tail.reverse();
for line in tail {
println!("{line}");
}
Ok(())
}
async fn wait_until_ready(paths: &RuntimePaths, timeout: Duration) -> anyhow::Result<()> {
let started = Instant::now();
loop {
if let Ok(ControlResponse::Status { snapshot }) = request_control(paths, "status").await {
println!("服务已启动");
print_snapshot(&snapshot);
return Ok(());
}
if started.elapsed() >= timeout {
anyhow::bail!(
"服务未在 {} 秒内就绪,请查看日志: {}",
timeout.as_secs(),
paths.log_file.display()
);
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
async fn wait_until_stopped(paths: &RuntimePaths, timeout: Duration) -> anyhow::Result<()> {
let started = Instant::now();
loop {
if request_control(paths, "status").await.is_err()
&& read_pid(paths)?.is_none_or(|pid| !process_is_alive(pid))
{
return Ok(());
}
if started.elapsed() >= timeout {
anyhow::bail!(
"服务未在 {} 秒内停止,请检查 PID 文件: {}",
timeout.as_secs(),
paths.pid_file.display()
);
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
pub fn write_pid_file(paths: &RuntimePaths) -> anyhow::Result<()> {
paths.ensure_dir()?;
std::fs::write(&paths.pid_file, std::process::id().to_string())?;
Ok(())
}
pub fn cleanup_runtime_files(paths: &RuntimePaths) {
if let Some(pid) = read_pid(paths).ok().flatten()
&& pid != std::process::id()
{
return;
}
let _ = std::fs::remove_file(&paths.pid_file);
let _ = std::fs::remove_file(&paths.socket_file);
}
fn cleanup_stale_files(paths: &RuntimePaths) -> anyhow::Result<()> {
if let Some(pid) = read_pid(paths)?
&& process_is_alive(pid)
{
anyhow::bail!(
"已有服务进程存在但控制 socket 不可用,PID: {}。请先执行 stop 或检查日志: {}",
pid,
paths.log_file.display()
);
}
let _ = std::fs::remove_file(&paths.pid_file);
let _ = std::fs::remove_file(&paths.socket_file);
Ok(())
}
fn print_offline_status(paths: &RuntimePaths) -> anyhow::Result<()> {
if let Some(pid) = read_pid(paths)?
&& process_is_alive(pid)
{
println!("服务进程存在但控制 socket 不可用,PID: {pid}");
println!("socket: {}", paths.socket_file.display());
println!("日志文件: {}", paths.log_file.display());
return Ok(());
}
println!("服务状态: stopped");
if paths.state_file.exists() {
println!("最后状态快照: {}", paths.state_file.display());
}
println!("日志文件: {}", paths.log_file.display());
Ok(())
}
fn print_snapshot(snapshot: &RuntimeSnapshot) {
println!("服务状态: {}", phase_name(&snapshot.phase));
println!("PID: {}", snapshot.pid);
println!("HTTP: {}", snapshot.http_addr);
println!("启动时间: {}", snapshot.started_at);
println!(
"队列: {}",
if snapshot.queue_alive {
"running"
} else {
"stopped"
}
);
if let Some(error) = &snapshot.last_error {
println!("最近错误: {error}");
}
if snapshot.wechat_accounts.is_empty() {
println!("微信账号: 0");
} else {
println!("微信账号:");
for account in &snapshot.wechat_accounts {
let last_event = account
.last_event_at
.map(|time| time.to_string())
.unwrap_or_else(|| "-".to_string());
let last_error = account.last_error.as_deref().unwrap_or("-");
println!(
" - {}: {:?}, last_event={}, last_error={}",
account.account_id, account.phase, last_event, last_error
);
}
}
}
fn phase_name(phase: &ServicePhase) -> &'static str {
match phase {
ServicePhase::Starting => "starting",
ServicePhase::Running => "running",
ServicePhase::Stopping => "stopping",
ServicePhase::Stopped => "stopped",
ServicePhase::Failed => "failed",
}
}
fn read_pid(paths: &RuntimePaths) -> anyhow::Result<Option<u32>> {
if !paths.pid_file.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&paths.pid_file)?;
let pid = content.trim().parse::<u32>()?;
Ok(Some(pid))
}
fn process_is_alive(pid: u32) -> bool {
std::path::Path::new("/proc").join(pid.to_string()).exists()
}
fn send_sigterm(pid: u32) -> anyhow::Result<()> {
let status = Command::new("kill")
.arg("-TERM")
.arg(pid.to_string())
.status()?;
if !status.success() {
anyhow::bail!("发送 SIGTERM 失败,PID: {pid}");
}
Ok(())
}
#[cfg(unix)]
fn detach_background_child(command: &mut Command) {
unsafe {
command.pre_exec(|| {
if libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
}
#[cfg(not(unix))]
fn detach_background_child(_command: &mut Command) {}
+5 -393
View File
@@ -2,47 +2,12 @@ mod channel;
mod console;
mod context_simulator;
pub mod context_simulator_data;
mod core;
pub mod model;
mod repository;
use chrono::{Duration, Utc};
use core::control::{ControlResponse, RuntimePaths, request_control, start_control_server};
use core::queue::create_queue;
use core::runtime::RuntimeState;
use core::supervisor::{cleanup_runtime_files, write_pid_file};
use ias_common::queue::QueueMessage;
use ias_context::ContextManager;
use ias_context::repository::ContextRepository;
use ias_http::create_http;
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 sqlx::PgPool;
use std::env;
use std::sync::Arc;
use ias_wechat::manager::WeChatMultiAccountManager;
use tokio::sync::{mpsc, watch};
use tokio::task::JoinHandle;
use crate::channel::wechat::manager::init_wechat;
pub use channel::wechat::manager::{delete_account, list_accounts, login_user, rename_account};
pub use core::supervisor;
const PROJECT_VERSION: &str = include_str!("../../VERSION");
fn current_version() -> &'static str {
PROJECT_VERSION.trim()
}
#[derive(Clone)]
pub struct AppState {
pub sender: mpsc::Sender<QueueMessage>,
pub db: PgPool,
pub runtime: Arc<RuntimeState>,
}
pub fn init_logging() {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
@@ -50,362 +15,9 @@ pub fn init_logging() {
let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
}
pub async fn run_service(pool: PgPool) -> anyhow::Result<()> {
let name: String = env::var("NAME").unwrap_or_else(|_| "ias".to_string());
let host = env::var("HOST").unwrap_or_else(|_| "0.0.0.0:9003".to_string());
let paths = RuntimePaths::new();
paths.ensure_dir()?;
if let Ok(ControlResponse::Status { snapshot }) = request_control(&paths, "status").await {
anyhow::bail!("服务已在运行,PID: {}", snapshot.pid);
}
println!("{} v{} 已启动...", name, current_version());
let runtime = Arc::new(RuntimeState::new(host.clone()));
let (sender, receiver) = mpsc::channel::<QueueMessage>(100);
let state = AppState {
sender: sender.clone(),
db: pool,
runtime: runtime.clone(),
};
let manger = init_wechat(Some(state.clone()))
.await
.ok_or_else(|| anyhow::anyhow!("微信初始化失败:未加载到微信账号"))?;
let manager = Arc::new(manger);
send_online_notices(&state.db, manager.as_ref()).await;
send_version_update_notice(&state.db, manager.as_ref()).await;
let mail_tasks = start_mail_tasks(state.db.clone(), manager.clone()).await;
let context_manager = ContextManager::new(state.db.clone());
let queue_task = create_queue(
sender.clone(),
receiver,
manager,
context_manager,
state.db.clone(),
)
.await?;
runtime.set_queue_alive(true).await;
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let mut control_task = tokio::spawn(start_control_server(
runtime.clone(),
paths.clone(),
shutdown_tx.clone(),
shutdown_tx.subscribe(),
));
tokio::select! {
result = &mut control_task => {
result??;
anyhow::bail!("服务控制通道已退出");
}
_ = tokio::time::sleep(std::time::Duration::from_millis(50)) => {}
}
write_pid_file(&paths)?;
let signal_task = tokio::spawn(wait_for_shutdown_signal(shutdown_tx.clone()));
runtime.mark_running().await;
runtime.write_snapshot(&paths.state_file).await?;
let routes = ias_context::routes::routes::<()>(state.db.clone())
.merge(ias_mail::routes::routes::<()>(state.db.clone()))
.merge(console::routes::<()>(state.db.clone()))
.merge(context_simulator::routes::<()>(state.db.clone()));
let result = create_http(host.clone(), shutdown_rx, state.db.clone(), routes).await;
runtime.mark_stopping().await;
runtime.write_snapshot(&paths.state_file).await?;
queue_task.abort();
for task in mail_tasks {
task.abort();
}
control_task.abort();
signal_task.abort();
if let Err(err) = &result {
runtime.mark_failed(err.to_string()).await;
runtime.write_snapshot(&paths.state_file).await?;
} else {
runtime.mark_stopped().await;
runtime.write_snapshot(&paths.state_file).await?;
}
cleanup_runtime_files(&paths);
result
}
async fn start_mail_tasks(
pool: PgPool,
manager: Arc<WeChatMultiAccountManager>,
) -> Vec<JoinHandle<()>> {
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() {
match MailPollerConfig::from_env() {
Ok(Some(config)) => configs.push(config),
Ok(None) => {}
Err(error) => eprintln!("读取环境变量邮件配置失败:{error}"),
}
}
if configs.is_empty() {
println!("邮件轮询未启动:未配置邮箱账户");
return Vec::new();
}
let (notification_tx, notification_rx) = mpsc::channel::<MailNotification>(64);
let mut tasks = configs
.into_iter()
.map(|config| start_mail_polling(pool.clone(), config, notification_tx.clone()))
.collect::<Vec<_>>();
tasks.push(tokio::spawn(handle_mail_notifications(
manager,
notification_rx,
)));
tasks
}
async fn handle_mail_notifications(
manager: Arc<WeChatMultiAccountManager>,
mut notification_rx: mpsc::Receiver<MailNotification>,
) {
while let Some(notification) = notification_rx.recv().await {
if let Err(error) = manager
.send_text(
&notification.account_id,
&notification.from_user_id,
&notification.text,
None,
)
.await
{
eprintln!(
"发送邮件提醒失败 account_id={} to={}: {}",
notification.account_id, notification.from_user_id, error
);
}
}
}
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() - Duration::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() - Duration::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 {
if 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 {
std::env::var("IA_ONLINE_NOTICE_ENABLED")
.map(|value| {
let value = value.trim().to_ascii_lowercase();
!matches!(value.as_str(), "0" | "false" | "no" | "off")
})
.unwrap_or(true)
}
fn version_notice_enabled() -> bool {
std::env::var("IA_VERSION_NOTICE_ENABLED")
.map(|value| {
let value = value.trim().to_ascii_lowercase();
!matches!(value.as_str(), "0" | "false" | "no" | "off")
})
.unwrap_or(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_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 wait_for_shutdown_signal(shutdown_tx: watch::Sender<bool>) {
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
let mut terminate = match signal(SignalKind::terminate()) {
Ok(signal) => signal,
Err(err) => {
eprintln!("监听 SIGTERM 失败: {err}");
return;
}
};
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = terminate.recv() => {}
}
}
#[cfg(not(unix))]
{
let _ = tokio::signal::ctrl_c().await;
}
let _ = shutdown_tx.send(true);
pub fn extra_http_routes(pool: PgPool) -> axum::Router {
ias_context::routes::routes::<()>(pool.clone())
.merge(ias_mail::routes::routes::<()>(pool.clone()))
.merge(console::routes::<()>(pool.clone()))
.merge(context_simulator::routes::<()>(pool))
}
@@ -75,24 +75,6 @@ impl UserRepositor {
.await?;
Ok(result.rows_affected())
}
pub async fn update_account_buf(
pool: &PgPool,
account_id: String,
buf: String,
) -> anyhow::Result<u64> {
let result = sqlx::query(
r#"
UPDATE ias_wechat_accounts
SET updates_buf = $1
WHERE account_id = $2
"#,
)
.bind(buf)
.bind(account_id)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
pub async fn delete_account(pool: &PgPool, account_id: String) -> anyhow::Result<u64> {
let result = sqlx::query(
r#"