Files
iAs/ias-http/src/updates.rs
T
wunianxiao 25dd0daa8b 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 项目规则文件
2026-07-06 17:57:48 +08:00

302 lines
7.3 KiB
Rust

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
}