feat: 0.2.27 - 抽离数据库基础设施模块

- 新增 ias-db crate,集中管理 PostgreSQL 连接池和 SQLx 迁移
- 迁移 ias-service/migrations 到 ias-db/migrations
- ias-main 改用 ias_db::connect_db(),ias-service 只接 PgPool
- 新增操作日志 HTTP 查询接口 (GET /v1/logs, GET /v1/logs/{id})
- 新增操作日志 Web 前端页面,支持按级别/模块/关键词筛选
- 消息队列关键节点增加操作日志写入
- ias-mail 新增 mark_remote_message_seen,IMAP 已读同步
- 工作流配置加载改为文件级容错 (load_configs_with_warnings)
- ias-context 工具调用说明更新为 query_api_docs + call_http_api
- 新增高德地图地理编码 HTTP 能力配置

模块版本: ias-db 0.1.0, ias-ai 0.1.14, ias-context 0.1.6, ias-http 0.1.15, ias-mail 0.1.16, ias-service 0.1.25, ias-main 0.2.27
This commit is contained in:
2026-07-08 17:44:21 +08:00
parent 7d5140c8d2
commit 7deae286ca
83 changed files with 1440 additions and 107 deletions
+54 -5
View File
@@ -59,9 +59,9 @@ async fn list_api_docs(Query(query): Query<ApiDocQuery>) -> Json<Value> {
.filter(|doc| {
group
.as_deref()
.map_or(true, |group| doc.group.eq_ignore_ascii_case(group))
.is_none_or(|group| doc.group.eq_ignore_ascii_case(group))
})
.filter(|doc| q.as_deref().map_or(true, |q| doc_or_group_matches(doc, q)))
.filter(|doc| q.as_deref().is_none_or(|q| doc_or_group_matches(doc, q)))
.collect::<Vec<_>>();
let total = docs.len();
let matched_groups = docs.iter().map(|doc| doc.group).collect::<BTreeSet<_>>();
@@ -70,10 +70,10 @@ async fn list_api_docs(Query(query): Query<ApiDocQuery>) -> Json<Value> {
.filter(|api_group| {
group
.as_deref()
.map_or(true, |group| api_group.id.eq_ignore_ascii_case(group))
.is_none_or(|group| api_group.id.eq_ignore_ascii_case(group))
})
.filter(|api_group| {
q.as_deref().map_or(true, |q| group_matches(api_group, q))
q.as_deref().is_none_or(|q| group_matches(api_group, q))
|| matched_groups.contains(api_group.id)
})
.collect::<Vec<_>>();
@@ -204,6 +204,21 @@ fn api_groups() -> Vec<ApiGroupDoc> {
"scope",
],
},
ApiGroupDoc {
id: "logs",
title: "操作日志",
description: "用于查询系统操作日志,支持按级别、模块和关键词筛选,帮助排障和审计。",
aspects: &[
"日志列表",
"日志筛选",
"单条日志详情",
"按级别过滤",
"按模块过滤",
],
keywords: &[
"log", "日志", "操作", "审计", "级别", "error", "warn", "info",
],
},
]
}
@@ -562,6 +577,40 @@ fn api_docs() -> Vec<ApiDoc> {
response: r#"{"success":true,"has_query":true,"scope_key":"wechat:...","tools":[...],"default_system_prompt":"...","tool_call_instruction":"...","default_messages":[...],"sessions":[...],"messages":[...],"memories":[...],"summaries":[...]}"#,
notes: &["未提供 account_id/from_user_id 时只返回默认调试数据。"],
},
ApiDoc {
id: "logs.list",
group: "logs",
method: "GET",
path: "/v1/logs",
summary: "查询操作日志列表",
description: "分页查询 ias_operation_logs 中的操作日志,支持按 level、module 和关键词 q 筛选。",
auth: "",
query_params: &[
"level: 可选日志级别,例如 info、warn、error",
"module: 可选模块名",
"q: 可选关键词,匹配 message 和 metadata",
"page: 可选页码,默认 1",
"per_page: 可选每页数量,默认 20,范围 1..100",
],
path_params: &[],
body: None,
response: r#"{"success":true,"page":1,"per_page":20,"total":1,"logs":[{"id":1,"level":"info","module":"http","message":"...","metadata":{},"created_at":"..."}]}"#,
notes: &["时间倒序排列,用于排障和审计。"],
},
ApiDoc {
id: "logs.get",
group: "logs",
method: "GET",
path: "/v1/logs/{id}",
summary: "查询单条操作日志",
description: "根据 id 返回单条操作日志的完整信息。",
auth: "",
query_params: &[],
path_params: &["id: 日志记录 id"],
body: None,
response: r#"{"success":true,"log":{"id":1,"level":"info","module":"http","message":"...","metadata":{},"created_at":"..."}}"#,
notes: &["日志不存在时返回 404。"],
},
]
}
@@ -598,7 +647,7 @@ fn doc_matches(doc: &ApiDoc, query: &str) -> bool {
fn doc_or_group_matches(doc: &ApiDoc, query: &str) -> bool {
doc_matches(doc, query)
|| api_group(doc.group).map_or(false, |group| group_matches(&group, query))
|| api_group(doc.group).is_some_and(|group| group_matches(&group, query))
}
fn group_matches(group: &ApiGroupDoc, query: &str) -> bool {
+2
View File
@@ -7,6 +7,7 @@ use tower_http::cors::{Any, CorsLayer};
use tower_http::services::{ServeDir, ServeFile};
use crate::api_docs::routes as api_doc_routes;
use crate::logs::routes as log_routes;
use crate::updates::routes as update_routes;
use crate::web::routes as web_routes;
@@ -26,6 +27,7 @@ pub async fn create_http(
let app = Router::new()
.route("/v1/health", get(health))
.merge(api_doc_routes::<()>())
.merge(log_routes::<()>(db.clone()))
.merge(update_routes::<()>(db.clone()))
.merge(web_routes::routes::<()>(db))
.merge(extra_routes)
+1
View File
@@ -1,5 +1,6 @@
pub mod api_docs;
pub mod app;
pub mod logs;
pub mod updates;
pub mod web;
+410
View File
@@ -0,0 +1,410 @@
use axum::extract::{Path, Query};
use axum::http::StatusCode;
use axum::routing::get;
use axum::{Extension, Json, Router};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sqlx::PgPool;
use sqlx::prelude::FromRow;
type ApiError = (StatusCode, Json<Value>);
#[derive(Clone)]
struct LogsRouteState {
db: PgPool,
}
#[derive(Debug, Clone, FromRow, Serialize)]
pub struct OperationLog {
pub id: i64,
pub level: String,
pub module: String,
pub message: String,
pub metadata: Value,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct LogListQuery {
pub level: Option<String>,
pub module: Option<String>,
pub q: Option<String>,
pub page: Option<i64>,
pub per_page: Option<i64>,
}
// ── 路由 ────────────────────────────────────────────────
pub fn routes<S>(db: PgPool) -> Router<S>
where
S: Clone + Send + Sync + 'static,
{
Router::new()
.route("/v1/logs", get(list_logs))
.route("/v1/logs/{id}", get(get_log))
.layer(Extension(LogsRouteState { db }))
}
// ── JSON API ─────────────────────────────────────────────
async fn list_logs(
Extension(state): Extension<LogsRouteState>,
Query(query): Query<LogListQuery>,
) -> Result<Json<Value>, ApiError> {
let level = optional_text(query.level);
let module = optional_text(query.module);
let q = optional_text(query.q);
let per_page = query.per_page.unwrap_or(20).clamp(1, 100);
let page = query.page.unwrap_or(1).max(1);
let offset = (page - 1) * per_page;
let (logs, total) = query_logs(
&state.db,
level.as_deref(),
module.as_deref(),
q.as_deref(),
per_page,
offset,
)
.await
.map_err(api_internal_error)?;
Ok(Json(json!({
"success": true,
"page": page,
"per_page": per_page,
"total": total,
"logs": logs,
})))
}
async fn get_log(
Extension(state): Extension<LogsRouteState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, ApiError> {
let log = find_log(&state.db, id)
.await
.map_err(api_internal_error)?
.ok_or_else(|| api_not_found("日志不存在"))?;
Ok(Json(json!({
"success": true,
"log": log,
})))
}
// ── 数据库查询 ──────────────────────────────────────────
async fn query_logs(
pool: &PgPool,
level: Option<&str>,
module: Option<&str>,
q: Option<&str>,
limit: i64,
offset: i64,
) -> anyhow::Result<(Vec<OperationLog>, i64)> {
let query_pattern = q.map(|value| format!("%{}%", value));
let total: i64 = match (level, module, query_pattern.as_deref()) {
(Some(lv), Some(mod_), Some(q)) => {
sqlx::query_scalar(
r#"
SELECT COUNT(*)
FROM ias_operation_logs
WHERE level = $1
AND module = $2
AND (message ILIKE $3 OR metadata::text ILIKE $3)
"#,
)
.bind(lv)
.bind(mod_)
.bind(q)
.fetch_one(pool)
.await?
}
(Some(lv), Some(mod_), None) => {
sqlx::query_scalar(
r#"SELECT COUNT(*) FROM ias_operation_logs WHERE level = $1 AND module = $2"#,
)
.bind(lv)
.bind(mod_)
.fetch_one(pool)
.await?
}
(Some(lv), None, Some(q)) => {
sqlx::query_scalar(
r#"
SELECT COUNT(*)
FROM ias_operation_logs
WHERE level = $1
AND (message ILIKE $2 OR metadata::text ILIKE $2)
"#,
)
.bind(lv)
.bind(q)
.fetch_one(pool)
.await?
}
(Some(lv), None, None) => {
sqlx::query_scalar(r#"SELECT COUNT(*) FROM ias_operation_logs WHERE level = $1"#)
.bind(lv)
.fetch_one(pool)
.await?
}
(None, Some(mod_), Some(q)) => {
sqlx::query_scalar(
r#"
SELECT COUNT(*)
FROM ias_operation_logs
WHERE module = $1
AND (message ILIKE $2 OR metadata::text ILIKE $2)
"#,
)
.bind(mod_)
.bind(q)
.fetch_one(pool)
.await?
}
(None, Some(mod_), None) => {
sqlx::query_scalar(r#"SELECT COUNT(*) FROM ias_operation_logs WHERE module = $1"#)
.bind(mod_)
.fetch_one(pool)
.await?
}
(None, None, Some(q)) => {
sqlx::query_scalar(
r#"
SELECT COUNT(*)
FROM ias_operation_logs
WHERE message ILIKE $1 OR metadata::text ILIKE $1
"#,
)
.bind(q)
.fetch_one(pool)
.await?
}
(None, None, None) => {
sqlx::query_scalar(r#"SELECT COUNT(*) FROM ias_operation_logs"#)
.fetch_one(pool)
.await?
}
};
let rows = match (level, module, query_pattern.as_deref()) {
(Some(lv), Some(mod_), Some(q)) => {
sqlx::query_as::<_, OperationLog>(
r#"
SELECT id, level, module, message, metadata, created_at
FROM ias_operation_logs
WHERE level = $1
AND module = $2
AND (message ILIKE $3 OR metadata::text ILIKE $3)
ORDER BY created_at DESC
LIMIT $4 OFFSET $5
"#,
)
.bind(lv)
.bind(mod_)
.bind(q)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?
}
(Some(lv), Some(mod_), None) => {
sqlx::query_as::<_, OperationLog>(
r#"
SELECT id, level, module, message, metadata, created_at
FROM ias_operation_logs
WHERE level = $1 AND module = $2
ORDER BY created_at DESC
LIMIT $3 OFFSET $4
"#,
)
.bind(lv)
.bind(mod_)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?
}
(Some(lv), None, Some(q)) => {
sqlx::query_as::<_, OperationLog>(
r#"
SELECT id, level, module, message, metadata, created_at
FROM ias_operation_logs
WHERE level = $1
AND (message ILIKE $2 OR metadata::text ILIKE $2)
ORDER BY created_at DESC
LIMIT $3 OFFSET $4
"#,
)
.bind(lv)
.bind(q)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?
}
(Some(lv), None, None) => {
sqlx::query_as::<_, OperationLog>(
r#"
SELECT id, level, module, message, metadata, created_at
FROM ias_operation_logs
WHERE level = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
"#,
)
.bind(lv)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?
}
(None, Some(mod_), Some(q)) => {
sqlx::query_as::<_, OperationLog>(
r#"
SELECT id, level, module, message, metadata, created_at
FROM ias_operation_logs
WHERE module = $1
AND (message ILIKE $2 OR metadata::text ILIKE $2)
ORDER BY created_at DESC
LIMIT $3 OFFSET $4
"#,
)
.bind(mod_)
.bind(q)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?
}
(None, Some(mod_), None) => {
sqlx::query_as::<_, OperationLog>(
r#"
SELECT id, level, module, message, metadata, created_at
FROM ias_operation_logs
WHERE module = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
"#,
)
.bind(mod_)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?
}
(None, None, Some(q)) => {
sqlx::query_as::<_, OperationLog>(
r#"
SELECT id, level, module, message, metadata, created_at
FROM ias_operation_logs
WHERE message ILIKE $1 OR metadata::text ILIKE $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
"#,
)
.bind(q)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?
}
(None, None, None) => {
sqlx::query_as::<_, OperationLog>(
r#"
SELECT id, level, module, message, metadata, created_at
FROM ias_operation_logs
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
"#,
)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?
}
};
Ok((rows, total))
}
async fn find_log(pool: &PgPool, id: i64) -> anyhow::Result<Option<OperationLog>> {
let row = sqlx::query_as::<_, OperationLog>(
r#"
SELECT id, level, module, message, metadata, created_at
FROM ias_operation_logs
WHERE id = $1
"#,
)
.bind(id)
.fetch_optional(pool)
.await?;
Ok(row)
}
// ── 辅助函数 ─────────────────────────────────────────────
fn optional_text(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn api_not_found(message: impl Into<String>) -> ApiError {
(
StatusCode::NOT_FOUND,
Json(json!({
"success": false,
"message": message.into(),
})),
)
}
fn api_internal_error(error: anyhow::Error) -> ApiError {
eprintln!("日志查询接口失败: {error}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"success": false,
"message": "日志查询接口失败",
})),
)
}
pub async fn log_operation(
pool: &PgPool,
level: &str,
module: &str,
message: &str,
metadata: Value,
) {
let level = level.trim().to_ascii_lowercase();
let level = if level.is_empty() { "info" } else { &level };
let result = sqlx::query(
r#"
INSERT INTO ias_operation_logs (level, module, message, metadata)
VALUES ($1, $2, $3, $4)
"#,
)
.bind(level)
.bind(module.trim())
.bind(message.trim())
.bind(&metadata)
.execute(pool)
.await;
if let Err(err) = result {
eprintln!("写入操作日志失败: {err}");
}
}
+1 -1
View File
@@ -75,7 +75,7 @@ fn configured_internal_url() -> String {
.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())
.unwrap_or_else(configured_base_url)
}
fn configured_base_url_from_env() -> Option<String> {