132 lines
5.9 KiB
TypeScript
132 lines
5.9 KiB
TypeScript
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 { sanitizeRichText } from './rich-text'
|
|
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<string, any>
|
|
if (document.hero?.backgroundImage === '/images/hero-bg-v1.png') {
|
|
document.hero.backgroundImage = '/images/hero-bg-v3.webp'
|
|
}
|
|
if (Array.isArray(document.products)) {
|
|
document.products = document.products.map((product: Record<string, any>, 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: sanitizeRichText(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.articles = document.articles.map((article: Record<string, any>) => ({
|
|
...article,
|
|
content: sanitizeRichText(article.content || ''),
|
|
}))
|
|
document.team ??= structuredClone(defaultSiteDocument.team)
|
|
document.team = document.team.map((member: Record<string, any>) => ({
|
|
...member,
|
|
summary: sanitizeRichText(member.summary || ''),
|
|
}))
|
|
document.channels ??= structuredClone(defaultSiteDocument.channels)
|
|
document.legal ??= structuredClone(defaultSiteDocument.legal)
|
|
document.brand = { ...defaultSiteDocument.brand, ...(document.brand || {}) }
|
|
document.teacher = { ...defaultSiteDocument.teacher, ...(document.teacher || {}) }
|
|
return document
|
|
}
|