重构并拆分项目

This commit is contained in:
2026-06-25 17:23:11 +08:00
parent 3476d8cbcb
commit 79ec21bfd4
127 changed files with 1555 additions and 12411 deletions
+48
View File
@@ -0,0 +1,48 @@
[package]
name = "service"
version = "0.1.0"
edition = "2024"
[dependencies]
# ── 公共模块 ──
common = { path = "../common" }
# ── ai api 调用 ──
ai = { path = "../ai" }
# ── 微信工具包 ──
wechat = { path = "../wechat" }
# ── api ──
axum = "0.8.9"
# ── 异步运行时 ──
tokio = { version = "1.0", features = [
"rt-multi-thread",
"macros",
"sync",
"time",
"process",
"signal",
"io-util",
] } # 异步运行时核心
# ── HTTP 客户端 ──
reqwest = { version = "0.12", default-features = false, features = [
"json",
"stream",
"rustls-tls",
] } # HTTP 请求(微信 API + DeepSeek API + 工具调用)
# ── 序列化 ──
serde = { version = "1.0", features = ["derive"] } # 序列化/反序列化
serde_json = "1.0" # JSON 处理
serde_yaml = "0.9" # YAML 解析(工具规范文件)
# ── 环境变量 ──
dotenvy = "0.15.7"
# ── 数据库 ──
sqlx = { version = "0.9.0", features = [
"runtime-tokio",
"tls-rustls",
"postgres",
"macros",
"migrate",
"chrono",
] }
# ── 错误处理 ──
anyhow = "1"
thiserror = "2"
@@ -0,0 +1,29 @@
-- iAs 初始数据库迁移
-- 认证信息和聊天记录存储
-- 应用状态表(key-value 存储,用于认证信息等)
CREATE TABLE IF NOT EXISTS app_state (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 聊天记录表
CREATE TABLE IF NOT EXISTS chat_records (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
direction TEXT NOT NULL, -- 'inbound' | 'outbound'
user_id TEXT NOT NULL, -- 微信用户 ID
account_id TEXT NOT NULL, -- 机器人账号 ID
text TEXT NOT NULL, -- 消息内容
source TEXT NOT NULL DEFAULT '', -- 消息来源
context_token TEXT NOT NULL DEFAULT '', -- 上下文令牌
message_id TEXT NOT NULL DEFAULT '',
meta JSONB NOT NULL DEFAULT '{}'::JSONB
);
CREATE INDEX IF NOT EXISTS idx_chat_records_user_created
ON chat_records (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_chat_records_account_created
ON chat_records (account_id, created_at DESC);
@@ -0,0 +1,17 @@
-- 待审批的工具调用
CREATE TABLE IF NOT EXISTS pending_approvals (
id UUID PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
user_id TEXT NOT NULL,
skill_name TEXT NOT NULL,
params JSONB NOT NULL,
code_hash TEXT NOT NULL,
attempts_left INTEGER NOT NULL DEFAULT 3,
status TEXT NOT NULL DEFAULT 'pending',
result JSONB,
consumed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_pending_approvals_user_status
ON pending_approvals (user_id, status);
@@ -0,0 +1,20 @@
-- 定时任务表(Phase 6
CREATE TABLE IF NOT EXISTS scheduled_tasks (
id UUID PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
user_id TEXT NOT NULL,
command TEXT NOT NULL,
shell TEXT NOT NULL DEFAULT '/bin/bash',
cwd TEXT NOT NULL DEFAULT '',
interval_seconds INTEGER NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true,
next_run_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_run_at TIMESTAMPTZ,
last_status TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_due
ON scheduled_tasks (enabled, next_run_at);
@@ -0,0 +1,15 @@
-- LLM Token 用量统计
CREATE TABLE IF NOT EXISTS llm_usage (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
user_id TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
provider TEXT NOT NULL DEFAULT 'deepseek',
prompt_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
cache_hit_tokens INTEGER NOT NULL DEFAULT 0,
cache_miss_tokens INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_llm_usage_created ON llm_usage (created_at DESC);
@@ -0,0 +1,9 @@
-- 长期记忆表
CREATE TABLE IF NOT EXISTS user_memories (
id SERIAL PRIMARY KEY,
user_id TEXT NOT NULL DEFAULT 'default',
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_user_memories_user ON user_memories (user_id);
@@ -0,0 +1,12 @@
-- 会话摘要持久化
CREATE TABLE IF NOT EXISTS session_summaries (
id SERIAL PRIMARY KEY,
user_id TEXT NOT NULL DEFAULT 'default',
summary_text TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT 'overflow',
message_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_session_summaries_created
ON session_summaries (created_at DESC);
@@ -0,0 +1,11 @@
-- 修复 llm_usage 表:从 iPet 旧格式迁移到 iAs 新格式
-- 旧表有 kind/entry 字段,新表需要 prompt_tokens/completion_tokens 等
ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS prompt_tokens INTEGER NOT NULL DEFAULT 0;
ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS completion_tokens INTEGER NOT NULL DEFAULT 0;
ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS total_tokens INTEGER NOT NULL DEFAULT 0;
ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS cache_hit_tokens INTEGER NOT NULL DEFAULT 0;
ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS cache_miss_tokens INTEGER NOT NULL DEFAULT 0;
ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS user_id TEXT NOT NULL DEFAULT '';
ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS model TEXT NOT NULL DEFAULT '';
ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS provider TEXT NOT NULL DEFAULT 'deepseek';
@@ -0,0 +1,3 @@
-- scheduled_tasks.user_id 默认值修复
ALTER TABLE scheduled_tasks ALTER COLUMN user_id SET DEFAULT '';
ALTER TABLE scheduled_tasks ALTER COLUMN user_id DROP NOT NULL;
@@ -0,0 +1,3 @@
-- 聊天记录去重索引(普通索引,不用 UNIQUE 避免存量重复数据冲突)
CREATE INDEX IF NOT EXISTS idx_chat_records_message_id
ON chat_records (message_id) WHERE message_id != '';
@@ -0,0 +1,4 @@
-- 移除 llm_usage 表中旧 iPet 遗留列的 NOT NULL 约束
-- kind/entry 是旧格式字段,新代码不使用它们
ALTER TABLE llm_usage ALTER COLUMN kind DROP NOT NULL;
ALTER TABLE llm_usage ALTER COLUMN entry DROP NOT NULL;
@@ -0,0 +1,26 @@
-- 修复 chat_records 表中 iPet 遗留的 context_token_present (boolean) → context_token (text)
-- 迁移 0001 创建的是 context_token TEXT,但旧库中列名是 context_token_present BOOLEAN
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'chat_records' AND column_name = 'context_token_present') THEN
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'chat_records' AND column_name = 'context_token') THEN
-- 两列都存在(混合/半迁移库):回填数据后删除旧列
UPDATE chat_records
SET context_token = CASE WHEN context_token_present::boolean THEN '1' ELSE '' END
WHERE context_token = '';
ALTER TABLE chat_records DROP COLUMN context_token_present;
ELSE
-- 只有旧列:类型转换 boolean → text (true → '1', false → ''),再重命名
ALTER TABLE chat_records
ALTER COLUMN context_token_present TYPE TEXT
USING CASE WHEN context_token_present::boolean THEN '1' ELSE '' END;
ALTER TABLE chat_records RENAME COLUMN context_token_present TO context_token;
ALTER TABLE chat_records ALTER COLUMN context_token SET DEFAULT '';
END IF;
END IF;
END;
$$;
@@ -0,0 +1,12 @@
-- 聊天记录去重:清理存量重复 → 建 partial UNIQUE 索引支持 ON CONFLICT
-- 1) 删除重复记录(保留每个 message_id 最早的那条)
DELETE FROM chat_records a
USING chat_records b
WHERE a.message_id = b.message_id
AND a.message_id NOT IN ('', '0')
AND a.id > b.id;
-- 2) 替换旧普通索引为 UNIQUE partial index(排除空和占位值)
DROP INDEX IF EXISTS idx_chat_records_message_id;
CREATE UNIQUE INDEX idx_chat_records_message_id
ON chat_records (message_id) WHERE message_id NOT IN ('', '0');
@@ -0,0 +1,3 @@
-- 确保 llm_usage 表在全新数据库上也有 kind/entry 列(迁移 0010 仅解除了旧库的 NOT NULL
ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS kind TEXT;
ALTER TABLE llm_usage ADD COLUMN IF NOT EXISTS entry JSONB;
@@ -0,0 +1,6 @@
-- 定时任务增加 task_type 字段:system(系统任务)或 model(模型任务)
-- 模型只能管理 model 类型的任务,不能修改 system 任务
ALTER TABLE scheduled_tasks ADD COLUMN IF NOT EXISTS task_type TEXT NOT NULL DEFAULT 'model';
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_type
ON scheduled_tasks (task_type, enabled, next_run_at);
@@ -0,0 +1,4 @@
-- 空迁移:用于对齐 _sqlx_migrations 表中已记录但文件丢失的迁移
-- 该迁移已被之前的某次运行应用过,但对应文件被删除/重命名
-- 此文件仅用于让 sqlx 迁移校验通过,不执行任何 DDL
SELECT 1;
@@ -0,0 +1,4 @@
-- 为"按用户查询最近的会话边界(clear/timeout 摘要)"添加索引。
-- list_recent_chat_records_for_context 会据此过滤掉已过期会话的消息。
CREATE INDEX IF NOT EXISTS idx_session_summaries_user_created
ON session_summaries (user_id, created_at DESC);
@@ -0,0 +1,10 @@
-- Add migration script here
CREATE TABLE ias_wechat_users (
id BIGSERIAL PRIMARY KEY,
user_name VARCHAR(100) NOT NULL,
token VARCHAR(255),
account_id VARCHAR(255),
base_url VARCHAR(255),
user_status INTEGER,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
@@ -0,0 +1,2 @@
-- Add migration script here
ALTER TABLE ias_wechat_users ADD COLUMN user_id VARCHAR(255);
+1
View File
@@ -0,0 +1 @@
pub mod wechat;
+63
View File
@@ -0,0 +1,63 @@
use std::time::Duration;
use wechat::{client::WeChatClient, types::WeixinMessage};
use crate::AppState;
pub async fn init_wechat(state: Option<AppState>) -> Result<(), Box<dyn std::error::Error>> {
match state {
Some(state)=>{
load_config(state);
}
None=>{}
}
let client = WeChatClient::new(None);
let login = client
.login(
|qrcode_url| {
println!("二维码地址: {qrcode_url}");
},
120,
)
.await?;
// println!("登录成功: account_id={}", login.account_id);
// client.notify_start().await?;
// loop {
// let updates = client.receive_messages().await?;
// for msg in updates.msgs {
// if msg.msg_type != Some(WeixinMessage::TYPE_USER) {
// continue;
// }
// let Some(from_user_id) = msg.from_user_id.as_deref() else {
// continue;
// };
// let Some(text) = msg.text_content() else {
// continue;
// };
// client
// .send_text(
// from_user_id,
// &format!("收到: {text}"),
// msg.context_token.as_deref(),
// )
// .await?;
// }
// tokio::time::sleep(Duration::from_secs(1)).await;
// }
Ok(())
}
fn load_config(state: AppState){
let db = state.db;
}
+36
View File
@@ -0,0 +1,36 @@
use std::sync::OnceLock;
use tokio::sync::Mutex;
use common::ai::ChatCompletionRequestMessage;
static CHAT_CONTEXT: OnceLock<Mutex<Vec<ChatCompletionRequestMessage>>> = OnceLock::new();
fn chat_context() -> &'static Mutex<Vec<ChatCompletionRequestMessage>> {
CHAT_CONTEXT.get_or_init(|| Mutex::new(Vec::new()))
}
pub async fn push_context_message(msg: ChatCompletionRequestMessage) {
let mut context = chat_context().lock().await;
context.push(msg);
}
pub async fn snapshot_context() -> Vec<ChatCompletionRequestMessage> {
let context = chat_context().lock().await;
context.clone()
}
pub async fn add_assistant_message(content: String) {
let message = ChatCompletionRequestMessage::new("assistant".to_string(), content);
push_context_message(message).await;
}
pub async fn add_user_message(content: String) {
let message = ChatCompletionRequestMessage::new("user".to_string(), content);
push_context_message(message).await;
}
pub async fn add_tool_message(call_id: String, result: String) {
let message = ChatCompletionRequestMessage::tool("user".to_string(), result, call_id);
push_context_message(message).await;
}
+2
View File
@@ -0,0 +1,2 @@
pub mod queue;
pub mod context;
+62
View File
@@ -0,0 +1,62 @@
use crate::{
AppState,
core::context::{add_assistant_message, add_tool_message, add_user_message, snapshot_context},
};
use ai::core::send_message;
use axum::Json;
use axum::extract::State;
use common::queue::{ChannelMessage, QueueMessage};
use serde_json::error;
use std::sync::Arc;
use tokio::sync::{mpsc, mpsc::Sender};
pub async fn create_queue() -> Result<Sender<QueueMessage>, error::Error> {
let (sender, mut receiver) = mpsc::channel::<QueueMessage>(100);
let result_sender = sender.clone();
tokio::spawn(async move {
while let Some(msg) = receiver.recv().await {
let queue_sender = sender.clone();
match msg {
QueueMessage::Aissitant(reasoning, content) => {
println!("\n\n收到llm回复:\n{}\n-------\n{}", reasoning, content);
add_assistant_message(content).await;
}
QueueMessage::Channel(name, text) => {
println!("\n\n收到来自{}消息:\n{}", name, text);
add_user_message(text).await;
send_message(queue_sender, snapshot_context().await)
.await
.unwrap();
}
QueueMessage::Tool(tool_call_id, name, result) => {
println!("\n\n调用{}工具的结果:\n{}", name, result);
add_tool_message(tool_call_id, result).await;
send_message(queue_sender, snapshot_context().await)
.await
.unwrap();
}
}
}
});
Ok(result_sender)
}
pub async fn send_to_queue(
State(state): State<Arc<AppState>>,
Json(body): Json<ChannelMessage>,
) -> Json<serde_json::Value> {
match state
.sender
.send(QueueMessage::Channel(body.channel, body.content))
.await
{
Ok(_) => Json(serde_json::json!({
"success":true,
"message":"queued"
})),
Err(_) => Json(serde_json::json!({
"success":true,
"message":"queue closed"
})),
}
}
+33
View File
@@ -0,0 +1,33 @@
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde_json::json;
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("not found")]
NotFound,
#[error(transparent)]
AnyHow(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::NotFound => (StatusCode::NOT_FOUND, "resource not found".to_string()),
AppError::AnyHow(err) => {
eprintln!("internal error: {err:?}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal server error".to_string(),
)
}
};
(status, Json(json!({"error":message}))).into_response()
}
}
pub type AppResult<T> = Result<T, AppError>;
+25
View File
@@ -0,0 +1,25 @@
use std::sync::Arc;
use axum::routing::{get, post};
use axum::{Json, Router};
use crate::AppState;
use crate::core::queue::send_to_queue;
pub async fn create_http(state: AppState, host: String) {
let app = Router::new()
.route("/message", post(send_to_queue))
.route("/health", get(health))
.with_state(Arc::new(state));
let listener = tokio::net::TcpListener::bind(host.clone()).await.unwrap();
axum::serve(listener, app).await.unwrap();
println!("服务启动在:{}", host);
}
async fn health() -> Json<serde_json::Value> {
Json(serde_json::json!({
"success": true,
"message": "service is running"
}))
}
+1
View File
@@ -0,0 +1 @@
pub mod app;
+55
View File
@@ -0,0 +1,55 @@
mod channel;
mod core;
mod error;
mod http;
mod repository;
use common::queue::QueueMessage;
use core::queue::create_queue;
use http::app::create_http;
use sqlx::{PgPool, postgres::PgPoolOptions};
use std::env;
use tokio::sync::mpsc;
use crate::channel::wechat::init_wechat;
#[derive(Clone)]
pub struct AppState {
pub sender: mpsc::Sender<QueueMessage>,
pub db: PgPool,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
if std::env::var("RUST_ANALYZER").is_err() {
dotenv::dotenv().ok();
}
// dotenvy::dotenv().ok();
let name = 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 db_url = env::var("DATABASE_URL").expect("必须设置DATABASE_URL");
let pool = PgPoolOptions::new()
.max_connections(10)
.connect(&db_url)
.await?;
let sender = create_queue().await.unwrap();
let state = AppState {
sender: sender,
db: pool,
};
println!("{} start in : {}", name, host);
create_http(state.clone(), host).await;
init_wechat(Some(state)).await.unwrap();
Ok(())
}
#[test]
fn test() {
let api_key = std::env::var("ROUTER_API_KEY").unwrap();
println!("{}", api_key);
}
+1
View File
@@ -0,0 +1 @@
pub mod user_repository;
+39
View File
@@ -0,0 +1,39 @@
use sqlx::PgPool;
use common::db::user::{WechatUser, WechatUserCreate};
pub struct UserRepositor;
impl UserRepositor {
pub async fn create(pool: &PgPool, user: WechatUserCreate) -> anyhow::Result<isize> {
let record = sqlx::query!(
r#"
INSERT INTO ias_wechat_users (user_name,token,account_id,base_url,user_id,user_status)
VALUE ($1,$2,$3,$4,$5,1)
RETURNING id
"#,
user.user_name,
user.token,
user.account_id,
user.base_url,
user.user_id,
)
.fetch_one(pool)
.await?;
Ok(record.id)
}
pub async fn find_by_user_id(pool: &PgPool, user_id: isize) -> anyhow::Result<isize> {
let user = sqlx::query_as!(
WechatUser,
r#"
SELECT *
FROM ias_wechat_users
WHERE user_id = $1
"#,
user_id,
)
.fetch_optional(pool)
.await?;
Ok(user)
}
}