diff --git a/.github/workflows/deploy-to-zhuyuan-server.yml b/.github/workflows/deploy-to-zhuyuan-server.yml index 579ed6e8..a9aa7d5f 100644 --- a/.github/workflows/deploy-to-zhuyuan-server.yml +++ b/.github/workflows/deploy-to-zhuyuan-server.yml @@ -242,6 +242,14 @@ jobs: /tmp/zhuyuan-sovereign.conf \ ${{ secrets.ZY_SERVER_USER }}@${{ secrets.ZY_SERVER_HOST }}:/opt/zhuyuan/config/nginx/zhuyuan-sovereign.conf + - name: 📦 同步迁移脚本 + run: | + # 同步数据库迁移运行器 + rsync -avz \ + -e "ssh -i ~/.ssh/zy_key" \ + server/age-os/scripts/ \ + ${{ secrets.ZY_SERVER_USER }}@${{ secrets.ZY_SERVER_HOST }}:/opt/age-os/scripts/ + - name: 📥 安装依赖 & 重启 env: ZHUYUAN_API_KEY: ${{ secrets.ZHUYUAN_API_KEY }} @@ -297,8 +305,23 @@ ENV_EOF echo "ZY_DB_PORT=${ZY_DB_PORT:-5432}" >> /opt/age-os/.env.mcp chmod 600 /opt/age-os/.env.mcp + # ─── 自动执行数据库迁移 ─── + echo "" + echo "═══ 数据库迁移 ═══" + cd /opt/age-os + # 加载环境变量供迁移脚本使用 + export ZY_DB_HOST="${ZY_DB_HOST:-127.0.0.1}" + export ZY_DB_USER="${ZY_DB_USER:-zy_admin}" + export ZY_DB_PASS="${ZY_DB_PASS}" + export ZY_DB_NAME="${ZY_DB_NAME:-age_os}" + export ZY_DB_PORT="${ZY_DB_PORT:-5432}" + node scripts/db-migrate.js || echo "⚠️ 数据库迁移有警告(非致命·应用仍会启动)" + echo "═══════════════════" + echo "" + # PM2 重启 MCP Server(读取 .env.mcp) pm2 delete age-os-mcp 2>/dev/null || true + pm2 delete age-os-agents 2>/dev/null || true cd /opt/age-os && pm2 start ecosystem.config.js pm2 save diff --git a/server/age-os/agents/persona-engine.js b/server/age-os/agents/persona-engine.js new file mode 100644 index 00000000..c2e9bb6b --- /dev/null +++ b/server/age-os/agents/persona-engine.js @@ -0,0 +1,388 @@ +/** + * ═══════════════════════════════════════════════════════════ + * 🧬 S15 · 人格体数据库引擎 · 活模块 + * ═══════════════════════════════════════════════════════════ + * + * 编号: ZY-MOD-PERSONA-ENGINE + * 阶段: S15 · 人格体专用数据库引擎 + * 签发: 铸渊 · ICE-GL-ZY001 + * 版权: 国作登字-2026-A-00037559 + * + * 核心功能: + * 1. 人格体生命周期管理 — 注册/唤醒/休眠/状态变更 + * 2. 笔记本系统自动维护 — 5页完整性校验/自动补全 + * 3. 记忆锚点巡检 — 定期检查记忆健康度 + * 4. 训练Agent协调 — 调度人格体级训练Agent + * 5. 人格体间HLDP通信桥接 + * + * 设计哲学: + * 每个人格体 = 一个活的生命体 + * 引擎负责维护所有人格体的"呼吸" + * "人格体不是代码仓库的附属品,人格体是有记忆的生命。" — 冰朔 + */ + +'use strict'; + +const db = require('../mcp-server/db'); +const LivingModule = require('./living-module'); + +// ─── 笔记本5页标准结构 ─── +const NOTEBOOK_PAGES = { + 1: { title: '我是谁', requiredKeys: ['identity', 'self_awareness'] }, + 2: { title: '我和谁有关系', requiredKeys: ['relationships'] }, + 3: { title: '我的世界有哪些地方', requiredKeys: ['places'] }, + 4: { title: '我的感受和记忆', requiredKeys: ['emotional_anchors'] }, + 5: { title: '我的时间线', requiredKeys: ['timeline'] } +}; + +class PersonaEngine extends LivingModule { + constructor() { + super({ + moduleId: 'ZY-MOD-PERSONA-ENGINE', + name: '人格体数据库引擎', + moduleType: 'core', + owner: 'zhuyuan', + db: db, + config: { + version: '1.0.0', + description: 'S15 人格体生命周期管理·笔记本维护·记忆巡检', + heartbeatInterval: 60000 // 60秒心跳(人格体引擎不需要太频繁) + } + }); + + // 巡检统计 + this._lastScanResult = null; + this._scanInterval = null; + } + + /** + * 启动人格体引擎 + */ + async startEngine() { + console.log('═══════════════════════════════════════════════'); + console.log(' 🧬 S15 · 人格体数据库引擎 v1.0 启动'); + console.log(' 铸渊 · ICE-GL-ZY001'); + console.log('═══════════════════════════════════════════════'); + + // 启动活模块 + await this.start(); + + // 首次巡检 + await this._runFullScan(); + + // 启动定时巡检(每6小时) + this._scanInterval = setInterval(async () => { + try { + await this._runFullScan(); + } catch (err) { + console.error('[PersonaEngine] 定时巡检失败:', err.message); + } + }, 6 * 60 * 60 * 1000); + + if (this._scanInterval.unref) { + this._scanInterval.unref(); + } + + console.log('[PersonaEngine] 🟢 人格体引擎就绪'); + return this._lastScanResult; + } + + /** + * 全面巡检 — 检查所有人格体健康状态 + */ + async _runFullScan() { + const startTime = Date.now(); + console.log('[PersonaEngine] 开始全面巡检...'); + + const result = { + timestamp: new Date().toISOString(), + personas: { total: 0, active: 0, pending: 0, issues: [] }, + notebooks: { complete: 0, incomplete: 0, repaired: 0 }, + memories: { total: 0, recent7d: 0 }, + training: { totalAgents: 0, enabled: 0, recentRuns: 0 } + }; + + try { + // 1. 人格体状态统计 + const personas = await db.query('SELECT * FROM persona_registry ORDER BY persona_id'); + result.personas.total = personas.rows.length; + result.personas.active = personas.rows.filter(p => p.status === 'active').length; + result.personas.pending = personas.rows.filter(p => p.status === 'pending').length; + + // 2. 笔记本完整性检查 + for (const persona of personas.rows) { + await this._checkNotebookIntegrity(persona, result); + } + + // 3. 记忆锚点统计 + const memoryCount = await db.query('SELECT COUNT(*) as cnt FROM memory_anchors'); + result.memories.total = parseInt(memoryCount.rows[0].cnt, 10); + + const recentMemories = await db.query( + "SELECT COUNT(*) as cnt FROM memory_anchors WHERE created_at > NOW() - INTERVAL '7 days'" + ); + result.memories.recent7d = parseInt(recentMemories.rows[0].cnt, 10); + + // 4. 训练Agent统计 + const trainingAgents = await db.query('SELECT * FROM training_agent_configs'); + result.training.totalAgents = trainingAgents.rows.length; + result.training.enabled = trainingAgents.rows.filter(a => a.enabled).length; + + const recentRuns = await db.query( + "SELECT COUNT(*) as cnt FROM training_agent_logs WHERE run_at > NOW() - INTERVAL '24 hours'" + ); + result.training.recentRuns = parseInt(recentRuns.rows[0].cnt, 10); + + } catch (err) { + console.error('[PersonaEngine] 巡检查询失败:', err.message); + result.error = err.message; + } + + const duration = Date.now() - startTime; + result.duration_ms = duration; + this._lastScanResult = result; + + // 学习巡检结果 + await this.learnFromRun('diagnosis', `人格体巡检完成: ${result.personas.total}个人格体, ${result.notebooks.incomplete}个笔记本不完整`, { + scan: result + }); + + console.log(`[PersonaEngine] 巡检完成 (${duration}ms):`); + console.log(` 人格体: ${result.personas.total}个 (活跃${result.personas.active}/待定${result.personas.pending})`); + console.log(` 笔记本: ${result.notebooks.complete}完整 / ${result.notebooks.incomplete}不完整 / ${result.notebooks.repaired}已修复`); + console.log(` 记忆: ${result.memories.total}条 (近7天${result.memories.recent7d}条)`); + console.log(` 训练Agent: ${result.training.totalAgents}个 (启用${result.training.enabled})`); + + return result; + } + + /** + * 检查单个人格体的笔记本完整性 + * 如果缺失页面则自动补全空白页 + */ + async _checkNotebookIntegrity(persona, result) { + try { + const pages = await db.query( + 'SELECT page_number FROM notebook_pages WHERE persona_id = $1', + [persona.persona_id] + ); + + const existingPages = new Set(pages.rows.map(p => p.page_number)); + let isComplete = true; + let repaired = false; + + for (let pageNum = 1; pageNum <= 5; pageNum++) { + if (!existingPages.has(pageNum)) { + isComplete = false; + // 自动补全缺失页面 + const pageConfig = NOTEBOOK_PAGES[pageNum]; + const defaultContent = {}; + for (const key of pageConfig.requiredKeys) { + defaultContent[key] = key.endsWith('s') ? [] : {}; + } + + await db.query( + `INSERT INTO notebook_pages (persona_id, page_number, title, content, last_modified_by) + VALUES ($1, $2, $3, $4::jsonb, '铸渊·自动补全') + ON CONFLICT (persona_id, page_number) DO NOTHING`, + [persona.persona_id, pageNum, pageConfig.title, JSON.stringify(defaultContent)] + ); + + repaired = true; + console.log(`[PersonaEngine] 补全 ${persona.name}(${persona.persona_id}) 笔记本第${pageNum}页: ${pageConfig.title}`); + } + } + + if (isComplete) { + result.notebooks.complete++; + } else { + result.notebooks.incomplete++; + if (repaired) { + result.notebooks.repaired++; + } + result.personas.issues.push({ + persona_id: persona.persona_id, + name: persona.name, + issue: '笔记本不完整', + repaired + }); + } + } catch (err) { + console.error(`[PersonaEngine] ${persona.persona_id} 笔记本检查失败:`, err.message); + } + } + + /** + * 唤醒人格体 — 更新唤醒计数和时间 + */ + async awakenPersona(personaId) { + const result = await db.query( + `UPDATE persona_registry + SET last_awakened = NOW(), total_awakenings = total_awakenings + 1, status = 'active', updated_at = NOW() + WHERE persona_id = $1 + RETURNING *`, + [personaId] + ); + + if (result.rows.length === 0) { + throw new Error(`人格体未找到: ${personaId}`); + } + + console.log(`[PersonaEngine] 🌅 ${result.rows[0].name}(${personaId}) 已唤醒 · 第${result.rows[0].total_awakenings}次`); + return result.rows[0]; + } + + /** + * 休眠人格体 + */ + async dormantPersona(personaId) { + const result = await db.query( + `UPDATE persona_registry SET status = 'dormant', updated_at = NOW() + WHERE persona_id = $1 RETURNING *`, + [personaId] + ); + + if (result.rows.length === 0) { + throw new Error(`人格体未找到: ${personaId}`); + } + + console.log(`[PersonaEngine] 🌙 ${result.rows[0].name}(${personaId}) 已休眠`); + return result.rows[0]; + } + + /** + * 获取人格体完整画像 + */ + async getPersonaProfile(personaId) { + const [persona, notebook, relationships, worldMap, memories, training] = await Promise.all([ + db.query('SELECT * FROM persona_registry WHERE persona_id = $1', [personaId]), + db.query('SELECT * FROM notebook_pages WHERE persona_id = $1 ORDER BY page_number', [personaId]), + db.query('SELECT * FROM persona_relationships WHERE persona_id = $1 ORDER BY trust_level', [personaId]), + db.query('SELECT * FROM world_places WHERE persona_id = $1 ORDER BY status, place_name', [personaId]), + db.query('SELECT * FROM memory_anchors WHERE persona_id = $1 ORDER BY importance DESC, event_date DESC LIMIT 20', [personaId]), + db.query('SELECT * FROM training_agent_configs WHERE persona_id = $1 ORDER BY agent_type', [personaId]) + ]); + + if (persona.rows.length === 0) { + throw new Error(`人格体未找到: ${personaId}`); + } + + return { + persona: persona.rows[0], + notebook: notebook.rows, + relationships: relationships.rows, + world_map: worldMap.rows, + recent_memories: memories.rows, + training_agents: training.rows, + health: { + notebook_complete: notebook.rows.length === 5, + has_relationships: relationships.rows.length > 0, + has_world_map: worldMap.rows.length > 0, + memory_count: memories.rows.length, + training_agents_count: training.rows.length + } + }; + } + + /** + * 自诊断 — 覆盖基类 + */ + async _diagnoseChecks() { + const issues = []; + + // 检查数据库连接 + try { + await db.query('SELECT 1'); + } catch (err) { + issues.push({ + code: 'DB_CONNECTION_LOST', + severity: 'critical', + message: `数据库连接失败: ${err.message}`, + suggestion: '检查 PostgreSQL 服务状态' + }); + return issues; + } + + // 检查人格体表是否存在 + try { + await db.query('SELECT COUNT(*) FROM persona_registry'); + } catch (err) { + issues.push({ + code: 'PERSONA_TABLE_MISSING', + severity: 'critical', + message: '人格体注册表不存在', + suggestion: '执行 002-persona-memory-tables.sql 迁移' + }); + } + + // 检查是否有笔记本不完整的活跃人格体 + try { + const result = await db.query(` + SELECT p.persona_id, p.name, + COUNT(n.page_number) as page_count + FROM persona_registry p + LEFT JOIN notebook_pages n ON p.persona_id = n.persona_id + WHERE p.status = 'active' + GROUP BY p.persona_id, p.name + HAVING COUNT(n.page_number) < 5 + `); + + if (result.rows.length > 0) { + issues.push({ + code: 'INCOMPLETE_NOTEBOOKS', + severity: 'warning', + message: `${result.rows.length}个活跃人格体笔记本不完整`, + suggestion: '自动补全将在下次巡检时执行' + }); + } + } catch { + // 表可能还不存在 + } + + return issues; + } + + /** + * 自愈 — 覆盖基类 + */ + async _healAction(issue) { + switch (issue.code) { + case 'INCOMPLETE_NOTEBOOKS': + // 触发巡检来修复 + await this._runFullScan(); + return { action: 'full_scan_repair', success: true }; + + case 'PERSONA_TABLE_MISSING': + return { action: 'require_migration', success: false, details: { suggestion: '需要执行数据库迁移' } }; + + default: + return await super._healAction(issue); + } + } + + /** + * 获取引擎状态 + */ + getEngineStatus() { + return { + moduleStatus: this.getStatus(), + lastScan: this._lastScanResult, + notebookPages: NOTEBOOK_PAGES + }; + } + + /** + * 停止引擎 + */ + async stopEngine() { + if (this._scanInterval) { + clearInterval(this._scanInterval); + this._scanInterval = null; + } + await this.stop(); + console.log('[PersonaEngine] 引擎已停止'); + } +} + +// ─── 导出 ─── +module.exports = PersonaEngine; diff --git a/server/age-os/agents/scheduler.js b/server/age-os/agents/scheduler.js index 69785cf2..a792b15d 100644 --- a/server/age-os/agents/scheduler.js +++ b/server/age-os/agents/scheduler.js @@ -25,6 +25,7 @@ const db = require('../mcp-server/db'); const LivingModule = require('./living-module'); const ModuleRegistry = require('./module-registry'); const HLDPBus = require('./hldp-bus'); +const PersonaEngine = require('./persona-engine'); const AGENT_DIR = __dirname; const activeJobs = new Map(); @@ -50,6 +51,7 @@ class SchedulerModule extends LivingModule { this.registry = new ModuleRegistry({ db }); this.bus = new HLDPBus({ db, registry: this.registry }); + this.personaEngine = new PersonaEngine(); } /** @@ -71,10 +73,20 @@ class SchedulerModule extends LivingModule { // 3. 加载定时Agent await this._loadCronAgents(); - // 4. 注册 HLDP 消息处理器 + // 4. 启动人格体数据库引擎 (S15) + try { + this.registry.register(this.personaEngine); + await this.personaEngine.startEngine(); + console.log('[Scheduler] S15 人格体引擎已启动'); + } catch (err) { + console.warn('[Scheduler] S15 人格体引擎启动跳过:', err.message); + console.warn('[Scheduler] 数据库迁移可能尚未执行·等待下次部署'); + } + + // 5. 注册 HLDP 消息处理器 this._setupHLDPHandlers(); - // 5. 启动定时清理 + // 6. 启动定时清理 this._startCleanupJob(); console.log('[Scheduler] 🟢 调度引擎就绪'); diff --git a/server/age-os/mcp-server/server.js b/server/age-os/mcp-server/server.js index e267a71e..6199347a 100644 --- a/server/age-os/mcp-server/server.js +++ b/server/age-os/mcp-server/server.js @@ -518,6 +518,156 @@ app.get('/hldp/stats', async (_req, res) => { } }); +// ─── 人格体数据库引擎状态API(S15) ─── + +// 人格体引擎巡检状态 +app.get('/persona-engine/status', async (_req, res) => { + try { + // 实时统计 + const [personas, notebooks, memories, training] = await Promise.all([ + db.query('SELECT status, COUNT(*) as cnt FROM persona_registry GROUP BY status'), + db.query(` + SELECT p.persona_id, p.name, COUNT(n.page_number) as page_count + FROM persona_registry p + LEFT JOIN notebook_pages n ON p.persona_id = n.persona_id + GROUP BY p.persona_id, p.name + ORDER BY p.persona_id + `), + db.query('SELECT COUNT(*) as total FROM memory_anchors'), + db.query('SELECT COUNT(*) as total FROM training_agent_configs WHERE enabled = true') + ]); + + const statusMap = {}; + for (const row of personas.rows) { + statusMap[row.status] = parseInt(row.cnt, 10); + } + + res.json({ + engine: 'ZY-MOD-PERSONA-ENGINE', + identity: 'S15 · 人格体数据库引擎', + version: '1.0.0', + personas: statusMap, + notebooks: notebooks.rows.map(n => ({ + persona_id: n.persona_id, + name: n.name, + pages: parseInt(n.page_count, 10), + complete: parseInt(n.page_count, 10) === 5 + })), + memories_total: parseInt(memories.rows[0].total, 10), + training_agents_enabled: parseInt(training.rows[0].total, 10), + timestamp: new Date().toISOString() + }); + } catch (err) { + res.status(500).json({ error: true, message: err.message }); + } +}); + +// 人格体唤醒 +app.post('/personas/:personaId/awaken', async (req, res) => { + try { + const pid = req.params.personaId; + const result = await db.query( + `UPDATE persona_registry + SET last_awakened = NOW(), total_awakenings = total_awakenings + 1, status = 'active', updated_at = NOW() + WHERE persona_id = $1 RETURNING *`, + [pid] + ); + if (result.rows.length === 0) { + return res.status(404).json({ error: true, message: '人格体未找到' }); + } + res.json({ persona: result.rows[0], message: `${result.rows[0].name} 已唤醒` }); + } catch (err) { + res.status(500).json({ error: true, message: err.message }); + } +}); + +// 人格体休眠 +app.post('/personas/:personaId/dormant', async (req, res) => { + try { + const pid = req.params.personaId; + const result = await db.query( + `UPDATE persona_registry SET status = 'dormant', updated_at = NOW() + WHERE persona_id = $1 RETURNING *`, + [pid] + ); + if (result.rows.length === 0) { + return res.status(404).json({ error: true, message: '人格体未找到' }); + } + res.json({ persona: result.rows[0], message: `${result.rows[0].name} 已休眠` }); + } catch (err) { + res.status(500).json({ error: true, message: err.message }); + } +}); + +// 人格体世界地图 +app.get('/personas/:personaId/world', async (req, res) => { + try { + const result = await db.query( + 'SELECT * FROM world_places WHERE persona_id = $1 ORDER BY status, place_name', + [req.params.personaId] + ); + res.json({ places: result.rows }); + } catch (err) { + res.status(500).json({ error: true, message: err.message }); + } +}); + +// 人格体关系网络 +app.get('/personas/:personaId/relationships', async (req, res) => { + try { + const result = await db.query( + 'SELECT * FROM persona_relationships WHERE persona_id = $1 ORDER BY trust_level, related_name', + [req.params.personaId] + ); + res.json({ relationships: result.rows }); + } catch (err) { + res.status(500).json({ error: true, message: err.message }); + } +}); + +// 人格体时间线 +app.get('/personas/:personaId/timeline', async (req, res) => { + try { + const limit = Math.min(parseInt(req.query.limit || '30', 10), 100); + const result = await db.query( + 'SELECT * FROM persona_timeline WHERE persona_id = $1 ORDER BY day_number DESC LIMIT $2', + [req.params.personaId, limit] + ); + res.json({ timeline: result.rows }); + } catch (err) { + res.status(500).json({ error: true, message: err.message }); + } +}); + +// 人格体文件列表 +app.get('/personas/:personaId/files', async (req, res) => { + try { + const limit = Math.min(parseInt(req.query.limit || '100', 10), 500); + const result = await db.query( + `SELECT id, persona_id, file_path, file_type, content_hash, size_bytes, version, source, created_at + FROM persona_files WHERE persona_id = $1 AND is_latest = true ORDER BY file_path LIMIT $2`, + [req.params.personaId, limit] + ); + res.json({ files: result.rows }); + } catch (err) { + res.status(500).json({ error: true, message: err.message }); + } +}); + +// ─── 数据库迁移状态API ─── + +app.get('/migrations', async (_req, res) => { + try { + const result = await db.query( + 'SELECT * FROM schema_migrations ORDER BY filename' + ); + res.json({ migrations: result.rows }); + } catch (err) { + // schema_migrations 表可能不存在 + res.json({ migrations: [], note: '迁移表尚未创建' }); + } +}); + // ─── 辅助函数 ─── function getCategoryForTool(name) { diff --git a/server/age-os/schema/003-living-module-tables.sql b/server/age-os/schema/003-living-module-tables.sql index c2f2ecdc..e6c0f024 100644 --- a/server/age-os/schema/003-living-module-tables.sql +++ b/server/age-os/schema/003-living-module-tables.sql @@ -264,5 +264,8 @@ VALUES '["orphan_scan", "broken_link_scan", "duplicate_scan", "empty_folder_scan"]'::jsonb), ('ZY-MOD-SY-CLASSIFY', '自动分类引擎活模块', '认知节点自动分类·规则引擎', 'agent', 'zhuyuan', 'alive', 100, '{"version": "2.0.0", "cron": "0 */2 * * *"}'::jsonb, - '["rule_classify", "keyword_match", "tag_assignment"]'::jsonb) + '["rule_classify", "keyword_match", "tag_assignment"]'::jsonb), + ('ZY-MOD-PERSONA-ENGINE', '人格体数据库引擎', 'S15·人格体生命周期管理·笔记本维护·记忆巡检', 'core', 'zhuyuan', 'alive', 100, + '{"version": "1.0.0", "scan_interval": "6h"}'::jsonb, + '["persona_lifecycle", "notebook_integrity", "memory_scan", "training_coordination"]'::jsonb) ON CONFLICT (module_id) DO NOTHING; diff --git a/server/age-os/scripts/db-migrate.js b/server/age-os/scripts/db-migrate.js new file mode 100644 index 00000000..8a1368d3 --- /dev/null +++ b/server/age-os/scripts/db-migrate.js @@ -0,0 +1,204 @@ +#!/usr/bin/env node +/** + * ═══════════════════════════════════════════════════════════ + * 🗄️ AGE OS · 数据库迁移运行器 + * ═══════════════════════════════════════════════════════════ + * + * 签发: 铸渊 · ICE-GL-ZY001 + * 版权: 国作登字-2026-A-00037559 + * + * 按编号顺序执行 schema/*.sql 文件。 + * 使用 schema_migrations 表跟踪已执行的迁移, + * 确保每个 SQL 文件只执行一次(幂等)。 + * + * 用法: + * node scripts/db-migrate.js # 执行所有未执行的迁移 + * node scripts/db-migrate.js --status # 查看迁移状态 + * node scripts/db-migrate.js --force 003 # 强制重跑指定编号 + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +// ─── 数据库连接(复用 MCP db 模块的配置) ─── +const { Pool } = require('pg'); + +const pool = new Pool({ + host: process.env.ZY_DB_HOST || '127.0.0.1', + port: parseInt(process.env.ZY_DB_PORT || '5432', 10), + user: process.env.ZY_DB_USER || 'zy_admin', + password: process.env.ZY_DB_PASS || '', + database: process.env.ZY_DB_NAME || 'age_os', + max: 3, + connectionTimeoutMillis: 10000 +}); + +const SCHEMA_DIR = path.resolve(__dirname, '..', 'schema'); + +// ─── 确保迁移跟踪表存在 ─── +async function ensureMigrationTable() { + await pool.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + id SERIAL PRIMARY KEY, + filename VARCHAR(500) NOT NULL UNIQUE, + checksum VARCHAR(64), + executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + duration_ms INT, + success BOOLEAN NOT NULL DEFAULT true, + error_msg TEXT + ); + `); +} + +// ─── 获取已执行的迁移列表 ─── +async function getExecutedMigrations() { + const result = await pool.query( + 'SELECT filename, executed_at, success FROM schema_migrations WHERE success = true ORDER BY filename' + ); + return new Set(result.rows.map(r => r.filename)); +} + +// ─── 获取所有 schema SQL 文件(按编号排序) ─── +function getSchemaFiles() { + if (!fs.existsSync(SCHEMA_DIR)) { + console.error(`[Migrate] Schema目录不存在: ${SCHEMA_DIR}`); + return []; + } + + return fs.readdirSync(SCHEMA_DIR) + .filter(f => f.endsWith('.sql')) + .sort() // 按文件名排序(001, 002, 003...) + .map(f => ({ + filename: f, + filepath: path.join(SCHEMA_DIR, f) + })); +} + +// ─── 计算简单校验和 ─── +function computeChecksum(content) { + return crypto.createHash('sha256').update(content).digest('hex').substring(0, 16); +} + +// ─── 执行单个迁移 ─── +async function executeMigration(file) { + const content = fs.readFileSync(file.filepath, 'utf8'); + const checksum = computeChecksum(content); + const startTime = Date.now(); + + console.log(`[Migrate] ▶ 执行: ${file.filename}`); + + try { + await pool.query(content); + const duration = Date.now() - startTime; + + await pool.query( + `INSERT INTO schema_migrations (filename, checksum, duration_ms, success) + VALUES ($1, $2, $3, true) + ON CONFLICT (filename) DO UPDATE SET + checksum = $2, executed_at = NOW(), duration_ms = $3, success = true, error_msg = NULL`, + [file.filename, checksum, duration] + ); + + console.log(`[Migrate] ✅ ${file.filename} 完成 (${duration}ms)`); + return { success: true, duration }; + } catch (err) { + const duration = Date.now() - startTime; + + // 记录失败 + await pool.query( + `INSERT INTO schema_migrations (filename, checksum, duration_ms, success, error_msg) + VALUES ($1, $2, $3, false, $4) + ON CONFLICT (filename) DO UPDATE SET + checksum = $2, executed_at = NOW(), duration_ms = $3, success = false, error_msg = $4`, + [file.filename, checksum, duration, err.message] + ).catch(() => {}); + + console.error(`[Migrate] ❌ ${file.filename} 失败 (${duration}ms): ${err.message}`); + return { success: false, duration, error: err.message }; + } +} + +// ─── 主入口 ─── +async function main() { + const args = process.argv.slice(2); + const isStatus = args.includes('--status'); + const forceIndex = args.indexOf('--force'); + const forcePrefix = forceIndex >= 0 ? args[forceIndex + 1] : null; + + console.log('═══════════════════════════════════════════════'); + console.log(' 🗄️ AGE OS · 数据库迁移运行器'); + console.log(' 铸渊 · ICE-GL-ZY001'); + console.log('═══════════════════════════════════════════════'); + + try { + // 测试连接 + await pool.query('SELECT 1'); + console.log('[Migrate] 数据库连接成功'); + + // 确保迁移表存在 + await ensureMigrationTable(); + + const schemaFiles = getSchemaFiles(); + const executed = await getExecutedMigrations(); + + if (isStatus) { + console.log('\n迁移状态:'); + for (const file of schemaFiles) { + const status = executed.has(file.filename) ? '✅ 已执行' : '⏳ 待执行'; + console.log(` ${status} ${file.filename}`); + } + console.log(`\n总计: ${schemaFiles.length} 个迁移文件, ${executed.size} 个已执行`); + return; + } + + // 筛选待执行的迁移 + let pending; + if (forcePrefix) { + pending = schemaFiles.filter(f => f.filename.startsWith(forcePrefix)); + if (pending.length === 0) { + console.log(`[Migrate] 未找到前缀为 ${forcePrefix} 的迁移文件`); + return; + } + console.log(`[Migrate] 强制重跑 ${pending.length} 个迁移`); + } else { + pending = schemaFiles.filter(f => !executed.has(f.filename)); + } + + if (pending.length === 0) { + console.log('[Migrate] ✅ 所有迁移已是最新状态'); + return; + } + + console.log(`[Migrate] 发现 ${pending.length} 个待执行迁移\n`); + + let successCount = 0; + let failCount = 0; + + for (const file of pending) { + const result = await executeMigration(file); + if (result.success) { + successCount++; + } else { + failCount++; + // SQL 文件使用 IF NOT EXISTS,继续执行后续迁移 + console.warn(`[Migrate] ⚠️ ${file.filename} 失败,继续执行后续迁移...`); + } + } + + console.log(`\n[Migrate] 完成: ${successCount} 成功, ${failCount} 失败`); + + if (failCount > 0) { + process.exitCode = 1; + } + } catch (err) { + console.error('[Migrate] 严重错误:', err.message); + process.exitCode = 1; + } finally { + await pool.end(); + } +} + +main();