software copyright

This commit is contained in:
jiahong
2026-03-22 15:24:40 +08:00
parent e303bb868a
commit 60f336e345
155 changed files with 127262 additions and 0 deletions
@@ -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;