diff --git a/README.md b/README.md index e881cb1..dd60163 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ git push origin v1.3.0-rc.1 首次成功推送后,容器镜像会出现在 `biss` 所有者的软件包列表。Gitea 的软件包归属于用户或组织,不会天然归属于某个仓库;打开该软件包的设置页面,将它关联到 `Exam-Information-System`,即可让它显示在此仓库的“软件包”页。之后可使用 `docker pull git.biss.click/biss/exam-information-system:latest` 拉取。 -需要清空并重建空业务库时运行 `npm run reset-db`。需要测试数据时再手动运行 `npm run seed-test-data`;导入脚本会读取项目根目录的 `.env`,并根据 `DATABASE_CLIENT` 选择 SQLite 或 MySQL。它会生成 4 所学校、360 名批量考生及 360 条不同状态的报名数据,并明确不生成考场编排计划和准考证。省市区县下拉数据位于 `src/data/china-regions.mjs`,当前版本为国家地名信息库截至 2025-12-31 的三级快照,并补入和康县(653228)与和安县(653229);从新版 CSV 更新时可运行 `node scripts/build-regions.mjs src/data/china-regions.mjs`。 +需要清空并重建空业务库时运行 `npm run reset-db`;该命令与 `npm run initialize-system` 使用同一套初始化流程,会读取项目根目录的 `.env`,并根据 `DATABASE_CLIENT` 选择 SQLite 或 MySQL。也可通过 `npm run reset-db -- --sqlite` 或 `npm run reset-db -- --mysql` 显式选择数据库;MySQL 中存在无法识别为样例数据的业务记录时仍会拒绝覆盖,只有确认目标可清空后才能追加 `--force`。需要测试数据时再手动运行 `npm run seed-test-data`;导入脚本会生成 5 所学校、1200 名批量考生及对应的不同状态报名数据。省市区县下拉数据位于 `src/data/china-regions.mjs`,当前版本为国家地名信息库截至 2025-12-31 的三级快照,并补入和康县(653228)与和安县(653229);从新版 CSV 更新时可运行 `node scripts/build-regions.mjs src/data/china-regions.mjs`。 ## 数据库配置 diff --git a/database.mjs b/database.mjs index 8fc9565..348c887 100644 --- a/database.mjs +++ b/database.mjs @@ -3,6 +3,7 @@ import { dirname, join, resolve } from 'node:path'; import { mysqlSchema, sqliteSchema } from './src/database/schema.mjs'; import { createSqliteAdapter } from './src/database/sqlite-adapter.mjs'; import { createMysqlAdapter } from './src/database/mysql-adapter.mjs'; +import { CURRENT_SCHEMA_VERSION } from './src/database/version.mjs'; export const relationalTables = [ 'schema_metadata', @@ -60,7 +61,7 @@ export function buildSeedOperations(state) { const nullable = value => value == null || value === '' ? null : value; add( - 'UPDATE schema_metadata SET schema_version = 20, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1', + `UPDATE schema_metadata SET schema_version = ${CURRENT_SCHEMA_VERSION}, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1`, Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0, state.meta?.createdAt || new Date().toISOString() ); diff --git a/scripts/import-test-data.mjs b/scripts/import-test-data.mjs index 7d34f30..fb40c57 100644 --- a/scripts/import-test-data.mjs +++ b/scripts/import-test-data.mjs @@ -6,6 +6,7 @@ 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'; +import { CURRENT_SCHEMA_VERSION } from '../src/database/version.mjs'; const root = resolve(process.cwd()); const envPath = join(root, '.env'); @@ -101,8 +102,8 @@ async function prepareMysql() { } const [metadataRows] = await connection.query('SELECT schema_version FROM schema_metadata WHERE id = 1'); - if (Number(metadataRows[0]?.schema_version) !== 16) { - throw new Error(`MySQL 数据库结构版本不是 v16(当前 ${metadataRows[0]?.schema_version ?? '未知'}),请先完成结构初始化`); + if (Number(metadataRows[0]?.schema_version) !== CURRENT_SCHEMA_VERSION) { + throw new Error(`MySQL 数据库结构版本不是 v${CURRENT_SCHEMA_VERSION}(当前 ${metadataRows[0]?.schema_version ?? '未知'}),请先完成结构初始化`); } const [examPartitionRows] = await connection.query( diff --git a/scripts/reset-dev-database.mjs b/scripts/reset-dev-database.mjs index 345e88b..9492a52 100644 --- a/scripts/reset-dev-database.mjs +++ b/scripts/reset-dev-database.mjs @@ -1,35 +1,4 @@ -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 { createBaseDatabase } from '../src/data/base.mjs'; - -const root = resolve(process.cwd()); -const databasePath = join(root, 'data', 'exam.sqlite'); -const relativePath = relative(root, databasePath); -if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) throw new Error('拒绝重建工作区以外的数据库'); - -for (const path of [databasePath, `${databasePath}-shm`, `${databasePath}-wal`]) await rm(path, { 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 = databasePath; -const database = await createDatabase({ - root, - 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(); -await database.close(); -console.log(`Rebuilt empty ${databasePath} (schema ${state.meta.version}, ${state.candidateProfiles.length} candidates)`); +// Keep the historical reset-db entry point, but run the same guarded initializer used +// by initialize-system so SQLite, MySQL, .env loading and schema checks stay in sync. +if (!process.argv.slice(2).includes('--empty')) process.argv.push('--empty'); +await import('./import-test-data.mjs'); diff --git a/src/data/base.mjs b/src/data/base.mjs index 3aa85c5..8a3b6d2 100644 --- a/src/data/base.mjs +++ b/src/data/base.mjs @@ -1,3 +1,5 @@ +import { CURRENT_SCHEMA_VERSION } from '../database/version.mjs'; + const admissionNumberRules = (nowIso) => [ { id: 'admit_rule_district_room_seat', code: 'district_room_seat', name: '县区编号 + 考场号 + 座位号', @@ -63,7 +65,7 @@ export function createBaseDatabase({ nowIso, hashPassword, initialAdmin = {} }) const adminId = 'usr_admin'; const createdAt = nowIso(); return { - meta: { version: 19, createdAt }, + meta: { version: CURRENT_SCHEMA_VERSION, createdAt }, settings: { selfRegistrationEnabled: false }, organization: { name: '考试服务平台', code: 'EXAM-SERVICE', phone: '', address: '' }, schools: [], classes: [], diff --git a/src/database/version.mjs b/src/database/version.mjs new file mode 100644 index 0000000..c210217 --- /dev/null +++ b/src/database/version.mjs @@ -0,0 +1 @@ +export const CURRENT_SCHEMA_VERSION = 20; diff --git a/tests/system.test.mjs b/tests/system.test.mjs index 25b417b..c759650 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -9,6 +9,7 @@ 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 { CURRENT_SCHEMA_VERSION } from '../src/database/version.mjs'; import { totpAtStep } from '../src/security/totp.mjs'; import { buildCenterMaterialsWorkbook, buildWorkbook } from '../excel.mjs'; @@ -60,7 +61,12 @@ 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 初始化系统时应识别样例数据并保护非样例业务库'); +assert.match(testDataImportSource, /CURRENT_SCHEMA_VERSION/, '数据库导入脚本应复用统一的当前结构版本'); +const resetDatabaseSource = await readFile(resolve(root, 'scripts', 'reset-dev-database.mjs'), 'utf8'); +assert.match(resetDatabaseSource, /import\(['"]\.\/import-test-data\.mjs['"]\)/, '重置入口应复用统一的数据库初始化流程'); +assert.doesNotMatch(resetDatabaseSource, /DATABASE_CLIENT\s*=\s*['"]sqlite['"]/, '重置入口不得再强制使用 SQLite'); const baseState = createBaseDatabase({ nowIso: () => new Date().toISOString(), hashPassword: password => `test-${password}` }); +assert.equal(baseState.meta.version, CURRENT_SCHEMA_VERSION, '空库初始状态必须使用当前结构版本'); assert.equal(baseState.schools.length, 0, '正常首次建库不得预置学校'); assert.equal(baseState.candidateProfiles.length, 0, '正常首次建库不得预置考生'); assert.equal(baseState.exams.length, 0, '正常首次建库不得预置考试');