openlearn-next 0.6.0 → 0.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/cli-data.mjs +535 -40
- package/cli-doctor.mjs +456 -17
- package/cli.mjs +214 -12
- package/dist/assets/AcademicYearFilter-DUUGpDKt.js +1 -0
- package/dist/assets/ActivityWorkspaceWidget-CMN8elwQ.js +1 -0
- package/dist/assets/AdminDirectoryView-Dlz7q-xf.js +5 -0
- package/dist/assets/AssessmentCenter-CaoAI9Ke.js +1 -0
- package/dist/assets/BatchPickerModal-CzutdtIz.js +1 -0
- package/dist/assets/ClassesView-RkFPpWRu.js +32 -0
- package/dist/assets/ClassroomCountdownWidget-gs42lIGW.js +2 -0
- package/dist/assets/CloudDriveModal-C6JtK08M.js +1 -0
- package/dist/assets/ComputerLabView-Dr-g8FrU.js +1 -0
- package/dist/assets/CourseManagement-DNeaJcT9.js +1 -0
- package/dist/assets/CourseWizardModal-vw876VJr.js +3 -0
- package/dist/assets/CoursewareHubPanel-CyxDIJAF.js +1 -0
- package/dist/assets/Dashboard-C1iYnfCh.js +8 -0
- package/dist/assets/ExportWeightModal-CTZ-O_wi.js +1 -0
- package/dist/assets/GLTFLoader-CZJbo_y1.js +1 -0
- package/dist/assets/{HelpTour-B-RKDb1-.js → HelpTour-D_q1z_MI.js} +1 -1
- package/dist/assets/HelpView-wyKldm_R.js +204 -0
- package/dist/assets/ImportLessonsModal-C09Z-qtx.js +1 -0
- package/dist/assets/ImportModal-DqjqNeH4.js +2 -0
- package/dist/assets/InteractiveCoursewareViewer-V97euiJF.js +1 -0
- package/dist/assets/InteractiveWhiteboard-COLMh_-w.js +65 -0
- package/dist/assets/LazyCourseware-0iFYlRGj.js +2 -0
- package/dist/assets/LazyWhiteboard-CcoBIx6V.js +2 -0
- package/dist/assets/LessonEditorView-BWmtKwmP.js +1 -0
- package/dist/assets/LiveClassroomView-CaS1DzSS.js +61 -0
- package/dist/assets/NotificationDetailModal-4MdNF4tw.js +1 -0
- package/dist/assets/OrbitControls-CQjN_uwS.js +1 -0
- package/dist/assets/PluginView-BqTbAp30.js +77 -0
- package/dist/assets/{ProcessLogsModal-DfKPVIYe.js → ProcessLogsModal-Cfr-T9Gd.js} +1 -1
- package/dist/assets/QuizGeneratorModal-xTq22vDq.js +1 -0
- package/dist/assets/StudentAssignmentView-Cy_JdPYB.js +1 -0
- package/dist/assets/StudentLessonView-B3fKejCU.js +1 -0
- package/dist/assets/StudentView-CpJsG7FT.js +2 -0
- package/dist/assets/SystemResourceLibraryModal-YZ85mv_v.js +1 -0
- package/dist/assets/TeacherView-B3zhzd5g.js +2 -0
- package/dist/assets/TimetableView-D_4XmrRN.js +6 -0
- package/dist/assets/UTIF-FfCsrHhe.js +1 -0
- package/dist/assets/__vite-optional-peer-dep_ai_ooxml-ui-BIHI7g3E.js +1 -0
- package/dist/assets/browser-DEI0QxZC.js +1 -0
- package/dist/assets/html2canvas-pro.esm-Bo50djkB.js +12 -0
- package/dist/assets/index-BzXADCXZ.js +131 -0
- package/dist/assets/index-C6hlh8pw.css +1 -0
- package/dist/assets/{index.es-DvJa33KC.js → index.es-DyEF0CZ9.js} +1 -1
- package/dist/assets/pluginParsers-PzgFzxS9.js +1 -0
- package/dist/assets/react_pptx-react-viewer-BIHI7g3E.js +1 -0
- package/dist/assets/teaching-modes-client-BeVaDdJ-.js +1 -0
- package/dist/assets/three.module-iOYB3rD4.js +4228 -0
- package/dist/assets/timeline-duration-BgdJCSrW.js +1 -0
- package/dist/assets/{vendor-charts-DGsG2RUv.js → vendor-charts-SrpBsvM3.js} +2 -2
- package/dist/assets/{vendor-icons-CyfamhaC.js → vendor-icons-CXrZ3R2V.js} +2 -2
- package/dist/assets/{vendor-konva-DBWwlLTb.js → vendor-konva-Dne0Z-WC.js} +1 -1
- package/dist/assets/{vendor-pdf-D0UGJOgo.js → vendor-pdf-C1Vh2Aqq.js} +56 -56
- package/dist/assets/vendor-pptx-Bzn6whfK.js +11311 -0
- package/dist/assets/vendor-pptx-DIWdXF1L.css +1 -0
- package/dist/assets/{vendor-react-CZ1I7IcM.js → vendor-react-Kv_JATwr.js} +1 -1
- package/dist/assets/vendor-utils-aM7aYvPP.js +1 -0
- package/dist/cli-migrator.cjs +327 -0
- package/dist/env-validation.cjs +351 -0
- package/dist/index.html +5 -5
- package/dist/plugin-net-preload.cjs +263 -0
- package/dist/server.cjs +20361 -4337
- package/migrations/000_initial_schema.sql +410 -0
- package/migrations/001_add_execution_mode.sql +6 -0
- package/migrations/002_add_client_session_expiry.sql +6 -0
- package/migrations/003_classroom_runtime.sql +33 -0
- package/migrations/004_courseware_score_config.sql +26 -0
- package/migrations/005_assignment_hub.sql +155 -0
- package/migrations/006_classroom_event_bus.sql +28 -0
- package/migrations/007_interactive_classroom.sql +119 -0
- package/migrations/008_teaching_modes.sql +36 -0
- package/migrations/009_classroom_peer_review.sql +159 -0
- package/migrations/010_class_groups.sql +33 -0
- package/migrations/011_classroom_feed.sql +34 -0
- package/migrations/012_auto_record_score.sql +48 -0
- package/migrations/013_score_source_strategy.sql +81 -0
- package/migrations/014_performance_indexes.sql +21 -0
- package/migrations/015_demo_data_registry.sql +26 -0
- package/migrations/016_preset_polls_and_passcode_expiry.sql +33 -0
- package/migrations/017_pacing_signals_segment.sql +6 -0
- package/migrations/018_processes_plugin_owner.sql +26 -0
- package/migrations/019_plugins_version.sql +61 -0
- package/migrations/020_upload_max_file_mb.sql +14 -0
- package/migrations/021_class_owner_and_password_gate.sql +11 -0
- package/migrations/022_plugin_public_routes.sql +14 -0
- package/migrations/023_encrypt_class_passcodes.sql +18 -0
- package/migrations/024_segment_gates.sql +37 -0
- package/migrations/025_assignment_lookup_indexes.sql +11 -0
- package/migrations/026_plugin_grants.sql +21 -0
- package/migrations/027_academic_year.sql +26 -0
- package/migrations/028_classroom_indexes.sql +14 -0
- package/migrations/029_clean_lesson_think_artifacts.sql +13 -0
- package/migrations/030_backfill_academic_year.sql +30 -0
- package/migrations/031_assessment_core.sql +158 -0
- package/migrations/032_progress_verification.sql +17 -0
- package/migrations/033_student_lock_mode.sql +9 -0
- package/migrations/034_plugin_orphan_data.sql +27 -0
- package/migrations/035_icebreaker_checkins.sql +26 -0
- package/migrations/036_student_read_notifications_created_at.sql +19 -0
- package/migrations/037_plugin_config_scopes.sql +40 -0
- package/migrations/038_assessment_ai_grading_suggestion.sql +10 -0
- package/migrations/039_question_bank_sharing_and_papers.sql +14 -0
- package/migrations/040_assignment_grade_history.sql +37 -0
- package/migrations/041_course_package_idempotency.sql +33 -0
- package/migrations/042_assignment_grade_resubmit_audit.sql +12 -0
- package/migrations/043_mark_sha256_passwords.sql +31 -0
- package/migrations/README.md +61 -0
- package/migrations/schema-golden.json +640 -0
- package/package.json +56 -36
- package/dist/assets/ActivityWorkspaceWidget-_6XMBAcc.js +0 -1
- package/dist/assets/AdminDirectoryView-BufUF-sk.js +0 -5
- package/dist/assets/BatchPickerModal-CES2DlPx.js +0 -1
- package/dist/assets/ClassesView-BLvRJKcc.js +0 -24
- package/dist/assets/ClassroomCountdownWidget-DfKotHP8.js +0 -2
- package/dist/assets/CloudDriveModal-CLe4I18S.js +0 -1
- package/dist/assets/ComputerLabView-CPnWWguS.js +0 -1
- package/dist/assets/CourseManagement-BSR61ggI.js +0 -1
- package/dist/assets/CourseWizardModal-D2m3PG7k.js +0 -3
- package/dist/assets/CoursewareHubPanel-gqjte-Pv.js +0 -1
- package/dist/assets/Dashboard-BPBJd0QR.js +0 -8
- package/dist/assets/ExportWeightModal-C3gIzDaP.js +0 -1
- package/dist/assets/HelpView-Bk64VK-J.js +0 -204
- package/dist/assets/ImportLessonsModal-DUGpcZV3.js +0 -1
- package/dist/assets/ImportModal-ChY_Y5DZ.js +0 -1
- package/dist/assets/InteractiveCoursewareViewer-B9wvha7w.js +0 -1
- package/dist/assets/InteractiveWhiteboard-FPrze5CJ.js +0 -78
- package/dist/assets/LazyCourseware-BV5nOs5y.js +0 -2
- package/dist/assets/LazyWhiteboard-Cy5DdYnO.js +0 -2
- package/dist/assets/LessonEditorView-3SyplRhr.js +0 -1
- package/dist/assets/LiveClassroomView-CX7SUnmx.js +0 -61
- package/dist/assets/NotificationDetailModal-BBATA8Lh.js +0 -1
- package/dist/assets/PluginView-D5jMNscn.js +0 -66
- package/dist/assets/QuizGeneratorModal-CbRirQId.js +0 -1
- package/dist/assets/StudentAssignmentView-ucXAOac9.js +0 -1
- package/dist/assets/StudentLessonView-CW6FYMtd.js +0 -13
- package/dist/assets/StudentView-Bxrut8tS.js +0 -2
- package/dist/assets/SystemResourceLibraryModal-CXdrWHDL.js +0 -1
- package/dist/assets/TeacherView-DXg0boJH.js +0 -2
- package/dist/assets/TimetableView-DPABkaFX.js +0 -6
- package/dist/assets/index-B3W5zons.css +0 -1
- package/dist/assets/index-B6wtREUD.js +0 -221
- package/dist/assets/purify.es-CYR4BTuT.js +0 -3
- package/dist/assets/vendor-pptx-D4btWy5L.js +0 -39
- package/dist/assets/vendor-utils-B6R8dnOe.js +0 -1
package/README.md
CHANGED
|
@@ -98,7 +98,11 @@ openlearn-next # 启动
|
|
|
98
98
|
npm update -g openlearn-next # 更新
|
|
99
99
|
```
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
种子账号用户名仍是 `admin`(管理员)和 `teacher`(教师),但**不再使用与用户名相同的默认密码**。首次空库启动时:
|
|
102
|
+
|
|
103
|
+
- 若设置了 `OPENLEARN_ADMIN_PASSWORD` / `OPENLEARN_TEACHER_PASSWORD`,使用该口令。口令若仍等于用户名,登录后必须立刻修改,期间除改密、登出和会话查询外,其余 `/api` 请求(含 GET)返回 403。
|
|
104
|
+
- 未设置时随机生成 20 位口令,明文只打印一次,并写入 `storage/bootstrap-credentials.txt`(权限 600)。请在首次登录后修改并删除该文件。
|
|
105
|
+
- 已有数据库若口令仍是 `admin` / `teacher`,可以登录,但同样被强制改密拦住,直到 `POST /api/auth/change-password` 成功。不要在生产环境设置 `PLAYWRIGHT_TEST`(该变量会保留测试用默认口令并跳过改密门禁)。
|
|
102
106
|
|
|
103
107
|
### 本地开发
|
|
104
108
|
|
package/cli-data.mjs
CHANGED
|
@@ -1,6 +1,96 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
1
2
|
import fs from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import os from 'node:os';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 敏感文件权限收紧(与 `packages/core/db/secure-files.ts` 同一口径)。
|
|
13
|
+
*
|
|
14
|
+
* ## 为什么在这里内联而不是 import
|
|
15
|
+
*
|
|
16
|
+
* CLI 脚本必须能在**发布包**里独立运行 —— 发布清单(package.json `files`)只有
|
|
17
|
+
* `cli-*.mjs` 与 `dist/*.cjs`,源码里的 `secure-files.ts` 并不存在。走 `import()`
|
|
18
|
+
* 会在任何非源码检出环境直接 MODULE_NOT_FOUND(与 `loadCliMigrator` 踩的是同一个坑)。
|
|
19
|
+
*
|
|
20
|
+
* ## 口径
|
|
21
|
+
*
|
|
22
|
+
* - 备份目录 0700、备份文件 0600:备份是**全量学生数据的未加密副本**。
|
|
23
|
+
* - **只收紧不放宽**:已合规(或比基线更严,如 0400)时不动,只在真的含有
|
|
24
|
+
* group/other 位时才 chmod。
|
|
25
|
+
* - Windows 等不支持 chmod 的环境:try/catch 吞掉,不阻断备份/还原。
|
|
26
|
+
*/
|
|
27
|
+
const SECURE_FILE_MODE = 0o600;
|
|
28
|
+
const SECURE_DIR_MODE = 0o700;
|
|
29
|
+
/** group/other 位掩码:任何一位为 1 即视为「宽于基线」 */
|
|
30
|
+
const GROUP_OTHER_BITS = 0o077;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 把已存在的文件收紧到 600。
|
|
34
|
+
* @returns 是否发生了实际变更
|
|
35
|
+
*/
|
|
36
|
+
function hardenFile(filePath) {
|
|
37
|
+
try {
|
|
38
|
+
const mode = fs.statSync(filePath).mode & 0o777;
|
|
39
|
+
if ((mode & GROUP_OTHER_BITS) === 0) return false;
|
|
40
|
+
fs.chmodSync(filePath, SECURE_FILE_MODE);
|
|
41
|
+
return true;
|
|
42
|
+
} catch {
|
|
43
|
+
// 不存在 / Windows 不支持 chmod:交给调用方后续处理
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 确保目录存在且合规:不存在则按 0700 创建,已存在但过宽则收紧。
|
|
50
|
+
*/
|
|
51
|
+
function ensureSecureDir(dirPath) {
|
|
52
|
+
try {
|
|
53
|
+
// mode 只对**新建**目录生效;0700 不含 group/other 位,因此 umask 不会削掉任何一位。
|
|
54
|
+
fs.mkdirSync(dirPath, { recursive: true, mode: SECURE_DIR_MODE });
|
|
55
|
+
} catch {
|
|
56
|
+
// 目录可能已存在或不可创建
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const mode = fs.statSync(dirPath).mode & 0o777;
|
|
60
|
+
if ((mode & GROUP_OTHER_BITS) !== 0) fs.chmodSync(dirPath, SECURE_DIR_MODE);
|
|
61
|
+
} catch {
|
|
62
|
+
// 同上
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 加载迁移器(`pnpm build` 产出的 `dist/cli-migrator.cjs`)。
|
|
68
|
+
*
|
|
69
|
+
* ## 为什么不再直接 `import('./packages/core/db/migrator.js')`
|
|
70
|
+
*
|
|
71
|
+
* 那条路径**在任何环境都不存在**:源码检出里只有 `migrator.ts`,发布包的 `files`
|
|
72
|
+
* 清单里也只有 `dist/*` 与几个 `cli-*.mjs`。于是 `openlearn-next migrate status` /
|
|
73
|
+
* `migrate rollback` 一旦走到加载点就抛 MODULE_NOT_FOUND —— 两条帮助里宣传的命令
|
|
74
|
+
* 实际从未可用(`_migrations` 表不存在时会在更早的分支提前返回,把这个缺陷藏住了)。
|
|
75
|
+
*
|
|
76
|
+
* 现在与 `env-validation` 同法:构建期打成独立 CJS,运行时用 createRequire 载入
|
|
77
|
+
* (而不是 `await import('...')`,后者会被 Vite/Vitest 在转换期静态解析)。
|
|
78
|
+
*
|
|
79
|
+
* 产物缺失时给出**可操作**的报错,而不是让用户对着 MODULE_NOT_FOUND 猜。
|
|
80
|
+
*/
|
|
81
|
+
export function loadCliMigrator() {
|
|
82
|
+
const artifact = path.join(__dirname, 'dist', 'cli-migrator.cjs');
|
|
83
|
+
if (!fs.existsSync(artifact)) {
|
|
84
|
+
const error = new Error(
|
|
85
|
+
`迁移器构建产物缺失:${artifact}\n` +
|
|
86
|
+
' 源码检出请先执行 `pnpm build`(或 `bash scripts/build-cli-artifacts.sh`);\n' +
|
|
87
|
+
' 若你安装的是发布包,这属于打包缺陷,请反馈。',
|
|
88
|
+
);
|
|
89
|
+
error.code = 'CLI_MIGRATOR_ARTIFACT_MISSING';
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
return requireFromHere(artifact);
|
|
93
|
+
}
|
|
4
94
|
|
|
5
95
|
/**
|
|
6
96
|
* 解析本地目标数据库路径
|
|
@@ -32,17 +122,90 @@ export async function runBackup(outputFile, options = {}) {
|
|
|
32
122
|
const now = new Date();
|
|
33
123
|
const pad = (n) => String(n).padStart(2, '0');
|
|
34
124
|
const timestamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
125
|
+
const isFull = Boolean(options.full);
|
|
126
|
+
const defaultExt = isFull ? '.zip' : '.db';
|
|
35
127
|
const target = outputFile
|
|
36
128
|
? path.resolve(outputFile)
|
|
37
|
-
: path.resolve(process.cwd(), `
|
|
129
|
+
: path.resolve(process.cwd(), `openlearn_${isFull ? 'full_backup' : 'backup'}_${timestamp}${defaultExt}`);
|
|
130
|
+
|
|
131
|
+
ensureSecureDir(path.dirname(target));
|
|
38
132
|
|
|
39
|
-
|
|
133
|
+
if (isFull) {
|
|
134
|
+
try {
|
|
135
|
+
const JSZip = (await import('jszip')).default;
|
|
136
|
+
const zip = new JSZip();
|
|
137
|
+
|
|
138
|
+
// 1. 内存/临时备份 SQLite 数据库
|
|
139
|
+
const Database = (await import('better-sqlite3')).default;
|
|
140
|
+
const tempDbPath = path.join(os.tmpdir(), `openlearn_temp_backup_${Date.now()}.db`);
|
|
141
|
+
const db = new Database(dbPath, { readonly: true });
|
|
142
|
+
await db.backup(tempDbPath);
|
|
143
|
+
db.close();
|
|
144
|
+
const dbBuffer = fs.readFileSync(tempDbPath);
|
|
145
|
+
try {
|
|
146
|
+
fs.unlinkSync(tempDbPath);
|
|
147
|
+
} catch {}
|
|
148
|
+
zip.file('data.db', dbBuffer);
|
|
149
|
+
|
|
150
|
+
// 2. 递归打包 uploads 与 storage 目录
|
|
151
|
+
const dataDir = process.env.OPENLEARN_DATA_DIR || path.dirname(dbPath);
|
|
152
|
+
const uploadsDir = path.join(dataDir, 'uploads');
|
|
153
|
+
const storageDir = path.join(dataDir, 'storage');
|
|
154
|
+
|
|
155
|
+
const addDirToZip = (dir, prefix) => {
|
|
156
|
+
if (!fs.existsSync(dir)) return;
|
|
157
|
+
const items = fs.readdirSync(dir, { withFileTypes: true });
|
|
158
|
+
for (const item of items) {
|
|
159
|
+
const itemPath = path.join(dir, item.name);
|
|
160
|
+
const zipRel = prefix ? `${prefix}/${item.name}` : item.name;
|
|
161
|
+
if (item.isDirectory()) {
|
|
162
|
+
addDirToZip(itemPath, zipRel);
|
|
163
|
+
} else if (item.isFile()) {
|
|
164
|
+
zip.file(zipRel, fs.readFileSync(itemPath));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
addDirToZip(uploadsDir, 'uploads');
|
|
170
|
+
addDirToZip(storageDir, 'storage');
|
|
171
|
+
|
|
172
|
+
// 3. 元数据信息
|
|
173
|
+
zip.file(
|
|
174
|
+
'metadata.json',
|
|
175
|
+
JSON.stringify(
|
|
176
|
+
{
|
|
177
|
+
version: '0.6.0',
|
|
178
|
+
type: 'openlearn-full-backup',
|
|
179
|
+
timestamp: Date.now(),
|
|
180
|
+
createdAt: new Date().toISOString(),
|
|
181
|
+
},
|
|
182
|
+
null,
|
|
183
|
+
2,
|
|
184
|
+
),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const zipBuffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
|
|
188
|
+
fs.writeFileSync(target, zipBuffer);
|
|
189
|
+
// 整包备份含全量学生数据(数据库 + 课件 + 作业)—— 落盘即 0600
|
|
190
|
+
hardenFile(target);
|
|
191
|
+
const stat = fs.statSync(target);
|
|
192
|
+
const sizeKb = Math.round(stat.size / 1024);
|
|
193
|
+
log(`✓ 系统全量整包备份完成(数据库 + 课件 uploads + 作业 storage)!`);
|
|
194
|
+
log(` 全量归档文件: ${target} (${sizeKb} KB)`);
|
|
195
|
+
return { ok: true, targetFile: target };
|
|
196
|
+
} catch (fullErr) {
|
|
197
|
+
log(`✗ 全量备份生成失败: ${fullErr.message}`);
|
|
198
|
+
return { ok: false, error: fullErr.message };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
40
201
|
|
|
41
202
|
try {
|
|
42
203
|
const Database = (await import('better-sqlite3')).default;
|
|
43
204
|
const db = new Database(dbPath, { readonly: true });
|
|
44
205
|
await db.backup(target);
|
|
45
206
|
db.close();
|
|
207
|
+
// 冷备是全量学生数据的未加密副本 —— 落盘即 0600
|
|
208
|
+
hardenFile(target);
|
|
46
209
|
|
|
47
210
|
const stat = fs.statSync(target);
|
|
48
211
|
const sizeKb = Math.round(stat.size / 1024);
|
|
@@ -53,6 +216,8 @@ export async function runBackup(outputFile, options = {}) {
|
|
|
53
216
|
// 降级为物理文件复制
|
|
54
217
|
try {
|
|
55
218
|
fs.copyFileSync(dbPath, target);
|
|
219
|
+
// copyFileSync 按默认 umask 落盘 → 收紧到 600
|
|
220
|
+
hardenFile(target);
|
|
56
221
|
const stat = fs.statSync(target);
|
|
57
222
|
const sizeKb = Math.round(stat.size / 1024);
|
|
58
223
|
log(`✓ 数据库文件冷备完成(直接镜像模式):${target} (${sizeKb} KB)`);
|
|
@@ -65,15 +230,125 @@ export async function runBackup(outputFile, options = {}) {
|
|
|
65
230
|
}
|
|
66
231
|
|
|
67
232
|
/**
|
|
68
|
-
*
|
|
233
|
+
* 数据安全回滚与还原(支持 .db 镜像与 .zip 全量整包)
|
|
69
234
|
* @param {string} sourceFile
|
|
70
235
|
* @param {Object} [options]
|
|
71
236
|
* @returns {Promise<{ ok: boolean, error?: string }>}
|
|
72
237
|
*/
|
|
238
|
+
/**
|
|
239
|
+
* 读取 SQLite 快照的完整性信息。
|
|
240
|
+
*
|
|
241
|
+
* 迁移失败后的 `pre_migration_*.db` 与 `backup` 产物都是普通 SQLite 镜像:
|
|
242
|
+
* 直接覆盖主库前必须确认它真的能打开、`integrity_check` 通过,否则「恢复」
|
|
243
|
+
* 会用一个坏文件替掉还能用的库。
|
|
244
|
+
*
|
|
245
|
+
* @param {string} filePath
|
|
246
|
+
* @returns {Promise<{ ok: boolean, error?: string, migrations: string[] }>}
|
|
247
|
+
*/
|
|
248
|
+
async function inspectSqliteSnapshot(filePath) {
|
|
249
|
+
const Database = (await import('better-sqlite3')).default;
|
|
250
|
+
let db;
|
|
251
|
+
try {
|
|
252
|
+
db = new Database(filePath, { readonly: true, fileMustExist: true });
|
|
253
|
+
const integrity = db.pragma('integrity_check', { simple: true });
|
|
254
|
+
if (String(integrity).toLowerCase() !== 'ok') {
|
|
255
|
+
return { ok: false, error: `快照完整性校验失败: ${integrity}`, migrations: [] };
|
|
256
|
+
}
|
|
257
|
+
let migrations = [];
|
|
258
|
+
try {
|
|
259
|
+
migrations = db
|
|
260
|
+
.prepare('SELECT name FROM _migrations ORDER BY name')
|
|
261
|
+
.all()
|
|
262
|
+
.map((row) => String(row.name));
|
|
263
|
+
} catch {
|
|
264
|
+
// 极早期/非平台数据库没有 _migrations —— 交给迁移兼容性检查给出明确提示。
|
|
265
|
+
migrations = [];
|
|
266
|
+
}
|
|
267
|
+
return { ok: true, migrations };
|
|
268
|
+
} catch (err) {
|
|
269
|
+
return { ok: false, error: `无法打开快照(不是有效的 SQLite 数据库?): ${err.message}`, migrations: [] };
|
|
270
|
+
} finally {
|
|
271
|
+
try {
|
|
272
|
+
db?.close();
|
|
273
|
+
} catch {
|
|
274
|
+
/* ignore */
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** 当前代码自带的迁移文件(用于判断快照是否来自「未来版本」)。 */
|
|
280
|
+
function listCurrentMigrationNames(migrationsDir) {
|
|
281
|
+
try {
|
|
282
|
+
// DB 里 `_migrations.name` 存的是**去扩展名**的迁移名(migrator 的约定)。
|
|
283
|
+
return fs
|
|
284
|
+
.readdirSync(migrationsDir)
|
|
285
|
+
.filter((name) => name.endsWith('.sql'))
|
|
286
|
+
.map((name) => name.replace(/\.sql$/, ''))
|
|
287
|
+
.sort();
|
|
288
|
+
} catch {
|
|
289
|
+
return [];
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* 迁移兼容性预检。
|
|
295
|
+
*
|
|
296
|
+
* - 快照里有当前代码不认识的迁移 → 快照来自更新版本,恢复后再启动会触发
|
|
297
|
+
* checksum/结构不匹配。默认拒绝,`--force` 显式放行(管理员可能就是要回滚)。
|
|
298
|
+
* - 当前代码有快照没有的迁移 → 正常升级路径,下次启动会补上。
|
|
299
|
+
*/
|
|
300
|
+
function checkMigrationCompatibility(snapshotMigrations, migrationsDir) {
|
|
301
|
+
const current = new Set(listCurrentMigrationNames(migrationsDir));
|
|
302
|
+
const unknown = snapshotMigrations.filter((name) => current.size > 0 && !current.has(name));
|
|
303
|
+
const pending = [...current].filter((name) => !snapshotMigrations.includes(name));
|
|
304
|
+
return { unknown, pending };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** 用 VACUUM INTO 生成一致性安全副本(直接 copy 会漏掉 WAL 里未 checkpoint 的页)。 */
|
|
308
|
+
async function createSafetySnapshot(dbPath, backupsDir) {
|
|
309
|
+
const Database = (await import('better-sqlite3')).default;
|
|
310
|
+
// 备份目录装着全量学生数据副本:创建即 0700,不依赖 umask
|
|
311
|
+
ensureSecureDir(backupsDir);
|
|
312
|
+
const now = new Date();
|
|
313
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
314
|
+
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
315
|
+
const target = path.join(backupsDir, `pre_restore_${stamp}.db`);
|
|
316
|
+
const db = new Database(dbPath, { readonly: true });
|
|
317
|
+
try {
|
|
318
|
+
db.exec(`VACUUM INTO '${target.replace(/'/g, "''")}'`);
|
|
319
|
+
} finally {
|
|
320
|
+
db.close();
|
|
321
|
+
}
|
|
322
|
+
// VACUUM INTO 生成的文件按默认 umask 落盘(实测 644)→ 收紧到 600
|
|
323
|
+
hardenFile(target);
|
|
324
|
+
return target;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* 安全还原(Issue #150 加固)。
|
|
329
|
+
*
|
|
330
|
+
* 与旧实现的区别:
|
|
331
|
+
* 1. 先校验快照(SQLite 头 + `PRAGMA integrity_check`)再动主库;
|
|
332
|
+
* 2. 迁移兼容性预检:快照来自更新版本时默认拒绝(`force: true` 放行);
|
|
333
|
+
* 3. 安全副本用 `VACUUM INTO` 生成,落在 `BACKUP_DIR`(默认 `<dbDir>/backups`),
|
|
334
|
+
* 失败时可原路恢复;
|
|
335
|
+
* 4. 原子替换:先写临时文件再 `rename`,避免半截库;替换后清理 `-wal`/`-shm`。
|
|
336
|
+
*
|
|
337
|
+
* 停服仍是操作者的责任:SQLite 文件被运行中的进程持有替换会得到旧句柄。
|
|
338
|
+
*
|
|
339
|
+
* @param {string} [sourceFile] `.db` 快照 / 全量 `.zip` 备份
|
|
340
|
+
* @param {Object} [options]
|
|
341
|
+
* @param {string} [options.dbPath]
|
|
342
|
+
* @param {boolean} [options.force] 允许恢复「快照迁移比当前代码更新」的库
|
|
343
|
+
* @param {string} [options.migrationsDir]
|
|
344
|
+
* @param {string} [options.backupsDir]
|
|
345
|
+
* @param {boolean} [options.silent]
|
|
346
|
+
*/
|
|
73
347
|
export async function runRestore(sourceFile, options = {}) {
|
|
74
348
|
const log = options.silent ? () => {} : (msg) => console.log(`[openlearn-next] ${msg}`);
|
|
75
349
|
if (!sourceFile) {
|
|
76
|
-
const msg =
|
|
350
|
+
const msg =
|
|
351
|
+
'未指定待还原的备份文件。用法: npx openlearn-next restore <backup.db|backup.zip|--snapshot pre_migration_xxx.db>';
|
|
77
352
|
log(`✗ ${msg}`);
|
|
78
353
|
return { ok: false, error: msg };
|
|
79
354
|
}
|
|
@@ -85,15 +360,23 @@ export async function runRestore(sourceFile, options = {}) {
|
|
|
85
360
|
return { ok: false, error: msg };
|
|
86
361
|
}
|
|
87
362
|
|
|
88
|
-
|
|
363
|
+
const dbPath = resolveDbPath(options.dbPath);
|
|
364
|
+
const dbDir = path.dirname(dbPath);
|
|
365
|
+
const migrationsDir = options.migrationsDir || path.resolve(process.cwd(), 'migrations');
|
|
366
|
+
const backupsDir = options.backupsDir || process.env.BACKUP_DIR || path.join(dbDir, 'backups');
|
|
367
|
+
fs.mkdirSync(dbDir, { recursive: true });
|
|
368
|
+
|
|
369
|
+
// 识别文件类型:校验头字节(PK 为 Zip 压缩包,SQLite 为数据库镜像)
|
|
370
|
+
let isZip = false;
|
|
89
371
|
try {
|
|
90
372
|
const fd = fs.openSync(src, 'r');
|
|
91
373
|
const buffer = Buffer.alloc(16);
|
|
92
374
|
fs.readSync(fd, buffer, 0, 16, 0);
|
|
93
375
|
fs.closeSync(fd);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
376
|
+
if (buffer[0] === 0x50 && buffer[1] === 0x4b) {
|
|
377
|
+
isZip = true;
|
|
378
|
+
} else if (!buffer.toString('utf-8').startsWith('SQLite format 3')) {
|
|
379
|
+
const msg = `文件既非 SQLite 数据库镜像亦非合法的 OpenLearn 备份 ZIP: ${src}`;
|
|
97
380
|
log(`✗ ${msg}`);
|
|
98
381
|
return { ok: false, error: msg };
|
|
99
382
|
}
|
|
@@ -102,30 +385,99 @@ export async function runRestore(sourceFile, options = {}) {
|
|
|
102
385
|
return { ok: false, error: err.message };
|
|
103
386
|
}
|
|
104
387
|
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
388
|
+
// ── 1. 把待恢复的数据库落到临时文件并校验 ───────────────────────────────
|
|
389
|
+
const stagePath = path.join(dbDir, `.restore_stage_${process.pid}_${Date.now()}.db`);
|
|
390
|
+
let extraction = null;
|
|
391
|
+
try {
|
|
392
|
+
if (isZip) {
|
|
393
|
+
const JSZip = (await import('jszip')).default;
|
|
394
|
+
const zip = await JSZip.loadAsync(fs.readFileSync(src));
|
|
395
|
+
const dbEntry = zip.file('data.db');
|
|
396
|
+
if (!dbEntry) throw new Error('ZIP 归档中未找到 data.db 数据库主体');
|
|
397
|
+
fs.writeFileSync(stagePath, await dbEntry.async('nodebuffer'));
|
|
398
|
+
extraction = zip;
|
|
399
|
+
} else {
|
|
400
|
+
fs.copyFileSync(src, stagePath);
|
|
401
|
+
}
|
|
402
|
+
// 暂存文件随后会被 rename 成主库 —— 权限按默认 umask 落盘(实测 644),
|
|
403
|
+
// 这里先收紧到 600,避免主库以宽权限重生。
|
|
404
|
+
hardenFile(stagePath);
|
|
108
405
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
406
|
+
const inspection = await inspectSqliteSnapshot(stagePath);
|
|
407
|
+
if (!inspection.ok) {
|
|
408
|
+
const msg = inspection.error || '快照校验失败';
|
|
409
|
+
log(`✗ ${msg}`);
|
|
410
|
+
return { ok: false, error: msg };
|
|
411
|
+
}
|
|
115
412
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
413
|
+
// ── 2. 迁移兼容性预检 ────────────────────────────────────────────────
|
|
414
|
+
const { unknown, pending } = checkMigrationCompatibility(inspection.migrations, migrationsDir);
|
|
415
|
+
if (unknown.length > 0) {
|
|
416
|
+
const msg =
|
|
417
|
+
`快照包含当前代码不认识的迁移(来自更新版本?):${unknown.join(', ')}。` +
|
|
418
|
+
'恢复后再启动会触发结构与 checksum 不匹配;确认要回滚请加 --force。';
|
|
419
|
+
if (!options.force) {
|
|
420
|
+
log(`✗ ${msg}`);
|
|
421
|
+
return { ok: false, error: msg, unknownMigrations: unknown };
|
|
422
|
+
}
|
|
423
|
+
log(`⚠ ${msg}(--force 已放行)`);
|
|
424
|
+
}
|
|
425
|
+
if (pending.length > 0) {
|
|
426
|
+
log(`ℹ 快照缺少 ${pending.length} 条当前迁移,恢复后首次启动会自动补齐`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ── 3. 安全副本 ─────────────────────────────────────────────────────
|
|
430
|
+
let safetyBackup;
|
|
431
|
+
if (fs.existsSync(dbPath)) {
|
|
432
|
+
safetyBackup = await createSafetySnapshot(dbPath, backupsDir);
|
|
433
|
+
log(`ℹ 已创建恢复前安全副本: ${safetyBackup}`);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ── 4. 原子替换 + 清理 WAL/SHM ──────────────────────────────────────
|
|
437
|
+
fs.renameSync(stagePath, dbPath);
|
|
438
|
+
// rename 会继承源文件的权限位;再收紧一次,确保主库始终 ≤600
|
|
439
|
+
hardenFile(dbPath);
|
|
440
|
+
for (const suffix of ['-wal', '-shm']) {
|
|
441
|
+
const sidecar = `${dbPath}${suffix}`;
|
|
442
|
+
if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// zip 备份里的 uploads/ 与 storage/ 一并还原(沿用旧行为)
|
|
446
|
+
if (extraction) {
|
|
447
|
+
const dataDir = process.env.OPENLEARN_DATA_DIR || dbDir;
|
|
448
|
+
let extractedCount = 0;
|
|
449
|
+
for (const [relPath, file] of Object.entries(extraction.files)) {
|
|
450
|
+
if (file.dir) continue;
|
|
451
|
+
if (relPath.startsWith('uploads/') || relPath.startsWith('storage/')) {
|
|
452
|
+
const dest = path.join(dataDir, relPath);
|
|
453
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
454
|
+
fs.writeFileSync(dest, await file.async('nodebuffer'));
|
|
455
|
+
extractedCount++;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
log(`✓ 全量整包还原成功!数据库与 ${extractedCount} 个资源文件已写入 ${dataDir}`);
|
|
459
|
+
} else {
|
|
460
|
+
log(`✓ 数据库还原成功!已切换为 ${src} 的数据。`);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
ok: true,
|
|
465
|
+
restoredFrom: src,
|
|
466
|
+
safetyBackup,
|
|
467
|
+
migrations: inspection.migrations.length,
|
|
468
|
+
pendingMigrations: pending.length,
|
|
469
|
+
};
|
|
126
470
|
} catch (err) {
|
|
127
|
-
log(`✗
|
|
471
|
+
log(`✗ 还原失败: ${err.message}`);
|
|
128
472
|
return { ok: false, error: err.message };
|
|
473
|
+
} finally {
|
|
474
|
+
if (fs.existsSync(stagePath)) {
|
|
475
|
+
try {
|
|
476
|
+
fs.unlinkSync(stagePath);
|
|
477
|
+
} catch {
|
|
478
|
+
/* ignore */
|
|
479
|
+
}
|
|
480
|
+
}
|
|
129
481
|
}
|
|
130
482
|
}
|
|
131
483
|
|
|
@@ -135,12 +487,23 @@ export async function runRestore(sourceFile, options = {}) {
|
|
|
135
487
|
* @param {Object} [options]
|
|
136
488
|
* @returns {Promise<{ ok: boolean, error?: string }>}
|
|
137
489
|
*/
|
|
138
|
-
|
|
490
|
+
function generateResetPassword() {
|
|
491
|
+
const alphabet = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
492
|
+
const bytes = crypto.randomBytes(20);
|
|
493
|
+
let out = '';
|
|
494
|
+
for (let i = 0; i < bytes.length; i++) out += alphabet[bytes[i] % alphabet.length];
|
|
495
|
+
if (!/[A-Za-z]/.test(out) || !/[0-9]/.test(out)) out = `${out.slice(0, 18)}a7`;
|
|
496
|
+
return out;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export async function runResetAdmin(newPassword, options = {}) {
|
|
139
500
|
const log = options.silent ? () => {} : (msg) => console.log(`[openlearn-next] ${msg}`);
|
|
140
501
|
const dbPath = resolveDbPath(options.dbPath);
|
|
502
|
+
const generated = newPassword === undefined || newPassword === null || String(newPassword).length === 0;
|
|
503
|
+
const password = generated ? generateResetPassword() : String(newPassword);
|
|
141
504
|
|
|
142
505
|
if (!fs.existsSync(dbPath)) {
|
|
143
|
-
const msg = `数据库尚未初始化 (${dbPath})
|
|
506
|
+
const msg = `数据库尚未初始化 (${dbPath})。请先启动一次平台,口令来自环境变量或 storage/bootstrap-credentials.txt。`;
|
|
144
507
|
log(`ℹ ${msg}`);
|
|
145
508
|
return { ok: false, error: msg };
|
|
146
509
|
}
|
|
@@ -149,10 +512,10 @@ export async function runResetAdmin(newPassword = 'admin', options = {}) {
|
|
|
149
512
|
let hash;
|
|
150
513
|
try {
|
|
151
514
|
const bcrypt = (await import('bcryptjs')).default;
|
|
152
|
-
hash = bcrypt.hashSync(
|
|
515
|
+
hash = bcrypt.hashSync(password, 10);
|
|
153
516
|
} catch {
|
|
154
|
-
const
|
|
155
|
-
hash =
|
|
517
|
+
const nodeCrypto = await import('node:crypto');
|
|
518
|
+
hash = nodeCrypto.createHash('sha256').update(password).digest('hex');
|
|
156
519
|
}
|
|
157
520
|
|
|
158
521
|
const Database = (await import('better-sqlite3')).default;
|
|
@@ -166,16 +529,33 @@ export async function runResetAdmin(newPassword = 'admin', options = {}) {
|
|
|
166
529
|
return { ok: false, error: msg };
|
|
167
530
|
}
|
|
168
531
|
|
|
169
|
-
const
|
|
532
|
+
const hasMustChange = db
|
|
533
|
+
.prepare('PRAGMA table_info(users)')
|
|
534
|
+
.all()
|
|
535
|
+
.some((col) => col.name === 'must_change_password');
|
|
536
|
+
const mustChange = password === 'admin' || generated ? 1 : 0;
|
|
537
|
+
const updateSql = hasMustChange
|
|
538
|
+
? 'UPDATE users SET password_hash = ?, must_change_password = ? WHERE username = ?'
|
|
539
|
+
: 'UPDATE users SET password_hash = ? WHERE username = ?';
|
|
540
|
+
const updateArgs = hasMustChange ? [hash, mustChange, 'admin'] : [hash, 'admin'];
|
|
541
|
+
const updateRes = db.prepare(updateSql).run(...updateArgs);
|
|
170
542
|
|
|
171
543
|
if (updateRes.changes === 0) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
544
|
+
if (hasMustChange) {
|
|
545
|
+
db.prepare(
|
|
546
|
+
'INSERT INTO users (id, username, password_hash, role, name, created_at, must_change_password) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
|
547
|
+
).run(`usr_admin_${Date.now()}`, 'admin', hash, 'administrator', 'System Admin', Date.now(), mustChange);
|
|
548
|
+
} else {
|
|
549
|
+
db.prepare(
|
|
550
|
+
'INSERT INTO users (id, username, password_hash, role, name, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
|
551
|
+
).run(`usr_admin_${Date.now()}`, 'admin', hash, 'administrator', 'System Admin', Date.now());
|
|
552
|
+
}
|
|
553
|
+
log(`✓ 管理员账号不存在,已新建管理员账号 admin。口令:${password}`);
|
|
177
554
|
} else {
|
|
178
|
-
log(`✓ 管理员 (admin)
|
|
555
|
+
log(`✓ 管理员 (admin) 口令已重置。口令:${password}`);
|
|
556
|
+
}
|
|
557
|
+
if (mustChange) {
|
|
558
|
+
log(' 该口令登录后必须立即修改。不要把口令设成用户名 admin。');
|
|
179
559
|
}
|
|
180
560
|
|
|
181
561
|
db.close();
|
|
@@ -263,3 +643,118 @@ export async function runPluginsList(options = {}) {
|
|
|
263
643
|
return { ok: false, error: err.message };
|
|
264
644
|
}
|
|
265
645
|
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* 查看数据库迁移状态清单 (已应用 / 待应用)
|
|
649
|
+
* @param {Object} [options]
|
|
650
|
+
* @returns {Promise<{ ok: boolean, applied?: Array<any>, pending?: Array<any>, error?: string }>}
|
|
651
|
+
*/
|
|
652
|
+
export async function runMigrateStatus(options = {}) {
|
|
653
|
+
const log = options.silent ? () => {} : (msg) => console.log(`[openlearn-next] ${msg}`);
|
|
654
|
+
const dbPath = resolveDbPath(options.dbPath);
|
|
655
|
+
|
|
656
|
+
if (!fs.existsSync(dbPath)) {
|
|
657
|
+
log(`✗ 数据库文件未找到: ${dbPath}`);
|
|
658
|
+
return { ok: false, error: 'Database file not found' };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
try {
|
|
662
|
+
const Database = (await import('better-sqlite3')).default;
|
|
663
|
+
const db = new Database(dbPath, { readonly: true });
|
|
664
|
+
|
|
665
|
+
const tableCheck = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='_migrations'").get();
|
|
666
|
+
if (!tableCheck) {
|
|
667
|
+
log(`ℹ 数据库尚未执行过版本化迁移 (_migrations 表未建立)。`);
|
|
668
|
+
db.close();
|
|
669
|
+
return { ok: true, applied: [], pending: [] };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const appliedRows = db.prepare('SELECT name, applied_at, checksum FROM _migrations ORDER BY applied_at ASC').all();
|
|
673
|
+
db.close();
|
|
674
|
+
|
|
675
|
+
const { loadMigrationsFromDirectory, getDefaultMigrationsDir } = loadCliMigrator();
|
|
676
|
+
const allMigrations = loadMigrationsFromDirectory(getDefaultMigrationsDir());
|
|
677
|
+
const appliedSet = new Set(appliedRows.map((r) => r.name));
|
|
678
|
+
const pending = allMigrations.filter((m) => !appliedSet.has(m.name));
|
|
679
|
+
|
|
680
|
+
if (!options.silent) {
|
|
681
|
+
const bold = '\x1b[1m';
|
|
682
|
+
const green = '\x1b[32m';
|
|
683
|
+
const yellow = '\x1b[33m';
|
|
684
|
+
const cyan = '\x1b[36m';
|
|
685
|
+
const reset = '\x1b[0m';
|
|
686
|
+
|
|
687
|
+
console.log(`\n${bold}${cyan}数据库迁移版本化状态 (总计 ${allMigrations.length} 个迁移脚本):${reset}`);
|
|
688
|
+
console.log(` ${green}✓ 已应用 (${appliedRows.length} 个):${reset}`);
|
|
689
|
+
for (const row of appliedRows) {
|
|
690
|
+
const time = new Date(row.applied_at).toLocaleString();
|
|
691
|
+
console.log(` ${green}●${reset} ${row.name.padEnd(35)} (应用时间: ${time})`);
|
|
692
|
+
}
|
|
693
|
+
if (pending.length > 0) {
|
|
694
|
+
console.log(`\n ${yellow}○ 待应用 (${pending.length} 个):${reset}`);
|
|
695
|
+
for (const m of pending) {
|
|
696
|
+
console.log(` ${yellow}○${reset} ${m.name}`);
|
|
697
|
+
}
|
|
698
|
+
} else {
|
|
699
|
+
console.log(`\n ${green}✓ 架构版本全部对齐,无待执行的增量迁移。${reset}\n`);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
return { ok: true, applied: appliedRows, pending };
|
|
704
|
+
} catch (err) {
|
|
705
|
+
log(`✗ 查询迁移状态失败: ${err.message}`);
|
|
706
|
+
return { ok: false, error: err.message };
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* 安全回滚最近一次应用的单项迁移 (按 -- DOWN 逆操作)
|
|
712
|
+
* @param {Object} [options]
|
|
713
|
+
* @returns {Promise<{ ok: boolean, rolledBack?: string, error?: string }>}
|
|
714
|
+
*/
|
|
715
|
+
export async function runMigrateRollback(options = {}) {
|
|
716
|
+
const log = options.silent ? () => {} : (msg) => console.log(`[openlearn-next] ${msg}`);
|
|
717
|
+
const dbPath = resolveDbPath(options.dbPath);
|
|
718
|
+
|
|
719
|
+
if (!fs.existsSync(dbPath)) {
|
|
720
|
+
log(`✗ 数据库文件未找到: ${dbPath}`);
|
|
721
|
+
return { ok: false, error: 'Database file not found' };
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
try {
|
|
725
|
+
const Database = (await import('better-sqlite3')).default;
|
|
726
|
+
const db = new Database(dbPath);
|
|
727
|
+
|
|
728
|
+
const tableCheck = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='_migrations'").get();
|
|
729
|
+
if (!tableCheck) {
|
|
730
|
+
log(`✗ 数据库中未找到 _migrations 元表,无法执行回滚。`);
|
|
731
|
+
db.close();
|
|
732
|
+
return { ok: false, error: 'No _migrations table' };
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const lastRow = db.prepare('SELECT name FROM _migrations ORDER BY applied_at DESC LIMIT 1').get();
|
|
736
|
+
if (!lastRow) {
|
|
737
|
+
log(`ℹ 数据库中当前暂无已应用的迁移记录可供回滚。`);
|
|
738
|
+
db.close();
|
|
739
|
+
return { ok: true };
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const { loadMigrationsFromDirectory, getDefaultMigrationsDir, rollbackMigration } = loadCliMigrator();
|
|
743
|
+
const allMigrations = loadMigrationsFromDirectory(getDefaultMigrationsDir());
|
|
744
|
+
const targetMigration = allMigrations.find((m) => m.name === lastRow.name);
|
|
745
|
+
|
|
746
|
+
if (!targetMigration) {
|
|
747
|
+
log(`✗ 未在本地 migrations 目录中找到 "${lastRow.name}.sql",无法获取 DOWN 段执行回滚。`);
|
|
748
|
+
db.close();
|
|
749
|
+
return { ok: false, error: `Migration file ${lastRow.name} not found` };
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
rollbackMigration(db, targetMigration);
|
|
753
|
+
db.close();
|
|
754
|
+
log(`✓ 成功回滚迁移: ${targetMigration.name} (已执行 DOWN 逆操作并清理 _migrations 记录)`);
|
|
755
|
+
return { ok: true, rolledBack: targetMigration.name };
|
|
756
|
+
} catch (err) {
|
|
757
|
+
log(`✗ 回滚迁移失败: ${err.message}`);
|
|
758
|
+
return { ok: false, error: err.message };
|
|
759
|
+
}
|
|
760
|
+
}
|