数据库
This commit is contained in:
@@ -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
|
||||
@@ -1,3 +1,8 @@
|
||||
data/db.json
|
||||
data/test-db.json
|
||||
data/*.sqlite
|
||||
data/*.sqlite-shm
|
||||
data/*.sqlite-wal
|
||||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
打开 <http://127.0.0.1:4173>。
|
||||
|
||||
首次运行会自动创建 `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 开发与生产环境变量模板
|
||||
```
|
||||
|
||||
+916
@@ -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 });
|
||||
}
|
||||
Generated
+160
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+43
-51
@@ -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'));
|
||||
|
||||
+22
-3
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user