import mysql from 'mysql2/promise' import pool from './connection' import { readFileSync } from 'fs' import { join } from 'path' async function columnExists(conn: mysql.Connection, table: string, column: string): Promise { const [rows] = await conn.query( `SELECT COUNT(*) as cnt FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`, [table, column] ) return (rows as any[])[0].cnt > 0 } async function tableExists(conn: mysql.Connection, table: string): Promise { const [rows] = await conn.query( `SELECT COUNT(*) as cnt FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`, [table] ) return (rows as any[])[0].cnt > 0 } async function runMigrations(conn: mysql.Connection) { console.log('[DB] Checking migrations...') // transactions 表添加 user_id const hasTxUserId = await columnExists(conn, 'transactions', 'user_id') console.log(`[DB] transactions.user_id exists: ${hasTxUserId}`) if (!hasTxUserId) { console.log('[DB] Migrating: adding user_id to transactions') await conn.query('ALTER TABLE transactions ADD COLUMN user_id INT NOT NULL DEFAULT 1 AFTER id') await conn.query('ALTER TABLE transactions ADD INDEX idx_user_date (user_id, date)') await conn.query('ALTER TABLE transactions ADD INDEX idx_user_type_date (user_id, type, date)') console.log('[DB] transactions.user_id added') } // budgets 表添加 user_id const hasBudgetUserId = await columnExists(conn, 'budgets', 'user_id') console.log(`[DB] budgets.user_id exists: ${hasBudgetUserId}`) if (!hasBudgetUserId) { console.log('[DB] Migrating: adding user_id to budgets') await conn.query('ALTER TABLE budgets ADD COLUMN user_id INT NOT NULL DEFAULT 1 AFTER id') await conn.query('ALTER TABLE budgets ADD UNIQUE KEY uk_user_month (user_id, month)') console.log('[DB] budgets.user_id added') } // users 表添加 nickname 和 avatar_url const hasNickname = await columnExists(conn, 'users', 'nickname') if (!hasNickname) { console.log('[DB] Migrating: adding nickname to users') await conn.query("ALTER TABLE users ADD COLUMN nickname VARCHAR(50) DEFAULT '小菜' COMMENT '用户昵称'") } const hasAvatarUrl = await columnExists(conn, 'users', 'avatar_url') if (!hasAvatarUrl) { console.log('[DB] Migrating: adding avatar_url to users') await conn.query("ALTER TABLE users ADD COLUMN avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL'") } // 群组表 + transactions.group_id const hasGroupId = await columnExists(conn, 'transactions', 'group_id') console.log(`[DB] transactions.group_id exists: ${hasGroupId}`) if (!hasGroupId) { console.log('[DB] Migrating: creating groups tables and adding group_id') await conn.query(` CREATE TABLE IF NOT EXISTS \`groups\` ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, invite_code VARCHAR(10) UNIQUE NOT NULL, created_by INT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) await conn.query(` CREATE TABLE IF NOT EXISTS group_members ( id INT AUTO_INCREMENT PRIMARY KEY, group_id INT NOT NULL, user_id INT NOT NULL, role ENUM('owner', 'member') DEFAULT 'member', nickname VARCHAR(50) DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_group_user (group_id, user_id), FOREIGN KEY (group_id) REFERENCES \`groups\`(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) await conn.query("ALTER TABLE transactions ADD COLUMN group_id INT DEFAULT NULL COMMENT '群组标签'") await conn.query("ALTER TABLE transactions ADD INDEX idx_group_date (group_id, date)") console.log('[DB] Groups migration complete') } // users 表添加 role(管理员角色) const hasRole = await columnExists(conn, 'users', 'role') if (!hasRole) { console.log('[DB] Migrating: adding role to users') await conn.query("ALTER TABLE users ADD COLUMN role ENUM('user', 'admin') DEFAULT 'user' AFTER avatar_url") } // 通知表 const hasNotifications = await tableExists(conn, 'notifications') if (!hasNotifications) { console.log('[DB] Migrating: creating notifications table') await conn.query(` CREATE TABLE IF NOT EXISTS notifications ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT DEFAULT NULL COMMENT 'NULL=系统公告,非NULL=个人通知', group_id INT DEFAULT NULL COMMENT '群组通知关联', type ENUM('system', 'group', 'personal') NOT NULL, title VARCHAR(100) NOT NULL, content TEXT, is_read TINYINT(1) DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_user_read (user_id, is_read), INDEX idx_type (type) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) } // 保存的筛选方案表 const hasSavedFilters = await tableExists(conn, 'saved_filters') if (!hasSavedFilters) { console.log('[DB] Migrating: creating saved_filters table') await conn.query(` CREATE TABLE IF NOT EXISTS saved_filters ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, name VARCHAR(50) NOT NULL, filters JSON NOT NULL COMMENT '筛选条件', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) } // 公告系统增强:扩展 notifications 表 const hasPinned = await columnExists(conn, 'notifications', 'is_pinned') if (!hasPinned) { console.log('[DB] Migrating: extending notifications table') await conn.query('ALTER TABLE notifications ADD COLUMN is_pinned TINYINT(1) DEFAULT 0 AFTER is_read') await conn.query('ALTER TABLE notifications ADD COLUMN is_urgent TINYINT(1) DEFAULT 0 AFTER is_pinned') await conn.query('ALTER TABLE notifications ADD COLUMN publish_at TIMESTAMP NULL AFTER is_urgent') await conn.query('ALTER TABLE notifications ADD COLUMN expire_at TIMESTAMP NULL AFTER publish_at') await conn.query('ALTER TABLE notifications ADD COLUMN image_url VARCHAR(500) DEFAULT \'\' AFTER expire_at') await conn.query('ALTER TABLE notifications ADD COLUMN link_url VARCHAR(500) DEFAULT \'\' AFTER image_url') await conn.query('ALTER TABLE notifications ADD INDEX idx_pinned (is_pinned, created_at)') await conn.query('ALTER TABLE notifications ADD INDEX idx_expire (expire_at)') } // 公告已读记录表(解决系统/群组公告已读状态共享问题) const hasNotificationReads = await tableExists(conn, 'notification_reads') if (!hasNotificationReads) { console.log('[DB] Migrating: creating notification_reads table') await conn.query(` CREATE TABLE IF NOT EXISTS notification_reads ( notification_id INT NOT NULL, user_id INT NOT NULL, read_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (notification_id, user_id), INDEX idx_user (user_id), FOREIGN KEY (notification_id) REFERENCES notifications(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) } // users 表添加 slogan(个性签名) const hasSlogan = await columnExists(conn, 'users', 'slogan') if (!hasSlogan) { console.log('[DB] Migrating: adding slogan to users') await conn.query("ALTER TABLE users ADD COLUMN slogan VARCHAR(100) DEFAULT '记账小能手' COMMENT '个性签名' AFTER avatar_url") } // 系统配置表 const hasSysConfig = await tableExists(conn, 'sys_config') if (!hasSysConfig) { console.log('[DB] Migrating: creating sys_config table') await conn.query(` CREATE TABLE IF NOT EXISTS sys_config ( config_key VARCHAR(50) PRIMARY KEY COMMENT '配置键', config_value TEXT NOT NULL COMMENT '配置值', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) // 插入默认配置 await conn.query(` INSERT IGNORE INTO sys_config (config_key, config_value) VALUES ('app_version', '1.0.0'), ('app_slogan', '温暖 x 轻松的个人记账小程序'), ('app_description', '让记账从负担变成享受') `) } // 反馈表 const hasFeedbacks = await tableExists(conn, 'feedbacks') if (!hasFeedbacks) { console.log('[DB] Migrating: creating feedbacks table') await conn.query(` CREATE TABLE IF NOT EXISTS feedbacks ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, type ENUM('bug', 'feature', 'ux', 'other') NOT NULL DEFAULT 'other' COMMENT '反馈类型', content TEXT NOT NULL COMMENT '反馈内容', contact VARCHAR(100) DEFAULT '' COMMENT '联系方式', status ENUM('pending', 'processed', 'ignored') DEFAULT 'pending' COMMENT '处理状态', admin_reply TEXT DEFAULT '' COMMENT '管理员回复', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_user (user_id), INDEX idx_status (status), INDEX idx_created (created_at), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) } } export async function initDatabase() { const dbName = process.env.DB_NAME || 'xiaocai' // 先用不指定数据库的连接,确保数据库存在 const bootstrap = await mysql.createConnection({ host: process.env.DB_HOST || 'localhost', user: process.env.DB_USER || 'xiaocai', password: process.env.DB_PASSWORD || 'xiaocai123', multipleStatements: true, }) await bootstrap.query(`CREATE DATABASE IF NOT EXISTS \`${dbName}\` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`) await bootstrap.end() // 用独立连接执行建表和填充(需要 multipleStatements) const initConn = await mysql.createConnection({ host: process.env.DB_HOST || 'localhost', user: process.env.DB_USER || 'xiaocai', password: process.env.DB_PASSWORD || 'xiaocai123', database: dbName, multipleStatements: true, }) try { const schemaPath = join(__dirname, 'schema.sql') const seedPath = join(__dirname, 'seed.sql') const schema = readFileSync(schemaPath, 'utf-8') const cleanSchema = schema .replace(/CREATE DATABASE.*?;/gi, '') .replace(/USE\s+\w+;/gi, '') await initConn.query(cleanSchema) const seed = readFileSync(seedPath, 'utf-8') await initConn.query(seed) console.log('[DB] Schema and seed applied') } catch (err: any) { if (err.code === 'ER_DUP_ENTRY' || err.errno === 1062) { console.log('[DB] Already initialized, skipping seed') } else { console.error('[DB] Init warning:', err.message) } } // 始终运行迁移(独立于 schema/seed) try { await runMigrations(initConn) console.log('[DB] Migrations complete') } catch (err: any) { console.error('[DB] Migration error:', err.message) } await initConn.end() console.log('[DB] Database ready') }