509 lines
30 KiB
JavaScript
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;
|
|
}
|