feat: 拆分 worker 架构并增强 assistant 安全保护
- 新增 daemon/channel/assistant/function/scheduler/logging worker 架构和 JetStream 消息协议 - 修复邮件通知误入 assistant、迁移 checksum、日志 subject 和工具调用无限循环风险 - 新增 assistant 工具轮次熔断、工具结果截断和提示词软收敛规则 - 补充邮件推送关键字、可信发件人配置、升级日志和验证测试
This commit is contained in:
+2
-29
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,2 +1 @@
|
||||
pub mod looper;
|
||||
pub mod manager;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
pub mod control;
|
||||
pub mod queue;
|
||||
pub mod runtime;
|
||||
pub mod supervisor;
|
||||
@@ -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"
|
||||
// })),
|
||||
// }
|
||||
// }
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(
|
||||
¬ification.account_id,
|
||||
¬ification.from_user_id,
|
||||
¬ification.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#"
|
||||
|
||||
Reference in New Issue
Block a user