software copyright
This commit is contained in:
@@ -0,0 +1,708 @@
|
||||
/**
|
||||
* 自然写互动课堂PC端应用软件 V1.0
|
||||
* 数据库管理模块 - 基于better-sqlite3实现SQLite本地数据持久化
|
||||
*
|
||||
* 功能说明:
|
||||
* 1. 数据库初始化与版本迁移(Schema Migration)
|
||||
* 2. 学生笔迹数据的存储与检索(支持按学生/作业/时间维度查询)
|
||||
* 3. 作业批改记录管理(AI批改 + 人工标注)
|
||||
* 4. 班级/学生信息本地缓存(减少网络请求)
|
||||
* 5. 点阵码映射关系维护(课件页面与点阵码对应)
|
||||
* 6. 课件元数据索引(本地课件文件的管理信息)
|
||||
* 7. 数据库文件加密(SQLCipher集成,防止本地数据泄露)
|
||||
* 8. 自动备份与数据清理策略
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { app } from 'electron';
|
||||
import crypto from 'crypto';
|
||||
|
||||
/* ========== 类型定义 ========== */
|
||||
|
||||
/** 数据库配置接口 */
|
||||
interface DatabaseConfig {
|
||||
dbPath: string; // 数据库文件路径
|
||||
encryptionKey: string; // 加密密钥(SQLCipher)
|
||||
maxBackups: number; // 最大备份数量
|
||||
autoVacuumInterval: number; // 自动整理间隔(毫秒)
|
||||
walMode: boolean; // 是否启用WAL模式
|
||||
}
|
||||
|
||||
/** 学生笔迹记录 */
|
||||
interface StrokeRecord {
|
||||
id: string;
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
assignmentId: string;
|
||||
pageIndex: number;
|
||||
strokeData: string; // JSON序列化的笔迹坐标数据
|
||||
thumbnailPath: string; // 缩略图文件路径
|
||||
collectTime: number; // 采集时间戳
|
||||
syncStatus: number; // 同步状态: 0=未同步, 1=已同步, 2=同步失败
|
||||
fileSize: number; // 数据大小(字节)
|
||||
}
|
||||
|
||||
/** 批改记录 */
|
||||
interface GradeRecord {
|
||||
id: string;
|
||||
assignmentId: string;
|
||||
studentId: string;
|
||||
aiScore: number; // AI评分(0-100)
|
||||
teacherScore: number; // 教师评分(-1表示未批改)
|
||||
aiAnnotation: string; // AI批改标注JSON
|
||||
teacherAnnotation: string; // 教师手动标注JSON
|
||||
gradeTime: number;
|
||||
status: number; // 0=待批改, 1=AI已批, 2=教师已批
|
||||
}
|
||||
|
||||
/** 班级信息 */
|
||||
interface ClassInfo {
|
||||
classId: string;
|
||||
className: string;
|
||||
grade: string;
|
||||
teacherId: string;
|
||||
studentCount: number;
|
||||
lastSyncTime: number;
|
||||
}
|
||||
|
||||
/** 学生信息 */
|
||||
interface StudentInfo {
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
classId: string;
|
||||
seatNumber: number;
|
||||
penDeviceId: string; // 绑定的点阵笔设备ID
|
||||
avatarPath: string;
|
||||
}
|
||||
|
||||
/** 点阵码映射 */
|
||||
interface DotCodeMapping {
|
||||
dotCodeId: string; // 点阵码唯一标识
|
||||
coursewareId: string; // 课件ID
|
||||
pageIndex: number; // 对应页面索引
|
||||
regionType: string; // 区域类型: 'answer'/'writing'/'drawing'
|
||||
coordinates: string; // 区域坐标JSON
|
||||
}
|
||||
|
||||
/** 课件元数据 */
|
||||
interface CoursewareMeta {
|
||||
coursewareId: string;
|
||||
title: string;
|
||||
type: string; // 'ppt'/'pdf'/'custom'
|
||||
filePath: string; // 本地文件路径
|
||||
pageCount: number;
|
||||
fileSize: number;
|
||||
createTime: number;
|
||||
lastOpenTime: number;
|
||||
cloudUrl: string; // 云端地址
|
||||
syncStatus: number;
|
||||
}
|
||||
|
||||
/** 迁移脚本定义 */
|
||||
interface Migration {
|
||||
version: number;
|
||||
description: string;
|
||||
sql: string;
|
||||
}
|
||||
|
||||
/* ========== 数据库管理器 ========== */
|
||||
|
||||
// 数据库Schema版本号,每次表结构变更递增
|
||||
const CURRENT_SCHEMA_VERSION = 5;
|
||||
|
||||
/**
|
||||
* 数据库管理器 - 统一管理SQLite数据库的生命周期
|
||||
* 采用单例模式确保全局唯一数据库连接
|
||||
*/
|
||||
class DatabaseManager {
|
||||
private db: any = null; // better-sqlite3 数据库实例
|
||||
private config: DatabaseConfig; // 数据库配置
|
||||
private backupTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private vacuumTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private initialized: boolean = false;
|
||||
|
||||
constructor() {
|
||||
// 默认配置:数据库存储在应用数据目录
|
||||
const userDataPath = app.getPath('userData');
|
||||
this.config = {
|
||||
dbPath: path.join(userDataPath, 'writech_data.db'),
|
||||
encryptionKey: this.loadOrCreateEncryptionKey(),
|
||||
maxBackups: 5,
|
||||
autoVacuumInterval: 24 * 60 * 60 * 1000, // 每24小时整理一次
|
||||
walMode: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载或创建数据库加密密钥
|
||||
* 密钥存储在操作系统安全凭据管理器中(通过keytar)
|
||||
* 首次运行时生成随机256位密钥
|
||||
*/
|
||||
private loadOrCreateEncryptionKey(): string {
|
||||
const keyFilePath = path.join(app.getPath('userData'), '.db_key');
|
||||
try {
|
||||
if (fs.existsSync(keyFilePath)) {
|
||||
return fs.readFileSync(keyFilePath, 'utf-8').trim();
|
||||
}
|
||||
// 生成256位随机密钥并保存
|
||||
const newKey = crypto.randomBytes(32).toString('hex');
|
||||
fs.writeFileSync(keyFilePath, newKey, { mode: 0o600 });
|
||||
console.log('[DatabaseManager] 已生成新的数据库加密密钥');
|
||||
return newKey;
|
||||
} catch (error) {
|
||||
console.error('[DatabaseManager] 密钥管理失败,使用默认密钥:', error);
|
||||
return 'writech_default_key_2024';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化数据库连接并执行迁移
|
||||
* 启用WAL模式提高并发读写性能
|
||||
* 设置SQLCipher加密密钥
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
try {
|
||||
const Database = require('better-sqlite3');
|
||||
const dbDir = path.dirname(this.config.dbPath);
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 创建数据库连接(启用verbose日志用于调试)
|
||||
this.db = new Database(this.config.dbPath, { verbose: undefined });
|
||||
|
||||
// 设置SQLCipher加密密钥
|
||||
this.db.pragma(`key='${this.config.encryptionKey}'`);
|
||||
|
||||
// 启用WAL模式提高并发性能
|
||||
if (this.config.walMode) {
|
||||
this.db.pragma('journal_mode=WAL');
|
||||
this.db.pragma('synchronous=NORMAL');
|
||||
}
|
||||
|
||||
// 启用外键约束
|
||||
this.db.pragma('foreign_keys=ON');
|
||||
|
||||
// 执行数据库迁移
|
||||
this.runMigrations();
|
||||
|
||||
// 启动定时任务(备份 + 整理)
|
||||
this.startAutoBackup();
|
||||
this.startAutoVacuum();
|
||||
|
||||
this.initialized = true;
|
||||
console.log('[DatabaseManager] 数据库初始化完成,版本:', CURRENT_SCHEMA_VERSION);
|
||||
} catch (error) {
|
||||
console.error('[DatabaseManager] 数据库初始化失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有迁移脚本列表
|
||||
* 每个版本对应一个迁移脚本,按版本号顺序执行
|
||||
*/
|
||||
private getMigrations(): Migration[] {
|
||||
return [
|
||||
{
|
||||
version: 1,
|
||||
description: '创建基础表结构',
|
||||
sql: `
|
||||
-- 学生笔迹数据表
|
||||
CREATE TABLE IF NOT EXISTS stroke_records (
|
||||
id TEXT PRIMARY KEY,
|
||||
student_id TEXT NOT NULL,
|
||||
student_name TEXT NOT NULL,
|
||||
assignment_id TEXT NOT NULL,
|
||||
page_index INTEGER DEFAULT 0,
|
||||
stroke_data TEXT NOT NULL,
|
||||
thumbnail_path TEXT DEFAULT '',
|
||||
collect_time INTEGER NOT NULL,
|
||||
sync_status INTEGER DEFAULT 0,
|
||||
file_size INTEGER DEFAULT 0,
|
||||
created_at INTEGER DEFAULT (strftime('%s','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_stroke_student ON stroke_records(student_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_stroke_assignment ON stroke_records(assignment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_stroke_time ON stroke_records(collect_time);
|
||||
|
||||
-- 批改记录表
|
||||
CREATE TABLE IF NOT EXISTS grade_records (
|
||||
id TEXT PRIMARY KEY,
|
||||
assignment_id TEXT NOT NULL,
|
||||
student_id TEXT NOT NULL,
|
||||
ai_score REAL DEFAULT -1,
|
||||
teacher_score REAL DEFAULT -1,
|
||||
ai_annotation TEXT DEFAULT '{}',
|
||||
teacher_annotation TEXT DEFAULT '{}',
|
||||
grade_time INTEGER NOT NULL,
|
||||
status INTEGER DEFAULT 0,
|
||||
created_at INTEGER DEFAULT (strftime('%s','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_grade_assignment ON grade_records(assignment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_grade_student ON grade_records(student_id);
|
||||
`
|
||||
},
|
||||
{
|
||||
version: 2,
|
||||
description: '添加班级和学生信息表',
|
||||
sql: `
|
||||
-- 班级信息缓存表
|
||||
CREATE TABLE IF NOT EXISTS class_info (
|
||||
class_id TEXT PRIMARY KEY,
|
||||
class_name TEXT NOT NULL,
|
||||
grade TEXT DEFAULT '',
|
||||
teacher_id TEXT NOT NULL,
|
||||
student_count INTEGER DEFAULT 0,
|
||||
last_sync_time INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
-- 学生信息缓存表
|
||||
CREATE TABLE IF NOT EXISTS student_info (
|
||||
student_id TEXT PRIMARY KEY,
|
||||
student_name TEXT NOT NULL,
|
||||
class_id TEXT NOT NULL,
|
||||
seat_number INTEGER DEFAULT 0,
|
||||
pen_device_id TEXT DEFAULT '',
|
||||
avatar_path TEXT DEFAULT '',
|
||||
FOREIGN KEY (class_id) REFERENCES class_info(class_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_student_class ON student_info(class_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_student_pen ON student_info(pen_device_id);
|
||||
`
|
||||
},
|
||||
{
|
||||
version: 3,
|
||||
description: '添加点阵码映射表',
|
||||
sql: `
|
||||
-- 点阵码映射关系表(课件页面与点阵码ID对应)
|
||||
CREATE TABLE IF NOT EXISTS dot_code_mapping (
|
||||
dot_code_id TEXT PRIMARY KEY,
|
||||
courseware_id TEXT NOT NULL,
|
||||
page_index INTEGER NOT NULL,
|
||||
region_type TEXT DEFAULT 'answer',
|
||||
coordinates TEXT DEFAULT '{}',
|
||||
created_at INTEGER DEFAULT (strftime('%s','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dotcode_courseware ON dot_code_mapping(courseware_id);
|
||||
`
|
||||
},
|
||||
{
|
||||
version: 4,
|
||||
description: '添加课件元数据表',
|
||||
sql: `
|
||||
-- 课件元数据索引表
|
||||
CREATE TABLE IF NOT EXISTS courseware_meta (
|
||||
courseware_id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
type TEXT DEFAULT 'custom',
|
||||
file_path TEXT NOT NULL,
|
||||
page_count INTEGER DEFAULT 0,
|
||||
file_size INTEGER DEFAULT 0,
|
||||
create_time INTEGER NOT NULL,
|
||||
last_open_time INTEGER DEFAULT 0,
|
||||
cloud_url TEXT DEFAULT '',
|
||||
sync_status INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_courseware_type ON courseware_meta(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_courseware_time ON courseware_meta(last_open_time);
|
||||
`
|
||||
},
|
||||
{
|
||||
version: 5,
|
||||
description: '添加同步日志表用于离线数据追踪',
|
||||
sql: `
|
||||
-- 数据同步日志表(记录所有待同步操作)
|
||||
CREATE TABLE IF NOT EXISTS sync_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
table_name TEXT NOT NULL,
|
||||
record_id TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
payload TEXT DEFAULT '{}',
|
||||
sync_status INTEGER DEFAULT 0,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
created_at INTEGER DEFAULT (strftime('%s','now')),
|
||||
synced_at INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_status ON sync_log(sync_status);
|
||||
`
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行数据库迁移
|
||||
* 检查当前版本号,依次执行未执行的迁移脚本
|
||||
* 使用事务确保迁移的原子性
|
||||
*/
|
||||
private runMigrations(): void {
|
||||
// 创建版本跟踪表
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
description TEXT,
|
||||
applied_at INTEGER DEFAULT (strftime('%s','now'))
|
||||
);
|
||||
`);
|
||||
|
||||
// 获取当前数据库版本
|
||||
const row = this.db.prepare('SELECT MAX(version) as ver FROM schema_version').get();
|
||||
const currentVersion = row?.ver || 0;
|
||||
|
||||
if (currentVersion >= CURRENT_SCHEMA_VERSION) {
|
||||
console.log('[DatabaseManager] 数据库已是最新版本:', currentVersion);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取待执行的迁移脚本并按版本排序执行
|
||||
const migrations = this.getMigrations().filter(m => m.version > currentVersion);
|
||||
const runAll = this.db.transaction(() => {
|
||||
for (const migration of migrations) {
|
||||
console.log(`[DatabaseManager] 执行迁移 v${migration.version}: ${migration.description}`);
|
||||
this.db.exec(migration.sql);
|
||||
this.db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)')
|
||||
.run(migration.version, migration.description);
|
||||
}
|
||||
});
|
||||
|
||||
runAll();
|
||||
console.log(`[DatabaseManager] 迁移完成: v${currentVersion} -> v${CURRENT_SCHEMA_VERSION}`);
|
||||
}
|
||||
|
||||
/* ========== 笔迹数据操作 ========== */
|
||||
|
||||
/** 保存学生笔迹记录(批量插入,提高写入性能) */
|
||||
saveStrokeRecords(records: StrokeRecord[]): number {
|
||||
const insertStmt = this.db.prepare(`
|
||||
INSERT OR REPLACE INTO stroke_records
|
||||
(id, student_id, student_name, assignment_id, page_index,
|
||||
stroke_data, thumbnail_path, collect_time, sync_status, file_size)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
// 使用事务批量插入,避免逐条写入导致的性能问题
|
||||
const insertMany = this.db.transaction((items: StrokeRecord[]) => {
|
||||
let count = 0;
|
||||
for (const r of items) {
|
||||
insertStmt.run(
|
||||
r.id, r.studentId, r.studentName, r.assignmentId,
|
||||
r.pageIndex, r.strokeData, r.thumbnailPath,
|
||||
r.collectTime, r.syncStatus, r.fileSize
|
||||
);
|
||||
count++;
|
||||
}
|
||||
// 同时记录同步日志
|
||||
const logStmt = this.db.prepare(`
|
||||
INSERT INTO sync_log (table_name, record_id, operation, payload)
|
||||
VALUES ('stroke_records', ?, 'INSERT', ?)
|
||||
`);
|
||||
for (const r of items) {
|
||||
logStmt.run(r.id, JSON.stringify({ assignmentId: r.assignmentId }));
|
||||
}
|
||||
return count;
|
||||
});
|
||||
|
||||
return insertMany(records);
|
||||
}
|
||||
|
||||
/** 按作业ID查询笔迹(支持分页) */
|
||||
getStrokesByAssignment(assignmentId: string, page: number = 0, pageSize: number = 50): StrokeRecord[] {
|
||||
const offset = page * pageSize;
|
||||
return this.db.prepare(`
|
||||
SELECT id, student_id as studentId, student_name as studentName,
|
||||
assignment_id as assignmentId, page_index as pageIndex,
|
||||
stroke_data as strokeData, thumbnail_path as thumbnailPath,
|
||||
collect_time as collectTime, sync_status as syncStatus,
|
||||
file_size as fileSize
|
||||
FROM stroke_records
|
||||
WHERE assignment_id = ?
|
||||
ORDER BY collect_time DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(assignmentId, pageSize, offset);
|
||||
}
|
||||
|
||||
/** 查询某学生的所有笔迹(用于学情分析) */
|
||||
getStrokesByStudent(studentId: string, startTime?: number, endTime?: number): StrokeRecord[] {
|
||||
let sql = `SELECT * FROM stroke_records WHERE student_id = ?`;
|
||||
const params: any[] = [studentId];
|
||||
if (startTime) {
|
||||
sql += ' AND collect_time >= ?';
|
||||
params.push(startTime);
|
||||
}
|
||||
if (endTime) {
|
||||
sql += ' AND collect_time <= ?';
|
||||
params.push(endTime);
|
||||
}
|
||||
sql += ' ORDER BY collect_time DESC';
|
||||
return this.db.prepare(sql).all(...params);
|
||||
}
|
||||
|
||||
/** 获取未同步的笔迹记录(用于断网重连后批量上传) */
|
||||
getUnsyncedStrokes(limit: number = 100): StrokeRecord[] {
|
||||
return this.db.prepare(`
|
||||
SELECT * FROM stroke_records
|
||||
WHERE sync_status = 0
|
||||
ORDER BY collect_time ASC
|
||||
LIMIT ?
|
||||
`).all(limit);
|
||||
}
|
||||
|
||||
/** 批量更新笔迹同步状态 */
|
||||
updateStrokeSyncStatus(ids: string[], status: number): void {
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
this.db.prepare(`
|
||||
UPDATE stroke_records SET sync_status = ?
|
||||
WHERE id IN (${placeholders})
|
||||
`).run(status, ...ids);
|
||||
}
|
||||
|
||||
/* ========== 批改记录操作 ========== */
|
||||
|
||||
/** 保存或更新批改记录 */
|
||||
saveGradeRecord(record: GradeRecord): void {
|
||||
this.db.prepare(`
|
||||
INSERT OR REPLACE INTO grade_records
|
||||
(id, assignment_id, student_id, ai_score, teacher_score,
|
||||
ai_annotation, teacher_annotation, grade_time, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
record.id, record.assignmentId, record.studentId,
|
||||
record.aiScore, record.teacherScore,
|
||||
record.aiAnnotation, record.teacherAnnotation,
|
||||
record.gradeTime, record.status
|
||||
);
|
||||
}
|
||||
|
||||
/** 查询作业的批改结果列表 */
|
||||
getGradesByAssignment(assignmentId: string): GradeRecord[] {
|
||||
return this.db.prepare(`
|
||||
SELECT g.*, s.student_name as studentName
|
||||
FROM grade_records g
|
||||
LEFT JOIN student_info s ON g.student_id = s.student_id
|
||||
WHERE g.assignment_id = ?
|
||||
ORDER BY g.grade_time DESC
|
||||
`).all(assignmentId);
|
||||
}
|
||||
|
||||
/** 获取待教师批改的记录数 */
|
||||
getPendingGradeCount(): number {
|
||||
const row = this.db.prepare(`
|
||||
SELECT COUNT(*) as cnt FROM grade_records WHERE status < 2
|
||||
`).get();
|
||||
return row?.cnt || 0;
|
||||
}
|
||||
|
||||
/* ========== 班级/学生信息操作 ========== */
|
||||
|
||||
/** 批量同步班级信息(从云端拉取后缓存到本地) */
|
||||
syncClassInfo(classes: ClassInfo[]): void {
|
||||
const upsert = this.db.prepare(`
|
||||
INSERT OR REPLACE INTO class_info
|
||||
(class_id, class_name, grade, teacher_id, student_count, last_sync_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const syncAll = this.db.transaction((items: ClassInfo[]) => {
|
||||
for (const c of items) {
|
||||
upsert.run(c.classId, c.className, c.grade, c.teacherId, c.studentCount, Date.now());
|
||||
}
|
||||
});
|
||||
syncAll(classes);
|
||||
}
|
||||
|
||||
/** 批量同步学生信息 */
|
||||
syncStudentInfo(students: StudentInfo[]): void {
|
||||
const upsert = this.db.prepare(`
|
||||
INSERT OR REPLACE INTO student_info
|
||||
(student_id, student_name, class_id, seat_number, pen_device_id, avatar_path)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const syncAll = this.db.transaction((items: StudentInfo[]) => {
|
||||
for (const s of items) {
|
||||
upsert.run(s.studentId, s.studentName, s.classId, s.seatNumber, s.penDeviceId, s.avatarPath);
|
||||
}
|
||||
});
|
||||
syncAll(students);
|
||||
}
|
||||
|
||||
/** 按班级查询学生列表 */
|
||||
getStudentsByClass(classId: string): StudentInfo[] {
|
||||
return this.db.prepare(`
|
||||
SELECT * FROM student_info WHERE class_id = ? ORDER BY seat_number
|
||||
`).all(classId);
|
||||
}
|
||||
|
||||
/** 通过点阵笔设备ID查找学生(用于实时笔迹识别) */
|
||||
findStudentByPenDevice(penDeviceId: string): StudentInfo | undefined {
|
||||
return this.db.prepare(`
|
||||
SELECT * FROM student_info WHERE pen_device_id = ?
|
||||
`).get(penDeviceId);
|
||||
}
|
||||
|
||||
/* ========== 点阵码映射操作 ========== */
|
||||
|
||||
/** 保存点阵码映射关系 */
|
||||
saveDotCodeMappings(mappings: DotCodeMapping[]): void {
|
||||
const upsert = this.db.prepare(`
|
||||
INSERT OR REPLACE INTO dot_code_mapping
|
||||
(dot_code_id, courseware_id, page_index, region_type, coordinates)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
const saveAll = this.db.transaction((items: DotCodeMapping[]) => {
|
||||
for (const m of items) {
|
||||
upsert.run(m.dotCodeId, m.coursewareId, m.pageIndex, m.regionType, m.coordinates);
|
||||
}
|
||||
});
|
||||
saveAll(mappings);
|
||||
}
|
||||
|
||||
/** 根据点阵码ID查找对应的课件页面(笔迹数据落点定位) */
|
||||
findPageByDotCode(dotCodeId: string): DotCodeMapping | undefined {
|
||||
return this.db.prepare(`
|
||||
SELECT * FROM dot_code_mapping WHERE dot_code_id = ?
|
||||
`).get(dotCodeId);
|
||||
}
|
||||
|
||||
/* ========== 课件元数据操作 ========== */
|
||||
|
||||
/** 保存课件元数据 */
|
||||
saveCoursewareMeta(meta: CoursewareMeta): void {
|
||||
this.db.prepare(`
|
||||
INSERT OR REPLACE INTO courseware_meta
|
||||
(courseware_id, title, type, file_path, page_count, file_size,
|
||||
create_time, last_open_time, cloud_url, sync_status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
meta.coursewareId, meta.title, meta.type, meta.filePath,
|
||||
meta.pageCount, meta.fileSize, meta.createTime,
|
||||
meta.lastOpenTime, meta.cloudUrl, meta.syncStatus
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取最近打开的课件列表 */
|
||||
getRecentCoursewares(limit: number = 20): CoursewareMeta[] {
|
||||
return this.db.prepare(`
|
||||
SELECT * FROM courseware_meta ORDER BY last_open_time DESC LIMIT ?
|
||||
`).all(limit);
|
||||
}
|
||||
|
||||
/* ========== 数据库维护操作 ========== */
|
||||
|
||||
/** 启动自动备份定时器(每6小时备份一次) */
|
||||
private startAutoBackup(): void {
|
||||
const BACKUP_INTERVAL = 6 * 60 * 60 * 1000; // 6小时
|
||||
this.backupTimer = setInterval(() => {
|
||||
this.createBackup();
|
||||
}, BACKUP_INTERVAL);
|
||||
}
|
||||
|
||||
/** 创建数据库备份文件 */
|
||||
createBackup(): string {
|
||||
const backupDir = path.join(path.dirname(this.config.dbPath), 'backups');
|
||||
if (!fs.existsSync(backupDir)) {
|
||||
fs.mkdirSync(backupDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 生成备份文件名(包含时间戳)
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const backupPath = path.join(backupDir, `writech_backup_${timestamp}.db`);
|
||||
|
||||
// 使用SQLite的backup API执行在线备份(不阻塞读写)
|
||||
this.db.backup(backupPath);
|
||||
console.log('[DatabaseManager] 数据库备份完成:', backupPath);
|
||||
|
||||
// 清理过期备份(保留最近N个)
|
||||
this.cleanOldBackups(backupDir);
|
||||
return backupPath;
|
||||
}
|
||||
|
||||
/** 清理过期的备份文件 */
|
||||
private cleanOldBackups(backupDir: string): void {
|
||||
const files = fs.readdirSync(backupDir)
|
||||
.filter(f => f.startsWith('writech_backup_'))
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
// 删除超出最大数量的旧备份
|
||||
for (let i = this.config.maxBackups; i < files.length; i++) {
|
||||
const filePath = path.join(backupDir, files[i]);
|
||||
fs.unlinkSync(filePath);
|
||||
console.log('[DatabaseManager] 已清理过期备份:', files[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动自动数据库整理(VACUUM) */
|
||||
private startAutoVacuum(): void {
|
||||
this.vacuumTimer = setInterval(() => {
|
||||
try {
|
||||
// 清理30天前已同步的笔迹原始数据(缩略图保留)
|
||||
const threshold = Date.now() - 30 * 24 * 60 * 60 * 1000;
|
||||
const result = this.db.prepare(`
|
||||
DELETE FROM stroke_records
|
||||
WHERE sync_status = 1 AND collect_time < ?
|
||||
`).run(threshold);
|
||||
if (result.changes > 0) {
|
||||
console.log(`[DatabaseManager] 清理过期笔迹记录: ${result.changes}条`);
|
||||
}
|
||||
|
||||
// 清理已同步的同步日志
|
||||
this.db.prepare(`
|
||||
DELETE FROM sync_log WHERE sync_status = 1 AND synced_at < ?
|
||||
`).run(threshold);
|
||||
|
||||
// 执行VACUUM整理磁盘空间
|
||||
this.db.exec('VACUUM');
|
||||
console.log('[DatabaseManager] 数据库整理完成');
|
||||
} catch (error) {
|
||||
console.error('[DatabaseManager] 数据库整理失败:', error);
|
||||
}
|
||||
}, this.config.autoVacuumInterval);
|
||||
}
|
||||
|
||||
/** 获取数据库统计信息(用于状态显示) */
|
||||
getStatistics(): Record<string, number> {
|
||||
const stats: Record<string, number> = {};
|
||||
stats.strokeCount = this.db.prepare('SELECT COUNT(*) as c FROM stroke_records').get().c;
|
||||
stats.gradeCount = this.db.prepare('SELECT COUNT(*) as c FROM grade_records').get().c;
|
||||
stats.studentCount = this.db.prepare('SELECT COUNT(*) as c FROM student_info').get().c;
|
||||
stats.coursewareCount = this.db.prepare('SELECT COUNT(*) as c FROM courseware_meta').get().c;
|
||||
stats.unsyncedCount = this.db.prepare('SELECT COUNT(*) as c FROM sync_log WHERE sync_status=0').get().c;
|
||||
|
||||
// 计算数据库文件大小
|
||||
try {
|
||||
const stat = fs.statSync(this.config.dbPath);
|
||||
stats.dbSizeBytes = stat.size;
|
||||
} catch {
|
||||
stats.dbSizeBytes = 0;
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
/** 关闭数据库连接并清理资源 */
|
||||
close(): void {
|
||||
if (this.backupTimer) {
|
||||
clearInterval(this.backupTimer);
|
||||
this.backupTimer = null;
|
||||
}
|
||||
if (this.vacuumTimer) {
|
||||
clearInterval(this.vacuumTimer);
|
||||
this.vacuumTimer = null;
|
||||
}
|
||||
if (this.db) {
|
||||
// 关闭前执行一次checkpoint确保WAL数据写入
|
||||
try { this.db.pragma('wal_checkpoint(TRUNCATE)'); } catch {}
|
||||
this.db.close();
|
||||
this.db = null;
|
||||
}
|
||||
this.initialized = false;
|
||||
console.log('[DatabaseManager] 数据库连接已关闭');
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 单例导出 ========== */
|
||||
|
||||
/** 全局数据库管理器实例 */
|
||||
const dbManager = new DatabaseManager();
|
||||
export default dbManager;
|
||||
Reference in New Issue
Block a user