import Database from 'better-sqlite3'; import path from 'path'; import fs from 'fs'; import { fileURLToPath } from 'url'; import { randomBytes, scryptSync } from 'crypto'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const dataDir = path.resolve(__dirname, '..', 'data'); fs.mkdirSync(dataDir, { recursive: true }); export const db = new Database(path.join(dataDir, 'app.db')); db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); db.exec(` CREATE TABLE IF NOT EXISTS operation_groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, status TEXT NOT NULL DEFAULT 'active', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, group_id INTEGER, username TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', must_change_password INTEGER NOT NULL DEFAULT 1, last_login_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (group_id) REFERENCES operation_groups(id) ON DELETE RESTRICT ); CREATE UNIQUE INDEX IF NOT EXISTS one_group_admin_per_group ON users(group_id) WHERE role = 'group_admin'; CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS customer_sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id INTEGER NOT NULL, reviewer_name TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS audit_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, group_id INTEGER, user_id INTEGER, action TEXT NOT NULL, entity_type TEXT NOT NULL, entity_id INTEGER, detail TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS api_keys ( id INTEGER PRIMARY KEY AUTOINCREMENT, group_id INTEGER, project_id INTEGER, name TEXT NOT NULL, key_prefix TEXT NOT NULL UNIQUE, key_hash TEXT NOT NULL UNIQUE, scope TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', created_by INTEGER NOT NULL, last_used_at TEXT, revoked_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (group_id) REFERENCES operation_groups(id) ON DELETE RESTRICT, FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE RESTRICT ); CREATE TABLE IF NOT EXISTS storage_configs ( id INTEGER PRIMARY KEY AUTOINCREMENT, provider TEXT NOT NULL DEFAULT 'tencent_cos', region TEXT NOT NULL, bucket TEXT NOT NULL, public_base_url TEXT NOT NULL DEFAULT '', cdn_domain TEXT NOT NULL DEFAULT '', path_prefix TEXT NOT NULL DEFAULT 'delivery-desk', secret_id_encrypted TEXT NOT NULL, secret_key_encrypted TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'draft', test_status TEXT NOT NULL DEFAULT 'untested', test_message TEXT NOT NULL DEFAULT '', last_tested_at TEXT, created_by INTEGER NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), activated_at TEXT, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE RESTRICT ); CREATE TABLE IF NOT EXISTS projects ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, client_description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'active', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS collections ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id INTEGER NOT NULL, name TEXT NOT NULL, client_description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'reviewing', created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, external_id TEXT, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS images ( id INTEGER PRIMARY KEY AUTOINCREMENT, note_id INTEGER NOT NULL, url TEXT NOT NULL, width INTEGER NOT NULL DEFAULT 0, height INTEGER NOT NULL DEFAULT 0, order_index INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS annotations ( id INTEGER PRIMARY KEY AUTOINCREMENT, image_id INTEGER NOT NULL, x REAL NOT NULL, y REAL NOT NULL, content TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS work_comments ( id INTEGER PRIMARY KEY AUTOINCREMENT, note_id INTEGER NOT NULL, content TEXT NOT NULL, author_name TEXT NOT NULL DEFAULT '客户', author_role TEXT NOT NULL DEFAULT 'client', status TEXT NOT NULL DEFAULT 'open', created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS text_annotations ( id INTEGER PRIMARY KEY AUTOINCREMENT, note_id INTEGER NOT NULL, version_number INTEGER NOT NULL, target TEXT NOT NULL, content TEXT NOT NULL, author_name TEXT NOT NULL DEFAULT '客户', status TEXT NOT NULL DEFAULT 'open', created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS work_versions ( id INTEGER PRIMARY KEY AUTOINCREMENT, note_id INTEGER NOT NULL, version_number INTEGER NOT NULL, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', tags TEXT NOT NULL DEFAULT '[]', review_status TEXT NOT NULL DEFAULT 'pending', created_by INTEGER, created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(note_id, version_number), FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS review_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, note_id INTEGER NOT NULL, version_number INTEGER NOT NULL, event_type TEXT NOT NULL, from_status TEXT, to_status TEXT NOT NULL, reason TEXT NOT NULL DEFAULT '', actor_name TEXT NOT NULL, actor_role TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE ); `); function addColumn(table: string, definition: string) { const name = definition.split(/\s+/)[0]; const columns = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]; if (!columns.some((column) => column.name === name)) { db.exec(`ALTER TABLE ${table} ADD COLUMN ${definition}`); } } addColumn('notes', "collection_id INTEGER"); addColumn('notes', 'external_id TEXT'); addColumn('notes', "tags TEXT NOT NULL DEFAULT '[]'"); addColumn('notes', "review_status TEXT NOT NULL DEFAULT 'pending'"); addColumn('notes', 'version_number INTEGER NOT NULL DEFAULT 1'); addColumn('annotations', "author_name TEXT NOT NULL DEFAULT '客户'"); addColumn('annotations', "status TEXT NOT NULL DEFAULT 'open'"); addColumn('projects', 'group_id INTEGER'); addColumn('projects', "access_password_hash TEXT NOT NULL DEFAULT ''"); addColumn('projects', 'customer_access_enabled INTEGER NOT NULL DEFAULT 0'); addColumn('projects', 'access_expires_at TEXT'); addColumn('images', "storage_provider TEXT NOT NULL DEFAULT 'local'"); addColumn('images', "storage_key TEXT NOT NULL DEFAULT ''"); addColumn('images', 'version_number INTEGER NOT NULL DEFAULT 1'); addColumn('users', 'last_login_at TEXT'); db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_collection_external_id ON notes(collection_id, external_id) WHERE external_id IS NOT NULL AND external_id != ''"); const seedGroup = db.prepare('SELECT id FROM operation_groups ORDER BY id LIMIT 1').get() as { id: number } | undefined; let groupId = seedGroup?.id; if (!groupId) { groupId = Number(db.prepare('INSERT INTO operation_groups (name) VALUES (?)').run('默认运营组').lastInsertRowid); } function passwordHash(password: string): string { const salt = randomBytes(16).toString('hex'); return `${salt}:${scryptSync(password, salt, 64).toString('hex')}`; } const userCount = (db.prepare('SELECT COUNT(*) AS count FROM users').get() as { count: number }).count; if (userCount === 0) { const insert = db.prepare('INSERT INTO users (group_id, username, display_name, password_hash, role) VALUES (?, ?, ?, ?, ?)'); insert.run(null, 'admin', '平台管理员', passwordHash(process.env.INITIAL_ADMIN_PASSWORD || 'ChangeMe123!'), 'platform_admin'); insert.run(groupId, 'manager', '组管理员', passwordHash(process.env.INITIAL_MANAGER_PASSWORD || 'Manager123!'), 'group_admin'); insert.run(groupId, 'operator', '光影叙事', passwordHash(process.env.INITIAL_OPERATOR_PASSWORD || 'Operator123!'), 'operator'); } db.prepare("UPDATE operation_groups SET name = '默认运营组' WHERE name IN ('默认内容团队', '默认创作空间')").run(); db.prepare("UPDATE users SET display_name = '组管理员' WHERE username = 'manager' AND display_name IN ('团队管理员', '空间管理员')").run(); db.prepare("UPDATE users SET display_name = '光影叙事' WHERE username = 'operator' AND display_name IN ('内容运营', '内容专员', '内容编辑')").run(); const seedProject = db.prepare('SELECT id FROM projects ORDER BY id LIMIT 1').get() as { id: number } | undefined; let projectId = seedProject?.id; if (!projectId) { projectId = Number(db.prepare(`INSERT INTO projects (name, slug, client_description) VALUES (?, ?, ?)`) .run('光影内容计划', 'light-notes', '阶段作品、反馈与验收记录').lastInsertRowid); } const seedCollection = db.prepare('SELECT id FROM collections WHERE project_id = ? ORDER BY id LIMIT 1').get(projectId) as { id: number } | undefined; let collectionId = seedCollection?.id; if (!collectionId) { collectionId = Number(db.prepare(`INSERT INTO collections (project_id, name, client_description) VALUES (?, ?, ?)`) .run(projectId, '2026 年 7 月任务', '本月内容作品交付集').lastInsertRowid); } db.prepare('UPDATE notes SET collection_id = ? WHERE collection_id IS NULL').run(collectionId); db.prepare('UPDATE projects SET group_id = ? WHERE group_id IS NULL').run(groupId); db.prepare(`UPDATE collections SET name = '2026 年 7 月任务', client_description = '本月内容作品交付集' WHERE project_id = (SELECT id FROM projects WHERE slug = 'light-notes') AND (name LIKE '%?%' OR client_description LIKE '%?%')`).run(); db.prepare(`INSERT OR IGNORE INTO work_versions (note_id, version_number, title, description, tags, review_status) SELECT id, version_number, title, description, tags, review_status FROM notes`).run(); db.exec(` CREATE INDEX IF NOT EXISTS idx_collections_project_id ON collections(project_id); CREATE INDEX IF NOT EXISTS idx_notes_collection_id ON notes(collection_id); CREATE INDEX IF NOT EXISTS idx_images_note_id ON images(note_id); CREATE INDEX IF NOT EXISTS idx_annotations_image_id ON annotations(image_id); CREATE INDEX IF NOT EXISTS idx_comments_note_id ON work_comments(note_id); CREATE INDEX IF NOT EXISTS idx_text_annotations_note_id ON text_annotations(note_id, version_number); CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash); CREATE INDEX IF NOT EXISTS idx_customer_sessions_token_hash ON customer_sessions(token_hash); CREATE INDEX IF NOT EXISTS idx_customer_sessions_project_id ON customer_sessions(project_id); CREATE INDEX IF NOT EXISTS idx_audit_group_id ON audit_logs(group_id); CREATE INDEX IF NOT EXISTS idx_api_keys_group_id ON api_keys(group_id); CREATE INDEX IF NOT EXISTS idx_api_keys_project_id ON api_keys(project_id); CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash); CREATE INDEX IF NOT EXISTS idx_storage_configs_status ON storage_configs(status); CREATE INDEX IF NOT EXISTS idx_work_versions_note_id ON work_versions(note_id, version_number); CREATE INDEX IF NOT EXISTS idx_review_events_note_id ON review_events(note_id, version_number); `); export default db;