feat: add workflow toolkit and runtime support

This commit is contained in:
2026-07-06 10:45:19 +08:00
parent efc6fa7067
commit ed7f89d44f
44 changed files with 3770 additions and 127 deletions
+11 -1
View File
@@ -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
View File
@@ -1 +1 @@
pub mod wechat;
pub mod wechat;
+7 -1
View File
@@ -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}");
}
}
+6 -4
View File
@@ -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 -1
View File
@@ -1,2 +1,2 @@
pub mod looper;
pub mod manager;
pub mod looper;
+24 -1
View File
@@ -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 {
// 微信渠道配置
+142
View File
@@ -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(())
}
+4 -1
View File
@@ -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
View File
@@ -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(
+195
View File
@@ -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(())
}
}
+275
View File
@@ -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
View File
@@ -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
View File
@@ -1 +1 @@
pub mod app;
pub mod app;
+130 -26
View File
@@ -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);
}
+3
View File
@@ -0,0 +1,3 @@
pub mod model;
pub mod repository;
pub mod routes;
+59
View File
@@ -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,
}
+56
View File
@@ -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)
}
}
+479
View File
@@ -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("&amp;"),
'<' => escaped.push_str("&lt;"),
'>' => escaped.push_str("&gt;"),
'"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&#39;"),
_ => 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)
)
}