import Database from 'better-sqlite3' import { hashSync } from 'bcryptjs' import { createHash } from 'node:crypto' import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { defaultSiteDocument, siteDocumentSchema } from './site-schema' let database: Database.Database | undefined const now = () => new Date().toISOString() export function useDatabase() { if (database) return database const config = useRuntimeConfig() const databasePath = resolve(process.cwd(), config.databasePath) mkdirSync(dirname(databasePath), { recursive: true }) database = new Database(databasePath) database.pragma('journal_mode = WAL') database.pragma('foreign_keys = ON') database.pragma('busy_timeout = 5000') database.exec(` CREATE TABLE IF NOT EXISTS schema_migrations ( filename TEXT PRIMARY KEY, checksum TEXT NOT NULL, applied_at TEXT NOT NULL ) `) const migrationDir = resolve(process.cwd(), 'server/database/migrations') const migrations = readdirSync(migrationDir).filter(name => name.endsWith('.sql')).sort().map((filename) => { const sql = readFileSync(resolve(migrationDir, filename), 'utf8') return { filename, sql, checksum: createHash('sha256').update(sql).digest('hex') } }) const applied = new Map((database.prepare('SELECT filename, checksum FROM schema_migrations').all() as Array<{ filename: string; checksum: string }>).map(row => [row.filename, row.checksum])) for (const migration of migrations) { const recordedChecksum = applied.get(migration.filename) if (recordedChecksum && recordedChecksum !== migration.checksum) throw new Error(`Migration ${migration.filename} was modified after being applied`) } const pending = migrations.filter(migration => !applied.has(migration.filename)) if (pending.length && existsSync(databasePath) && statSync(databasePath).size > 0) { database.pragma('wal_checkpoint(TRUNCATE)') copyFileSync(databasePath, `${databasePath}.pre-migration-${Date.now()}.bak`) } const applyMigration = database.transaction((migration: typeof migrations[number]) => { database!.exec(migration.sql) database!.prepare('INSERT INTO schema_migrations (filename, checksum, applied_at) VALUES (?, ?, ?)').run(migration.filename, migration.checksum, now()) }) pending.forEach(migration => applyMigration(migration)) seedInitialData(database) return database } function seedInitialData(db: Database.Database) { const timestamp = now() const siteExists = db.prepare('SELECT 1 FROM site_documents WHERE document_key = ?').get('website') if (!siteExists) { const json = JSON.stringify(defaultSiteDocument) db.prepare(` INSERT INTO site_documents (document_key, draft_json, published_json, updated_at, published_at) VALUES (?, ?, ?, ?, ?) `).run('website', json, json, timestamp, timestamp) } const userCount = db.prepare('SELECT COUNT(*) AS count FROM admin_users').get() as { count: number } if (userCount.count === 0) { const config = useRuntimeConfig() const password = config.initialAdminPassword || (import.meta.dev ? 'DevOnly-ChangeMe-2026!' : '') if (!password) { throw new Error('NUXT_INITIAL_ADMIN_PASSWORD must be set before the first production start') } db.prepare(` INSERT INTO admin_users (username, password_hash, display_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?) `).run(config.initialAdminUsername, hashSync(password, 12), '超级管理员', timestamp, timestamp) } } export function readSiteDocument(mode: 'draft' | 'published' = 'published') { const db = useDatabase() const row = db.prepare('SELECT draft_json, published_json, updated_at, published_at FROM site_documents WHERE document_key = ?') .get('website') as { draft_json: string; published_json: string; updated_at: string; published_at: string } return { data: siteDocumentSchema.parse(normalizeLegacyDocument(JSON.parse(mode === 'draft' ? row.draft_json : row.published_json))), updatedAt: row.updated_at, publishedAt: row.published_at, } } function normalizeLegacyDocument(input: unknown) { if (!input || typeof input !== 'object') return input const document = structuredClone(input) as Record if (Array.isArray(document.products)) { document.products = document.products.map((product: Record, index: number) => { const fallback = defaultSiteDocument.products[index] const slug = product.slug || fallback?.slug || `product-${index + 1}` return { slug, title: product.title || fallback?.title || `产品 ${index + 1}`, subtitle: product.subtitle || '', type: product.type || fallback?.type || 'course', summary: product.summary || fallback?.summary || '', content: product.content || fallback?.content || '', image: product.image || fallback?.image || '/images/hero-bg-v3.webp', href: product.href || `/products/${slug}`, status: product.status || 'published', } }) } document.articles ??= structuredClone(defaultSiteDocument.articles) document.team ??= structuredClone(defaultSiteDocument.team) document.channels ??= structuredClone(defaultSiteDocument.channels) document.legal ??= structuredClone(defaultSiteDocument.legal) document.brand = { ...defaultSiteDocument.brand, ...(document.brand || {}) } document.teacher = { ...defaultSiteDocument.teacher, ...(document.teacher || {}) } return document }