feat: 模块统一 ias- 前缀、独立版本与内网地址支持 (0.1.7)

- 所有 Rust crate 统一加 ias- 前缀:ai → ias-ai、common → ias-common、context → ias-context、mail → ias-mail、service → ias-service、wechat → ias-wechat
- 新增 ias-main 模块承载 CLI 入口和二进制打包
- 各模块开始独立维护版本号,ias-main 0.1.7 代表整体版本
- 新增 IA_WEB_INTERNAL_URL 环境变量,版本通知同时发送公网和内网链接
- 将已有升级日志翻译为中文
- ias_upgrade_logs 新增 modules JSONB 字段
- 新增 VERSION 文件和 CHANGELOG.md
- 新增 AGENTS.md 项目规则文件
This commit is contained in:
2026-07-06 17:57:48 +08:00
parent ed7f89d44f
commit 25dd0daa8b
91 changed files with 5546 additions and 777 deletions
+48
View File
@@ -0,0 +1,48 @@
use axum::routing::get;
use axum::{Json, Router};
use sqlx::PgPool;
use tokio::sync::watch;
use crate::updates::routes as update_routes;
use crate::web::routes as web_routes;
pub async fn create_http(
host: String,
mut shutdown_rx: watch::Receiver<bool>,
db: PgPool,
extra_routes: Router,
) -> anyhow::Result<()> {
let app = Router::new()
.route("/health", get(health))
.merge(update_routes::<()>(db.clone()))
.merge(web_routes::routes::<()>(db))
.merge(extra_routes);
let listener = tokio::net::TcpListener::bind(&host).await.map_err(|err| {
if err.kind() == std::io::ErrorKind::AddrInUse {
anyhow::anyhow!(
"HTTP 地址已被占用: {}。请停止已有服务,或修改 HOST 后重试",
host
)
} else {
anyhow::anyhow!("HTTP 地址监听失败: {}: {}", host, err)
}
})?;
println!("服务启动在:http://{}", listener.local_addr()?);
axum::serve(listener, app)
.with_graceful_shutdown(async move {
while shutdown_rx.changed().await.is_ok() {
if *shutdown_rx.borrow() {
break;
}
}
})
.await?;
Ok(())
}
async fn health() -> Json<serde_json::Value> {
Json(serde_json::json!({
"success": true,
"message": "service is running"
}))
}
+5
View File
@@ -0,0 +1,5 @@
pub mod app;
pub mod updates;
pub mod web;
pub use app::create_http;
+301
View File
@@ -0,0 +1,301 @@
use axum::extract::Path;
use axum::http::StatusCode;
use axum::response::Html;
use axum::routing::get;
use axum::{Extension, Router};
use chrono::{DateTime, Utc};
use serde_json::Value;
use sqlx::PgPool;
use sqlx::prelude::FromRow;
type HtmlError = (StatusCode, Html<String>);
#[derive(Clone)]
struct UpdateRouteState {
db: PgPool,
}
#[derive(Debug, Clone, FromRow)]
struct UpgradeLog {
version: String,
released_at: DateTime<Utc>,
title: String,
description: String,
changes: Value,
}
pub fn routes<S>(db: PgPool) -> Router<S>
where
S: Clone + Send + Sync + 'static,
{
Router::new()
.route("/updates", get(show_updates))
.route("/updates/{version}", get(show_update))
.layer(Extension(UpdateRouteState { db }))
}
async fn show_updates(
Extension(state): Extension<UpdateRouteState>,
) -> Result<Html<String>, HtmlError> {
let logs = list_upgrade_logs(&state.db)
.await
.map_err(html_internal_error)?;
Ok(Html(render_updates_page(&logs)))
}
async fn show_update(
Extension(state): Extension<UpdateRouteState>,
Path(version): Path<String>,
) -> Result<Html<String>, HtmlError> {
let log = find_upgrade_log(&state.db, &version)
.await
.map_err(html_internal_error)?
.ok_or_else(html_not_found)?;
Ok(Html(render_update_page(&log)))
}
async fn list_upgrade_logs(pool: &PgPool) -> anyhow::Result<Vec<UpgradeLog>> {
let rows = sqlx::query_as::<_, UpgradeLog>(
r#"
SELECT version, released_at, title, description, changes
FROM ias_upgrade_logs
ORDER BY released_at DESC, version DESC
"#,
)
.fetch_all(pool)
.await?;
Ok(rows)
}
async fn find_upgrade_log(pool: &PgPool, version: &str) -> anyhow::Result<Option<UpgradeLog>> {
let row = sqlx::query_as::<_, UpgradeLog>(
r#"
SELECT version, released_at, title, description, changes
FROM ias_upgrade_logs
WHERE version = $1
"#,
)
.bind(version.trim())
.fetch_optional(pool)
.await?;
Ok(row)
}
fn render_updates_page(logs: &[UpgradeLog]) -> String {
let items = logs
.iter()
.map(|log| {
format!(
r#"<li><a href="/updates/{version}">v{version}</a><span>{date}</span><strong>{title}</strong><p>{description}</p></li>"#,
version = escape_html(&log.version),
date = escape_html(&format_date(log.released_at)),
title = escape_html(&log.title),
description = escape_html(&log.description),
)
})
.collect::<Vec<_>>()
.join("");
render_shell(
"更新日志",
r#"<p class="summary">服务版本更新记录。</p>"#,
&format!(r#"<ol class="updates">{items}</ol>"#),
)
}
fn render_update_page(log: &UpgradeLog) -> String {
let changes = render_changes(&log.changes);
let body = format!(
r#"<p class="summary">{description}</p>
<section class="meta">
<span>版本:v{version}</span>
<span>发布时间:{date}</span>
</section>
<section class="panel">
<h2>变更内容</h2>
{changes}
</section>
<p class="back"><a href="/updates">查看全部更新</a></p>"#,
description = escape_html(&log.description),
version = escape_html(&log.version),
date = escape_html(&format_date(log.released_at)),
changes = changes,
);
render_shell(&format!("v{} {}", log.version, log.title), "", &body)
}
fn render_changes(value: &Value) -> String {
let Some(items) = value.as_array() else {
return format!("<pre>{}</pre>", escape_html(&value.to_string()));
};
let items = items
.iter()
.filter_map(Value::as_str)
.map(|item| format!("<li>{}</li>", escape_html(item)))
.collect::<Vec<_>>()
.join("");
format!("<ul>{items}</ul>")
}
fn render_shell(title: &str, intro: &str, body: &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>
<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;
}}
h1 {{
margin: 0 0 12px;
font-size: 34px;
line-height: 1.18;
letter-spacing: 0;
}}
h2 {{
margin: 0 0 14px;
font-size: 20px;
line-height: 1.3;
letter-spacing: 0;
}}
.summary {{
margin: 0 0 22px;
color: #57606a;
font-size: 16px;
line-height: 1.7;
}}
.meta {{
display: flex;
flex-wrap: wrap;
gap: 10px 18px;
margin: 0 0 24px;
color: #57606a;
font-size: 14px;
}}
.panel {{
border: 1px solid #d8dee4;
border-radius: 8px;
background: #ffffff;
padding: 22px 24px;
}}
ul {{
margin: 0;
padding-left: 22px;
line-height: 1.8;
}}
.updates {{
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 12px;
}}
.updates li {{
border: 1px solid #d8dee4;
border-radius: 8px;
background: #ffffff;
padding: 18px 20px;
}}
.updates a {{
display: inline-block;
margin-right: 12px;
color: #0969da;
font-weight: 700;
text-decoration: none;
}}
.updates span {{
color: #6e7781;
font-size: 13px;
}}
.updates strong {{
display: block;
margin-top: 8px;
font-size: 18px;
line-height: 1.35;
}}
.updates p {{
margin: 8px 0 0;
color: #57606a;
line-height: 1.65;
}}
.back {{
margin: 18px 0 0;
}}
.back a {{
color: #0969da;
text-decoration: none;
}}
</style>
</head>
<body>
<main>
<h1>{title}</h1>
{intro}
{body}
</main>
</body>
</html>"#,
title = escape_html(title),
intro = intro,
body = body,
)
}
fn format_date(value: DateTime<Utc>) -> String {
value.format("%Y-%m-%d %H:%M:%S UTC").to_string()
}
fn html_not_found() -> HtmlError {
(
StatusCode::NOT_FOUND,
Html(render_shell(
"更新日志不存在",
r#"<p class="summary">没有找到对应版本的更新日志。</p>"#,
r#"<p class="back"><a href="/updates">查看全部更新</a></p>"#,
)),
)
}
fn html_internal_error(error: anyhow::Error) -> HtmlError {
eprintln!("读取更新日志失败: {error}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Html(render_shell(
"更新日志暂时不可用",
r#"<p class="summary">读取更新日志失败。</p>"#,
"",
)),
)
}
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
}
+7
View File
@@ -0,0 +1,7 @@
pub mod model;
pub mod repository;
pub mod routes;
pub use model::{NewWebPage, WebPage};
pub use repository::WebPageRepository;
pub use routes::{generate_slug, internal_url_for_path, public_path, public_url_for_path};
+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)
}
}
+524
View File
@@ -0,0 +1,524 @@
use axum::extract::Path;
use axum::http::{HeaderMap, StatusCode};
use axum::response::Html;
use axum::routing::{get, post};
use axum::{Extension, Json, Router};
use serde_json::{Value, json};
use sqlx::PgPool;
use uuid::Uuid;
use crate::web::model::{
CreateMailPageRequest, CreateWebPageRequest, CreateWebPageResponse, NewWebPage, WebPage,
};
use crate::web::repository::WebPageRepository;
type ApiError = (StatusCode, Json<Value>);
type HtmlError = (StatusCode, Html<String>);
#[derive(Clone)]
pub struct WebRouteState {
pub db: PgPool,
}
impl WebRouteState {
pub fn new(db: PgPool) -> Self {
Self { db }
}
}
pub fn routes<S>(db: PgPool) -> Router<S>
where
S: Clone + Send + Sync + 'static,
{
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))
.layer(Extension(WebRouteState::new(db)))
}
async fn create_page(
Extension(state): Extension<WebRouteState>,
headers: HeaderMap,
Json(request): Json<CreateWebPageRequest>,
) -> Result<Json<CreateWebPageResponse>, ApiError> {
let page_type = normalize_key(request.page_type.as_deref().unwrap_or("page"), "page_type")?;
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(
Extension(state): Extension<WebRouteState>,
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(
Extension(state): Extension<WebRouteState>,
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(
Extension(state): Extension<WebRouteState>,
Path((page_type, slug)): Path<(String, String)>,
) -> Result<Html<String>, HtmlError> {
let page_type = normalize_key(&page_type, "page_type").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,
}
}
pub fn public_path(page: &WebPage) -> String {
if page.page_type == "mail" {
format!("/mail/{}", page.slug)
} else {
format!("/web/{}/{}", page.page_type, page.slug)
}
}
pub fn public_url_for_path(path: &str) -> String {
format!("{}{}", configured_base_url(), path)
}
pub fn internal_url_for_path(path: &str) -> String {
format!("{}{}", configured_internal_url(), path)
}
fn request_base_url(headers: &HeaderMap) -> String {
if let Some(base_url) = configured_base_url_from_env() {
return base_url;
}
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()).replace("0.0.0.0", "127.0.0.1")
}
fn configured_base_url() -> String {
configured_base_url_from_env().unwrap_or_else(|| "http://127.0.0.1:9003".to_string())
}
fn configured_internal_url() -> String {
std::env::var("IA_WEB_INTERNAL_URL")
.ok()
.map(|value| value.trim().trim_end_matches('/').to_string())
.filter(|value| !value.is_empty())
.map(|value| value.replace("0.0.0.0", "127.0.0.1"))
.unwrap_or_else(|| configured_base_url())
}
fn configured_base_url_from_env() -> Option<String> {
std::env::var("IA_WEB_BASE_URL")
.or_else(|_| std::env::var("WEB_BASE_URL"))
.or_else(|_| std::env::var("HOST").map(|host| format!("http://{host}")))
.ok()
.map(|value| value.trim().trim_end_matches('/').to_string())
.filter(|value| !value.is_empty())
.map(|value| value.replace("0.0.0.0", "127.0.0.1"))
}
pub 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: 32px;
line-height: 1.18;
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" => "主题",
"message_id" => "邮件 ID",
"mailbox" => "邮箱目录",
"uid" => "UID",
"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)
)
}