Files
xiaocai/server/src/db/init.ts
wangxiaogang 263a7c4616 feat(iter-v2): T01 complete - security + infrastructure
- S1: .githooks/pre-commit blocks .env commits, .env.test in .gitignore
- S3: TOKEN_SECRET centralized to config/token.ts, all files import from it
- S3: checkTokenSecret enforces min 32-char TOKEN_SECRET at startup
- S5: Refresh Token mechanism (Access 2h + Refresh 30d, dual token)
- S5: POST /auth/refresh endpoint with rotation and max 5 per user
- S5: Frontend request.ts auto-refresh on 40101, concurrent-safe
- S6: Soft delete (deleted_at column), admin soft-delete with confirmName
- S6: POST /admin/users/:id/restore endpoint
- S6: All user queries filter deleted_at IS NULL
- S7: DB connection requires env vars, no fallback credentials
- Infra: jest.config.js, vitest.config.ts, .env.test
- Infra: node-cron, jest, ts-jest, supertest, vitest, @pinia/testing
2026-06-11 09:24:10 +08:00

404 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
// 标签表
const hasTags = await tableExists(conn, 'tags')
if (!hasTags) {
console.log('[DB] Migrating: creating tags table')
await conn.query(`
CREATE TABLE IF NOT EXISTS tags (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
name VARCHAR(20) NOT NULL COMMENT '标签名称',
color VARCHAR(20) NOT NULL DEFAULT '#FF8C69' COMMENT '标签颜色',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_name (user_id, name),
INDEX idx_user (user_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
}
// 交易-标签关联表
const hasTransactionTags = await tableExists(conn, 'transaction_tags')
if (!hasTransactionTags) {
console.log('[DB] Migrating: creating transaction_tags table')
await conn.query(`
CREATE TABLE IF NOT EXISTS transaction_tags (
transaction_id INT NOT NULL,
tag_id INT NOT NULL,
PRIMARY KEY (transaction_id, tag_id),
INDEX idx_tag (tag_id),
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
}
// users 表添加软删除字段
const hasDeletedAt = await columnExists(conn, 'users', 'deleted_at')
if (!hasDeletedAt) {
console.log('[DB] Migrating: adding deleted_at to users')
await conn.query("ALTER TABLE users ADD COLUMN deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间NULL=正常)'")
await conn.query('ALTER TABLE users ADD INDEX idx_users_deleted (deleted_at)')
console.log('[DB] users.deleted_at added')
}
// Refresh Token 表
const hasRefreshTokens = await tableExists(conn, 'refresh_tokens')
if (!hasRefreshTokens) {
console.log('[DB] Migrating: creating refresh_tokens table')
await conn.query(`
CREATE TABLE IF NOT EXISTS refresh_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token_hash VARCHAR(64) NOT NULL COMMENT 'SHA-256(token)',
expires_at DATETIME NOT NULL COMMENT '过期时间',
revoked_at DATETIME DEFAULT NULL COMMENT '撤销时间NULL=有效)',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_hash (token_hash),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
console.log('[DB] refresh_tokens table created')
}
}
export async function initDatabase() {
const dbName = process.env.DB_NAME!
const dbHost = process.env.DB_HOST!
const dbUser = process.env.DB_USER!
const dbPassword = process.env.DB_PASSWORD!
// 先用不指定数据库的连接,确保数据库存在
const bootstrap = await mysql.createConnection({
host: dbHost,
user: dbUser,
password: dbPassword,
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: dbHost,
user: dbUser,
password: dbPassword,
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')
}