From 5a33a6bcc4972d03ba670470758c925ee052eaa2 Mon Sep 17 00:00:00 2001 From: biss Date: Mon, 20 Jul 2026 20:09:26 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 33 ++++++++++++++++++------ package.json | 5 +++- scripts/import-test-data.mjs | 49 ++++++++++++++++++++++++++++-------- src/data/seed.mjs | 24 ++++++++---------- tests/system.test.mjs | 19 ++++++++------ 5 files changed, 91 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index af24ca6..5f2cbe9 100644 --- a/README.md +++ b/README.md @@ -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` 决定,然后再重新启动应用。 ## 自动化测试 diff --git a/package.json b/package.json index 93f66ae..4c854c3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/import-test-data.mjs b/scripts/import-test-data.mjs index b1e24d3..30426ee 100644 --- a/scripts/import-test-data.mjs +++ b/scripts/import-test-data.mjs @@ -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; -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}`); +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'); +} diff --git a/src/data/seed.mjs b/src/data/seed.mjs index 0ce1147..f640e63 100644 --- a/src/data/seed.mjs +++ b/src/data/seed.mjs @@ -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({ diff --git a/tests/system.test.mjs b/tests/system.test.mjs index 29949ba..dfc6322 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -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: [