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:
@@ -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 {
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
pub mod api_docs;
|
||||
pub mod app;
|
||||
pub mod logs;
|
||||
pub mod updates;
|
||||
pub mod web;
|
||||
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user