数据库脚本修正

This commit is contained in:
2026-07-21 18:03:47 +08:00 Unverified
parent 607b71e4f0
commit b443015943
7 changed files with 20 additions and 40 deletions
+1 -1
View File
@@ -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` 拉取。 首次成功推送后,容器镜像会出现在 `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
View File
@@ -3,6 +3,7 @@ import { dirname, join, resolve } from 'node:path';
import { mysqlSchema, sqliteSchema } from './src/database/schema.mjs'; import { mysqlSchema, sqliteSchema } from './src/database/schema.mjs';
import { createSqliteAdapter } from './src/database/sqlite-adapter.mjs'; import { createSqliteAdapter } from './src/database/sqlite-adapter.mjs';
import { createMysqlAdapter } from './src/database/mysql-adapter.mjs'; import { createMysqlAdapter } from './src/database/mysql-adapter.mjs';
import { CURRENT_SCHEMA_VERSION } from './src/database/version.mjs';
export const relationalTables = [ export const relationalTables = [
'schema_metadata', 'schema_metadata',
@@ -60,7 +61,7 @@ export function buildSeedOperations(state) {
const nullable = value => value == null || value === '' ? null : value; const nullable = value => value == null || value === '' ? null : value;
add( 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, Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0,
state.meta?.createdAt || new Date().toISOString() state.meta?.createdAt || new Date().toISOString()
); );
+3 -2
View File
@@ -6,6 +6,7 @@ import { loadEnvFile } from 'node:process';
import { buildSeedOperations, createDatabase, relationalTables } from '../database.mjs'; import { buildSeedOperations, createDatabase, relationalTables } from '../database.mjs';
import { createBaseDatabase } from '../src/data/base.mjs'; import { createBaseDatabase } from '../src/data/base.mjs';
import { createSeedDatabase } from '../src/data/seed.mjs'; import { createSeedDatabase } from '../src/data/seed.mjs';
import { CURRENT_SCHEMA_VERSION } from '../src/database/version.mjs';
const root = resolve(process.cwd()); const root = resolve(process.cwd());
const envPath = join(root, '.env'); 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'); const [metadataRows] = await connection.query('SELECT schema_version FROM schema_metadata WHERE id = 1');
if (Number(metadataRows[0]?.schema_version) !== 16) { if (Number(metadataRows[0]?.schema_version) !== CURRENT_SCHEMA_VERSION) {
throw new Error(`MySQL 数据库结构版本不是 v16(当前 ${metadataRows[0]?.schema_version ?? '未知'}),请先完成结构初始化`); throw new Error(`MySQL 数据库结构版本不是 v${CURRENT_SCHEMA_VERSION}(当前 ${metadataRows[0]?.schema_version ?? '未知'}),请先完成结构初始化`);
} }
const [examPartitionRows] = await connection.query( const [examPartitionRows] = await connection.query(
+4 -35
View File
@@ -1,35 +1,4 @@
import { pbkdf2Sync, randomBytes } from 'node:crypto'; // Keep the historical reset-db entry point, but run the same guarded initializer used
import { rm } from 'node:fs/promises'; // by initialize-system so SQLite, MySQL, .env loading and schema checks stay in sync.
import { isAbsolute, join, relative, resolve } from 'node:path'; if (!process.argv.slice(2).includes('--empty')) process.argv.push('--empty');
import { createDatabase } from '../database.mjs'; await import('./import-test-data.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)`);
+3 -1
View File
@@ -1,3 +1,5 @@
import { CURRENT_SCHEMA_VERSION } from '../database/version.mjs';
const admissionNumberRules = (nowIso) => [ const admissionNumberRules = (nowIso) => [
{ {
id: 'admit_rule_district_room_seat', code: 'district_room_seat', name: '县区编号 + 考场号 + 座位号', 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 adminId = 'usr_admin';
const createdAt = nowIso(); const createdAt = nowIso();
return { return {
meta: { version: 19, createdAt }, meta: { version: CURRENT_SCHEMA_VERSION, createdAt },
settings: { selfRegistrationEnabled: false }, settings: { selfRegistrationEnabled: false },
organization: { name: '考试服务平台', code: 'EXAM-SERVICE', phone: '', address: '' }, organization: { name: '考试服务平台', code: 'EXAM-SERVICE', phone: '', address: '' },
schools: [], classes: [], schools: [], classes: [],
+1
View File
@@ -0,0 +1 @@
export const CURRENT_SCHEMA_VERSION = 20;
+6
View File
@@ -9,6 +9,7 @@ import { createDatabase, relationalTables } from '../database.mjs';
import { createBaseDatabase } from '../src/data/base.mjs'; import { createBaseDatabase } from '../src/data/base.mjs';
import { createSeedDatabase } from '../src/data/seed.mjs'; import { createSeedDatabase } from '../src/data/seed.mjs';
import { mysqlSchema } from '../src/database/schema.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 { totpAtStep } from '../src/security/totp.mjs';
import { buildCenterMaterialsWorkbook, buildWorkbook } from '../excel.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, /beginTransaction\(\).*buildSeedOperations\(state\).*commit\(\)/s, 'MySQL ');
assert.match(testDataImportSource, /initializeEmpty.*createBaseDatabase/s, ''); assert.match(testDataImportSource, /initializeEmpty.*createBaseDatabase/s, '');
assert.match(testDataImportSource, /recognizedTestData/, 'MySQL 初始化系统时应识别样例数据并保护非样例业务库'); 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}` }); 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.schools.length, 0, '正常首次建库不得预置学校');
assert.equal(baseState.candidateProfiles.length, 0, '正常首次建库不得预置考生'); assert.equal(baseState.candidateProfiles.length, 0, '正常首次建库不得预置考生');
assert.equal(baseState.exams.length, 0, '正常首次建库不得预置考试'); assert.equal(baseState.exams.length, 0, '正常首次建库不得预置考试');