数据库

This commit is contained in:
2026-07-19 21:48:15 +08:00 Unverified
parent de4d0961e4
commit bb5d3da766
8 changed files with 1229 additions and 61 deletions
+916
View File
@@ -0,0 +1,916 @@
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 });
}