338 lines
14 KiB
TypeScript
338 lines
14 KiB
TypeScript
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<boolean> {
|
||
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<boolean> {
|
||
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_name', '小菜记账'),
|
||
('app_version', '1.0.0'),
|
||
('app_slogan', '温暖 x 轻松的个人记账小程序'),
|
||
('app_description', '让记账从负担变成享受')
|
||
`)
|
||
}
|
||
|
||
// 确保 app_name 配置存在(兼容已有数据库)
|
||
const [appNameRows] = await conn.query(
|
||
"SELECT COUNT(*) as cnt FROM sys_config WHERE config_key = 'app_name'"
|
||
)
|
||
if ((appNameRows as any[])[0].cnt === 0) {
|
||
await conn.query("INSERT INTO sys_config (config_key, config_value) VALUES ('app_name', '小菜记账')")
|
||
}
|
||
|
||
// 反馈表
|
||
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 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
|
||
`)
|
||
}
|
||
|
||
// 埋点事件表
|
||
const hasTrackEvents = await tableExists(conn, 'track_events')
|
||
if (!hasTrackEvents) {
|
||
console.log('[DB] Migrating: creating track_events table')
|
||
await conn.query(`
|
||
CREATE TABLE IF NOT EXISTS track_events (
|
||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||
event VARCHAR(50) NOT NULL COMMENT '事件类型',
|
||
page VARCHAR(100) DEFAULT '' COMMENT '页面路径',
|
||
data JSON DEFAULT NULL COMMENT '事件数据',
|
||
user_id INT DEFAULT NULL COMMENT '用户ID',
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
INDEX idx_event (event),
|
||
INDEX idx_created (created_at),
|
||
INDEX idx_user (user_id)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||
`)
|
||
}
|
||
|
||
// 周期账单模板表
|
||
const hasRecurring = await tableExists(conn, 'recurring_templates')
|
||
if (!hasRecurring) {
|
||
console.log('[DB] Migrating: creating recurring_templates table')
|
||
await conn.query(`
|
||
CREATE TABLE IF NOT EXISTS recurring_templates (
|
||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||
user_id INT NOT NULL,
|
||
amount INT NOT NULL COMMENT '单位:分',
|
||
type ENUM('expense', 'income') NOT NULL,
|
||
category_id INT NOT NULL,
|
||
note VARCHAR(200) DEFAULT '',
|
||
frequency ENUM('weekly', 'monthly', 'yearly') NOT NULL DEFAULT 'monthly',
|
||
next_date DATE NOT NULL COMMENT '下次生成日期',
|
||
end_date DATE DEFAULT NULL COMMENT '截止日期,NULL=永久',
|
||
is_active TINYINT(1) DEFAULT 1,
|
||
group_id INT DEFAULT NULL,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
INDEX idx_user (user_id),
|
||
INDEX idx_next_date (next_date),
|
||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||
`)
|
||
}
|
||
|
||
// transactions 表添加 recurring_id
|
||
const hasRecurringId = await columnExists(conn, 'transactions', 'recurring_id')
|
||
if (!hasRecurringId) {
|
||
console.log('[DB] Migrating: adding recurring_id to transactions')
|
||
await conn.query("ALTER TABLE transactions ADD COLUMN recurring_id INT DEFAULT NULL COMMENT '关联的周期模板ID' AFTER group_id")
|
||
}
|
||
}
|
||
|
||
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')
|
||
}
|