Files
Exam-Information-System/database.mjs
T
2026-07-19 21:48:15 +08:00

917 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) {
await transaction([
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
),
auditOperation(log)
]);
},
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 });
}