Norcoo's picture
Add Turso cloud database support for persistent storage
514e876
const sqlite3 = require('sqlite3').verbose();
const { createClient } = require('@libsql/client');
const path = require('path');
// 检查是否使用 Turso(云端数据库)
const USE_TURSO = !!(process.env.TURSO_DATABASE_URL && process.env.TURSO_AUTH_TOKEN);
const DB_PATH = process.env.DB_PATH || './database/fitjourney.db';
let db = null;
let tursoClient = null;
const initDatabase = () => {
return new Promise((resolve, reject) => {
if (USE_TURSO) {
// 使用 Turso 云端数据库
try {
tursoClient = createClient({
url: process.env.TURSO_DATABASE_URL,
authToken: process.env.TURSO_AUTH_TOKEN,
});
console.log('✅ Connected to Turso database');
// 创建表
createTables()
.then(() => resolve())
.catch(reject);
} catch (error) {
console.error('❌ Error connecting to Turso:', error.message);
reject(error);
}
} else {
// 使用本地 SQLite 数据库
const dbDir = path.dirname(DB_PATH);
const fs = require('fs');
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
db = new sqlite3.Database(DB_PATH, (err) => {
if (err) {
console.error('❌ Error opening database:', err.message);
reject(err);
return;
}
console.log('✅ Connected to local SQLite database');
// 创建用户表
createTables()
.then(() => resolve())
.catch(reject);
});
}
});
};
const createTables = () => {
return new Promise((resolve, reject) => {
const createUsersTable = `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
is_verified INTEGER DEFAULT 0,
verification_code TEXT,
verification_expires INTEGER,
reset_token TEXT,
reset_expires INTEGER,
is_activated INTEGER DEFAULT 0,
activation_expires INTEGER,
created_at INTEGER DEFAULT (strftime('%s', 'now')),
updated_at INTEGER DEFAULT (strftime('%s', 'now'))
)
`;
const createTokensTable = `
CREATE TABLE IF NOT EXISTS user_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token_hash TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER DEFAULT (strftime('%s', 'now')),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
`;
const createActivationCodesTable = `
CREATE TABLE IF NOT EXISTS activation_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
days INTEGER NOT NULL DEFAULT 30,
is_used INTEGER DEFAULT 0,
used_by INTEGER,
used_at INTEGER,
created_at INTEGER DEFAULT (strftime('%s', 'now')),
FOREIGN KEY (used_by) REFERENCES users (id) ON DELETE SET NULL
)
`;
// 教练表
const createCoachesTable = `
CREATE TABLE IF NOT EXISTS coaches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
password_plain TEXT,
name TEXT,
phone TEXT,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT (datetime('now', 'localtime')),
updated_at DATETIME DEFAULT (datetime('now', 'localtime'))
)
`;
// 教练邀请码表
const createInviteCodesTable = `
CREATE TABLE IF NOT EXISTS invite_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
coach_id INTEGER NOT NULL,
code TEXT UNIQUE NOT NULL,
is_active INTEGER DEFAULT 1,
created_at INTEGER DEFAULT (strftime('%s', 'now')),
FOREIGN KEY (coach_id) REFERENCES coaches (id) ON DELETE CASCADE
)
`;
// 教练-学员关系表
const createTrainerStudentsTable = `
CREATE TABLE IF NOT EXISTS trainer_students (
id INTEGER PRIMARY KEY AUTOINCREMENT,
coach_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
student_name TEXT,
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (coach_id) REFERENCES coaches (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
UNIQUE(coach_id, user_id)
)
`;
// 用户数据表(用于教练查看学员数据)
const createUserDataTable = `
CREATE TABLE IF NOT EXISTS user_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER UNIQUE NOT NULL,
diet_data TEXT,
sleep_data TEXT,
exercise_data TEXT,
status_data TEXT,
personal_data TEXT,
updated_at DATETIME DEFAULT (datetime('now', 'localtime')),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
`;
// 迁移脚本:为现有的users表添加新列
const migrateUsersTable = () => {
return new Promise((migrateResolve, migrateReject) => {
// 检查是否存在is_activated列
db.all("PRAGMA table_info(users)", (err, columns) => {
if (err) {
console.error('❌ Error checking table info:', err.message);
migrateReject(err);
return;
}
const hasIsActivated = columns.some(col => col.name === 'is_activated');
const hasActivationExpires = columns.some(col => col.name === 'activation_expires');
let migrationCount = 0;
let totalMigrations = 0;
const checkMigrationComplete = () => {
migrationCount++;
if (migrationCount === totalMigrations) {
migrateResolve();
}
};
// 添加is_activated列
if (!hasIsActivated) {
totalMigrations++;
db.run("ALTER TABLE users ADD COLUMN is_activated INTEGER DEFAULT 0", (err) => {
if (err) {
console.error('❌ Error adding is_activated column:', err.message);
migrateReject(err);
return;
}
console.log('✅ Added is_activated column to users table');
checkMigrationComplete();
});
}
// 添加activation_expires列
if (!hasActivationExpires) {
totalMigrations++;
db.run("ALTER TABLE users ADD COLUMN activation_expires INTEGER", (err) => {
if (err) {
console.error('❌ Error adding activation_expires column:', err.message);
migrateReject(err);
return;
}
console.log('✅ Added activation_expires column to users table');
checkMigrationComplete();
});
}
// 如果没有需要迁移的列,直接完成
if (totalMigrations === 0) {
console.log('✅ Users table migration not needed');
migrateResolve();
}
});
});
};
// 迁移脚本:为教练表添加password_plain列
const migrateCoachesTable = () => {
return new Promise((migrateResolve, migrateReject) => {
db.all("PRAGMA table_info(coaches)", (err, columns) => {
if (err) {
console.error('❌ Error checking coaches table info:', err.message);
migrateReject(err);
return;
}
const hasPasswordPlain = columns.some(col => col.name === 'password_plain');
if (!hasPasswordPlain) {
db.run("ALTER TABLE coaches ADD COLUMN password_plain TEXT", (err) => {
if (err) {
console.error('❌ Error adding password_plain column:', err.message);
migrateReject(err);
return;
}
console.log('✅ Added password_plain column to coaches table');
migrateResolve();
});
} else {
console.log('✅ Coaches table migration not needed');
migrateResolve();
}
});
});
};
// 迁移脚本:为trainer_students表添加student_name列
const migrateTrainerStudentsTable = () => {
return new Promise((migrateResolve, migrateReject) => {
db.all("PRAGMA table_info(trainer_students)", (err, columns) => {
if (err) {
console.error('❌ Error checking trainer_students table info:', err.message);
migrateReject(err);
return;
}
const hasStudentName = columns.some(col => col.name === 'student_name');
if (!hasStudentName) {
db.run("ALTER TABLE trainer_students ADD COLUMN student_name TEXT", (err) => {
if (err) {
console.error('❌ Error adding student_name column:', err.message);
migrateReject(err);
return;
}
console.log('✅ Added student_name column to trainer_students table');
migrateResolve();
});
} else {
console.log('✅ Trainer_students table migration not needed');
migrateResolve();
}
});
});
};
const migrateUserDataTable = () => {
return new Promise((migrateResolve, migrateReject) => {
db.all("PRAGMA table_info(user_data)", (err, columns) => {
if (err) {
console.error('❌ Error checking user_data table info:', err.message);
migrateReject(err);
return;
}
const hasStatusData = columns.some(col => col.name === 'status_data');
const hasPersonalData = columns.some(col => col.name === 'personal_data');
let migrationCount = 0;
let totalMigrations = 0;
const checkMigrationComplete = () => {
migrationCount++;
if (migrationCount === totalMigrations) {
migrateResolve();
}
};
if (!hasStatusData) {
totalMigrations++;
}
if (!hasPersonalData) {
totalMigrations++;
}
if (totalMigrations === 0) {
console.log('✅ User_data table migration not needed');
migrateResolve();
return;
}
if (!hasStatusData) {
db.run("ALTER TABLE user_data ADD COLUMN status_data TEXT", (err) => {
if (err) {
console.error('❌ Error adding status_data column:', err.message);
migrateReject(err);
return;
}
console.log('✅ Added status_data column to user_data table');
checkMigrationComplete();
});
}
if (!hasPersonalData) {
db.run("ALTER TABLE user_data ADD COLUMN personal_data TEXT", (err) => {
if (err) {
console.error('❌ Error adding personal_data column:', err.message);
migrateReject(err);
return;
}
console.log('✅ Added personal_data column to user_data table');
checkMigrationComplete();
});
}
});
});
};
// 迁移 user_data 表的时间戳从UTC转换为本地时间(东八区)
const migrateUserDataTimestamp = () => {
return new Promise((migrateResolve, migrateReject) => {
// 更新所有现有记录的 updated_at,将UTC时间转换为东八区时间 (+8小时)
db.run(
`UPDATE user_data
SET updated_at = datetime(updated_at, '+8 hours')
WHERE updated_at IS NOT NULL
AND updated_at NOT LIKE '%+%'`,
(err) => {
if (err) {
console.error('❌ Error migrating user_data timestamps:', err.message);
migrateReject(err);
return;
}
console.log('✅ Migrated user_data timestamps to local time (UTC+8)');
migrateResolve();
}
);
});
};
// 修正 coaches 表的时间戳(如果之前加了 12 小时,现在改为只加 8 小时)
const fixCoachesTimezone = () => {
return new Promise((migrateResolve, migrateReject) => {
// 检查是否有 DATETIME 格式的数据(已经被迁移过的)
db.get(
`SELECT created_at FROM coaches WHERE created_at > '2025-01-01' LIMIT 1`,
(err, row) => {
if (err) {
console.error('❌ Error checking coaches timestamps:', err.message);
migrateReject(err);
return;
}
if (row && row.created_at) {
// 如果时间看起来比正常时间快(即多加了4小时),则修正
// 这里我们减去4小时,将 UTC+12 改为 UTC+8
db.run(
`UPDATE coaches
SET created_at = datetime(created_at, '-4 hours'),
updated_at = datetime(updated_at, '-4 hours')
WHERE created_at > datetime('now', 'localtime', '+1 hour')`,
(err) => {
if (err) {
console.error('❌ Error fixing coaches timezone:', err.message);
migrateReject(err);
return;
}
console.log('✅ Fixed coaches timezone (UTC+12 -> UTC+8)');
migrateResolve();
}
);
} else {
console.log('✅ Coaches timezone fix not needed');
migrateResolve();
}
}
);
});
};
const migrateCoachesTimestamp = () => {
return new Promise((migrateResolve, migrateReject) => {
// 检查coaches表的created_at字段类型
db.all("PRAGMA table_info(coaches)", (err, columns) => {
if (err) {
console.error('❌ Error checking coaches table info:', err.message);
migrateReject(err);
return;
}
const createdAtCol = columns.find(col => col.name === 'created_at');
// 如果created_at是INTEGER类型,需要转换
if (createdAtCol && createdAtCol.type === 'INTEGER') {
console.log('🔄 Migrating coaches table timestamp fields from INTEGER to DATETIME...');
// SQLite不支持直接修改列类型,需要重建表
db.serialize(() => {
// 1. 创建新表
db.run(`
CREATE TABLE coaches_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
password_plain TEXT,
name TEXT,
phone TEXT,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`, (err) => {
if (err) {
console.error('❌ Error creating new coaches table:', err.message);
migrateReject(err);
return;
}
// 2. 复制数据,将INTEGER时间戳转换为DATETIME(服务器已在东八区,使用localtime即可)
db.run(`
INSERT INTO coaches_new (id, username, password_hash, password_plain, name, phone, is_active, created_at, updated_at)
SELECT id, username, password_hash, password_plain, name, phone, is_active,
datetime(created_at, 'unixepoch', 'localtime'),
datetime(updated_at, 'unixepoch', 'localtime')
FROM coaches
`, (err) => {
if (err) {
console.error('❌ Error copying data to new coaches table:', err.message);
migrateReject(err);
return;
}
// 3. 删除旧表
db.run('DROP TABLE coaches', (err) => {
if (err) {
console.error('❌ Error dropping old coaches table:', err.message);
migrateReject(err);
return;
}
// 4. 重命名新表
db.run('ALTER TABLE coaches_new RENAME TO coaches', (err) => {
if (err) {
console.error('❌ Error renaming new coaches table:', err.message);
migrateReject(err);
return;
}
console.log('✅ Coaches table timestamp migration completed');
migrateResolve();
});
});
});
});
});
} else {
console.log('✅ Coaches table timestamp migration not needed');
migrateResolve();
}
});
});
};
// 使用统一的方式执行SQL(支持 Turso 和 SQLite)
const executeSQL = async (sql, errorMsg, successMsg) => {
try {
await runQuery(sql);
console.log(successMsg);
} catch (err) {
console.error(errorMsg, err.message);
throw err;
}
};
// 按顺序创建所有表
(async () => {
try {
await executeSQL(createUsersTable, '❌ Error creating users table:', '✅ Users table ready');
await executeSQL(createTokensTable, '❌ Error creating tokens table:', '✅ Tokens table ready');
await executeSQL(createActivationCodesTable, '❌ Error creating activation codes table:', '✅ Activation codes table ready');
await executeSQL(createCoachesTable, '❌ Error creating coaches table:', '✅ Coaches table ready');
await executeSQL(createInviteCodesTable, '❌ Error creating invite codes table:', '✅ Invite codes table ready');
await executeSQL(createTrainerStudentsTable, '❌ Error creating trainer_students table:', '✅ Trainer students table ready');
await executeSQL(createUserDataTable, '❌ Error creating user_data table:', '✅ User data table ready');
// 只在本地 SQLite 时执行迁移(Turso 不需要)
if (!USE_TURSO) {
await migrateUsersTable();
await migrateCoachesTable();
await migrateTrainerStudentsTable();
await migrateUserDataTable();
await migrateUserDataTimestamp();
await migrateCoachesTimestamp();
await fixCoachesTimezone();
}
console.log('✅ Database migration completed');
resolve();
} catch (error) {
reject(error);
}
})();
});
};
const getDatabase = () => {
if (USE_TURSO) {
if (!tursoClient) {
throw new Error('Turso database not initialized. Call initDatabase() first.');
}
return tursoClient;
} else {
if (!db) {
throw new Error('Database not initialized. Call initDatabase() first.');
}
return db;
}
};
// 辅助函数:执行SQL查询(支持 Turso 和 SQLite)
const runQuery = async (sql, params = []) => {
if (USE_TURSO) {
try {
const result = await tursoClient.execute({ sql, args: params });
return {
id: result.lastInsertRowid ? Number(result.lastInsertRowid) : null,
changes: result.rowsAffected
};
} catch (error) {
throw error;
}
} else {
return new Promise((resolve, reject) => {
db.run(sql, params, function(err) {
if (err) {
reject(err);
} else {
resolve({ id: this.lastID, changes: this.changes });
}
});
});
}
};
const getQuery = async (sql, params = []) => {
if (USE_TURSO) {
try {
const result = await tursoClient.execute({ sql, args: params });
return result.rows[0] || undefined;
} catch (error) {
throw error;
}
} else {
return new Promise((resolve, reject) => {
db.get(sql, params, (err, row) => {
if (err) {
reject(err);
} else {
resolve(row);
}
});
});
}
};
const allQuery = async (sql, params = []) => {
if (USE_TURSO) {
try {
const result = await tursoClient.execute({ sql, args: params });
return result.rows || [];
} catch (error) {
throw error;
}
} else {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => {
if (err) {
reject(err);
} else {
resolve(rows);
}
});
});
}
};
module.exports = {
initDatabase,
getDatabase,
runQuery,
getQuery,
allQuery
};