修复
This commit is contained in:
@@ -166,15 +166,34 @@ npm run seed-test-data:mysql -- --force
|
||||
|
||||
测试数据脚本会提供以下账号;其中初始超级管理员也可能由正常首次建库创建,并可通过环境变量改名、改密,其余校级、班级和考生账号不会在正常启动时创建:
|
||||
|
||||
为了便于临时联调,所有预置样例账号统一使用密码 `12345678`,且预置考生不会在首次登录时被要求改密。通过系统业务流程后续新建的账号仍按正式规则生成随机初始密码。
|
||||
|
||||
| 角色 | 账号 | 密码 |
|
||||
| --- | --- | --- |
|
||||
| 超级管理员 | `admin` | `Admin123!` |
|
||||
| 超级管理员(监督演示) | `supervisor` | `Admin123!` |
|
||||
| 校级管理员 | `school_admin` | `School123!` |
|
||||
| 同校校级管理员(转交演示) | `school_admin_2` | `School123!` |
|
||||
| 班级管理员 | `class_admin` | `Class123!` |
|
||||
| 同班班级管理员(均分演示) | `class_admin_2` | `Class123!` |
|
||||
| 考生(首次登录需改密) | `2026-HZ01-F-0001` | `Candidate123!` |
|
||||
| 超级管理员 | `admin` | `12345678` |
|
||||
| 超级管理员(监督演示) | `supervisor` | `12345678` |
|
||||
| 校级管理员 | `school_admin` | `12345678` |
|
||||
| 同校校级管理员(转交演示) | `school_admin_2` | `12345678` |
|
||||
| 班级管理员 | `class_admin` | `12345678` |
|
||||
| 同班班级管理员(均分演示) | `class_admin_2` | `12345678` |
|
||||
| 考生 | `2026-HZ01-F-0001` | `12345678` |
|
||||
|
||||
## 测试结束后初始化系统
|
||||
|
||||
先停止应用,然后运行以下命令。命令会读取 `.env` 并自动选择 SQLite 或 MySQL,删除样例学校、考生、考试、报名等业务数据,恢复系统基础配置和一个初始超级管理员:
|
||||
|
||||
```powershell
|
||||
npm run initialize-system
|
||||
```
|
||||
|
||||
也可以明确指定数据库类型:
|
||||
|
||||
```powershell
|
||||
npm run initialize-system:sqlite
|
||||
npm run initialize-system:mysql
|
||||
```
|
||||
|
||||
MySQL 模式会自动识别由本项目生成的批量样例数据并清理。若目标包含无法识别为样例数据的业务记录,命令会拒绝执行;只有明确确认目标可完全清空时才可运行 `npm run initialize-system:mysql -- --force`。初始化完成后,初始管理员账号由 `.env` 中的 `INITIAL_ADMIN_USERNAME`、`INITIAL_ADMIN_PASSWORD`、`INITIAL_ADMIN_DISPLAY_NAME` 决定,然后再重新启动应用。
|
||||
|
||||
## 自动化测试
|
||||
|
||||
|
||||
+4
-1
@@ -9,7 +9,10 @@
|
||||
"reset-db": "node scripts/reset-dev-database.mjs",
|
||||
"seed-test-data": "node scripts/import-test-data.mjs",
|
||||
"seed-test-data:sqlite": "node scripts/import-test-data.mjs --sqlite",
|
||||
"seed-test-data:mysql": "node scripts/import-test-data.mjs --mysql"
|
||||
"seed-test-data:mysql": "node scripts/import-test-data.mjs --mysql",
|
||||
"initialize-system": "node scripts/import-test-data.mjs --empty",
|
||||
"initialize-system:sqlite": "node scripts/import-test-data.mjs --empty --sqlite",
|
||||
"initialize-system:mysql": "node scripts/import-test-data.mjs --empty --mysql"
|
||||
},
|
||||
"dependencies": {
|
||||
"ckeditor5": "^48.3.1",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { rm } from 'node:fs/promises';
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import { loadEnvFile } from 'node:process';
|
||||
import { buildSeedOperations, createDatabase, relationalTables } from '../database.mjs';
|
||||
import { createBaseDatabase } from '../src/data/base.mjs';
|
||||
import { createSeedDatabase } from '../src/data/seed.mjs';
|
||||
|
||||
const root = resolve(process.cwd());
|
||||
@@ -17,13 +18,24 @@ const client = String(configuredClient || (process.env.NODE_ENV === 'production'
|
||||
if (!['sqlite', 'mysql'].includes(client)) throw new Error(`不支持的 DATABASE_CLIENT:${client}`);
|
||||
process.env.DATABASE_CLIENT = client;
|
||||
const force = options.has('--force');
|
||||
const initializeEmpty = options.has('--empty');
|
||||
|
||||
function hashPassword(password, salt = randomBytes(16).toString('hex')) {
|
||||
const hash = pbkdf2Sync(password, salt, 120000, 32, 'sha256').toString('hex');
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
const seed = () => createSeedDatabase({ nowIso: () => new Date().toISOString(), hashPassword });
|
||||
const createTargetState = () => initializeEmpty
|
||||
? 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
|
||||
}
|
||||
})
|
||||
: createSeedDatabase({ nowIso: () => new Date().toISOString(), hashPassword });
|
||||
|
||||
async function prepareSqlite() {
|
||||
const databasePath = resolve(process.env.SQLITE_PATH || join(root, 'data', 'exam.sqlite'));
|
||||
@@ -100,19 +112,28 @@ async function prepareMysql() {
|
||||
}
|
||||
const [[userRow]] = await connection.query('SELECT COUNT(*) AS count FROM users');
|
||||
if (Number(userRow.count) > 1) nonEmpty.push(`users=${userRow.count}`);
|
||||
if (nonEmpty.length && !force) {
|
||||
let recognizedTestData = false;
|
||||
if (initializeEmpty && nonEmpty.length) {
|
||||
const [[bulkUsers]] = await connection.query("SELECT COUNT(*) AS count FROM users WHERE id LIKE 'usr_bulk_%'");
|
||||
const [[testSchools]] = await connection.query("SELECT COUNT(*) AS count FROM schools WHERE id IN ('school_hz1', 'school_hz3', 'school_hz5', 'school_hz7')");
|
||||
recognizedTestData = Number(bulkUsers.count) >= 300 && Number(testSchools.count) === 4;
|
||||
}
|
||||
if (nonEmpty.length && !force && !recognizedTestData) {
|
||||
const forceCommand = initializeEmpty
|
||||
? 'npm run initialize-system:mysql -- --force'
|
||||
: 'npm run seed-test-data:mysql -- --force';
|
||||
throw new Error(
|
||||
`MySQL 数据库 ${databaseName} 已有业务数据(${nonEmpty.slice(0, 8).join(', ')}${nonEmpty.length > 8 ? ', ...' : ''})。` +
|
||||
'如确认这是可覆盖的测试库,请运行 npm run seed-test-data:mysql -- --force'
|
||||
`如确认这是可覆盖的测试库,请运行 ${forceCommand}`
|
||||
);
|
||||
}
|
||||
if (nonEmpty.length) {
|
||||
console.warn(`[force] Replacing existing business data in MySQL database ${databaseName}`);
|
||||
console.warn(`[${recognizedTestData ? 'test-data cleanup' : 'force'}] Replacing existing business data in MySQL database ${databaseName}`);
|
||||
} else {
|
||||
console.log(`Preparing empty MySQL database ${databaseName} for test data`);
|
||||
console.log(`Preparing empty MySQL database ${databaseName} for ${initializeEmpty ? 'system initialization' : 'test data'}`);
|
||||
}
|
||||
|
||||
const state = seed();
|
||||
const state = createTargetState();
|
||||
await connection.query('SET FOREIGN_KEY_CHECKS = 0');
|
||||
await connection.beginTransaction();
|
||||
try {
|
||||
@@ -145,7 +166,7 @@ if (client === 'sqlite') {
|
||||
}
|
||||
const database = await createDatabase({
|
||||
root,
|
||||
seed
|
||||
seed: createTargetState
|
||||
});
|
||||
const state = await database.read();
|
||||
await database.close();
|
||||
@@ -154,7 +175,13 @@ const pending = state.registrations.filter(item => item.status === 'pending').le
|
||||
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;
|
||||
if (initializeEmpty) {
|
||||
console.log(`Initialized empty system in ${location}${mysqlAlreadyImported ? ' (transactional replace)' : ''}`);
|
||||
console.log(`${state.users.length} initial administrator, ${state.schools.length} schools, ${state.candidateProfiles.length} candidates, ${state.registrations.length} registrations`);
|
||||
} else {
|
||||
console.log(`Imported test data into ${location}${mysqlAlreadyImported ? ' (transactional replace)' : ''}`);
|
||||
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}`);
|
||||
console.log('all predefined test account passwords: 12345678');
|
||||
}
|
||||
|
||||
+11
-13
@@ -5,6 +5,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
const candidateId = 'usr_demo';
|
||||
const examId = 'exam_autumn_2026';
|
||||
const registrationId = 'reg_demo_2026';
|
||||
const testPasswordHash = hashPassword('12345678');
|
||||
const database = {
|
||||
meta: { version: 15, createdAt: nowIso() },
|
||||
settings: { selfRegistrationEnabled: false },
|
||||
@@ -24,13 +25,13 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
{ id: 'class_hz3_301', schoolId: 'school_hz3', name: '高三(1)班', grade: '高三', active: true }
|
||||
],
|
||||
users: [
|
||||
{ id: adminId, username: 'admin', passwordHash: hashPassword('Admin123!'), role: 'admin', adminLevel: 'super', displayName: '林老师', active: true, createdAt: nowIso() },
|
||||
{ id: 'usr_supervisor', username: 'supervisor', passwordHash: hashPassword('Admin123!'), role: 'admin', adminLevel: 'super', displayName: '赵督导', active: true, createdAt: nowIso() },
|
||||
{ id: schoolAdminId, username: 'school_admin', passwordHash: hashPassword('School123!'), role: 'admin', adminLevel: 'school', schoolId: 'school_hz1', displayName: '王校管', active: true, createdAt: nowIso() },
|
||||
{ id: schoolAdmin2Id, username: 'school_admin_2', passwordHash: hashPassword('School123!'), role: 'admin', adminLevel: 'school', schoolId: 'school_hz1', displayName: '陈校管', active: true, createdAt: nowIso() },
|
||||
{ id: 'usr_class_admin', username: 'class_admin', passwordHash: hashPassword('Class123!'), role: 'admin', adminLevel: 'class', schoolId: 'school_hz1', classId: 'class_hz1_302', displayName: '孙班管', active: true, createdAt: nowIso() },
|
||||
{ id: 'usr_class_admin_2', username: 'class_admin_2', passwordHash: hashPassword('Class123!'), role: 'admin', adminLevel: 'class', schoolId: 'school_hz1', classId: 'class_hz1_302', displayName: '李班管', active: true, createdAt: nowIso() },
|
||||
{ id: candidateId, username: '2026-HZ01-F-0001', candidateNumber: '2026-HZ01-F-0001', passwordHash: hashPassword('Candidate123!'), role: 'candidate', displayName: '周雨桐', active: true, mustChangePassword: true, createdAt: nowIso() }
|
||||
{ id: adminId, username: 'admin', passwordHash: testPasswordHash, role: 'admin', adminLevel: 'super', displayName: '林老师', active: true, createdAt: nowIso() },
|
||||
{ id: 'usr_supervisor', username: 'supervisor', passwordHash: testPasswordHash, role: 'admin', adminLevel: 'super', displayName: '赵督导', active: true, createdAt: nowIso() },
|
||||
{ id: schoolAdminId, username: 'school_admin', passwordHash: testPasswordHash, role: 'admin', adminLevel: 'school', schoolId: 'school_hz1', displayName: '王校管', active: true, createdAt: nowIso() },
|
||||
{ id: schoolAdmin2Id, username: 'school_admin_2', passwordHash: testPasswordHash, role: 'admin', adminLevel: 'school', schoolId: 'school_hz1', displayName: '陈校管', active: true, createdAt: nowIso() },
|
||||
{ id: 'usr_class_admin', username: 'class_admin', passwordHash: testPasswordHash, role: 'admin', adminLevel: 'class', schoolId: 'school_hz1', classId: 'class_hz1_302', displayName: '孙班管', active: true, createdAt: nowIso() },
|
||||
{ id: 'usr_class_admin_2', username: 'class_admin_2', passwordHash: testPasswordHash, role: 'admin', adminLevel: 'class', schoolId: 'school_hz1', classId: 'class_hz1_302', displayName: '李班管', active: true, createdAt: nowIso() },
|
||||
{ id: candidateId, username: '2026-HZ01-F-0001', candidateNumber: '2026-HZ01-F-0001', passwordHash: testPasswordHash, role: 'candidate', displayName: '周雨桐', active: true, mustChangePassword: false, createdAt: nowIso() }
|
||||
],
|
||||
candidateProfiles: [
|
||||
{
|
||||
@@ -187,20 +188,17 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
id: generatedSchoolAdminId, username: `test_school_admin_${school.key}`, passwordHash: testPasswordHash,
|
||||
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,
|
||||
passwordHash: testPasswordHash, role: 'admin', adminLevel: 'class', schoolId: school.id, classId,
|
||||
displayName: `${school.name}高三${classIndex}班测试班管`, active: true, createdAt: nowIso()
|
||||
});
|
||||
}
|
||||
@@ -256,7 +254,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
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',
|
||||
id: userId, username: candidateNumber, candidateNumber, passwordHash: testPasswordHash, role: 'candidate',
|
||||
displayName: name, active: true, mustChangePassword: false, createdAt
|
||||
});
|
||||
database.candidateProfiles.push({
|
||||
|
||||
+12
-7
@@ -48,11 +48,16 @@ assert.doesNotMatch(testDataImportSource, /process\.env\.DATABASE_CLIENT\s*=\s*[
|
||||
assert.match(testDataImportSource, /nonEmpty\.length && !force/, 'MySQL 已有业务数据时应默认拒绝覆盖');
|
||||
assert.match(testDataImportSource, /SET FOREIGN_KEY_CHECKS = 0/, 'MySQL 样例数据替换应在受控外键环境中执行');
|
||||
assert.match(testDataImportSource, /beginTransaction\(\).*buildSeedOperations\(state\).*commit\(\)/s, 'MySQL 样例数据应在同一事务内清理并写入');
|
||||
assert.match(testDataImportSource, /initializeEmpty.*createBaseDatabase/s, '测试结束后应支持恢复空业务系统');
|
||||
assert.match(testDataImportSource, /recognizedTestData/, 'MySQL 初始化系统时应识别样例数据并保护非样例业务库');
|
||||
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, '正常首次建库不得预置报名');
|
||||
const passwordProbeState = createSeedDatabase({ nowIso: () => new Date().toISOString(), hashPassword: password => `test-${password}` });
|
||||
assert.deepEqual(new Set(passwordProbeState.users.map(user => user.passwordHash)), new Set(['test-12345678']), '所有预置测试账号应统一使用密码 12345678');
|
||||
assert.ok(passwordProbeState.users.every(user => !user.mustChangePassword), '预置测试账号登录后不应强制修改统一密码');
|
||||
await rm(testDb, { force: true });
|
||||
await rm(`${testDb}-shm`, { force: true });
|
||||
await rm(`${testDb}-wal`, { force: true });
|
||||
@@ -202,15 +207,15 @@ try {
|
||||
});
|
||||
assert.equal(closedRegister.response.status, 403, '自主注册关闭时公开注册必须拒绝');
|
||||
|
||||
const loginAdmin = await admin.request('/api/auth/login', { method: 'POST', body: { username: 'admin', password: 'Admin123!' } });
|
||||
const loginAdmin = await admin.request('/api/auth/login', { method: 'POST', body: { username: 'admin', password: '12345678' } });
|
||||
assert.equal(loginAdmin.data.user.role, 'admin');
|
||||
assert.equal(loginAdmin.data.user.adminLevel, 'super', '默认管理员应为超级管理员');
|
||||
assert.equal((await schoolAdmin.request('/api/auth/login', { method: 'POST', body: { username: 'school_admin', password: 'School123!' } })).data.user.adminLevel, 'school');
|
||||
assert.equal((await schoolAdmin2.request('/api/auth/login', { method: 'POST', body: { username: 'school_admin_2', password: 'School123!' } })).data.user.adminLevel, 'school');
|
||||
assert.equal((await classAdmin.request('/api/auth/login', { method: 'POST', body: { username: 'class_admin', password: 'Class123!' } })).data.user.adminLevel, 'class');
|
||||
assert.equal((await classAdmin2.request('/api/auth/login', { method: 'POST', body: { username: 'class_admin_2', password: 'Class123!' } })).data.user.adminLevel, 'class');
|
||||
assert.equal((await classAdmin2.request('/api/auth/change-password', { method: 'POST', body: { currentPassword: 'Class123!', newPassword: 'ClassChanged123!' } })).response.status, 200, '管理员登录后应可修改自己的密码');
|
||||
assert.equal((await createClient().request('/api/auth/login', { method: 'POST', body: { username: 'class_admin_2', password: 'Class123!' } })).response.status, 401, '管理员改密后旧密码应立即失效');
|
||||
assert.equal((await schoolAdmin.request('/api/auth/login', { method: 'POST', body: { username: 'school_admin', password: '12345678' } })).data.user.adminLevel, 'school');
|
||||
assert.equal((await schoolAdmin2.request('/api/auth/login', { method: 'POST', body: { username: 'school_admin_2', password: '12345678' } })).data.user.adminLevel, 'school');
|
||||
assert.equal((await classAdmin.request('/api/auth/login', { method: 'POST', body: { username: 'class_admin', password: '12345678' } })).data.user.adminLevel, 'class');
|
||||
assert.equal((await classAdmin2.request('/api/auth/login', { method: 'POST', body: { username: 'class_admin_2', password: '12345678' } })).data.user.adminLevel, 'class');
|
||||
assert.equal((await classAdmin2.request('/api/auth/change-password', { method: 'POST', body: { currentPassword: '12345678', newPassword: 'ClassChanged123!' } })).response.status, 200, '管理员登录后应可修改自己的密码');
|
||||
assert.equal((await createClient().request('/api/auth/login', { method: 'POST', body: { username: 'class_admin_2', password: '12345678' } })).response.status, 401, '管理员改密后旧密码应立即失效');
|
||||
|
||||
assert.equal((await admin.request('/api/admin/candidate-accounts', { method: 'POST', body: {} })).response.status, 404, '超级管理员不得再从旧入口直接生成报名号');
|
||||
const singleAccountBatch = await schoolAdmin.request('/api/admin/candidate-account-batches', { method: 'POST', body: { quotas: [
|
||||
|
||||
Reference in New Issue
Block a user