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
+64
View File
@@ -0,0 +1,64 @@
[package]
name = "ias-service"
version = "0.1.0"
edition = "2024"
[dependencies]
# ── 公共模块 ──
ias-common = { path = "../ias-common" }
# ── 上下文管理 ──
ias-context = { path = "../ias-context" }
# ── HTTP 服务与预览页面 ──
ias-http = { path = "../ias-http" }
# ── 邮件轮询 ──
ias-mail = { path = "../ias-mail" }
# ── ai api 调用 ──
ias-ai = { path = "../ias-ai" }
# ── 微信工具包 ──
ias-wechat = { path = "../ias-wechat" }
# ── api ──
axum = "0.8.9"
# ── 异步运行时 ──
tokio = { version = "1.0", features = [
"rt-multi-thread",
"macros",
"sync",
"time",
"process",
"signal",
"io-util",
"net",
] } # 异步运行时核心
# ── 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"
# ── 日志管理 ──
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# ── 时间处理 ──
chrono = { version = "0.4", features = ["serde"] } # 日期时间
# ── 唯一标识 ──
uuid = { version = "1", features = ["v4", "serde"] }
# ── Unix 后台进程控制 ──
libc = "0.2"
@@ -0,0 +1,12 @@
-- Add migration script here
CREATE TABLE ias_wechat_accounts (
id BIGSERIAL PRIMARY KEY,
user_name VARCHAR(100) NOT NULL,
token VARCHAR(255),
account_id VARCHAR(255),
base_url VARCHAR(255),
user_id VARCHAR(255),
updates_buf VARCHAR(255),
user_status INTEGER,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
@@ -0,0 +1,13 @@
CREATE TABLE ias_web_pages (
id BIGSERIAL PRIMARY KEY,
slug VARCHAR(64) NOT NULL UNIQUE,
page_type VARCHAR(64) NOT NULL,
title VARCHAR(255) NOT NULL,
summary TEXT,
content TEXT NOT NULL,
source_label VARCHAR(255),
metadata TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_ias_web_pages_type_slug ON ias_web_pages(page_type, slug);
@@ -0,0 +1,66 @@
CREATE TABLE ias_long_term_memories (
id BIGSERIAL PRIMARY KEY,
scope_key VARCHAR(768) NOT NULL,
channel VARCHAR(64) NOT NULL,
account_id VARCHAR(255) NOT NULL,
from_user_id VARCHAR(255) NOT NULL,
context_label TEXT,
content TEXT NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP
);
CREATE INDEX idx_ias_long_term_memories_scope_updated
ON ias_long_term_memories(scope_key, updated_at DESC)
WHERE deleted_at IS NULL;
CREATE TABLE ias_context_sessions (
id BIGSERIAL PRIMARY KEY,
scope_key VARCHAR(768) NOT NULL,
channel VARCHAR(64) NOT NULL,
account_id VARCHAR(255) NOT NULL,
from_user_id VARCHAR(255) NOT NULL,
context_label TEXT,
started_at TIMESTAMP NOT NULL DEFAULT NOW(),
last_message_at TIMESTAMP NOT NULL DEFAULT NOW(),
ended_at TIMESTAMP,
end_reason VARCHAR(64),
summary TEXT
);
CREATE INDEX idx_ias_context_sessions_active
ON ias_context_sessions(scope_key, started_at DESC)
WHERE ended_at IS NULL;
CREATE INDEX idx_ias_context_sessions_ended
ON ias_context_sessions(scope_key, ended_at DESC)
WHERE ended_at IS NOT NULL;
CREATE TABLE ias_context_messages (
id BIGSERIAL PRIMARY KEY,
session_id BIGINT NOT NULL REFERENCES ias_context_sessions(id) ON DELETE CASCADE,
role VARCHAR(32) NOT NULL,
content TEXT NOT NULL,
tool_call_id VARCHAR(255),
tool_calls TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_ias_context_messages_session
ON ias_context_messages(session_id, id);
CREATE TABLE ias_context_summaries (
id BIGSERIAL PRIMARY KEY,
scope_key VARCHAR(768) NOT NULL,
summary_type VARCHAR(32) NOT NULL,
source_session_id BIGINT REFERENCES ias_context_sessions(id) ON DELETE SET NULL,
content TEXT NOT NULL,
started_at TIMESTAMP,
ended_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_ias_context_summaries_latest
ON ias_context_summaries(scope_key, summary_type, created_at DESC);
@@ -0,0 +1,23 @@
ALTER TABLE ias_long_term_memories
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC',
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC',
ALTER COLUMN deleted_at TYPE TIMESTAMPTZ USING deleted_at AT TIME ZONE 'UTC',
ALTER COLUMN created_at SET DEFAULT NOW(),
ALTER COLUMN updated_at SET DEFAULT NOW();
ALTER TABLE ias_context_sessions
ALTER COLUMN started_at TYPE TIMESTAMPTZ USING started_at AT TIME ZONE 'UTC',
ALTER COLUMN last_message_at TYPE TIMESTAMPTZ USING last_message_at AT TIME ZONE 'UTC',
ALTER COLUMN ended_at TYPE TIMESTAMPTZ USING ended_at AT TIME ZONE 'UTC',
ALTER COLUMN started_at SET DEFAULT NOW(),
ALTER COLUMN last_message_at SET DEFAULT NOW();
ALTER TABLE ias_context_messages
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC',
ALTER COLUMN created_at SET DEFAULT NOW();
ALTER TABLE ias_context_summaries
ALTER COLUMN started_at TYPE TIMESTAMPTZ USING started_at AT TIME ZONE 'UTC',
ALTER COLUMN ended_at TYPE TIMESTAMPTZ USING ended_at AT TIME ZONE 'UTC',
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC',
ALTER COLUMN created_at SET DEFAULT NOW();
@@ -0,0 +1,29 @@
UPDATE ias_long_term_memories
SET scope_key = channel || ':' || account_id || ':' || from_user_id;
UPDATE ias_context_sessions
SET scope_key = channel || ':' || account_id || ':' || from_user_id;
UPDATE ias_context_summaries AS summary
SET scope_key = session.scope_key
FROM ias_context_sessions AS session
WHERE summary.source_session_id = session.id;
WITH ranked AS (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY scope_key
ORDER BY started_at DESC, id DESC
) AS rank
FROM ias_context_sessions
WHERE ended_at IS NULL
)
UPDATE ias_context_sessions AS session
SET
ended_at = last_message_at,
end_reason = 'scope_normalized',
summary = COALESCE(summary, '旧上下文 scope 合并时自动结束。')
FROM ranked
WHERE session.id = ranked.id
AND ranked.rank > 1;
@@ -0,0 +1,62 @@
CREATE TABLE ias_upgrade_logs (
id BIGSERIAL PRIMARY KEY,
version VARCHAR(64) NOT NULL UNIQUE,
released_at TIMESTAMPTZ NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
changes JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_ias_upgrade_logs_released
ON ias_upgrade_logs(released_at DESC);
INSERT INTO ias_upgrade_logs (
version,
released_at,
title,
description,
changes
) VALUES (
'0.1.1',
'2026-07-06T00:00:00+08:00',
'Context management and startup notices',
'Added standalone context management, memory tooling, session summaries, LLM response compatibility, startup online notices, and initial version/changelog files.',
'[
"Added the standalone Rust context module for long-term memories, session context, session summaries, and recent-week summaries.",
"Added timestamped memory records and context APIs for querying, creating, updating, and deleting memories.",
"Added context-window rollover rules for /new, two-hour inactivity, and token-limit thresholds.",
"Added automatic session summary generation when a new context is opened.",
"Added memory tools so the assistant can query and update user memories during conversation.",
"Fixed LLM response parsing so router responses without service_tier are accepted.",
"Added startup online notices so users receive a friendly message after the service comes back online.",
"Added the project version file and changelog, and documented the version/changelog rule."
]'::jsonb
) ON CONFLICT (version) DO UPDATE SET
released_at = EXCLUDED.released_at,
title = EXCLUDED.title,
description = EXCLUDED.description,
changes = EXCLUDED.changes;
INSERT INTO ias_upgrade_logs (
version,
released_at,
title,
description,
changes
) VALUES (
'0.1.2',
'2026-07-06T00:00:00+08:00',
'Persisted upgrade logs and Codex rules',
'Added persisted upgrade records and clarified that version/changelog rules must live in the AGENTS.md file Codex reads for the current context.',
'[
"Added the ias_upgrade_logs database table for persisted upgrade records.",
"Added migration-seeded upgrade records for versions 0.1.1 and 0.1.2.",
"Updated Codex project rules in AGENTS.md so version bumps update VERSION, crate versions, CHANGELOG.md, and database upgrade records together.",
"Clarified that project rules should live in the AGENTS.md file Codex actually reads for the current context."
]'::jsonb
) ON CONFLICT (version) DO UPDATE SET
released_at = EXCLUDED.released_at,
title = EXCLUDED.title,
description = EXCLUDED.description,
changes = EXCLUDED.changes;
@@ -0,0 +1,21 @@
INSERT INTO ias_upgrade_logs (
version,
released_at,
title,
description,
changes
) VALUES (
'0.1.3',
'2026-07-06T00:00:00+08:00',
'Detached background service startup',
'Fixed the service supervisor so background start and restart detach the child process from the invoking command session, then recorded the upgrade in the database.',
'[
"Fixed background service startup so ias service start/restart detaches the child service process from the invoking command session.",
"Added a database upgrade-log migration for version 0.1.3.",
"Verified service health using ias service status after restart."
]'::jsonb
) ON CONFLICT (version) DO UPDATE SET
released_at = EXCLUDED.released_at,
title = EXCLUDED.title,
description = EXCLUDED.description,
changes = EXCLUDED.changes;
@@ -0,0 +1,48 @@
CREATE TABLE ias_mail_messages (
id BIGSERIAL PRIMARY KEY,
source_key VARCHAR(512) NOT NULL,
mailbox VARCHAR(255) NOT NULL,
uid VARCHAR(255) NOT NULL,
message_id TEXT,
from_label TEXT NOT NULL DEFAULT '',
subject TEXT NOT NULL DEFAULT '',
summary TEXT NOT NULL DEFAULT '',
content_preview TEXT NOT NULL DEFAULT '',
preview_page_id BIGINT REFERENCES ias_web_pages(id) ON DELETE SET NULL,
preview_url TEXT,
received_at TIMESTAMPTZ,
raw_size BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (source_key, mailbox, uid)
);
CREATE INDEX idx_ias_mail_messages_created
ON ias_mail_messages(created_at DESC);
CREATE INDEX idx_ias_mail_messages_received
ON ias_mail_messages(received_at DESC);
INSERT INTO ias_upgrade_logs (
version,
released_at,
title,
description,
changes
) VALUES (
'0.1.4',
'2026-07-06T00:00:00+08:00',
'Mail polling and standalone HTTP module',
'Added a standalone mail module for IMAP polling and notifications, moved HTTP serving and web preview pages into a standalone Rust module, and added mail persistence tables.',
'[
"Added the standalone Rust mail module for IMAP mailbox polling, message parsing, database deduplication, preview-page creation, and user notifications.",
"Added the standalone Rust ias-http module for HTTP serving, health checks, and reusable web preview pages.",
"Moved the existing web preview routes out of service and into ias-http.",
"Added /mail-api/messages and /mail-api/messages/{id} for inspecting stored mail records.",
"Added the ias_mail_messages database table and persisted the 0.1.4 upgrade record.",
"Added mail polling configuration through IA_MAIL_* environment variables; polling stays disabled when mailbox settings are absent."
]'::jsonb
) ON CONFLICT (version) DO UPDATE SET
released_at = EXCLUDED.released_at,
title = EXCLUDED.title,
description = EXCLUDED.description,
changes = EXCLUDED.changes;
@@ -0,0 +1,56 @@
CREATE TABLE ias_mail_accounts (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
source_key VARCHAR(512) NOT NULL UNIQUE,
imap_host VARCHAR(255) NOT NULL,
imap_port INTEGER NOT NULL DEFAULT 993,
imap_username TEXT NOT NULL,
imap_password TEXT NOT NULL,
mailbox VARCHAR(255) NOT NULL DEFAULT 'INBOX',
notify_account_id VARCHAR(255) NOT NULL,
notify_user_id VARCHAR(255) NOT NULL,
poll_seconds INTEGER NOT NULL DEFAULT 60,
fetch_limit INTEGER NOT NULL DEFAULT 10,
accept_invalid_certs BOOLEAN NOT NULL DEFAULT FALSE,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_checked_at TIMESTAMPTZ,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_ias_mail_accounts_enabled
ON ias_mail_accounts(enabled, id);
ALTER TABLE ias_mail_messages
ADD COLUMN mail_account_id BIGINT REFERENCES ias_mail_accounts(id) ON DELETE SET NULL,
ADD COLUMN content TEXT NOT NULL DEFAULT '',
ADD COLUMN raw_headers TEXT;
CREATE INDEX idx_ias_mail_messages_account
ON ias_mail_messages(mail_account_id, created_at DESC);
INSERT INTO ias_upgrade_logs (
version,
released_at,
title,
description,
changes
) VALUES (
'0.1.5',
'2026-07-06T00:00:00+08:00',
'Database-backed mail auth and cache',
'Added guided mail account configuration with ias mail auth, persisted mail accounts in the database, and expanded mail message caching.',
'[
"Added database-backed mail account configuration through the guided ias mail auth command.",
"Added the ias_mail_accounts table and linked cached mail messages back to their configured account.",
"Extended the mail cache to persist full parsed mail content and raw headers in ias_mail_messages.",
"Updated mail polling to load enabled mail accounts from the database first, while keeping environment-based configuration as a fallback.",
"Added mail account listing through ias mail list.",
"Persisted the 0.1.5 upgrade record."
]'::jsonb
) ON CONFLICT (version) DO UPDATE SET
released_at = EXCLUDED.released_at,
title = EXCLUDED.title,
description = EXCLUDED.description,
changes = EXCLUDED.changes;
@@ -0,0 +1,28 @@
CREATE TABLE ias_version_notifications (
version VARCHAR(64) PRIMARY KEY,
notified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
recipient_count INTEGER NOT NULL DEFAULT 0
);
INSERT INTO ias_upgrade_logs (
version,
released_at,
title,
description,
changes
) VALUES (
'0.1.6',
'2026-07-06T00:00:00+08:00',
'Version update notifications and changelog pages',
'Added HTTP upgrade-log pages and one-time startup notifications that tell users which version the service updated to.',
'[
"Added HTTP-rendered upgrade log pages at /updates and /updates/{version}.",
"Added one-time startup notifications for new service versions, including the updated version number and an HTTP upgrade-log link.",
"Added persisted version notification state so users are not notified repeatedly after ordinary restarts.",
"Persisted the 0.1.6 upgrade record."
]'::jsonb
) ON CONFLICT (version) DO UPDATE SET
released_at = EXCLUDED.released_at,
title = EXCLUDED.title,
description = EXCLUDED.description,
changes = EXCLUDED.changes;
@@ -0,0 +1,73 @@
-- 将已有升级日志翻译为中文
UPDATE ias_upgrade_logs SET
title = '上下文管理和启动通知',
description = '新增独立的上下文管理模块、记忆工具、会话摘要、LLM 响应兼容性、启动上线通知,以及初始版本和更新日志文件。',
changes = '[
"新增独立的 Rust context 模块,支持长期记忆、会话上下文、会话摘要和近一周摘要。",
"新增带时间戳的记忆记录和上下文 API,支持查询、创建、更新和删除记忆。",
"新增上下文窗口切换规则,涵盖 /new 命令、两小时不活跃和 token 上限阈值。",
"新增开启新上下文时自动生成会话摘要。",
"新增记忆工具,使助手在对话中可以查询和更新用户记忆。",
"修复 LLM 响应解析,使不带 service_tier 的路由响应也能正常接受。",
"新增启动上线通知,服务恢复后向用户发送友好提示消息。",
"新增项目版本文件和更新日志,并记录版本/更新日志规则。"
]'::jsonb
WHERE version = '0.1.1';
UPDATE ias_upgrade_logs SET
title = '持久化升级日志和 Codex 规则',
description = '新增持久化升级记录,并明确版本/更新日志规则必须写在 Codex 实际读取的 AGENTS.md 文件中。',
changes = '[
"新增 ias_upgrade_logs 数据库表,用于持久化升级记录。",
"新增迁移种子数据,写入 0.1.1 和 0.1.2 的升级记录。",
"更新 AGENTS.md 中的 Codex 项目规则,使版本升级同步更新 VERSION、crate 版本、CHANGELOG.md 和数据库升级记录。",
"明确项目规则应写在 Codex 实际读取的 AGENTS.md 文件中。"
]'::jsonb
WHERE version = '0.1.2';
UPDATE ias_upgrade_logs SET
title = '分离后台服务启动进程',
description = '修复服务管理器,使后台启动和重启能将子进程与调用命令的会话分离,并在数据库中记录本次升级。',
changes = '[
"修复后台服务启动问题,使 ias service start/restart 能将子服务进程与调用命令的会话分离。",
"新增版本 0.1.3 的数据库升级日志迁移。",
"重启后通过 ias service status 验证服务健康状态。"
]'::jsonb
WHERE version = '0.1.3';
UPDATE ias_upgrade_logs SET
title = '邮件轮询和独立 HTTP 模块',
description = '新增独立的邮件模块用于 IMAP 轮询和通知,将 HTTP 服务和网页预览页面迁移到独立 Rust 模块,并新增邮件持久化表。',
changes = '[
"新增独立的 Rust mail 模块,实现 IMAP 邮箱轮询、邮件解析、数据库去重、预览页面创建和用户通知。",
"新增独立的 Rust ias-http 模块,提供 HTTP 服务、健康检查和可复用的网页预览页面。",
"将现有的网页预览路由从 service 迁移至 ias-http。",
"新增 /mail-api/messages 和 /mail-api/messages/{id} 接口,用于查看存储的邮件记录。",
"新增 ias_mail_messages 数据库表,持久化 0.1.4 升级记录。",
"新增通过 IA_MAIL_* 环境变量配置邮件轮询;未配置邮箱时轮询保持禁用。"
]'::jsonb
WHERE version = '0.1.4';
UPDATE ias_upgrade_logs SET
title = '基于数据库的邮件认证和缓存',
description = '新增通过 ias mail auth 引导命令配置邮件账号,将邮件账号持久化到数据库,并扩展邮件消息缓存。',
changes = '[
"新增基于数据库的邮件账号配置,通过 ias mail auth 引导命令完成。",
"新增 ias_mail_accounts 表,并将缓存的邮件消息关联到对应配置账号。",
"扩展邮件缓存,在 ias_mail_messages 中持久化完整解析后的邮件内容和原始头信息。",
"更新邮件轮询逻辑,优先从数据库加载启用的邮件账号,同时保留环境变量配置作为回退方案。",
"新增 ias mail list 命令查看邮件账号列表。",
"持久化 0.1.5 升级记录。"
]'::jsonb
WHERE version = '0.1.5';
UPDATE ias_upgrade_logs SET
title = '版本更新通知和更新日志页面',
description = '新增 HTTP 更新日志页面和一次性启动通知,告知用户服务更新到了哪个版本。',
changes = '[
"新增 HTTP 渲染的更新日志页面,路由为 /updates 和 /updates/{version}。",
"新增服务版本一次性启动通知,包含更新的版本号和 HTTP 更新日志链接。",
"新增持久化的版本通知状态,避免普通重启后重复通知用户。",
"持久化 0.1.6 升级记录。"
]'::jsonb
WHERE version = '0.1.6';
@@ -0,0 +1,38 @@
INSERT INTO ias_upgrade_logs (
version,
released_at,
title,
description,
changes,
modules
) VALUES (
'0.1.7',
'2026-07-06T00:00:00+08:00',
'模块统一 ias 前缀、独立版本与内网地址支持',
'所有 Rust crate 统一加 ias- 前缀,各模块开始独立维护版本号,新增内网地址环境变量,版本通知同时发送公网和内网链接,并将已有升级日志翻译为中文。',
'[
"所有 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 环境变量,用于配置内网可访问的基础 URL。",
"新增 internal_url_for_path() 函数,版本更新通知同时发送公网和内网地址的 Markdown 链接。",
"将已有升级日志的 title、description、changes 翻译为中文。",
"ias_upgrade_logs 新增 modules JSONB 字段记录各模块独立版本。",
"持久化 0.1.7 升级记录。"
]'::jsonb,
'{
"ias-common": "0.1.0",
"ias-ai": "0.1.0",
"ias-wechat": "0.1.0",
"ias-context": "0.1.0",
"ias-http": "0.1.0",
"ias-mail": "0.1.0",
"ias-service": "0.1.0",
"ias-main": "0.1.7"
}'::jsonb
) ON CONFLICT (version) DO UPDATE SET
released_at = EXCLUDED.released_at,
title = EXCLUDED.title,
description = EXCLUDED.description,
changes = EXCLUDED.changes,
modules = EXCLUDED.modules;
@@ -0,0 +1,27 @@
-- 新增 modules 字段,记录各模块独立版本
ALTER TABLE ias_upgrade_logs
ADD COLUMN modules JSONB NOT NULL DEFAULT '{}'::jsonb;
-- 更新已有记录的模块版本信息
UPDATE ias_upgrade_logs SET modules = '{
"ias-common": "0.1.0",
"ias-ai": "0.1.0",
"ias-wechat": "0.1.0",
"ias-context": "0.1.0",
"ias-http": "0.1.0",
"ias-mail": "0.1.0",
"ias-service": "0.1.0",
"ias-main": "0.1.0"
}'::jsonb WHERE version IN ('0.1.1', '0.1.2', '0.1.3', '0.1.4', '0.1.5', '0.1.6');
-- 更新 0.1.7 的记录
UPDATE ias_upgrade_logs SET modules = '{
"ias-common": "0.1.0",
"ias-ai": "0.1.0",
"ias-wechat": "0.1.0",
"ias-context": "0.1.0",
"ias-http": "0.1.0",
"ias-mail": "0.1.0",
"ias-service": "0.1.0",
"ias-main": "0.1.7"
}'::jsonb WHERE version = '0.1.7';
+1
View File
@@ -0,0 +1 @@
pub mod wechat;
+73
View File
@@ -0,0 +1,73 @@
use std::sync::Arc;
use crate::{AppState, repository::user_repository::UserRepositor};
use ias_common::queue::{ChannelMessage, QueueMessage};
use sqlx::PgPool;
use tokio::sync::mpsc::Receiver;
use ias_wechat::{manager::WeChatEvent, types::WeixinMessage};
pub async fn start_loop(
state: Arc<AppState>,
mut event_rx: Receiver<WeChatEvent>,
) -> Result<(), String> {
tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
match event {
WeChatEvent::UpdatesBuf {
account_id,
updates_buf,
} => {
state.runtime.record_wechat_event(account_id.clone()).await;
persist_updates_buf(&state.db, account_id, updates_buf).await;
}
WeChatEvent::Message {
account_id,
message,
updates_buf,
} => {
state.runtime.record_wechat_event(account_id.clone()).await;
persist_updates_buf(&state.db, account_id.clone(), updates_buf).await;
if message.msg_type != Some(WeixinMessage::TYPE_USER) {
continue;
}
let Some(from_user_id) = message.from_user_id.as_deref() else {
continue;
};
let Some(text) = message.text_content() else {
continue;
};
println!("Message account_id:{}=>{}", account_id.clone(), text);
state
.sender
.clone()
.send(QueueMessage::Channel(ChannelMessage {
channel: "wechat".to_string(),
account_id,
from_user_id: from_user_id.to_string(),
content: text.to_string(),
context: message.context_token,
}))
.await
.unwrap();
}
WeChatEvent::PollError { account_id, error } => {
state
.runtime
.record_wechat_error(account_id.clone(), error.clone())
.await;
println!("微信账号轮询失败 account_id={account_id}: {error}");
}
}
}
});
Ok(())
}
async fn persist_updates_buf(pool: &PgPool, account_id: String, buf: String) {
UserRepositor::update_account_buf(pool, account_id, buf)
.await
.unwrap();
}
+125
View File
@@ -0,0 +1,125 @@
use crate::model::user::{WechatAccount, WechatAccountCreate};
use sqlx::PgPool;
use std::io;
use std::sync::Arc;
use ias_wechat::client::WeChatClient;
use ias_wechat::manager::{WeChatAccountConfig, WeChatMultiAccountManager};
use crate::AppState;
use crate::channel::wechat::looper::start_loop;
use crate::repository::user_repository::UserRepositor;
pub async fn init_wechat(state: Option<AppState>) -> Option<WeChatMultiAccountManager> {
if let Some(state) = state {
println!("init_wechat");
if let Some(accounts) = load_accounts(&state.db).await {
println!("accounts:{}", accounts.len());
let (mut manager, event_rx) = WeChatMultiAccountManager::new(1024);
for account in accounts {
println!("account:{}", account.account_id.clone().unwrap_or_default());
manager
.start_account(WeChatAccountConfig {
token: account.token.unwrap_or_default(),
base_url: account.base_url.unwrap_or_default(),
account_id: account.account_id.unwrap_or_default(),
updates_buf: account.updates_buf.unwrap_or_default(),
})
.await
.unwrap();
}
state
.runtime
.replace_wechat_accounts(manager.account_ids())
.await;
start_loop(Arc::new(state.clone()), event_rx).await.unwrap();
return Some(manager);
}
}
None
}
pub async fn login_user(pool: &PgPool) {
let client = WeChatClient::new(None);
let login = client
.login(
|qrcode_url| {
println!("二维码地址: {qrcode_url}");
},
120,
)
.await;
match login {
Ok(account) => {
let account_id = account.account_id;
if let Ok(exist) = UserRepositor::find_wechat_account(pool, account_id.clone()).await {
eprintln!(
"账号已存在:{}:{}",
exist.account_id.unwrap_or_default(),
exist.user_name.unwrap_or_default()
);
} else {
let mut user_name = String::new();
io::stdin().read_line(&mut user_name).unwrap();
let user_name = if user_name.trim().is_empty() {
let user_start: String = account_id.clone().chars().take(6).collect();
format!("user{}", user_start).to_string()
} else {
user_name.trim().to_string()
};
let user = WechatAccountCreate {
account_id,
token: account.token,
base_url: account.base_url,
user_id: account.user_id,
user_name: user_name.to_string(),
};
// todo 后续需要处理错误
UserRepositor::add_wechat_account(pool, user).await.unwrap();
}
}
Err(err) => {
eprintln!("登录失败,请重试:{}", err)
}
}
}
pub async fn list_accounts(pool: &PgPool) {
println!("已登录用户如下:");
if let Some(accounts) = load_accounts(pool).await {
for account in accounts {
let user_name = account.user_name.unwrap_or_default();
let account_id = account.account_id.unwrap_or_default();
println!(" - {}:{}", user_name, account_id);
}
}
}
pub async fn rename_account(pool: &PgPool, account: String, name: String) {
if let Ok(lines) = UserRepositor::update_account_name(pool, account, name).await
&& lines > 0
{
println!("修改成功")
}
}
pub async fn delete_account(pool: &PgPool, account: String) {
if let Ok(lines) = UserRepositor::delete_account(pool, account).await
&& lines > 0
{
println!("删除成功")
}
}
async fn load_accounts(pool: &PgPool) -> Option<Vec<WechatAccount>> {
let accounts = UserRepositor::list_wechat_accounts(pool)
.await
.unwrap_or_default();
if !accounts.is_empty() {
return Some(accounts);
}
None
}
+2
View File
@@ -0,0 +1,2 @@
pub mod looper;
pub mod manager;
+142
View File
@@ -0,0 +1,142 @@
use std::path::PathBuf;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::watch;
use crate::core::runtime::{RuntimeSnapshot, RuntimeState};
#[derive(Debug, Clone)]
pub struct RuntimePaths {
pub dir: PathBuf,
pub pid_file: PathBuf,
pub socket_file: PathBuf,
pub state_file: PathBuf,
pub log_file: PathBuf,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ControlResponse {
Status { snapshot: RuntimeSnapshot },
Ack { message: String },
Error { message: String },
}
impl RuntimePaths {
pub fn new() -> Self {
let dir = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| {
let user = std::env::var("USER").unwrap_or_else(|_| "user".to_string());
std::env::temp_dir().join(format!("ias-{user}"))
})
.join("ias");
Self {
pid_file: dir.join("ias.pid"),
socket_file: dir.join("ias.sock"),
state_file: dir.join("ias.state.json"),
log_file: dir.join("ias.log"),
dir,
}
}
pub fn ensure_dir(&self) -> anyhow::Result<()> {
std::fs::create_dir_all(&self.dir)?;
Ok(())
}
}
pub async fn start_control_server(
runtime: Arc<RuntimeState>,
paths: RuntimePaths,
shutdown_tx: watch::Sender<bool>,
mut shutdown_rx: watch::Receiver<bool>,
) -> anyhow::Result<()> {
paths.ensure_dir()?;
if paths.socket_file.exists() {
if UnixStream::connect(&paths.socket_file).await.is_ok() {
anyhow::bail!(
"服务控制 socket 已存在且可连接: {}",
paths.socket_file.display()
);
}
std::fs::remove_file(&paths.socket_file)?;
}
let listener = UnixListener::bind(&paths.socket_file)?;
loop {
tokio::select! {
result = listener.accept() => {
let (stream, _) = result?;
let runtime = runtime.clone();
let shutdown_tx = shutdown_tx.clone();
tokio::spawn(async move {
if let Err(err) = handle_control_stream(stream, runtime, shutdown_tx).await {
eprintln!("处理服务控制命令失败: {err}");
}
});
}
changed = shutdown_rx.changed() => {
if changed.is_err() || *shutdown_rx.borrow() {
break;
}
}
}
}
Ok(())
}
pub async fn request_control(
paths: &RuntimePaths,
command: &str,
) -> anyhow::Result<ControlResponse> {
let mut stream = UnixStream::connect(&paths.socket_file).await?;
stream.write_all(command.as_bytes()).await?;
stream.write_all(b"\n").await?;
let mut reader = BufReader::new(stream);
let mut line = String::new();
reader.read_line(&mut line).await?;
let response = serde_json::from_str(line.trim())?;
Ok(response)
}
async fn handle_control_stream(
stream: UnixStream,
runtime: Arc<RuntimeState>,
shutdown_tx: watch::Sender<bool>,
) -> anyhow::Result<()> {
let mut reader = BufReader::new(stream);
let mut command = String::new();
reader.read_line(&mut command).await?;
let command = command.trim();
let response = match command {
"status" => ControlResponse::Status {
snapshot: runtime.snapshot().await,
},
"shutdown" => {
runtime.mark_stopping().await;
let _ = shutdown_tx.send(true);
ControlResponse::Ack {
message: "服务正在停止".to_string(),
}
}
_ => ControlResponse::Error {
message: format!("未知控制命令: {command}"),
},
};
let response = serde_json::to_string(&response)?;
let stream = reader.get_mut();
stream.write_all(response.as_bytes()).await?;
stream.write_all(b"\n").await?;
stream.flush().await?;
Ok(())
}
+4
View File
@@ -0,0 +1,4 @@
pub mod control;
pub mod queue;
pub mod runtime;
pub mod supervisor;
+297
View File
@@ -0,0 +1,297 @@
use ias_ai::core::send_message;
use ias_common::model::ai::ChatCompletionRequestMessage;
use ias_common::queue::{ChannelMessage, QueueMessage};
use ias_context::{ContextManager, NewSessionReason};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{mpsc::Receiver, mpsc::Sender};
use tokio::task::JoinHandle;
use ias_wechat::manager::WeChatMultiAccountManager;
struct TypingSession {
client_id: String,
refresh_task: JoinHandle<()>,
}
pub async fn create_queue(
sender: Sender<QueueMessage>,
mut receiver: Receiver<QueueMessage>,
manager: Arc<WeChatMultiAccountManager>,
context_manager: ContextManager,
) -> anyhow::Result<JoinHandle<()>> {
let handle = tokio::spawn(async move {
let mut typing_sessions: HashMap<String, TypingSession> = HashMap::new();
while let Some(msg) = receiver.recv().await {
let queue_sender = sender.clone();
match msg {
QueueMessage::Aissitant(channel, assistant) => {
let content = assistant.content.clone();
let reasoning = assistant.reasoning.clone().unwrap_or_default();
let has_tool_calls = assistant
.tool_calls
.as_ref()
.is_some_and(|calls| !calls.is_empty());
println!(
"\n\n收到llm回复:\n{}\n-------\n{}\n工具调用数:{}",
reasoning,
content,
assistant.tool_calls.as_ref().map_or(0, Vec::len)
);
if has_tool_calls {
refresh_typing(manager.clone(), &typing_sessions, &channel).await;
} else if !content.trim().is_empty() || !reasoning.trim().is_empty() {
let text = if !content.trim().is_empty() {
content.trim()
} else {
reasoning.trim()
};
let session_key = typing_key(&channel);
if let Some(session) = typing_sessions.remove(&session_key) {
session.refresh_task.abort();
manager
.send_text_with_client_id(
&channel.account_id,
&channel.from_user_id,
text,
channel.context.as_deref(),
&session.client_id,
)
.await
.unwrap();
} else {
manager
.send_text(
&channel.account_id,
&channel.from_user_id,
text,
channel.context.as_deref(),
)
.await
.unwrap();
}
} else if assistant
.tool_calls
.as_ref()
.is_none_or(|calls| calls.is_empty())
{
stop_typing(&mut typing_sessions, &channel);
}
if let Err(err) = context_manager
.record_assistant_message(
&channel,
assistant.content.clone(),
assistant.tool_calls.clone(),
)
.await
{
eprintln!("记录 assistant 上下文失败: {}", err);
}
}
QueueMessage::Notice(channel, text) => {
let text = text.trim();
if !text.is_empty() {
println!("\n\n发送工具调用提醒:\n{}", text);
if let Err(err) = manager
.send_text(
&channel.account_id,
&channel.from_user_id,
text,
channel.context.as_deref(),
)
.await
{
eprintln!("发送工具调用提醒失败: {}", err);
}
}
}
QueueMessage::Channel(bundle) => {
println!(
"\n\n收到来自{}消息:\n{}",
bundle.from_user_id.clone(),
bundle.content.clone()
);
if bundle.content.trim().eq_ignore_ascii_case("/new") {
stop_typing(&mut typing_sessions, &bundle);
let text = match context_manager
.start_new_session(&bundle, NewSessionReason::UserCommand)
.await
{
Ok(_) => "已开启新的上下文。".to_string(),
Err(err) => {
eprintln!("开启新上下文失败: {}", err);
"开启新上下文失败,请稍后再试。".to_string()
}
};
if let Err(err) = manager
.send_text(
&bundle.account_id,
&bundle.from_user_id,
&text,
bundle.context.as_deref(),
)
.await
{
eprintln!("发送新上下文确认失败: {}", err);
}
continue;
}
start_typing(manager.clone(), &mut typing_sessions, &bundle).await;
let messages = match context_manager.record_user_message(&bundle).await {
Ok(snapshot) => snapshot.messages,
Err(err) => {
eprintln!("记录 user 上下文失败,退回单轮消息: {}", err);
vec![ChatCompletionRequestMessage::new(
"user".to_string(),
bundle.content.clone(),
)]
}
};
tokio::spawn(async move {
if let Err(err) = send_message(queue_sender, messages, bundle).await {
eprintln!("发送消息失败: {}", err);
}
});
}
QueueMessage::Tool(channel, tool_call_id, name, result) => {
println!("\n\n调用{}工具的结果:\n{}", name, result);
refresh_typing(manager.clone(), &typing_sessions, &channel).await;
let messages = match context_manager
.record_tool_message(&channel, tool_call_id.clone(), result.clone())
.await
{
Ok(snapshot) => snapshot.messages,
Err(err) => {
eprintln!("记录 tool 上下文失败,退回工具结果消息: {}", err);
vec![ChatCompletionRequestMessage::tool(
"tool".to_string(),
result.clone(),
tool_call_id.clone(),
)]
}
};
tokio::spawn(async move {
if let Err(err) = send_message(queue_sender, messages, channel).await {
eprintln!("发送消息失败: {}", err);
}
});
}
}
}
});
Ok(handle)
}
async fn start_typing(
manager: Arc<WeChatMultiAccountManager>,
sessions: &mut HashMap<String, TypingSession>,
channel: &ChannelMessage,
) {
let key = typing_key(channel);
stop_typing(sessions, channel);
let client_id = match manager
.send_typing(
&channel.account_id,
&channel.from_user_id,
channel.context.as_deref(),
)
.await
{
Ok(client_id) => client_id,
Err(err) => {
eprintln!("设置微信输入中状态失败: {}", err);
return;
}
};
let account_id = channel.account_id.clone();
let from_user_id = channel.from_user_id.clone();
let context = channel.context.clone();
let refresh_client_id = client_id.clone();
let refresh_manager = manager.clone();
let refresh_task = tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(4)).await;
if let Err(err) = refresh_manager
.send_typing_with_client_id(
&account_id,
&from_user_id,
context.as_deref(),
&refresh_client_id,
)
.await
{
eprintln!("刷新微信输入中状态失败: {}", err);
return;
}
}
});
sessions.insert(
key,
TypingSession {
client_id,
refresh_task,
},
);
}
async fn refresh_typing(
manager: Arc<WeChatMultiAccountManager>,
sessions: &HashMap<String, TypingSession>,
channel: &ChannelMessage,
) {
let Some(session) = sessions.get(&typing_key(channel)) else {
return;
};
if let Err(err) = manager
.send_typing_with_client_id(
&channel.account_id,
&channel.from_user_id,
channel.context.as_deref(),
&session.client_id,
)
.await
{
eprintln!("刷新微信输入中状态失败: {}", err);
}
}
fn stop_typing(sessions: &mut HashMap<String, TypingSession>, channel: &ChannelMessage) {
if let Some(session) = sessions.remove(&typing_key(channel)) {
session.refresh_task.abort();
}
}
fn typing_key(channel: &ChannelMessage) -> String {
format!(
"{}:{}:{}",
channel.account_id,
channel.from_user_id,
channel.context.as_deref().unwrap_or_default()
)
}
// pub async fn send_to_queue(
// State(state): State<Arc<AppState>>,
// Json(body): Json<ChannelMessageBundle>,
// ) -> 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"
// })),
// }
// }
+195
View File
@@ -0,0 +1,195 @@
use std::collections::HashMap;
use std::path::Path;
use std::process;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ServicePhase {
Starting,
Running,
Stopping,
Stopped,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AccountRuntimePhase {
Running,
Retrying,
Stopped,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountRuntimeStatus {
pub account_id: String,
pub phase: AccountRuntimePhase,
pub last_error: Option<String>,
pub last_event_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuntimeSnapshot {
pub phase: ServicePhase,
pub pid: u32,
pub started_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub http_addr: String,
pub queue_alive: bool,
pub wechat_accounts: Vec<AccountRuntimeStatus>,
pub last_error: Option<String>,
}
struct RuntimeInner {
phase: ServicePhase,
started_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
http_addr: String,
queue_alive: bool,
wechat_accounts: HashMap<String, AccountRuntimeStatus>,
last_error: Option<String>,
}
pub struct RuntimeState {
inner: Mutex<RuntimeInner>,
}
impl RuntimeState {
pub fn new(http_addr: String) -> Self {
let now = Utc::now();
Self {
inner: Mutex::new(RuntimeInner {
phase: ServicePhase::Starting,
started_at: now,
updated_at: now,
http_addr,
queue_alive: false,
wechat_accounts: HashMap::new(),
last_error: None,
}),
}
}
pub async fn mark_running(&self) {
let mut inner = self.inner.lock().await;
inner.phase = ServicePhase::Running;
inner.updated_at = Utc::now();
inner.last_error = None;
}
pub async fn mark_stopping(&self) {
let mut inner = self.inner.lock().await;
inner.phase = ServicePhase::Stopping;
inner.queue_alive = false;
inner.updated_at = Utc::now();
}
pub async fn mark_stopped(&self) {
let mut inner = self.inner.lock().await;
inner.phase = ServicePhase::Stopped;
inner.queue_alive = false;
inner.updated_at = Utc::now();
}
pub async fn mark_failed(&self, error: impl Into<String>) {
let mut inner = self.inner.lock().await;
inner.phase = ServicePhase::Failed;
inner.queue_alive = false;
inner.updated_at = Utc::now();
inner.last_error = Some(error.into());
}
pub async fn set_queue_alive(&self, alive: bool) {
let mut inner = self.inner.lock().await;
inner.queue_alive = alive;
inner.updated_at = Utc::now();
}
pub async fn replace_wechat_accounts(&self, account_ids: Vec<String>) {
let mut inner = self.inner.lock().await;
let now = Utc::now();
inner.wechat_accounts = account_ids
.into_iter()
.map(|account_id| {
let status = AccountRuntimeStatus {
account_id: account_id.clone(),
phase: AccountRuntimePhase::Running,
last_error: None,
last_event_at: Some(now),
};
(account_id, status)
})
.collect();
inner.updated_at = now;
}
pub async fn record_wechat_event(&self, account_id: impl Into<String>) {
self.update_wechat_account(account_id.into(), AccountRuntimePhase::Running, None)
.await;
}
pub async fn record_wechat_error(
&self,
account_id: impl Into<String>,
error: impl Into<String>,
) {
self.update_wechat_account(
account_id.into(),
AccountRuntimePhase::Retrying,
Some(error.into()),
)
.await;
}
async fn update_wechat_account(
&self,
account_id: String,
phase: AccountRuntimePhase,
last_error: Option<String>,
) {
let mut inner = self.inner.lock().await;
let now = Utc::now();
let status = inner
.wechat_accounts
.entry(account_id.clone())
.or_insert_with(|| AccountRuntimeStatus {
account_id,
phase: AccountRuntimePhase::Running,
last_error: None,
last_event_at: None,
});
status.phase = phase;
status.last_error = last_error;
status.last_event_at = Some(now);
inner.updated_at = now;
}
pub async fn snapshot(&self) -> RuntimeSnapshot {
let inner = self.inner.lock().await;
let mut wechat_accounts = inner.wechat_accounts.values().cloned().collect::<Vec<_>>();
wechat_accounts.sort_by(|left, right| left.account_id.cmp(&right.account_id));
RuntimeSnapshot {
phase: inner.phase.clone(),
pid: process::id(),
started_at: inner.started_at,
updated_at: inner.updated_at,
http_addr: inner.http_addr.clone(),
queue_alive: inner.queue_alive,
wechat_accounts,
last_error: inner.last_error.clone(),
}
}
pub async fn write_snapshot(&self, path: &Path) -> anyhow::Result<()> {
let snapshot = self.snapshot().await;
let data = serde_json::to_vec_pretty(&snapshot)?;
std::fs::write(path, data)?;
Ok(())
}
}
+294
View File
@@ -0,0 +1,294 @@
use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use crate::core::control::{ControlResponse, RuntimePaths, request_control};
use crate::core::runtime::{RuntimeSnapshot, ServicePhase};
pub async fn start_service() -> anyhow::Result<()> {
let paths = RuntimePaths::new();
paths.ensure_dir()?;
if let Ok(ControlResponse::Status { snapshot }) = request_control(&paths, "status").await {
println!("服务已经在运行,PID: {}", snapshot.pid);
print_snapshot(&snapshot);
return Ok(());
}
cleanup_stale_files(&paths)?;
let mut log = OpenOptions::new()
.create(true)
.append(true)
.open(&paths.log_file)?;
writeln!(log, "\n===== 启动后台服务 =====")?;
let exe = std::env::current_exe()?;
let stdout = log.try_clone()?;
let stderr = log;
let mut command = Command::new(exe);
command
.arg("service")
.arg("run")
.env("IAS_BACKGROUND", "1")
.stdin(Stdio::null())
.stdout(Stdio::from(stdout))
.stderr(Stdio::from(stderr));
detach_background_child(&mut command);
let child = command.spawn()?;
println!("后台服务启动中,PID: {}", child.id());
wait_until_ready(&paths, Duration::from_secs(8)).await?;
println!("日志文件: {}", paths.log_file.display());
Ok(())
}
pub async fn stop_service() -> anyhow::Result<()> {
let paths = RuntimePaths::new();
paths.ensure_dir()?;
match request_control(&paths, "shutdown").await {
Ok(ControlResponse::Ack { message }) => println!("{message}"),
Ok(ControlResponse::Error { message }) => println!("停止失败: {message}"),
Ok(ControlResponse::Status { .. }) => println!("停止失败: 控制端返回了意外状态响应"),
Err(_) => {
if let Some(pid) = read_pid(&paths)? {
if process_is_alive(pid) {
println!("控制 socket 不可用,发送 SIGTERM 到 PID: {pid}");
send_sigterm(pid)?;
} else {
println!("服务未运行,清理过期运行文件");
cleanup_stale_files(&paths)?;
return Ok(());
}
} else {
println!("服务未运行");
return Ok(());
}
}
}
wait_until_stopped(&paths, Duration::from_secs(10)).await?;
cleanup_stale_files(&paths)?;
println!("服务已停止");
Ok(())
}
pub async fn restart_service() -> anyhow::Result<()> {
stop_service().await?;
start_service().await
}
pub async fn status_service() -> anyhow::Result<()> {
let paths = RuntimePaths::new();
paths.ensure_dir()?;
match request_control(&paths, "status").await {
Ok(ControlResponse::Status { snapshot }) => print_snapshot(&snapshot),
Ok(ControlResponse::Error { message }) => println!("状态查询失败: {message}"),
Ok(ControlResponse::Ack { message }) => println!("{message}"),
Err(_) => print_offline_status(&paths)?,
}
Ok(())
}
pub fn print_logs(lines: usize) -> anyhow::Result<()> {
let paths = RuntimePaths::new();
if !paths.log_file.exists() {
println!("日志文件不存在: {}", paths.log_file.display());
return Ok(());
}
let mut file = File::open(&paths.log_file)?;
let mut content = String::new();
file.read_to_string(&mut content)?;
let mut tail = content.lines().rev().take(lines).collect::<Vec<_>>();
tail.reverse();
for line in tail {
println!("{line}");
}
Ok(())
}
async fn wait_until_ready(paths: &RuntimePaths, timeout: Duration) -> anyhow::Result<()> {
let started = Instant::now();
loop {
if let Ok(ControlResponse::Status { snapshot }) = request_control(paths, "status").await {
println!("服务已启动");
print_snapshot(&snapshot);
return Ok(());
}
if started.elapsed() >= timeout {
anyhow::bail!(
"服务未在 {} 秒内就绪,请查看日志: {}",
timeout.as_secs(),
paths.log_file.display()
);
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
async fn wait_until_stopped(paths: &RuntimePaths, timeout: Duration) -> anyhow::Result<()> {
let started = Instant::now();
loop {
if request_control(paths, "status").await.is_err()
&& read_pid(paths)?.is_none_or(|pid| !process_is_alive(pid))
{
return Ok(());
}
if started.elapsed() >= timeout {
anyhow::bail!(
"服务未在 {} 秒内停止,请检查 PID 文件: {}",
timeout.as_secs(),
paths.pid_file.display()
);
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
pub fn write_pid_file(paths: &RuntimePaths) -> anyhow::Result<()> {
paths.ensure_dir()?;
std::fs::write(&paths.pid_file, std::process::id().to_string())?;
Ok(())
}
pub fn cleanup_runtime_files(paths: &RuntimePaths) {
if let Some(pid) = read_pid(paths).ok().flatten()
&& pid != std::process::id()
{
return;
}
let _ = std::fs::remove_file(&paths.pid_file);
let _ = std::fs::remove_file(&paths.socket_file);
}
fn cleanup_stale_files(paths: &RuntimePaths) -> anyhow::Result<()> {
if let Some(pid) = read_pid(paths)?
&& process_is_alive(pid)
{
anyhow::bail!(
"已有服务进程存在但控制 socket 不可用,PID: {}。请先执行 stop 或检查日志: {}",
pid,
paths.log_file.display()
);
}
let _ = std::fs::remove_file(&paths.pid_file);
let _ = std::fs::remove_file(&paths.socket_file);
Ok(())
}
fn print_offline_status(paths: &RuntimePaths) -> anyhow::Result<()> {
if let Some(pid) = read_pid(paths)?
&& process_is_alive(pid)
{
println!("服务进程存在但控制 socket 不可用,PID: {pid}");
println!("socket: {}", paths.socket_file.display());
println!("日志文件: {}", paths.log_file.display());
return Ok(());
}
println!("服务状态: stopped");
if paths.state_file.exists() {
println!("最后状态快照: {}", paths.state_file.display());
}
println!("日志文件: {}", paths.log_file.display());
Ok(())
}
fn print_snapshot(snapshot: &RuntimeSnapshot) {
println!("服务状态: {}", phase_name(&snapshot.phase));
println!("PID: {}", snapshot.pid);
println!("HTTP: {}", snapshot.http_addr);
println!("启动时间: {}", snapshot.started_at);
println!(
"队列: {}",
if snapshot.queue_alive {
"running"
} else {
"stopped"
}
);
if let Some(error) = &snapshot.last_error {
println!("最近错误: {error}");
}
if snapshot.wechat_accounts.is_empty() {
println!("微信账号: 0");
} else {
println!("微信账号:");
for account in &snapshot.wechat_accounts {
let last_event = account
.last_event_at
.map(|time| time.to_string())
.unwrap_or_else(|| "-".to_string());
let last_error = account.last_error.as_deref().unwrap_or("-");
println!(
" - {}: {:?}, last_event={}, last_error={}",
account.account_id, account.phase, last_event, last_error
);
}
}
}
fn phase_name(phase: &ServicePhase) -> &'static str {
match phase {
ServicePhase::Starting => "starting",
ServicePhase::Running => "running",
ServicePhase::Stopping => "stopping",
ServicePhase::Stopped => "stopped",
ServicePhase::Failed => "failed",
}
}
fn read_pid(paths: &RuntimePaths) -> anyhow::Result<Option<u32>> {
if !paths.pid_file.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&paths.pid_file)?;
let pid = content.trim().parse::<u32>()?;
Ok(Some(pid))
}
fn process_is_alive(pid: u32) -> bool {
std::path::Path::new("/proc").join(pid.to_string()).exists()
}
fn send_sigterm(pid: u32) -> anyhow::Result<()> {
let status = Command::new("kill")
.arg("-TERM")
.arg(pid.to_string())
.status()?;
if !status.success() {
anyhow::bail!("发送 SIGTERM 失败,PID: {pid}");
}
Ok(())
}
#[cfg(unix)]
fn detach_background_child(command: &mut Command) {
unsafe {
command.pre_exec(|| {
if libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
}
#[cfg(not(unix))]
fn detach_background_child(_command: &mut Command) {}
+408
View File
@@ -0,0 +1,408 @@
mod channel;
mod core;
pub mod model;
mod repository;
use chrono::{Duration, Utc};
use ias_common::queue::QueueMessage;
use ias_context::ContextManager;
use ias_context::repository::ContextRepository;
use core::control::{ControlResponse, RuntimePaths, request_control, start_control_server};
use core::queue::create_queue;
use core::runtime::RuntimeState;
use core::supervisor::{
cleanup_runtime_files, write_pid_file,
};
use ias_http::create_http;
use ias_http::web::{internal_url_for_path, public_url_for_path};
use ias_mail::repository::MailRepository;
use ias_mail::{MailNotification, MailPollerConfig, start_mail_polling};
use sqlx::{PgPool, postgres::PgPoolOptions};
use std::env;
use std::sync::Arc;
use tokio::sync::{mpsc, watch};
use tokio::task::JoinHandle;
use ias_wechat::manager::WeChatMultiAccountManager;
use crate::channel::wechat::manager::init_wechat;
pub use core::supervisor;
pub use channel::wechat::manager::{delete_account, list_accounts, login_user, rename_account};
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Clone)]
pub struct AppState {
pub sender: mpsc::Sender<QueueMessage>,
pub db: PgPool,
pub runtime: Arc<RuntimeState>,
}
pub async fn connect_db() -> anyhow::Result<PgPool> {
let db_url = env::var("DATABASE_URL").map_err(|_| anyhow::anyhow!("必须设置DATABASE_URL"))?;
let pool = PgPoolOptions::new()
.max_connections(10)
.connect(&db_url)
.await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(pool)
}
pub fn init_logging() {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
}
pub async fn run_service(pool: PgPool) -> anyhow::Result<()> {
let name: String = 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 paths = RuntimePaths::new();
paths.ensure_dir()?;
if let Ok(ControlResponse::Status { snapshot }) = request_control(&paths, "status").await {
anyhow::bail!("服务已在运行,PID: {}", snapshot.pid);
}
println!("{} v{} 已启动...", name, CURRENT_VERSION);
let runtime = Arc::new(RuntimeState::new(host.clone()));
let (sender, receiver) = mpsc::channel::<QueueMessage>(100);
let state = AppState {
sender: sender.clone(),
db: pool,
runtime: runtime.clone(),
};
let manger = init_wechat(Some(state.clone()))
.await
.ok_or_else(|| anyhow::anyhow!("微信初始化失败:未加载到微信账号"))?;
let manager = Arc::new(manger);
send_online_notices(&state.db, manager.as_ref()).await;
send_version_update_notice(&state.db, manager.as_ref()).await;
let mail_tasks = start_mail_tasks(state.db.clone(), manager.clone()).await;
let context_manager = ContextManager::new(state.db.clone());
let queue_task = create_queue(sender.clone(), receiver, manager, context_manager).await?;
runtime.set_queue_alive(true).await;
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let mut control_task = tokio::spawn(start_control_server(
runtime.clone(),
paths.clone(),
shutdown_tx.clone(),
shutdown_tx.subscribe(),
));
tokio::select! {
result = &mut control_task => {
result??;
anyhow::bail!("服务控制通道已退出");
}
_ = tokio::time::sleep(std::time::Duration::from_millis(50)) => {}
}
write_pid_file(&paths)?;
let signal_task = tokio::spawn(wait_for_shutdown_signal(shutdown_tx.clone()));
runtime.mark_running().await;
runtime.write_snapshot(&paths.state_file).await?;
let routes = ias_context::routes::routes::<()>(state.db.clone())
.merge(ias_mail::routes::routes::<()>(state.db.clone()));
let result = create_http(host.clone(), shutdown_rx, state.db.clone(), routes).await;
runtime.mark_stopping().await;
runtime.write_snapshot(&paths.state_file).await?;
queue_task.abort();
for task in mail_tasks {
task.abort();
}
control_task.abort();
signal_task.abort();
if let Err(err) = &result {
runtime.mark_failed(err.to_string()).await;
runtime.write_snapshot(&paths.state_file).await?;
} else {
runtime.mark_stopped().await;
runtime.write_snapshot(&paths.state_file).await?;
}
cleanup_runtime_files(&paths);
result
}
async fn start_mail_tasks(
pool: PgPool,
manager: Arc<WeChatMultiAccountManager>,
) -> Vec<JoinHandle<()>> {
let mut configs = match MailRepository::list_enabled_accounts(&pool).await {
Ok(accounts) => accounts
.into_iter()
.map(MailPollerConfig::from_account)
.collect::<Vec<_>>(),
Err(error) => {
eprintln!("读取数据库邮件账户失败:{error}");
Vec::new()
}
};
if configs.is_empty() {
match MailPollerConfig::from_env() {
Ok(Some(config)) => configs.push(config),
Ok(None) => {}
Err(error) => eprintln!("读取环境变量邮件配置失败:{error}"),
}
}
if configs.is_empty() {
println!("邮件轮询未启动:未配置邮箱账户");
return Vec::new();
}
let (notification_tx, notification_rx) = mpsc::channel::<MailNotification>(64);
let mut tasks = configs
.into_iter()
.map(|config| start_mail_polling(pool.clone(), config, notification_tx.clone()))
.collect::<Vec<_>>();
tasks.push(tokio::spawn(handle_mail_notifications(
manager,
notification_rx,
)));
tasks
}
async fn handle_mail_notifications(
manager: Arc<WeChatMultiAccountManager>,
mut notification_rx: mpsc::Receiver<MailNotification>,
) {
while let Some(notification) = notification_rx.recv().await {
if let Err(error) = manager
.send_text(
&notification.account_id,
&notification.from_user_id,
&notification.text,
None,
)
.await
{
eprintln!(
"发送邮件提醒失败 account_id={} to={}: {}",
notification.account_id, notification.from_user_id, error
);
}
}
}
async fn send_online_notices(pool: &PgPool, manager: &WeChatMultiAccountManager) {
if !online_notice_enabled() {
return;
}
let text = online_notice_text();
let lookback_hours = read_i64_env("IA_ONLINE_NOTICE_LOOKBACK_HOURS", 168);
let limit = read_i64_env("IA_ONLINE_NOTICE_LIMIT", 50);
let since = Utc::now() - Duration::hours(lookback_hours);
for account_id in manager.account_ids() {
let recipients =
match ContextRepository::recent_recipients(pool, "wechat", &account_id, since, limit)
.await
{
Ok(recipients) => recipients,
Err(error) => {
eprintln!("查询上线提醒收件人失败 account_id={account_id}: {error}");
continue;
}
};
for recipient in recipients {
if let Err(error) = manager
.send_text(&account_id, &recipient.from_user_id, &text, None)
.await
{
eprintln!(
"发送上线提醒失败 account_id={} to={}: {}",
account_id, recipient.from_user_id, error
);
}
}
}
}
async fn send_version_update_notice(pool: &PgPool, manager: &WeChatMultiAccountManager) {
if !version_notice_enabled() {
return;
}
let version = CURRENT_VERSION;
match version_notice_already_sent(pool, version).await {
Ok(true) => return,
Ok(false) => {}
Err(error) => {
eprintln!("查询版本更新通知状态失败: {error}");
return;
}
}
let title = match upgrade_log_title(pool, version).await {
Ok(Some(title)) => title,
Ok(None) => "服务更新".to_string(),
Err(error) => {
eprintln!("查询版本更新日志标题失败: {error}");
"服务更新".to_string()
}
};
let url = public_url_for_path(&format!("/updates/{version}"));
let internal_url = internal_url_for_path(&format!("/updates/{version}"));
let text = format!(
"已更新到 v{version}{title}\n[公网地址]({url})\n[内网地址]({internal_url})"
);
let lookback_hours = read_i64_env("IA_VERSION_NOTICE_LOOKBACK_HOURS", 168);
let limit = read_i64_env("IA_VERSION_NOTICE_LIMIT", 50);
let since = Utc::now() - Duration::hours(lookback_hours);
let mut sent_count = 0;
for account_id in manager.account_ids() {
let recipients =
match ContextRepository::recent_recipients(pool, "wechat", &account_id, since, limit)
.await
{
Ok(recipients) => recipients,
Err(error) => {
eprintln!("查询版本更新通知收件人失败 account_id={account_id}: {error}");
continue;
}
};
for recipient in recipients {
match manager
.send_text(&account_id, &recipient.from_user_id, &text, None)
.await
{
Ok(_) => sent_count += 1,
Err(error) => {
eprintln!(
"发送版本更新通知失败 account_id={} to={}: {}",
account_id, recipient.from_user_id, error
);
}
}
}
}
if sent_count > 0 {
if let Err(error) = mark_version_notice_sent(pool, version, sent_count).await {
eprintln!("记录版本更新通知状态失败: {error}");
}
}
}
async fn version_notice_already_sent(pool: &PgPool, version: &str) -> anyhow::Result<bool> {
let exists = sqlx::query_scalar::<_, i32>(
r#"
SELECT 1
FROM ias_version_notifications
WHERE version = $1
"#,
)
.bind(version)
.fetch_optional(pool)
.await?;
Ok(exists.is_some())
}
async fn upgrade_log_title(pool: &PgPool, version: &str) -> anyhow::Result<Option<String>> {
let title = sqlx::query_scalar::<_, String>(
r#"
SELECT title
FROM ias_upgrade_logs
WHERE version = $1
"#,
)
.bind(version)
.fetch_optional(pool)
.await?;
Ok(title)
}
async fn mark_version_notice_sent(
pool: &PgPool,
version: &str,
recipient_count: i32,
) -> anyhow::Result<()> {
sqlx::query(
r#"
INSERT INTO ias_version_notifications (version, recipient_count)
VALUES ($1, $2)
ON CONFLICT (version) DO UPDATE SET
notified_at = NOW(),
recipient_count = EXCLUDED.recipient_count
"#,
)
.bind(version)
.bind(recipient_count)
.execute(pool)
.await?;
Ok(())
}
fn online_notice_enabled() -> bool {
std::env::var("IA_ONLINE_NOTICE_ENABLED")
.map(|value| {
let value = value.trim().to_ascii_lowercase();
!matches!(value.as_str(), "0" | "false" | "no" | "off")
})
.unwrap_or(true)
}
fn version_notice_enabled() -> bool {
std::env::var("IA_VERSION_NOTICE_ENABLED")
.map(|value| {
let value = value.trim().to_ascii_lowercase();
!matches!(value.as_str(), "0" | "false" | "no" | "off")
})
.unwrap_or(true)
}
fn online_notice_text() -> String {
std::env::var("IA_ONLINE_NOTICE_TEXT")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "我又上线啦,现在可以继续帮你处理消息了。".to_string())
}
fn read_i64_env(name: &str, default: i64) -> i64 {
std::env::var(name)
.ok()
.and_then(|value| value.trim().parse::<i64>().ok())
.filter(|value| *value > 0)
.unwrap_or(default)
}
async fn wait_for_shutdown_signal(shutdown_tx: watch::Sender<bool>) {
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
let mut terminate = match signal(SignalKind::terminate()) {
Ok(signal) => signal,
Err(err) => {
eprintln!("监听 SIGTERM 失败: {err}");
return;
}
};
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = terminate.recv() => {}
}
}
#[cfg(not(unix))]
{
let _ = tokio::signal::ctrl_c().await;
}
let _ = shutdown_tx.send(true);
}
+1
View File
@@ -0,0 +1 @@
pub mod user;
+25
View File
@@ -0,0 +1,25 @@
use chrono::NaiveDateTime;
use serde::Serialize;
use sqlx::prelude::FromRow;
#[derive(Debug, Clone, Serialize, FromRow)]
pub struct WechatAccount {
pub id: i64,
pub user_name: Option<String>,
pub token: Option<String>,
pub account_id: Option<String>,
pub base_url: Option<String>,
pub user_id: Option<String>,
pub updates_buf: Option<String>,
pub user_status: Option<i32>,
pub created_at: Option<NaiveDateTime>,
}
#[derive(Debug, Clone, Serialize)]
pub struct WechatAccountCreate {
pub user_name: String,
pub token: String,
pub account_id: String,
pub base_url: String,
pub user_id: String,
}
+1
View File
@@ -0,0 +1 @@
pub mod user_repository;
@@ -0,0 +1,108 @@
use anyhow::Ok;
use sqlx::{PgPool, Row};
use crate::model::user::{WechatAccount, WechatAccountCreate};
pub struct UserRepositor;
impl UserRepositor {
pub async fn add_wechat_account(
pool: &PgPool,
user: WechatAccountCreate,
) -> anyhow::Result<i64> {
let row = sqlx::query(
r#"
INSERT INTO ias_wechat_accounts (user_name,token,account_id,base_url,user_id,user_status)
VALUES ($1,$2,$3,$4,$5,1)
RETURNING id
"#
)
.bind ( user.user_name)
.bind ( user.token)
.bind ( user.account_id)
.bind ( user.base_url)
.bind ( user.user_id)
.fetch_one(pool)
.await?;
let id = row.try_get::<i64, _>("id");
Ok(id.unwrap_or_default())
}
pub async fn find_wechat_account(
pool: &PgPool,
account_id: String,
) -> anyhow::Result<WechatAccount> {
let row = sqlx::query_as::<_, WechatAccount>(
r#"
SELECT *
FROM ias_wechat_accounts
WHERE account_id = $1
AND user_status = 1
"#,
)
.bind(account_id)
.fetch_one(pool)
.await?;
Ok(row)
}
pub async fn list_wechat_accounts(pool: &PgPool) -> anyhow::Result<Vec<WechatAccount>> {
let row = sqlx::query_as::<_, WechatAccount>(
r#"
SELECT *
FROM ias_wechat_accounts
WHERE user_status = 1
"#,
)
.fetch_all(pool)
.await?;
Ok(row)
}
pub async fn update_account_name(
pool: &PgPool,
account_id: String,
new_name: String,
) -> anyhow::Result<u64> {
let result = sqlx::query(
r#"
UPDATE ias_wechat_accounts
SET user_name = $1
WHERE account_id = $2
"#,
)
.bind(new_name)
.bind(account_id)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
pub async fn update_account_buf(
pool: &PgPool,
account_id: String,
buf: String,
) -> anyhow::Result<u64> {
let result = sqlx::query(
r#"
UPDATE ias_wechat_accounts
SET updates_buf = $1
WHERE account_id = $2
"#,
)
.bind(buf)
.bind(account_id)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
pub async fn delete_account(pool: &PgPool, account_id: String) -> anyhow::Result<u64> {
let result = sqlx::query(
r#"
DELETE from ias_wechat_accounts
WHERE account_id = $1
"#,
)
.bind(account_id)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
}