openlearn-next 0.6.0 → 0.7.3

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.
Files changed (147) hide show
  1. package/README.md +5 -1
  2. package/cli-data.mjs +535 -40
  3. package/cli-doctor.mjs +456 -17
  4. package/cli.mjs +214 -12
  5. package/dist/assets/AcademicYearFilter-DUUGpDKt.js +1 -0
  6. package/dist/assets/ActivityWorkspaceWidget-5PzyxF6a.js +1 -0
  7. package/dist/assets/AdminDirectoryView-BCtmQTEK.js +5 -0
  8. package/dist/assets/AssessmentCenter-B43ojHG-.js +1 -0
  9. package/dist/assets/BatchPickerModal-CbmjInp7.js +1 -0
  10. package/dist/assets/ClassesView-DEnMaDOt.js +32 -0
  11. package/dist/assets/ClassroomCountdownWidget-CHq4983w.js +2 -0
  12. package/dist/assets/CloudDriveModal-D2t3Uyzr.js +1 -0
  13. package/dist/assets/ComputerLabView-YtnoT5Rb.js +1 -0
  14. package/dist/assets/CourseManagement-DOXyJ_j-.js +1 -0
  15. package/dist/assets/CourseWizardModal-Dbi6Caw2.js +3 -0
  16. package/dist/assets/CoursewareHubPanel-CyxDIJAF.js +1 -0
  17. package/dist/assets/Dashboard-2rLu_ZBy.js +8 -0
  18. package/dist/assets/ExportWeightModal-BrmAh6VX.js +1 -0
  19. package/dist/assets/GLTFLoader-CZJbo_y1.js +1 -0
  20. package/dist/assets/{HelpTour-B-RKDb1-.js → HelpTour-D_q1z_MI.js} +1 -1
  21. package/dist/assets/HelpView-CVBrKDrd.js +204 -0
  22. package/dist/assets/ImportLessonsModal-PhHtX31R.js +1 -0
  23. package/dist/assets/ImportModal-DqjqNeH4.js +2 -0
  24. package/dist/assets/InteractiveCoursewareViewer-Vi6lLz7a.js +1 -0
  25. package/dist/assets/InteractiveWhiteboard-lTzeayX5.js +65 -0
  26. package/dist/assets/LazyCourseware-KIt0rGtW.js +2 -0
  27. package/dist/assets/LazyWhiteboard-CCN3pYcW.js +2 -0
  28. package/dist/assets/LessonEditorView-D_GVhHuM.js +1 -0
  29. package/dist/assets/LiveClassroomView-BRsZ6QEq.js +61 -0
  30. package/dist/assets/NotificationDetailModal-tY5rxYCN.js +1 -0
  31. package/dist/assets/OrbitControls-CQjN_uwS.js +1 -0
  32. package/dist/assets/PluginView-CedpAW6d.js +77 -0
  33. package/dist/assets/{ProcessLogsModal-DfKPVIYe.js → ProcessLogsModal-Cfr-T9Gd.js} +1 -1
  34. package/dist/assets/QuizGeneratorModal-bUA002kP.js +1 -0
  35. package/dist/assets/StudentAssignmentView-BhAz6Hfk.js +1 -0
  36. package/dist/assets/StudentLessonView-DC1yX4kG.js +1 -0
  37. package/dist/assets/StudentView-PHKxIV9T.js +2 -0
  38. package/dist/assets/SystemResourceLibraryModal-DgXYe6_x.js +1 -0
  39. package/dist/assets/TeacherView-DscJtOJ9.js +2 -0
  40. package/dist/assets/TimetableView-D2ozoj_D.js +6 -0
  41. package/dist/assets/UTIF-FfCsrHhe.js +1 -0
  42. package/dist/assets/__vite-optional-peer-dep_ai_ooxml-ui-BIHI7g3E.js +1 -0
  43. package/dist/assets/browser-DEI0QxZC.js +1 -0
  44. package/dist/assets/html2canvas-pro.esm-Bo50djkB.js +12 -0
  45. package/dist/assets/index-2676X8YB.js +131 -0
  46. package/dist/assets/index-C6hlh8pw.css +1 -0
  47. package/dist/assets/{index.es-DvJa33KC.js → index.es-DyEF0CZ9.js} +1 -1
  48. package/dist/assets/pluginParsers-e4PSm2Iy.js +1 -0
  49. package/dist/assets/react_pptx-react-viewer-BIHI7g3E.js +1 -0
  50. package/dist/assets/teaching-modes-client-BeVaDdJ-.js +1 -0
  51. package/dist/assets/three.module-iOYB3rD4.js +4228 -0
  52. package/dist/assets/timeline-duration-BgdJCSrW.js +1 -0
  53. package/dist/assets/{vendor-charts-DGsG2RUv.js → vendor-charts-SrpBsvM3.js} +2 -2
  54. package/dist/assets/{vendor-icons-CyfamhaC.js → vendor-icons-CXrZ3R2V.js} +2 -2
  55. package/dist/assets/{vendor-konva-DBWwlLTb.js → vendor-konva-Dne0Z-WC.js} +1 -1
  56. package/dist/assets/{vendor-pdf-D0UGJOgo.js → vendor-pdf-C1Vh2Aqq.js} +56 -56
  57. package/dist/assets/vendor-pptx-Bzn6whfK.js +11311 -0
  58. package/dist/assets/vendor-pptx-DIWdXF1L.css +1 -0
  59. package/dist/assets/{vendor-react-CZ1I7IcM.js → vendor-react-Kv_JATwr.js} +1 -1
  60. package/dist/assets/vendor-utils-aM7aYvPP.js +1 -0
  61. package/dist/cli-migrator.cjs +310 -0
  62. package/dist/env-validation.cjs +351 -0
  63. package/dist/index.html +5 -5
  64. package/dist/plugin-net-preload.cjs +263 -0
  65. package/dist/server.cjs +20271 -4337
  66. package/migrations/000_initial_schema.sql +410 -0
  67. package/migrations/001_add_execution_mode.sql +6 -0
  68. package/migrations/002_add_client_session_expiry.sql +6 -0
  69. package/migrations/003_classroom_runtime.sql +33 -0
  70. package/migrations/004_courseware_score_config.sql +26 -0
  71. package/migrations/005_assignment_hub.sql +155 -0
  72. package/migrations/006_classroom_event_bus.sql +28 -0
  73. package/migrations/007_interactive_classroom.sql +119 -0
  74. package/migrations/008_teaching_modes.sql +36 -0
  75. package/migrations/009_classroom_peer_review.sql +159 -0
  76. package/migrations/010_class_groups.sql +33 -0
  77. package/migrations/011_classroom_feed.sql +34 -0
  78. package/migrations/012_auto_record_score.sql +48 -0
  79. package/migrations/013_score_source_strategy.sql +81 -0
  80. package/migrations/014_performance_indexes.sql +21 -0
  81. package/migrations/015_demo_data_registry.sql +26 -0
  82. package/migrations/016_preset_polls_and_passcode_expiry.sql +33 -0
  83. package/migrations/017_pacing_signals_segment.sql +6 -0
  84. package/migrations/018_processes_plugin_owner.sql +26 -0
  85. package/migrations/019_plugins_version.sql +61 -0
  86. package/migrations/020_upload_max_file_mb.sql +14 -0
  87. package/migrations/021_class_owner_and_password_gate.sql +11 -0
  88. package/migrations/022_plugin_public_routes.sql +14 -0
  89. package/migrations/023_encrypt_class_passcodes.sql +18 -0
  90. package/migrations/024_segment_gates.sql +37 -0
  91. package/migrations/025_assignment_lookup_indexes.sql +11 -0
  92. package/migrations/026_plugin_grants.sql +21 -0
  93. package/migrations/027_academic_year.sql +26 -0
  94. package/migrations/028_classroom_indexes.sql +14 -0
  95. package/migrations/029_clean_lesson_think_artifacts.sql +13 -0
  96. package/migrations/030_backfill_academic_year.sql +30 -0
  97. package/migrations/031_assessment_core.sql +158 -0
  98. package/migrations/032_progress_verification.sql +17 -0
  99. package/migrations/033_student_lock_mode.sql +9 -0
  100. package/migrations/034_plugin_orphan_data.sql +27 -0
  101. package/migrations/035_icebreaker_checkins.sql +26 -0
  102. package/migrations/036_student_read_notifications_created_at.sql +19 -0
  103. package/migrations/037_plugin_config_scopes.sql +40 -0
  104. package/migrations/038_assessment_ai_grading_suggestion.sql +10 -0
  105. package/migrations/039_question_bank_sharing_and_papers.sql +14 -0
  106. package/migrations/040_assignment_grade_history.sql +37 -0
  107. package/migrations/041_course_package_idempotency.sql +33 -0
  108. package/migrations/042_assignment_grade_resubmit_audit.sql +12 -0
  109. package/migrations/043_mark_sha256_passwords.sql +31 -0
  110. package/migrations/README.md +61 -0
  111. package/migrations/schema-golden.json +640 -0
  112. package/package.json +55 -36
  113. package/dist/assets/ActivityWorkspaceWidget-_6XMBAcc.js +0 -1
  114. package/dist/assets/AdminDirectoryView-BufUF-sk.js +0 -5
  115. package/dist/assets/BatchPickerModal-CES2DlPx.js +0 -1
  116. package/dist/assets/ClassesView-BLvRJKcc.js +0 -24
  117. package/dist/assets/ClassroomCountdownWidget-DfKotHP8.js +0 -2
  118. package/dist/assets/CloudDriveModal-CLe4I18S.js +0 -1
  119. package/dist/assets/ComputerLabView-CPnWWguS.js +0 -1
  120. package/dist/assets/CourseManagement-BSR61ggI.js +0 -1
  121. package/dist/assets/CourseWizardModal-D2m3PG7k.js +0 -3
  122. package/dist/assets/CoursewareHubPanel-gqjte-Pv.js +0 -1
  123. package/dist/assets/Dashboard-BPBJd0QR.js +0 -8
  124. package/dist/assets/ExportWeightModal-C3gIzDaP.js +0 -1
  125. package/dist/assets/HelpView-Bk64VK-J.js +0 -204
  126. package/dist/assets/ImportLessonsModal-DUGpcZV3.js +0 -1
  127. package/dist/assets/ImportModal-ChY_Y5DZ.js +0 -1
  128. package/dist/assets/InteractiveCoursewareViewer-B9wvha7w.js +0 -1
  129. package/dist/assets/InteractiveWhiteboard-FPrze5CJ.js +0 -78
  130. package/dist/assets/LazyCourseware-BV5nOs5y.js +0 -2
  131. package/dist/assets/LazyWhiteboard-Cy5DdYnO.js +0 -2
  132. package/dist/assets/LessonEditorView-3SyplRhr.js +0 -1
  133. package/dist/assets/LiveClassroomView-CX7SUnmx.js +0 -61
  134. package/dist/assets/NotificationDetailModal-BBATA8Lh.js +0 -1
  135. package/dist/assets/PluginView-D5jMNscn.js +0 -66
  136. package/dist/assets/QuizGeneratorModal-CbRirQId.js +0 -1
  137. package/dist/assets/StudentAssignmentView-ucXAOac9.js +0 -1
  138. package/dist/assets/StudentLessonView-CW6FYMtd.js +0 -13
  139. package/dist/assets/StudentView-Bxrut8tS.js +0 -2
  140. package/dist/assets/SystemResourceLibraryModal-CXdrWHDL.js +0 -1
  141. package/dist/assets/TeacherView-DXg0boJH.js +0 -2
  142. package/dist/assets/TimetableView-DPABkaFX.js +0 -6
  143. package/dist/assets/index-B3W5zons.css +0 -1
  144. package/dist/assets/index-B6wtREUD.js +0 -221
  145. package/dist/assets/purify.es-CYR4BTuT.js +0 -3
  146. package/dist/assets/vendor-pptx-D4btWy5L.js +0 -39
  147. 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
- 默认账号:`admin` / `admin`(管理员)、`teacher` / `teacher`(教师)。
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(), `openlearn_backup_${timestamp}.db`);
129
+ : path.resolve(process.cwd(), `openlearn_${isFull ? 'full_backup' : 'backup'}_${timestamp}${defaultExt}`);
130
+
131
+ ensureSecureDir(path.dirname(target));
38
132
 
39
- fs.mkdirSync(path.dirname(target), { recursive: true });
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 = '未指定待还原的备份文件。用法: npx openlearn-next restore <backup.db>';
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
- // 校验 SQLite 文件头 (Magic Header: "SQLite format 3\0")
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
- const headerStr = buffer.toString('utf-8');
95
- if (!headerStr.startsWith('SQLite format 3')) {
96
- const msg = `文件并非有效的 SQLite 数据库备份: ${src}`;
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
- const dbPath = resolveDbPath(options.dbPath);
106
- const dbDir = path.dirname(dbPath);
107
- fs.mkdirSync(dbDir, { recursive: true });
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
- if (fs.existsSync(dbPath)) {
111
- const bakFile = `${dbPath}.bak_${Date.now()}`;
112
- fs.copyFileSync(dbPath, bakFile);
113
- log(`ℹ 已为现有数据库创建安全回滚镜像: ${bakFile}`);
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
- try {
117
- fs.copyFileSync(src, dbPath);
118
- // 清理旧的 WAL 与 SHM
119
- const wal = `${dbPath}-wal`;
120
- const shm = `${dbPath}-shm`;
121
- if (fs.existsSync(wal)) fs.unlinkSync(wal);
122
- if (fs.existsSync(shm)) fs.unlinkSync(shm);
123
-
124
- log(`✓ 数据库还原成功!当前主库已切换为 ${src} 的数据。`);
125
- return { ok: true };
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(`✗ 覆盖写入数据库失败: ${err.message}`);
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
- export async function runResetAdmin(newPassword = 'admin', options = {}) {
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})。请直接启动一次平台即可生成默认 admin/admin 账号。`;
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(newPassword, 10);
515
+ hash = bcrypt.hashSync(password, 10);
153
516
  } catch {
154
- const crypto = await import('node:crypto');
155
- hash = crypto.createHash('sha256').update(newPassword).digest('hex');
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 updateRes = db.prepare('UPDATE users SET password_hash = ? WHERE username = ?').run(hash, 'admin');
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
- // 若数据库中无 admin 用户,则插入
173
- db.prepare(
174
- 'INSERT INTO users (id, username, password_hash, role, name, created_at) VALUES (?, ?, ?, ?, ?, ?)',
175
- ).run(`usr_admin_${Date.now()}`, 'admin', hash, 'administrator', 'System Admin', Date.now());
176
- log(`✓ 管理员账号不存在,已新建管理员账号: admin (密码: ${newPassword})`);
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) 密码已成功重置为: ${newPassword}`);
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
+ }