Files
Exam-Information-System/src/database/sqlite-adapter.mjs
T

509 lines
30 KiB
JavaScript

import { synchronizeSqlitePartitions } from './partition-storage.mjs';
export function createSqliteAdapter(context) {
const {
mkdir,
dirname,
sqliteSchema,
mysqlSchema,
optional,
buildSeedOperations,
stateFromRows,
readSqliteRows,
readMysqlRows,
createRepository
} = context;
async function createSqliteStore({ path, seed }) {
const { DatabaseSync } = await import('node:sqlite');
await mkdir(dirname(path), { recursive: true });
const connection = new DatabaseSync(path, { timeout: 5000 });
connection.exec('PRAGMA journal_mode = WAL;');
connection.exec('PRAGMA synchronous = NORMAL;');
const tableExists = name => Boolean(connection.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name));
const ensureColumns = (table, columns) => {
if (!tableExists(table)) return;
const existing = new Set(connection.prepare(`PRAGMA table_info(${table})`).all().map(item => item.name));
for (const [name, definition] of columns) {
if (!existing.has(name)) connection.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
}
};
ensureColumns('users', [
['admin_level', 'TEXT'], ['school_id', 'TEXT'], ['class_id', 'TEXT'], ['active', 'INTEGER NOT NULL DEFAULT 1'],
['candidate_number', 'TEXT'], ['must_change_password', 'INTEGER NOT NULL DEFAULT 0'],
['totp_enabled', 'INTEGER NOT NULL DEFAULT 0'], ['totp_secret_encrypted', 'TEXT'],
['totp_recovery_codes', "TEXT NOT NULL DEFAULT '[]'"], ['totp_last_used_step', 'INTEGER'],
['archived_at', 'TEXT'], ['archived_by', 'TEXT']
]);
ensureColumns('schema_metadata', [['self_registration_enabled', 'INTEGER NOT NULL DEFAULT 0']]);
ensureColumns('schools', [
['is_source_school', 'INTEGER NOT NULL DEFAULT 1'], ['is_admission_school', 'INTEGER NOT NULL DEFAULT 1']
]);
ensureColumns('candidate_profiles', [
['school_id', 'TEXT'], ['class_id', 'TEXT'], ['native_place', 'TEXT'], ['birth_date', 'TEXT'], ['ethnicity', 'TEXT'],
['postal_code', 'TEXT'], ['guardian_name', 'TEXT'], ['guardian_phone', 'TEXT'], ['profile_completed', 'INTEGER NOT NULL DEFAULT 0'],
['province_code', 'TEXT'], ['province_name', 'TEXT'], ['city_code', 'TEXT'], ['city_name', 'TEXT'],
['district_code', 'TEXT'], ['district_name', 'TEXT'], ['specialty_types', "TEXT NOT NULL DEFAULT '[]'"],
['specialty_category', 'TEXT'], ['specialty_type', 'TEXT'],
['specialty_certificate', 'TEXT'], ['policy_eligibility', 'TEXT']
]);
ensureColumns('registrations', [['registration_number', 'TEXT'], ['number_rule_id', 'TEXT'], ['feature_score', 'REAL NOT NULL DEFAULT 0']]);
ensureColumns('exams', [
['pass_policy', "TEXT NOT NULL DEFAULT 'rank_percent'"], ['pass_value', 'REAL NOT NULL DEFAULT 60'],
['archived_at', 'TEXT'], ['archived_by', 'TEXT']
]);
ensureColumns('exam_subjects', [
['full_score', 'REAL NOT NULL DEFAULT 150'], ['pass_score', 'REAL NOT NULL DEFAULT 90'],
['pass_rule', "TEXT NOT NULL DEFAULT 'fixed_score'"], ['pass_value', 'REAL NOT NULL DEFAULT 90']
]);
ensureColumns('test_centers', [
['code', 'TEXT'], ['manager_name', 'TEXT'], ['manager_phone', 'TEXT'], ['emergency_phone', 'TEXT'],
['gate_open_time', 'TEXT'], ['transport', 'TEXT'], ['status', "TEXT NOT NULL DEFAULT 'active'"], ['notes', 'TEXT'],
['province_code', 'TEXT'], ['province_name', 'TEXT'], ['city_code', 'TEXT'], ['city_name', 'TEXT'],
['district_code', 'TEXT'], ['district_name', 'TEXT']
]);
ensureColumns('center_change_requests', [
['province_code', 'TEXT'], ['province_name', 'TEXT'], ['city_code', 'TEXT'], ['city_name', 'TEXT'],
['district_code', 'TEXT'], ['district_name', 'TEXT']
]);
ensureColumns('test_rooms', [['seat_plan', 'TEXT']]);
ensureColumns('center_change_rooms', [['seat_plan', 'TEXT']]);
ensureColumns('admit_cards', [
['center_code', "TEXT NOT NULL DEFAULT ''"], ['center_address', "TEXT NOT NULL DEFAULT ''"]
]);
ensureColumns('admit_card_subjects', [
['building', "TEXT NOT NULL DEFAULT ''"], ['floor', "TEXT NOT NULL DEFAULT ''"]
]);
if (tableExists('users')) {
const usersSql = connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'users'").get()?.sql || '';
if (!usersSql.includes('admission_school')) {
connection.exec(`
PRAGMA foreign_keys = OFF;
BEGIN IMMEDIATE;
CREATE TABLE users_v18 (
id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, candidate_number TEXT UNIQUE, password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin', 'candidate', 'admission_school')),
admin_level TEXT CHECK (admin_level IN ('super', 'school', 'class')),
school_id TEXT REFERENCES schools(id) ON DELETE SET NULL, class_id TEXT REFERENCES school_classes(id) ON DELETE SET NULL,
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), must_change_password INTEGER NOT NULL DEFAULT 0 CHECK (must_change_password IN (0, 1)),
totp_enabled INTEGER NOT NULL DEFAULT 0 CHECK (totp_enabled IN (0, 1)), totp_secret_encrypted TEXT,
totp_recovery_codes TEXT NOT NULL DEFAULT '[]', totp_last_used_step INTEGER, archived_at TEXT,
archived_by TEXT REFERENCES users_v18(id) ON DELETE RESTRICT, display_name TEXT NOT NULL, created_at TEXT NOT NULL
) STRICT;
INSERT INTO users_v18 SELECT id, username, candidate_number, password_hash, role, admin_level, school_id, class_id,
active, must_change_password, COALESCE(totp_enabled, 0), totp_secret_encrypted, COALESCE(totp_recovery_codes, '[]'),
totp_last_used_step, archived_at, archived_by, display_name, created_at FROM users;
DROP TABLE users;
ALTER TABLE users_v18 RENAME TO users;
COMMIT;
PRAGMA foreign_keys = ON;
`);
}
}
if (tableExists('workflow_definitions')) {
const definitionSql = connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'workflow_definitions'").get()?.sql || '';
if (!definitionSql.includes('candidate_account_batch')) {
connection.exec(`
PRAGMA foreign_keys = OFF;
BEGIN IMMEDIATE;
CREATE TABLE workflow_definitions_v5 (
id TEXT PRIMARY KEY,
business_type TEXT NOT NULL CHECK (business_type IN ('profile_change', 'registration_review', 'center_change', 'candidate_account_batch')),
name TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
updated_by TEXT REFERENCES users(id) ON DELETE SET NULL,
updated_at TEXT NOT NULL,
UNIQUE (business_type, active)
) STRICT;
INSERT INTO workflow_definitions_v5 (id, business_type, name, active, updated_by, updated_at)
SELECT id, business_type, name, active, updated_by, updated_at FROM workflow_definitions;
DROP TABLE workflow_definitions;
ALTER TABLE workflow_definitions_v5 RENAME TO workflow_definitions;
COMMIT;
PRAGMA foreign_keys = ON;
`);
}
}
if (tableExists('registrations')) {
const registrationsSql = connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'registrations'").get()?.sql || '';
if (/registration_number\s+TEXT\s+UNIQUE/i.test(registrationsSql)) {
connection.exec(`
PRAGMA foreign_keys = OFF;
BEGIN IMMEDIATE;
CREATE TABLE registrations_v4 (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE,
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')),
payment_status TEXT NOT NULL CHECK (payment_status IN ('unpaid', 'paid', 'refunded')),
created_at TEXT NOT NULL,
reviewed_at TEXT,
review_note TEXT,
registration_number TEXT,
number_rule_id TEXT,
UNIQUE (user_id, exam_id)
) STRICT;
INSERT INTO registrations_v4 (
id, user_id, exam_id, status, payment_status, created_at, reviewed_at, review_note, registration_number, number_rule_id
) SELECT id, user_id, exam_id, status, payment_status, created_at, reviewed_at, review_note, registration_number, number_rule_id FROM registrations;
DROP TABLE registrations;
ALTER TABLE registrations_v4 RENAME TO registrations;
COMMIT;
PRAGMA foreign_keys = ON;
`);
}
}
connection.exec(sqliteSchema);
connection.exec('CREATE UNIQUE INDEX IF NOT EXISTS uq_users_candidate_number ON users(candidate_number)');
const existingSystem = connection.prepare('SELECT * FROM schema_metadata WHERE id = 1').get();
if (existingSystem && Number(existingSystem.schema_version || 1) < 9) {
const extension = seed();
connection.exec('PRAGMA foreign_keys = OFF;');
connection.exec('BEGIN IMMEDIATE');
try {
connection.exec(`
DROP TABLE IF EXISTS admit_card_subjects;
DROP TABLE IF EXISTS admit_cards;
DROP TABLE IF EXISTS exam_arrangement_plans;
DROP TABLE IF EXISTS admission_number_rules;
CREATE TABLE admission_number_rules (
id TEXT PRIMARY KEY, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, description TEXT NOT NULL,
separator TEXT NOT NULL DEFAULT '', segments_json TEXT NOT NULL, example TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), created_at TEXT NOT NULL
) STRICT;
CREATE TABLE exam_arrangement_plans (
id TEXT PRIMARY KEY, exam_id TEXT NOT NULL UNIQUE REFERENCES exams(id) ON DELETE CASCADE,
number_rule_id TEXT NOT NULL REFERENCES admission_number_rules(id),
mixing_scope TEXT NOT NULL CHECK (mixing_scope IN ('class', 'school', 'district', 'city', 'province')),
random_seed TEXT NOT NULL, candidate_count INTEGER NOT NULL CHECK (candidate_count >= 0),
center_count INTEGER NOT NULL CHECK (center_count >= 0),
subject_assignment_count INTEGER NOT NULL CHECK (subject_assignment_count >= 0),
subject_combination_count INTEGER NOT NULL CHECK (subject_combination_count >= 0),
same_school_center_rate REAL NOT NULL, warnings_json TEXT NOT NULL,
generated_by TEXT REFERENCES users(id) ON DELETE SET NULL, generated_at TEXT NOT NULL
) STRICT;
CREATE TABLE admit_cards (
registration_id TEXT PRIMARY KEY REFERENCES registrations(id) ON DELETE CASCADE,
plan_id TEXT NOT NULL REFERENCES exam_arrangement_plans(id) ON DELETE CASCADE,
card_number TEXT NOT NULL UNIQUE, center_id TEXT REFERENCES test_centers(id) ON DELETE SET NULL,
test_center TEXT NOT NULL, center_code TEXT NOT NULL, center_address TEXT NOT NULL, generated_at TEXT NOT NULL
) STRICT;
CREATE TABLE admit_card_subjects (
registration_id TEXT NOT NULL REFERENCES admit_cards(registration_id) ON DELETE CASCADE,
subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE,
room_id TEXT REFERENCES test_rooms(id) ON DELETE SET NULL, room TEXT NOT NULL, room_code TEXT NOT NULL,
exam_room_code TEXT NOT NULL, building TEXT NOT NULL, floor TEXT NOT NULL, seat TEXT NOT NULL, subject_signature TEXT NOT NULL,
PRIMARY KEY (registration_id, subject_id), UNIQUE (subject_id, room_id, seat)
) STRICT;
CREATE INDEX idx_arrangement_plans_exam ON exam_arrangement_plans(exam_id, generated_at);
CREATE INDEX idx_admit_subjects_room ON admit_card_subjects(subject_id, room_id, seat);
`);
for (const rule of extension.admissionNumberRules) connection.prepare(
`INSERT INTO admission_number_rules (
id, code, name, description, separator, segments_json, example, active, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(rule.id, rule.code, rule.name, rule.description, rule.separator || '', JSON.stringify(rule.segments || []),
rule.example || '', rule.active === false ? 0 : 1, rule.createdAt);
connection.prepare('UPDATE schema_metadata SET schema_version = 9, app_version = 9 WHERE id = 1').run();
connection.exec('COMMIT');
} catch (error) {
connection.exec('ROLLBACK');
connection.close();
throw error;
} finally {
try { connection.exec('PRAGMA foreign_keys = ON;'); } catch {}
}
}
if (existingSystem && Number(existingSystem.schema_version || 1) < 10) {
connection.prepare(`
UPDATE admit_cards SET
center_code = COALESCE((SELECT code FROM test_centers WHERE id = admit_cards.center_id), center_code),
center_address = COALESCE((SELECT trim(
COALESCE(province_name, '') || ' ' || COALESCE(city_name, '') || ' ' ||
COALESCE(district_name, '') || ' ' || COALESCE(address, '')
) FROM test_centers WHERE id = admit_cards.center_id), center_address)
`).run();
connection.prepare(`
UPDATE admit_card_subjects SET
building = COALESCE((SELECT building FROM test_rooms WHERE id = admit_card_subjects.room_id), building),
floor = COALESCE((SELECT floor FROM test_rooms WHERE id = admit_card_subjects.room_id), floor)
`).run();
connection.prepare('UPDATE schema_metadata SET schema_version = 10, app_version = 10 WHERE id = 1').run();
}
if (existingSystem && Number(existingSystem.schema_version || 1) < 11) {
connection.exec('PRAGMA foreign_keys = OFF;');
connection.exec('BEGIN IMMEDIATE');
try {
connection.prepare("UPDATE exam_subjects SET pass_rule = 'fixed_score', pass_value = pass_score").run();
connection.exec(`
CREATE TABLE results_v11 (
id TEXT PRIMARY KEY,
registration_id TEXT NOT NULL REFERENCES registrations(id) ON DELETE CASCADE,
subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE,
score REAL NOT NULL CHECK (score >= 0),
grade TEXT NOT NULL,
published INTEGER NOT NULL DEFAULT 0 CHECK (published IN (0, 1)),
updated_at TEXT,
published_at TEXT,
UNIQUE (registration_id, subject_id)
) STRICT;
INSERT INTO results_v11 (id, registration_id, subject_id, score, grade, published, updated_at, published_at)
SELECT id, registration_id, subject_id, score, grade, published, updated_at, published_at FROM results;
DROP TABLE results;
ALTER TABLE results_v11 RENAME TO results;
CREATE INDEX IF NOT EXISTS idx_results_registration ON results(registration_id, published);
`);
connection.prepare('UPDATE schema_metadata SET schema_version = 11, app_version = 11 WHERE id = 1').run();
connection.exec('COMMIT');
} catch (error) {
connection.exec('ROLLBACK');
connection.close();
throw error;
} finally {
try { connection.exec('PRAGMA foreign_keys = ON;'); } catch {}
}
}
if (existingSystem && Number(existingSystem.schema_version || 1) < 12) {
connection.prepare("UPDATE exams SET pass_policy = 'rank_percent' WHERE pass_policy = 'score_ratio'").run();
connection.prepare('UPDATE schema_metadata SET schema_version = 12, app_version = 12 WHERE id = 1').run();
}
if (existingSystem && Number(existingSystem.schema_version || 1) < 13) {
connection.prepare('UPDATE schema_metadata SET schema_version = 13, app_version = 13 WHERE id = 1').run();
}
if (existingSystem && Number(existingSystem.schema_version || 1) < 15) {
throw new Error('开发数据库结构已升级到 v15,请先运行 npm run reset-db 重建数据库');
}
if (existingSystem && Number(existingSystem.schema_version || 1) < 16) {
connection.prepare('UPDATE schema_metadata SET schema_version = 16 WHERE id = 1').run();
}
if (existingSystem && Number(existingSystem.schema_version || 1) < 17) {
connection.prepare('UPDATE schema_metadata SET schema_version = 17 WHERE id = 1').run();
}
if (existingSystem && Number(existingSystem.schema_version || 1) < 18) {
connection.prepare('UPDATE schema_metadata SET schema_version = 18, app_version = 18 WHERE id = 1').run();
}
if (existingSystem && Number(existingSystem.schema_version || 1) < 19) {
connection.prepare('UPDATE schema_metadata SET schema_version = 19, app_version = 19 WHERE id = 1').run();
}
if (existingSystem && Number(existingSystem.schema_version || 1) < 20) {
connection.exec(`
PRAGMA foreign_keys = OFF;
BEGIN IMMEDIATE;
CREATE TABLE admission_records_v20 (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('setting', 'plan', 'preference', 'placement', 'notification', 'indicator_qualification', 'qualification_publication', 'cutoff_publication')),
exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE,
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
school_id TEXT REFERENCES schools(id) ON DELETE CASCADE,
status TEXT NOT NULL,
payload_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
) STRICT;
INSERT INTO admission_records_v20 SELECT id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at FROM admission_records;
DROP TABLE admission_records;
ALTER TABLE admission_records_v20 RENAME TO admission_records;
CREATE INDEX idx_admission_records_lookup ON admission_records(kind, exam_id, school_id, user_id, status);
UPDATE schema_metadata SET schema_version = 20, app_version = 20 WHERE id = 1;
COMMIT;
PRAGMA foreign_keys = ON;
`);
}
if (existingSystem && Number(existingSystem.app_version || 1) < 2) {
const extension = seed();
connection.exec('BEGIN IMMEDIATE');
try {
for (const school of extension.schools) connection.prepare(
'INSERT OR IGNORE INTO schools (id, name, code, address, active) VALUES (?, ?, ?, ?, ?)'
).run(school.id, school.name, school.code, optional(school.address), school.active === false ? 0 : 1);
for (const schoolClass of extension.classes) connection.prepare(
'INSERT OR IGNORE INTO school_classes (id, school_id, name, grade, active) VALUES (?, ?, ?, ?, ?)'
).run(schoolClass.id, schoolClass.schoolId, schoolClass.name, schoolClass.grade, schoolClass.active === false ? 0 : 1);
connection.prepare("UPDATE users SET admin_level = COALESCE(admin_level, 'super'), active = COALESCE(active, 1) WHERE role = 'admin'").run();
for (const user of extension.users.filter(item => item.role === 'admin')) connection.prepare(
`INSERT OR IGNORE INTO users (
id, username, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at
) VALUES (?, ?, ?, 'admin', ?, ?, ?, ?, ?, ?)`
).run(user.id, user.username, user.passwordHash, user.adminLevel, optional(user.schoolId), optional(user.classId), user.active === false ? 0 : 1, user.displayName, user.createdAt);
for (const profile of extension.candidateProfiles) connection.prepare(
`UPDATE candidate_profiles SET school_id = COALESCE(school_id, ?), class_id = COALESCE(class_id, ?)
WHERE school = ? AND grade = ?`
).run(optional(profile.schoolId), optional(profile.classId), profile.school, profile.grade);
if (!connection.prepare('SELECT id FROM test_centers LIMIT 1').get()) {
for (const center of extension.testCenters) connection.prepare(
'INSERT INTO test_centers (id, school_id, name, address, contact, rooms, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(center.id, center.schoolId, center.name, center.address, optional(center.contact), center.rooms || '', center.updatedAt);
}
if (!connection.prepare('SELECT id FROM number_rules LIMIT 1').get()) {
for (const rule of extension.numberRules) {
connection.prepare('INSERT INTO number_rules (id, name, separator, active, created_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)').run(rule.id, rule.name, rule.separator || '', rule.active ? 1 : 0, optional(rule.createdBy), rule.updatedAt);
rule.segments.forEach((segment, index) => connection.prepare(
'INSERT INTO number_rule_segments (id, rule_id, position, type, value, width) VALUES (?, ?, ?, ?, ?, ?)'
).run(segment.id, rule.id, Number(segment.position || index + 1), segment.type, optional(segment.value), Number(segment.width || 0)));
}
}
if (!connection.prepare('SELECT id FROM workflow_definitions LIMIT 1').get()) {
for (const workflow of extension.workflows) {
connection.prepare(
'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
).run(workflow.id, workflow.businessType, workflow.name, workflow.active === false ? 0 : 1, optional(workflow.updatedBy), workflow.updatedAt);
workflow.steps.forEach((step, index) => connection.prepare(
'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)'
).run(step.id, workflow.id, Number(step.position || index + 1), step.name, step.adminLevel));
}
}
connection.prepare('UPDATE schema_metadata SET schema_version = 2, app_version = 2 WHERE id = 1').run();
connection.exec('COMMIT');
} catch (error) {
connection.exec('ROLLBACK');
connection.close();
throw error;
}
}
if (existingSystem && Number(existingSystem.app_version || 1) < 3) {
const extension = seed();
connection.exec('BEGIN IMMEDIATE');
try {
for (const center of extension.testCenters) connection.prepare(
`UPDATE test_centers SET
code = COALESCE(NULLIF(code, ''), ?), manager_name = COALESCE(manager_name, ?),
manager_phone = COALESCE(manager_phone, ?), emergency_phone = COALESCE(emergency_phone, ?),
gate_open_time = COALESCE(gate_open_time, ?), transport = COALESCE(transport, ?),
status = COALESCE(status, 'active'), notes = COALESCE(notes, ?)
WHERE id = ?`
).run(center.code, optional(center.managerName), optional(center.managerPhone), optional(center.emergencyPhone),
optional(center.gateOpenTime), optional(center.transport), optional(center.notes), center.id);
connection.prepare("UPDATE test_centers SET code = 'CENTER-' || substr(id, -8) WHERE code IS NULL OR code = ''").run();
if (!connection.prepare('SELECT id FROM test_rooms LIMIT 1').get()) {
for (const room of extension.testRooms) connection.prepare(
`INSERT INTO test_rooms (
id, center_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(room.id, room.centerId, room.code, room.name, room.building, optional(room.floor), Number(room.capacity),
Number(room.seatStart), Number(room.seatEnd), room.roomType, room.status, optional(room.notes));
}
const centerWorkflow = extension.workflows.find(item => item.businessType === 'center_change');
if (centerWorkflow && !connection.prepare("SELECT id FROM workflow_definitions WHERE business_type = 'center_change' AND active = 1").get()) {
connection.prepare(
'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
).run(centerWorkflow.id, centerWorkflow.businessType, centerWorkflow.name, 1, optional(centerWorkflow.updatedBy), centerWorkflow.updatedAt);
for (const [index, step] of centerWorkflow.steps.entries()) connection.prepare(
'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)'
).run(step.id, centerWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel);
}
connection.prepare('UPDATE schema_metadata SET schema_version = 3, app_version = 3 WHERE id = 1').run();
connection.exec('COMMIT');
} catch (error) {
connection.exec('ROLLBACK');
connection.close();
throw error;
}
}
if (existingSystem && Number(existingSystem.app_version || 1) < 4) {
const extension = seed();
connection.exec('BEGIN IMMEDIATE');
try {
for (const user of extension.users.filter(item => item.role === 'candidate')) connection.prepare(
`UPDATE users SET candidate_number = COALESCE(NULLIF(candidate_number, ''), ?),
must_change_password = COALESCE(must_change_password, ?) WHERE id = ?`
).run(optional(user.candidateNumber), user.mustChangePassword ? 1 : 0, user.id);
connection.prepare(`UPDATE users SET candidate_number = COALESCE(
(SELECT registration_number FROM registrations WHERE registrations.user_id = users.id AND registration_number IS NOT NULL AND registration_number <> '' ORDER BY created_at LIMIT 1),
'CAND-' || substr(id, -10)
) WHERE role = 'candidate' AND (candidate_number IS NULL OR candidate_number = '')`).run();
for (const profile of extension.candidateProfiles) connection.prepare(
`UPDATE candidate_profiles SET native_place = COALESCE(native_place, ?), birth_date = COALESCE(birth_date, ?),
ethnicity = COALESCE(ethnicity, ?), postal_code = COALESCE(postal_code, ?), guardian_name = COALESCE(guardian_name, ?),
guardian_phone = COALESCE(guardian_phone, ?), profile_completed = ? WHERE id = ?`
).run(optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity), optional(profile.postalCode),
optional(profile.guardianName), optional(profile.guardianPhone), profile.profileCompleted ? 1 : 0, profile.id);
connection.prepare(`UPDATE registrations SET registration_number = (
SELECT candidate_number FROM users WHERE users.id = registrations.user_id
) WHERE registration_number IS NULL OR registration_number = ''`).run();
connection.prepare('UPDATE schema_metadata SET schema_version = 4, app_version = 4 WHERE id = 1').run();
connection.exec('COMMIT');
} catch (error) {
connection.exec('ROLLBACK');
connection.close();
throw error;
}
}
if (existingSystem && Number(existingSystem.app_version || 1) < 5) {
const extension = seed();
const batchWorkflow = extension.workflows.find(item => item.businessType === 'candidate_account_batch');
connection.exec('BEGIN IMMEDIATE');
try {
if (batchWorkflow && !connection.prepare("SELECT id FROM workflow_definitions WHERE business_type = 'candidate_account_batch' AND active = 1").get()) {
connection.prepare(
'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
).run(batchWorkflow.id, batchWorkflow.businessType, batchWorkflow.name, 1, optional(batchWorkflow.updatedBy), batchWorkflow.updatedAt);
for (const [index, step] of batchWorkflow.steps.entries()) connection.prepare(
'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)'
).run(step.id, batchWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel);
}
connection.prepare('UPDATE schema_metadata SET schema_version = 5, app_version = 5 WHERE id = 1').run();
connection.exec('COMMIT');
} catch (error) {
connection.exec('ROLLBACK');
connection.close();
throw error;
}
}
if (existingSystem && Number(existingSystem.app_version || 1) < 6) {
connection.prepare('UPDATE schema_metadata SET schema_version = 6, app_version = 6 WHERE id = 1').run();
}
if (existingSystem && Number(existingSystem.app_version || 1) < 7) {
connection.prepare('UPDATE schema_metadata SET schema_version = 7, app_version = 7 WHERE id = 1').run();
}
if (existingSystem && Number(existingSystem.schema_version || 1) < 15) {
throw new Error('开发数据库结构已升级到 v15,请先运行 npm run reset-db 重建数据库');
}
if (!connection.prepare('SELECT id FROM schema_metadata WHERE id = 1').get()) {
const initialState = seed();
connection.exec('BEGIN IMMEDIATE');
try {
connection.prepare(`
INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
VALUES (1, 18, ?, ?, ?)
`).run(Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString());
for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params);
connection.exec('COMMIT');
} catch (error) {
connection.exec('ROLLBACK');
connection.close();
throw error;
}
}
synchronizeSqlitePartitions(connection);
const transaction = async operations => {
connection.exec('BEGIN IMMEDIATE');
try {
for (const item of operations) connection.prepare(item.sql).run(...item.params);
synchronizeSqlitePartitions(connection);
connection.exec('COMMIT');
} catch (error) {
connection.exec('ROLLBACK');
throw error;
}
};
return createRepository({
client: 'sqlite',
location: path,
read: async () => stateFromRows(readSqliteRows(connection)),
transaction,
close: async () => connection.close()
});
}
return createSqliteStore;
}