数据库脚本修正
This commit is contained in:
@@ -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 <CSV路径> 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 <CSV路径> src/data/china-regions.mjs`。
|
||||
|
||||
## 数据库配置
|
||||
|
||||
|
||||
+2
-1
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
|
||||
+3
-1
@@ -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: [],
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const CURRENT_SCHEMA_VERSION = 20;
|
||||
@@ -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, '正常首次建库不得预置考试');
|
||||
|
||||
Reference in New Issue
Block a user