diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0f75cce --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# 本地开发(默认) +NODE_ENV=development +DATABASE_CLIENT=sqlite +SQLITE_PATH=./data/exam.sqlite +HOST=127.0.0.1 +PORT=4173 + +# MySQL 8.4 生产环境:将 DATABASE_CLIENT 改为 mysql,并配置以下变量。 +# NODE_ENV=production +# DATABASE_CLIENT=mysql +# MYSQL_HOST=127.0.0.1 +# MYSQL_PORT=3306 +# MYSQL_USER=exam_app +# MYSQL_PASSWORD=replace-with-a-strong-password +# MYSQL_DATABASE=exam_information +# MYSQL_CONNECTION_LIMIT=10 +# HOST=0.0.0.0 + +# 也可以用单个连接地址替代全部 MYSQL_* 连接参数: +# DATABASE_URL=mysql://exam_app:password@127.0.0.1:3306/exam_information diff --git a/.gitignore b/.gitignore index 62b4701..0c4a5a4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ data/db.json data/test-db.json +data/*.sqlite +data/*.sqlite-shm +data/*.sqlite-wal +node_modules/ +.env *.log diff --git a/README.md b/README.md index 78cd086..9c5c00a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 衡准 · 考试信息管理系统 -一个完整可运行的双角色考试信息管理系统,使用零依赖 Node.js 后端和 JSON 持久化,开箱即可运行。 +一个完整可运行的双角色考试信息管理系统,使用 Node.js 后端;本地开发采用 SQLite,生产环境支持 MySQL 8.4。 ## 已实现功能 @@ -37,22 +37,73 @@ - PBKDF2 加盐密码哈希 - HttpOnly、SameSite 登录 Cookie - 服务端角色权限校验 -- JSON 文件持久化 +- SQLite / MySQL 8.4 双数据库持久化 +- 规范关系模型、外键、唯一约束和业务索引 +- 业务写入与审计日志使用原子事务提交 - 关键管理操作审计日志 - 桌面端与移动端响应式布局 - 零第三方运行时依赖 ## 运行 -需要 Node.js 18 或更高版本。 +需要 Node.js 22.5 或更高版本(SQLite 使用 Node.js 内置驱动)。 ```powershell +npm install npm start ``` 打开 。 -首次运行会自动创建 `data/db.json`。 +本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前处于开发阶段,不兼容或导入旧版 JSON/单表数据库;更改表结构后请删除本地 SQLite 文件并重新启动。 + +## 数据库配置 + +应用根据 `DATABASE_CLIENT` 使用不同数据库。未设置时,开发/测试环境默认 `sqlite`,`NODE_ENV=production` 默认 `mysql`。 + +### 本地 SQLite + +```powershell +$env:DATABASE_CLIENT = 'sqlite' +$env:SQLITE_PATH = './data/exam.sqlite' +npm start +``` + +`SQLITE_PATH` 可省略,默认路径就是 `./data/exam.sqlite`。 + +### 生产 MySQL 8.4 + +先在 MySQL 8.4 中创建数据库和最小权限账号: + +```sql +CREATE DATABASE exam_information CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +CREATE USER 'exam_app'@'%' IDENTIFIED BY 'replace-with-a-strong-password'; +GRANT SELECT, INSERT, UPDATE, CREATE ON exam_information.* TO 'exam_app'@'%'; +``` + +启动应用时设置连接信息,应用会自动创建以下关系表并初始化演示数据: + +- `users`、`candidate_profiles` +- `exams`、`exam_subjects` +- `registrations`、`registration_subjects`、`admit_cards` +- `results`、`notices`、`audit_logs` +- `organization`、`schema_metadata` + +所有关联均有外键约束,账号、证件号、考试代码、报名关系、准考证号和单科成绩均有对应唯一约束。 + +```powershell +$env:NODE_ENV = 'production' +$env:DATABASE_CLIENT = 'mysql' +$env:MYSQL_HOST = '127.0.0.1' +$env:MYSQL_PORT = '3306' +$env:MYSQL_USER = 'exam_app' +$env:MYSQL_PASSWORD = 'replace-with-a-strong-password' +$env:MYSQL_DATABASE = 'exam_information' +$env:HOST = '0.0.0.0' +npm start +``` + +也可以只设置标准连接地址 `DATABASE_URL=mysql://user:password@host:3306/database`。完整模板见 `.env.example`;项目不会自动加载 `.env` 文件,部署平台应将这些值作为进程环境变量注入。 ## 演示账号 @@ -67,7 +118,7 @@ npm start npm test ``` -测试使用独立临时数据库,覆盖注册、审核、多科目报名、准考证生成与下载、通知发布、成绩发布和权限隔离完整流程。 +测试使用独立临时 SQLite 数据库,覆盖注册、审核、多科目报名、准考证生成与下载、通知发布、成绩发布和权限隔离完整流程。 ## 项目结构 @@ -76,6 +127,8 @@ index.html 页面入口 styles.css 公共首页、考生端、管理端响应式样式 app.js 前端路由、状态和业务交互 server.mjs HTTP 服务、认证、权限与全部业务 API +database.mjs 分表结构、SQLite / MySQL 适配与事务仓储 tests/system.test.mjs 端到端系统测试 -data/db.json 运行后生成的持久化数据 +data/exam.sqlite 本地运行后生成的 SQLite 数据库 +.env.example 开发与生产环境变量模板 ``` diff --git a/database.mjs b/database.mjs new file mode 100644 index 0000000..2f8a51d --- /dev/null +++ b/database.mjs @@ -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 }); +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5463f63 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,160 @@ +{ + "name": "hengzhun-exam-system", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hengzhun-exam-system", + "version": "1.0.0", + "dependencies": { + "mysql2": "^3.14.2" + }, + "engines": { + "node": ">=22.5" + } + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/mysql2": { + "version": "3.23.1", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.23.1.tgz", + "integrity": "sha512-tTuRnC7qCet2IOfSNMYZ5SwXuBnfvBPAcIA28P0gtruXyZlU1LMxA6uha32kYypoFgyYklMqhLWwt4laYwXR/Q==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sql-escaper": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz", + "integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT", + "peer": true + } + } +} diff --git a/package.json b/package.json index bbba093..ad88aa9 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,10 @@ "start": "node server.mjs", "test": "node tests/system.test.mjs" }, + "dependencies": { + "mysql2": "^3.14.2" + }, "engines": { - "node": ">=18" + "node": ">=22.5" } } diff --git a/server.mjs b/server.mjs index 1ee6483..11c4456 100644 --- a/server.mjs +++ b/server.mjs @@ -1,11 +1,12 @@ import { createServer } from 'node:http'; -import { readFile, writeFile, mkdir, access } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; import { extname, join, normalize, resolve } from 'node:path'; import { randomBytes, pbkdf2Sync, timingSafeEqual } from 'node:crypto'; +import { createDatabase } from './database.mjs'; const root = resolve(process.cwd()); const port = Number(process.env.PORT || 4173); -const dbPath = resolve(process.env.EXAM_DB_PATH || join(root, 'data', 'db.json')); +const host = process.env.HOST || '127.0.0.1'; const sessions = new Map(); const staticFiles = new Set(['/index.html', '/styles.css', '/app.js']); const mimeTypes = { @@ -108,24 +109,8 @@ function seedDatabase() { }; } -async function ensureDatabase() { - try { - await access(dbPath); - } catch { - await mkdir(resolve(dbPath, '..'), { recursive: true }); - await writeFile(dbPath, JSON.stringify(seedDatabase(), null, 2), 'utf8'); - } -} - -async function readDb() { - await ensureDatabase(); - return JSON.parse(await readFile(dbPath, 'utf8')); -} - -async function writeDb(db) { - await mkdir(resolve(dbPath, '..'), { recursive: true }); - await writeFile(dbPath, JSON.stringify(db, null, 2), 'utf8'); -} +const database = await createDatabase({ root, seed: seedDatabase }); +const readDb = () => database.read(); function sendJson(response, status, payload, headers = {}) { response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers }); @@ -213,8 +198,10 @@ function examRegistrationView(db, registration) { } function logAction(db, user, action, detail) { - db.auditLogs.unshift({ id: uid('log'), actorId: user.id, actorName: user.displayName, action, detail, createdAt: nowIso() }); + const log = { id: uid('log'), actorId: user.id, actorName: user.displayName, action, detail, createdAt: nowIso() }; + db.auditLogs.unshift(log); db.auditLogs = db.auditLogs.slice(0, 200); + return log; } function escapeHtml(value) { @@ -265,9 +252,7 @@ async function handleAuth(request, response, pathname) { if (db.candidateProfiles.some(profile => profile.idNumber === idNumber)) return sendError(response, 409, '该证件号码已注册'); const user = { id: uid('usr'), username, passwordHash: hashPassword(password), role: 'candidate', displayName: name, createdAt: nowIso() }; const profile = { id: uid('profile'), userId: user.id, name, idNumber, phone, gender: cleanText(body.gender, 10), email: cleanText(body.email, 80), school: cleanText(body.school, 80), grade: cleanText(body.grade, 50), address: '', emergencyContact: '', emergencyPhone: '', status: 'pending', reviewNote: '', updatedAt: nowIso() }; - db.users.push(user); - db.candidateProfiles.push(profile); - await writeDb(db); + await database.createCandidate(user, profile); return sendJson(response, 201, { ok: true, message: '注册成功,请等待管理员审核资料' }); } if (request.method === 'POST' && pathname === '/api/auth/login') { @@ -310,9 +295,7 @@ async function handleCandidate(request, response, pathname) { profile.status = 'pending'; profile.reviewNote = ''; profile.updatedAt = nowIso(); - const persistedUser = db.users.find(item => item.id === user.id); - if (persistedUser) persistedUser.displayName = profile.name; - await writeDb(db); + await database.updateCandidateProfile(profile, profile.name); return sendJson(response, 200, { ok: true, profile, message: '资料已提交,等待管理员复核' }); } if (request.method === 'GET' && pathname === '/api/candidate/exams') { @@ -334,8 +317,7 @@ async function handleCandidate(request, response, pathname) { const subjectIds = [...new Set(Array.isArray(body.subjectIds) ? body.subjectIds : [])]; if (!subjectIds.length || subjectIds.some(id => !exam.subjects.some(subject => subject.id === id))) return sendError(response, 400, '请选择有效的报考科目'); const registration = { id: uid('reg'), userId: user.id, examId: exam.id, subjectIds, status: 'pending', paymentStatus: 'unpaid', createdAt: nowIso(), admitCard: null }; - db.registrations.push(registration); - await writeDb(db); + await database.createRegistration(registration); return sendJson(response, 201, { ok: true, registration: examRegistrationView(db, registration), message: '考试报名已提交' }); } if (request.method === 'GET' && pathname === '/api/candidate/results') { @@ -390,8 +372,8 @@ async function handleAdmin(request, response, pathname) { profile.reviewNote = cleanText(body.reviewNote, 300); profile.reviewedAt = nowIso(); profile.reviewerId = user.id; - logAction(db, user, body.status === 'approved' ? '通过考生资料' : '退回考生资料', `${profile.name}:${profile.reviewNote || '无备注'}`); - await writeDb(db); + const log = logAction(db, user, body.status === 'approved' ? '通过考生资料' : '退回考生资料', `${profile.name}:${profile.reviewNote || '无备注'}`); + await database.reviewCandidate(profile, log); return sendJson(response, 200, { ok: true, profile }); } if (request.method === 'GET' && pathname === '/api/admin/registrations') { @@ -412,8 +394,8 @@ async function handleAdmin(request, response, pathname) { registration.reviewedAt = nowIso(); if (body.status === 'approved') registration.paymentStatus = 'paid'; const profile = db.candidateProfiles.find(item => item.userId === registration.userId); - logAction(db, user, body.status === 'approved' ? '通过考试报名' : '退回考试报名', `${profile?.name || registration.userId} · ${db.exams.find(item => item.id === registration.examId)?.name}`); - await writeDb(db); + const log = logAction(db, user, body.status === 'approved' ? '通过考试报名' : '退回考试报名', `${profile?.name || registration.userId} · ${db.exams.find(item => item.id === registration.examId)?.name}`); + await database.reviewRegistration(registration, log); return sendJson(response, 200, { ok: true, registration }); } const admitMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)\/admit-card$/); @@ -425,15 +407,15 @@ async function handleAdmin(request, response, pathname) { const exam = db.exams.find(item => item.id === registration.examId); const sequence = String(db.registrations.filter(item => item.examId === exam.id && item.admitCard).length + 1).padStart(4, '0'); registration.admitCard = { - number: `${exam.code.replace(/\D/g, '').slice(-4) || '2026'}-${sequence}`, + number: `${exam.code.replace(/[^a-z0-9]/gi, '').toUpperCase().slice(-12) || String(new Date().getFullYear())}-${sequence}`, testCenter: cleanText((await readJson(request)).testCenter || '海州市第一中学', 80), room: `0${Math.ceil(Number(sequence) / 30) || 1} 考场`, seat: String(((Number(sequence) - 1) % 30) + 1).padStart(2, '0'), generatedAt: nowIso() }; const profile = db.candidateProfiles.find(item => item.userId === registration.userId); - logAction(db, user, '生成准考证', `${profile?.name || registration.userId} · ${registration.admitCard.number}`); - await writeDb(db); + const log = logAction(db, user, '生成准考证', `${profile?.name || registration.userId} · ${registration.admitCard.number}`); + await database.createAdmitCard(registration.id, registration.admitCard, log); } return sendJson(response, 200, { ok: true, admitCard: registration.admitCard }); } @@ -446,9 +428,8 @@ async function handleAdmin(request, response, pathname) { const subjects = subjectNames.map(name => cleanText(typeof name === 'string' ? name : name.name, 30)).filter(Boolean).map((name, index) => ({ id: uid('sub'), name, date: cleanText(body.examStart, 10), start: '09:00', end: '11:00', fee: 0, order: index + 1 })); if (!subjects.length) return sendError(response, 400, '请至少添加一个考试科目'); const exam = { id: uid('exam'), code: cleanText(body.code, 30) || `EX-${new Date().getFullYear()}-${String(db.exams.length + 1).padStart(2, '0')}`, name, description: cleanText(body.description, 500), registrationStart: body.registrationStart, registrationEnd: body.registrationEnd, examStart: body.examStart, examEnd: body.examEnd, admitDownloadStart: body.admitDownloadStart || body.registrationEnd, admitDownloadEnd: body.admitDownloadEnd || body.examStart, location: cleanText(body.location, 100), status: body.status === 'published' ? 'published' : 'draft', subjects, createdAt: nowIso() }; - db.exams.push(exam); - logAction(db, user, '创建考试', `${exam.name} · ${subjects.length} 个科目`); - await writeDb(db); + const log = logAction(db, user, '创建考试', `${exam.name} · ${subjects.length} 个科目`); + await database.createExam(exam, log); return sendJson(response, 201, { ok: true, exam }); } const examMatch = pathname.match(/^\/api\/admin\/exams\/([^/]+)$/); @@ -458,8 +439,8 @@ async function handleAdmin(request, response, pathname) { if (!exam) return sendError(response, 404, '考试不存在'); if (body.status && ['draft', 'published', 'closed'].includes(body.status)) exam.status = body.status; ['name', 'description', 'location', 'registrationStart', 'registrationEnd', 'examStart', 'examEnd', 'admitDownloadStart', 'admitDownloadEnd'].forEach(field => { if (body[field] != null) exam[field] = cleanText(body[field], 500); }); - logAction(db, user, '更新考试', `${exam.name} · 状态 ${exam.status}`); - await writeDb(db); + const log = logAction(db, user, '更新考试', `${exam.name} · 状态 ${exam.status}`); + await database.updateExam(exam, log); return sendJson(response, 200, { ok: true, exam }); } if (request.method === 'GET' && pathname === '/api/admin/notices') return sendJson(response, 200, { ok: true, notices: db.notices.sort((a, b) => new Date(b.publishAt || b.createdAt) - new Date(a.publishAt || a.createdAt)) }); @@ -469,9 +450,8 @@ async function handleAdmin(request, response, pathname) { const content = cleanText(body.content, 5000); if (!title || !content) return sendError(response, 400, '通知标题和正文不能为空'); const notice = { id: uid('notice'), title, summary: cleanText(body.summary, 260) || content.slice(0, 80), content, category: cleanText(body.category, 30) || '通知公告', pinned: Boolean(body.pinned), status: body.status === 'draft' ? 'draft' : 'published', publishAt: body.status === 'draft' ? null : nowIso(), createdAt: nowIso(), author: user.displayName }; - db.notices.push(notice); - logAction(db, user, notice.status === 'published' ? '发布通知' : '保存通知草稿', notice.title); - await writeDb(db); + const log = logAction(db, user, notice.status === 'published' ? '发布通知' : '保存通知草稿', notice.title); + await database.createNotice(notice, log); return sendJson(response, 201, { ok: true, notice }); } const noticeMatch = pathname.match(/^\/api\/admin\/notices\/([^/]+)$/); @@ -485,8 +465,8 @@ async function handleAdmin(request, response, pathname) { notice.status = body.status; if (body.status === 'published' && !notice.publishAt) notice.publishAt = nowIso(); } - logAction(db, user, '更新通知', `${notice.title} · ${notice.status}`); - await writeDb(db); + const log = logAction(db, user, '更新通知', `${notice.title} · ${notice.status}`); + await database.updateNotice(notice, log); return sendJson(response, 200, { ok: true, notice }); } if (request.method === 'GET' && pathname === '/api/admin/results') { @@ -508,6 +488,7 @@ async function handleAdmin(request, response, pathname) { const score = Number(body.score); if (!Number.isFinite(score) || score < 0 || score > 150) return sendError(response, 400, '成绩必须在 0—150 之间'); let result = db.results.find(item => item.registrationId === registration.id && item.subjectId === body.subjectId); + const isNew = !result; if (!result) { result = { id: uid('result'), registrationId: registration.id, subjectId: body.subjectId }; db.results.push(result); @@ -515,8 +496,8 @@ async function handleAdmin(request, response, pathname) { Object.assign(result, { score, grade: cleanText(body.grade, 10) || (score >= 135 ? 'A+' : score >= 120 ? 'A' : score >= 105 ? 'B+' : score >= 90 ? 'B' : score >= 60 ? 'C' : 'D'), published: Boolean(body.published), updatedAt: nowIso(), publishedAt: body.published ? nowIso() : null }); const profile = db.candidateProfiles.find(item => item.userId === registration.userId); const subject = exam.subjects.find(item => item.id === body.subjectId); - logAction(db, user, body.published ? '发布成绩' : '保存成绩', `${profile?.name} · ${subject?.name} · ${score}`); - await writeDb(db); + const log = logAction(db, user, body.published ? '发布成绩' : '保存成绩', `${profile?.name} · ${subject?.name} · ${score}`); + await database.saveResult(result, isNew, log); return sendJson(response, 200, { ok: true, result }); } return sendError(response, 404, '管理功能接口不存在'); @@ -554,7 +535,18 @@ const server = createServer(async (request, response) => { } }); -await ensureDatabase(); -server.listen(port, '127.0.0.1', () => { - console.log(`衡准考试信息管理系统:http://127.0.0.1:${port}`); +server.listen(port, host, () => { + console.log(`衡准考试信息管理系统:http://${host}:${port}`); + console.log(`数据库:${database.client}(${database.location})`); }); + +async function shutdown(signal) { + console.log(`收到 ${signal},正在关闭服务...`); + server.close(async () => { + await database.close(); + process.exit(0); + }); +} + +process.once('SIGINT', () => shutdown('SIGINT')); +process.once('SIGTERM', () => shutdown('SIGTERM')); diff --git a/tests/system.test.mjs b/tests/system.test.mjs index 112a340..a82f208 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -1,17 +1,20 @@ import { spawn } from 'node:child_process'; -import { rm } from 'node:fs/promises'; +import { readFile, rm } from 'node:fs/promises'; import { resolve } from 'node:path'; import assert from 'node:assert/strict'; +import { relationalTables } from '../database.mjs'; const root = resolve(process.cwd()); const port = 4182; const baseUrl = `http://127.0.0.1:${port}`; -const testDb = resolve(root, 'data', 'test-db.json'); +const testDb = resolve(root, 'data', 'test-db.sqlite'); await rm(testDb, { force: true }); +await rm(`${testDb}-shm`, { force: true }); +await rm(`${testDb}-wal`, { force: true }); const server = spawn(process.execPath, ['server.mjs'], { cwd: root, - env: { ...process.env, PORT: String(port), EXAM_DB_PATH: testDb }, + env: { ...process.env, NODE_ENV: 'test', DATABASE_CLIENT: 'sqlite', PORT: String(port), SQLITE_PATH: testDb }, stdio: ['ignore', 'pipe', 'pipe'] }); @@ -54,6 +57,19 @@ const anonymous = createClient(); try { await waitForServer(); + const sqliteFile = await readFile(testDb); + assert.equal(sqliteFile.subarray(0, 16).toString(), 'SQLite format 3\0', '测试持久化文件必须是真实 SQLite 数据库'); + const { DatabaseSync } = await import('node:sqlite'); + const inspector = new DatabaseSync(testDb, { readOnly: true }); + const tableNames = inspector.prepare(` + SELECT name FROM sqlite_master + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' + ORDER BY name + `).all().map(row => row.name); + inspector.close(); + assert.deepEqual(tableNames, [...relationalTables].sort(), '业务数据必须按关系模型分表存储'); + assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储'); + const publicHome = await anonymous.request('/api/public/home'); assert.equal(publicHome.response.status, 200); assert.ok(publicHome.data.notices.length >= 3, '公开首页应返回通知'); @@ -164,6 +180,7 @@ try { assert.ok(results.data.results.some(item => item.score === 126 && item.subjectName === '语文'), '已发布成绩应在考生端可查询'); console.log('✓ 公开首页与通知读取'); + console.log(`✓ SQLite 关系型数据库初始化(${relationalTables.length} 张分表)`); console.log('✓ 考生自主注册、完整资料维护与管理员审核'); console.log('✓ 多科目考试创建与考生自主选科报名'); console.log('✓ 报名审核、准考证生成、开放期下载与窗口限制'); @@ -173,4 +190,6 @@ try { server.kill('SIGTERM'); await new Promise(resolveWait => server.once('exit', resolveWait)); await rm(testDb, { force: true }); + await rm(`${testDb}-shm`, { force: true }); + await rm(`${testDb}-wal`, { force: true }); }