Separate base database initialization from test data seeding
This commit is contained in:
@@ -5,6 +5,11 @@ SQLITE_PATH=./data/exam.sqlite
|
|||||||
HOST=127.0.0.1
|
HOST=127.0.0.1
|
||||||
PORT=4173
|
PORT=4173
|
||||||
|
|
||||||
|
# 仅在首次创建空数据库时使用。部署前务必修改初始密码。
|
||||||
|
INITIAL_ADMIN_USERNAME=admin
|
||||||
|
INITIAL_ADMIN_PASSWORD=Admin123!
|
||||||
|
INITIAL_ADMIN_DISPLAY_NAME=系统管理员
|
||||||
|
|
||||||
# MySQL 8.4 生产环境:将 DATABASE_CLIENT 改为 mysql,并配置以下变量。
|
# MySQL 8.4 生产环境:将 DATABASE_CLIENT 改为 mysql,并配置以下变量。
|
||||||
# NODE_ENV=production
|
# NODE_ENV=production
|
||||||
# DATABASE_CLIENT=mysql
|
# DATABASE_CLIENT=mysql
|
||||||
|
|||||||
@@ -87,9 +87,9 @@ npm start
|
|||||||
|
|
||||||
打开 <http://127.0.0.1:4173>。
|
打开 <http://127.0.0.1:4173>。
|
||||||
|
|
||||||
本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前数据库结构版本为 v15;v15 将线下缴费确认从报名审核中拆分,并增加缴费确认人、确认时间及名单导出。开发阶段可直接运行 `npm run reset-db` 重建完整数据库,不要求兼容旧数据;低于 v15 的开发库会提示重建,不执行兼容迁移。
|
本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构,但不会导入学校、考生、考试或报名测试数据。首次建库只写入系统基础配置和一个超级管理员;账号、密码和显示名可通过 `INITIAL_ADMIN_USERNAME`、`INITIAL_ADMIN_PASSWORD`、`INITIAL_ADMIN_DISPLAY_NAME` 设置。当前数据库结构版本为 v15;低于 v15 的开发库会提示重建,不执行兼容迁移。
|
||||||
|
|
||||||
需要清空并重建本地演示库时运行 `npm run reset-db`。省市区县下拉数据位于 `src/data/china-regions.mjs`,当前版本为国家地名信息库截至 2025-12-31 的三级快照,并补入和康县(653228)与和安县(653229);从新版 CSV 更新时可运行 `node scripts/build-regions.mjs <CSV路径> src/data/china-regions.mjs`。
|
需要清空并重建空业务库时运行 `npm run reset-db`。需要测试数据时再手动运行 `npm run seed-test-data`;该命令会覆盖本地 SQLite 数据库,生成 4 所学校、360 名批量考生及 360 条不同状态的报名数据,并明确不生成考场编排计划和准考证。省市区县下拉数据位于 `src/data/china-regions.mjs`,当前版本为国家地名信息库截至 2025-12-31 的三级快照,并补入和康县(653228)与和安县(653229);从新版 CSV 更新时可运行 `node scripts/build-regions.mjs <CSV路径> src/data/china-regions.mjs`。
|
||||||
|
|
||||||
## 数据库配置
|
## 数据库配置
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ CREATE USER 'exam_app'@'%' IDENTIFIED BY 'replace-with-a-strong-password';
|
|||||||
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER ON exam_information.* TO 'exam_app'@'%';
|
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER ON exam_information.* TO 'exam_app'@'%';
|
||||||
```
|
```
|
||||||
|
|
||||||
启动应用时设置连接信息,应用会自动创建以下关系表并初始化演示数据:
|
启动应用时设置连接信息,应用会自动创建以下关系表和系统基础配置,但不会自动写入测试业务数据:
|
||||||
|
|
||||||
- `schools`、`school_classes`、`users`、`candidate_profiles`
|
- `schools`、`school_classes`、`users`、`candidate_profiles`
|
||||||
- `exams`、`exam_subjects`
|
- `exams`、`exam_subjects`
|
||||||
@@ -144,7 +144,9 @@ npm start
|
|||||||
|
|
||||||
也可以只设置标准连接地址 `DATABASE_URL=mysql://user:password@host:3306/database`。完整模板见 `.env.example`;将模板复制为 `.env` 后取消 MySQL 配置项的注释并填写实际连接信息即可。生产部署仍建议由部署平台注入环境变量,避免在服务器文件中保存密码。
|
也可以只设置标准连接地址 `DATABASE_URL=mysql://user:password@host:3306/database`。完整模板见 `.env.example`;将模板复制为 `.env` 后取消 MySQL 配置项的注释并填写实际连接信息即可。生产部署仍建议由部署平台注入环境变量,避免在服务器文件中保存密码。
|
||||||
|
|
||||||
## 演示账号
|
## 手动测试数据账号
|
||||||
|
|
||||||
|
测试数据脚本会提供以下账号;其中初始超级管理员也可能由正常首次建库创建,并可通过环境变量改名、改密,其余校级、班级和考生账号不会在正常启动时创建:
|
||||||
|
|
||||||
| 角色 | 账号 | 密码 |
|
| 角色 | 账号 | 密码 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -176,7 +178,9 @@ server.mjs HTTP 服务启动、模块装配与静态文
|
|||||||
database.mjs 数据仓储与数据库模块装配
|
database.mjs 数据仓储与数据库模块装配
|
||||||
excel.mjs Excel 模板、导入解析与导出工作簿
|
excel.mjs Excel 模板、导入解析与导出工作簿
|
||||||
|
|
||||||
src/data/seed.mjs 演示数据
|
src/data/base.mjs 空业务库与系统基础配置
|
||||||
|
src/data/seed.mjs 手动测试数据生成器
|
||||||
|
scripts/import-test-data.mjs 独立测试数据导入脚本
|
||||||
src/http/responses.mjs JSON、文件与请求体处理
|
src/http/responses.mjs JSON、文件与请求体处理
|
||||||
src/security/session.mjs Cookie 会话与当前用户
|
src/security/session.mjs Cookie 会话与当前用户
|
||||||
src/security/authorization.mjs 管理层级、权限和数据范围
|
src/security/authorization.mjs 管理层级、权限和数据范围
|
||||||
|
|||||||
@@ -120,12 +120,6 @@ document.addEventListener('click', async event => {
|
|||||||
state.user = null; state.profile = null; state.pageData = null; state.permissions = []; state.scopeLabel = '';
|
state.user = null; state.profile = null; state.pageData = null; state.permissions = []; state.scopeLabel = '';
|
||||||
await refreshPublic(); navigate('home'); toast('已安全退出', '期待下次见面'); return;
|
await refreshPublic(); navigate('home'); toast('已安全退出', '期待下次见面'); return;
|
||||||
}
|
}
|
||||||
if (action === 'fill-demo') {
|
|
||||||
const form = document.querySelector('[data-form="login"]');
|
|
||||||
const accounts = { admin: ['admin', 'Admin123!'], school: ['school_admin', 'School123!'], class: ['class_admin', 'Class123!'], candidate: ['2026-HZ01-F-0001', 'Candidate123!'] };
|
|
||||||
[form.username.value, form.password.value] = accounts[target.dataset.type] || accounts.candidate;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (action === 'open-notice') {
|
if (action === 'open-notice') {
|
||||||
const notice = state.publicData.notices.find(item => item.id === target.dataset.id) || (await api(`/api/public/notices/${target.dataset.id}`)).notice;
|
const notice = state.publicData.notices.find(item => item.id === target.dataset.id) || (await api(`/api/public/notices/${target.dataset.id}`)).notice;
|
||||||
const contentHtml = notice.contentHtml || `<p>${h(notice.content).replace(/\r?\n/g, '</p><p>')}</p>`;
|
const contentHtml = notice.contentHtml || `<p>${h(notice.content).replace(/\r?\n/g, '</p><p>')}</p>`;
|
||||||
|
|||||||
+2
-1
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.mjs",
|
"start": "node server.mjs",
|
||||||
"test": "node tests/system.test.mjs",
|
"test": "node tests/system.test.mjs",
|
||||||
"reset-db": "node scripts/reset-dev-database.mjs"
|
"reset-db": "node scripts/reset-dev-database.mjs",
|
||||||
|
"seed-test-data": "node scripts/import-test-data.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ckeditor5": "^48.3.1",
|
"ckeditor5": "^48.3.1",
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { pbkdf2Sync, randomBytes } from 'node:crypto';
|
||||||
|
import { rm } from 'node:fs/promises';
|
||||||
|
import { isAbsolute, join, relative, resolve } from 'node:path';
|
||||||
|
import { createDatabase } from '../database.mjs';
|
||||||
|
import { createSeedDatabase } from '../src/data/seed.mjs';
|
||||||
|
|
||||||
|
const root = resolve(process.cwd());
|
||||||
|
const databasePath = resolve(process.env.SQLITE_PATH || join(root, 'data', 'exam.sqlite'));
|
||||||
|
const relativePath = relative(root, databasePath);
|
||||||
|
if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) throw new Error('拒绝覆盖工作区以外的数据库');
|
||||||
|
|
||||||
|
function hashPassword(password, salt = randomBytes(16).toString('hex')) {
|
||||||
|
const hash = pbkdf2Sync(password, salt, 120000, 32, 'sha256').toString('hex');
|
||||||
|
return `${salt}:${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await rm(databasePath, { force: true });
|
||||||
|
await rm(`${databasePath}-shm`, { force: true });
|
||||||
|
await rm(`${databasePath}-wal`, { force: true });
|
||||||
|
|
||||||
|
process.env.DATABASE_CLIENT = 'sqlite';
|
||||||
|
process.env.SQLITE_PATH = databasePath;
|
||||||
|
const database = await createDatabase({
|
||||||
|
root,
|
||||||
|
seed: () => createSeedDatabase({ nowIso: () => new Date().toISOString(), hashPassword })
|
||||||
|
});
|
||||||
|
const state = await database.read();
|
||||||
|
await database.close();
|
||||||
|
|
||||||
|
const pending = state.registrations.filter(item => item.status === 'pending').length;
|
||||||
|
const rejected = state.registrations.filter(item => item.status === 'rejected').length;
|
||||||
|
const unpaid = state.registrations.filter(item => item.status === 'approved' && item.paymentStatus === 'unpaid').length;
|
||||||
|
const paid = state.registrations.filter(item => item.status === 'approved' && item.paymentStatus === 'paid').length;
|
||||||
|
console.log(`Imported test data into ${databasePath}`);
|
||||||
|
console.log(`${state.schools.length} schools, ${state.candidateProfiles.length} candidates, ${state.registrations.length} registrations`);
|
||||||
|
console.log(`pending ${pending}, rejected ${rejected}, approved/unpaid ${unpaid}, approved/paid ${paid}`);
|
||||||
|
console.log(`arrangement plans ${state.arrangementPlans.length}, admit cards ${state.registrations.filter(item => item.admitCard).length}`);
|
||||||
@@ -2,7 +2,7 @@ import { pbkdf2Sync, randomBytes } from 'node:crypto';
|
|||||||
import { rm } from 'node:fs/promises';
|
import { rm } from 'node:fs/promises';
|
||||||
import { isAbsolute, join, relative, resolve } from 'node:path';
|
import { isAbsolute, join, relative, resolve } from 'node:path';
|
||||||
import { createDatabase } from '../database.mjs';
|
import { createDatabase } from '../database.mjs';
|
||||||
import { createSeedDatabase } from '../src/data/seed.mjs';
|
import { createBaseDatabase } from '../src/data/base.mjs';
|
||||||
|
|
||||||
const root = resolve(process.cwd());
|
const root = resolve(process.cwd());
|
||||||
const databasePath = join(root, 'data', 'exam.sqlite');
|
const databasePath = join(root, 'data', 'exam.sqlite');
|
||||||
@@ -20,8 +20,16 @@ process.env.DATABASE_CLIENT = 'sqlite';
|
|||||||
process.env.SQLITE_PATH = databasePath;
|
process.env.SQLITE_PATH = databasePath;
|
||||||
const database = await createDatabase({
|
const database = await createDatabase({
|
||||||
root,
|
root,
|
||||||
seed: () => createSeedDatabase({ nowIso: () => new Date().toISOString(), hashPassword })
|
seed: () => createBaseDatabase({
|
||||||
|
nowIso: () => new Date().toISOString(),
|
||||||
|
hashPassword,
|
||||||
|
initialAdmin: {
|
||||||
|
username: process.env.INITIAL_ADMIN_USERNAME,
|
||||||
|
password: process.env.INITIAL_ADMIN_PASSWORD,
|
||||||
|
displayName: process.env.INITIAL_ADMIN_DISPLAY_NAME
|
||||||
|
}
|
||||||
|
})
|
||||||
});
|
});
|
||||||
const state = await database.read();
|
const state = await database.read();
|
||||||
await database.close();
|
await database.close();
|
||||||
console.log(`Rebuilt ${databasePath} (schema ${state.meta.version}, ${state.candidateProfiles.length} candidates, ${state.testCenters.length} centers)`);
|
console.log(`Rebuilt empty ${databasePath} (schema ${state.meta.version}, ${state.candidateProfiles.length} candidates)`);
|
||||||
|
|||||||
+11
-3
@@ -13,7 +13,7 @@ import { createPublicRoutes } from './src/routes/public.routes.mjs';
|
|||||||
import { adminLevelNames, adminScopeLabel, createPermissionGuard, hasPermission, permissionsByLevel, profileInScope, registrationInScope } from './src/security/authorization.mjs';
|
import { adminLevelNames, adminScopeLabel, createPermissionGuard, hasPermission, permissionsByLevel, profileInScope, registrationInScope } from './src/security/authorization.mjs';
|
||||||
import { createSessionManager } from './src/security/session.mjs';
|
import { createSessionManager } from './src/security/session.mjs';
|
||||||
import { readBodyBuffer, readJson, sendError, sendJson, sendWorkbook } from './src/http/responses.mjs';
|
import { readBodyBuffer, readJson, sendError, sendJson, sendWorkbook } from './src/http/responses.mjs';
|
||||||
import { createSeedDatabase } from './src/data/seed.mjs';
|
import { createBaseDatabase } from './src/data/base.mjs';
|
||||||
import { resolveRegion } from './src/data/region-service.mjs';
|
import { resolveRegion } from './src/data/region-service.mjs';
|
||||||
|
|
||||||
const root = resolve(process.cwd());
|
const root = resolve(process.cwd());
|
||||||
@@ -70,8 +70,16 @@ function verifyPassword(password, stored) {
|
|||||||
return actual.length === expectedBuffer.length && timingSafeEqual(actual, expectedBuffer);
|
return actual.length === expectedBuffer.length && timingSafeEqual(actual, expectedBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
const seedDatabase = () => createSeedDatabase({ nowIso, hashPassword });
|
const initializeDatabase = () => createBaseDatabase({
|
||||||
const database = await createDatabase({ root, seed: seedDatabase });
|
nowIso,
|
||||||
|
hashPassword,
|
||||||
|
initialAdmin: {
|
||||||
|
username: process.env.INITIAL_ADMIN_USERNAME,
|
||||||
|
password: process.env.INITIAL_ADMIN_PASSWORD,
|
||||||
|
displayName: process.env.INITIAL_ADMIN_DISPLAY_NAME
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const database = await createDatabase({ root, seed: initializeDatabase });
|
||||||
const readDb = () => database.read();
|
const readDb = () => database.read();
|
||||||
|
|
||||||
const { parseCookies, currentUser, safeUser, requireUser } = createSessionManager({ sessions, readDb, sendError });
|
const { parseCookies, currentUser, safeUser, requireUser } = createSessionManager({ sessions, readDb, sendError });
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function createPublicViews(context) {
|
|||||||
function renderAuth(kind) {
|
function renderAuth(kind) {
|
||||||
const login = kind === 'login';
|
const login = kind === 'login';
|
||||||
const selfRegistration = state.publicData.selfRegistrationEnabled;
|
const selfRegistration = state.publicData.selfRegistrationEnabled;
|
||||||
app.innerHTML = `<main class="auth-page"><section class="auth-story"><div>${brand()}<p class="overline">CANDIDATE SERVICE</p><h1>${login ? '凭一个号码,' : '自主申请,'}<br><em>${login ? '办理每一次考试。' : '领取固定报名号。'}</em></h1><p>报名号就是考生账户,不因考试、科目或年度报名而改变。</p></div><div class="auth-quote"><span>首次登录顺序</span><p>修改初始密码 → 补全个人信息 → 等待资料审核。</p></div></section><section class="auth-panel"><button class="back-link" data-route="home">← 返回首页</button><div class="auth-card"><p class="overline">${login ? 'ACCOUNT LOGIN' : 'CANDIDATE NUMBER'}</p><h2>${login ? '报名号登录' : '自主申请报名号'}</h2><p>${login ? '考生填写报名号和密码;管理员继续使用管理账号。' : selfRegistration ? '提交基础学籍范围后,系统生成一个长期使用的报名号。' : '当前未开放自主注册,请联系学校领取报名号和初始密码。'}</p>${login ? loginForm() : selfRegistration ? registerForm() : '<div class="registration-closed"><strong>自主注册已关闭</strong><span>学校管理员会为考生创建账户并下发初始密码。</span><button class="solid-button" data-route="login">返回报名号登录</button></div>'}${login && selfRegistration ? `<div class="auth-switch">还没有报名号?<button data-route="register">自主申请</button></div>` : !login ? '<div class="auth-switch">已经有报名号?<button data-route="login">返回登录</button></div>' : ''}${login ? `<div class="demo-accounts"><strong>演示账号</strong><button data-action="fill-demo" data-type="candidate">考生:2026-HZ01-F-0001 / Candidate123!</button><button data-action="fill-demo" data-type="admin">超级管理员:admin / Admin123!</button><button data-action="fill-demo" data-type="school">校级管理员:school_admin / School123!</button><button data-action="fill-demo" data-type="class">班级管理员:class_admin / Class123!</button></div>` : ''}</div></section></main>`;
|
app.innerHTML = `<main class="auth-page"><section class="auth-story"><div>${brand()}<p class="overline">CANDIDATE SERVICE</p><h1>${login ? '凭一个号码,' : '自主申请,'}<br><em>${login ? '办理每一次考试。' : '领取固定报名号。'}</em></h1><p>报名号就是考生账户,不因考试、科目或年度报名而改变。</p></div><div class="auth-quote"><span>首次登录顺序</span><p>修改初始密码 → 补全个人信息 → 等待资料审核。</p></div></section><section class="auth-panel"><button class="back-link" data-route="home">← 返回首页</button><div class="auth-card"><p class="overline">${login ? 'ACCOUNT LOGIN' : 'CANDIDATE NUMBER'}</p><h2>${login ? '报名号登录' : '自主申请报名号'}</h2><p>${login ? '考生填写报名号和密码;管理员继续使用管理账号。' : selfRegistration ? '提交基础学籍范围后,系统生成一个长期使用的报名号。' : '当前未开放自主注册,请联系学校领取报名号和初始密码。'}</p>${login ? loginForm() : selfRegistration ? registerForm() : '<div class="registration-closed"><strong>自主注册已关闭</strong><span>学校管理员会为考生创建账户并下发初始密码。</span><button class="solid-button" data-route="login">返回报名号登录</button></div>'}${login && selfRegistration ? `<div class="auth-switch">还没有报名号?<button data-route="register">自主申请</button></div>` : !login ? '<div class="auth-switch">已经有报名号?<button data-route="login">返回登录</button></div>' : ''}</div></section></main>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loginForm() {
|
function loginForm() {
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
const admissionNumberRules = (nowIso) => [
|
||||||
|
{
|
||||||
|
id: 'admit_rule_district_room_seat', code: 'district_room_seat', name: '县区编号 + 考场号 + 座位号',
|
||||||
|
description: '适合县区统一组织,号码直接反映县区、考试考场与座位。', separator: '', example: '32070603108', active: true, createdAt: nowIso(),
|
||||||
|
segments: [
|
||||||
|
{ source: 'district_code', label: '县区编号', width: 6 },
|
||||||
|
{ source: 'exam_room_code', label: '考场号', width: 3 },
|
||||||
|
{ source: 'seat', label: '座位号', width: 2 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'admit_rule_district_room_sequence', code: 'district_room_sequence', name: '县区号 + 考场号 + 流水号',
|
||||||
|
description: '以县区为流水边界,适合不希望座位号直接出现在号码中的场景。', separator: '', example: '3207060310028', active: true, createdAt: nowIso(),
|
||||||
|
segments: [
|
||||||
|
{ source: 'district_code', label: '县区号', width: 6 },
|
||||||
|
{ source: 'exam_room_code', label: '考场号', width: 3 },
|
||||||
|
{ source: 'sequence', label: '流水号', width: 4 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'admit_rule_center_school_room_seat', code: 'center_school_room_seat', name: '考点学校代码 + 考场号 + 座位号',
|
||||||
|
description: '号码前缀取考点所属学校代码,便于考点现场快速识别。', separator: '', example: 'HZ0303108', active: true, createdAt: nowIso(),
|
||||||
|
segments: [
|
||||||
|
{ source: 'center_school_code', label: '考点学校代码' },
|
||||||
|
{ source: 'exam_room_code', label: '考场号', width: 3 },
|
||||||
|
{ source: 'seat', label: '座位号', width: 2 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'admit_rule_candidate_school_room_seat', code: 'candidate_school_room_seat', name: '考生学校代码 + 考场号 + 座位号',
|
||||||
|
description: '号码前缀保留考生学籍学校代码,适合按生源学校归档。', separator: '', example: 'HZ0103108', active: true, createdAt: nowIso(),
|
||||||
|
segments: [
|
||||||
|
{ source: 'candidate_school_code', label: '考生学校代码' },
|
||||||
|
{ source: 'exam_room_code', label: '考场号', width: 3 },
|
||||||
|
{ source: 'seat', label: '座位号', width: 2 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const workflows = (adminId, nowIso) => [
|
||||||
|
{ id: 'workflow_profile', businessType: 'profile_change', name: '考生信息修改审批', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [
|
||||||
|
{ id: 'workflow_profile_step_1', position: 1, name: '学校学籍复核', adminLevel: 'school' },
|
||||||
|
{ id: 'workflow_profile_step_2', position: 2, name: '考试中心终审', adminLevel: 'super' }
|
||||||
|
] },
|
||||||
|
{ id: 'workflow_registration', businessType: 'registration_review', name: '考试报名审核', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [
|
||||||
|
{ id: 'workflow_registration_step_1', position: 1, name: '学校报名初审', adminLevel: 'school' },
|
||||||
|
{ id: 'workflow_registration_step_2', position: 2, name: '考试中心终审', adminLevel: 'super' }
|
||||||
|
] },
|
||||||
|
{ id: 'workflow_center', businessType: 'center_change', name: '考点考场变更审批', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [
|
||||||
|
{ id: 'workflow_center_step_1', position: 1, name: '考试中心考务终审', adminLevel: 'super' }
|
||||||
|
] },
|
||||||
|
{ id: 'workflow_account_batch', businessType: 'candidate_account_batch', name: '批量报名号申领审批', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [
|
||||||
|
{ id: 'workflow_account_batch_step_1', position: 1, name: '考试中心账号终审', adminLevel: 'super' }
|
||||||
|
] },
|
||||||
|
{ id: 'workflow_score_appeal', businessType: 'score_appeal', name: '考生成绩复议', active: true, updatedBy: adminId, updatedAt: nowIso(), steps: [
|
||||||
|
{ id: 'workflow_score_appeal_step_1', position: 1, name: '班级情况核验', adminLevel: 'class' },
|
||||||
|
{ id: 'workflow_score_appeal_step_2', position: 2, name: '学校成绩复核', adminLevel: 'school' },
|
||||||
|
{ id: 'workflow_score_appeal_step_3', position: 3, name: '考试中心终审', adminLevel: 'super' }
|
||||||
|
] }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function createBaseDatabase({ nowIso, hashPassword, initialAdmin = {} }) {
|
||||||
|
const adminId = 'usr_admin';
|
||||||
|
const createdAt = nowIso();
|
||||||
|
return {
|
||||||
|
meta: { version: 15, createdAt },
|
||||||
|
settings: { selfRegistrationEnabled: false },
|
||||||
|
organization: { name: '考试服务平台', code: 'EXAM-SERVICE', phone: '', address: '' },
|
||||||
|
schools: [], classes: [],
|
||||||
|
users: [{
|
||||||
|
id: adminId, username: initialAdmin.username || 'admin', passwordHash: hashPassword(initialAdmin.password || 'Admin123!'),
|
||||||
|
role: 'admin', adminLevel: 'super', displayName: initialAdmin.displayName || '系统管理员', active: true, createdAt
|
||||||
|
}],
|
||||||
|
candidateProfiles: [], notices: [], exams: [], registrations: [], results: [],
|
||||||
|
testCenters: [], testRooms: [], centerChangeRequests: [], centerChangeRooms: [],
|
||||||
|
admissionNumberRules: admissionNumberRules(nowIso), arrangementPlans: [],
|
||||||
|
candidateAccountBatches: [], candidateAccountBatchItems: [],
|
||||||
|
numberRules: [{
|
||||||
|
id: 'rule_default', name: '年度学校性别流水号', separator: '-', active: true, createdBy: adminId, updatedAt: nowIso(), segments: [
|
||||||
|
{ id: 'segment_year', position: 1, type: 'year', value: '', width: 4 },
|
||||||
|
{ id: 'segment_school', position: 2, type: 'school_code', value: '', width: 0 },
|
||||||
|
{ id: 'segment_gender', position: 3, type: 'gender', value: '', width: 0 },
|
||||||
|
{ id: 'segment_sequence', position: 4, type: 'sequence', value: '', width: 4 }
|
||||||
|
]
|
||||||
|
}],
|
||||||
|
workflows: workflows(adminId, nowIso), workflowInstances: [], workflowActions: [], auditLogs: []
|
||||||
|
};
|
||||||
|
}
|
||||||
+141
-23
@@ -5,7 +5,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
|||||||
const candidateId = 'usr_demo';
|
const candidateId = 'usr_demo';
|
||||||
const examId = 'exam_autumn_2026';
|
const examId = 'exam_autumn_2026';
|
||||||
const registrationId = 'reg_demo_2026';
|
const registrationId = 'reg_demo_2026';
|
||||||
return {
|
const database = {
|
||||||
meta: { version: 15, createdAt: nowIso() },
|
meta: { version: 15, createdAt: nowIso() },
|
||||||
settings: { selfRegistrationEnabled: false },
|
settings: { selfRegistrationEnabled: false },
|
||||||
organization: {
|
organization: {
|
||||||
@@ -75,23 +75,10 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
|||||||
registrations: [
|
registrations: [
|
||||||
{
|
{
|
||||||
id: registrationId, userId: candidateId, examId, subjectIds: ['sub_chinese', 'sub_math', 'sub_physics', 'sub_english', 'sub_chemistry'],
|
id: registrationId, userId: candidateId, examId, subjectIds: ['sub_chinese', 'sub_math', 'sub_physics', 'sub_english', 'sub_chemistry'],
|
||||||
status: 'approved', paymentStatus: 'paid', paidAt: '2026-07-18T09:00:00.000Z', paidBy: 'usr_class_admin', createdAt: '2026-07-08T05:18:00.000Z', reviewedAt: '2026-07-18T08:32:00.000Z', registrationNumber: '2026-HZ01-F-0001', numberRuleId: 'rule_default',
|
status: 'approved', paymentStatus: 'paid', paidAt: '2026-07-18T09:00:00.000Z', paidBy: 'usr_class_admin', createdAt: '2026-07-08T05:18:00.000Z', reviewedAt: '2026-07-18T08:32:00.000Z', registrationNumber: '2026-HZ01-F-0001', numberRuleId: 'rule_default'
|
||||||
admitCard: {
|
|
||||||
planId: 'arrangement_demo_2026', number: '32070600108', centerId: 'center_hz3', testCenter: '海州市第三中学考点', centerCode: 'HZ03-C01', centerAddress: '江苏省 连云港市 连云区 育才路 16 号',
|
|
||||||
room: '第 001 考场', seat: '08', generatedAt: '2026-07-19T02:00:00.000Z', assignments: [
|
|
||||||
{ subjectId: 'sub_chinese', roomId: 'room_hz3_001', roomName: '第 001 考场', roomCode: '001', examRoomCode: '001', building: '笃学楼', floor: '1 层', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' },
|
|
||||||
{ subjectId: 'sub_math', roomId: 'room_hz3_001', roomName: '第 001 考场', roomCode: '001', examRoomCode: '001', building: '笃学楼', floor: '1 层', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' },
|
|
||||||
{ subjectId: 'sub_physics', roomId: 'room_hz3_002', roomName: '第 002 考场', roomCode: '002', examRoomCode: '002', building: '笃学楼', floor: '1 层', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' },
|
|
||||||
{ subjectId: 'sub_english', roomId: 'room_hz3_001', roomName: '第 001 考场', roomCode: '001', examRoomCode: '001', building: '笃学楼', floor: '1 层', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' },
|
|
||||||
{ subjectId: 'sub_chemistry', roomId: 'room_hz3_002', roomName: '第 002 考场', roomCode: '002', examRoomCode: '002', building: '笃学楼', floor: '1 层', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
results: [
|
results: [],
|
||||||
{ id: 'result_demo_1', registrationId, subjectId: 'sub_chinese', score: 118, grade: 'B+', published: true, publishedAt: '2026-07-19T03:00:00.000Z' },
|
|
||||||
{ id: 'result_demo_2', registrationId, subjectId: 'sub_math', score: 132, grade: 'A', published: true, publishedAt: '2026-07-19T03:00:00.000Z' }
|
|
||||||
],
|
|
||||||
testCenters: [
|
testCenters: [
|
||||||
{ id: 'center_hz1', schoolId: 'school_hz1', code: 'HZ01-C01', name: '海州市第一中学考点', provinceCode: '320000', provinceName: '江苏省', cityCode: '320700', cityName: '连云港市', districtCode: '320706', districtName: '海州区', address: '学府路 8 号', contact: '0518-8602 1101', managerName: '王立新', managerPhone: '13800001101', emergencyPhone: '0518-8602 1190', gateOpenTime: '07:00', transport: '地铁 2 号线学府路站 2 号口,步行约 600 米', status: 'active', notes: '南门为考生唯一入口,无障碍通道位于东侧。', rooms: '教学楼 A:001、002;实验楼:机考 01', updatedAt: nowIso() },
|
{ id: 'center_hz1', schoolId: 'school_hz1', code: 'HZ01-C01', name: '海州市第一中学考点', provinceCode: '320000', provinceName: '江苏省', cityCode: '320700', cityName: '连云港市', districtCode: '320706', districtName: '海州区', address: '学府路 8 号', contact: '0518-8602 1101', managerName: '王立新', managerPhone: '13800001101', emergencyPhone: '0518-8602 1190', gateOpenTime: '07:00', transport: '地铁 2 号线学府路站 2 号口,步行约 600 米', status: 'active', notes: '南门为考生唯一入口,无障碍通道位于东侧。', rooms: '教学楼 A:001、002;实验楼:机考 01', updatedAt: nowIso() },
|
||||||
{ id: 'center_hz3', schoolId: 'school_hz3', code: 'HZ03-C01', name: '海州市第三中学考点', provinceCode: '320000', provinceName: '江苏省', cityCode: '320700', cityName: '连云港市', districtCode: '320703', districtName: '连云区', address: '育才路 16 号', contact: '0518-8602 3301', managerName: '李文峰', managerPhone: '13800003301', emergencyPhone: '0518-8602 3390', gateOpenTime: '07:10', transport: '公交 18、32 路育才路站,考点不提供社会车辆停车位', status: 'active', notes: '西门设置临时物品存放区。', rooms: '笃学楼:001、002', updatedAt: nowIso() }
|
{ id: 'center_hz3', schoolId: 'school_hz3', code: 'HZ03-C01', name: '海州市第三中学考点', provinceCode: '320000', provinceName: '江苏省', cityCode: '320700', cityName: '连云港市', districtCode: '320703', districtName: '连云区', address: '育才路 16 号', contact: '0518-8602 3301', managerName: '李文峰', managerPhone: '13800003301', emergencyPhone: '0518-8602 3390', gateOpenTime: '07:10', transport: '公交 18、32 路育才路站,考点不提供社会车辆停车位', status: 'active', notes: '西门设置临时物品存放区。', rooms: '笃学楼:001、002', updatedAt: nowIso() }
|
||||||
@@ -143,13 +130,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
arrangementPlans: [
|
arrangementPlans: [],
|
||||||
{
|
|
||||||
id: 'arrangement_demo_2026', examId, numberRuleId: 'admit_rule_district_room_seat', mixingScope: 'city', randomSeed: 'EX-2026-AUT',
|
|
||||||
candidateCount: 1, centerCount: 1, subjectAssignmentCount: 5, subjectCombinationCount: 1, sameSchoolCenterRate: 0,
|
|
||||||
warnings: [], generatedBy: adminId, generatedAt: '2026-07-19T02:00:00.000Z'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
candidateAccountBatches: [],
|
candidateAccountBatches: [],
|
||||||
candidateAccountBatchItems: [],
|
candidateAccountBatchItems: [],
|
||||||
numberRules: [
|
numberRules: [
|
||||||
@@ -187,4 +168,141 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
|||||||
{ id: 'log_1', actorId: adminId, action: '发布通知', detail: '发布《市第三中学考点交通提示》', createdAt: '2026-07-18T06:00:00.000Z' }
|
{ id: 'log_1', actorId: adminId, action: '发布通知', detail: '发布《市第三中学考点交通提示》', createdAt: '2026-07-18T06:00:00.000Z' }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const schoolDefinitions = [
|
||||||
|
{ key: 'hz1', id: 'school_hz1', code: 'HZ01', name: '海州市第一中学', districtCode: '320706', districtName: '海州区', address: '学府路 8 号' },
|
||||||
|
{ key: 'hz3', id: 'school_hz3', code: 'HZ03', name: '海州市第三中学', districtCode: '320703', districtName: '连云区', address: '育才路 16 号' },
|
||||||
|
{ key: 'hz5', id: 'school_hz5', code: 'HZ05', name: '海州市第五中学', districtCode: '320707', districtName: '赣榆区', address: '青口路 28 号' },
|
||||||
|
{ key: 'hz7', id: 'school_hz7', code: 'HZ07', name: '海州市第七中学', districtCode: '320723', districtName: '灌云县', address: '胜利路 66 号' }
|
||||||
|
];
|
||||||
|
for (const school of schoolDefinitions) {
|
||||||
|
if (!database.schools.some(item => item.id === school.id)) {
|
||||||
|
database.schools.push({ id: school.id, name: school.name, code: school.code, address: `江苏省连云港市${school.districtName}${school.address}`, active: true });
|
||||||
|
}
|
||||||
|
for (let classIndex = 1; classIndex <= 3; classIndex += 1) {
|
||||||
|
const classId = `class_${school.key}_30${classIndex}`;
|
||||||
|
if (!database.classes.some(item => item.id === classId)) {
|
||||||
|
database.classes.push({ id: classId, schoolId: school.id, name: `高三(${classIndex})班`, grade: '高三', active: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const schoolPasswordHash = hashPassword('School123!');
|
||||||
|
const classPasswordHash = hashPassword('Class123!');
|
||||||
|
const candidatePasswordHash = hashPassword('Candidate123!');
|
||||||
|
for (const school of schoolDefinitions) {
|
||||||
|
const generatedSchoolAdminId = `usr_test_school_admin_${school.key}`;
|
||||||
|
database.users.push({
|
||||||
|
id: generatedSchoolAdminId, username: `test_school_admin_${school.key}`, passwordHash: schoolPasswordHash,
|
||||||
|
role: 'admin', adminLevel: 'school', schoolId: school.id, displayName: `${school.name}测试校管`, active: true, createdAt: nowIso()
|
||||||
|
});
|
||||||
|
for (let classIndex = 1; classIndex <= 3; classIndex += 1) {
|
||||||
|
const classId = `class_${school.key}_30${classIndex}`;
|
||||||
|
database.users.push({
|
||||||
|
id: `usr_test_class_admin_${school.key}_${classIndex}`, username: `test_class_admin_${school.key}_${classIndex}`,
|
||||||
|
passwordHash: classPasswordHash, role: 'admin', adminLevel: 'class', schoolId: school.id, classId,
|
||||||
|
displayName: `${school.name}高三${classIndex}班测试班管`, active: true, createdAt: nowIso()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const school of schoolDefinitions) {
|
||||||
|
let center = database.testCenters.find(item => item.schoolId === school.id);
|
||||||
|
if (!center) {
|
||||||
|
center = {
|
||||||
|
id: `center_${school.key}`, schoolId: school.id, code: `${school.code}-C01`, name: `${school.name}考点`,
|
||||||
|
provinceCode: '320000', provinceName: '江苏省', cityCode: '320700', cityName: '连云港市',
|
||||||
|
districtCode: school.districtCode, districtName: school.districtName, address: school.address,
|
||||||
|
contact: '0518-8602 0000', managerName: '测试负责人', managerPhone: '13800000000', emergencyPhone: '0518-8602 0120',
|
||||||
|
gateOpenTime: '07:00', transport: '测试数据:请以正式考点通知为准', status: 'active', notes: '仅供手动导入测试数据使用', rooms: '', updatedAt: nowIso()
|
||||||
|
};
|
||||||
|
database.testCenters.push(center);
|
||||||
|
}
|
||||||
|
for (let roomIndex = 1; roomIndex <= 4; roomIndex += 1) {
|
||||||
|
const roomId = `room_${school.key}_test_${roomIndex}`;
|
||||||
|
if (!database.testRooms.some(item => item.id === roomId)) {
|
||||||
|
database.testRooms.push({
|
||||||
|
id: roomId, centerId: center.id, code: `T0${roomIndex}`, name: `测试第 ${roomIndex} 考场`, building: '测试教学楼',
|
||||||
|
floor: `${Math.ceil(roomIndex / 2)} 层`, capacity: 30, seatPlan: '等待正式编排', roomType: 'standard', status: 'active', notes: '未编排'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试库停留在考场编排前:不预置编排计划、准考证或成绩。
|
||||||
|
const familyNames = ['赵', '钱', '孙', '李', '周', '吴', '郑', '王', '冯', '陈', '褚', '卫'];
|
||||||
|
const givenNames = ['子涵', '梓萱', '宇航', '雨欣', '浩然', '思远', '佳宁', '晨曦', '明轩', '若彤', '嘉诚', '欣怡'];
|
||||||
|
const subjectSets = [
|
||||||
|
['sub_chinese', 'sub_math', 'sub_english'],
|
||||||
|
['sub_chinese', 'sub_math', 'sub_physics', 'sub_chemistry'],
|
||||||
|
['sub_chinese', 'sub_math', 'sub_history', 'sub_biology'],
|
||||||
|
['sub_chinese', 'sub_math', 'sub_english', 'sub_physics', 'sub_chemistry'],
|
||||||
|
['sub_chinese', 'sub_math', 'sub_english', 'sub_history', 'sub_biology']
|
||||||
|
];
|
||||||
|
const registrationWorkflowId = 'workflow_registration';
|
||||||
|
for (let index = 0; index < 360; index += 1) {
|
||||||
|
const serial = index + 1001;
|
||||||
|
const school = schoolDefinitions[index % schoolDefinitions.length];
|
||||||
|
const classIndex = Math.floor(index / schoolDefinitions.length) % 3 + 1;
|
||||||
|
const classId = `class_${school.key}_30${classIndex}`;
|
||||||
|
const gender = index % 2 === 0 ? '男' : '女';
|
||||||
|
const genderCode = gender === '男' ? 'M' : 'F';
|
||||||
|
const userId = `usr_bulk_${String(index + 1).padStart(4, '0')}`;
|
||||||
|
const profileId = `profile_bulk_${String(index + 1).padStart(4, '0')}`;
|
||||||
|
const registrationIdBulk = `reg_bulk_${String(index + 1).padStart(4, '0')}`;
|
||||||
|
const candidateNumber = `2026-${school.code}-${genderCode}-${String(serial).padStart(4, '0')}`;
|
||||||
|
const createdAt = new Date(Date.UTC(2026, 6, 2 + (index % 20), 1 + (index % 8), index % 60)).toISOString();
|
||||||
|
const name = `${familyNames[index % familyNames.length]}${givenNames[Math.floor(index / familyNames.length) % givenNames.length]}${Math.floor(index / 144) + 1}`;
|
||||||
|
const idNumber = `3207002008${String(index % 12 + 1).padStart(2, '0')}${String(index % 28 + 1).padStart(2, '0')}${String(index + 1).padStart(4, '0')}`;
|
||||||
|
const phone = `138${String(10000000 + index).padStart(8, '0')}`;
|
||||||
|
database.users.push({
|
||||||
|
id: userId, username: candidateNumber, candidateNumber, passwordHash: candidatePasswordHash, role: 'candidate',
|
||||||
|
displayName: name, active: true, mustChangePassword: false, createdAt
|
||||||
|
});
|
||||||
|
database.candidateProfiles.push({
|
||||||
|
id: profileId, userId, name, gender, idNumber, phone, email: `candidate${String(index + 1).padStart(4, '0')}@example.test`,
|
||||||
|
school: school.name, grade: `高三(${classIndex})班`, schoolId: school.id, classId,
|
||||||
|
provinceCode: '320000', provinceName: '江苏省', cityCode: '320700', cityName: '连云港市',
|
||||||
|
districtCode: school.districtCode, districtName: school.districtName, address: `${school.address}测试宿舍 ${index % 20 + 1} 号`,
|
||||||
|
emergencyContact: `${familyNames[index % familyNames.length]}家长`, emergencyPhone: `139${String(10000000 + index).padStart(8, '0')}`,
|
||||||
|
nativePlace: `江苏${school.districtName}`, birthDate: `2008-${String(index % 12 + 1).padStart(2, '0')}-${String(index % 28 + 1).padStart(2, '0')}`,
|
||||||
|
ethnicity: index % 19 === 0 ? '回族' : '汉族', postalCode: '222000', guardianName: `${familyNames[index % familyNames.length]}家长`,
|
||||||
|
guardianPhone: `139${String(10000000 + index).padStart(8, '0')}`, profileCompleted: true, status: 'approved',
|
||||||
|
reviewNote: '批量测试数据:学籍核验通过', reviewedAt: '2026-06-30T08:00:00.000Z', reviewerId: adminId, updatedAt: createdAt
|
||||||
|
});
|
||||||
|
|
||||||
|
const scenario = index % 4;
|
||||||
|
const status = scenario === 0 ? 'pending' : scenario === 1 ? 'rejected' : 'approved';
|
||||||
|
const paymentStatus = scenario === 3 ? 'paid' : 'unpaid';
|
||||||
|
const classAdminId = `usr_test_class_admin_${school.key}_${classIndex}`;
|
||||||
|
const schoolAdmin = `usr_test_school_admin_${school.key}`;
|
||||||
|
database.registrations.push({
|
||||||
|
id: registrationIdBulk, userId, examId, subjectIds: subjectSets[index % subjectSets.length], status, paymentStatus,
|
||||||
|
paidAt: paymentStatus === 'paid' ? '2026-07-25T08:30:00.000Z' : null,
|
||||||
|
paidBy: paymentStatus === 'paid' ? classAdminId : null,
|
||||||
|
createdAt, reviewedAt: status === 'pending' ? null : '2026-07-22T08:00:00.000Z',
|
||||||
|
reviewNote: status === 'rejected' ? '测试场景:报名资料被退回' : status === 'approved' ? '测试场景:报名审核通过' : '',
|
||||||
|
registrationNumber: candidateNumber, numberRuleId: 'rule_default'
|
||||||
|
});
|
||||||
|
if (status === 'pending' || status === 'rejected') {
|
||||||
|
const instanceId = `flow_reg_bulk_${String(index + 1).padStart(4, '0')}`;
|
||||||
|
database.workflowInstances.push({
|
||||||
|
id: instanceId, workflowId: registrationWorkflowId, businessType: 'registration_review', businessId: registrationIdBulk,
|
||||||
|
status, currentStep: 1, assigneeId: status === 'pending' ? schoolAdmin : null, createdAt,
|
||||||
|
completedAt: status === 'rejected' ? '2026-07-22T08:00:00.000Z' : null
|
||||||
|
});
|
||||||
|
database.workflowActions.push({
|
||||||
|
id: `flow_action_submit_bulk_${String(index + 1).padStart(4, '0')}`, instanceId, actorId: userId, action: 'submit',
|
||||||
|
note: '提交考试报名', fromAssigneeId: null, toAssigneeId: schoolAdmin, createdAt
|
||||||
|
});
|
||||||
|
if (status === 'rejected') {
|
||||||
|
database.workflowActions.push({
|
||||||
|
id: `flow_action_reject_bulk_${String(index + 1).padStart(4, '0')}`, instanceId, actorId: schoolAdmin, action: 'reject',
|
||||||
|
note: '测试场景:报名资料被退回', fromAssigneeId: schoolAdmin, toAssigneeId: null, createdAt: '2026-07-22T08:00:00.000Z'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return database;
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-1
@@ -1,9 +1,12 @@
|
|||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
|
import { pbkdf2Sync, randomBytes } from 'node:crypto';
|
||||||
import { readFile, rm } from 'node:fs/promises';
|
import { readFile, rm } from 'node:fs/promises';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import ExcelJS from 'exceljs';
|
import ExcelJS from 'exceljs';
|
||||||
import { relationalTables } from '../database.mjs';
|
import { createDatabase, relationalTables } from '../database.mjs';
|
||||||
|
import { createBaseDatabase } from '../src/data/base.mjs';
|
||||||
|
import { createSeedDatabase } from '../src/data/seed.mjs';
|
||||||
import { mysqlSchema } from '../src/database/schema.mjs';
|
import { mysqlSchema } from '../src/database/schema.mjs';
|
||||||
import { buildCenterMaterialsWorkbook, buildWorkbook } from '../excel.mjs';
|
import { buildCenterMaterialsWorkbook, buildWorkbook } from '../excel.mjs';
|
||||||
|
|
||||||
@@ -36,10 +39,31 @@ assert.match(mysqlAdapterSource, /existingResultLockTriggers\.has\(name\)\) awai
|
|||||||
assert.doesNotMatch(mysqlAdapterSource, /\.execute\(\s*['"`]\s*(?:CREATE|ALTER|DROP|SHOW)\b/i, 'MySQL DDL 和 SHOW 语句不得通过预处理协议执行');
|
assert.doesNotMatch(mysqlAdapterSource, /\.execute\(\s*['"`]\s*(?:CREATE|ALTER|DROP|SHOW)\b/i, 'MySQL DDL 和 SHOW 语句不得通过预处理协议执行');
|
||||||
assert.match(mysqlAdapterSource, /existingAppTables\.length && \(!hasSchemaMetadata \|\| existingSchemaVersion !== 15\)/, 'MySQL 应重建未完成或过期的开发结构');
|
assert.match(mysqlAdapterSource, /existingAppTables\.length && \(!hasSchemaMetadata \|\| existingSchemaVersion !== 15\)/, 'MySQL 应重建未完成或过期的开发结构');
|
||||||
assert.match(mysqlAdapterSource, /\[\.\.\.mysqlTableNames\]\.reverse\(\)/, 'MySQL 半成品表应按外键依赖逆序清理');
|
assert.match(mysqlAdapterSource, /\[\.\.\.mysqlTableNames\]\.reverse\(\)/, 'MySQL 半成品表应按外键依赖逆序清理');
|
||||||
|
const serverSource = await readFile(resolve(root, 'server.mjs'), 'utf8');
|
||||||
|
assert.doesNotMatch(serverSource, /src\/data\/seed\.mjs|createSeedDatabase/, '服务启动不得引用测试数据生成器');
|
||||||
|
const baseState = createBaseDatabase({ nowIso: () => new Date().toISOString(), hashPassword: password => `test-${password}` });
|
||||||
|
assert.equal(baseState.schools.length, 0, '正常首次建库不得预置学校');
|
||||||
|
assert.equal(baseState.candidateProfiles.length, 0, '正常首次建库不得预置考生');
|
||||||
|
assert.equal(baseState.exams.length, 0, '正常首次建库不得预置考试');
|
||||||
|
assert.equal(baseState.registrations.length, 0, '正常首次建库不得预置报名');
|
||||||
await rm(testDb, { force: true });
|
await rm(testDb, { force: true });
|
||||||
await rm(`${testDb}-shm`, { force: true });
|
await rm(`${testDb}-shm`, { force: true });
|
||||||
await rm(`${testDb}-wal`, { force: true });
|
await rm(`${testDb}-wal`, { force: true });
|
||||||
|
|
||||||
|
function hashPassword(password, salt = randomBytes(16).toString('hex')) {
|
||||||
|
const hash = pbkdf2Sync(password, salt, 120000, 32, 'sha256').toString('hex');
|
||||||
|
return `${salt}:${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 系统测试显式准备测试库;服务启动本身只会初始化空业务库。
|
||||||
|
process.env.DATABASE_CLIENT = 'sqlite';
|
||||||
|
process.env.SQLITE_PATH = testDb;
|
||||||
|
const seededTestDatabase = await createDatabase({
|
||||||
|
root,
|
||||||
|
seed: () => createSeedDatabase({ nowIso: () => new Date().toISOString(), hashPassword })
|
||||||
|
});
|
||||||
|
await seededTestDatabase.close();
|
||||||
|
|
||||||
const server = spawn(process.execPath, ['server.mjs'], {
|
const server = spawn(process.execPath, ['server.mjs'], {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
env: { ...process.env, NODE_ENV: 'test', DATABASE_CLIENT: 'sqlite', PORT: String(port), SQLITE_PATH: testDb },
|
env: { ...process.env, NODE_ENV: 'test', DATABASE_CLIENT: 'sqlite', PORT: String(port), SQLITE_PATH: testDb },
|
||||||
@@ -127,6 +151,15 @@ try {
|
|||||||
const examColumns = inspector.prepare('PRAGMA table_info(exams)').all().map(row => row.name);
|
const examColumns = inspector.prepare('PRAGMA table_info(exams)').all().map(row => row.name);
|
||||||
const userColumns = inspector.prepare('PRAGMA table_info(users)').all().map(row => row.name);
|
const userColumns = inspector.prepare('PRAGMA table_info(users)').all().map(row => row.name);
|
||||||
const registrationColumns = inspector.prepare('PRAGMA table_info(registrations)').all().map(row => row.name);
|
const registrationColumns = inspector.prepare('PRAGMA table_info(registrations)').all().map(row => row.name);
|
||||||
|
const seededSchoolCount = inspector.prepare('SELECT COUNT(*) AS count FROM schools').get().count;
|
||||||
|
const seededCandidateCount = inspector.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'candidate'").get().count;
|
||||||
|
const seededRegistrationCounts = inspector.prepare(`
|
||||||
|
SELECT status, payment_status, COUNT(*) AS count
|
||||||
|
FROM registrations
|
||||||
|
GROUP BY status, payment_status
|
||||||
|
`).all();
|
||||||
|
const seededArrangementCount = inspector.prepare('SELECT COUNT(*) AS count FROM exam_arrangement_plans').get().count;
|
||||||
|
const seededAdmitCardCount = inspector.prepare('SELECT COUNT(*) AS count FROM admit_cards').get().count;
|
||||||
inspector.close();
|
inspector.close();
|
||||||
assert.deepEqual(tableNames, [...relationalTables].sort(), '业务数据必须按关系模型分表存储');
|
assert.deepEqual(tableNames, [...relationalTables].sort(), '业务数据必须按关系模型分表存储');
|
||||||
assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储');
|
assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储');
|
||||||
@@ -135,6 +168,12 @@ try {
|
|||||||
assert.equal(resultLockTriggers.length, 3, '数据库应从插入、更新、删除三个方向永久锁定归档成绩');
|
assert.equal(resultLockTriggers.length, 3, '数据库应从插入、更新、删除三个方向永久锁定归档成绩');
|
||||||
assert.ok(['archived_at', 'archived_by'].every(column => userColumns.includes(column)), '账户应保存独立归档状态和校方操作人');
|
assert.ok(['archived_at', 'archived_by'].every(column => userColumns.includes(column)), '账户应保存独立归档状态和校方操作人');
|
||||||
assert.ok(['payment_status', 'paid_at', 'paid_by'].every(column => registrationColumns.includes(column)), '报名应保存缴费状态、确认时间和班级负责人');
|
assert.ok(['payment_status', 'paid_at', 'paid_by'].every(column => registrationColumns.includes(column)), '报名应保存缴费状态、确认时间和班级负责人');
|
||||||
|
assert.ok(seededSchoolCount >= 4, '独立测试数据应覆盖至少四所学校');
|
||||||
|
assert.ok(seededCandidateCount >= 360, '独立测试数据应包含数百名考生');
|
||||||
|
assert.ok(seededRegistrationCounts.some(item => item.status === 'approved' && item.payment_status === 'unpaid' && item.count >= 90), '测试数据应包含大量已报名未缴费记录');
|
||||||
|
assert.ok(seededRegistrationCounts.some(item => item.status === 'approved' && item.payment_status === 'paid' && item.count >= 90), '测试数据应包含大量已报名且缴费记录');
|
||||||
|
assert.equal(seededArrangementCount, 0, '测试数据不得预置考场编排计划');
|
||||||
|
assert.equal(seededAdmitCardCount, 0, '测试数据不得预置准考证或考场座位');
|
||||||
assert.ok(['center_code', 'center_address'].every(column => admitCardColumns.includes(column)), '准考证应保存考点代码与详细地址快照');
|
assert.ok(['center_code', 'center_address'].every(column => admitCardColumns.includes(column)), '准考证应保存考点代码与详细地址快照');
|
||||||
assert.ok(['building', 'floor'].every(column => admitSubjectColumns.includes(column)), '分科考场应保存楼栋楼层快照');
|
assert.ok(['building', 'floor'].every(column => admitSubjectColumns.includes(column)), '分科考场应保存楼栋楼层快照');
|
||||||
assert.ok(['pass_rule', 'pass_value'].every(column => examSubjectColumns.includes(column)), '科目应保存独立及格线计算方式和规则数值');
|
assert.ok(['pass_rule', 'pass_value'].every(column => examSubjectColumns.includes(column)), '科目应保存独立及格线计算方式和规则数值');
|
||||||
|
|||||||
Reference in New Issue
Block a user