feat: add workflow toolkit and runtime support
This commit is contained in:
+11
-1
@@ -3,6 +3,10 @@ name = "service"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "ias"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# ── 公共模块 ──
|
||||
common = { path = "../common" }
|
||||
@@ -21,6 +25,7 @@ tokio = { version = "1.0", features = [
|
||||
"process",
|
||||
"signal",
|
||||
"io-util",
|
||||
"net",
|
||||
] } # 异步运行时核心
|
||||
# ── HTTP 客户端 ──
|
||||
reqwest = { version = "0.12", default-features = false, features = [
|
||||
@@ -46,7 +51,12 @@ sqlx = { version = "0.9.0", features = [
|
||||
# ── 错误处理 ──
|
||||
anyhow = "1"
|
||||
thiserror = "2"
|
||||
# ── 日志管理 ──
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
# ── cli ──
|
||||
clap = { version = "4.6.1", features = ["derive"] }
|
||||
# ── 时间处理 ──
|
||||
chrono = { version = "0.4", features = ["serde"] } # 日期时间
|
||||
chrono = { version = "0.4", features = ["serde"] } # 日期时间
|
||||
# ── 唯一标识 ──
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE ias_web_pages (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
slug VARCHAR(64) NOT NULL UNIQUE,
|
||||
page_type VARCHAR(64) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
summary TEXT,
|
||||
content TEXT NOT NULL,
|
||||
source_label VARCHAR(255),
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_ias_web_pages_type_slug ON ias_web_pages(page_type, slug);
|
||||
@@ -1 +1 @@
|
||||
pub mod wechat;
|
||||
pub mod wechat;
|
||||
|
||||
@@ -17,6 +17,7 @@ pub async fn start_loop(
|
||||
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 {
|
||||
@@ -24,6 +25,7 @@ pub async fn start_loop(
|
||||
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) {
|
||||
@@ -43,7 +45,7 @@ pub async fn start_loop(
|
||||
.clone()
|
||||
.send(QueueMessage::Channel(ChannelMessage {
|
||||
channel: "wechat".to_string(),
|
||||
account_id: account_id,
|
||||
account_id,
|
||||
from_user_id: from_user_id.to_string(),
|
||||
content: text.to_string(),
|
||||
context: message.context_token,
|
||||
@@ -52,6 +54,10 @@ pub async fn start_loop(
|
||||
.unwrap();
|
||||
}
|
||||
WeChatEvent::PollError { account_id, error } => {
|
||||
state
|
||||
.runtime
|
||||
.record_wechat_error(account_id.clone(), error.clone())
|
||||
.await;
|
||||
println!("微信账号轮询失败 account_id={account_id}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ pub async fn init_wechat(state: Option<AppState>) -> Option<WeChatMultiAccountMa
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
state
|
||||
.runtime
|
||||
.replace_wechat_accounts(manager.account_ids())
|
||||
.await;
|
||||
|
||||
start_loop(Arc::new(state.clone()), event_rx).await.unwrap();
|
||||
return Some(manager);
|
||||
@@ -67,7 +71,7 @@ pub async fn login_user(pool: &PgPool) {
|
||||
user_name.trim().to_string()
|
||||
};
|
||||
let user = WechatAccountCreate {
|
||||
account_id: account_id,
|
||||
account_id,
|
||||
token: account.token,
|
||||
base_url: account.base_url,
|
||||
user_id: account.user_id,
|
||||
@@ -75,9 +79,7 @@ pub async fn login_user(pool: &PgPool) {
|
||||
};
|
||||
|
||||
// todo 后续需要处理错误
|
||||
UserRepositor::add_wechat_account(&pool, user)
|
||||
.await
|
||||
.unwrap();
|
||||
UserRepositor::add_wechat_account(pool, user).await.unwrap();
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
pub mod looper;
|
||||
pub mod manager;
|
||||
pub mod looper;
|
||||
+24
-1
@@ -12,7 +12,10 @@ pub struct Cli {
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum Commands {
|
||||
// 启动服务
|
||||
Service,
|
||||
Service {
|
||||
#[command(subcommand)]
|
||||
command: Option<ServiceAction>,
|
||||
},
|
||||
// 配置渠道
|
||||
Channel {
|
||||
#[command(subcommand)]
|
||||
@@ -20,6 +23,26 @@ pub enum Commands {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum ServiceAction {
|
||||
/// 前台运行服务
|
||||
Run,
|
||||
/// 后台启动服务
|
||||
Start,
|
||||
/// 停止后台服务
|
||||
Stop,
|
||||
/// 重启后台服务
|
||||
Restart,
|
||||
/// 查看服务运行状态
|
||||
Status,
|
||||
/// 查看后台服务日志
|
||||
Logs {
|
||||
/// 输出最后多少行日志
|
||||
#[arg(short, long, default_value_t = 80)]
|
||||
lines: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum Channels {
|
||||
// 微信渠道配置
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
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,3 +1,6 @@
|
||||
pub mod cli;
|
||||
pub mod context;
|
||||
pub mod queue;
|
||||
pub mod control;
|
||||
pub mod queue;
|
||||
pub mod runtime;
|
||||
pub mod supervisor;
|
||||
|
||||
+177
-16
@@ -2,40 +2,101 @@ use crate::core::context::{
|
||||
add_assistant_message, add_tool_message, add_user_message, snapshot_context,
|
||||
};
|
||||
use ai::core::send_message;
|
||||
use common::queue::QueueMessage;
|
||||
use common::queue::{ChannelMessage, QueueMessage};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{mpsc::Receiver, mpsc::Sender};
|
||||
use tokio::task::JoinHandle;
|
||||
use wechat::manager::WeChatMultiAccountManager;
|
||||
|
||||
struct TypingSession {
|
||||
client_id: String,
|
||||
refresh_task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
pub async fn create_queue(
|
||||
sender: Sender<QueueMessage>,
|
||||
mut receiver: Receiver<QueueMessage>,
|
||||
manager: WeChatMultiAccountManager,
|
||||
) -> anyhow::Result<()> {
|
||||
tokio::spawn(async move {
|
||||
) -> anyhow::Result<JoinHandle<()>> {
|
||||
let manager = Arc::new(manager);
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut typing_sessions: HashMap<String, TypingSession> = HashMap::new();
|
||||
while let Some(msg) = receiver.recv().await {
|
||||
let queue_sender = sender.clone();
|
||||
match msg {
|
||||
QueueMessage::Aissitant(channel, assistant) => {
|
||||
let content = assistant.content.clone();
|
||||
let reasoning = assistant.reasoning.clone().unwrap_or_default();
|
||||
println!("\n\n收到llm回复:\n{}\n-------\n{}", reasoning, content);
|
||||
if !content.trim().is_empty() || !reasoning.trim().is_empty() {
|
||||
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)
|
||||
);
|
||||
|
||||
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()
|
||||
};
|
||||
manager
|
||||
let session_key = typing_key(&channel);
|
||||
if let Some(session) = typing_sessions.remove(&session_key) {
|
||||
session.refresh_task.abort();
|
||||
manager
|
||||
.send_text_with_client_id(
|
||||
&channel.account_id,
|
||||
&channel.from_user_id,
|
||||
text,
|
||||
channel.context.as_deref(),
|
||||
&session.client_id,
|
||||
)
|
||||
.await
|
||||
.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(&mut typing_sessions, &channel);
|
||||
}
|
||||
add_assistant_message(assistant.content, assistant.tool_calls).await;
|
||||
}
|
||||
QueueMessage::Notice(channel, text) => {
|
||||
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,
|
||||
text,
|
||||
channel.context.as_deref(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
{
|
||||
eprintln!("发送工具调用提醒失败: {}", err);
|
||||
}
|
||||
}
|
||||
add_assistant_message(assistant.content, assistant.tool_calls).await;
|
||||
}
|
||||
QueueMessage::Channel(bundle) => {
|
||||
println!(
|
||||
@@ -43,23 +104,123 @@ pub async fn create_queue(
|
||||
bundle.from_user_id.clone(),
|
||||
bundle.content.clone()
|
||||
);
|
||||
start_typing(manager.clone(), &mut typing_sessions, &bundle).await;
|
||||
// todo 目前按单用户处理
|
||||
add_user_message(bundle.content.clone()).await;
|
||||
send_message(queue_sender, snapshot_context().await, bundle)
|
||||
.await
|
||||
.unwrap();
|
||||
let messages = snapshot_context().await;
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = send_message(queue_sender, messages, bundle).await {
|
||||
eprintln!("发送消息失败: {}", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
QueueMessage::Tool(channel, tool_call_id, name, result) => {
|
||||
println!("\n\n调用{}工具的结果:\n{}", name, result);
|
||||
refresh_typing(manager.clone(), &typing_sessions, &channel).await;
|
||||
add_tool_message(tool_call_id, result).await;
|
||||
send_message(queue_sender, snapshot_context().await, channel)
|
||||
.await
|
||||
.unwrap();
|
||||
let messages = snapshot_context().await;
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = send_message(queue_sender, messages, channel).await {
|
||||
eprintln!("发送消息失败: {}", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
async fn start_typing(
|
||||
manager: Arc<WeChatMultiAccountManager>,
|
||||
sessions: &mut HashMap<String, TypingSession>,
|
||||
channel: &ChannelMessage,
|
||||
) {
|
||||
let key = typing_key(channel);
|
||||
stop_typing(sessions, channel);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_typing(sessions: &mut HashMap<String, TypingSession>, channel: &ChannelMessage) {
|
||||
if let Some(session) = sessions.remove(&typing_key(channel)) {
|
||||
session.refresh_task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
fn typing_key(channel: &ChannelMessage) -> String {
|
||||
format!(
|
||||
"{}:{}:{}",
|
||||
channel.account_id,
|
||||
channel.from_user_id,
|
||||
channel.context.as_deref().unwrap_or_default()
|
||||
)
|
||||
}
|
||||
|
||||
// pub async fn send_to_queue(
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
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 child = Command::new(exe)
|
||||
.arg("service")
|
||||
.arg("run")
|
||||
.env("IAS_BACKGROUND", "1")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(stdout))
|
||||
.stderr(Stdio::from(stderr))
|
||||
.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(())
|
||||
}
|
||||
+29
-5
@@ -1,20 +1,44 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Ok;
|
||||
use axum::routing::{get};
|
||||
use anyhow::anyhow;
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use tokio::sync::watch;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::web::routes::routes as web_routes;
|
||||
// use crate::core::queue::send_to_queue;
|
||||
|
||||
pub async fn create_http(state: AppState, host: String) -> anyhow::Result<()> {
|
||||
pub async fn create_http(
|
||||
state: AppState,
|
||||
host: String,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
) -> anyhow::Result<()> {
|
||||
let app = Router::new()
|
||||
// .route("/message", post(send_to_queue))
|
||||
.route("/health", get(health))
|
||||
.merge(web_routes())
|
||||
.with_state(Arc::new(state));
|
||||
let listener = tokio::net::TcpListener::bind(host.clone()).await?;
|
||||
let listener = tokio::net::TcpListener::bind(&host).await.map_err(|err| {
|
||||
if err.kind() == std::io::ErrorKind::AddrInUse {
|
||||
anyhow!(
|
||||
"HTTP 地址已被占用: {}。请停止已有服务,或修改 HOST 后重试",
|
||||
host
|
||||
)
|
||||
} else {
|
||||
anyhow!("HTTP 地址监听失败: {}: {}", host, err)
|
||||
}
|
||||
})?;
|
||||
println!("服务启动在:http://{}", listener.local_addr()?);
|
||||
axum::serve(listener, app).await?;
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async move {
|
||||
while shutdown_rx.changed().await.is_ok() {
|
||||
if *shutdown_rx.borrow() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
pub mod app;
|
||||
pub mod app;
|
||||
|
||||
+130
-26
@@ -3,71 +3,175 @@ mod core;
|
||||
mod http;
|
||||
mod model;
|
||||
mod repository;
|
||||
mod web;
|
||||
|
||||
use clap::Parser;
|
||||
use common::queue::QueueMessage;
|
||||
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, print_logs, restart_service, start_service, status_service,
|
||||
stop_service, write_pid_file,
|
||||
};
|
||||
use http::app::create_http;
|
||||
use sqlx::{PgPool, postgres::PgPoolOptions};
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
|
||||
use crate::channel::wechat::manager::{
|
||||
delete_account, init_wechat, list_accounts, login_user, rename_account,
|
||||
};
|
||||
use crate::core::cli::{Channels, Cli, Commands, WechatAction};
|
||||
use crate::core::cli::{Channels, Cli, Commands, ServiceAction, WechatAction};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub sender: mpsc::Sender<QueueMessage>,
|
||||
pub db: PgPool,
|
||||
pub runtime: Arc<RuntimeState>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
dotenvy::dotenv().ok();
|
||||
let db_url = env::var("DATABASE_URL").expect("必须设置DATABASE_URL");
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(10)
|
||||
.connect(&db_url)
|
||||
.await?;
|
||||
init_logging();
|
||||
let cli = Cli::parse();
|
||||
match cli.command {
|
||||
Commands::Service => {
|
||||
service(pool).await.unwrap();
|
||||
}
|
||||
Commands::Channel { command } => match command {
|
||||
Channels::Wechat { command } => match command {
|
||||
WechatAction::Login => login_user(&pool).await,
|
||||
WechatAction::List => list_accounts(&pool).await,
|
||||
WechatAction::Delete { account } => delete_account(&pool, account).await,
|
||||
WechatAction::Rename { account, name } => {
|
||||
rename_account(&pool, account, name).await
|
||||
Commands::Service { command } => {
|
||||
let action = command.unwrap_or(ServiceAction::Run);
|
||||
match action {
|
||||
ServiceAction::Run => {
|
||||
let pool = connect_db().await?;
|
||||
run_service(pool).await?;
|
||||
}
|
||||
},
|
||||
},
|
||||
ServiceAction::Start => start_service().await?,
|
||||
ServiceAction::Stop => stop_service().await?,
|
||||
ServiceAction::Restart => restart_service().await?,
|
||||
ServiceAction::Status => status_service().await?,
|
||||
ServiceAction::Logs { lines } => print_logs(lines)?,
|
||||
}
|
||||
}
|
||||
Commands::Channel { command } => {
|
||||
let pool = connect_db().await?;
|
||||
match command {
|
||||
Channels::Wechat { command } => match command {
|
||||
WechatAction::Login => login_user(&pool).await,
|
||||
WechatAction::List => list_accounts(&pool).await,
|
||||
WechatAction::Delete { account } => delete_account(&pool, account).await,
|
||||
WechatAction::Rename { account, name } => {
|
||||
rename_account(&pool, account, name).await
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn service(pool: PgPool) -> anyhow::Result<()> {
|
||||
async fn connect_db() -> anyhow::Result<PgPool> {
|
||||
let db_url = env::var("DATABASE_URL").map_err(|_| anyhow::anyhow!("必须设置DATABASE_URL"))?;
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(10)
|
||||
.connect(&db_url)
|
||||
.await?;
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
fn init_logging() {
|
||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
|
||||
let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
|
||||
}
|
||||
|
||||
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!("{}已启动...", name);
|
||||
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.unwrap();
|
||||
create_queue(sender.clone(), receiver, manger)
|
||||
let manger = init_wechat(Some(state.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
.ok_or_else(|| anyhow::anyhow!("微信初始化失败:未加载到微信账号"))?;
|
||||
let queue_task = create_queue(sender.clone(), receiver, manger).await?;
|
||||
runtime.set_queue_alive(true).await;
|
||||
|
||||
// 启动http服务器,务必放在最下面执行
|
||||
create_http(state.clone(), host.clone()).await.unwrap();
|
||||
Ok(())
|
||||
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 result = create_http(state.clone(), host.clone(), shutdown_rx).await;
|
||||
runtime.mark_stopping().await;
|
||||
runtime.write_snapshot(&paths.state_file).await?;
|
||||
|
||||
queue_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 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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod model;
|
||||
pub mod repository;
|
||||
pub mod routes;
|
||||
@@ -0,0 +1,59 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sqlx::prelude::FromRow;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||
pub struct WebPage {
|
||||
pub id: i64,
|
||||
pub slug: String,
|
||||
pub page_type: String,
|
||||
pub title: String,
|
||||
pub summary: Option<String>,
|
||||
pub content: String,
|
||||
pub source_label: Option<String>,
|
||||
pub metadata: String,
|
||||
pub created_at: Option<NaiveDateTime>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewWebPage {
|
||||
pub slug: String,
|
||||
pub page_type: String,
|
||||
pub title: String,
|
||||
pub summary: Option<String>,
|
||||
pub content: String,
|
||||
pub source_label: Option<String>,
|
||||
pub metadata: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateWebPageRequest {
|
||||
pub page_type: Option<String>,
|
||||
pub title: String,
|
||||
pub summary: Option<String>,
|
||||
pub content: String,
|
||||
pub source_label: Option<String>,
|
||||
pub metadata: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateMailPageRequest {
|
||||
pub from: String,
|
||||
pub subject: String,
|
||||
pub reminder: Option<String>,
|
||||
pub content: String,
|
||||
pub received_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateWebPageResponse {
|
||||
pub success: bool,
|
||||
pub id: i64,
|
||||
pub slug: String,
|
||||
pub page_type: String,
|
||||
pub title: String,
|
||||
pub summary: Option<String>,
|
||||
pub path: String,
|
||||
pub url: String,
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::web::model::{NewWebPage, WebPage};
|
||||
|
||||
pub struct WebPageRepository;
|
||||
|
||||
impl WebPageRepository {
|
||||
pub async fn create(pool: &PgPool, page: NewWebPage) -> anyhow::Result<WebPage> {
|
||||
let row = sqlx::query_as::<_, WebPage>(
|
||||
r#"
|
||||
INSERT INTO ias_web_pages (
|
||||
slug,
|
||||
page_type,
|
||||
title,
|
||||
summary,
|
||||
content,
|
||||
source_label,
|
||||
metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, slug, page_type, title, summary, content, source_label, metadata, created_at
|
||||
"#,
|
||||
)
|
||||
.bind(page.slug)
|
||||
.bind(page.page_type)
|
||||
.bind(page.title)
|
||||
.bind(page.summary)
|
||||
.bind(page.content)
|
||||
.bind(page.source_label)
|
||||
.bind(page.metadata)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
pub async fn find_by_type_and_slug(
|
||||
pool: &PgPool,
|
||||
page_type: &str,
|
||||
slug: &str,
|
||||
) -> anyhow::Result<Option<WebPage>> {
|
||||
let row = sqlx::query_as::<_, WebPage>(
|
||||
r#"
|
||||
SELECT id, slug, page_type, title, summary, content, source_label, metadata, created_at
|
||||
FROM ias_web_pages
|
||||
WHERE page_type = $1 AND slug = $2
|
||||
"#,
|
||||
)
|
||||
.bind(page_type)
|
||||
.bind(slug)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::Html;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde_json::{Value, json};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::web::model::{
|
||||
CreateMailPageRequest, CreateWebPageRequest, CreateWebPageResponse, NewWebPage, WebPage,
|
||||
};
|
||||
use crate::web::repository::WebPageRepository;
|
||||
|
||||
type ApiError = (StatusCode, Json<Value>);
|
||||
type HtmlError = (StatusCode, Html<String>);
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/web/pages", post(create_page))
|
||||
.route("/web/mail", post(create_mail_page))
|
||||
.route("/mail/{slug}", get(show_mail_page))
|
||||
.route("/web/{page_type}/{slug}", get(show_typed_page))
|
||||
}
|
||||
|
||||
async fn create_page(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<CreateWebPageRequest>,
|
||||
) -> Result<Json<CreateWebPageResponse>, ApiError> {
|
||||
let page_type = normalize_key(request.page_type.as_deref().unwrap_or("page"), "page")?;
|
||||
let title = required_text(&request.title, "title")?;
|
||||
let content = required_text(&request.content, "content")?;
|
||||
let metadata = request.metadata.unwrap_or_else(|| json!({})).to_string();
|
||||
|
||||
let page = NewWebPage {
|
||||
slug: generate_slug(),
|
||||
page_type,
|
||||
title,
|
||||
summary: optional_text(request.summary),
|
||||
content,
|
||||
source_label: optional_text(request.source_label),
|
||||
metadata,
|
||||
};
|
||||
|
||||
let page = WebPageRepository::create(&state.db, page)
|
||||
.await
|
||||
.map_err(api_internal_error)?;
|
||||
|
||||
Ok(Json(response_for_page(&page, &headers)))
|
||||
}
|
||||
|
||||
async fn create_mail_page(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<CreateMailPageRequest>,
|
||||
) -> Result<Json<CreateWebPageResponse>, ApiError> {
|
||||
let from = required_text(&request.from, "from")?;
|
||||
let subject = required_text(&request.subject, "subject")?;
|
||||
let content = required_text(&request.content, "content")?;
|
||||
let reminder = optional_text(request.reminder);
|
||||
let received_at = optional_text(request.received_at);
|
||||
let summary = reminder
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("您收到了一份来自 {from} 的邮件提醒"));
|
||||
|
||||
let metadata = json!({
|
||||
"from": from,
|
||||
"subject": subject,
|
||||
"reminder": reminder,
|
||||
"received_at": received_at,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let page = NewWebPage {
|
||||
slug: generate_slug(),
|
||||
page_type: "mail".to_string(),
|
||||
title: subject,
|
||||
summary: Some(summary),
|
||||
content,
|
||||
source_label: Some(from),
|
||||
metadata,
|
||||
};
|
||||
|
||||
let page = WebPageRepository::create(&state.db, page)
|
||||
.await
|
||||
.map_err(api_internal_error)?;
|
||||
|
||||
Ok(Json(response_for_page(&page, &headers)))
|
||||
}
|
||||
|
||||
async fn show_mail_page(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<Html<String>, HtmlError> {
|
||||
let page = WebPageRepository::find_by_type_and_slug(&state.db, "mail", &slug)
|
||||
.await
|
||||
.map_err(html_internal_error)?
|
||||
.ok_or_else(html_not_found)?;
|
||||
|
||||
Ok(Html(render_page(&page)))
|
||||
}
|
||||
|
||||
async fn show_typed_page(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((page_type, slug)): Path<(String, String)>,
|
||||
) -> Result<Html<String>, HtmlError> {
|
||||
let page_type = normalize_key(&page_type, "page").map_err(|_| html_not_found())?;
|
||||
let page = WebPageRepository::find_by_type_and_slug(&state.db, &page_type, &slug)
|
||||
.await
|
||||
.map_err(html_internal_error)?
|
||||
.ok_or_else(html_not_found)?;
|
||||
|
||||
Ok(Html(render_page(&page)))
|
||||
}
|
||||
|
||||
fn response_for_page(page: &WebPage, headers: &HeaderMap) -> CreateWebPageResponse {
|
||||
let path = public_path(page);
|
||||
let url = format!("{}{}", request_base_url(headers), path);
|
||||
|
||||
CreateWebPageResponse {
|
||||
success: true,
|
||||
id: page.id,
|
||||
slug: page.slug.clone(),
|
||||
page_type: page.page_type.clone(),
|
||||
title: page.title.clone(),
|
||||
summary: page.summary.clone(),
|
||||
path,
|
||||
url,
|
||||
}
|
||||
}
|
||||
|
||||
fn public_path(page: &WebPage) -> String {
|
||||
if page.page_type == "mail" {
|
||||
format!("/mail/{}", page.slug)
|
||||
} else {
|
||||
format!("/web/{}/{}", page.page_type, page.slug)
|
||||
}
|
||||
}
|
||||
|
||||
fn request_base_url(headers: &HeaderMap) -> String {
|
||||
if let Ok(base_url) = std::env::var("WEB_BASE_URL")
|
||||
&& !base_url.trim().is_empty()
|
||||
{
|
||||
return base_url.trim().trim_end_matches('/').to_string();
|
||||
}
|
||||
|
||||
let host = headers
|
||||
.get("host")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("127.0.0.1:9003");
|
||||
let proto = headers
|
||||
.get("x-forwarded-proto")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("http");
|
||||
|
||||
format!("{}://{}", proto.trim(), host.trim())
|
||||
}
|
||||
|
||||
fn generate_slug() -> String {
|
||||
Uuid::new_v4().simple().to_string()
|
||||
}
|
||||
|
||||
fn required_text(value: &str, field: &str) -> Result<String, ApiError> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return Err(api_bad_request(format!("{field} 不能为空")));
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
fn optional_text(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn normalize_key(value: &str, field: &str) -> Result<String, ApiError> {
|
||||
let normalized = value.trim().to_ascii_lowercase();
|
||||
if normalized.is_empty() || normalized.len() > 64 {
|
||||
return Err(api_bad_request(format!("{field} 长度必须在 1 到 64 之间")));
|
||||
}
|
||||
if !normalized
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
|
||||
{
|
||||
return Err(api_bad_request(format!(
|
||||
"{field} 只能包含字母、数字、下划线或连字符"
|
||||
)));
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn render_page(page: &WebPage) -> String {
|
||||
let summary = page
|
||||
.summary
|
||||
.as_deref()
|
||||
.map(|summary| format!(r#"<p class="summary">{}</p>"#, escape_html(summary)))
|
||||
.unwrap_or_default();
|
||||
let source = page
|
||||
.source_label
|
||||
.as_deref()
|
||||
.map(|source| {
|
||||
format!(
|
||||
r#"<span class="meta-item">来源:{}</span>"#,
|
||||
escape_html(source)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let created_at = page
|
||||
.created_at
|
||||
.map(|time| time.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
let metadata = render_metadata(&page.metadata);
|
||||
let content = render_text_content(&page.content);
|
||||
let page_type = escape_html(&page.page_type);
|
||||
|
||||
format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{title}</title>
|
||||
<style>
|
||||
:root {{
|
||||
color-scheme: light;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #f6f7f9;
|
||||
color: #1f2328;
|
||||
}}
|
||||
body {{
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: #f6f7f9;
|
||||
}}
|
||||
main {{
|
||||
width: min(880px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 40px 0 56px;
|
||||
}}
|
||||
.eyebrow {{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #d8dee4;
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
color: #57606a;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}}
|
||||
h1 {{
|
||||
margin: 18px 0 10px;
|
||||
font-size: clamp(28px, 4vw, 42px);
|
||||
line-height: 1.15;
|
||||
font-weight: 720;
|
||||
letter-spacing: 0;
|
||||
}}
|
||||
.summary {{
|
||||
margin: 0 0 18px;
|
||||
color: #57606a;
|
||||
font-size: 17px;
|
||||
line-height: 1.65;
|
||||
}}
|
||||
.meta {{
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
margin: 0 0 28px;
|
||||
color: #6e7781;
|
||||
font-size: 14px;
|
||||
}}
|
||||
.content {{
|
||||
border: 1px solid #d8dee4;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
padding: 24px;
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}}
|
||||
.details {{
|
||||
margin: 24px 0;
|
||||
border-top: 1px solid #d8dee4;
|
||||
border-bottom: 1px solid #d8dee4;
|
||||
padding: 14px 0;
|
||||
}}
|
||||
dl {{
|
||||
display: grid;
|
||||
grid-template-columns: minmax(88px, 160px) 1fr;
|
||||
gap: 10px 16px;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}}
|
||||
dt {{
|
||||
color: #6e7781;
|
||||
}}
|
||||
dd {{
|
||||
margin: 0;
|
||||
color: #24292f;
|
||||
overflow-wrap: anywhere;
|
||||
}}
|
||||
@media (max-width: 640px) {{
|
||||
main {{
|
||||
width: min(100% - 24px, 880px);
|
||||
padding-top: 28px;
|
||||
}}
|
||||
.content {{
|
||||
padding: 18px;
|
||||
}}
|
||||
dl {{
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2px 0;
|
||||
}}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<span class="eyebrow">{page_type}</span>
|
||||
<h1>{title}</h1>
|
||||
{summary}
|
||||
<div class="meta">{source}<span class="meta-item">创建时间:{created_at}</span></div>
|
||||
{metadata}
|
||||
<article class="content">{content}</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>"#,
|
||||
title = escape_html(&page.title),
|
||||
summary = summary,
|
||||
source = source,
|
||||
created_at = escape_html(&created_at),
|
||||
metadata = metadata,
|
||||
content = content,
|
||||
page_type = page_type
|
||||
)
|
||||
}
|
||||
|
||||
fn render_metadata(metadata: &str) -> String {
|
||||
let Ok(Value::Object(values)) = serde_json::from_str::<Value>(metadata) else {
|
||||
return String::new();
|
||||
};
|
||||
|
||||
let items = values
|
||||
.into_iter()
|
||||
.filter_map(|(key, value)| {
|
||||
let value = metadata_value_to_text(value)?;
|
||||
Some(format!(
|
||||
"<dt>{}</dt><dd>{}</dd>",
|
||||
escape_html(metadata_label(&key)),
|
||||
escape_html(&value)
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
|
||||
if items.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(r#"<section class="details"><dl>{items}</dl></section>"#)
|
||||
}
|
||||
}
|
||||
|
||||
fn metadata_label(key: &str) -> &str {
|
||||
match key {
|
||||
"from" => "发件人",
|
||||
"subject" => "主题",
|
||||
"reminder" => "提醒",
|
||||
"received_at" => "收件时间",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn metadata_value_to_text(value: Value) -> Option<String> {
|
||||
match value {
|
||||
Value::Null => None,
|
||||
Value::String(value) => {
|
||||
let value = value.trim().to_string();
|
||||
(!value.is_empty()).then_some(value)
|
||||
}
|
||||
Value::Array(values) if values.is_empty() => None,
|
||||
Value::Object(values) if values.is_empty() => None,
|
||||
other => Some(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_text_content(content: &str) -> String {
|
||||
content
|
||||
.lines()
|
||||
.map(|line| {
|
||||
if line.trim().is_empty() {
|
||||
"<br>".to_string()
|
||||
} else {
|
||||
escape_html(line)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("<br>\n")
|
||||
}
|
||||
|
||||
fn escape_html(value: &str) -> String {
|
||||
let mut escaped = String::with_capacity(value.len());
|
||||
for ch in value.chars() {
|
||||
match ch {
|
||||
'&' => escaped.push_str("&"),
|
||||
'<' => escaped.push_str("<"),
|
||||
'>' => escaped.push_str(">"),
|
||||
'"' => escaped.push_str("""),
|
||||
'\'' => escaped.push_str("'"),
|
||||
_ => escaped.push(ch),
|
||||
}
|
||||
}
|
||||
escaped
|
||||
}
|
||||
|
||||
fn api_bad_request(message: impl Into<String>) -> ApiError {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"success": false,
|
||||
"message": message.into(),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
fn api_internal_error(error: anyhow::Error) -> ApiError {
|
||||
eprintln!("创建 web 页面失败: {error}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({
|
||||
"success": false,
|
||||
"message": "创建 web 页面失败",
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
fn html_not_found() -> HtmlError {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Html(render_error_page("页面不存在", "没有找到对应的内容页面")),
|
||||
)
|
||||
}
|
||||
|
||||
fn html_internal_error(error: anyhow::Error) -> HtmlError {
|
||||
eprintln!("读取 web 页面失败: {error}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Html(render_error_page("页面暂时不可用", "读取页面内容失败")),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_error_page(title: &str, message: &str) -> String {
|
||||
format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body style="margin:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f6f7f9;color:#1f2328;">
|
||||
<main style="width:min(760px,calc(100% - 32px));margin:0 auto;padding:48px 0;">
|
||||
<h1 style="font-size:32px;line-height:1.2;margin:0 0 12px;">{title}</h1>
|
||||
<p style="font-size:16px;line-height:1.7;margin:0;color:#57606a;">{message}</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>"#,
|
||||
title = escape_html(title),
|
||||
message = escape_html(message)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user