import { mkdir } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; export const relationalTables = [ 'schema_metadata', 'organization', 'users', 'candidate_profiles', 'notices', 'exams', 'exam_subjects', 'registrations', 'registration_subjects', 'admit_cards', 'results', 'audit_logs' ]; const sqliteSchema = ` PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS schema_metadata ( id INTEGER PRIMARY KEY CHECK (id = 1), schema_version INTEGER NOT NULL DEFAULT 1, app_version INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS organization ( id INTEGER PRIMARY KEY CHECK (id = 1), name TEXT NOT NULL, code TEXT NOT NULL, phone TEXT NOT NULL, address TEXT NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('admin', 'candidate')), display_name TEXT NOT NULL, created_at TEXT NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS candidate_profiles ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, gender TEXT, id_number TEXT NOT NULL UNIQUE, phone TEXT NOT NULL, email TEXT, school TEXT, grade TEXT, address TEXT, emergency_contact TEXT, emergency_phone TEXT, status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), review_note TEXT, reviewed_at TEXT, reviewer_id TEXT REFERENCES users(id) ON DELETE SET NULL, updated_at TEXT NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS notices ( id TEXT PRIMARY KEY, title TEXT NOT NULL, summary TEXT NOT NULL, content TEXT NOT NULL, category TEXT NOT NULL, pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0, 1)), status TEXT NOT NULL CHECK (status IN ('draft', 'published')), publish_at TEXT, created_at TEXT, author TEXT NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS exams ( id TEXT PRIMARY KEY, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, description TEXT NOT NULL, registration_start TEXT NOT NULL, registration_end TEXT NOT NULL, exam_start TEXT NOT NULL, exam_end TEXT NOT NULL, admit_download_start TEXT NOT NULL, admit_download_end TEXT NOT NULL, location TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('draft', 'published', 'closed')), created_at TEXT NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS exam_subjects ( id TEXT PRIMARY KEY, exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE, name TEXT NOT NULL, subject_date TEXT NOT NULL, start_time TEXT NOT NULL, end_time TEXT NOT NULL, fee REAL NOT NULL DEFAULT 0, position INTEGER NOT NULL, UNIQUE (exam_id, position) ) STRICT; CREATE TABLE IF NOT EXISTS registrations ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE, status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')), payment_status TEXT NOT NULL CHECK (payment_status IN ('unpaid', 'paid', 'refunded')), created_at TEXT NOT NULL, reviewed_at TEXT, review_note TEXT, UNIQUE (user_id, exam_id) ) STRICT; CREATE TABLE IF NOT EXISTS registration_subjects ( registration_id TEXT NOT NULL REFERENCES registrations(id) ON DELETE CASCADE, subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE, PRIMARY KEY (registration_id, subject_id) ) STRICT; CREATE TABLE IF NOT EXISTS admit_cards ( registration_id TEXT PRIMARY KEY REFERENCES registrations(id) ON DELETE CASCADE, card_number TEXT NOT NULL UNIQUE, test_center TEXT NOT NULL, room TEXT NOT NULL, seat TEXT NOT NULL, generated_at TEXT NOT NULL ) STRICT; CREATE TABLE IF NOT EXISTS results ( id TEXT PRIMARY KEY, registration_id TEXT NOT NULL REFERENCES registrations(id) ON DELETE CASCADE, subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE, score REAL NOT NULL CHECK (score >= 0 AND score <= 150), grade TEXT NOT NULL, published INTEGER NOT NULL DEFAULT 0 CHECK (published IN (0, 1)), updated_at TEXT, published_at TEXT, UNIQUE (registration_id, subject_id) ) STRICT; CREATE TABLE IF NOT EXISTS audit_logs ( id TEXT PRIMARY KEY, actor_id TEXT REFERENCES users(id) ON DELETE SET NULL, action TEXT NOT NULL, detail TEXT NOT NULL, created_at TEXT NOT NULL ) STRICT; CREATE INDEX IF NOT EXISTS idx_profiles_status ON candidate_profiles(status); CREATE INDEX IF NOT EXISTS idx_notices_status_publish ON notices(status, publish_at); CREATE INDEX IF NOT EXISTS idx_exams_status_registration ON exams(status, registration_start, registration_end); CREATE INDEX IF NOT EXISTS idx_subjects_exam ON exam_subjects(exam_id, position); CREATE INDEX IF NOT EXISTS idx_registrations_status ON registrations(status); CREATE INDEX IF NOT EXISTS idx_registrations_exam ON registrations(exam_id); CREATE INDEX IF NOT EXISTS idx_results_registration ON results(registration_id, published); CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at); `; const mysqlSchema = [ `CREATE TABLE IF NOT EXISTS schema_metadata ( id TINYINT UNSIGNED NOT NULL, schema_version INT UNSIGNED NOT NULL DEFAULT 1, app_version INT UNSIGNED NOT NULL DEFAULT 1, created_at VARCHAR(35) NOT NULL, PRIMARY KEY (id), CONSTRAINT chk_schema_metadata_singleton CHECK (id = 1) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS organization ( id TINYINT UNSIGNED NOT NULL, name VARCHAR(120) NOT NULL, code VARCHAR(60) NOT NULL, phone VARCHAR(60) NOT NULL, address VARCHAR(255) NOT NULL, PRIMARY KEY (id), CONSTRAINT chk_organization_singleton CHECK (id = 1) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS users ( id VARCHAR(64) NOT NULL, username VARCHAR(100) NOT NULL, password_hash VARCHAR(255) NOT NULL, role ENUM('admin', 'candidate') NOT NULL, display_name VARCHAR(100) NOT NULL, created_at VARCHAR(35) NOT NULL, PRIMARY KEY (id), UNIQUE KEY uq_users_username (username) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS candidate_profiles ( id VARCHAR(64) NOT NULL, user_id VARCHAR(64) NOT NULL, name VARCHAR(100) NOT NULL, gender VARCHAR(20) NULL, id_number VARCHAR(60) NOT NULL, phone VARCHAR(60) NOT NULL, email VARCHAR(160) NULL, school VARCHAR(160) NULL, grade VARCHAR(100) NULL, address VARCHAR(255) NULL, emergency_contact VARCHAR(100) NULL, emergency_phone VARCHAR(60) NULL, status ENUM('pending', 'approved', 'rejected') NOT NULL, review_note VARCHAR(500) NULL, reviewed_at VARCHAR(35) NULL, reviewer_id VARCHAR(64) NULL, updated_at VARCHAR(35) NOT NULL, PRIMARY KEY (id), UNIQUE KEY uq_profiles_user (user_id), UNIQUE KEY uq_profiles_id_number (id_number), KEY idx_profiles_status (status), CONSTRAINT fk_profiles_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT fk_profiles_reviewer FOREIGN KEY (reviewer_id) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS notices ( id VARCHAR(64) NOT NULL, title VARCHAR(240) NOT NULL, summary VARCHAR(500) NOT NULL, content TEXT NOT NULL, category VARCHAR(60) NOT NULL, pinned BOOLEAN NOT NULL DEFAULT FALSE, status ENUM('draft', 'published') NOT NULL, publish_at VARCHAR(35) NULL, created_at VARCHAR(35) NULL, author VARCHAR(100) NOT NULL, PRIMARY KEY (id), KEY idx_notices_status_publish (status, publish_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS exams ( id VARCHAR(64) NOT NULL, code VARCHAR(60) NOT NULL, name VARCHAR(200) NOT NULL, description TEXT NOT NULL, registration_start VARCHAR(35) NOT NULL, registration_end VARCHAR(35) NOT NULL, exam_start VARCHAR(35) NOT NULL, exam_end VARCHAR(35) NOT NULL, admit_download_start VARCHAR(35) NOT NULL, admit_download_end VARCHAR(35) NOT NULL, location VARCHAR(200) NOT NULL, status ENUM('draft', 'published', 'closed') NOT NULL, created_at VARCHAR(35) NOT NULL, PRIMARY KEY (id), UNIQUE KEY uq_exams_code (code), KEY idx_exams_status_registration (status, registration_start, registration_end) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS exam_subjects ( id VARCHAR(64) NOT NULL, exam_id VARCHAR(64) NOT NULL, name VARCHAR(100) NOT NULL, subject_date VARCHAR(35) NOT NULL, start_time VARCHAR(20) NOT NULL, end_time VARCHAR(20) NOT NULL, fee DOUBLE NOT NULL DEFAULT 0, position INT UNSIGNED NOT NULL, PRIMARY KEY (id), UNIQUE KEY uq_subjects_exam_position (exam_id, position), KEY idx_subjects_exam (exam_id, position), CONSTRAINT fk_subjects_exam FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS registrations ( id VARCHAR(64) NOT NULL, user_id VARCHAR(64) NOT NULL, exam_id VARCHAR(64) NOT NULL, status ENUM('pending', 'approved', 'rejected') NOT NULL, payment_status ENUM('unpaid', 'paid', 'refunded') NOT NULL, created_at VARCHAR(35) NOT NULL, reviewed_at VARCHAR(35) NULL, review_note VARCHAR(500) NULL, PRIMARY KEY (id), UNIQUE KEY uq_registrations_user_exam (user_id, exam_id), KEY idx_registrations_status (status), KEY idx_registrations_exam (exam_id), CONSTRAINT fk_registrations_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT fk_registrations_exam FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS registration_subjects ( registration_id VARCHAR(64) NOT NULL, subject_id VARCHAR(64) NOT NULL, PRIMARY KEY (registration_id, subject_id), CONSTRAINT fk_registration_subjects_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE, CONSTRAINT fk_registration_subjects_subject FOREIGN KEY (subject_id) REFERENCES exam_subjects(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS admit_cards ( registration_id VARCHAR(64) NOT NULL, card_number VARCHAR(100) NOT NULL, test_center VARCHAR(200) NOT NULL, room VARCHAR(100) NOT NULL, seat VARCHAR(30) NOT NULL, generated_at VARCHAR(35) NOT NULL, PRIMARY KEY (registration_id), UNIQUE KEY uq_admit_cards_number (card_number), CONSTRAINT fk_admit_cards_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS results ( id VARCHAR(64) NOT NULL, registration_id VARCHAR(64) NOT NULL, subject_id VARCHAR(64) NOT NULL, score DOUBLE NOT NULL, grade VARCHAR(20) NOT NULL, published BOOLEAN NOT NULL DEFAULT FALSE, updated_at VARCHAR(35) NULL, published_at VARCHAR(35) NULL, PRIMARY KEY (id), UNIQUE KEY uq_results_registration_subject (registration_id, subject_id), KEY idx_results_registration (registration_id, published), CONSTRAINT chk_results_score CHECK (score >= 0 AND score <= 150), CONSTRAINT fk_results_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE, CONSTRAINT fk_results_subject FOREIGN KEY (subject_id) REFERENCES exam_subjects(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`, `CREATE TABLE IF NOT EXISTS audit_logs ( id VARCHAR(64) NOT NULL, actor_id VARCHAR(64) NULL, action VARCHAR(100) NOT NULL, detail VARCHAR(1000) NOT NULL, created_at VARCHAR(35) NOT NULL, PRIMARY KEY (id), KEY idx_audit_created (created_at), CONSTRAINT fk_audit_actor FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci` ]; function validateState(state, source = '数据库') { const collections = ['users', 'candidateProfiles', 'notices', 'exams', 'registrations', 'results', 'auditLogs']; if (!state || typeof state !== 'object' || collections.some(name => !Array.isArray(state[name]))) { throw new Error(`${source}中的应用数据格式无效`); } return state; } function buildSeedOperations(state) { validateState(state); const operations = []; const add = (sql, ...params) => operations.push({ sql, params }); const nullable = value => value == null || value === '' ? null : value; add( 'UPDATE schema_metadata SET schema_version = 1, app_version = ?, created_at = ? WHERE id = 1', Number(state.meta?.version || 1), state.meta?.createdAt || new Date().toISOString() ); add( 'INSERT INTO organization (id, name, code, phone, address) VALUES (1, ?, ?, ?, ?)', state.organization?.name || '', state.organization?.code || '', state.organization?.phone || '', state.organization?.address || '' ); for (const user of state.users) { add( 'INSERT INTO users (id, username, password_hash, role, display_name, created_at) VALUES (?, ?, ?, ?, ?, ?)', user.id, user.username, user.passwordHash, user.role, user.displayName, user.createdAt ); } for (const profile of state.candidateProfiles) { add( `INSERT INTO candidate_profiles ( id, user_id, name, gender, id_number, phone, email, school, grade, address, emergency_contact, emergency_phone, status, review_note, reviewed_at, reviewer_id, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, profile.id, profile.userId, profile.name, nullable(profile.gender), profile.idNumber, profile.phone, nullable(profile.email), nullable(profile.school), nullable(profile.grade), nullable(profile.address), nullable(profile.emergencyContact), nullable(profile.emergencyPhone), profile.status, nullable(profile.reviewNote), nullable(profile.reviewedAt), nullable(profile.reviewerId), profile.updatedAt ); } for (const notice of state.notices) { add( `INSERT INTO notices ( id, title, summary, content, category, pinned, status, publish_at, created_at, author ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, notice.id, notice.title, notice.summary, notice.content, notice.category, notice.pinned ? 1 : 0, notice.status, nullable(notice.publishAt), nullable(notice.createdAt), notice.author ); } for (const exam of state.exams) { add( `INSERT INTO exams ( id, code, name, description, registration_start, registration_end, exam_start, exam_end, admit_download_start, admit_download_end, location, status, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, exam.id, exam.code, exam.name, exam.description || '', exam.registrationStart, exam.registrationEnd, exam.examStart, exam.examEnd, exam.admitDownloadStart, exam.admitDownloadEnd, exam.location || '', exam.status, exam.createdAt ); exam.subjects.forEach((subject, index) => add( `INSERT INTO exam_subjects ( id, exam_id, name, subject_date, start_time, end_time, fee, position ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, subject.id, exam.id, subject.name, subject.date || String(exam.examStart).slice(0, 10), subject.start || '', subject.end || '', Number(subject.fee || 0), Number(subject.order || index + 1) )); } for (const registration of state.registrations) { add( `INSERT INTO registrations ( id, user_id, exam_id, status, payment_status, created_at, reviewed_at, review_note ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, registration.id, registration.userId, registration.examId, registration.status, registration.paymentStatus, registration.createdAt, nullable(registration.reviewedAt), nullable(registration.reviewNote) ); for (const subjectId of registration.subjectIds) { add('INSERT INTO registration_subjects (registration_id, subject_id) VALUES (?, ?)', registration.id, subjectId); } if (registration.admitCard) { add( `INSERT INTO admit_cards ( registration_id, card_number, test_center, room, seat, generated_at ) VALUES (?, ?, ?, ?, ?, ?)`, registration.id, registration.admitCard.number, registration.admitCard.testCenter, registration.admitCard.room, registration.admitCard.seat, registration.admitCard.generatedAt ); } } for (const result of state.results) { add( `INSERT INTO results ( id, registration_id, subject_id, score, grade, published, updated_at, published_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, result.id, result.registrationId, result.subjectId, Number(result.score), result.grade, result.published ? 1 : 0, nullable(result.updatedAt), nullable(result.publishedAt) ); } for (const log of state.auditLogs) { add( 'INSERT INTO audit_logs (id, actor_id, action, detail, created_at) VALUES (?, ?, ?, ?, ?)', log.id, nullable(log.actorId), log.action, log.detail, log.createdAt ); } return operations; } function stateFromRows(rows) { const subjectsByExam = new Map(); for (const row of rows.subjects) { const subject = { id: row.id, name: row.name, date: row.subject_date, start: row.start_time, end: row.end_time, fee: Number(row.fee), order: Number(row.position) }; const subjects = subjectsByExam.get(row.exam_id) || []; subjects.push(subject); subjectsByExam.set(row.exam_id, subjects); } const registrationSubjects = new Map(); for (const row of rows.registrationSubjects) { const subjectIds = registrationSubjects.get(row.registration_id) || []; subjectIds.push(row.subject_id); registrationSubjects.set(row.registration_id, subjectIds); } const admitCards = new Map(rows.admitCards.map(row => [row.registration_id, { number: row.card_number, testCenter: row.test_center, room: row.room, seat: row.seat, generatedAt: row.generated_at }])); const organization = rows.organization; const state = { meta: { version: Number(rows.system.app_version), createdAt: rows.system.created_at }, organization: { name: organization.name, code: organization.code, phone: organization.phone, address: organization.address }, users: rows.users.map(row => ({ id: row.id, username: row.username, passwordHash: row.password_hash, role: row.role, displayName: row.display_name, createdAt: row.created_at })), candidateProfiles: rows.profiles.map(row => ({ id: row.id, userId: row.user_id, name: row.name, gender: row.gender || '', idNumber: row.id_number, phone: row.phone, email: row.email || '', school: row.school || '', grade: row.grade || '', address: row.address || '', emergencyContact: row.emergency_contact || '', emergencyPhone: row.emergency_phone || '', status: row.status, reviewNote: row.review_note || '', reviewedAt: row.reviewed_at, reviewerId: row.reviewer_id, updatedAt: row.updated_at })), notices: rows.notices.map(row => ({ id: row.id, title: row.title, summary: row.summary, content: row.content, category: row.category, pinned: Boolean(row.pinned), status: row.status, publishAt: row.publish_at, createdAt: row.created_at, author: row.author })), exams: rows.exams.map(row => ({ id: row.id, code: row.code, name: row.name, description: row.description, registrationStart: row.registration_start, registrationEnd: row.registration_end, examStart: row.exam_start, examEnd: row.exam_end, admitDownloadStart: row.admit_download_start, admitDownloadEnd: row.admit_download_end, location: row.location, status: row.status, createdAt: row.created_at, subjects: subjectsByExam.get(row.id) || [] })), registrations: rows.registrations.map(row => ({ id: row.id, userId: row.user_id, examId: row.exam_id, subjectIds: registrationSubjects.get(row.id) || [], status: row.status, paymentStatus: row.payment_status, createdAt: row.created_at, reviewedAt: row.reviewed_at, reviewNote: row.review_note || '', admitCard: admitCards.get(row.id) || null })), results: rows.results.map(row => ({ id: row.id, registrationId: row.registration_id, subjectId: row.subject_id, score: Number(row.score), grade: row.grade, published: Boolean(row.published), updatedAt: row.updated_at, publishedAt: row.published_at })), auditLogs: rows.auditLogs.map(row => ({ id: row.id, actorId: row.actor_id, action: row.action, detail: row.detail, createdAt: row.created_at })) }; return state; } function readSqliteRows(connection) { return { system: connection.prepare('SELECT * FROM schema_metadata WHERE id = 1').get(), organization: connection.prepare('SELECT * FROM organization WHERE id = 1').get(), users: connection.prepare('SELECT * FROM users ORDER BY created_at, id').all(), profiles: connection.prepare('SELECT * FROM candidate_profiles ORDER BY updated_at, id').all(), notices: connection.prepare('SELECT * FROM notices ORDER BY publish_at, created_at, id').all(), exams: connection.prepare('SELECT * FROM exams ORDER BY created_at, id').all(), subjects: connection.prepare('SELECT * FROM exam_subjects ORDER BY exam_id, position, id').all(), registrations: connection.prepare('SELECT * FROM registrations ORDER BY created_at, id').all(), registrationSubjects: connection.prepare('SELECT * FROM registration_subjects ORDER BY registration_id, subject_id').all(), admitCards: connection.prepare('SELECT * FROM admit_cards ORDER BY registration_id').all(), results: connection.prepare('SELECT * FROM results ORDER BY id').all(), auditLogs: connection.prepare('SELECT * FROM audit_logs ORDER BY created_at DESC, id DESC').all() }; } async function readMysqlRows(connection) { const query = async sql => (await connection.execute(sql))[0]; const one = async sql => (await query(sql))[0]; return { system: await one('SELECT * FROM schema_metadata WHERE id = 1'), organization: await one('SELECT * FROM organization WHERE id = 1'), users: await query('SELECT * FROM users ORDER BY created_at, id'), profiles: await query('SELECT * FROM candidate_profiles ORDER BY updated_at, id'), notices: await query('SELECT * FROM notices ORDER BY publish_at, created_at, id'), exams: await query('SELECT * FROM exams ORDER BY created_at, id'), subjects: await query('SELECT * FROM exam_subjects ORDER BY exam_id, position, id'), registrations: await query('SELECT * FROM registrations ORDER BY created_at, id'), registrationSubjects: await query('SELECT * FROM registration_subjects ORDER BY registration_id, subject_id'), admitCards: await query('SELECT * FROM admit_cards ORDER BY registration_id'), results: await query('SELECT * FROM results ORDER BY id'), auditLogs: await query('SELECT * FROM audit_logs ORDER BY created_at DESC, id DESC') }; } function operation(sql, ...params) { return { sql, params }; } function optional(value) { return value == null || value === '' ? null : value; } function auditOperation(log) { return operation( 'INSERT INTO audit_logs (id, actor_id, action, detail, created_at) VALUES (?, ?, ?, ?, ?)', log.id, optional(log.actorId), log.action, log.detail, log.createdAt ); } function createRepository({ client, location, read, transaction, close }) { return { client, location, read, close, async createCandidate(user, profile) { await transaction([ operation( 'INSERT INTO users (id, username, password_hash, role, display_name, created_at) VALUES (?, ?, ?, ?, ?, ?)', user.id, user.username, user.passwordHash, user.role, user.displayName, user.createdAt ), operation( `INSERT INTO candidate_profiles ( id, user_id, name, gender, id_number, phone, email, school, grade, address, emergency_contact, emergency_phone, status, review_note, reviewed_at, reviewer_id, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, profile.id, profile.userId, profile.name, optional(profile.gender), profile.idNumber, profile.phone, optional(profile.email), optional(profile.school), optional(profile.grade), optional(profile.address), optional(profile.emergencyContact), optional(profile.emergencyPhone), profile.status, optional(profile.reviewNote), optional(profile.reviewedAt), optional(profile.reviewerId), profile.updatedAt ) ]); }, async updateCandidateProfile(profile, displayName) { await transaction([ operation( `UPDATE candidate_profiles SET name = ?, gender = ?, id_number = ?, phone = ?, email = ?, school = ?, grade = ?, address = ?, emergency_contact = ?, emergency_phone = ?, status = ?, review_note = ?, reviewed_at = ?, reviewer_id = ?, updated_at = ? WHERE id = ?`, profile.name, optional(profile.gender), profile.idNumber, profile.phone, optional(profile.email), optional(profile.school), optional(profile.grade), optional(profile.address), optional(profile.emergencyContact), optional(profile.emergencyPhone), profile.status, optional(profile.reviewNote), optional(profile.reviewedAt), optional(profile.reviewerId), profile.updatedAt, profile.id ), operation('UPDATE users SET display_name = ? WHERE id = ?', displayName, profile.userId) ]); }, async createRegistration(registration) { const operations = [operation( `INSERT INTO registrations ( id, user_id, exam_id, status, payment_status, created_at, reviewed_at, review_note ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, registration.id, registration.userId, registration.examId, registration.status, registration.paymentStatus, registration.createdAt, optional(registration.reviewedAt), optional(registration.reviewNote) )]; for (const subjectId of registration.subjectIds) { operations.push(operation( 'INSERT INTO registration_subjects (registration_id, subject_id) VALUES (?, ?)', registration.id, subjectId )); } await transaction(operations); }, async reviewCandidate(profile, log) { await transaction([ operation( `UPDATE candidate_profiles SET status = ?, review_note = ?, reviewed_at = ?, reviewer_id = ? WHERE id = ?`, profile.status, optional(profile.reviewNote), profile.reviewedAt, profile.reviewerId, profile.id ), auditOperation(log) ]); }, async reviewRegistration(registration, log) { await transaction([ operation( `UPDATE registrations SET status = ?, payment_status = ?, reviewed_at = ?, review_note = ? WHERE id = ?`, registration.status, registration.paymentStatus, registration.reviewedAt, optional(registration.reviewNote), registration.id ), auditOperation(log) ]); }, async createAdmitCard(registrationId, admitCard, log) { await transaction([ operation( `INSERT INTO admit_cards ( registration_id, card_number, test_center, room, seat, generated_at ) VALUES (?, ?, ?, ?, ?, ?)`, registrationId, admitCard.number, admitCard.testCenter, admitCard.room, admitCard.seat, admitCard.generatedAt ), auditOperation(log) ]); }, async createExam(exam, log) { const operations = [operation( `INSERT INTO exams ( id, code, name, description, registration_start, registration_end, exam_start, exam_end, admit_download_start, admit_download_end, location, status, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, exam.id, exam.code, exam.name, exam.description || '', exam.registrationStart, exam.registrationEnd, exam.examStart, exam.examEnd, exam.admitDownloadStart, exam.admitDownloadEnd, exam.location || '', exam.status, exam.createdAt )]; exam.subjects.forEach((subject, index) => operations.push(operation( `INSERT INTO exam_subjects ( id, exam_id, name, subject_date, start_time, end_time, fee, position ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, subject.id, exam.id, subject.name, subject.date || String(exam.examStart).slice(0, 10), subject.start || '', subject.end || '', Number(subject.fee || 0), Number(subject.order || index + 1) ))); operations.push(auditOperation(log)); await transaction(operations); }, async updateExam(exam, log, replaceSubjects = false) { const operations = [ operation( `UPDATE exams SET code = ?, name = ?, description = ?, registration_start = ?, registration_end = ?, exam_start = ?, exam_end = ?, admit_download_start = ?, admit_download_end = ?, location = ?, status = ? WHERE id = ?`, exam.code, exam.name, exam.description || '', exam.registrationStart, exam.registrationEnd, exam.examStart, exam.examEnd, exam.admitDownloadStart, exam.admitDownloadEnd, exam.location || '', exam.status, exam.id ) ]; if (replaceSubjects) { operations.push(operation('DELETE FROM exam_subjects WHERE exam_id = ?', exam.id)); exam.subjects.forEach((subject, index) => operations.push(operation( `INSERT INTO exam_subjects ( id, exam_id, name, subject_date, start_time, end_time, fee, position ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, subject.id, exam.id, subject.name, subject.date || String(exam.examStart).slice(0, 10), subject.start || '', subject.end || '', Number(subject.fee || 0), Number(subject.order || index + 1) ))); } operations.push(auditOperation(log)); await transaction(operations); }, async createNotice(notice, log) { await transaction([ operation( `INSERT INTO notices ( id, title, summary, content, category, pinned, status, publish_at, created_at, author ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, notice.id, notice.title, notice.summary, notice.content, notice.category, notice.pinned ? 1 : 0, notice.status, optional(notice.publishAt), optional(notice.createdAt), notice.author ), auditOperation(log) ]); }, async updateNotice(notice, log) { await transaction([ operation( `UPDATE notices SET title = ?, summary = ?, content = ?, category = ?, pinned = ?, status = ?, publish_at = ? WHERE id = ?`, notice.title, notice.summary, notice.content, notice.category, notice.pinned ? 1 : 0, notice.status, optional(notice.publishAt), notice.id ), auditOperation(log) ]); }, async saveResult(result, isNew, log) { const resultOperation = isNew ? operation( `INSERT INTO results ( id, registration_id, subject_id, score, grade, published, updated_at, published_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, result.id, result.registrationId, result.subjectId, result.score, result.grade, result.published ? 1 : 0, optional(result.updatedAt), optional(result.publishedAt) ) : operation( `UPDATE results SET score = ?, grade = ?, published = ?, updated_at = ?, published_at = ? WHERE id = ?`, result.score, result.grade, result.published ? 1 : 0, optional(result.updatedAt), optional(result.publishedAt), result.id ); await transaction([resultOperation, auditOperation(log)]); } }; } async function createSqliteStore({ path, seed }) { const { DatabaseSync } = await import('node:sqlite'); await mkdir(dirname(path), { recursive: true }); const connection = new DatabaseSync(path, { timeout: 5000 }); connection.exec('PRAGMA journal_mode = WAL;'); connection.exec('PRAGMA synchronous = NORMAL;'); connection.exec(sqliteSchema); if (!connection.prepare('SELECT id FROM schema_metadata WHERE id = 1').get()) { const initialState = seed(); connection.exec('BEGIN IMMEDIATE'); try { connection.prepare(` INSERT INTO schema_metadata (id, schema_version, app_version, created_at) VALUES (1, 1, ?, ?) `).run(Number(initialState.meta?.version || 1), initialState.meta?.createdAt || new Date().toISOString()); for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params); connection.exec('COMMIT'); } catch (error) { connection.exec('ROLLBACK'); connection.close(); throw error; } } const transaction = async operations => { connection.exec('BEGIN IMMEDIATE'); try { for (const item of operations) connection.prepare(item.sql).run(...item.params); connection.exec('COMMIT'); } catch (error) { connection.exec('ROLLBACK'); throw error; } }; return createRepository({ client: 'sqlite', location: path, read: async () => stateFromRows(readSqliteRows(connection)), transaction, close: async () => connection.close() }); } async function createMysqlStore({ seed }) { const { default: mysql } = await import('mysql2/promise'); const connectionUrl = process.env.DATABASE_URL; const database = process.env.MYSQL_DATABASE; if (!connectionUrl && (!process.env.MYSQL_HOST || !process.env.MYSQL_USER || !database)) { throw new Error('MySQL 配置不完整:请设置 DATABASE_URL,或 MYSQL_HOST、MYSQL_USER、MYSQL_DATABASE'); } const pool = connectionUrl ? mysql.createPool(connectionUrl) : mysql.createPool({ host: process.env.MYSQL_HOST, port: Number(process.env.MYSQL_PORT || 3306), user: process.env.MYSQL_USER, password: process.env.MYSQL_PASSWORD || '', database, waitForConnections: true, connectionLimit: Number(process.env.MYSQL_CONNECTION_LIMIT || 10), charset: 'utf8mb4', timezone: 'Z', enableKeepAlive: true }); for (const statement of mysqlSchema) await pool.execute(statement); const [existing] = await pool.execute('SELECT id FROM schema_metadata WHERE id = 1'); if (!existing.length) { const initialState = seed(); const connection = await pool.getConnection(); try { await connection.beginTransaction(); const [insert] = await connection.execute(` INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, created_at) VALUES (1, 1, ?, ?) `, [Number(initialState.meta?.version || 1), initialState.meta?.createdAt || new Date().toISOString()]); if (insert.affectedRows === 1) { for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params); } await connection.commit(); } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } } const transaction = async operations => { const connection = await pool.getConnection(); try { await connection.beginTransaction(); for (const item of operations) await connection.execute(item.sql, item.params); await connection.commit(); } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } }; const read = async () => { const connection = await pool.getConnection(); try { await connection.beginTransaction(); const state = stateFromRows(await readMysqlRows(connection)); await connection.commit(); return state; } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } }; return createRepository({ client: 'mysql', location: connectionUrl ? 'DATABASE_URL' : `${process.env.MYSQL_HOST}:${process.env.MYSQL_PORT || 3306}/${database}`, read, transaction, close: async () => pool.end() }); } export async function createDatabase({ root, seed }) { const client = String(process.env.DATABASE_CLIENT || (process.env.NODE_ENV === 'production' ? 'mysql' : 'sqlite')).toLowerCase(); if (!['sqlite', 'mysql'].includes(client)) { throw new Error(`不支持的 DATABASE_CLIENT:${client}(可选 sqlite 或 mysql)`); } if (client === 'mysql') return createMysqlStore({ seed }); const path = resolve(process.env.SQLITE_PATH || join(root, 'data', 'exam.sqlite')); return createSqliteStore({ path, seed }); }