openlearn-next 0.3.20 → 0.3.22
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 +161 -38
- package/dist/assets/{AdminDirectoryView-CGo33RNh.js → AdminDirectoryView-D-AOtp1o.js} +1 -1
- package/dist/assets/{BatchPickerModal-CasfiwEQ.js → BatchPickerModal-C9FH3J57.js} +1 -1
- package/dist/assets/{ClassesView-DIyJwydJ.js → ClassesView-DTuyCvYd.js} +1 -1
- package/dist/assets/{CloudDriveModal-C6fpWLLE.js → CloudDriveModal-Bi-aMuax.js} +1 -1
- package/dist/assets/{ComputerLabView-DGha_J8B.js → ComputerLabView-DWMtMSHV.js} +1 -1
- package/dist/assets/{CourseManagement-2o8YUTDb.js → CourseManagement-CeelAyst.js} +1 -1
- package/dist/assets/{CourseWizardModal-Clo7hCCp.js → CourseWizardModal-m7TDgcB2.js} +1 -1
- package/dist/assets/{CoursewareHubPanel-CEK_QEc5.js → CoursewareHubPanel-C4LQjt4v.js} +1 -1
- package/dist/assets/{Dashboard-CBIiLzje.js → Dashboard-CiBtG7Ge.js} +2 -2
- package/dist/assets/{ExportWeightModal-DkDuPsiM.js → ExportWeightModal-C4d-0uTM.js} +1 -1
- package/dist/assets/{HelpTour-Dlc1vvy3.js → HelpTour-00eT17Vg.js} +1 -1
- package/dist/assets/{HelpView-g1BzpeC9.js → HelpView-B9Pny6u9.js} +4 -4
- package/dist/assets/{ImportLessonsModal-DMyN1EmC.js → ImportLessonsModal-hKRuw9p4.js} +1 -1
- package/dist/assets/{ImportModal-B2acbUT8.js → ImportModal-CVXHZ1Ei.js} +1 -1
- package/dist/assets/{InteractiveCoursewareViewer-DEicS9GQ.js → InteractiveCoursewareViewer-DMMAs2k_.js} +1 -1
- package/dist/assets/InteractiveWhiteboard-jpweQ094.js +77 -0
- package/dist/assets/{LazyCourseware-Bjy3qJWw.js → LazyCourseware-DVgyrg3r.js} +2 -2
- package/dist/assets/{LazyWhiteboard-D4Bj3dzG.js → LazyWhiteboard-BSzsAPXC.js} +2 -2
- package/dist/assets/{LessonEditorView-DpU2g0Uq.js → LessonEditorView-D4g48fr5.js} +1 -1
- package/dist/assets/LiveClassroomView-HQGhVwd_.js +1 -0
- package/dist/assets/{NotificationDetailModal-DC3AYUtD.js → NotificationDetailModal-1MOkBDr_.js} +1 -1
- package/dist/assets/{PluginView-S6TW3dLz.js → PluginView-D8cQ8mLM.js} +3 -3
- package/dist/assets/{ProcessLogsModal-MtK-Pe4R.js → ProcessLogsModal-gZ9LILYM.js} +1 -1
- package/dist/assets/{QuizGeneratorModal-Bh6XOC83.js → QuizGeneratorModal-BFCJe1cm.js} +1 -1
- package/dist/assets/{StudentAssignmentView-BCwbft0k.js → StudentAssignmentView-C34nleZT.js} +1 -1
- package/dist/assets/{StudentLessonView-CewqFUl5.js → StudentLessonView-DNbu2o04.js} +1 -1
- package/dist/assets/{StudentPreviewModal-Blww1Gv5.js → StudentPreviewModal-BXbKzQgo.js} +1 -1
- package/dist/assets/{StudentView-CR-84lLY.js → StudentView-BF2WjF4_.js} +2 -2
- package/dist/assets/{SystemResourceLibraryModal-Dyv2YJ7c.js → SystemResourceLibraryModal-Ce-Dis6M.js} +1 -1
- package/dist/assets/TeacherView-BySyCiZG.js +2 -0
- package/dist/assets/{TimetableView-Do_LWpV2.js → TimetableView-0VaTfpz3.js} +4 -4
- package/dist/assets/index-C7jMgry5.js +216 -0
- package/dist/assets/index-huP36ZNf.css +1 -0
- package/dist/assets/{vendor-icons-B2DRJfZU.js → vendor-icons-30iiuCQb.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/server.cjs +2730 -357
- package/package.json +21 -22
- package/dist/assets/InteractiveWhiteboard-DbpOL5pI.js +0 -78
- package/dist/assets/LiveClassroomView-Bqrth_T2.js +0 -1
- package/dist/assets/TeacherView-Cszhv8bO.js +0 -2
- package/dist/assets/index-Ch_mD6B0.css +0 -1
- package/dist/assets/index-CnuEzAfQ.js +0 -202
package/dist/server.cjs
CHANGED
|
@@ -343,7 +343,7 @@ var init_token = __esm({
|
|
|
343
343
|
});
|
|
344
344
|
|
|
345
345
|
// packages/core/di/interfaces.ts
|
|
346
|
-
var ICommandBusServiceToken4, IEventBusServiceToken3, IActionRegistryServiceToken4, ICapabilityServiceToken, IProcessServiceToken2, IStorageServiceToken, IAIServiceToken, IDatabaseToken4, IPluginHostToken, IPluginLifecycleManagerToken, IPluginDistributionManagerToken, IPluginRuntimeCompositionToken, IUnifiedExtensionRegistryToken, IPluginCapabilityGatewayToken, ICapabilityRegistryToken, IAuthSessionBridgeToken, ISemesterGradeServiceToken, IPointsDimensionRegistryToken, IPointsLedgerServiceToken, ILessonEngineServiceToken, IClassroomRuntimeServiceToken, IPresenceEngineServiceToken, ITeachingCollaborationServiceToken, ILearningAnalyticsServiceToken, IAICapabilityServiceToken, ICapabilityRuntimeServiceToken, ICapabilityGovernanceServiceToken, IPlatformServiceRegistryToken;
|
|
346
|
+
var ICommandBusServiceToken4, IEventBusServiceToken3, IActionRegistryServiceToken4, ICapabilityServiceToken, IProcessServiceToken2, IStorageServiceToken, IAIServiceToken, IDatabaseToken4, IPluginHostToken, IPluginLifecycleManagerToken, IPluginDistributionManagerToken, IPluginRuntimeCompositionToken, IUnifiedExtensionRegistryToken, IPluginCapabilityGatewayToken, ICapabilityRegistryToken, IAuthSessionBridgeToken, ISemesterGradeServiceToken, IPointsDimensionRegistryToken, IPointsLedgerServiceToken, ILessonEngineServiceToken, IClassroomRuntimeServiceToken, IPresenceEngineServiceToken, ITeachingCollaborationServiceToken, ILearningAnalyticsServiceToken, IAICapabilityServiceToken, ICapabilityRuntimeServiceToken, ICapabilityGovernanceServiceToken, IPlatformServiceRegistryToken, ICoursewareRuntimeScriptRegistryToken;
|
|
347
347
|
var init_interfaces = __esm({
|
|
348
348
|
"packages/core/di/interfaces.ts"() {
|
|
349
349
|
init_token();
|
|
@@ -402,6 +402,9 @@ var init_interfaces = __esm({
|
|
|
402
402
|
IPlatformServiceRegistryToken = new Token(
|
|
403
403
|
"@openlearn/core:IPlatformServiceRegistryService"
|
|
404
404
|
);
|
|
405
|
+
ICoursewareRuntimeScriptRegistryToken = new Token(
|
|
406
|
+
"@openlearn/core:ICoursewareRuntimeScriptRegistry"
|
|
407
|
+
);
|
|
405
408
|
}
|
|
406
409
|
});
|
|
407
410
|
|
|
@@ -682,6 +685,93 @@ var init_ai_service = __esm({
|
|
|
682
685
|
}
|
|
683
686
|
});
|
|
684
687
|
|
|
688
|
+
// packages/core/di/courseware-runtime-script-registry.ts
|
|
689
|
+
var DEFAULT_PRIORITY, DEFAULT_POSITION, CoursewareRuntimeScriptRegistry;
|
|
690
|
+
var init_courseware_runtime_script_registry = __esm({
|
|
691
|
+
"packages/core/di/courseware-runtime-script-registry.ts"() {
|
|
692
|
+
DEFAULT_PRIORITY = 100;
|
|
693
|
+
DEFAULT_POSITION = "body-end";
|
|
694
|
+
CoursewareRuntimeScriptRegistry = class _CoursewareRuntimeScriptRegistry {
|
|
695
|
+
constructor() {
|
|
696
|
+
/** key = `${owner}::${id}`,保证不同插件可以使用相同的脚本 id */
|
|
697
|
+
this.scripts = /* @__PURE__ */ new Map();
|
|
698
|
+
}
|
|
699
|
+
static key(owner, id) {
|
|
700
|
+
return `${owner}::${id}`;
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* 注册(或覆盖)一个课件运行时脚本。
|
|
704
|
+
*
|
|
705
|
+
* @param owner - 注册方,通常是 `ctx.pluginId`
|
|
706
|
+
* @param script - 脚本描述;同一 owner 下相同 id 视为覆盖
|
|
707
|
+
*/
|
|
708
|
+
register(owner, script) {
|
|
709
|
+
if (!owner) throw new Error("CoursewareRuntimeScriptRegistry.register: owner is required");
|
|
710
|
+
if (!script || !script.id) throw new Error("CoursewareRuntimeScriptRegistry.register: script.id is required");
|
|
711
|
+
if (typeof script.source !== "string" || !script.source.trim()) {
|
|
712
|
+
throw new Error("CoursewareRuntimeScriptRegistry.register: script.source must be a non-empty string");
|
|
713
|
+
}
|
|
714
|
+
this.scripts.set(_CoursewareRuntimeScriptRegistry.key(owner, script.id), {
|
|
715
|
+
...script,
|
|
716
|
+
owner,
|
|
717
|
+
priority: typeof script.priority === "number" ? script.priority : DEFAULT_PRIORITY,
|
|
718
|
+
position: script.position === "head" ? "head" : DEFAULT_POSITION
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
/** 注销某个 owner 下的单个脚本 */
|
|
722
|
+
unregister(owner, id) {
|
|
723
|
+
this.scripts.delete(_CoursewareRuntimeScriptRegistry.key(owner, id));
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* 清理脚本。
|
|
727
|
+
*
|
|
728
|
+
* @param owner - 省略时清空所有 owner(谨慎使用,仅用于测试或平台重置)
|
|
729
|
+
*/
|
|
730
|
+
clear(owner) {
|
|
731
|
+
if (!owner) {
|
|
732
|
+
this.scripts.clear();
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
const prefix = `${owner}::`;
|
|
736
|
+
for (const key of [...this.scripts.keys()]) {
|
|
737
|
+
if (key.startsWith(prefix)) this.scripts.delete(key);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* 列出对目标课件生效的脚本(已按 priority 升序排序)。
|
|
742
|
+
*
|
|
743
|
+
* @param courseware - 目标课件的 `id` / `uuid`;省略时只返回「全局脚本」(两者均未限定者)
|
|
744
|
+
*/
|
|
745
|
+
list(courseware) {
|
|
746
|
+
const matched = [];
|
|
747
|
+
for (const entry of this.scripts.values()) {
|
|
748
|
+
if (this.matches(entry, courseware)) matched.push(entry);
|
|
749
|
+
}
|
|
750
|
+
return matched.map((entry, index) => ({ entry, index })).sort((a, b) => a.entry.priority - b.entry.priority || a.index - b.index).map((item) => item.entry);
|
|
751
|
+
}
|
|
752
|
+
/** 列出当前所有注册方(便于诊断与测试) */
|
|
753
|
+
listOwners() {
|
|
754
|
+
return [...new Set([...this.scripts.values()].map((entry) => entry.owner))];
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* 判定脚本是否对目标课件生效。
|
|
758
|
+
*
|
|
759
|
+
* - 未限定 `coursewareId` 也未限定 `coursewareUuid` → 全局脚本,对所有课件生效;
|
|
760
|
+
* - 限定了任一者 → 需要与传入的 `courseware` 对应字段精确相等(两者都命中任一即算命中)。
|
|
761
|
+
*/
|
|
762
|
+
matches(entry, courseware) {
|
|
763
|
+
const scopedById = typeof entry.coursewareId === "string" && entry.coursewareId.length > 0;
|
|
764
|
+
const scopedByUuid = typeof entry.coursewareUuid === "string" && entry.coursewareUuid.length > 0;
|
|
765
|
+
if (!scopedById && !scopedByUuid) return true;
|
|
766
|
+
if (!courseware) return false;
|
|
767
|
+
if (scopedById && courseware.id && entry.coursewareId === courseware.id) return true;
|
|
768
|
+
if (scopedByUuid && courseware.uuid && entry.coursewareUuid === courseware.uuid) return true;
|
|
769
|
+
return false;
|
|
770
|
+
}
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
|
|
685
775
|
// packages/core/plugin-host/types.ts
|
|
686
776
|
var PLUGIN_SHARED_MODULES;
|
|
687
777
|
var init_types = __esm({
|
|
@@ -873,9 +963,9 @@ var init_http_router = __esm({
|
|
|
873
963
|
/**
|
|
874
964
|
* 注册指定动词与路径的 Handler
|
|
875
965
|
*/
|
|
876
|
-
route(method,
|
|
966
|
+
route(method, path24, handler) {
|
|
877
967
|
const upperMethod = method.toUpperCase();
|
|
878
|
-
const normalized =
|
|
968
|
+
const normalized = path24.startsWith("/") ? path24 : "/" + path24;
|
|
879
969
|
const { regex, paramNames } = compileRoutePattern(normalized);
|
|
880
970
|
this.routes.push({
|
|
881
971
|
method: upperMethod,
|
|
@@ -886,36 +976,36 @@ var init_http_router = __esm({
|
|
|
886
976
|
isStream: false
|
|
887
977
|
});
|
|
888
978
|
}
|
|
889
|
-
get(
|
|
890
|
-
this.route("GET",
|
|
979
|
+
get(path24, handler) {
|
|
980
|
+
this.route("GET", path24, handler);
|
|
891
981
|
}
|
|
892
|
-
post(
|
|
893
|
-
this.route("POST",
|
|
982
|
+
post(path24, handler) {
|
|
983
|
+
this.route("POST", path24, handler);
|
|
894
984
|
}
|
|
895
|
-
put(
|
|
896
|
-
this.route("PUT",
|
|
985
|
+
put(path24, handler) {
|
|
986
|
+
this.route("PUT", path24, handler);
|
|
897
987
|
}
|
|
898
|
-
patch(
|
|
899
|
-
this.route("PATCH",
|
|
988
|
+
patch(path24, handler) {
|
|
989
|
+
this.route("PATCH", path24, handler);
|
|
900
990
|
}
|
|
901
|
-
delete(
|
|
902
|
-
this.route("DELETE",
|
|
991
|
+
delete(path24, handler) {
|
|
992
|
+
this.route("DELETE", path24, handler);
|
|
903
993
|
}
|
|
904
994
|
stream(methodOrPath, pathOrHandler, maybeHandler) {
|
|
905
995
|
if (typeof pathOrHandler === "function") {
|
|
906
|
-
const
|
|
996
|
+
const path24 = methodOrPath;
|
|
907
997
|
const handler = pathOrHandler;
|
|
908
|
-
this.registerStream("GET",
|
|
909
|
-
this.registerStream("POST",
|
|
998
|
+
this.registerStream("GET", path24, handler);
|
|
999
|
+
this.registerStream("POST", path24, handler);
|
|
910
1000
|
} else {
|
|
911
1001
|
const method = methodOrPath.toUpperCase();
|
|
912
|
-
const
|
|
1002
|
+
const path24 = pathOrHandler;
|
|
913
1003
|
const handler = maybeHandler;
|
|
914
|
-
this.registerStream(method,
|
|
1004
|
+
this.registerStream(method, path24, handler);
|
|
915
1005
|
}
|
|
916
1006
|
}
|
|
917
|
-
registerStream(method,
|
|
918
|
-
const normalized =
|
|
1007
|
+
registerStream(method, path24, handler) {
|
|
1008
|
+
const normalized = path24.startsWith("/") ? path24 : "/" + path24;
|
|
919
1009
|
const { regex, paramNames } = compileRoutePattern(normalized);
|
|
920
1010
|
this.routes.push({
|
|
921
1011
|
method: method.toUpperCase(),
|
|
@@ -929,9 +1019,9 @@ var init_http_router = __esm({
|
|
|
929
1019
|
/**
|
|
930
1020
|
* 检查指定动词与路径是否为流式路由
|
|
931
1021
|
*/
|
|
932
|
-
isStream(method,
|
|
1022
|
+
isStream(method, path24) {
|
|
933
1023
|
const upperMethod = method.toUpperCase();
|
|
934
|
-
const normalized =
|
|
1024
|
+
const normalized = path24.startsWith("/") ? path24 : "/" + path24;
|
|
935
1025
|
for (const entry of this.routes) {
|
|
936
1026
|
if (entry.method !== upperMethod) continue;
|
|
937
1027
|
if (entry.regex.test(normalized)) {
|
|
@@ -943,9 +1033,9 @@ var init_http_router = __esm({
|
|
|
943
1033
|
/**
|
|
944
1034
|
* 匹配路由并提取路由参数
|
|
945
1035
|
*/
|
|
946
|
-
match(method,
|
|
1036
|
+
match(method, path24) {
|
|
947
1037
|
const upperMethod = method.toUpperCase();
|
|
948
|
-
const normalized =
|
|
1038
|
+
const normalized = path24.startsWith("/") ? path24 : "/" + path24;
|
|
949
1039
|
for (const entry of this.routes) {
|
|
950
1040
|
if (entry.method !== upperMethod) continue;
|
|
951
1041
|
const match = normalized.match(entry.regex);
|
|
@@ -1122,6 +1212,21 @@ __export(context_builder_exports, {
|
|
|
1122
1212
|
createSafeFunction: () => createSafeFunction,
|
|
1123
1213
|
registerSharedModule: () => registerSharedModule
|
|
1124
1214
|
});
|
|
1215
|
+
function assertSafeSqlIdentifier(value, label) {
|
|
1216
|
+
if (typeof value !== "string" || !SQL_IDENTIFIER_PATTERN.test(value)) {
|
|
1217
|
+
throw new Error(`[SEC] Invalid SQL identifier for ${label}: ${JSON.stringify(value)}`);
|
|
1218
|
+
}
|
|
1219
|
+
return value;
|
|
1220
|
+
}
|
|
1221
|
+
function assertSafeCreateTableSchema(schema) {
|
|
1222
|
+
if (typeof schema !== "string" || schema.length === 0 || schema.length > 4e3) {
|
|
1223
|
+
throw new Error("[SEC] createTable schema must be a non-empty string (max 4000 chars)");
|
|
1224
|
+
}
|
|
1225
|
+
if (schema.includes(";")) {
|
|
1226
|
+
throw new Error('[SEC] createTable schema must not contain ";" (multiple statements are not allowed)');
|
|
1227
|
+
}
|
|
1228
|
+
return schema;
|
|
1229
|
+
}
|
|
1125
1230
|
function registerSharedModule(name, mod) {
|
|
1126
1231
|
sharedModules[name] = mod;
|
|
1127
1232
|
}
|
|
@@ -1452,15 +1557,19 @@ async function buildContext(serviceRegistry, tracker, pluginId, manifest, db2, s
|
|
|
1452
1557
|
const tablePrefix = `plugin_${pluginId.replace(/[^a-zA-Z0-9_]/g, "_")}_`;
|
|
1453
1558
|
const dbApi = {
|
|
1454
1559
|
async ensureTable(tableName, schema) {
|
|
1560
|
+
assertSafeSqlIdentifier(tableName, "ensureTable(tableName)");
|
|
1561
|
+
assertSafeCreateTableSchema(schema);
|
|
1455
1562
|
const fullName = tablePrefix + tableName;
|
|
1456
1563
|
db2.exec(`CREATE TABLE IF NOT EXISTS ${fullName} (${schema})`);
|
|
1457
1564
|
},
|
|
1458
1565
|
table(tableName) {
|
|
1566
|
+
assertSafeSqlIdentifier(tableName, "table(tableName)");
|
|
1459
1567
|
return tablePrefix + tableName;
|
|
1460
1568
|
},
|
|
1461
1569
|
async dropAllTables() {
|
|
1462
1570
|
const tables = db2.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ?`).all(tablePrefix + "%");
|
|
1463
1571
|
for (const t of tables) {
|
|
1572
|
+
if (!/^plugin_[A-Za-z0-9_]+$/.test(t.name)) continue;
|
|
1464
1573
|
db2.exec(`DROP TABLE IF EXISTS ${t.name}`);
|
|
1465
1574
|
}
|
|
1466
1575
|
},
|
|
@@ -1558,7 +1667,7 @@ async function buildContext(serviceRegistry, tracker, pluginId, manifest, db2, s
|
|
|
1558
1667
|
}
|
|
1559
1668
|
};
|
|
1560
1669
|
}
|
|
1561
|
-
var sharedModules;
|
|
1670
|
+
var SQL_IDENTIFIER_PATTERN, sharedModules;
|
|
1562
1671
|
var init_context_builder = __esm({
|
|
1563
1672
|
"packages/core/plugin-host/context-builder.ts"() {
|
|
1564
1673
|
init_types();
|
|
@@ -1567,6 +1676,7 @@ var init_context_builder = __esm({
|
|
|
1567
1676
|
init_logger();
|
|
1568
1677
|
init_interfaces();
|
|
1569
1678
|
init_plugin_namespace();
|
|
1679
|
+
SQL_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
|
1570
1680
|
sharedModules = {};
|
|
1571
1681
|
}
|
|
1572
1682
|
});
|
|
@@ -2842,6 +2952,7 @@ var init_di = __esm({
|
|
|
2842
2952
|
init_interfaces();
|
|
2843
2953
|
init_storage_service();
|
|
2844
2954
|
init_ai_service();
|
|
2955
|
+
init_courseware_runtime_script_registry();
|
|
2845
2956
|
init_container();
|
|
2846
2957
|
}
|
|
2847
2958
|
});
|
|
@@ -3387,15 +3498,29 @@ parentPort.on('message', async function(msg) {
|
|
|
3387
3498
|
var dbService = rawServices['@openlearn/core:IDatabase'];
|
|
3388
3499
|
var dbApi = dbService ? {
|
|
3389
3500
|
ensureTable: function(tableName, schema) {
|
|
3501
|
+
// SEC: tableName / schema \u7531\u63D2\u4EF6\u63D0\u4F9B\uFF0C\u5C5E\u4E0D\u53EF\u4FE1\u8F93\u5165\u3002\u76F4\u63A5\u62FC\u63A5\u4F1A\u5BFC\u81F4 SQL \u6CE8\u5165
|
|
3502
|
+
// \uFF08\u4F8B\u5982 tableName = "t (x); DROP TABLE events; --"\uFF09\u3002\u6B64\u5904\u5F3A\u5236\u6807\u8BC6\u7B26\u767D\u540D\u5355
|
|
3503
|
+
// \u5E76\u7981\u6B62 schema \u4E2D\u7684\u5206\u53F7\uFF0C\u907F\u514D\u591A\u8BED\u53E5\u6CE8\u5165\u3002\u4E0E inline \u6A21\u5F0F\u4FDD\u6301\u4E00\u81F4\u3002
|
|
3504
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(String(tableName))) {
|
|
3505
|
+
throw new Error("[SEC] Invalid SQL identifier for ensureTable: " + String(tableName));
|
|
3506
|
+
}
|
|
3507
|
+
if (typeof schema !== "string" || schema.length === 0 || schema.length > 4000 || schema.indexOf(";") !== -1) {
|
|
3508
|
+
throw new Error("[SEC] Invalid CREATE TABLE schema fragment: must be non-empty and contain no semicolon");
|
|
3509
|
+
}
|
|
3390
3510
|
var fullName = tablePrefix + tableName;
|
|
3391
3511
|
return dbService.prepareAndRun('CREATE TABLE IF NOT EXISTS ' + fullName + ' (' + schema + ')', []);
|
|
3392
3512
|
},
|
|
3393
3513
|
table: function(tableName) {
|
|
3514
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(String(tableName))) {
|
|
3515
|
+
throw new Error("[SEC] Invalid SQL identifier for table(): " + String(tableName));
|
|
3516
|
+
}
|
|
3394
3517
|
return tablePrefix + tableName;
|
|
3395
3518
|
},
|
|
3396
3519
|
dropAllTables: async function() {
|
|
3397
3520
|
var tables = await dbService.prepareAndAll("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ?", [tablePrefix + '%']);
|
|
3398
3521
|
for (var i = 0; i < tables.length; i++) {
|
|
3522
|
+
// SEC: \u8868\u540D\u6765\u81EA sqlite_master\uFF0C\u4ECD\u4E8C\u6B21\u6821\u9A8C\u540E\u518D\u62FC\u8FDB DDL
|
|
3523
|
+
if (!/^plugin_[A-Za-z0-9_]+$/.test(String(tables[i].name))) continue;
|
|
3399
3524
|
await dbService.prepareAndRun('DROP TABLE IF EXISTS ' + tables[i].name, []);
|
|
3400
3525
|
}
|
|
3401
3526
|
},
|
|
@@ -3592,12 +3717,12 @@ parentPort.on('message', async function(msg) {
|
|
|
3592
3717
|
});
|
|
3593
3718
|
`;
|
|
3594
3719
|
}
|
|
3595
|
-
var import_node_worker_threads, import_fs4,
|
|
3720
|
+
var import_node_worker_threads, import_fs4, import_node_path4, import_uuid12, ALL_SERVICE_TOKENS, MAX_WORKERS, ACTIVATE_TIMEOUT_MS, ACTIVATE_PROGRESS_SLIDE_MS, WorkerRegistry, WorkerManager;
|
|
3596
3721
|
var init_worker_manager = __esm({
|
|
3597
3722
|
"packages/core/worker-runtime/worker-manager.ts"() {
|
|
3598
3723
|
import_node_worker_threads = require("node:worker_threads");
|
|
3599
3724
|
import_fs4 = __toESM(require("fs"), 1);
|
|
3600
|
-
|
|
3725
|
+
import_node_path4 = __toESM(require("node:path"), 1);
|
|
3601
3726
|
init_transport();
|
|
3602
3727
|
init_service_host();
|
|
3603
3728
|
init_logger();
|
|
@@ -3885,7 +4010,7 @@ var init_worker_manager = __esm({
|
|
|
3885
4010
|
const bootstrapDataUrl = `data:text/javascript;base64,${encodedBootstrap}`;
|
|
3886
4011
|
let worker;
|
|
3887
4012
|
try {
|
|
3888
|
-
const resolvedPluginDir = pluginDir && import_fs4.default.existsSync(
|
|
4013
|
+
const resolvedPluginDir = pluginDir && import_fs4.default.existsSync(import_node_path4.default.join(pluginDir, "index.js")) ? pluginDir : void 0;
|
|
3889
4014
|
worker = new import_node_worker_threads.Worker(new URL(bootstrapDataUrl), {
|
|
3890
4015
|
// Pass both the DB id (`pluginId` — used as the actor/registry key)
|
|
3891
4016
|
// and `manifestId` (used as the namespace prefix for command types).
|
|
@@ -4129,8 +4254,8 @@ __export(server_exports, {
|
|
|
4129
4254
|
startServer: () => startServer
|
|
4130
4255
|
});
|
|
4131
4256
|
module.exports = __toCommonJS(server_exports);
|
|
4132
|
-
var
|
|
4133
|
-
var
|
|
4257
|
+
var import_express4 = __toESM(require("express"), 1);
|
|
4258
|
+
var import_path17 = __toESM(require("path"), 1);
|
|
4134
4259
|
var import_dotenv = __toESM(require("dotenv"), 1);
|
|
4135
4260
|
var import_os2 = __toESM(require("os"), 1);
|
|
4136
4261
|
var import_child_process3 = require("child_process");
|
|
@@ -4172,6 +4297,14 @@ var EventBus = class {
|
|
|
4172
4297
|
|
|
4173
4298
|
// packages/core/command-bus/index.ts
|
|
4174
4299
|
var import_uuid = require("uuid");
|
|
4300
|
+
var DEFAULT_QUIET_COMMANDS = /* @__PURE__ */ new Set([
|
|
4301
|
+
"courseware.list",
|
|
4302
|
+
"courseware.get_attempt_raw_data",
|
|
4303
|
+
"whiteboard.query",
|
|
4304
|
+
"whiteboard.get_element",
|
|
4305
|
+
"vfs.read_path",
|
|
4306
|
+
"vfs.list_dir"
|
|
4307
|
+
]);
|
|
4175
4308
|
var CommandBus = class {
|
|
4176
4309
|
constructor(eventBus) {
|
|
4177
4310
|
this.eventBus = eventBus;
|
|
@@ -4215,9 +4348,12 @@ var CommandBus = class {
|
|
|
4215
4348
|
if (!handler) {
|
|
4216
4349
|
throw new Error(`No handler registered for command: ${normalizedCommand.type}`);
|
|
4217
4350
|
}
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4351
|
+
const isQuiet = normalizedCommand.metadata?.silent === true || DEFAULT_QUIET_COMMANDS.has(normalizedCommand.type);
|
|
4352
|
+
if (!isQuiet || process.env.DEBUG?.includes("commandbus") || process.env.DEBUG_COMMAND_BUS === "true") {
|
|
4353
|
+
console.log(
|
|
4354
|
+
`[CommandBus] Executing: ${normalizedCommand.type} (ID: ${normalizedCommand.id}) by ${normalizedCommand.actorId}`
|
|
4355
|
+
);
|
|
4356
|
+
}
|
|
4221
4357
|
try {
|
|
4222
4358
|
const result = await handler.execute(normalizedCommand);
|
|
4223
4359
|
return result;
|
|
@@ -4281,7 +4417,7 @@ var ActionRegistry = class {
|
|
|
4281
4417
|
};
|
|
4282
4418
|
|
|
4283
4419
|
// packages/core/capability-system/index.ts
|
|
4284
|
-
var CapabilityGuard = class {
|
|
4420
|
+
var CapabilityGuard = class _CapabilityGuard {
|
|
4285
4421
|
constructor() {
|
|
4286
4422
|
this.actorCapabilities = /* @__PURE__ */ new Map();
|
|
4287
4423
|
this.actorCapabilities.set("user-demo", ["*:*:*"]);
|
|
@@ -4321,11 +4457,33 @@ var CapabilityGuard = class {
|
|
|
4321
4457
|
revokeAll(actorId) {
|
|
4322
4458
|
this.actorCapabilities.delete(actorId);
|
|
4323
4459
|
}
|
|
4460
|
+
static extractRole(actorId) {
|
|
4461
|
+
if (!actorId || typeof actorId !== "string") return null;
|
|
4462
|
+
const lastColon = actorId.lastIndexOf(":");
|
|
4463
|
+
if (lastColon === -1) return null;
|
|
4464
|
+
const role = actorId.slice(lastColon + 1);
|
|
4465
|
+
if (["administrator", "admin", "teacher", "student", "anonymous"].includes(role)) return role;
|
|
4466
|
+
return null;
|
|
4467
|
+
}
|
|
4324
4468
|
check(actorId, requiredCap) {
|
|
4325
|
-
const
|
|
4469
|
+
const role = _CapabilityGuard.extractRole(actorId);
|
|
4470
|
+
const isAdmin = actorId === "role:administrator" || actorId === "admin" || actorId === "usr_admin" || actorId === "admin-demo" || role === "administrator" || role === "admin";
|
|
4326
4471
|
if (isAdmin) return true;
|
|
4327
|
-
if (
|
|
4328
|
-
const teacherCaps = [
|
|
4472
|
+
if (role === "teacher") {
|
|
4473
|
+
const teacherCaps = [
|
|
4474
|
+
"lesson:*",
|
|
4475
|
+
"whiteboard:*",
|
|
4476
|
+
"management:*",
|
|
4477
|
+
"quiz:*",
|
|
4478
|
+
"vfs:*",
|
|
4479
|
+
"process:*",
|
|
4480
|
+
"plugin:*",
|
|
4481
|
+
"student:write",
|
|
4482
|
+
"assignment:read",
|
|
4483
|
+
"assignment:submit",
|
|
4484
|
+
"assignment:review",
|
|
4485
|
+
"assignment:manage"
|
|
4486
|
+
];
|
|
4329
4487
|
const [reqRes2, reqAct2] = requiredCap.split(":");
|
|
4330
4488
|
if (teacherCaps.some((c) => {
|
|
4331
4489
|
const [res, act] = c.split(":");
|
|
@@ -4334,8 +4492,15 @@ var CapabilityGuard = class {
|
|
|
4334
4492
|
return true;
|
|
4335
4493
|
}
|
|
4336
4494
|
}
|
|
4337
|
-
if (
|
|
4338
|
-
const studentCaps = [
|
|
4495
|
+
if (role === "student") {
|
|
4496
|
+
const studentCaps = [
|
|
4497
|
+
"student:write",
|
|
4498
|
+
"lesson:read",
|
|
4499
|
+
"whiteboard:read",
|
|
4500
|
+
"assignment:read",
|
|
4501
|
+
"assignment:submit",
|
|
4502
|
+
"assignment:review"
|
|
4503
|
+
];
|
|
4339
4504
|
const [reqRes2, reqAct2] = requiredCap.split(":");
|
|
4340
4505
|
if (studentCaps.some((c) => {
|
|
4341
4506
|
const [res, act] = c.split(":");
|
|
@@ -5061,15 +5226,89 @@ db.exec(`
|
|
|
5061
5226
|
updated_at TEXT DEFAULT (datetime('now'))
|
|
5062
5227
|
);
|
|
5063
5228
|
|
|
5229
|
+
-- \u2500\u2500 \u4F5C\u4E1A\u4E2D\u5FC3\uFF08Assignment Hub\uFF09\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
5230
|
+
-- plugin_assignments \u662F\u4F5C\u4E1A\u5B9E\u4F53\uFF1A\u767D\u677F\u4E0A\u7684\u300C\u8BFE\u5802\u4F5C\u4E1A\u4EFB\u52A1\u300D\u6559\u5B66\u5BF9\u8C61\u53EA\u662F\u5B83\u7684
|
|
5231
|
+
-- \u4E00\u4E2A\u6295\u5F71\u7247\u6BB5\uFF08element_id\uFF09\uFF0Cclass_id \u8BA9\u5B83\u540C\u65F6\u51FA\u73B0\u5728\u73ED\u7EA7\u4F5C\u4E1A\u6210\u7EE9\u9875\u3002
|
|
5232
|
+
CREATE TABLE IF NOT EXISTS plugin_assignments (
|
|
5233
|
+
id TEXT PRIMARY KEY,
|
|
5234
|
+
class_id TEXT,
|
|
5235
|
+
lesson_id TEXT,
|
|
5236
|
+
element_id TEXT,
|
|
5237
|
+
title TEXT NOT NULL,
|
|
5238
|
+
description TEXT,
|
|
5239
|
+
instructions TEXT,
|
|
5240
|
+
due_at INTEGER,
|
|
5241
|
+
allow_late INTEGER NOT NULL DEFAULT 1,
|
|
5242
|
+
allow_text INTEGER NOT NULL DEFAULT 1,
|
|
5243
|
+
allow_link INTEGER NOT NULL DEFAULT 0,
|
|
5244
|
+
max_files INTEGER NOT NULL DEFAULT 10,
|
|
5245
|
+
max_file_size INTEGER NOT NULL DEFAULT 20971520,
|
|
5246
|
+
allowed_ext TEXT NOT NULL DEFAULT '',
|
|
5247
|
+
peer_review_mode TEXT NOT NULL DEFAULT 'assigned',
|
|
5248
|
+
peer_review_count INTEGER NOT NULL DEFAULT 2,
|
|
5249
|
+
peer_review_due_at INTEGER,
|
|
5250
|
+
teacher_weight REAL NOT NULL DEFAULT 0.6,
|
|
5251
|
+
peer_weight REAL NOT NULL DEFAULT 0.4,
|
|
5252
|
+
status TEXT NOT NULL DEFAULT 'published',
|
|
5253
|
+
created_by TEXT,
|
|
5254
|
+
created_at INTEGER NOT NULL,
|
|
5255
|
+
updated_at INTEGER NOT NULL
|
|
5256
|
+
);
|
|
5257
|
+
|
|
5258
|
+
-- \u4E00\u4E2A\u4F5C\u4E1A\u4E00\u4E2A\u5B66\u751F\u4E00\u884C\uFF08assignment_id \u975E\u7A7A\u65F6\uFF09\uFF1B\u4FDD\u7559 lesson_id \u5217\u4EE5\u517C\u5BB9\u65E7\u6570\u636E
|
|
5064
5259
|
CREATE TABLE IF NOT EXISTS plugin_submissions (
|
|
5065
5260
|
id TEXT PRIMARY KEY,
|
|
5066
|
-
|
|
5261
|
+
assignment_id TEXT,
|
|
5262
|
+
lesson_id TEXT,
|
|
5067
5263
|
student_id TEXT NOT NULL,
|
|
5068
|
-
file_path TEXT
|
|
5264
|
+
file_path TEXT,
|
|
5069
5265
|
version INTEGER NOT NULL DEFAULT 1,
|
|
5070
5266
|
created_at INTEGER NOT NULL,
|
|
5071
|
-
updated_at INTEGER NOT NULL
|
|
5072
|
-
|
|
5267
|
+
updated_at INTEGER NOT NULL
|
|
5268
|
+
);
|
|
5269
|
+
|
|
5270
|
+
-- \u6BCF\u6B21\u63D0\u4EA4\u90FD\u7559\u6863\uFF08\u91CD\u4EA4\u4E0D\u8986\u76D6\u5386\u53F2\uFF09
|
|
5271
|
+
CREATE TABLE IF NOT EXISTS plugin_submission_versions (
|
|
5272
|
+
id TEXT PRIMARY KEY,
|
|
5273
|
+
submission_id TEXT NOT NULL,
|
|
5274
|
+
assignment_id TEXT,
|
|
5275
|
+
student_id TEXT NOT NULL,
|
|
5276
|
+
version INTEGER NOT NULL,
|
|
5277
|
+
files_json TEXT NOT NULL DEFAULT '[]',
|
|
5278
|
+
text_content TEXT,
|
|
5279
|
+
link_url TEXT,
|
|
5280
|
+
is_late INTEGER NOT NULL DEFAULT 0,
|
|
5281
|
+
submitted_at INTEGER NOT NULL,
|
|
5282
|
+
UNIQUE(submission_id, version)
|
|
5283
|
+
);
|
|
5284
|
+
|
|
5285
|
+
-- \u4E0A\u4F20\u7684\u5B9E\u4F53\u6587\u4EF6\uFF08\u78C1\u76D8\u5B58\u50A8 + \u5143\u6570\u636E\uFF09\uFF0C\u4E0B\u8F7D\u7AEF\u70B9\u6309\u6B64\u8868\u9274\u6743
|
|
5286
|
+
CREATE TABLE IF NOT EXISTS plugin_assignment_files (
|
|
5287
|
+
id TEXT PRIMARY KEY,
|
|
5288
|
+
assignment_id TEXT NOT NULL,
|
|
5289
|
+
submission_id TEXT,
|
|
5290
|
+
version_id TEXT,
|
|
5291
|
+
student_id TEXT NOT NULL,
|
|
5292
|
+
original_name TEXT NOT NULL,
|
|
5293
|
+
stored_path TEXT NOT NULL,
|
|
5294
|
+
size INTEGER NOT NULL DEFAULT 0,
|
|
5295
|
+
mime TEXT,
|
|
5296
|
+
sha256 TEXT,
|
|
5297
|
+
uploaded_at INTEGER NOT NULL,
|
|
5298
|
+
deleted_at INTEGER
|
|
5299
|
+
);
|
|
5300
|
+
|
|
5301
|
+
-- \u4E92\u8BC4\u4EFB\u52A1\uFF08\u8C01\u8BC4\u8C01 / \u533F\u540D / \u622A\u6B62\uFF09\uFF0C\u7531\u5206\u914D\u7B56\u7565\u751F\u6210
|
|
5302
|
+
CREATE TABLE IF NOT EXISTS plugin_peer_review_tasks (
|
|
5303
|
+
id TEXT PRIMARY KEY,
|
|
5304
|
+
assignment_id TEXT NOT NULL,
|
|
5305
|
+
submission_id TEXT NOT NULL,
|
|
5306
|
+
reviewer_id TEXT NOT NULL,
|
|
5307
|
+
anonymous INTEGER NOT NULL DEFAULT 1,
|
|
5308
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
5309
|
+
due_at INTEGER,
|
|
5310
|
+
created_at INTEGER NOT NULL,
|
|
5311
|
+
UNIQUE(submission_id, reviewer_id)
|
|
5073
5312
|
);
|
|
5074
5313
|
|
|
5075
5314
|
CREATE TABLE IF NOT EXISTS plugin_peer_reviews (
|
|
@@ -5079,6 +5318,11 @@ db.exec(`
|
|
|
5079
5318
|
score INTEGER NOT NULL,
|
|
5080
5319
|
comment TEXT,
|
|
5081
5320
|
created_at INTEGER NOT NULL,
|
|
5321
|
+
assignment_id TEXT,
|
|
5322
|
+
task_id TEXT,
|
|
5323
|
+
anonymous INTEGER NOT NULL DEFAULT 1,
|
|
5324
|
+
status TEXT NOT NULL DEFAULT 'submitted',
|
|
5325
|
+
updated_at INTEGER,
|
|
5082
5326
|
UNIQUE(submission_id, reviewer_id)
|
|
5083
5327
|
);
|
|
5084
5328
|
|
|
@@ -5091,7 +5335,12 @@ db.exec(`
|
|
|
5091
5335
|
peer_weight REAL NOT NULL DEFAULT 0.4,
|
|
5092
5336
|
calculated_final_score INTEGER,
|
|
5093
5337
|
status TEXT NOT NULL DEFAULT 'draft',
|
|
5094
|
-
graded_at INTEGER
|
|
5338
|
+
graded_at INTEGER,
|
|
5339
|
+
assignment_id TEXT,
|
|
5340
|
+
peer_average_score REAL,
|
|
5341
|
+
source TEXT NOT NULL DEFAULT 'teacher',
|
|
5342
|
+
published_at INTEGER,
|
|
5343
|
+
graded_by TEXT
|
|
5095
5344
|
);
|
|
5096
5345
|
|
|
5097
5346
|
-- Performance Indexes
|
|
@@ -5104,7 +5353,22 @@ db.exec(`
|
|
|
5104
5353
|
CREATE INDEX IF NOT EXISTS idx_events_type_time ON events(type, timestamp);
|
|
5105
5354
|
CREATE INDEX IF NOT EXISTS idx_assignments_class ON assignments(class_id);
|
|
5106
5355
|
CREATE INDEX IF NOT EXISTS idx_attendance_schedule ON attendance(schedule_id);
|
|
5356
|
+
|
|
5357
|
+
-- \u4F5C\u4E1A\u4E2D\u5FC3\u7D22\u5F15
|
|
5358
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_plugin_assignments_element ON plugin_assignments(element_id);
|
|
5359
|
+
CREATE INDEX IF NOT EXISTS idx_plugin_assignments_lesson ON plugin_assignments(lesson_id);
|
|
5360
|
+
CREATE INDEX IF NOT EXISTS idx_plugin_assignments_class ON plugin_assignments(class_id);
|
|
5361
|
+
CREATE INDEX IF NOT EXISTS idx_plugin_submission_versions_submission ON plugin_submission_versions(submission_id);
|
|
5362
|
+
CREATE INDEX IF NOT EXISTS idx_plugin_assignment_files_owner ON plugin_assignment_files(assignment_id, student_id);
|
|
5363
|
+
CREATE INDEX IF NOT EXISTS idx_plugin_peer_review_tasks_reviewer ON plugin_peer_review_tasks(reviewer_id, status);
|
|
5107
5364
|
`);
|
|
5365
|
+
try {
|
|
5366
|
+
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_plugin_submissions_assignment_student ON plugin_submissions(assignment_id, student_id)");
|
|
5367
|
+
db.exec(
|
|
5368
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS idx_plugin_submissions_legacy ON plugin_submissions(lesson_id, student_id) WHERE assignment_id IS NULL"
|
|
5369
|
+
);
|
|
5370
|
+
} catch {
|
|
5371
|
+
}
|
|
5108
5372
|
try {
|
|
5109
5373
|
db.exec("ALTER TABLE lessons ADD COLUMN creator_id TEXT");
|
|
5110
5374
|
} catch {
|
|
@@ -6606,7 +6870,632 @@ ${htmlContent}
|
|
|
6606
6870
|
}
|
|
6607
6871
|
};
|
|
6608
6872
|
|
|
6873
|
+
// packages/plugins/score-monitor-script.ts
|
|
6874
|
+
var SCORE_MONITOR_SCRIPT_ID = "score-variable-monitor";
|
|
6875
|
+
var SCORE_MONITOR_SCRIPT_OWNER = "@openlearn/plugin-builtin";
|
|
6876
|
+
var SCORE_MONITOR_SCRIPT = `(function () {
|
|
6877
|
+
'use strict';
|
|
6878
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
|
6879
|
+
if (window.__LMS_WATCH_DISABLED__ === true) return;
|
|
6880
|
+
if (window.__LMS_WATCH__ === false) return;
|
|
6881
|
+
if (window.__LMS_SCORE_WATCHER_READY__ === true) return;
|
|
6882
|
+
window.__LMS_SCORE_WATCHER_READY__ = true;
|
|
6883
|
+
|
|
6884
|
+
var INTERVAL_MS = 800;
|
|
6885
|
+
var SILENCE_MS = 1200;
|
|
6886
|
+
var MAX_SUBMITS = 60;
|
|
6887
|
+
var KEY_RE = /score|point|grade|mark|correct|right/i;
|
|
6888
|
+
var SKIP_RE = /^_|^on[A-Z]|^webkit|^moz|^ms/;
|
|
6889
|
+
var NUM_RE = /[0-9]+(?:[.][0-9]+)?/;
|
|
6890
|
+
var RATIO_RE = /([0-9]+(?:[.][0-9]+)?)[^0-9]*[/\uFF0F|\u4E4B][^0-9]*([0-9]+)/;
|
|
6891
|
+
var DOM_SELECTORS = [
|
|
6892
|
+
'#score', '#points', '#grade', '#finalScore', '#totalScore', '#scoreDisplay', '#score-num',
|
|
6893
|
+
'.score', '.points', '.grade', '.final-score',
|
|
6894
|
+
'[id*="score" i]', '[id*="point" i]', '[id*="grade" i]'
|
|
6895
|
+
];
|
|
6896
|
+
|
|
6897
|
+
var state = { snapshot: null, pendingChanged: null, timer: null, submitted: 0, stopped: false };
|
|
6898
|
+
var seenElements = [];
|
|
6899
|
+
|
|
6900
|
+
function num(value) {
|
|
6901
|
+
if (typeof value === 'number') return isFinite(value) ? value : null;
|
|
6902
|
+
if (typeof value !== 'string') return null;
|
|
6903
|
+
var match = value.match(RATIO_RE);
|
|
6904
|
+
if (match) {
|
|
6905
|
+
var part = parseFloat(match[1]);
|
|
6906
|
+
var whole = parseFloat(match[2]);
|
|
6907
|
+
if (whole > 0) return Math.round((part / whole) * 10000) / 100;
|
|
6908
|
+
}
|
|
6909
|
+
var found = value.match(NUM_RE);
|
|
6910
|
+
if (!found) return null;
|
|
6911
|
+
var parsed = parseFloat(found[0]);
|
|
6912
|
+
return isFinite(parsed) ? parsed : null;
|
|
6913
|
+
}
|
|
6914
|
+
|
|
6915
|
+
function selectorKey(selector) {
|
|
6916
|
+
return 'dom_' + selector.replace(/[^A-Za-z0-9_-]+/g, '_');
|
|
6917
|
+
}
|
|
6918
|
+
|
|
6919
|
+
function isShown(el) {
|
|
6920
|
+
if (!el || !el.getBoundingClientRect) return false;
|
|
6921
|
+
var rect = el.getBoundingClientRect();
|
|
6922
|
+
if (rect.width <= 0 || rect.height <= 0) return false;
|
|
6923
|
+
var node = el;
|
|
6924
|
+
while (node && node.nodeType === 1) {
|
|
6925
|
+
var style = null;
|
|
6926
|
+
try { style = window.getComputedStyle(node); } catch (err) { style = null; }
|
|
6927
|
+
if (style && (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0')) return false;
|
|
6928
|
+
node = node.parentNode;
|
|
6929
|
+
}
|
|
6930
|
+
return true;
|
|
6931
|
+
}
|
|
6932
|
+
|
|
6933
|
+
function resolvePath(path) {
|
|
6934
|
+
try {
|
|
6935
|
+
var node = window;
|
|
6936
|
+
var parts = String(path).split('.');
|
|
6937
|
+
for (var i = 0; i < parts.length; i++) {
|
|
6938
|
+
if (node === null || typeof node === 'undefined') return null;
|
|
6939
|
+
node = node[parts[i]];
|
|
6940
|
+
}
|
|
6941
|
+
return node;
|
|
6942
|
+
} catch (err) {
|
|
6943
|
+
return null;
|
|
6944
|
+
}
|
|
6945
|
+
}
|
|
6946
|
+
|
|
6947
|
+
function snapshot() {
|
|
6948
|
+
var result = {};
|
|
6949
|
+
var explicit = window.__LMS_WATCH__;
|
|
6950
|
+
var names = [];
|
|
6951
|
+
if (typeof explicit === 'string' && explicit) names.push(explicit);
|
|
6952
|
+
else if (Object.prototype.toString.call(explicit) === '[object Array]') {
|
|
6953
|
+
for (var i = 0; i < explicit.length; i++) if (typeof explicit[i] === 'string') names.push(explicit[i]);
|
|
6954
|
+
}
|
|
6955
|
+
if (names.length) {
|
|
6956
|
+
for (var n = 0; n < names.length; n++) {
|
|
6957
|
+
var value = num(resolvePath(names[n]));
|
|
6958
|
+
if (value !== null) result[names[n]] = value;
|
|
6959
|
+
}
|
|
6960
|
+
} else {
|
|
6961
|
+
var keys = Object.keys(window);
|
|
6962
|
+
for (var k = 0; k < keys.length; k++) {
|
|
6963
|
+
var key = keys[k];
|
|
6964
|
+
if (SKIP_RE.test(key) || !KEY_RE.test(key)) continue;
|
|
6965
|
+
var raw = null;
|
|
6966
|
+
try { raw = window[key]; } catch (err) { continue; }
|
|
6967
|
+
if (typeof raw !== 'number' && typeof raw !== 'string') continue;
|
|
6968
|
+
var numValue = num(raw);
|
|
6969
|
+
if (numValue !== null) result[key] = numValue;
|
|
6970
|
+
}
|
|
6971
|
+
}
|
|
6972
|
+
seenElements = [];
|
|
6973
|
+
for (var s = 0; s < DOM_SELECTORS.length; s++) {
|
|
6974
|
+
var selector = DOM_SELECTORS[s];
|
|
6975
|
+
var nodes = [];
|
|
6976
|
+
try { nodes = document.querySelectorAll(selector); } catch (err) { nodes = []; }
|
|
6977
|
+
for (var t = 0; t < nodes.length; t++) {
|
|
6978
|
+
var el = nodes[t];
|
|
6979
|
+
if (seenElements.indexOf(el) !== -1) continue;
|
|
6980
|
+
seenElements.push(el);
|
|
6981
|
+
if (!isShown(el)) continue;
|
|
6982
|
+
var text = (el.textContent || '').trim();
|
|
6983
|
+
if (!text || text.length > 40) continue;
|
|
6984
|
+
var domValue = num(text);
|
|
6985
|
+
if (domValue === null) continue;
|
|
6986
|
+
if (typeof result[selectorKey(selector)] === 'undefined') result[selectorKey(selector)] = domValue;
|
|
6987
|
+
}
|
|
6988
|
+
}
|
|
6989
|
+
return result;
|
|
6990
|
+
}
|
|
6991
|
+
|
|
6992
|
+
function diff(prev, next) {
|
|
6993
|
+
var changed = {};
|
|
6994
|
+
var keys = Object.keys(next);
|
|
6995
|
+
for (var i = 0; i < keys.length; i++) {
|
|
6996
|
+
var key = keys[i];
|
|
6997
|
+
if (!prev || prev[key] !== next[key]) changed[key] = next[key];
|
|
6998
|
+
}
|
|
6999
|
+
return changed;
|
|
7000
|
+
}
|
|
7001
|
+
|
|
7002
|
+
function report(payload) {
|
|
7003
|
+
try {
|
|
7004
|
+
if (window.LMS && typeof window.LMS.saveProgress === 'function') {
|
|
7005
|
+
window.LMS.saveProgress(payload);
|
|
7006
|
+
return true;
|
|
7007
|
+
}
|
|
7008
|
+
} catch (err) { /* \u5FFD\u7565\u5E76\u5C1D\u8BD5\u515C\u5E95\u901A\u9053 */ }
|
|
7009
|
+
try {
|
|
7010
|
+
var student = window.__LMS_STUDENT__ || {};
|
|
7011
|
+
var courseware = window.__LMS_COURSEWARE__ || {};
|
|
7012
|
+
window.parent.postMessage(
|
|
7013
|
+
{ type: 'LMS_SAVE_PROGRESS', uuid: courseware.uuid, attempt_id: student.attempt_id, payload: payload },
|
|
7014
|
+
'*'
|
|
7015
|
+
);
|
|
7016
|
+
return true;
|
|
7017
|
+
} catch (err) {
|
|
7018
|
+
return false;
|
|
7019
|
+
}
|
|
7020
|
+
}
|
|
7021
|
+
|
|
7022
|
+
function flush() {
|
|
7023
|
+
if (state.stopped) return;
|
|
7024
|
+
if (window.__LMS_WATCH_DISABLED__ === true) { state.stopped = true; return; }
|
|
7025
|
+
var changed = state.pendingChanged || {};
|
|
7026
|
+
state.pendingChanged = null;
|
|
7027
|
+
var keys = Object.keys(changed);
|
|
7028
|
+
if (!keys.length) return;
|
|
7029
|
+
if (state.submitted >= MAX_SUBMITS) return;
|
|
7030
|
+
var best = null;
|
|
7031
|
+
for (var i = 0; i < keys.length; i++) {
|
|
7032
|
+
var value = changed[keys[i]];
|
|
7033
|
+
if (typeof value !== 'number') continue;
|
|
7034
|
+
if (best === null || value > best) best = value;
|
|
7035
|
+
}
|
|
7036
|
+
if (best === null) return;
|
|
7037
|
+
var snap = {};
|
|
7038
|
+
var all = state.snapshot || {};
|
|
7039
|
+
var allKeys = Object.keys(all);
|
|
7040
|
+
for (var j = 0; j < allKeys.length; j++) snap[allKeys[j]] = all[allKeys[j]];
|
|
7041
|
+
snap._changed = keys;
|
|
7042
|
+
snap._at = Date.now();
|
|
7043
|
+
try { console.warn('Score variable changed (' + keys.join(', ') + ') -> ' + best); } catch (err) { /* noop */ }
|
|
7044
|
+
var ok = report({ score: best, watch: snap });
|
|
7045
|
+
if (ok) state.submitted = state.submitted + 1;
|
|
7046
|
+
}
|
|
7047
|
+
|
|
7048
|
+
function tick() {
|
|
7049
|
+
if (state.stopped) return;
|
|
7050
|
+
if (window.__LMS_WATCH_DISABLED__ === true) { state.stopped = true; return; }
|
|
7051
|
+
if (state.submitted >= MAX_SUBMITS) { state.stopped = true; return; }
|
|
7052
|
+
var student = window.__LMS_STUDENT__;
|
|
7053
|
+
if (!student || !student.attempt_id) return;
|
|
7054
|
+
var next = snapshot();
|
|
7055
|
+
var changed = diff(state.snapshot, next);
|
|
7056
|
+
state.snapshot = next;
|
|
7057
|
+
if (!Object.keys(changed).length) return;
|
|
7058
|
+
state.pendingChanged = Object.assign(state.pendingChanged || {}, changed);
|
|
7059
|
+
if (state.timer) clearTimeout(state.timer);
|
|
7060
|
+
state.timer = setTimeout(function () { state.timer = null; flush(); }, SILENCE_MS);
|
|
7061
|
+
}
|
|
7062
|
+
|
|
7063
|
+
function init() {
|
|
7064
|
+
state.snapshot = snapshot();
|
|
7065
|
+
setInterval(tick, INTERVAL_MS);
|
|
7066
|
+
try {
|
|
7067
|
+
if (!window.__LMS_SCORE_WATCHERS__ || Object.prototype.toString.call(window.__LMS_SCORE_WATCHERS__) !== '[object Array]') {
|
|
7068
|
+
window.__LMS_SCORE_WATCHERS__ = [];
|
|
7069
|
+
}
|
|
7070
|
+
window.__LMS_SCORE_WATCHERS__.push({
|
|
7071
|
+
stop: function () {
|
|
7072
|
+
state.stopped = true;
|
|
7073
|
+
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
|
7074
|
+
}
|
|
7075
|
+
});
|
|
7076
|
+
} catch (err) { /* noop */ }
|
|
7077
|
+
if (typeof MutationObserver === 'function' && document.documentElement) {
|
|
7078
|
+
var scheduled = false;
|
|
7079
|
+
var observer = new MutationObserver(function () {
|
|
7080
|
+
if (scheduled) return;
|
|
7081
|
+
scheduled = true;
|
|
7082
|
+
setTimeout(function () { scheduled = false; tick(); }, 300);
|
|
7083
|
+
});
|
|
7084
|
+
try {
|
|
7085
|
+
observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true });
|
|
7086
|
+
} catch (err) { /* noop */ }
|
|
7087
|
+
}
|
|
7088
|
+
try { console.warn('[native] score monitor armed, watching score-like variables'); } catch (err) { /* noop */ }
|
|
7089
|
+
}
|
|
7090
|
+
|
|
7091
|
+
if (document.body) init();
|
|
7092
|
+
else document.addEventListener('DOMContentLoaded', init);
|
|
7093
|
+
})();`;
|
|
7094
|
+
|
|
7095
|
+
// packages/plugins/courseware-score.ts
|
|
7096
|
+
var DEFAULT_SCORE_POLICY = "LATEST";
|
|
7097
|
+
var GLOBAL_SCORE_CONFIG_KEY = "*";
|
|
7098
|
+
var SCORE_CONFIG_TABLE = "courseware_score_config";
|
|
7099
|
+
var DEFAULT_SCORE_CONFIG = {
|
|
7100
|
+
courseware_id: GLOBAL_SCORE_CONFIG_KEY,
|
|
7101
|
+
courseware_name: null,
|
|
7102
|
+
score_policy: DEFAULT_SCORE_POLICY,
|
|
7103
|
+
score_fields: "",
|
|
7104
|
+
raw_full_score: 100,
|
|
7105
|
+
target_full_score: 100,
|
|
7106
|
+
weight_percentage: 100,
|
|
7107
|
+
lesson_id: null,
|
|
7108
|
+
updated_at: 0
|
|
7109
|
+
};
|
|
7110
|
+
var BUILTIN_SCORE_CONFIG = { ...DEFAULT_SCORE_CONFIG };
|
|
7111
|
+
function toNumber(value) {
|
|
7112
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
7113
|
+
if (typeof value === "boolean") return null;
|
|
7114
|
+
if (typeof value === "string") {
|
|
7115
|
+
const trimmed = value.trim();
|
|
7116
|
+
if (!trimmed) return null;
|
|
7117
|
+
const cleaned = trimmed.endsWith("%") ? trimmed.slice(0, -1).trim() : trimmed;
|
|
7118
|
+
if (!cleaned || !/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(cleaned)) return null;
|
|
7119
|
+
const num = Number(cleaned);
|
|
7120
|
+
return Number.isFinite(num) ? num : null;
|
|
7121
|
+
}
|
|
7122
|
+
return null;
|
|
7123
|
+
}
|
|
7124
|
+
function clamp(value, min, max) {
|
|
7125
|
+
if (!Number.isFinite(value)) return min;
|
|
7126
|
+
if (value < min) return min;
|
|
7127
|
+
if (value > max) return max;
|
|
7128
|
+
return value;
|
|
7129
|
+
}
|
|
7130
|
+
function round2(value) {
|
|
7131
|
+
if (!Number.isFinite(value)) return value;
|
|
7132
|
+
return Math.round(value * 100) / 100;
|
|
7133
|
+
}
|
|
7134
|
+
function isKnownScorePolicy(value) {
|
|
7135
|
+
if (typeof value !== "string") return false;
|
|
7136
|
+
const raw = value.trim().toUpperCase().replace(/[\s-]+/g, "_");
|
|
7137
|
+
return KNOWN_POLICY_ALIASES.has(raw);
|
|
7138
|
+
}
|
|
7139
|
+
var KNOWN_POLICY_ALIASES = /* @__PURE__ */ new Set([
|
|
7140
|
+
"LATEST",
|
|
7141
|
+
"LAST",
|
|
7142
|
+
"LATEST_SCORE",
|
|
7143
|
+
"\u6700\u65B0",
|
|
7144
|
+
"\u6700\u8FD1",
|
|
7145
|
+
"MAX",
|
|
7146
|
+
"MAXIMUM",
|
|
7147
|
+
"HIGHEST",
|
|
7148
|
+
"MAX_SCORE",
|
|
7149
|
+
"\u6700\u9AD8",
|
|
7150
|
+
"\u6700\u9AD8\u5206",
|
|
7151
|
+
"AVERAGE",
|
|
7152
|
+
"AVG",
|
|
7153
|
+
"MEAN",
|
|
7154
|
+
"\u5E73\u5747",
|
|
7155
|
+
"\u5E73\u5747\u5206",
|
|
7156
|
+
"FIRST",
|
|
7157
|
+
"EARLIEST",
|
|
7158
|
+
"\u9996\u4E2A",
|
|
7159
|
+
"\u6700\u65E9"
|
|
7160
|
+
]);
|
|
7161
|
+
function normalizeScorePolicy(value) {
|
|
7162
|
+
const raw = typeof value === "string" ? value.trim().toUpperCase().replace(/[\s-]+/g, "_") : "";
|
|
7163
|
+
if (["MAX", "MAXIMUM", "HIGHEST", "MAX_SCORE", "\u6700\u9AD8", "\u6700\u9AD8\u5206"].includes(raw)) return "MAX";
|
|
7164
|
+
if (["AVERAGE", "AVG", "MEAN", "\u5E73\u5747", "\u5E73\u5747\u5206"].includes(raw)) return "AVERAGE";
|
|
7165
|
+
if (["FIRST", "EARLIEST", "\u9996\u4E2A", "\u6700\u65E9"].includes(raw)) return "FIRST";
|
|
7166
|
+
return "LATEST";
|
|
7167
|
+
}
|
|
7168
|
+
function parseScoreFields(fields) {
|
|
7169
|
+
if (Array.isArray(fields)) {
|
|
7170
|
+
return fields.map((f) => String(f ?? "").trim()).filter(Boolean);
|
|
7171
|
+
}
|
|
7172
|
+
if (typeof fields !== "string" || !fields.trim()) return [];
|
|
7173
|
+
return fields.split(/[\n\r,,;;|]+/).map((f) => f.trim()).filter(Boolean);
|
|
7174
|
+
}
|
|
7175
|
+
function getNested(raw, path24) {
|
|
7176
|
+
if (raw === null || raw === void 0) return void 0;
|
|
7177
|
+
if (!path24) return void 0;
|
|
7178
|
+
const parts = String(path24).split(".").map((p) => p.trim()).filter(Boolean);
|
|
7179
|
+
let cursor = raw;
|
|
7180
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
7181
|
+
if (cursor === null || cursor === void 0) return void 0;
|
|
7182
|
+
const part = parts[i];
|
|
7183
|
+
if (/^\d+$/.test(part)) {
|
|
7184
|
+
const idx = Number(part);
|
|
7185
|
+
cursor = Array.isArray(cursor) ? cursor[idx] : cursor[part];
|
|
7186
|
+
} else {
|
|
7187
|
+
cursor = cursor[part];
|
|
7188
|
+
}
|
|
7189
|
+
if (i < parts.length - 1 && typeof cursor === "string" && /^[[{]/.test(cursor.trim())) {
|
|
7190
|
+
try {
|
|
7191
|
+
cursor = JSON.parse(cursor);
|
|
7192
|
+
} catch (e) {
|
|
7193
|
+
}
|
|
7194
|
+
}
|
|
7195
|
+
}
|
|
7196
|
+
return cursor;
|
|
7197
|
+
}
|
|
7198
|
+
var COMMON_SCORE_KEYS = [
|
|
7199
|
+
"score",
|
|
7200
|
+
"points",
|
|
7201
|
+
"point",
|
|
7202
|
+
"grade",
|
|
7203
|
+
"mark",
|
|
7204
|
+
"correct",
|
|
7205
|
+
"right",
|
|
7206
|
+
"userScore",
|
|
7207
|
+
"totalScore",
|
|
7208
|
+
"finalScore",
|
|
7209
|
+
"rawScore"
|
|
7210
|
+
];
|
|
7211
|
+
var COMMON_SCORE_CONTAINERS = ["data", "body", "payload", "detail", "summary", "progress"];
|
|
7212
|
+
var SCAN_MAX_DEPTH = 4;
|
|
7213
|
+
var SCAN_MAX_NODES = 300;
|
|
7214
|
+
function scanForScore(node, depth, budget) {
|
|
7215
|
+
if (node === null || node === void 0) return null;
|
|
7216
|
+
budget.count += 1;
|
|
7217
|
+
if (budget.count > SCAN_MAX_NODES || depth > SCAN_MAX_DEPTH) return null;
|
|
7218
|
+
if (typeof node === "string") {
|
|
7219
|
+
const trimmed = node.trim();
|
|
7220
|
+
if (!trimmed) return null;
|
|
7221
|
+
if (/^[[{]/.test(trimmed)) {
|
|
7222
|
+
try {
|
|
7223
|
+
return scanForScore(JSON.parse(trimmed), depth + 1, budget);
|
|
7224
|
+
} catch (e) {
|
|
7225
|
+
return null;
|
|
7226
|
+
}
|
|
7227
|
+
}
|
|
7228
|
+
const keyValue = /(?:^|[?&\s,;])(?:score|point|points|grade|mark|correct|right)=([+-]?\d+(?:\.\d+)?)/i.exec(trimmed);
|
|
7229
|
+
if (keyValue) return Number(keyValue[1]);
|
|
7230
|
+
const ratio = /^([+-]?\d+(?:\.\d+)?)\s*\/\s*([+-]?\d+(?:\.\d+)?)$/.exec(trimmed);
|
|
7231
|
+
if (ratio) return Number(ratio[1]);
|
|
7232
|
+
return null;
|
|
7233
|
+
}
|
|
7234
|
+
if (typeof node !== "object") return null;
|
|
7235
|
+
for (const key of COMMON_SCORE_KEYS) {
|
|
7236
|
+
const value = toNumber(node[key]);
|
|
7237
|
+
if (value !== null) return value;
|
|
7238
|
+
}
|
|
7239
|
+
for (const key of Object.keys(node)) {
|
|
7240
|
+
const child = node[key];
|
|
7241
|
+
if (child === null || child === void 0) continue;
|
|
7242
|
+
if (typeof child !== "object" && typeof child !== "string") continue;
|
|
7243
|
+
const value = scanForScore(child, depth + 1, budget);
|
|
7244
|
+
if (value !== null) return value;
|
|
7245
|
+
}
|
|
7246
|
+
return null;
|
|
7247
|
+
}
|
|
7248
|
+
function extractScoreFromFields(raw, fields) {
|
|
7249
|
+
for (const field of parseScoreFields(fields)) {
|
|
7250
|
+
const value = toNumber(getNested(raw, field));
|
|
7251
|
+
if (value !== null) return value;
|
|
7252
|
+
}
|
|
7253
|
+
return null;
|
|
7254
|
+
}
|
|
7255
|
+
function pickScoreFromPayload(payload, fields) {
|
|
7256
|
+
const byFields = extractScoreFromFields(payload, fields);
|
|
7257
|
+
if (byFields !== null) return byFields;
|
|
7258
|
+
if (typeof payload === "string") return scanForScore(payload, 0, { count: 0 });
|
|
7259
|
+
if (payload === null || typeof payload !== "object") return null;
|
|
7260
|
+
for (const key of COMMON_SCORE_KEYS) {
|
|
7261
|
+
const value = toNumber(payload[key]);
|
|
7262
|
+
if (value !== null) return value;
|
|
7263
|
+
}
|
|
7264
|
+
for (const container of COMMON_SCORE_CONTAINERS) {
|
|
7265
|
+
const nested = payload[container];
|
|
7266
|
+
if (nested === null || typeof nested !== "object") continue;
|
|
7267
|
+
for (const key of COMMON_SCORE_KEYS) {
|
|
7268
|
+
const value = toNumber(nested[key]);
|
|
7269
|
+
if (value !== null) return value;
|
|
7270
|
+
}
|
|
7271
|
+
}
|
|
7272
|
+
return scanForScore(payload, 0, { count: 0 });
|
|
7273
|
+
}
|
|
7274
|
+
function aggregateScores(scores, policy) {
|
|
7275
|
+
const list = (Array.isArray(scores) ? scores : []).map((s) => toNumber(s)).filter((n) => n !== null);
|
|
7276
|
+
if (list.length === 0) return null;
|
|
7277
|
+
const resolved = normalizeScorePolicy(policy);
|
|
7278
|
+
if (resolved === "MAX") return list.reduce((a, b) => b > a ? b : a, list[0]);
|
|
7279
|
+
if (resolved === "AVERAGE") return list.reduce((a, b) => a + b, 0) / list.length;
|
|
7280
|
+
if (resolved === "FIRST") return list[0];
|
|
7281
|
+
return list[list.length - 1];
|
|
7282
|
+
}
|
|
7283
|
+
function normalizeScoreConfig(row) {
|
|
7284
|
+
if (!row || typeof row !== "object") return { ...BUILTIN_SCORE_CONFIG };
|
|
7285
|
+
const positive = (value, fallback) => {
|
|
7286
|
+
const num = toNumber(value);
|
|
7287
|
+
return num !== null && num > 0 ? num : fallback;
|
|
7288
|
+
};
|
|
7289
|
+
const weight = toNumber(row.weight_percentage);
|
|
7290
|
+
const updatedAt = toNumber(row.updated_at);
|
|
7291
|
+
return {
|
|
7292
|
+
courseware_id: String(row.courseware_id ?? GLOBAL_SCORE_CONFIG_KEY),
|
|
7293
|
+
courseware_name: row.courseware_name === void 0 || row.courseware_name === null ? null : String(row.courseware_name),
|
|
7294
|
+
score_policy: normalizeScorePolicy(row.score_policy),
|
|
7295
|
+
score_fields: typeof row.score_fields === "string" ? row.score_fields : "",
|
|
7296
|
+
raw_full_score: positive(row.raw_full_score, 100),
|
|
7297
|
+
target_full_score: positive(row.target_full_score, 100),
|
|
7298
|
+
weight_percentage: weight === null ? 100 : clamp(weight, 0, 1e3),
|
|
7299
|
+
lesson_id: row.lesson_id === void 0 || row.lesson_id === null ? null : String(row.lesson_id),
|
|
7300
|
+
updated_at: updatedAt ?? 0
|
|
7301
|
+
};
|
|
7302
|
+
}
|
|
7303
|
+
function resolveScoreConfig(db2, coursewareId) {
|
|
7304
|
+
const id = coursewareId === null || coursewareId === void 0 ? "" : String(coursewareId).trim();
|
|
7305
|
+
if (id && id !== GLOBAL_SCORE_CONFIG_KEY) {
|
|
7306
|
+
try {
|
|
7307
|
+
const row = db2.prepare(`SELECT * FROM ${SCORE_CONFIG_TABLE} WHERE courseware_id = ?`).get(id);
|
|
7308
|
+
if (row) return { config: normalizeScoreConfig(row), source: "courseware" };
|
|
7309
|
+
} catch (e) {
|
|
7310
|
+
}
|
|
7311
|
+
}
|
|
7312
|
+
try {
|
|
7313
|
+
const global = db2.prepare(`SELECT * FROM ${SCORE_CONFIG_TABLE} WHERE courseware_id = ?`).get(GLOBAL_SCORE_CONFIG_KEY);
|
|
7314
|
+
if (global) return { config: normalizeScoreConfig(global), source: "global" };
|
|
7315
|
+
} catch (e) {
|
|
7316
|
+
}
|
|
7317
|
+
return { config: { ...BUILTIN_SCORE_CONFIG }, source: "builtin" };
|
|
7318
|
+
}
|
|
7319
|
+
function listScoreConfigs(db2) {
|
|
7320
|
+
try {
|
|
7321
|
+
const rows = db2.prepare(
|
|
7322
|
+
`SELECT * FROM ${SCORE_CONFIG_TABLE} ORDER BY CASE WHEN courseware_id = '${GLOBAL_SCORE_CONFIG_KEY}' THEN 0 ELSE 1 END, updated_at DESC`
|
|
7323
|
+
).all();
|
|
7324
|
+
return (rows || []).map(normalizeScoreConfig);
|
|
7325
|
+
} catch (e) {
|
|
7326
|
+
return [];
|
|
7327
|
+
}
|
|
7328
|
+
}
|
|
7329
|
+
function pickInput(input, camel, snake) {
|
|
7330
|
+
if (input[camel] !== void 0) return input[camel];
|
|
7331
|
+
if (input[snake] !== void 0) return input[snake];
|
|
7332
|
+
return void 0;
|
|
7333
|
+
}
|
|
7334
|
+
function saveScoreConfig(db2, input) {
|
|
7335
|
+
if (!input || typeof input !== "object") throw new Error("save_score_config: invalid payload");
|
|
7336
|
+
const coursewareId = String(
|
|
7337
|
+
pickInput(input, "coursewareId", "courseware_id") ?? ""
|
|
7338
|
+
).trim();
|
|
7339
|
+
if (!coursewareId) throw new Error("save_score_config: coursewareId is required");
|
|
7340
|
+
if (coursewareId.length > 128) throw new Error("save_score_config: coursewareId is too long");
|
|
7341
|
+
const policyRaw = pickInput(input, "scorePolicy", "score_policy");
|
|
7342
|
+
if (policyRaw !== void 0 && policyRaw !== null && String(policyRaw).trim() !== "" && !isKnownScorePolicy(String(policyRaw))) {
|
|
7343
|
+
throw new Error(
|
|
7344
|
+
`save_score_config: unknown scorePolicy "${policyRaw}" (expected one of LATEST / MAX / AVERAGE / FIRST)`
|
|
7345
|
+
);
|
|
7346
|
+
}
|
|
7347
|
+
const fieldsRaw = pickInput(input, "scoreFields", "score_fields");
|
|
7348
|
+
const fields = Array.isArray(fieldsRaw) ? parseScoreFields(fieldsRaw).join(",") : parseScoreFields(fieldsRaw ?? "").join(",");
|
|
7349
|
+
if (fields.length > 2e3) throw new Error("save_score_config: scoreFields is too long");
|
|
7350
|
+
const nameRaw = pickInput(input, "coursewareName", "courseware_name");
|
|
7351
|
+
const lessonRaw = pickInput(input, "lessonId", "lesson_id");
|
|
7352
|
+
let existing = null;
|
|
7353
|
+
try {
|
|
7354
|
+
const row = db2.prepare(`SELECT * FROM ${SCORE_CONFIG_TABLE} WHERE courseware_id = ?`).get(coursewareId);
|
|
7355
|
+
if (row) existing = normalizeScoreConfig(row);
|
|
7356
|
+
} catch (e) {
|
|
7357
|
+
throw new Error("save_score_config: courseware_score_config table is missing (migration 004 not applied)");
|
|
7358
|
+
}
|
|
7359
|
+
const rawFull = toNumber(pickInput(input, "rawFullScore", "raw_full_score"));
|
|
7360
|
+
const targetFull = toNumber(pickInput(input, "targetFullScore", "target_full_score"));
|
|
7361
|
+
const weight = toNumber(pickInput(input, "weightPercentage", "weight_percentage"));
|
|
7362
|
+
if (rawFull !== void 0 && rawFull !== null && rawFull <= 0) throw new Error("save_score_config: rawFullScore must be > 0");
|
|
7363
|
+
if (targetFull !== void 0 && targetFull !== null && targetFull <= 0)
|
|
7364
|
+
throw new Error("save_score_config: targetFullScore must be > 0");
|
|
7365
|
+
if (weight !== void 0 && weight !== null && (weight < 0 || weight > 1e3))
|
|
7366
|
+
throw new Error("save_score_config: weightPercentage must be within [0, 1000]");
|
|
7367
|
+
const next = {
|
|
7368
|
+
courseware_id: coursewareId,
|
|
7369
|
+
courseware_name: nameRaw === void 0 ? existing?.courseware_name ?? (coursewareId === GLOBAL_SCORE_CONFIG_KEY ? "\u5168\u5C40\u9ED8\u8BA4\u7B56\u7565" : null) : nameRaw === null ? null : String(nameRaw).slice(0, 200),
|
|
7370
|
+
score_policy: policyRaw === void 0 || policyRaw === null || String(policyRaw).trim() === "" ? existing?.score_policy ?? DEFAULT_SCORE_POLICY : normalizeScorePolicy(policyRaw),
|
|
7371
|
+
score_fields: fields || (fieldsRaw === void 0 ? existing?.score_fields ?? "" : ""),
|
|
7372
|
+
raw_full_score: rawFull ?? existing?.raw_full_score ?? 100,
|
|
7373
|
+
target_full_score: targetFull ?? existing?.target_full_score ?? 100,
|
|
7374
|
+
weight_percentage: weight ?? existing?.weight_percentage ?? 100,
|
|
7375
|
+
lesson_id: lessonRaw === void 0 ? existing?.lesson_id ?? null : lessonRaw === null ? null : String(lessonRaw).slice(0, 128),
|
|
7376
|
+
updated_at: Date.now()
|
|
7377
|
+
};
|
|
7378
|
+
db2.prepare(
|
|
7379
|
+
`INSERT INTO ${SCORE_CONFIG_TABLE}
|
|
7380
|
+
(courseware_id, courseware_name, score_policy, score_fields, raw_full_score, target_full_score, weight_percentage, lesson_id, updated_at)
|
|
7381
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
7382
|
+
ON CONFLICT(courseware_id) DO UPDATE SET
|
|
7383
|
+
courseware_name = excluded.courseware_name,
|
|
7384
|
+
score_policy = excluded.score_policy,
|
|
7385
|
+
score_fields = excluded.score_fields,
|
|
7386
|
+
raw_full_score = excluded.raw_full_score,
|
|
7387
|
+
target_full_score = excluded.target_full_score,
|
|
7388
|
+
weight_percentage = excluded.weight_percentage,
|
|
7389
|
+
lesson_id = excluded.lesson_id,
|
|
7390
|
+
updated_at = excluded.updated_at`
|
|
7391
|
+
).run(
|
|
7392
|
+
next.courseware_id,
|
|
7393
|
+
next.courseware_name,
|
|
7394
|
+
next.score_policy,
|
|
7395
|
+
next.score_fields,
|
|
7396
|
+
next.raw_full_score,
|
|
7397
|
+
next.target_full_score,
|
|
7398
|
+
next.weight_percentage,
|
|
7399
|
+
next.lesson_id,
|
|
7400
|
+
next.updated_at
|
|
7401
|
+
);
|
|
7402
|
+
return next;
|
|
7403
|
+
}
|
|
7404
|
+
function resolveCoursewareIdFromAttempt(db2, attemptId) {
|
|
7405
|
+
if (!attemptId) return null;
|
|
7406
|
+
try {
|
|
7407
|
+
const row = db2.prepare("SELECT courseware_id FROM courseware_attempt WHERE id = ?").get(attemptId);
|
|
7408
|
+
return row && row.courseware_id ? String(row.courseware_id) : null;
|
|
7409
|
+
} catch (e) {
|
|
7410
|
+
return null;
|
|
7411
|
+
}
|
|
7412
|
+
}
|
|
7413
|
+
function collectScoreSamples(db2, attemptId, fields) {
|
|
7414
|
+
if (!attemptId) return [];
|
|
7415
|
+
let rows = [];
|
|
7416
|
+
try {
|
|
7417
|
+
rows = db2.prepare("SELECT event_type, payload_json, created_at FROM submission_raw WHERE attempt_id = ? ORDER BY created_at ASC").all(attemptId) || [];
|
|
7418
|
+
} catch (e) {
|
|
7419
|
+
return [];
|
|
7420
|
+
}
|
|
7421
|
+
const samples = [];
|
|
7422
|
+
for (const row of rows) {
|
|
7423
|
+
let payload = null;
|
|
7424
|
+
try {
|
|
7425
|
+
payload = JSON.parse(row?.payload_json ?? "null");
|
|
7426
|
+
} catch (e) {
|
|
7427
|
+
payload = null;
|
|
7428
|
+
}
|
|
7429
|
+
if (payload === null || payload === void 0) continue;
|
|
7430
|
+
const value = pickScoreFromPayload(payload, fields);
|
|
7431
|
+
if (value === null) continue;
|
|
7432
|
+
samples.push({
|
|
7433
|
+
value,
|
|
7434
|
+
at: toNumber(row?.created_at) ?? 0,
|
|
7435
|
+
eventType: String(row?.event_type ?? "")
|
|
7436
|
+
});
|
|
7437
|
+
}
|
|
7438
|
+
return samples;
|
|
7439
|
+
}
|
|
7440
|
+
function aggregateAttemptScore(db2, attemptId, options = {}) {
|
|
7441
|
+
const coursewareId = options.coursewareId === void 0 ? resolveCoursewareIdFromAttempt(db2, attemptId) : options.coursewareId;
|
|
7442
|
+
const resolved = resolveScoreConfig(db2, coursewareId);
|
|
7443
|
+
const hasExplicitConfig = resolved.source !== "builtin";
|
|
7444
|
+
const policyOverride = options.policy;
|
|
7445
|
+
const policy = policyOverride === void 0 || policyOverride === null || String(policyOverride).trim() === "" ? resolved.config.score_policy : normalizeScorePolicy(policyOverride);
|
|
7446
|
+
const scoreFields = options.scoreFields === void 0 ? resolved.config.score_fields : options.scoreFields;
|
|
7447
|
+
const rawFull = toNumber(options.rawFullScore);
|
|
7448
|
+
const targetFull = toNumber(options.targetFullScore);
|
|
7449
|
+
const weight = toNumber(options.weightPercentage);
|
|
7450
|
+
const config = {
|
|
7451
|
+
...resolved.config,
|
|
7452
|
+
score_policy: policy,
|
|
7453
|
+
score_fields: typeof scoreFields === "string" ? scoreFields : parseScoreFields(scoreFields).join(","),
|
|
7454
|
+
raw_full_score: rawFull !== null && rawFull > 0 ? rawFull : resolved.config.raw_full_score,
|
|
7455
|
+
target_full_score: targetFull !== null && targetFull > 0 ? targetFull : resolved.config.target_full_score,
|
|
7456
|
+
weight_percentage: weight !== null ? clamp(weight, 0, 1e3) : resolved.config.weight_percentage
|
|
7457
|
+
};
|
|
7458
|
+
const sampleDetails = collectScoreSamples(db2, attemptId, scoreFields);
|
|
7459
|
+
const samples = sampleDetails.map((s) => s.value);
|
|
7460
|
+
const rawAggregate = aggregateScores(samples, policy);
|
|
7461
|
+
let normalized = null;
|
|
7462
|
+
if (rawAggregate !== null) {
|
|
7463
|
+
if (!hasExplicitConfig) {
|
|
7464
|
+
normalized = rawAggregate;
|
|
7465
|
+
} else {
|
|
7466
|
+
const scaled = rawAggregate / config.raw_full_score * config.target_full_score;
|
|
7467
|
+
normalized = round2(clamp(scaled, 0, config.target_full_score));
|
|
7468
|
+
}
|
|
7469
|
+
}
|
|
7470
|
+
return {
|
|
7471
|
+
policy,
|
|
7472
|
+
config,
|
|
7473
|
+
hasExplicitConfig,
|
|
7474
|
+
samples,
|
|
7475
|
+
sampleDetails,
|
|
7476
|
+
rawAggregate,
|
|
7477
|
+
normalized,
|
|
7478
|
+
finalScore: normalized,
|
|
7479
|
+
weightPercentage: config.weight_percentage
|
|
7480
|
+
};
|
|
7481
|
+
}
|
|
7482
|
+
function describeAggregation(aggregation) {
|
|
7483
|
+
return {
|
|
7484
|
+
policy: aggregation.policy,
|
|
7485
|
+
sample_count: aggregation.samples.length,
|
|
7486
|
+
samples: aggregation.samples.slice(-50),
|
|
7487
|
+
raw_aggregate: aggregation.rawAggregate,
|
|
7488
|
+
normalized: aggregation.normalized,
|
|
7489
|
+
raw_full_score: aggregation.config.raw_full_score,
|
|
7490
|
+
target_full_score: aggregation.config.target_full_score,
|
|
7491
|
+
weight_percentage: aggregation.weightPercentage,
|
|
7492
|
+
configured: aggregation.hasExplicitConfig,
|
|
7493
|
+
aggregated_at: Date.now()
|
|
7494
|
+
};
|
|
7495
|
+
}
|
|
7496
|
+
|
|
6609
7497
|
// packages/plugins/builtin.ts
|
|
7498
|
+
var nativeRuntimeScriptRegistry = null;
|
|
6610
7499
|
function copyFolderSync(src, dest) {
|
|
6611
7500
|
if (import_fs3.default.cpSync) {
|
|
6612
7501
|
import_fs3.default.cpSync(src, dest, { recursive: true });
|
|
@@ -6647,6 +7536,21 @@ var BuiltinPlugin = {
|
|
|
6647
7536
|
const pluginHost = await ctx.resolve(IPluginHostToken);
|
|
6648
7537
|
const lifecycleManager = await ctx.resolve(IPluginLifecycleManagerToken);
|
|
6649
7538
|
const distributionManager = await ctx.resolve(IPluginDistributionManagerToken);
|
|
7539
|
+
try {
|
|
7540
|
+
const runtimeScriptRegistry = await ctx.resolve(ICoursewareRuntimeScriptRegistryToken);
|
|
7541
|
+
if (runtimeScriptRegistry && typeof runtimeScriptRegistry.register === "function") {
|
|
7542
|
+
runtimeScriptRegistry.register(SCORE_MONITOR_SCRIPT_OWNER, {
|
|
7543
|
+
id: SCORE_MONITOR_SCRIPT_ID,
|
|
7544
|
+
description: "\u5E73\u53F0\u539F\u751F\u5206\u6570\u53D8\u91CF\u76D1\u89C6\u5668\uFF1A\u91C7\u96C6\u8BFE\u4EF6\u5185\u4EE3\u8868\u5206\u6570\u7684\u53D8\u91CF\u5E76\u6301\u7EED\u4E0A\u62A5\u4E3A\u6210\u7EE9\u6837\u672C",
|
|
7545
|
+
source: SCORE_MONITOR_SCRIPT,
|
|
7546
|
+
position: "body-end",
|
|
7547
|
+
priority: 200
|
|
7548
|
+
});
|
|
7549
|
+
nativeRuntimeScriptRegistry = runtimeScriptRegistry;
|
|
7550
|
+
}
|
|
7551
|
+
} catch (err) {
|
|
7552
|
+
console.warn("[builtin] \u6CE8\u518C\u539F\u751F\u5206\u6570\u53D8\u91CF\u76D1\u89C6\u5668\u5931\u8D25\uFF08\u5DF2\u5FFD\u7565\uFF09:", err.message);
|
|
7553
|
+
}
|
|
6650
7554
|
const createLessonCmdType = "lesson.create";
|
|
6651
7555
|
await actionRegistry.register({
|
|
6652
7556
|
id: "core-lesson-create",
|
|
@@ -7975,6 +8879,17 @@ var BuiltinPlugin = {
|
|
|
7975
8879
|
attemptId
|
|
7976
8880
|
);
|
|
7977
8881
|
}
|
|
8882
|
+
let aggregation = null;
|
|
8883
|
+
try {
|
|
8884
|
+
const attemptRow = db2.prepare("SELECT courseware_id FROM courseware_attempt WHERE id = ?").get(attemptId);
|
|
8885
|
+
aggregation = aggregateAttemptScore(db2, attemptId, {
|
|
8886
|
+
coursewareId: attemptRow?.courseware_id ?? null
|
|
8887
|
+
});
|
|
8888
|
+
} catch (aggErr) {
|
|
8889
|
+
console.warn("[courseware.submit_attempt] score aggregation failed, fallback to latest score:", aggErr);
|
|
8890
|
+
}
|
|
8891
|
+
const aggregatedScore = aggregation && aggregation.finalScore !== null ? aggregation.finalScore : null;
|
|
8892
|
+
const aggregationExtra = aggregation ? { score_aggregation: describeAggregation(aggregation) } : {};
|
|
7978
8893
|
const existing = db2.prepare("SELECT * FROM submission_result WHERE attempt_id = ?").get(attemptId);
|
|
7979
8894
|
if (!existing) {
|
|
7980
8895
|
db2.prepare(
|
|
@@ -7982,13 +8897,13 @@ var BuiltinPlugin = {
|
|
|
7982
8897
|
).run(
|
|
7983
8898
|
"res_" + import_crypto3.default.randomBytes(8).toString("hex"),
|
|
7984
8899
|
attemptId,
|
|
7985
|
-
score !== void 0 ? score : null,
|
|
8900
|
+
aggregatedScore !== null ? aggregatedScore : score !== void 0 ? score : null,
|
|
7986
8901
|
comment || null,
|
|
7987
8902
|
completion !== void 0 ? completion : null,
|
|
7988
|
-
JSON.stringify(extra)
|
|
8903
|
+
JSON.stringify({ ...extra, ...aggregationExtra })
|
|
7989
8904
|
);
|
|
7990
8905
|
} else {
|
|
7991
|
-
const finalScore = score !== void 0 ? score : existing.score;
|
|
8906
|
+
const finalScore = aggregatedScore !== null ? aggregatedScore : score !== void 0 ? score : existing.score;
|
|
7992
8907
|
const finalComment = comment || existing.comment;
|
|
7993
8908
|
const finalCompletion = completion !== void 0 ? completion : existing.completion;
|
|
7994
8909
|
let mergedExtra = {};
|
|
@@ -7996,7 +8911,7 @@ var BuiltinPlugin = {
|
|
|
7996
8911
|
mergedExtra = JSON.parse(existing.extra_json || "{}");
|
|
7997
8912
|
} catch (e) {
|
|
7998
8913
|
}
|
|
7999
|
-
mergedExtra = { ...mergedExtra, ...extra };
|
|
8914
|
+
mergedExtra = { ...mergedExtra, ...extra, ...aggregationExtra };
|
|
8000
8915
|
db2.prepare(
|
|
8001
8916
|
"UPDATE submission_result SET score = ?, comment = ?, completion = ?, extra_json = ? WHERE attempt_id = ?"
|
|
8002
8917
|
).run(finalScore, finalComment, finalCompletion, JSON.stringify(mergedExtra), attemptId);
|
|
@@ -8012,31 +8927,158 @@ var BuiltinPlugin = {
|
|
|
8012
8927
|
return { success: true };
|
|
8013
8928
|
}
|
|
8014
8929
|
});
|
|
8015
|
-
const
|
|
8930
|
+
const getScoreConfigCmdType = "courseware.get_score_config";
|
|
8016
8931
|
await actionRegistry.register({
|
|
8017
|
-
id: "core-courseware-get-
|
|
8018
|
-
commandType:
|
|
8019
|
-
description: "
|
|
8932
|
+
id: "core-courseware-get-score-config",
|
|
8933
|
+
commandType: getScoreConfigCmdType,
|
|
8934
|
+
description: "\u8BFB\u53D6\u4E92\u52A8\u8BFE\u4EF6\u7684\u6210\u7EE9\u5F52\u96C6\u914D\u7F6E\uFF08\u7B56\u7565 / \u5B57\u6BB5 / \u6EE1\u5206 / \u6743\u91CD\uFF09",
|
|
8020
8935
|
capabilityRequired: "lesson:read",
|
|
8021
8936
|
inputSchema: {
|
|
8022
8937
|
type: "OBJECT",
|
|
8023
8938
|
properties: {
|
|
8024
|
-
|
|
8025
|
-
}
|
|
8026
|
-
required: ["attemptId"]
|
|
8939
|
+
coursewareId: { type: "STRING", description: "\u8BFE\u4EF6 ID\uFF08\u7701\u7565\u6216\u4F20 '*' \u65F6\u8BFB\u53D6\u5168\u5C40\u9ED8\u8BA4\u914D\u7F6E\uFF09" }
|
|
8940
|
+
}
|
|
8027
8941
|
}
|
|
8028
8942
|
});
|
|
8029
|
-
await commandBus.registerHandler(
|
|
8943
|
+
await commandBus.registerHandler(getScoreConfigCmdType, {
|
|
8030
8944
|
async execute(command) {
|
|
8031
|
-
const payload = command.payload;
|
|
8032
|
-
const
|
|
8033
|
-
|
|
8034
|
-
return rows;
|
|
8945
|
+
const payload = command.payload || {};
|
|
8946
|
+
const resolved = resolveScoreConfig(db2, payload.coursewareId ?? payload.courseware_id ?? null);
|
|
8947
|
+
return { success: true, source: resolved.source, ...resolved.config };
|
|
8035
8948
|
}
|
|
8036
8949
|
});
|
|
8037
|
-
const
|
|
8950
|
+
const saveScoreConfigCmdType = "courseware.save_score_config";
|
|
8038
8951
|
await actionRegistry.register({
|
|
8039
|
-
id: "core-courseware-
|
|
8952
|
+
id: "core-courseware-save-score-config",
|
|
8953
|
+
commandType: saveScoreConfigCmdType,
|
|
8954
|
+
description: "\u4FDD\u5B58\u4E92\u52A8\u8BFE\u4EF6\u7684\u6210\u7EE9\u5F52\u96C6\u914D\u7F6E",
|
|
8955
|
+
capabilityRequired: "lesson:write",
|
|
8956
|
+
inputSchema: {
|
|
8957
|
+
type: "OBJECT",
|
|
8958
|
+
properties: {
|
|
8959
|
+
coursewareId: { type: "STRING", description: "\u8BFE\u4EF6 ID\uFF08\u4F20 '*' \u8868\u793A\u5168\u5C40\u9ED8\u8BA4\u7B56\u7565\uFF09" },
|
|
8960
|
+
coursewareName: { type: "STRING", description: "\u8BFE\u4EF6\u540D\u79F0\uFF08\u4FBF\u4E8E\u8BC6\u522B\uFF09" },
|
|
8961
|
+
scorePolicy: { type: "STRING", description: "LATEST | MAX | AVERAGE | FIRST" },
|
|
8962
|
+
scoreFields: { type: "STRING", description: "\u5206\u6570\u6240\u5728\u5B57\u6BB5\u8DEF\u5F84\uFF0C\u9017\u53F7\u5206\u9694\uFF0C\u5982 score,data.points" },
|
|
8963
|
+
rawFullScore: { type: "NUMBER", description: "\u8BFE\u4EF6\u539F\u59CB\u6EE1\u5206" },
|
|
8964
|
+
targetFullScore: { type: "NUMBER", description: "\u5F52\u96C6\u540E\u7684\u76EE\u6807\u6EE1\u5206" },
|
|
8965
|
+
weightPercentage: { type: "NUMBER", description: "\u8BE5\u8BFE\u4EF6\u5728\u603B\u8BC4\u4E2D\u7684\u6743\u91CD\uFF08\u767E\u5206\u6BD4\uFF09" },
|
|
8966
|
+
lessonId: { type: "STRING", description: "\u5173\u8054\u8BFE\u65F6 ID\uFF08\u53EF\u9009\uFF09" }
|
|
8967
|
+
},
|
|
8968
|
+
required: ["coursewareId"]
|
|
8969
|
+
}
|
|
8970
|
+
});
|
|
8971
|
+
await commandBus.registerHandler(saveScoreConfigCmdType, {
|
|
8972
|
+
async execute(command) {
|
|
8973
|
+
const saved = saveScoreConfig(db2, command.payload || {});
|
|
8974
|
+
await eventBus.publish({
|
|
8975
|
+
id: (0, import_uuid6.v7)(),
|
|
8976
|
+
type: "courseware.score_config_saved",
|
|
8977
|
+
source: "builtin.courseware",
|
|
8978
|
+
payload: { ...saved },
|
|
8979
|
+
timestamp: Date.now(),
|
|
8980
|
+
correlationId: command.id
|
|
8981
|
+
});
|
|
8982
|
+
return { success: true, ...saved };
|
|
8983
|
+
}
|
|
8984
|
+
});
|
|
8985
|
+
const listScoreConfigsCmdType = "courseware.list_score_configs";
|
|
8986
|
+
await actionRegistry.register({
|
|
8987
|
+
id: "core-courseware-list-score-configs",
|
|
8988
|
+
commandType: listScoreConfigsCmdType,
|
|
8989
|
+
description: "\u5217\u51FA\u6240\u6709\u5DF2\u914D\u7F6E\u7684\u8BFE\u4EF6\u6210\u7EE9\u5F52\u96C6\u7B56\u7565",
|
|
8990
|
+
capabilityRequired: "lesson:read",
|
|
8991
|
+
inputSchema: { type: "OBJECT", properties: {} }
|
|
8992
|
+
});
|
|
8993
|
+
await commandBus.registerHandler(listScoreConfigsCmdType, {
|
|
8994
|
+
async execute() {
|
|
8995
|
+
return { success: true, configs: listScoreConfigs(db2) };
|
|
8996
|
+
}
|
|
8997
|
+
});
|
|
8998
|
+
const regradeCmdType = "courseware.regrade_attempts";
|
|
8999
|
+
await actionRegistry.register({
|
|
9000
|
+
id: "core-courseware-regrade-attempts",
|
|
9001
|
+
commandType: regradeCmdType,
|
|
9002
|
+
description: "\u6309\u5F53\u524D\u6210\u7EE9\u5F52\u96C6\u7B56\u7565\u91CD\u7B97\u65E2\u6709 attempt \u7684\u5B98\u65B9\u6210\u7EE9\uFF08\u6539\u914D\u7F6E\u540E\u56DE\u586B\uFF09",
|
|
9003
|
+
capabilityRequired: "lesson:write",
|
|
9004
|
+
inputSchema: {
|
|
9005
|
+
type: "OBJECT",
|
|
9006
|
+
properties: {
|
|
9007
|
+
attemptId: { type: "STRING", description: "\u5355\u4E2A attempt ID\uFF08\u4E0E coursewareId \u4E8C\u9009\u4E00\uFF09" },
|
|
9008
|
+
coursewareId: { type: "STRING", description: "\u6309\u8BFE\u4EF6\u6279\u91CF\u91CD\u7B97\uFF08\u6700\u591A 2000 \u4E2A attempt\uFF09" },
|
|
9009
|
+
limit: { type: "NUMBER", description: "\u6279\u91CF\u91CD\u7B97\u4E0A\u9650\uFF0C\u9ED8\u8BA4 500" }
|
|
9010
|
+
}
|
|
9011
|
+
}
|
|
9012
|
+
});
|
|
9013
|
+
await commandBus.registerHandler(regradeCmdType, {
|
|
9014
|
+
async execute(command) {
|
|
9015
|
+
const payload = command.payload || {};
|
|
9016
|
+
const attemptId = typeof payload.attemptId === "string" ? payload.attemptId.trim() : "";
|
|
9017
|
+
const coursewareId = typeof payload.coursewareId === "string" ? payload.coursewareId.trim() : "";
|
|
9018
|
+
if (!attemptId && !coursewareId) {
|
|
9019
|
+
throw new Error("courseware.regrade_attempts: attemptId or coursewareId is required");
|
|
9020
|
+
}
|
|
9021
|
+
const requestedLimit = Number(payload.limit);
|
|
9022
|
+
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, 2e3) : 500;
|
|
9023
|
+
let attemptIds = [];
|
|
9024
|
+
if (attemptId) {
|
|
9025
|
+
attemptIds = [attemptId];
|
|
9026
|
+
} else {
|
|
9027
|
+
const rows = db2.prepare("SELECT id FROM courseware_attempt WHERE courseware_id = ? ORDER BY started_at DESC LIMIT ?").all(coursewareId, limit);
|
|
9028
|
+
attemptIds = (rows || []).map((r) => String(r.id));
|
|
9029
|
+
}
|
|
9030
|
+
const regraded = [];
|
|
9031
|
+
let failed = 0;
|
|
9032
|
+
for (const id of attemptIds) {
|
|
9033
|
+
try {
|
|
9034
|
+
const agg = aggregateAttemptScore(db2, id, { coursewareId: coursewareId || null });
|
|
9035
|
+
if (agg.finalScore === null) continue;
|
|
9036
|
+
const current = db2.prepare("SELECT * FROM submission_result WHERE attempt_id = ?").get(id);
|
|
9037
|
+
if (!current) continue;
|
|
9038
|
+
let mergedExtra = {};
|
|
9039
|
+
try {
|
|
9040
|
+
mergedExtra = JSON.parse(current.extra_json || "{}");
|
|
9041
|
+
} catch (e) {
|
|
9042
|
+
}
|
|
9043
|
+
mergedExtra = { ...mergedExtra, score_aggregation: describeAggregation(agg) };
|
|
9044
|
+
db2.prepare("UPDATE submission_result SET score = ?, extra_json = ? WHERE attempt_id = ?").run(
|
|
9045
|
+
agg.finalScore,
|
|
9046
|
+
JSON.stringify(mergedExtra),
|
|
9047
|
+
id
|
|
9048
|
+
);
|
|
9049
|
+
regraded.push({ attemptId: id, score: agg.finalScore, samples: agg.samples.length, policy: agg.policy });
|
|
9050
|
+
} catch (e) {
|
|
9051
|
+
failed += 1;
|
|
9052
|
+
}
|
|
9053
|
+
}
|
|
9054
|
+
return { success: true, scanned: attemptIds.length, regraded: regraded.length, failed, results: regraded };
|
|
9055
|
+
}
|
|
9056
|
+
});
|
|
9057
|
+
const getAttemptRawDataCmdType = "courseware.get_attempt_raw_data";
|
|
9058
|
+
await actionRegistry.register({
|
|
9059
|
+
id: "core-courseware-get-attempt-raw-data",
|
|
9060
|
+
commandType: getAttemptRawDataCmdType,
|
|
9061
|
+
description: "Get all captured raw events/submits for a student attempt",
|
|
9062
|
+
capabilityRequired: "lesson:read",
|
|
9063
|
+
inputSchema: {
|
|
9064
|
+
type: "OBJECT",
|
|
9065
|
+
properties: {
|
|
9066
|
+
attemptId: { type: "STRING", description: "ID of the attempt to query" }
|
|
9067
|
+
},
|
|
9068
|
+
required: ["attemptId"]
|
|
9069
|
+
}
|
|
9070
|
+
});
|
|
9071
|
+
await commandBus.registerHandler(getAttemptRawDataCmdType, {
|
|
9072
|
+
async execute(command) {
|
|
9073
|
+
const payload = command.payload;
|
|
9074
|
+
const { attemptId } = payload;
|
|
9075
|
+
const rows = db2.prepare("SELECT * FROM submission_raw WHERE attempt_id = ? ORDER BY created_at ASC").all(attemptId);
|
|
9076
|
+
return rows;
|
|
9077
|
+
}
|
|
9078
|
+
});
|
|
9079
|
+
const listCoursewareCmdType = "courseware.list";
|
|
9080
|
+
await actionRegistry.register({
|
|
9081
|
+
id: "core-courseware-list",
|
|
8040
9082
|
commandType: listCoursewareCmdType,
|
|
8041
9083
|
description: "List all uploaded interactive coursewares",
|
|
8042
9084
|
capabilityRequired: "lesson:read",
|
|
@@ -8090,6 +9132,14 @@ var BuiltinPlugin = {
|
|
|
8090
9132
|
});
|
|
8091
9133
|
},
|
|
8092
9134
|
deactivate: async () => {
|
|
9135
|
+
try {
|
|
9136
|
+
if (nativeRuntimeScriptRegistry && typeof nativeRuntimeScriptRegistry.clear === "function") {
|
|
9137
|
+
nativeRuntimeScriptRegistry.clear(SCORE_MONITOR_SCRIPT_OWNER);
|
|
9138
|
+
nativeRuntimeScriptRegistry = null;
|
|
9139
|
+
}
|
|
9140
|
+
} catch (err) {
|
|
9141
|
+
console.warn("[builtin] \u56DE\u6536\u539F\u751F\u5206\u6570\u53D8\u91CF\u76D1\u89C6\u5668\u5931\u8D25\uFF08\u5DF2\u5FFD\u7565\uFF09:", err.message);
|
|
9142
|
+
}
|
|
8093
9143
|
}
|
|
8094
9144
|
};
|
|
8095
9145
|
|
|
@@ -8295,6 +9345,27 @@ var AiPlannerPlugin = {
|
|
|
8295
9345
|
// packages/plugins/assignment-eval.ts
|
|
8296
9346
|
var import_uuid8 = require("uuid");
|
|
8297
9347
|
var import_plugin_sdk6 = require("@openlearn/plugin-sdk");
|
|
9348
|
+
var PRIVILEGED_ROLES = ["teacher", "administrator", "admin"];
|
|
9349
|
+
function parseActorId(actorId) {
|
|
9350
|
+
const match = /^user:([^:]+):(.+)$/.exec(String(actorId || ""));
|
|
9351
|
+
if (!match) return null;
|
|
9352
|
+
return { userId: match[1], role: match[2] };
|
|
9353
|
+
}
|
|
9354
|
+
function isPrivilegedActor(actorId) {
|
|
9355
|
+
const actor = parseActorId(actorId);
|
|
9356
|
+
if (!actor) return true;
|
|
9357
|
+
return PRIVILEGED_ROLES.includes(actor.role);
|
|
9358
|
+
}
|
|
9359
|
+
function actorUserId(actorId) {
|
|
9360
|
+
return parseActorId(actorId)?.userId ?? actorId ?? null;
|
|
9361
|
+
}
|
|
9362
|
+
function assertStudentOwnership(actorId, studentId, action) {
|
|
9363
|
+
if (isPrivilegedActor(actorId)) return;
|
|
9364
|
+
const actor = parseActorId(actorId);
|
|
9365
|
+
if (!actor || actor.userId !== studentId) {
|
|
9366
|
+
throw new Error(`Access Denied: Students can only ${action} their own assignment`);
|
|
9367
|
+
}
|
|
9368
|
+
}
|
|
8298
9369
|
var AssignmentEvalPlugin = {
|
|
8299
9370
|
manifest: {
|
|
8300
9371
|
id: "@openlearn/plugin-assignment-eval",
|
|
@@ -8305,140 +9376,632 @@ var AssignmentEvalPlugin = {
|
|
|
8305
9376
|
"@openlearn/core:ICommandBusService@^1.0.0",
|
|
8306
9377
|
"@openlearn/core:IActionRegistryService@^1.0.0",
|
|
8307
9378
|
"@openlearn/core:IDatabase@^1.0.0",
|
|
8308
|
-
"@openlearn/core:ISemesterGradeService@^1.0.0"
|
|
9379
|
+
"@openlearn/core:ISemesterGradeService@^1.0.0",
|
|
9380
|
+
"@openlearn/core:IEventBusService@^1.0.0"
|
|
8309
9381
|
],
|
|
8310
|
-
capabilitiesProposed: ["
|
|
9382
|
+
capabilitiesProposed: ["assignment:read", "assignment:submit", "assignment:review", "assignment:manage"],
|
|
8311
9383
|
engines: { openlearn: ">=0.2.5" }
|
|
8312
9384
|
},
|
|
8313
9385
|
activate: async (ctx) => {
|
|
8314
9386
|
const commandBus = ctx.services.commandBus;
|
|
8315
9387
|
const actionRegistry = ctx.services.actionRegistry;
|
|
9388
|
+
const eventBus = ctx.services.eventBus;
|
|
8316
9389
|
const db2 = await ctx.resolve(import_plugin_sdk6.IDatabaseToken);
|
|
8317
9390
|
const gradeService = await ctx.resolve(import_plugin_sdk6.ISemesterGradeServiceToken);
|
|
9391
|
+
const OWNED_COMMAND_TYPES = [
|
|
9392
|
+
"assignment.create",
|
|
9393
|
+
"assignment.list",
|
|
9394
|
+
"assignment.get",
|
|
9395
|
+
"assignment.submit",
|
|
9396
|
+
"assignment.peer_review",
|
|
9397
|
+
"assignment.assign_peer_reviews",
|
|
9398
|
+
"assignment.grade"
|
|
9399
|
+
];
|
|
9400
|
+
const OWNED_ACTION_IDS = [
|
|
9401
|
+
"eval-assignment-create",
|
|
9402
|
+
"eval-assignment-list",
|
|
9403
|
+
"eval-assignment-get",
|
|
9404
|
+
"eval-assignment-submit",
|
|
9405
|
+
"eval-assignment-peer-review",
|
|
9406
|
+
"eval-assignment-assign-peer-reviews",
|
|
9407
|
+
"eval-assignment-grade"
|
|
9408
|
+
];
|
|
9409
|
+
for (const type of OWNED_COMMAND_TYPES) {
|
|
9410
|
+
commandBus.unregisterHandler(type);
|
|
9411
|
+
}
|
|
9412
|
+
for (const id of OWNED_ACTION_IDS) {
|
|
9413
|
+
actionRegistry.unregister(id);
|
|
9414
|
+
}
|
|
8318
9415
|
commandBus.unregisterHandler("assignment.submit");
|
|
8319
9416
|
commandBus.unregisterHandler("assignment.grade");
|
|
9417
|
+
actionRegistry.unregister("core-assignment-create");
|
|
8320
9418
|
actionRegistry.unregister("core-assignment-submit");
|
|
8321
9419
|
actionRegistry.unregister("core-assignment-grade");
|
|
8322
|
-
const
|
|
9420
|
+
const loadAssignment = (assignmentId) => db2.prepare("SELECT * FROM plugin_assignments WHERE id = ?").get(assignmentId);
|
|
9421
|
+
const resolveAssignment = (assignmentId, lessonId) => {
|
|
9422
|
+
if (assignmentId) {
|
|
9423
|
+
const row = loadAssignment(assignmentId);
|
|
9424
|
+
if (!row) throw new Error(`Assignment not found: ${assignmentId}`);
|
|
9425
|
+
return row;
|
|
9426
|
+
}
|
|
9427
|
+
if (lessonId) {
|
|
9428
|
+
const existing = db2.prepare("SELECT * FROM plugin_assignments WHERE lesson_id = ? ORDER BY created_at ASC LIMIT 1").get(lessonId);
|
|
9429
|
+
if (existing) return existing;
|
|
9430
|
+
const now = Date.now();
|
|
9431
|
+
const autoId = "asg-" + (0, import_uuid8.v7)();
|
|
9432
|
+
db2.prepare(
|
|
9433
|
+
`INSERT INTO plugin_assignments
|
|
9434
|
+
(id, class_id, lesson_id, element_id, title, description, status, created_by, created_at, updated_at)
|
|
9435
|
+
VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?, ?)`
|
|
9436
|
+
).run(autoId, null, lessonId, null, "\u672C\u8BFE\u4F5C\u4E1A", "\u7531\u8BFE\u65F6\u4F5C\u4E1A\u5165\u53E3\u81EA\u52A8\u521B\u5EFA", null, now, now);
|
|
9437
|
+
return loadAssignment(autoId);
|
|
9438
|
+
}
|
|
9439
|
+
throw new Error("Missing required params: assignmentId or lessonId");
|
|
9440
|
+
};
|
|
9441
|
+
const latestVersionOfSubmission = (submissionId) => db2.prepare(
|
|
9442
|
+
"SELECT id, version, text_content, link_url, submitted_at, is_late FROM plugin_submission_versions WHERE submission_id = ? ORDER BY version DESC LIMIT 1"
|
|
9443
|
+
).get(submissionId);
|
|
9444
|
+
const filesOfVersion = (versionId) => db2.prepare(
|
|
9445
|
+
"SELECT id, original_name, size, mime FROM plugin_assignment_files WHERE version_id = ? AND deleted_at IS NULL ORDER BY uploaded_at ASC"
|
|
9446
|
+
).all(versionId);
|
|
9447
|
+
const buildPeerReviewTasks = (assignmentId, reviewerId) => {
|
|
9448
|
+
const tasks = db2.prepare(
|
|
9449
|
+
"SELECT id, submission_id, anonymous, status, due_at, created_at FROM plugin_peer_review_tasks WHERE assignment_id = ? AND reviewer_id = ? ORDER BY created_at ASC"
|
|
9450
|
+
).all(assignmentId, reviewerId);
|
|
9451
|
+
return tasks.map((task) => {
|
|
9452
|
+
const version = latestVersionOfSubmission(task.submission_id);
|
|
9453
|
+
const review = db2.prepare(
|
|
9454
|
+
"SELECT score, comment, status, updated_at FROM plugin_peer_reviews WHERE submission_id = ? AND reviewer_id = ?"
|
|
9455
|
+
).get(task.submission_id, reviewerId);
|
|
9456
|
+
const submissionUpdatedAt = db2.prepare("SELECT updated_at FROM plugin_submissions WHERE id = ?").get(task.submission_id)?.updated_at ?? null;
|
|
9457
|
+
return {
|
|
9458
|
+
taskId: task.id,
|
|
9459
|
+
submissionId: task.submission_id,
|
|
9460
|
+
status: task.status,
|
|
9461
|
+
anonymous: Number(task.anonymous) === 1,
|
|
9462
|
+
dueAt: task.due_at ?? null,
|
|
9463
|
+
createdAt: task.created_at,
|
|
9464
|
+
// 我评完之后作者又改过提交 → 提醒复核
|
|
9465
|
+
stale: Boolean(
|
|
9466
|
+
review?.status === "submitted" && submissionUpdatedAt && Number(review.updated_at || 0) < Number(submissionUpdatedAt)
|
|
9467
|
+
),
|
|
9468
|
+
review: review ? { score: review.score, comment: review.comment || "", submittedAt: review.updated_at ?? null } : null,
|
|
9469
|
+
submission: version ? {
|
|
9470
|
+
version: version.version,
|
|
9471
|
+
textContent: version.text_content || "",
|
|
9472
|
+
linkUrl: version.link_url || "",
|
|
9473
|
+
submittedAt: version.submitted_at,
|
|
9474
|
+
isLate: Number(version.is_late) === 1,
|
|
9475
|
+
files: filesOfVersion(version.id)
|
|
9476
|
+
} : null
|
|
9477
|
+
};
|
|
9478
|
+
});
|
|
9479
|
+
};
|
|
9480
|
+
const buildPeerProgress = (assignmentId) => {
|
|
9481
|
+
const nameOf = (studentId) => {
|
|
9482
|
+
try {
|
|
9483
|
+
const row = db2.prepare("SELECT name FROM students WHERE id = ?").get(studentId);
|
|
9484
|
+
return row?.name || studentId;
|
|
9485
|
+
} catch {
|
|
9486
|
+
return studentId;
|
|
9487
|
+
}
|
|
9488
|
+
};
|
|
9489
|
+
const tasks = db2.prepare("SELECT reviewer_id, status FROM plugin_peer_review_tasks WHERE assignment_id = ?").all(assignmentId);
|
|
9490
|
+
const byReviewer = /* @__PURE__ */ new Map();
|
|
9491
|
+
for (const task of tasks) {
|
|
9492
|
+
const entry = byReviewer.get(task.reviewer_id) || { studentId: task.reviewer_id, name: nameOf(task.reviewer_id), pending: 0, submitted: 0 };
|
|
9493
|
+
if (task.status === "submitted") entry.submitted += 1;
|
|
9494
|
+
else entry.pending += 1;
|
|
9495
|
+
byReviewer.set(task.reviewer_id, entry);
|
|
9496
|
+
}
|
|
9497
|
+
const flags = [];
|
|
9498
|
+
for (const entry of byReviewer.values()) {
|
|
9499
|
+
if (entry.pending > 0) {
|
|
9500
|
+
flags.push({
|
|
9501
|
+
type: "peer_review_pending",
|
|
9502
|
+
reviewerId: entry.studentId,
|
|
9503
|
+
detail: `${entry.name} \u8FD8\u6709 ${entry.pending} \u4EFD\u4E92\u8BC4\u672A\u5B8C\u6210`
|
|
9504
|
+
});
|
|
9505
|
+
}
|
|
9506
|
+
if (entry.submitted >= 2) {
|
|
9507
|
+
const full = db2.prepare(
|
|
9508
|
+
"SELECT COUNT(*) AS c FROM plugin_peer_reviews WHERE assignment_id = ? AND reviewer_id = ? AND score >= 100"
|
|
9509
|
+
).get(assignmentId, entry.studentId).c;
|
|
9510
|
+
if (full >= entry.submitted) {
|
|
9511
|
+
flags.push({
|
|
9512
|
+
type: "all_full_marks",
|
|
9513
|
+
reviewerId: entry.studentId,
|
|
9514
|
+
detail: `${entry.name} \u7684 ${entry.submitted} \u4EFD\u4E92\u8BC4\u5168\u90E8\u7ED9\u4E86\u6EE1\u5206\uFF0C\u5EFA\u8BAE\u590D\u6838`
|
|
9515
|
+
});
|
|
9516
|
+
}
|
|
9517
|
+
}
|
|
9518
|
+
}
|
|
9519
|
+
const gaps = db2.prepare(
|
|
9520
|
+
`SELECT r.reviewer_id AS reviewerId, r.score AS peerScore, g.teacher_score AS teacherScore, s.student_id AS authorId
|
|
9521
|
+
FROM plugin_peer_reviews r
|
|
9522
|
+
JOIN plugin_grades g ON g.submission_id = r.submission_id
|
|
9523
|
+
JOIN plugin_submissions s ON s.id = r.submission_id
|
|
9524
|
+
WHERE r.assignment_id = ? AND g.teacher_score IS NOT NULL`
|
|
9525
|
+
).all(assignmentId);
|
|
9526
|
+
for (const gap of gaps) {
|
|
9527
|
+
if (Math.abs(Number(gap.peerScore) - Number(gap.teacherScore)) <= 25) continue;
|
|
9528
|
+
flags.push({
|
|
9529
|
+
type: "score_gap",
|
|
9530
|
+
reviewerId: gap.reviewerId,
|
|
9531
|
+
detail: `${nameOf(gap.reviewerId)} \u7ED9 ${nameOf(gap.authorId)} \u7684\u4E92\u8BC4\u5206 ${gap.peerScore}\uFF0C\u4E0E\u6559\u5E08\u8BC4\u5206 ${gap.teacherScore} \u76F8\u5DEE\u8F83\u5927`
|
|
9532
|
+
});
|
|
9533
|
+
}
|
|
9534
|
+
const submissionCount = db2.prepare("SELECT COUNT(*) AS c FROM plugin_submissions WHERE assignment_id = ?").get(assignmentId).c;
|
|
9535
|
+
const completed = tasks.filter((t) => t.status === "submitted").length;
|
|
9536
|
+
return {
|
|
9537
|
+
submissions: submissionCount,
|
|
9538
|
+
tasks: tasks.length,
|
|
9539
|
+
completed,
|
|
9540
|
+
pending: tasks.length - completed,
|
|
9541
|
+
reviewers: Array.from(byReviewer.values()).sort((a, b) => b.pending - a.pending),
|
|
9542
|
+
flags
|
|
9543
|
+
};
|
|
9544
|
+
};
|
|
9545
|
+
const publishEvent2 = async (type, payload, correlationId) => {
|
|
9546
|
+
if (!eventBus || typeof eventBus.publish !== "function") return;
|
|
9547
|
+
try {
|
|
9548
|
+
await eventBus.publish({
|
|
9549
|
+
id: (0, import_uuid8.v7)(),
|
|
9550
|
+
type,
|
|
9551
|
+
source: "assignment-eval",
|
|
9552
|
+
payload,
|
|
9553
|
+
timestamp: Date.now(),
|
|
9554
|
+
correlationId
|
|
9555
|
+
});
|
|
9556
|
+
} catch (err) {
|
|
9557
|
+
ctx.log?.warn?.(`[assignment-eval] \u53D1\u5E03\u4E8B\u4EF6 ${type} \u5931\u8D25: ${err?.message}`);
|
|
9558
|
+
}
|
|
9559
|
+
};
|
|
9560
|
+
await actionRegistry.register({
|
|
9561
|
+
id: "eval-assignment-create",
|
|
9562
|
+
commandType: "assignment.create",
|
|
9563
|
+
description: "\u521B\u5EFA\u6216\u66F4\u65B0\u4F5C\u4E1A\u5B9E\u4F53\uFF08\u53EF\u540C\u65F6\u7ED1\u5B9A\u8BFE\u65F6\u3001\u73ED\u7EA7\u4E0E\u767D\u677F\u5143\u7D20\uFF09",
|
|
9564
|
+
capabilityRequired: "assignment:manage",
|
|
9565
|
+
inputSchema: {
|
|
9566
|
+
type: "OBJECT",
|
|
9567
|
+
properties: {
|
|
9568
|
+
assignmentId: { type: "STRING", description: "\u5DF2\u5B58\u5728\u5219\u66F4\u65B0\uFF0C\u7F3A\u7701\u4E3A\u65B0\u5EFA" },
|
|
9569
|
+
title: { type: "STRING", description: "\u4F5C\u4E1A\u6807\u9898" },
|
|
9570
|
+
description: { type: "STRING", description: "\u4F5C\u4E1A\u8BF4\u660E" },
|
|
9571
|
+
instructions: { type: "STRING", description: "\u4F5C\u7B54\u8981\u6C42" },
|
|
9572
|
+
lessonId: { type: "STRING", description: "\u5173\u8054\u8BFE\u65F6 ID" },
|
|
9573
|
+
classId: { type: "STRING", description: "\u5173\u8054\u73ED\u7EA7 ID" },
|
|
9574
|
+
elementId: { type: "STRING", description: "\u767D\u677F\u6559\u5B66\u5BF9\u8C61 ID" },
|
|
9575
|
+
dueAt: { type: "NUMBER", description: "\u622A\u6B62\u65F6\u95F4\uFF08\u6BEB\u79D2\u65F6\u95F4\u6233\uFF09" },
|
|
9576
|
+
allowLate: { type: "BOOLEAN", description: "\u662F\u5426\u5141\u8BB8\u8FDF\u4EA4" },
|
|
9577
|
+
allowText: { type: "BOOLEAN", description: "\u662F\u5426\u5141\u8BB8\u6587\u672C\u4F5C\u7B54" },
|
|
9578
|
+
allowLink: { type: "BOOLEAN", description: "\u662F\u5426\u5141\u8BB8\u94FE\u63A5\u4F5C\u7B54" },
|
|
9579
|
+
maxFiles: { type: "NUMBER", description: "\u6700\u591A\u6587\u4EF6\u6570" },
|
|
9580
|
+
maxFileSize: { type: "NUMBER", description: "\u5355\u6587\u4EF6\u5927\u5C0F\u4E0A\u9650\uFF08\u5B57\u8282\uFF09" },
|
|
9581
|
+
peerReviewCount: { type: "NUMBER", description: "\u6BCF\u4EBA\u9700\u8981\u4E92\u8BC4\u7684\u4EFD\u6570" },
|
|
9582
|
+
teacherWeight: { type: "NUMBER", description: "\u6559\u5E08\u8BC4\u5206\u6743\u91CD" },
|
|
9583
|
+
peerWeight: { type: "NUMBER", description: "\u4E92\u8BC4\u5E73\u5747\u5206\u6743\u91CD" },
|
|
9584
|
+
status: { type: "STRING", description: "published / draft / archived" }
|
|
9585
|
+
},
|
|
9586
|
+
required: ["title"]
|
|
9587
|
+
}
|
|
9588
|
+
});
|
|
9589
|
+
await commandBus.registerHandler("assignment.create", {
|
|
9590
|
+
async execute(command) {
|
|
9591
|
+
const payload = command.payload;
|
|
9592
|
+
const now = Date.now();
|
|
9593
|
+
const teacherWeight = payload.teacherWeight === void 0 ? 0.6 : Number(payload.teacherWeight);
|
|
9594
|
+
const peerWeight = payload.peerWeight === void 0 ? 0.4 : Number(payload.peerWeight);
|
|
9595
|
+
if (Math.abs(teacherWeight + peerWeight - 1) > 1e-3) {
|
|
9596
|
+
throw new Error("Access Denied: The sum of teacherWeight and peerWeight must equal 1.0");
|
|
9597
|
+
}
|
|
9598
|
+
const assignmentId = payload.assignmentId || "asg-" + (0, import_uuid8.v7)();
|
|
9599
|
+
const existing = loadAssignment(assignmentId);
|
|
9600
|
+
if (existing) {
|
|
9601
|
+
db2.prepare(
|
|
9602
|
+
`UPDATE plugin_assignments SET
|
|
9603
|
+
class_id = ?, lesson_id = ?, element_id = ?, title = ?, description = ?, instructions = ?,
|
|
9604
|
+
due_at = ?, allow_late = ?, allow_text = ?, allow_link = ?, max_files = ?, max_file_size = ?,
|
|
9605
|
+
peer_review_count = ?, teacher_weight = ?, peer_weight = ?, status = ?, updated_at = ?
|
|
9606
|
+
WHERE id = ?`
|
|
9607
|
+
).run(
|
|
9608
|
+
payload.classId ?? existing.class_id,
|
|
9609
|
+
payload.lessonId ?? existing.lesson_id,
|
|
9610
|
+
payload.elementId ?? existing.element_id,
|
|
9611
|
+
payload.title ?? existing.title,
|
|
9612
|
+
payload.description ?? null,
|
|
9613
|
+
payload.instructions ?? null,
|
|
9614
|
+
payload.dueAt ?? existing.due_at,
|
|
9615
|
+
payload.allowLate === void 0 ? existing.allow_late : payload.allowLate ? 1 : 0,
|
|
9616
|
+
payload.allowText === void 0 ? existing.allow_text : payload.allowText ? 1 : 0,
|
|
9617
|
+
payload.allowLink === void 0 ? existing.allow_link : payload.allowLink ? 1 : 0,
|
|
9618
|
+
payload.maxFiles ?? existing.max_files,
|
|
9619
|
+
payload.maxFileSize ?? existing.max_file_size,
|
|
9620
|
+
payload.peerReviewCount ?? existing.peer_review_count,
|
|
9621
|
+
teacherWeight,
|
|
9622
|
+
peerWeight,
|
|
9623
|
+
payload.status ?? existing.status,
|
|
9624
|
+
now,
|
|
9625
|
+
assignmentId
|
|
9626
|
+
);
|
|
9627
|
+
} else {
|
|
9628
|
+
db2.prepare(
|
|
9629
|
+
`INSERT INTO plugin_assignments
|
|
9630
|
+
(id, class_id, lesson_id, element_id, title, description, instructions, due_at, allow_late, allow_text,
|
|
9631
|
+
allow_link, max_files, max_file_size, peer_review_count, teacher_weight, peer_weight, status,
|
|
9632
|
+
created_by, created_at, updated_at)
|
|
9633
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
9634
|
+
).run(
|
|
9635
|
+
assignmentId,
|
|
9636
|
+
payload.classId ?? null,
|
|
9637
|
+
payload.lessonId ?? null,
|
|
9638
|
+
payload.elementId ?? null,
|
|
9639
|
+
payload.title,
|
|
9640
|
+
payload.description ?? null,
|
|
9641
|
+
payload.instructions ?? null,
|
|
9642
|
+
payload.dueAt ?? null,
|
|
9643
|
+
payload.allowLate === false ? 0 : 1,
|
|
9644
|
+
payload.allowText === false ? 0 : 1,
|
|
9645
|
+
payload.allowLink ? 1 : 0,
|
|
9646
|
+
payload.maxFiles ?? 10,
|
|
9647
|
+
payload.maxFileSize ?? 20971520,
|
|
9648
|
+
payload.peerReviewCount ?? 2,
|
|
9649
|
+
teacherWeight,
|
|
9650
|
+
peerWeight,
|
|
9651
|
+
payload.status ?? "published",
|
|
9652
|
+
actorUserId(command.actorId),
|
|
9653
|
+
now,
|
|
9654
|
+
now
|
|
9655
|
+
);
|
|
9656
|
+
}
|
|
9657
|
+
return { success: true, assignmentId, created: !existing };
|
|
9658
|
+
}
|
|
9659
|
+
});
|
|
9660
|
+
await actionRegistry.register({
|
|
9661
|
+
id: "eval-assignment-list",
|
|
9662
|
+
commandType: "assignment.list",
|
|
9663
|
+
description: "\u6309\u8BFE\u65F6 / \u73ED\u7EA7 / \u767D\u677F\u5143\u7D20 / \u5B66\u751F\u5217\u51FA\u4F5C\u4E1A",
|
|
9664
|
+
capabilityRequired: "assignment:read",
|
|
9665
|
+
inputSchema: {
|
|
9666
|
+
type: "OBJECT",
|
|
9667
|
+
properties: {
|
|
9668
|
+
lessonId: { type: "STRING" },
|
|
9669
|
+
classId: { type: "STRING" },
|
|
9670
|
+
elementId: { type: "STRING" },
|
|
9671
|
+
studentId: { type: "STRING", description: "\u9644\u5E26\u8BE5\u5B66\u751F\u7684\u63D0\u4EA4\u6982\u8981" },
|
|
9672
|
+
includeArchived: { type: "BOOLEAN" }
|
|
9673
|
+
}
|
|
9674
|
+
}
|
|
9675
|
+
});
|
|
9676
|
+
await commandBus.registerHandler("assignment.list", {
|
|
9677
|
+
async execute(command) {
|
|
9678
|
+
const payload = command.payload;
|
|
9679
|
+
const where = [];
|
|
9680
|
+
const params = [];
|
|
9681
|
+
if (payload.lessonId) {
|
|
9682
|
+
where.push("lesson_id = ?");
|
|
9683
|
+
params.push(payload.lessonId);
|
|
9684
|
+
}
|
|
9685
|
+
if (payload.classId) {
|
|
9686
|
+
where.push("class_id = ?");
|
|
9687
|
+
params.push(payload.classId);
|
|
9688
|
+
}
|
|
9689
|
+
if (payload.elementId) {
|
|
9690
|
+
where.push("element_id = ?");
|
|
9691
|
+
params.push(payload.elementId);
|
|
9692
|
+
}
|
|
9693
|
+
if (!payload.includeArchived) {
|
|
9694
|
+
where.push("status != 'archived'");
|
|
9695
|
+
}
|
|
9696
|
+
const clause = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
|
9697
|
+
const rows = db2.prepare(`SELECT * FROM plugin_assignments ${clause} ORDER BY created_at DESC LIMIT 200`).all(...params);
|
|
9698
|
+
const studentId = payload.studentId;
|
|
9699
|
+
if (!studentId) return { success: true, assignments: rows };
|
|
9700
|
+
const assignments = rows.map((row) => {
|
|
9701
|
+
const submission = db2.prepare("SELECT id, version, file_path, updated_at FROM plugin_submissions WHERE assignment_id = ? AND student_id = ?").get(row.id, studentId);
|
|
9702
|
+
const grade = submission ? db2.prepare("SELECT calculated_final_score, status FROM plugin_grades WHERE submission_id = ?").get(submission.id) : void 0;
|
|
9703
|
+
return { ...row, submission: submission || null, grade: grade || null };
|
|
9704
|
+
});
|
|
9705
|
+
return { success: true, assignments };
|
|
9706
|
+
}
|
|
9707
|
+
});
|
|
9708
|
+
await actionRegistry.register({
|
|
9709
|
+
id: "eval-assignment-get",
|
|
9710
|
+
commandType: "assignment.get",
|
|
9711
|
+
description: "\u8BFB\u53D6\u5355\u4E2A\u4F5C\u4E1A\u8BE6\u60C5\uFF08\u542B\u63D0\u4EA4\u7248\u672C\u4E0E\u7EDF\u8BA1\uFF09",
|
|
9712
|
+
capabilityRequired: "assignment:read",
|
|
9713
|
+
inputSchema: {
|
|
9714
|
+
type: "OBJECT",
|
|
9715
|
+
properties: {
|
|
9716
|
+
assignmentId: { type: "STRING" },
|
|
9717
|
+
studentId: { type: "STRING", description: "\u9644\u5E26\u8BE5\u5B66\u751F\u7684\u63D0\u4EA4\u4E0E\u7248\u672C\u5386\u53F2" }
|
|
9718
|
+
},
|
|
9719
|
+
required: ["assignmentId"]
|
|
9720
|
+
}
|
|
9721
|
+
});
|
|
9722
|
+
await commandBus.registerHandler("assignment.get", {
|
|
9723
|
+
async execute(command) {
|
|
9724
|
+
const { assignmentId, studentId } = command.payload;
|
|
9725
|
+
const assignment = loadAssignment(assignmentId);
|
|
9726
|
+
if (!assignment) throw new Error(`Assignment not found: ${assignmentId}`);
|
|
9727
|
+
const submissions = db2.prepare("SELECT id, student_id, version, updated_at FROM plugin_submissions WHERE assignment_id = ?").all(assignmentId);
|
|
9728
|
+
const grades = db2.prepare("SELECT submission_id, calculated_final_score, status FROM plugin_grades WHERE assignment_id = ?").all(assignmentId);
|
|
9729
|
+
const gradedIds = new Set(grades.filter((g) => g.status === "confirmed").map((g) => g.submission_id));
|
|
9730
|
+
const result = {
|
|
9731
|
+
success: true,
|
|
9732
|
+
assignment,
|
|
9733
|
+
stats: {
|
|
9734
|
+
submissionCount: submissions.length,
|
|
9735
|
+
gradedCount: gradedIds.size,
|
|
9736
|
+
pendingPeerReviews: db2.prepare("SELECT COUNT(*) AS c FROM plugin_peer_review_tasks WHERE assignment_id = ? AND status = 'pending'").get(assignmentId).c
|
|
9737
|
+
}
|
|
9738
|
+
};
|
|
9739
|
+
if (studentId) {
|
|
9740
|
+
const submission = db2.prepare("SELECT * FROM plugin_submissions WHERE assignment_id = ? AND student_id = ?").get(assignmentId, studentId);
|
|
9741
|
+
if (submission) {
|
|
9742
|
+
result.submission = submission;
|
|
9743
|
+
result.versions = db2.prepare("SELECT * FROM plugin_submission_versions WHERE submission_id = ? ORDER BY version DESC").all(submission.id);
|
|
9744
|
+
result.grade = db2.prepare("SELECT * FROM plugin_grades WHERE submission_id = ?").get(submission.id) || null;
|
|
9745
|
+
} else {
|
|
9746
|
+
result.submission = null;
|
|
9747
|
+
result.versions = [];
|
|
9748
|
+
result.grade = null;
|
|
9749
|
+
}
|
|
9750
|
+
result.myPeerReviewTasks = db2.prepare("SELECT * FROM plugin_peer_review_tasks WHERE assignment_id = ? AND reviewer_id = ?").all(assignmentId, studentId);
|
|
9751
|
+
result.peerReviewTasks = buildPeerReviewTasks(assignmentId, studentId);
|
|
9752
|
+
}
|
|
9753
|
+
if (command.payload.includePeerProgress) {
|
|
9754
|
+
result.peerProgress = buildPeerProgress(assignmentId);
|
|
9755
|
+
}
|
|
9756
|
+
return result;
|
|
9757
|
+
}
|
|
9758
|
+
});
|
|
8323
9759
|
await actionRegistry.register({
|
|
8324
9760
|
id: "eval-assignment-submit",
|
|
8325
|
-
commandType:
|
|
8326
|
-
description: "\u5B66\u751F\u63D0\u4EA4\u4F5C\u4E1A\u4F5C\u54C1\uFF0C\u652F\u6301\u591A\u6B21\
|
|
8327
|
-
capabilityRequired: "
|
|
9761
|
+
commandType: "assignment.submit",
|
|
9762
|
+
description: "\u5B66\u751F\u63D0\u4EA4\u4F5C\u4E1A\u4F5C\u54C1\uFF0C\u652F\u6301\u591A\u6B21\u63D0\u4EA4\u5E76\u4FDD\u7559\u7248\u672C\u5386\u53F2",
|
|
9763
|
+
capabilityRequired: "assignment:submit",
|
|
8328
9764
|
inputSchema: {
|
|
8329
9765
|
type: "OBJECT",
|
|
8330
9766
|
properties: {
|
|
8331
|
-
|
|
9767
|
+
assignmentId: { type: "STRING", description: "\u4F5C\u4E1A\u5B9E\u4F53 ID\uFF08\u63A8\u8350\uFF09" },
|
|
9768
|
+
lessonId: { type: "STRING", description: "\u65E7\u7248\u8BFE\u65F6\u4F5C\u4E1A\u5165\u53E3\uFF1A\u4EC5\u4F20\u8BFE\u65F6 ID \u65F6\u81EA\u52A8\u5F52\u5C5E\u5230\u8BE5\u8BFE\u65F6\u7684\u4F5C\u4E1A" },
|
|
8332
9769
|
studentId: { type: "STRING", description: "\u63D0\u4EA4\u4F5C\u54C1\u7684\u5B66\u751F ID" },
|
|
8333
|
-
filePath: { type: "STRING", description: "\
|
|
9770
|
+
filePath: { type: "STRING", description: "\u65E7\u7248\u517C\u5BB9\uFF1A\u6587\u672C\u5F62\u5F0F\u7684\u6587\u4EF6\u8DEF\u5F84\u63CF\u8FF0" },
|
|
9771
|
+
fileIds: { type: "ARRAY", items: { type: "STRING" }, description: "\u5DF2\u4E0A\u4F20\u6587\u4EF6\u7684\u5143\u6570\u636E ID \u5217\u8868" },
|
|
9772
|
+
textContent: { type: "STRING", description: "\u6587\u672C\u4F5C\u7B54\u5185\u5BB9" },
|
|
9773
|
+
linkUrl: { type: "STRING", description: "\u94FE\u63A5\u4F5C\u7B54\u5185\u5BB9" }
|
|
8334
9774
|
},
|
|
8335
|
-
required: ["
|
|
9775
|
+
required: ["studentId"]
|
|
8336
9776
|
}
|
|
8337
9777
|
});
|
|
8338
|
-
await commandBus.registerHandler(
|
|
9778
|
+
await commandBus.registerHandler("assignment.submit", {
|
|
8339
9779
|
async execute(command) {
|
|
8340
|
-
const
|
|
8341
|
-
|
|
8342
|
-
|
|
9780
|
+
const payload = command.payload;
|
|
9781
|
+
const { studentId, textContent, linkUrl, filePath } = payload;
|
|
9782
|
+
if (!studentId) {
|
|
9783
|
+
throw new Error("Missing required params: studentId");
|
|
8343
9784
|
}
|
|
8344
|
-
|
|
8345
|
-
|
|
8346
|
-
|
|
9785
|
+
assertStudentOwnership(command.actorId, studentId, "submit");
|
|
9786
|
+
const assignment = resolveAssignment(payload.assignmentId, payload.lessonId);
|
|
9787
|
+
const now = Date.now();
|
|
9788
|
+
const isLate = assignment.due_at !== null && now > Number(assignment.due_at) ? 1 : 0;
|
|
9789
|
+
if (isLate && !assignment.allow_late) {
|
|
9790
|
+
throw new Error("Access Denied: Assignment is closed (late submission is not allowed)");
|
|
9791
|
+
}
|
|
9792
|
+
if (textContent && !assignment.allow_text) {
|
|
9793
|
+
throw new Error("Access Denied: Text answers are not allowed for this assignment");
|
|
9794
|
+
}
|
|
9795
|
+
if (linkUrl && !assignment.allow_link) {
|
|
9796
|
+
throw new Error("Access Denied: Link answers are not allowed for this assignment");
|
|
9797
|
+
}
|
|
9798
|
+
const fileIds = Array.isArray(payload.fileIds) ? payload.fileIds.filter((f) => typeof f === "string") : [];
|
|
9799
|
+
if (fileIds.length > Number(assignment.max_files)) {
|
|
9800
|
+
throw new Error(`Access Denied: At most ${assignment.max_files} files are allowed`);
|
|
9801
|
+
}
|
|
9802
|
+
const existing = db2.prepare("SELECT id, version FROM plugin_submissions WHERE assignment_id = ? AND student_id = ?").get(assignment.id, studentId);
|
|
9803
|
+
const submissionId = existing?.id || "sub-" + (0, import_uuid8.v7)();
|
|
9804
|
+
const version = existing ? existing.version + 1 : 1;
|
|
9805
|
+
const versionId = "sv-" + (0, import_uuid8.v7)();
|
|
8347
9806
|
if (existing) {
|
|
8348
|
-
|
|
8349
|
-
|
|
8350
|
-
|
|
8351
|
-
filePath,
|
|
8352
|
-
version,
|
|
8353
|
-
Date.now(),
|
|
8354
|
-
submissionId
|
|
8355
|
-
);
|
|
9807
|
+
db2.prepare(
|
|
9808
|
+
"UPDATE plugin_submissions SET lesson_id = ?, file_path = ?, version = ?, updated_at = ? WHERE id = ?"
|
|
9809
|
+
).run(assignment.lesson_id ?? null, filePath ?? null, version, now, submissionId);
|
|
8356
9810
|
} else {
|
|
8357
|
-
submissionId = "sub-" + (0, import_uuid8.v7)();
|
|
8358
9811
|
db2.prepare(
|
|
8359
|
-
|
|
8360
|
-
|
|
9812
|
+
`INSERT INTO plugin_submissions (id, assignment_id, lesson_id, student_id, file_path, version, created_at, updated_at)
|
|
9813
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
9814
|
+
).run(submissionId, assignment.id, assignment.lesson_id ?? null, studentId, filePath ?? null, version, now, now);
|
|
8361
9815
|
}
|
|
8362
|
-
|
|
9816
|
+
if (fileIds.length > 0) {
|
|
9817
|
+
const placeholders = fileIds.map(() => "?").join(", ");
|
|
9818
|
+
db2.prepare(
|
|
9819
|
+
`UPDATE plugin_assignment_files SET submission_id = ?, version_id = ?
|
|
9820
|
+
WHERE assignment_id = ? AND student_id = ? AND id IN (${placeholders})`
|
|
9821
|
+
).run(submissionId, versionId, assignment.id, studentId, ...fileIds);
|
|
9822
|
+
}
|
|
9823
|
+
const attached = db2.prepare("SELECT id, original_name, size, mime FROM plugin_assignment_files WHERE version_id = ?").all(versionId);
|
|
9824
|
+
const filesJson = JSON.stringify(
|
|
9825
|
+
attached.length > 0 ? attached.map((f) => ({ fileId: f.id, name: f.original_name, size: f.size, mime: f.mime })) : filePath ? [{ name: String(filePath), path: String(filePath) }] : []
|
|
9826
|
+
);
|
|
9827
|
+
db2.prepare(
|
|
9828
|
+
`INSERT INTO plugin_submission_versions
|
|
9829
|
+
(id, submission_id, assignment_id, student_id, version, files_json, text_content, link_url, is_late, submitted_at)
|
|
9830
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
9831
|
+
).run(versionId, submissionId, assignment.id, studentId, version, filesJson, textContent ?? null, linkUrl ?? null, isLate, now);
|
|
9832
|
+
await publishEvent2(
|
|
9833
|
+
"assignment.submitted",
|
|
9834
|
+
{
|
|
9835
|
+
assignmentId: assignment.id,
|
|
9836
|
+
lessonId: assignment.lesson_id,
|
|
9837
|
+
classId: assignment.class_id,
|
|
9838
|
+
studentId,
|
|
9839
|
+
submissionId,
|
|
9840
|
+
version,
|
|
9841
|
+
isLate: isLate === 1
|
|
9842
|
+
},
|
|
9843
|
+
command.id
|
|
9844
|
+
);
|
|
9845
|
+
return { success: true, submissionId, version, versionId, assignmentId: assignment.id, isLate: isLate === 1 };
|
|
8363
9846
|
}
|
|
8364
9847
|
});
|
|
8365
|
-
const peerReviewCmd = "assignment.peer_review";
|
|
8366
9848
|
await actionRegistry.register({
|
|
8367
9849
|
id: "eval-assignment-peer-review",
|
|
8368
|
-
commandType:
|
|
8369
|
-
description: "\u5B66\u751F\u5BF9\
|
|
8370
|
-
capabilityRequired: "
|
|
9850
|
+
commandType: "assignment.peer_review",
|
|
9851
|
+
description: "\u5B66\u751F\u5BF9\u5206\u914D\u5230\u7684\u540C\u5B66\u4F5C\u4E1A\u8FDB\u884C\u4E92\u8BC4\u8BC4\u5206",
|
|
9852
|
+
capabilityRequired: "assignment:review",
|
|
8371
9853
|
inputSchema: {
|
|
8372
9854
|
type: "OBJECT",
|
|
8373
9855
|
properties: {
|
|
8374
9856
|
submissionId: { type: "STRING", description: "\u88AB\u8BC4\u4EF7\u7684\u4F5C\u4E1A\u63D0\u4EA4\u7269 ID" },
|
|
8375
9857
|
reviewerId: { type: "STRING", description: "\u6267\u884C\u8BC4\u4EF7\u7684\u5B66\u751F ID" },
|
|
8376
9858
|
score: { type: "INTEGER", description: "\u4E92\u8BC4\u5206\u6570 (0-100)" },
|
|
8377
|
-
comment: { type: "STRING", description: "\u4E92\u8BC4\u610F\u89C1" }
|
|
9859
|
+
comment: { type: "STRING", description: "\u4E92\u8BC4\u610F\u89C1" },
|
|
9860
|
+
taskId: { type: "STRING", description: "\u4E92\u8BC4\u4EFB\u52A1 ID\uFF08\u5206\u914D\u5F0F\u4E92\u8BC4\u65F6\u63D0\u4F9B\uFF09" }
|
|
8378
9861
|
},
|
|
8379
9862
|
required: ["submissionId", "reviewerId", "score"]
|
|
8380
9863
|
}
|
|
8381
9864
|
});
|
|
8382
|
-
await commandBus.registerHandler(
|
|
9865
|
+
await commandBus.registerHandler("assignment.peer_review", {
|
|
8383
9866
|
async execute(command) {
|
|
8384
|
-
const { submissionId, reviewerId, score, comment } = command.payload;
|
|
9867
|
+
const { submissionId, reviewerId, score, comment, taskId } = command.payload;
|
|
8385
9868
|
if (!submissionId || !reviewerId || score === void 0) {
|
|
8386
9869
|
throw new Error("Missing required params: submissionId, reviewerId, score");
|
|
8387
9870
|
}
|
|
9871
|
+
assertStudentOwnership(command.actorId, reviewerId, "review");
|
|
8388
9872
|
const parsedScore = Math.round(Number(score));
|
|
8389
9873
|
if (isNaN(parsedScore) || parsedScore < 0 || parsedScore > 100) {
|
|
8390
9874
|
throw new Error("Access Denied: Score must be between 0 and 100");
|
|
8391
9875
|
}
|
|
8392
|
-
const submission = db2.prepare("SELECT student_id FROM plugin_submissions WHERE id = ?").get(submissionId);
|
|
9876
|
+
const submission = db2.prepare("SELECT id, assignment_id, student_id FROM plugin_submissions WHERE id = ?").get(submissionId);
|
|
8393
9877
|
if (!submission) {
|
|
8394
9878
|
throw new Error(`Submission not found: ${submissionId}`);
|
|
8395
9879
|
}
|
|
8396
9880
|
if (submission.student_id === reviewerId) {
|
|
8397
9881
|
throw new Error("Access Denied: Students are not allowed to evaluate their own assignments");
|
|
8398
9882
|
}
|
|
9883
|
+
const task = db2.prepare("SELECT id, due_at, status FROM plugin_peer_review_tasks WHERE submission_id = ? AND reviewer_id = ?").get(submissionId, reviewerId);
|
|
9884
|
+
if (!task && submission.assignment_id) {
|
|
9885
|
+
const taskCount = db2.prepare("SELECT COUNT(*) AS c FROM plugin_peer_review_tasks WHERE assignment_id = ?").get(submission.assignment_id).c;
|
|
9886
|
+
if (taskCount > 0) {
|
|
9887
|
+
throw new Error("Access Denied: This submission is not assigned to you for peer review");
|
|
9888
|
+
}
|
|
9889
|
+
}
|
|
9890
|
+
const assignmentRow = submission.assignment_id ? loadAssignment(submission.assignment_id) : void 0;
|
|
9891
|
+
const resolvedTaskId = taskId || task?.id || null;
|
|
9892
|
+
const dueAt = task?.due_at ?? assignmentRow?.peer_review_due_at ?? null;
|
|
9893
|
+
if (dueAt !== null && dueAt !== void 0 && Date.now() > Number(dueAt)) {
|
|
9894
|
+
throw new Error("Access Denied: The peer review deadline has passed");
|
|
9895
|
+
}
|
|
9896
|
+
const now = Date.now();
|
|
8399
9897
|
const reviewId = "rev-" + (0, import_uuid8.v7)();
|
|
8400
9898
|
db2.prepare(
|
|
8401
9899
|
`
|
|
8402
|
-
INSERT INTO plugin_peer_reviews
|
|
8403
|
-
|
|
9900
|
+
INSERT INTO plugin_peer_reviews
|
|
9901
|
+
(id, submission_id, reviewer_id, score, comment, created_at, assignment_id, task_id, anonymous, status, updated_at)
|
|
9902
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 'submitted', ?)
|
|
8404
9903
|
ON CONFLICT(submission_id, reviewer_id) DO UPDATE SET
|
|
8405
9904
|
score = excluded.score,
|
|
8406
9905
|
comment = excluded.comment,
|
|
8407
|
-
created_at = excluded.created_at
|
|
9906
|
+
created_at = excluded.created_at,
|
|
9907
|
+
assignment_id = excluded.assignment_id,
|
|
9908
|
+
task_id = excluded.task_id,
|
|
9909
|
+
status = 'submitted',
|
|
9910
|
+
updated_at = excluded.updated_at
|
|
8408
9911
|
`
|
|
8409
|
-
).run(
|
|
9912
|
+
).run(
|
|
9913
|
+
reviewId,
|
|
9914
|
+
submissionId,
|
|
9915
|
+
reviewerId,
|
|
9916
|
+
parsedScore,
|
|
9917
|
+
comment || "",
|
|
9918
|
+
now,
|
|
9919
|
+
submission.assignment_id,
|
|
9920
|
+
resolvedTaskId,
|
|
9921
|
+
now
|
|
9922
|
+
);
|
|
9923
|
+
if (task) {
|
|
9924
|
+
db2.prepare("UPDATE plugin_peer_review_tasks SET status = 'submitted' WHERE id = ?").run(task.id);
|
|
9925
|
+
}
|
|
8410
9926
|
return { success: true, reviewId };
|
|
8411
9927
|
}
|
|
8412
9928
|
});
|
|
8413
|
-
|
|
9929
|
+
await actionRegistry.register({
|
|
9930
|
+
id: "eval-assignment-assign-peer-reviews",
|
|
9931
|
+
commandType: "assignment.assign_peer_reviews",
|
|
9932
|
+
description: "\u4E3A\u4F5C\u4E1A\u7684\u6BCF\u4EFD\u63D0\u4EA4\u968F\u673A\u5206\u914D\u4E92\u8BC4\u4EBA\uFF08\u53CC\u76F2\uFF0C\u6392\u9664\u672C\u4EBA\uFF09",
|
|
9933
|
+
capabilityRequired: "assignment:manage",
|
|
9934
|
+
inputSchema: {
|
|
9935
|
+
type: "OBJECT",
|
|
9936
|
+
properties: {
|
|
9937
|
+
assignmentId: { type: "STRING", description: "\u4F5C\u4E1A\u5B9E\u4F53 ID" },
|
|
9938
|
+
reviewerCount: { type: "NUMBER", description: "\u6BCF\u4EFD\u63D0\u4EA4\u5206\u914D\u51E0\u540D\u4E92\u8BC4\u4EBA\uFF08\u9ED8\u8BA4\u53D6\u4F5C\u4E1A\u914D\u7F6E\uFF09" },
|
|
9939
|
+
dueAt: { type: "NUMBER", description: "\u4E92\u8BC4\u622A\u6B62\u65F6\u95F4\uFF08\u6BEB\u79D2\u65F6\u95F4\u6233\uFF09" }
|
|
9940
|
+
},
|
|
9941
|
+
required: ["assignmentId"]
|
|
9942
|
+
}
|
|
9943
|
+
});
|
|
9944
|
+
await commandBus.registerHandler("assignment.assign_peer_reviews", {
|
|
9945
|
+
async execute(command) {
|
|
9946
|
+
const { assignmentId, dueAt } = command.payload;
|
|
9947
|
+
const assignment = loadAssignment(assignmentId);
|
|
9948
|
+
if (!assignment) throw new Error(`Assignment not found: ${assignmentId}`);
|
|
9949
|
+
const reviewerCount = Math.max(1, Math.min(10, Number(command.payload.reviewerCount ?? assignment.peer_review_count) || 1));
|
|
9950
|
+
const submissions = db2.prepare("SELECT id, student_id FROM plugin_submissions WHERE assignment_id = ? ORDER BY created_at ASC").all(assignmentId);
|
|
9951
|
+
const pools = /* @__PURE__ */ new Map();
|
|
9952
|
+
let created = 0;
|
|
9953
|
+
const now = Date.now();
|
|
9954
|
+
for (const submission of submissions) {
|
|
9955
|
+
const candidates = submissions.filter((s) => s.student_id !== submission.student_id);
|
|
9956
|
+
if (candidates.length === 0) continue;
|
|
9957
|
+
candidates.sort((a, b) => (pools.get(a.student_id) || 0) - (pools.get(b.student_id) || 0));
|
|
9958
|
+
const picked = candidates.slice(0, Math.min(reviewerCount, candidates.length));
|
|
9959
|
+
for (const candidate of picked) {
|
|
9960
|
+
const taskId = "prt-" + (0, import_uuid8.v7)();
|
|
9961
|
+
const result = db2.prepare(
|
|
9962
|
+
`INSERT INTO plugin_peer_review_tasks
|
|
9963
|
+
(id, assignment_id, submission_id, reviewer_id, anonymous, status, due_at, created_at)
|
|
9964
|
+
VALUES (?, ?, ?, ?, 1, 'pending', ?, ?)
|
|
9965
|
+
ON CONFLICT(submission_id, reviewer_id) DO NOTHING`
|
|
9966
|
+
).run(taskId, assignmentId, submission.id, candidate.student_id, dueAt ?? assignment.peer_review_due_at ?? null, now);
|
|
9967
|
+
if (result.changes > 0) {
|
|
9968
|
+
created += 1;
|
|
9969
|
+
pools.set(candidate.student_id, (pools.get(candidate.student_id) || 0) + 1);
|
|
9970
|
+
}
|
|
9971
|
+
}
|
|
9972
|
+
}
|
|
9973
|
+
if (dueAt !== void 0 && dueAt !== null) {
|
|
9974
|
+
db2.prepare("UPDATE plugin_assignments SET peer_review_due_at = ?, updated_at = ? WHERE id = ?").run(
|
|
9975
|
+
Number(dueAt),
|
|
9976
|
+
now,
|
|
9977
|
+
assignmentId
|
|
9978
|
+
);
|
|
9979
|
+
}
|
|
9980
|
+
return { success: true, created, submissions: submissions.length };
|
|
9981
|
+
}
|
|
9982
|
+
});
|
|
8414
9983
|
await actionRegistry.register({
|
|
8415
9984
|
id: "eval-assignment-grade",
|
|
8416
|
-
commandType:
|
|
9985
|
+
commandType: "assignment.grade",
|
|
8417
9986
|
description: "\u6559\u5E08\u5BF9\u5DF2\u63D0\u4EA4\u7684\u4F5C\u4E1A\u8FDB\u884C\u7EC8\u8BC4\u6253\u5206\uFF0C\u5E76\u53EF\u52A8\u6001\u8BBE\u5B9A\u6298\u7B97\u6743\u91CD\u4E0E\u53D1\u5E03\u6210\u7EE9",
|
|
8418
|
-
capabilityRequired: "
|
|
9987
|
+
capabilityRequired: "assignment:manage",
|
|
8419
9988
|
inputSchema: {
|
|
8420
9989
|
type: "OBJECT",
|
|
8421
9990
|
properties: {
|
|
8422
9991
|
submissionId: { type: "STRING", description: "\u88AB\u8BC4\u4EF7\u7684\u4F5C\u4E1A\u63D0\u4EA4\u7269 ID" },
|
|
8423
9992
|
teacherScore: { type: "INTEGER", description: "\u6559\u5E08\u7ED9\u51FA\u7684\u5E73\u65F6\u5206 (0-100)" },
|
|
8424
9993
|
teacherComment: { type: "STRING", description: "\u6559\u5E08\u8BC4\u8BED\u53CD\u9988" },
|
|
8425
|
-
teacherWeight: { type: "NUMBER", description: "\u6559\u5E08\u6253\u5206\u6743\u91CD (\u9ED8\u8BA4
|
|
8426
|
-
peerWeight: { type: "NUMBER", description: "\u5B66\u751F\u4E92\u8BC4\u5E73\u5747\u5206\u6743\u91CD (\u9ED8\u8BA4
|
|
9994
|
+
teacherWeight: { type: "NUMBER", description: "\u6559\u5E08\u6253\u5206\u6743\u91CD (\u9ED8\u8BA4\u53D6\u4F5C\u4E1A\u914D\u7F6E)" },
|
|
9995
|
+
peerWeight: { type: "NUMBER", description: "\u5B66\u751F\u4E92\u8BC4\u5E73\u5747\u5206\u6743\u91CD (\u9ED8\u8BA4\u53D6\u4F5C\u4E1A\u914D\u7F6E)" },
|
|
8427
9996
|
status: { type: "STRING", description: "\u72B6\u6001\uFF0Cconfirmed \u4E3A\u786E\u8BA4\u5E76\u540C\u6B65\uFF0Cdraft \u4E3A\u8349\u7A3F" }
|
|
8428
9997
|
},
|
|
8429
9998
|
required: ["submissionId", "teacherScore"]
|
|
8430
9999
|
}
|
|
8431
10000
|
});
|
|
8432
|
-
await commandBus.registerHandler(
|
|
10001
|
+
await commandBus.registerHandler("assignment.grade", {
|
|
8433
10002
|
async execute(command) {
|
|
8434
|
-
const
|
|
8435
|
-
|
|
8436
|
-
teacherScore,
|
|
8437
|
-
teacherComment,
|
|
8438
|
-
teacherWeight = 0.6,
|
|
8439
|
-
peerWeight = 0.4,
|
|
8440
|
-
status = "draft"
|
|
8441
|
-
} = command.payload;
|
|
10003
|
+
const payload = command.payload;
|
|
10004
|
+
const { submissionId, teacherScore, teacherComment, status = "draft" } = payload;
|
|
8442
10005
|
if (!submissionId || teacherScore === void 0) {
|
|
8443
10006
|
throw new Error("Missing required params: submissionId, teacherScore");
|
|
8444
10007
|
}
|
|
@@ -8446,29 +10009,34 @@ var AssignmentEvalPlugin = {
|
|
|
8446
10009
|
if (isNaN(parsedTeacherScore) || parsedTeacherScore < 0 || parsedTeacherScore > 100) {
|
|
8447
10010
|
throw new Error("Access Denied: Teacher score must be between 0 and 100");
|
|
8448
10011
|
}
|
|
8449
|
-
const
|
|
8450
|
-
if (Math.abs(totalWeight - 1) > 1e-3) {
|
|
8451
|
-
throw new Error("Access Denied: The sum of teacherWeight and peerWeight must equal 1.0");
|
|
8452
|
-
}
|
|
8453
|
-
const submission = db2.prepare("SELECT lesson_id, student_id FROM plugin_submissions WHERE id = ?").get(submissionId);
|
|
10012
|
+
const submission = db2.prepare("SELECT id, assignment_id, lesson_id, student_id FROM plugin_submissions WHERE id = ?").get(submissionId);
|
|
8454
10013
|
if (!submission) {
|
|
8455
10014
|
throw new Error(`Submission not found: ${submissionId}`);
|
|
8456
10015
|
}
|
|
10016
|
+
const assignment = submission.assignment_id ? loadAssignment(submission.assignment_id) : void 0;
|
|
10017
|
+
const teacherWeight = payload.teacherWeight === void 0 ? Number(assignment?.teacher_weight ?? 0.6) : Number(payload.teacherWeight);
|
|
10018
|
+
const peerWeight = payload.peerWeight === void 0 ? Number(assignment?.peer_weight ?? 0.4) : Number(payload.peerWeight);
|
|
10019
|
+
const totalWeight = teacherWeight + peerWeight;
|
|
10020
|
+
if (Math.abs(totalWeight - 1) > 1e-3) {
|
|
10021
|
+
throw new Error("Access Denied: The sum of teacherWeight and peerWeight must equal 1.0");
|
|
10022
|
+
}
|
|
8457
10023
|
const reviews = db2.prepare("SELECT score FROM plugin_peer_reviews WHERE submission_id = ?").all(submissionId);
|
|
8458
|
-
let peerAverageScore =
|
|
10024
|
+
let peerAverageScore = null;
|
|
8459
10025
|
let calculatedFinalScore = parsedTeacherScore;
|
|
8460
10026
|
if (reviews.length > 0) {
|
|
8461
10027
|
const sum = reviews.reduce((acc, r) => acc + r.score, 0);
|
|
8462
10028
|
peerAverageScore = sum / reviews.length;
|
|
8463
10029
|
calculatedFinalScore = Math.round(parsedTeacherScore * teacherWeight + peerAverageScore * peerWeight);
|
|
8464
10030
|
}
|
|
10031
|
+
const now = Date.now();
|
|
8465
10032
|
const gradeId = "grd-" + (0, import_uuid8.v7)();
|
|
8466
10033
|
db2.prepare(
|
|
8467
10034
|
`
|
|
8468
10035
|
INSERT INTO plugin_grades (
|
|
8469
|
-
id, submission_id, teacher_score, teacher_comment, teacher_weight, peer_weight,
|
|
10036
|
+
id, submission_id, teacher_score, teacher_comment, teacher_weight, peer_weight,
|
|
10037
|
+
calculated_final_score, status, graded_at, assignment_id, peer_average_score, source, published_at, graded_by
|
|
8470
10038
|
)
|
|
8471
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
10039
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'teacher', ?, ?)
|
|
8472
10040
|
ON CONFLICT(submission_id) DO UPDATE SET
|
|
8473
10041
|
teacher_score = excluded.teacher_score,
|
|
8474
10042
|
teacher_comment = excluded.teacher_comment,
|
|
@@ -8476,7 +10044,11 @@ var AssignmentEvalPlugin = {
|
|
|
8476
10044
|
peer_weight = excluded.peer_weight,
|
|
8477
10045
|
calculated_final_score = excluded.calculated_final_score,
|
|
8478
10046
|
status = excluded.status,
|
|
8479
|
-
graded_at = excluded.graded_at
|
|
10047
|
+
graded_at = excluded.graded_at,
|
|
10048
|
+
assignment_id = excluded.assignment_id,
|
|
10049
|
+
peer_average_score = excluded.peer_average_score,
|
|
10050
|
+
published_at = excluded.published_at,
|
|
10051
|
+
graded_by = excluded.graded_by
|
|
8480
10052
|
`
|
|
8481
10053
|
).run(
|
|
8482
10054
|
gradeId,
|
|
@@ -8487,12 +10059,74 @@ var AssignmentEvalPlugin = {
|
|
|
8487
10059
|
peerWeight,
|
|
8488
10060
|
calculatedFinalScore,
|
|
8489
10061
|
status,
|
|
8490
|
-
|
|
10062
|
+
now,
|
|
10063
|
+
submission.assignment_id,
|
|
10064
|
+
peerAverageScore,
|
|
10065
|
+
status === "confirmed" ? now : null,
|
|
10066
|
+
actorUserId(command.actorId)
|
|
8491
10067
|
);
|
|
8492
10068
|
if (status === "confirmed") {
|
|
8493
|
-
|
|
10069
|
+
let projectedByHost = false;
|
|
10070
|
+
if (submission.lesson_id) {
|
|
10071
|
+
try {
|
|
10072
|
+
await gradeService.saveSemesterGrade(submission.lesson_id, submission.student_id, calculatedFinalScore);
|
|
10073
|
+
projectedByHost = true;
|
|
10074
|
+
} catch (err) {
|
|
10075
|
+
ctx.log?.warn?.(`[assignment-eval] \u540C\u6B65\u5B66\u671F\u6210\u7EE9\u5931\u8D25: ${err?.message}`);
|
|
10076
|
+
}
|
|
10077
|
+
}
|
|
10078
|
+
if (!projectedByHost && assignment && assignment.class_id) {
|
|
10079
|
+
db2.prepare(
|
|
10080
|
+
`INSERT INTO assignments (id, class_id, lesson_id, title, description, content, created_at)
|
|
10081
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
10082
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
10083
|
+
class_id = excluded.class_id,
|
|
10084
|
+
lesson_id = excluded.lesson_id,
|
|
10085
|
+
title = excluded.title,
|
|
10086
|
+
description = excluded.description`
|
|
10087
|
+
).run(assignment.id, assignment.class_id, assignment.lesson_id, assignment.title, "", "", now);
|
|
10088
|
+
db2.prepare(
|
|
10089
|
+
`INSERT INTO assignment_submissions
|
|
10090
|
+
(assignment_id, student_id, content, score, feedback, submitted_at, graded_at, status)
|
|
10091
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'graded')
|
|
10092
|
+
ON CONFLICT(assignment_id, student_id) DO UPDATE SET
|
|
10093
|
+
score = excluded.score,
|
|
10094
|
+
feedback = excluded.feedback,
|
|
10095
|
+
graded_at = excluded.graded_at,
|
|
10096
|
+
status = 'graded'`
|
|
10097
|
+
).run(
|
|
10098
|
+
assignment.id,
|
|
10099
|
+
submission.student_id,
|
|
10100
|
+
"",
|
|
10101
|
+
calculatedFinalScore,
|
|
10102
|
+
teacherComment || "",
|
|
10103
|
+
now,
|
|
10104
|
+
now
|
|
10105
|
+
);
|
|
10106
|
+
}
|
|
10107
|
+
await publishEvent2(
|
|
10108
|
+
"assignment.graded",
|
|
10109
|
+
{
|
|
10110
|
+
assignmentId: submission.assignment_id,
|
|
10111
|
+
lessonId: submission.lesson_id,
|
|
10112
|
+
studentId: submission.student_id,
|
|
10113
|
+
submissionId,
|
|
10114
|
+
score: calculatedFinalScore,
|
|
10115
|
+
teacherScore: parsedTeacherScore,
|
|
10116
|
+
peerAverageScore,
|
|
10117
|
+
feedback: teacherComment || ""
|
|
10118
|
+
},
|
|
10119
|
+
command.id
|
|
10120
|
+
);
|
|
8494
10121
|
}
|
|
8495
|
-
return {
|
|
10122
|
+
return {
|
|
10123
|
+
success: true,
|
|
10124
|
+
calculatedFinalScore,
|
|
10125
|
+
peerAverageScore,
|
|
10126
|
+
teacherWeight,
|
|
10127
|
+
peerWeight,
|
|
10128
|
+
status
|
|
10129
|
+
};
|
|
8496
10130
|
}
|
|
8497
10131
|
});
|
|
8498
10132
|
},
|
|
@@ -8775,6 +10409,9 @@ var AuthSessionBridgeService = class {
|
|
|
8775
10409
|
}
|
|
8776
10410
|
};
|
|
8777
10411
|
|
|
10412
|
+
// packages/core/kernel/index.ts
|
|
10413
|
+
init_courseware_runtime_script_registry();
|
|
10414
|
+
|
|
8778
10415
|
// packages/core/plugin-host/index.ts
|
|
8779
10416
|
var import_uuid13 = require("uuid");
|
|
8780
10417
|
var import_fs5 = __toESM(require("fs"), 1);
|
|
@@ -8782,7 +10419,7 @@ var import_jszip2 = __toESM(require("jszip"), 1);
|
|
|
8782
10419
|
var import_path4 = __toESM(require("path"), 1);
|
|
8783
10420
|
var import_express = __toESM(require("express"), 1);
|
|
8784
10421
|
var import_node_module = require("node:module");
|
|
8785
|
-
var
|
|
10422
|
+
var import_node_url2 = require("node:url");
|
|
8786
10423
|
|
|
8787
10424
|
// packages/core/esm-loader/install-utils.ts
|
|
8788
10425
|
var import_jszip = __toESM(require("jszip"), 1);
|
|
@@ -8867,7 +10504,19 @@ async function validateAndBundleZip(zipBuffer) {
|
|
|
8867
10504
|
const rawManifest = JSON.parse(manifestJson);
|
|
8868
10505
|
rawManifest.main ||= "index.js";
|
|
8869
10506
|
const manifest = manifestSchema.parse(rawManifest);
|
|
8870
|
-
|
|
10507
|
+
let entryFile = zip.file(manifest.main);
|
|
10508
|
+
let resolvedMain = manifest.main;
|
|
10509
|
+
if (!entryFile && manifest.main.startsWith("dist/")) {
|
|
10510
|
+
const fallback = manifest.main.slice("dist/".length);
|
|
10511
|
+
const fallbackEntry = zip.file(fallback);
|
|
10512
|
+
if (fallbackEntry) {
|
|
10513
|
+
console.warn(
|
|
10514
|
+
`[install-utils] manifest.main "${manifest.main}" not found in ZIP; falling back to "${fallback}". Plugin author should update build script to drop the dist/ prefix.`
|
|
10515
|
+
);
|
|
10516
|
+
entryFile = fallbackEntry;
|
|
10517
|
+
resolvedMain = fallback;
|
|
10518
|
+
}
|
|
10519
|
+
}
|
|
8871
10520
|
if (!entryFile) {
|
|
8872
10521
|
throw new Error(`Entry file "${manifest.main}" specified in manifest not found in ZIP package`);
|
|
8873
10522
|
}
|
|
@@ -8884,7 +10533,7 @@ async function validateAndBundleZip(zipBuffer) {
|
|
|
8884
10533
|
fs4.writeFileSync(filePath, content);
|
|
8885
10534
|
}
|
|
8886
10535
|
const bundledCode = await bundlePlugin(entryCode, tmpDir);
|
|
8887
|
-
return { manifest, bundledCode, entryFileName:
|
|
10536
|
+
return { manifest, bundledCode, entryFileName: resolvedMain };
|
|
8888
10537
|
} finally {
|
|
8889
10538
|
try {
|
|
8890
10539
|
fs4.rmSync(tmpDir, { recursive: true, force: true });
|
|
@@ -9350,7 +10999,34 @@ var HotReloadActivationError = class extends HotReloadError {
|
|
|
9350
10999
|
init_interfaces();
|
|
9351
11000
|
|
|
9352
11001
|
// packages/core/version.ts
|
|
9353
|
-
var
|
|
11002
|
+
var import_node_fs3 = __toESM(require("node:fs"), 1);
|
|
11003
|
+
var import_node_path3 = __toESM(require("node:path"), 1);
|
|
11004
|
+
var import_node_url = require("node:url");
|
|
11005
|
+
var import_meta2 = {};
|
|
11006
|
+
var FALLBACK_VERSION = "0.3.21";
|
|
11007
|
+
function readPlatformVersion() {
|
|
11008
|
+
const bases = [];
|
|
11009
|
+
if (typeof __dirname !== "undefined") bases.push(__dirname);
|
|
11010
|
+
try {
|
|
11011
|
+
bases.push(import_node_path3.default.dirname((0, import_node_url.fileURLToPath)(import_meta2.url)));
|
|
11012
|
+
} catch {
|
|
11013
|
+
}
|
|
11014
|
+
for (const base of bases) {
|
|
11015
|
+
let dir = base;
|
|
11016
|
+
for (; ; ) {
|
|
11017
|
+
try {
|
|
11018
|
+
const pkg = JSON.parse(import_node_fs3.default.readFileSync(import_node_path3.default.join(dir, "package.json"), "utf-8"));
|
|
11019
|
+
if (pkg.name === "openlearn-next" && typeof pkg.version === "string") return pkg.version;
|
|
11020
|
+
} catch {
|
|
11021
|
+
}
|
|
11022
|
+
const parent = import_node_path3.default.dirname(dir);
|
|
11023
|
+
if (parent === dir) break;
|
|
11024
|
+
dir = parent;
|
|
11025
|
+
}
|
|
11026
|
+
}
|
|
11027
|
+
return FALLBACK_VERSION;
|
|
11028
|
+
}
|
|
11029
|
+
var PLATFORM_VERSION = readPlatformVersion();
|
|
9354
11030
|
var OPENLEARN_VERSION = PLATFORM_VERSION;
|
|
9355
11031
|
|
|
9356
11032
|
// packages/core/plugin-host/plugin-runtime-composition.ts
|
|
@@ -9655,11 +11331,11 @@ var PluginDistributionManager = class {
|
|
|
9655
11331
|
return;
|
|
9656
11332
|
}
|
|
9657
11333
|
const filePath = this.pluginHost.getPluginFilePath(this.pluginHost.resolvePluginUuid(pluginId));
|
|
9658
|
-
const
|
|
9659
|
-
if (!
|
|
11334
|
+
const fs18 = await import("node:fs");
|
|
11335
|
+
if (!fs18.existsSync(filePath)) {
|
|
9660
11336
|
throw new Error(`Cannot reload plugin "${pluginId}": source file missing at ${filePath}`);
|
|
9661
11337
|
}
|
|
9662
|
-
const code =
|
|
11338
|
+
const code = fs18.readFileSync(filePath, "utf-8");
|
|
9663
11339
|
await this.pluginHost.reloadPlugin(pluginId, code);
|
|
9664
11340
|
}
|
|
9665
11341
|
async updateFromZip(zipBuffer, options = {}) {
|
|
@@ -9689,7 +11365,7 @@ var PluginDistributionManager = class {
|
|
|
9689
11365
|
|
|
9690
11366
|
// packages/core/plugin-host/index.ts
|
|
9691
11367
|
init_http_router();
|
|
9692
|
-
var
|
|
11368
|
+
var import_meta3 = {};
|
|
9693
11369
|
var VALID_TRANSITIONS = {
|
|
9694
11370
|
["installed" /* INSTALLED */]: ["activating" /* ACTIVATING */, "uninstalled" /* UNINSTALLED */],
|
|
9695
11371
|
["activating" /* ACTIVATING */]: ["active" /* ACTIVE */, "error" /* ERROR */],
|
|
@@ -9724,7 +11400,7 @@ function resolveHostSdkDir() {
|
|
|
9724
11400
|
const bases = [];
|
|
9725
11401
|
if (typeof __dirname !== "undefined") bases.push(__dirname);
|
|
9726
11402
|
try {
|
|
9727
|
-
bases.push(import_path4.default.dirname((0,
|
|
11403
|
+
bases.push(import_path4.default.dirname((0, import_node_url2.fileURLToPath)(import_meta3.url)));
|
|
9728
11404
|
} catch {
|
|
9729
11405
|
}
|
|
9730
11406
|
for (const base of bases) {
|
|
@@ -9883,7 +11559,7 @@ var PluginHost = class {
|
|
|
9883
11559
|
/**
|
|
9884
11560
|
* 判断指定插件的某个请求路径是否为流式路由 (SSE)
|
|
9885
11561
|
*/
|
|
9886
|
-
isStreamRoute(pluginIdOrManifestId, method,
|
|
11562
|
+
isStreamRoute(pluginIdOrManifestId, method, path24) {
|
|
9887
11563
|
const upperMethod = method.toUpperCase();
|
|
9888
11564
|
const resolvedId = this.resolvePluginUuid(pluginIdOrManifestId);
|
|
9889
11565
|
const instance = this.pluginInstances.get(resolvedId) ?? this.pluginInstances.get(pluginIdOrManifestId);
|
|
@@ -9892,7 +11568,7 @@ var PluginHost = class {
|
|
|
9892
11568
|
for (const r of manifest.api.routes) {
|
|
9893
11569
|
if (r.method.toUpperCase() === upperMethod && r.streaming === true) {
|
|
9894
11570
|
const compiled = compileRoutePattern(r.path);
|
|
9895
|
-
if (compiled.regex.test(
|
|
11571
|
+
if (compiled.regex.test(path24)) {
|
|
9896
11572
|
return true;
|
|
9897
11573
|
}
|
|
9898
11574
|
}
|
|
@@ -9900,14 +11576,14 @@ var PluginHost = class {
|
|
|
9900
11576
|
}
|
|
9901
11577
|
if (!instance) return false;
|
|
9902
11578
|
if (instance.context?.http) {
|
|
9903
|
-
return instance.context.http.isStream(upperMethod,
|
|
11579
|
+
return instance.context.http.isStream(upperMethod, path24);
|
|
9904
11580
|
}
|
|
9905
11581
|
if (instance.workerRef) {
|
|
9906
11582
|
const routes = instance.workerRef.serviceHost.getRegisteredRoutes();
|
|
9907
11583
|
for (const r of routes) {
|
|
9908
11584
|
if (r.method === upperMethod && r.isStream) {
|
|
9909
11585
|
const compiled = compileRoutePattern(r.pattern);
|
|
9910
|
-
if (compiled.regex.test(
|
|
11586
|
+
if (compiled.regex.test(path24)) {
|
|
9911
11587
|
return true;
|
|
9912
11588
|
}
|
|
9913
11589
|
}
|
|
@@ -10550,7 +12226,7 @@ var PluginHost = class {
|
|
|
10550
12226
|
let mod;
|
|
10551
12227
|
if (process.env.NODE_ENV !== "test" && row.file_path && import_fs5.default.existsSync(row.file_path)) {
|
|
10552
12228
|
this.ensureHostSdkResolution();
|
|
10553
|
-
const fileUrl = (0,
|
|
12229
|
+
const fileUrl = (0, import_node_url2.pathToFileURL)(row.file_path);
|
|
10554
12230
|
mod = await import(`${fileUrl.href}?t=${Date.now()}`);
|
|
10555
12231
|
} else {
|
|
10556
12232
|
let sourceCode = "";
|
|
@@ -10885,6 +12561,7 @@ var PluginHost = class {
|
|
|
10885
12561
|
} catch (termErr) {
|
|
10886
12562
|
console.error(`[PluginHost] Worker termination error for "${pluginId}":`, termErr);
|
|
10887
12563
|
} finally {
|
|
12564
|
+
this.resourceTracker.disposeAll(pluginId);
|
|
10888
12565
|
this.pluginStates.set(pluginId, "inactive" /* INACTIVE */);
|
|
10889
12566
|
this.pluginInstances.delete(pluginId);
|
|
10890
12567
|
this.db.prepare("UPDATE plugins SET status = ? WHERE id = ?").run("inactive", pluginId);
|
|
@@ -10971,6 +12648,7 @@ var PluginHost = class {
|
|
|
10971
12648
|
await this.deactivatePlugin(pluginId);
|
|
10972
12649
|
}
|
|
10973
12650
|
}
|
|
12651
|
+
this.resourceTracker.disposeAll(pluginId);
|
|
10974
12652
|
const state = this.pluginStates.get(pluginId) ?? "installed" /* INSTALLED */;
|
|
10975
12653
|
const row = this.db.prepare("SELECT manifest, file_path FROM plugins WHERE id = ?").get(pluginId);
|
|
10976
12654
|
if (state === "uninstalled" /* UNINSTALLED */ && !row) {
|
|
@@ -11004,6 +12682,12 @@ var PluginHost = class {
|
|
|
11004
12682
|
} catch (e) {
|
|
11005
12683
|
console.warn(`[PluginHost] Failed to drop plugin tables for "${pluginId}":`, e);
|
|
11006
12684
|
}
|
|
12685
|
+
try {
|
|
12686
|
+
const capService = await this.serviceRegistry.resolve(ICapabilityServiceToken);
|
|
12687
|
+
await capService.revokeAll(`plugin:${manifestId}`);
|
|
12688
|
+
} catch (capErr) {
|
|
12689
|
+
console.warn(`[PluginHost] Failed to revoke capabilities for "${pluginId}":`, capErr);
|
|
12690
|
+
}
|
|
11007
12691
|
if (this.expressApp && this._registeredRoutes.has(manifestId)) {
|
|
11008
12692
|
try {
|
|
11009
12693
|
const route = this._registeredRoutes.get(manifestId);
|
|
@@ -11159,8 +12843,8 @@ var PluginHost = class {
|
|
|
11159
12843
|
dependencies: manifest.dependencies
|
|
11160
12844
|
};
|
|
11161
12845
|
import_fs5.default.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2), "utf-8");
|
|
11162
|
-
const { execSync
|
|
11163
|
-
|
|
12846
|
+
const { execSync } = await import("node:child_process");
|
|
12847
|
+
execSync(
|
|
11164
12848
|
"npm install --production --no-audit --no-fund --legacy-peer-deps --ignore-scripts --registry=https://registry.npmmirror.com",
|
|
11165
12849
|
{
|
|
11166
12850
|
cwd: pluginDir,
|
|
@@ -11186,8 +12870,8 @@ var PluginHost = class {
|
|
|
11186
12870
|
if (import_fs5.default.existsSync(deployScriptPath)) {
|
|
11187
12871
|
console.log(`[PluginHost] Running deploy script: node ${deployScriptPath}`);
|
|
11188
12872
|
try {
|
|
11189
|
-
const { execSync
|
|
11190
|
-
|
|
12873
|
+
const { execSync } = await import("node:child_process");
|
|
12874
|
+
execSync(`node "${deployScriptPath}" "${process.cwd()}"`, { timeout: 12e4 });
|
|
11191
12875
|
console.log(`[PluginHost] Deploy script completed for plugin "${manifest.id}"`);
|
|
11192
12876
|
} catch (deployErr) {
|
|
11193
12877
|
console.error(`[PluginHost] Deploy script failed for plugin "${manifest.id}":`, deployErr.message);
|
|
@@ -11408,8 +13092,8 @@ var PluginHost = class {
|
|
|
11408
13092
|
),
|
|
11409
13093
|
"utf-8"
|
|
11410
13094
|
);
|
|
11411
|
-
const { execSync
|
|
11412
|
-
|
|
13095
|
+
const { execSync } = await import("node:child_process");
|
|
13096
|
+
execSync(
|
|
11413
13097
|
"npm install --production --no-audit --no-fund --legacy-peer-deps --registry=https://registry.npmmirror.com",
|
|
11414
13098
|
{
|
|
11415
13099
|
cwd: pluginDir,
|
|
@@ -11917,8 +13601,8 @@ var HotReloadController = class {
|
|
|
11917
13601
|
const timer = setTimeout(async () => {
|
|
11918
13602
|
this.debounceTimers.delete(pluginId);
|
|
11919
13603
|
try {
|
|
11920
|
-
const
|
|
11921
|
-
const newSourceCode = await
|
|
13604
|
+
const fs18 = await import("fs/promises");
|
|
13605
|
+
const newSourceCode = await fs18.readFile(filePath, "utf-8");
|
|
11922
13606
|
console.log(`[HotReload] File changed: ${filePath} \u2192 triggering reload for "${pluginId}"`);
|
|
11923
13607
|
await this.pluginHost.reloadPlugin(pluginId, newSourceCode);
|
|
11924
13608
|
} catch (err) {
|
|
@@ -18358,6 +20042,7 @@ var Kernel = class {
|
|
|
18358
20042
|
getServiceRegistryKernel: async () => this.platformServiceRegistryKernel
|
|
18359
20043
|
});
|
|
18360
20044
|
this.serviceRegistry.register(IAuthSessionBridgeToken, new AuthSessionBridgeService(this.db));
|
|
20045
|
+
this.serviceRegistry.register(ICoursewareRuntimeScriptRegistryToken, new CoursewareRuntimeScriptRegistry());
|
|
18361
20046
|
this.commandBus.setInterceptor(async (command) => {
|
|
18362
20047
|
const action = this.actionRegistry.getActionByCommandType(command.type);
|
|
18363
20048
|
if (action) {
|
|
@@ -18840,6 +20525,43 @@ function setupPresence({ io, eventBus, lookupStudentClassIds }) {
|
|
|
18840
20525
|
}
|
|
18841
20526
|
}
|
|
18842
20527
|
);
|
|
20528
|
+
socket.on(
|
|
20529
|
+
"student-client-error",
|
|
20530
|
+
(data) => {
|
|
20531
|
+
if (!data || !data.studentId || !data.error) return;
|
|
20532
|
+
if (session && !isTeacherOrAdmin && session.userId !== data.studentId) {
|
|
20533
|
+
console.warn(`[Presence Security] Student ${session.userId} attempted to report error as ${data.studentId}`);
|
|
20534
|
+
return;
|
|
20535
|
+
}
|
|
20536
|
+
const studentName = data.studentName || onlineStudents.get(data.studentId)?.name || data.studentId;
|
|
20537
|
+
const lessonId = data.lessonId || activeStudentLessons.get(data.studentId) || null;
|
|
20538
|
+
const classId = data.classId || null;
|
|
20539
|
+
console.warn(
|
|
20540
|
+
`[Client Diagnostics] Student ${data.studentId} (${studentName}) reported error [${data.error.type || "runtime"}]: ${data.error.message || data.error.title} (Lesson: ${lessonId || "N/A"})`
|
|
20541
|
+
);
|
|
20542
|
+
eventBus.publish({
|
|
20543
|
+
id: `evt_err_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
|
20544
|
+
type: "student.client_error",
|
|
20545
|
+
source: "student_client",
|
|
20546
|
+
payload: {
|
|
20547
|
+
studentId: data.studentId,
|
|
20548
|
+
studentName,
|
|
20549
|
+
lessonId,
|
|
20550
|
+
classId,
|
|
20551
|
+
error: data.error
|
|
20552
|
+
},
|
|
20553
|
+
timestamp: data.error.timestamp || Date.now(),
|
|
20554
|
+
correlationId: lessonId || void 0
|
|
20555
|
+
});
|
|
20556
|
+
io.emit("student-error-alert", {
|
|
20557
|
+
studentId: data.studentId,
|
|
20558
|
+
studentName,
|
|
20559
|
+
lessonId,
|
|
20560
|
+
classId,
|
|
20561
|
+
error: data.error
|
|
20562
|
+
});
|
|
20563
|
+
}
|
|
20564
|
+
);
|
|
18843
20565
|
socket.on(
|
|
18844
20566
|
"teacher-broadcast-fullscreen",
|
|
18845
20567
|
(data) => {
|
|
@@ -19311,6 +21033,7 @@ var runOpenAIAgentChat = async (provider, request) => {
|
|
|
19311
21033
|
};
|
|
19312
21034
|
|
|
19313
21035
|
// server/middleware/auth.ts
|
|
21036
|
+
var SESSION_REFRESH_INTERVAL_MS = 5 * 60 * 1e3;
|
|
19314
21037
|
function getValidSession(token) {
|
|
19315
21038
|
let sessionRow;
|
|
19316
21039
|
try {
|
|
@@ -19329,11 +21052,21 @@ function getValidSession(token) {
|
|
|
19329
21052
|
kernelContainer.db.prepare("DELETE FROM client_sessions WHERE id = ?").run(token);
|
|
19330
21053
|
return null;
|
|
19331
21054
|
}
|
|
19332
|
-
|
|
19333
|
-
|
|
21055
|
+
if (!sessionRow.updated_at || now - sessionRow.updated_at > SESSION_REFRESH_INTERVAL_MS) {
|
|
21056
|
+
kernelContainer.db.prepare("UPDATE client_sessions SET updated_at = ? WHERE id = ?").run(now, token);
|
|
21057
|
+
}
|
|
21058
|
+
let session;
|
|
21059
|
+
try {
|
|
21060
|
+
session = JSON.parse(sessionRow.session_data);
|
|
21061
|
+
} catch {
|
|
21062
|
+
return null;
|
|
21063
|
+
}
|
|
19334
21064
|
if (session && !session.userId && session.studentId) {
|
|
19335
21065
|
session.userId = session.studentId;
|
|
19336
21066
|
}
|
|
21067
|
+
if (session.role && !["teacher", "student", "administrator", "admin"].includes(session.role)) {
|
|
21068
|
+
session.role = "anonymous";
|
|
21069
|
+
}
|
|
19337
21070
|
return session;
|
|
19338
21071
|
}
|
|
19339
21072
|
function getCookieToken(req) {
|
|
@@ -19358,8 +21091,10 @@ function getActorId(req) {
|
|
|
19358
21091
|
if (session.username === "admin" || session.userId === "usr_admin" || role === "admin" || role === "administrator") {
|
|
19359
21092
|
role = "administrator";
|
|
19360
21093
|
}
|
|
21094
|
+
const rawUserId = session.userId || session.studentId || "demo";
|
|
21095
|
+
const safeUserId = String(rawUserId).replace(/[:\s]/g, "_").slice(0, 64);
|
|
19361
21096
|
if (role) {
|
|
19362
|
-
return `user:${
|
|
21097
|
+
return `user:${safeUserId}:${role}`;
|
|
19363
21098
|
}
|
|
19364
21099
|
return "anonymous";
|
|
19365
21100
|
} catch {
|
|
@@ -19905,6 +21640,7 @@ function detectPromptInjection(input) {
|
|
|
19905
21640
|
// server/routes/shared.ts
|
|
19906
21641
|
var import_path7 = __toESM(require("path"), 1);
|
|
19907
21642
|
var import_crypto5 = __toESM(require("crypto"), 1);
|
|
21643
|
+
init_interfaces();
|
|
19908
21644
|
|
|
19909
21645
|
// server/utils/bridge-sdk.ts
|
|
19910
21646
|
var BRIDGE_SDK_CODE = `(function() {
|
|
@@ -20684,7 +22420,7 @@ var BRIDGE_SDK_CODE = `(function() {
|
|
|
20684
22420
|
|
|
20685
22421
|
// server/routes/shared.ts
|
|
20686
22422
|
function validateMagicBytes(buffer, fileName) {
|
|
20687
|
-
const
|
|
22423
|
+
const MAGIC_BYTES2 = {
|
|
20688
22424
|
".pdf": [[37, 80, 68, 70]],
|
|
20689
22425
|
// %PDF
|
|
20690
22426
|
".pptx": [[80, 75, 3, 4]],
|
|
@@ -20700,11 +22436,34 @@ function validateMagicBytes(buffer, fileName) {
|
|
|
20700
22436
|
// RIFF
|
|
20701
22437
|
};
|
|
20702
22438
|
const ext = import_path7.default.extname(fileName || "").toLowerCase();
|
|
20703
|
-
const signatures =
|
|
22439
|
+
const signatures = MAGIC_BYTES2[ext];
|
|
20704
22440
|
if (!signatures) return true;
|
|
20705
22441
|
return signatures.some((sig) => sig.every((byte, i) => buffer[i] === byte));
|
|
20706
22442
|
}
|
|
20707
22443
|
var BLOCKED_EXTENSIONS = [".exe", ".sh", ".bat", ".cmd", ".dll", ".so", ".dylib", ".scr", ".msi", ".ps1"];
|
|
22444
|
+
function collectCoursewareRuntimeScripts(cwInfo) {
|
|
22445
|
+
const empty = { head: "", bodyEnd: "" };
|
|
22446
|
+
try {
|
|
22447
|
+
const registry = kernelContainer?.serviceRegistry?.resolve?.(ICoursewareRuntimeScriptRegistryToken);
|
|
22448
|
+
if (!registry || typeof registry.list !== "function") return empty;
|
|
22449
|
+
const scripts = registry.list({ id: cwInfo.id, uuid: cwInfo.uuid }) || [];
|
|
22450
|
+
if (!scripts.length) return empty;
|
|
22451
|
+
let head = "";
|
|
22452
|
+
let bodyEnd = "";
|
|
22453
|
+
for (const script of scripts) {
|
|
22454
|
+
if (!script || typeof script.source !== "string" || !script.source.trim()) continue;
|
|
22455
|
+
const tag = `
|
|
22456
|
+
<!-- Courseware Runtime Script (${script.owner}/${script.id}) -->
|
|
22457
|
+
<script>${script.source}</script>`;
|
|
22458
|
+
if (script.position === "head") head += tag;
|
|
22459
|
+
else bodyEnd += tag;
|
|
22460
|
+
}
|
|
22461
|
+
return { head, bodyEnd };
|
|
22462
|
+
} catch (err) {
|
|
22463
|
+
console.warn("[injectLmsSdk] \u8BFB\u53D6\u8BFE\u4EF6\u8FD0\u884C\u65F6\u811A\u672C\u6269\u5C55\u70B9\u5931\u8D25\uFF08\u5DF2\u5FFD\u7565\uFF09:", err.message);
|
|
22464
|
+
return empty;
|
|
22465
|
+
}
|
|
22466
|
+
}
|
|
20708
22467
|
function injectLmsSdk(htmlContent, req, cwInfo) {
|
|
20709
22468
|
const token = getCookieToken(req);
|
|
20710
22469
|
let studentInfo = {
|
|
@@ -20762,6 +22521,7 @@ function injectLmsSdk(htmlContent, req, cwInfo) {
|
|
|
20762
22521
|
}
|
|
20763
22522
|
studentInfo.attempt_id = attempt.id;
|
|
20764
22523
|
}
|
|
22524
|
+
const runtimeScripts = collectCoursewareRuntimeScripts(cwInfo);
|
|
20765
22525
|
const injection = `
|
|
20766
22526
|
<!-- LMS Courseware SDK Inject -->
|
|
20767
22527
|
<script>
|
|
@@ -20771,7 +22531,7 @@ window.__LMS_COURSEWARE__ = {
|
|
|
20771
22531
|
name: ${JSON.stringify(cwInfo.name)}
|
|
20772
22532
|
};
|
|
20773
22533
|
</script>
|
|
20774
|
-
<script>${BRIDGE_SDK_CODE}</script
|
|
22534
|
+
<script>${BRIDGE_SDK_CODE}</script>${runtimeScripts.head}
|
|
20775
22535
|
`;
|
|
20776
22536
|
let html = htmlContent;
|
|
20777
22537
|
html = html.replace(
|
|
@@ -20799,6 +22559,13 @@ window.__LMS_COURSEWARE__ = {
|
|
|
20799
22559
|
} else {
|
|
20800
22560
|
html = injection + html;
|
|
20801
22561
|
}
|
|
22562
|
+
if (runtimeScripts.bodyEnd) {
|
|
22563
|
+
if (/<\/body>/i.test(html)) {
|
|
22564
|
+
html = html.replace(/<\/body>/i, `${runtimeScripts.bodyEnd}</body>`);
|
|
22565
|
+
} else {
|
|
22566
|
+
html += runtimeScripts.bodyEnd;
|
|
22567
|
+
}
|
|
22568
|
+
}
|
|
20802
22569
|
return html;
|
|
20803
22570
|
}
|
|
20804
22571
|
var generateStudentNumber = (db2) => {
|
|
@@ -21123,7 +22890,7 @@ var import_path9 = __toESM(require("path"), 1);
|
|
|
21123
22890
|
var import_fs9 = __toESM(require("fs"), 1);
|
|
21124
22891
|
function registerResourcesRoutes(ctx) {
|
|
21125
22892
|
const { app } = ctx;
|
|
21126
|
-
app.get("/api/resources", (req, res) => {
|
|
22893
|
+
app.get("/api/resources", requireAuth(), (req, res) => {
|
|
21127
22894
|
try {
|
|
21128
22895
|
const resources = kernelContainer.db.prepare("SELECT id, name, type, created_at FROM system_resources ORDER BY created_at DESC").all();
|
|
21129
22896
|
res.json(resources);
|
|
@@ -21131,7 +22898,7 @@ function registerResourcesRoutes(ctx) {
|
|
|
21131
22898
|
sendSafeError(res, e);
|
|
21132
22899
|
}
|
|
21133
22900
|
});
|
|
21134
|
-
app.get("/api/resources/:id", (req, res) => {
|
|
22901
|
+
app.get("/api/resources/:id", requireAuth(), (req, res) => {
|
|
21135
22902
|
try {
|
|
21136
22903
|
const resource = kernelContainer.db.prepare("SELECT * FROM system_resources WHERE id = ?").get(req.params.id);
|
|
21137
22904
|
if (!resource) return res.status(404).send("Resource not found");
|
|
@@ -21231,7 +22998,7 @@ function registerResourcesRoutes(ctx) {
|
|
|
21231
22998
|
sendSafeError(res, e);
|
|
21232
22999
|
}
|
|
21233
23000
|
});
|
|
21234
|
-
app.get("/api/resources/:id/*", (req, res) => {
|
|
23001
|
+
app.get("/api/resources/:id/*", requireAuth(), (req, res) => {
|
|
21235
23002
|
try {
|
|
21236
23003
|
const resource = kernelContainer.db.prepare("SELECT * FROM system_resources WHERE id = ?").get(req.params.id);
|
|
21237
23004
|
if (!resource) return res.status(404).send("Resource not found");
|
|
@@ -21463,7 +23230,7 @@ function registerCoursewareRoutes(ctx) {
|
|
|
21463
23230
|
app.get("/api/courseware", requireAuth(), async (req, res) => {
|
|
21464
23231
|
try {
|
|
21465
23232
|
const actorId = getActorId(req) || "system";
|
|
21466
|
-
const cmd = kernelContainer.commandBus.createCommand("courseware.list", {}, actorId);
|
|
23233
|
+
const cmd = kernelContainer.commandBus.createCommand("courseware.list", {}, actorId, { silent: true });
|
|
21467
23234
|
const result = await kernelContainer.commandBus.execute(cmd);
|
|
21468
23235
|
res.json(result);
|
|
21469
23236
|
} catch (e) {
|
|
@@ -21586,13 +23353,21 @@ function registerCoursewareRoutes(ctx) {
|
|
|
21586
23353
|
"INSERT INTO submission_raw (id, attempt_id, event_type, payload_json, created_at) VALUES (?, ?, ?, ?, ?)"
|
|
21587
23354
|
).run(rawId, attemptId, eventType, JSON.stringify(payload), Date.now());
|
|
21588
23355
|
const extracted = extractScoreCommentCompletion(payload);
|
|
21589
|
-
const score = extracted.score;
|
|
21590
23356
|
const comment = extracted.comment;
|
|
21591
23357
|
const completion = extracted.completion;
|
|
21592
|
-
|
|
23358
|
+
let aggregatedScore = null;
|
|
23359
|
+
let aggregationExtra = {};
|
|
23360
|
+
try {
|
|
23361
|
+
const aggregation = aggregateAttemptScore(kernelContainer.db, attemptId);
|
|
23362
|
+
if (aggregation.finalScore !== null) aggregatedScore = aggregation.finalScore;
|
|
23363
|
+
aggregationExtra = { score_aggregation: describeAggregation(aggregation) };
|
|
23364
|
+
} catch (aggErr) {
|
|
23365
|
+
console.warn("[courseware.log] score aggregation failed, fallback to latest score:", aggErr);
|
|
23366
|
+
}
|
|
23367
|
+
if (aggregatedScore !== null || comment !== void 0 || completion !== void 0) {
|
|
21593
23368
|
let parsedScore = null;
|
|
21594
|
-
if (score !== void 0 && score !== null) {
|
|
21595
|
-
const num = parseFloat(score);
|
|
23369
|
+
if (extracted.score !== void 0 && extracted.score !== null) {
|
|
23370
|
+
const num = parseFloat(extracted.score);
|
|
21596
23371
|
if (!isNaN(num)) {
|
|
21597
23372
|
parsedScore = num;
|
|
21598
23373
|
}
|
|
@@ -21611,13 +23386,16 @@ function registerCoursewareRoutes(ctx) {
|
|
|
21611
23386
|
).run(
|
|
21612
23387
|
"res_" + import_crypto8.default.randomBytes(8).toString("hex"),
|
|
21613
23388
|
attemptId,
|
|
21614
|
-
parsedScore,
|
|
23389
|
+
aggregatedScore !== null ? aggregatedScore : parsedScore,
|
|
21615
23390
|
comment || null,
|
|
21616
23391
|
parsedCompletion,
|
|
21617
|
-
JSON.stringify(
|
|
23392
|
+
JSON.stringify({
|
|
23393
|
+
...payload && typeof payload === "object" ? payload : {},
|
|
23394
|
+
...aggregationExtra
|
|
23395
|
+
})
|
|
21618
23396
|
);
|
|
21619
23397
|
} else {
|
|
21620
|
-
const finalScore = parsedScore !== null ? parsedScore : existing.score;
|
|
23398
|
+
const finalScore = aggregatedScore !== null ? aggregatedScore : parsedScore !== null ? parsedScore : existing.score;
|
|
21621
23399
|
const finalComment = comment || existing.comment;
|
|
21622
23400
|
const finalCompletion = parsedCompletion !== null ? parsedCompletion : existing.completion;
|
|
21623
23401
|
let mergedExtra = {};
|
|
@@ -21628,6 +23406,7 @@ function registerCoursewareRoutes(ctx) {
|
|
|
21628
23406
|
if (payload && typeof payload === "object") {
|
|
21629
23407
|
mergedExtra = { ...mergedExtra, ...payload };
|
|
21630
23408
|
}
|
|
23409
|
+
mergedExtra = { ...mergedExtra, ...aggregationExtra };
|
|
21631
23410
|
kernelContainer.db.prepare(
|
|
21632
23411
|
"UPDATE submission_result SET score = ?, comment = ?, completion = ?, extra_json = ? WHERE attempt_id = ?"
|
|
21633
23412
|
).run(finalScore, finalComment, finalCompletion, JSON.stringify(mergedExtra), attemptId);
|
|
@@ -21680,17 +23459,17 @@ function registerCoursewareRoutes(ctx) {
|
|
|
21680
23459
|
parsedCompletion = num;
|
|
21681
23460
|
}
|
|
21682
23461
|
}
|
|
21683
|
-
const
|
|
23462
|
+
const normalizedActorId = getActorId(req);
|
|
23463
|
+
const actorId = normalizedActorId && normalizedActorId !== "anonymous" ? normalizedActorId : `user:${session.userId || session.studentId || "student"}:${session.role || "student"}`;
|
|
23464
|
+
const payload = { attemptId };
|
|
23465
|
+
if (parsedScore !== null) payload.score = parsedScore;
|
|
23466
|
+
if (parsedCompletion !== null) payload.completion = parsedCompletion;
|
|
23467
|
+
if (comment !== void 0 && comment !== null) payload.comment = comment;
|
|
23468
|
+
if (status !== void 0 && status !== null) payload.status = status;
|
|
23469
|
+
if (extra && typeof extra === "object" && !Array.isArray(extra)) payload.extra = extra;
|
|
21684
23470
|
const cmd = kernelContainer.commandBus.createCommand(
|
|
21685
23471
|
"courseware.submit_attempt",
|
|
21686
|
-
|
|
21687
|
-
attemptId,
|
|
21688
|
-
score: parsedScore,
|
|
21689
|
-
comment,
|
|
21690
|
-
completion: parsedCompletion,
|
|
21691
|
-
status,
|
|
21692
|
-
extra
|
|
21693
|
-
},
|
|
23472
|
+
payload,
|
|
21694
23473
|
actorId
|
|
21695
23474
|
);
|
|
21696
23475
|
const result = await kernelContainer.commandBus.execute(cmd);
|
|
@@ -21700,11 +23479,65 @@ function registerCoursewareRoutes(ctx) {
|
|
|
21700
23479
|
sendSafeError(res, e);
|
|
21701
23480
|
}
|
|
21702
23481
|
});
|
|
21703
|
-
app.
|
|
23482
|
+
app.post("/api/courseware/attempts/:attemptId/adopt", (req, res) => {
|
|
21704
23483
|
try {
|
|
21705
|
-
const
|
|
21706
|
-
|
|
21707
|
-
|
|
23484
|
+
const { attemptId } = req.params;
|
|
23485
|
+
const token = getCookieToken(req);
|
|
23486
|
+
const session = req.session || (token ? getValidSession(token) : null);
|
|
23487
|
+
if (!session) {
|
|
23488
|
+
return res.status(401).json({ error: "Authentication required to adopt an attempt" });
|
|
23489
|
+
}
|
|
23490
|
+
const db2 = kernelContainer.db;
|
|
23491
|
+
const attemptRow = db2.prepare("SELECT id, courseware_id, student_id, status FROM courseware_attempt WHERE id = ?").get(attemptId);
|
|
23492
|
+
if (!attemptRow) {
|
|
23493
|
+
return res.status(404).json({ error: "Attempt not found" });
|
|
23494
|
+
}
|
|
23495
|
+
if (session.role === "teacher" || session.role === "administrator") {
|
|
23496
|
+
return res.json({ attemptId, adopted: false, reused: true, role: session.role });
|
|
23497
|
+
}
|
|
23498
|
+
const studentId = session.userId || session.studentId;
|
|
23499
|
+
if (!studentId) {
|
|
23500
|
+
return res.status(400).json({ error: "Session has no student identity" });
|
|
23501
|
+
}
|
|
23502
|
+
if (attemptRow.student_id === studentId) {
|
|
23503
|
+
return res.json({ attemptId, adopted: false, reused: true });
|
|
23504
|
+
}
|
|
23505
|
+
const UNOWNED_OWNERS = ["guest", "teacher", "teacher_preview", ""];
|
|
23506
|
+
if (UNOWNED_OWNERS.includes(attemptRow.student_id)) {
|
|
23507
|
+
const info = db2.prepare(
|
|
23508
|
+
"UPDATE courseware_attempt SET student_id = ? WHERE id = ? AND student_id IN ('guest','teacher','teacher_preview','')"
|
|
23509
|
+
).run(studentId, attemptId);
|
|
23510
|
+
if (info.changes > 0) {
|
|
23511
|
+
io.emit("courseware-attempt-updated", { attemptId, type: "adopt" });
|
|
23512
|
+
return res.json({ attemptId, adopted: true, reused: true });
|
|
23513
|
+
}
|
|
23514
|
+
}
|
|
23515
|
+
let own = db2.prepare("SELECT id FROM courseware_attempt WHERE courseware_id = ? AND student_id = ? AND status = ?").get(attemptRow.courseware_id, studentId, "active");
|
|
23516
|
+
if (!own) {
|
|
23517
|
+
const newId = "att_" + import_crypto8.default.randomBytes(8).toString("hex");
|
|
23518
|
+
db2.prepare(
|
|
23519
|
+
"INSERT INTO courseware_attempt (id, courseware_id, student_id, started_at, status) VALUES (?, ?, ?, ?, ?)"
|
|
23520
|
+
).run(newId, attemptRow.courseware_id, studentId, Date.now(), "active");
|
|
23521
|
+
own = { id: newId };
|
|
23522
|
+
}
|
|
23523
|
+
io.emit("courseware-attempt-updated", { attemptId: own.id, type: "adopt" });
|
|
23524
|
+
return res.json({
|
|
23525
|
+
attemptId: own.id,
|
|
23526
|
+
adopted: false,
|
|
23527
|
+
reused: true,
|
|
23528
|
+
reason: "attempt-owned-by-another-student"
|
|
23529
|
+
});
|
|
23530
|
+
} catch (e) {
|
|
23531
|
+
sendSafeError(res, e);
|
|
23532
|
+
}
|
|
23533
|
+
});
|
|
23534
|
+
app.get("/api/courseware/attempts", requireAuth(), (req, res) => {
|
|
23535
|
+
try {
|
|
23536
|
+
const session = req.session;
|
|
23537
|
+
const isStaff = session?.role === "teacher" || session?.role === "administrator" || session?.subRole === "administrator";
|
|
23538
|
+
const coursewareUuid = typeof req.query.coursewareUuid === "string" ? req.query.coursewareUuid.trim() : "";
|
|
23539
|
+
const baseSql = `
|
|
23540
|
+
SELECT a.id as attemptId, a.started_at, a.finished_at, a.status,
|
|
21708
23541
|
cw.name as coursewareName, cw.uuid as coursewareUuid,
|
|
21709
23542
|
COALESCE(s.name, CASE WHEN a.student_id = 'teacher' THEN 'Teacher (Test)' WHEN a.student_id = 'guest' THEN 'Guest Student' ELSE a.student_id END) as studentName,
|
|
21710
23543
|
a.student_id as studentId,
|
|
@@ -21712,26 +23545,40 @@ function registerCoursewareRoutes(ctx) {
|
|
|
21712
23545
|
(
|
|
21713
23546
|
SELECT COUNT(*) FROM assignment_submissions sub
|
|
21714
23547
|
JOIN assignments ast ON sub.assignment_id = ast.id
|
|
21715
|
-
WHERE sub.student_id = a.student_id
|
|
23548
|
+
WHERE sub.student_id = a.student_id
|
|
21716
23549
|
AND ast.title = '\u4E92\u52A8\u8BFE\u4EF6: ' || cw.name
|
|
21717
23550
|
) as isPromoted
|
|
21718
23551
|
FROM courseware_attempt a
|
|
21719
23552
|
JOIN courseware cw ON a.courseware_id = cw.id
|
|
21720
23553
|
LEFT JOIN students s ON a.student_id = s.id
|
|
21721
23554
|
LEFT JOIN submission_result r ON a.id = r.attempt_id
|
|
21722
|
-
|
|
21723
|
-
`
|
|
21724
|
-
|
|
21725
|
-
|
|
23555
|
+
`;
|
|
23556
|
+
const sql = coursewareUuid ? `${baseSql} WHERE cw.uuid = ? ORDER BY a.started_at DESC` : `${baseSql} ORDER BY a.started_at DESC`;
|
|
23557
|
+
const stmt = kernelContainer.db.prepare(sql);
|
|
23558
|
+
const rows = coursewareUuid ? stmt.all(coursewareUuid) : stmt.all();
|
|
23559
|
+
if (isStaff) {
|
|
23560
|
+
res.json(rows);
|
|
23561
|
+
return;
|
|
23562
|
+
}
|
|
23563
|
+
res.json(
|
|
23564
|
+
rows.map((row) => {
|
|
23565
|
+
const sanitized = { ...row };
|
|
23566
|
+
delete sanitized.extra_json;
|
|
23567
|
+
delete sanitized.comment;
|
|
23568
|
+
return sanitized;
|
|
23569
|
+
})
|
|
23570
|
+
);
|
|
21726
23571
|
} catch (e) {
|
|
21727
23572
|
sendSafeError(res, e);
|
|
21728
23573
|
}
|
|
21729
23574
|
});
|
|
21730
|
-
app.post("/api/courseware/debug", (req, res) => {
|
|
23575
|
+
app.post("/api/courseware/debug", requireAuth(), (req, res) => {
|
|
21731
23576
|
try {
|
|
21732
23577
|
const { msg, url, student, courseware } = req.body;
|
|
21733
|
-
const
|
|
21734
|
-
|
|
23578
|
+
const safeMsg = typeof msg === "string" ? msg.slice(0, 2e3) : String(msg).slice(0, 2e3);
|
|
23579
|
+
const safeUrl = typeof url === "string" ? url.slice(0, 1e3) : String(url).slice(0, 1e3);
|
|
23580
|
+
const logMsg = `[CLIENT DEBUG] ${safeMsg} | URL: ${safeUrl} | Student: ${JSON.stringify(student)?.slice(0, 1e3)} | Courseware: ${JSON.stringify(courseware)?.slice(0, 1e3)}`;
|
|
23581
|
+
console.log(`\x1B[35m[CLIENT DEBUG]\x1B[0m ${safeMsg}`);
|
|
21735
23582
|
const logFile2 = import_path10.default.join(process.cwd(), "client_debug.log");
|
|
21736
23583
|
import_fs10.default.appendFileSync(logFile2, `${(/* @__PURE__ */ new Date()).toISOString()} - ${logMsg}
|
|
21737
23584
|
`);
|
|
@@ -21740,13 +23587,20 @@ function registerCoursewareRoutes(ctx) {
|
|
|
21740
23587
|
sendSafeError(res, e);
|
|
21741
23588
|
}
|
|
21742
23589
|
});
|
|
21743
|
-
app.get("/api/courseware/attempts/:attemptId/raw", async (req, res) => {
|
|
23590
|
+
app.get("/api/courseware/attempts/:attemptId/raw", requireAuth(), async (req, res) => {
|
|
21744
23591
|
try {
|
|
21745
23592
|
const { attemptId } = req.params;
|
|
23593
|
+
const session = req.session;
|
|
23594
|
+
if (session.role !== "teacher" && session.role !== "administrator") {
|
|
23595
|
+
const owner = kernelContainer.db.prepare("SELECT student_id FROM courseware_attempt WHERE id = ?").get(attemptId);
|
|
23596
|
+
if (!owner || owner.student_id !== (session.userId || session.studentId)) {
|
|
23597
|
+
return res.status(403).json({ error: "Forbidden: Cannot read another student attempt" });
|
|
23598
|
+
}
|
|
23599
|
+
}
|
|
21746
23600
|
const cmd = kernelContainer.commandBus.createCommand(
|
|
21747
23601
|
"courseware.get_attempt_raw_data",
|
|
21748
23602
|
{ attemptId },
|
|
21749
|
-
|
|
23603
|
+
getActorId(req)
|
|
21750
23604
|
);
|
|
21751
23605
|
const result = await kernelContainer.commandBus.execute(cmd);
|
|
21752
23606
|
res.json(result);
|
|
@@ -21754,9 +23608,16 @@ function registerCoursewareRoutes(ctx) {
|
|
|
21754
23608
|
sendSafeError(res, e);
|
|
21755
23609
|
}
|
|
21756
23610
|
});
|
|
21757
|
-
app.get("/api/courseware/attempts/:attemptId/progress", (req, res) => {
|
|
23611
|
+
app.get("/api/courseware/attempts/:attemptId/progress", requireAuth(), (req, res) => {
|
|
21758
23612
|
try {
|
|
21759
23613
|
const { attemptId } = req.params;
|
|
23614
|
+
const session = req.session;
|
|
23615
|
+
if (session && session.role !== "teacher" && session.role !== "administrator" && session.subRole !== "administrator") {
|
|
23616
|
+
const owner = kernelContainer.db.prepare("SELECT student_id FROM courseware_attempt WHERE id = ?").get(attemptId);
|
|
23617
|
+
if (!owner || owner.student_id !== (session.userId || session.studentId)) {
|
|
23618
|
+
return res.status(403).json({ success: false, error: "Forbidden: Cannot read another student attempt" });
|
|
23619
|
+
}
|
|
23620
|
+
}
|
|
21760
23621
|
const result = kernelContainer.db.prepare("SELECT score, comment, completion, extra_json FROM submission_result WHERE attempt_id = ?").get(attemptId);
|
|
21761
23622
|
if (!result) {
|
|
21762
23623
|
return res.json({ progress: null });
|
|
@@ -21773,7 +23634,7 @@ function registerCoursewareRoutes(ctx) {
|
|
|
21773
23634
|
sendSafeError(res, e);
|
|
21774
23635
|
}
|
|
21775
23636
|
});
|
|
21776
|
-
app.post("/api/courseware/attempts/:attemptId/promote", async (req, res) => {
|
|
23637
|
+
app.post("/api/courseware/attempts/:attemptId/promote", requireAuth("teacher", "administrator"), async (req, res) => {
|
|
21777
23638
|
try {
|
|
21778
23639
|
const { attemptId } = req.params;
|
|
21779
23640
|
const { lessonId, classId } = req.body;
|
|
@@ -21806,24 +23667,25 @@ function registerCoursewareRoutes(ctx) {
|
|
|
21806
23667
|
}
|
|
21807
23668
|
}
|
|
21808
23669
|
const assignmentTitle = `\u4E92\u52A8\u8BFE\u4EF6: ${coursewareName}`;
|
|
21809
|
-
|
|
21810
|
-
|
|
21811
|
-
|
|
21812
|
-
|
|
23670
|
+
const promoteTx = kernelContainer.db.transaction(() => {
|
|
23671
|
+
let assignment = kernelContainer.db.prepare("SELECT id FROM assignments WHERE class_id = ? AND lesson_id = ? AND title = ?").get(classId, lessonId, assignmentTitle);
|
|
23672
|
+
let assignmentId2 = assignment?.id;
|
|
23673
|
+
if (!assignmentId2) {
|
|
23674
|
+
assignmentId2 = "ast-cw-" + import_crypto8.default.randomBytes(8).toString("hex");
|
|
23675
|
+
kernelContainer.db.prepare(
|
|
23676
|
+
"INSERT INTO assignments (id, class_id, lesson_id, title, description, content, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
|
23677
|
+
).run(
|
|
23678
|
+
assignmentId2,
|
|
23679
|
+
classId,
|
|
23680
|
+
lessonId,
|
|
23681
|
+
assignmentTitle,
|
|
23682
|
+
`\u6765\u81EA\u4E92\u52A8\u8BFE\u4EF6 [${coursewareName}] \u7684\u968F\u5802\u5B66\u4E60\u63D0\u4EA4\u6570\u636E\u8BB0\u5F55`,
|
|
23683
|
+
JSON.stringify({ type: "interactive_courseware", attemptId, coursewareUuid: attempt.courseware_uuid }),
|
|
23684
|
+
Date.now()
|
|
23685
|
+
);
|
|
23686
|
+
}
|
|
21813
23687
|
kernelContainer.db.prepare(
|
|
21814
|
-
|
|
21815
|
-
).run(
|
|
21816
|
-
assignmentId,
|
|
21817
|
-
classId,
|
|
21818
|
-
lessonId,
|
|
21819
|
-
assignmentTitle,
|
|
21820
|
-
`\u6765\u81EA\u4E92\u52A8\u8BFE\u4EF6 [${coursewareName}] \u7684\u968F\u5802\u5B66\u4E60\u63D0\u4EA4\u6570\u636E\u8BB0\u5F55`,
|
|
21821
|
-
JSON.stringify({ type: "interactive_courseware", attemptId, coursewareUuid: attempt.courseware_uuid }),
|
|
21822
|
-
Date.now()
|
|
21823
|
-
);
|
|
21824
|
-
}
|
|
21825
|
-
kernelContainer.db.prepare(
|
|
21826
|
-
`
|
|
23688
|
+
`
|
|
21827
23689
|
INSERT INTO assignment_submissions (assignment_id, student_id, content, score, feedback, submitted_at, graded_at, status)
|
|
21828
23690
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'graded')
|
|
21829
23691
|
ON CONFLICT(assignment_id, student_id) DO UPDATE SET
|
|
@@ -21834,24 +23696,27 @@ function registerCoursewareRoutes(ctx) {
|
|
|
21834
23696
|
graded_at = excluded.graded_at,
|
|
21835
23697
|
status = 'graded'
|
|
21836
23698
|
`
|
|
21837
|
-
|
|
21838
|
-
|
|
21839
|
-
|
|
21840
|
-
|
|
21841
|
-
|
|
21842
|
-
|
|
21843
|
-
|
|
21844
|
-
|
|
21845
|
-
|
|
21846
|
-
|
|
21847
|
-
|
|
23699
|
+
).run(
|
|
23700
|
+
assignmentId2,
|
|
23701
|
+
studentId,
|
|
23702
|
+
attempt.extra_json || "{}",
|
|
23703
|
+
finalScore,
|
|
23704
|
+
`\u7531\u6559\u5E08\u5728\u8BFE\u5802\u4E2D\u4FDD\u5B58\u5F55\u5165\u3002\u8BFE\u4EF6\u5B8C\u6210\u5EA6: ${Math.round(completion * 100)}%\u3002\u8BFE\u4EF6\u539F\u59CB\u53CD\uFFFD?: ${attempt.comment || "\uFFFD?"}`,
|
|
23705
|
+
Date.now(),
|
|
23706
|
+
Date.now()
|
|
23707
|
+
);
|
|
23708
|
+
kernelContainer.db.prepare(
|
|
23709
|
+
`
|
|
21848
23710
|
INSERT INTO student_lesson_progress (student_id, lesson_id, completed, progress_percent, completed_segments, assigned_at)
|
|
21849
23711
|
VALUES (?, ?, 1, 100, '[]', ?)
|
|
21850
23712
|
ON CONFLICT(student_id, lesson_id) DO UPDATE SET
|
|
21851
23713
|
completed = 1,
|
|
21852
23714
|
progress_percent = 100
|
|
21853
23715
|
`
|
|
21854
|
-
|
|
23716
|
+
).run(studentId, lessonId, Date.now());
|
|
23717
|
+
return assignmentId2;
|
|
23718
|
+
});
|
|
23719
|
+
const assignmentId = promoteTx();
|
|
21855
23720
|
io.emit("student-progress-updated", {
|
|
21856
23721
|
studentId,
|
|
21857
23722
|
lessonId,
|
|
@@ -21886,10 +23751,20 @@ var import_path11 = __toESM(require("path"), 1);
|
|
|
21886
23751
|
var import_fs11 = __toESM(require("fs"), 1);
|
|
21887
23752
|
function registerBridgeRoutes(ctx) {
|
|
21888
23753
|
const { app } = ctx;
|
|
21889
|
-
app.get("/bridge.js", (
|
|
23754
|
+
app.get("/bridge.js", (req, res) => {
|
|
21890
23755
|
res.setHeader("Content-Type", "application/javascript; charset=utf-8");
|
|
21891
23756
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
21892
|
-
|
|
23757
|
+
const cw = typeof req.query.cw === "string" ? req.query.cw : "";
|
|
23758
|
+
const cwName = typeof req.query.name === "string" ? req.query.name : "";
|
|
23759
|
+
let runtimeScripts = "";
|
|
23760
|
+
try {
|
|
23761
|
+
const scripts = collectCoursewareRuntimeScripts({ id: cw, name: cwName, uuid: cw });
|
|
23762
|
+
runtimeScripts = scripts.head + scripts.bodyEnd;
|
|
23763
|
+
} catch (e) {
|
|
23764
|
+
runtimeScripts = "";
|
|
23765
|
+
}
|
|
23766
|
+
res.send(runtimeScripts ? `${BRIDGE_SDK_CODE}
|
|
23767
|
+
${runtimeScripts}` : BRIDGE_SDK_CODE);
|
|
21893
23768
|
});
|
|
21894
23769
|
app.get("/runtime/:uuid", (req, res, next) => {
|
|
21895
23770
|
if (req.path.endsWith("/")) return next();
|
|
@@ -22139,7 +24014,7 @@ function requireWhiteboardWriteAccess() {
|
|
|
22139
24014
|
}
|
|
22140
24015
|
function registerLessonsRoutes(ctx) {
|
|
22141
24016
|
const { app, io } = ctx;
|
|
22142
|
-
app.get("/api/lessons", (req, res) => {
|
|
24017
|
+
app.get("/api/lessons", requireAuth(), (req, res) => {
|
|
22143
24018
|
const lessons = kernelContainer.db.prepare(
|
|
22144
24019
|
`
|
|
22145
24020
|
SELECT l.*, u.name as creator_name,
|
|
@@ -22151,7 +24026,7 @@ function registerLessonsRoutes(ctx) {
|
|
|
22151
24026
|
).all();
|
|
22152
24027
|
res.json(lessons);
|
|
22153
24028
|
});
|
|
22154
|
-
app.get("/api/lessons/:lessonId/eval-submissions", (req, res) => {
|
|
24029
|
+
app.get("/api/lessons/:lessonId/eval-submissions", requireAuth(), (req, res) => {
|
|
22155
24030
|
try {
|
|
22156
24031
|
const { lessonId } = req.params;
|
|
22157
24032
|
const rows = kernelContainer.db.prepare(
|
|
@@ -22167,7 +24042,7 @@ function registerLessonsRoutes(ctx) {
|
|
|
22167
24042
|
sendSafeError(res, err);
|
|
22168
24043
|
}
|
|
22169
24044
|
});
|
|
22170
|
-
app.get("/api/lessons/:lessonId/eval-grades", (req, res) => {
|
|
24045
|
+
app.get("/api/lessons/:lessonId/eval-grades", requireAuth("teacher", "administrator"), (req, res) => {
|
|
22171
24046
|
try {
|
|
22172
24047
|
const { lessonId } = req.params;
|
|
22173
24048
|
const submissions = kernelContainer.db.prepare(
|
|
@@ -22217,7 +24092,7 @@ function registerLessonsRoutes(ctx) {
|
|
|
22217
24092
|
sendSafeError(res, err);
|
|
22218
24093
|
}
|
|
22219
24094
|
});
|
|
22220
|
-
app.get("/api/eval-submissions/:submissionId/reviews", (req, res) => {
|
|
24095
|
+
app.get("/api/eval-submissions/:submissionId/reviews", requireAuth(), (req, res) => {
|
|
22221
24096
|
try {
|
|
22222
24097
|
const { submissionId } = req.params;
|
|
22223
24098
|
const rows = kernelContainer.db.prepare(
|
|
@@ -22233,7 +24108,7 @@ function registerLessonsRoutes(ctx) {
|
|
|
22233
24108
|
sendSafeError(res, err);
|
|
22234
24109
|
}
|
|
22235
24110
|
});
|
|
22236
|
-
app.get("/api/lessons/:lessonId/students/:studentId/eval-status", (req, res) => {
|
|
24111
|
+
app.get("/api/lessons/:lessonId/students/:studentId/eval-status", requireAuth(), (req, res) => {
|
|
22237
24112
|
try {
|
|
22238
24113
|
const { lessonId, studentId } = req.params;
|
|
22239
24114
|
const submission = kernelContainer.db.prepare(
|
|
@@ -22334,7 +24209,7 @@ function registerLessonsRoutes(ctx) {
|
|
|
22334
24209
|
sendSafeError(res, e);
|
|
22335
24210
|
}
|
|
22336
24211
|
});
|
|
22337
|
-
app.get("/api/lessons/:id/whiteboard", (req, res) => {
|
|
24212
|
+
app.get("/api/lessons/:id/whiteboard", requireAuth(), (req, res) => {
|
|
22338
24213
|
const id = req.params.id;
|
|
22339
24214
|
const elements = kernelContainer.db.prepare("SELECT * FROM whiteboard_elements WHERE lesson_id = ?").all(id);
|
|
22340
24215
|
if (!id.startsWith("assignment-") && !id.startsWith("snapshot-")) {
|
|
@@ -22492,6 +24367,18 @@ function registerLessonsRoutes(ctx) {
|
|
|
22492
24367
|
dataObj.submissions[studentId] = { answer, score, time: Date.now() };
|
|
22493
24368
|
kernelContainer.db.prepare("UPDATE whiteboard_elements SET data = ? WHERE id = ?").run(JSON.stringify(dataObj), elementId);
|
|
22494
24369
|
io.to(`lesson-${lessonId}`).emit("whiteboard-sync", { type: "element-updated", elementId });
|
|
24370
|
+
io.emit("whiteboard-quiz-answered", {
|
|
24371
|
+
lessonId,
|
|
24372
|
+
elementId,
|
|
24373
|
+
studentId,
|
|
24374
|
+
studentName: session.studentName || session.name || null,
|
|
24375
|
+
answer,
|
|
24376
|
+
score,
|
|
24377
|
+
isCorrect,
|
|
24378
|
+
time: Date.now(),
|
|
24379
|
+
correctAnswer: dataObj.correctAnswer || null,
|
|
24380
|
+
question: dataObj.question || null
|
|
24381
|
+
});
|
|
22495
24382
|
res.json({ success: true, isCorrect, score, studentId });
|
|
22496
24383
|
} catch (e) {
|
|
22497
24384
|
sendSafeError(res, e);
|
|
@@ -22565,11 +24452,14 @@ Provide a short, friendly, and helpful hint (1-2 sentences) directly related to
|
|
|
22565
24452
|
if (!ownership.allowed) {
|
|
22566
24453
|
return res.status(ownership.status).json({ success: false, error: ownership.error });
|
|
22567
24454
|
}
|
|
22568
|
-
kernelContainer.db.
|
|
22569
|
-
|
|
22570
|
-
|
|
22571
|
-
|
|
22572
|
-
|
|
24455
|
+
const delTx = kernelContainer.db.transaction(() => {
|
|
24456
|
+
kernelContainer.db.prepare("DELETE FROM whiteboard_elements WHERE lesson_id = ?").run(id);
|
|
24457
|
+
kernelContainer.db.prepare("DELETE FROM student_lesson_progress WHERE lesson_id = ?").run(id);
|
|
24458
|
+
kernelContainer.db.prepare("DELETE FROM schedules WHERE lesson_id = ?").run(id);
|
|
24459
|
+
kernelContainer.db.prepare("DELETE FROM assignments WHERE lesson_id = ?").run(id);
|
|
24460
|
+
return kernelContainer.db.prepare("DELETE FROM lessons WHERE id = ?").run(id);
|
|
24461
|
+
});
|
|
24462
|
+
const result = delTx();
|
|
22573
24463
|
if (result.changes === 0) {
|
|
22574
24464
|
return res.status(404).json({ error: "Lesson not found" });
|
|
22575
24465
|
}
|
|
@@ -22578,7 +24468,7 @@ Provide a short, friendly, and helpful hint (1-2 sentences) directly related to
|
|
|
22578
24468
|
sendSafeError(res, e);
|
|
22579
24469
|
}
|
|
22580
24470
|
});
|
|
22581
|
-
app.get("/api/lessons/:id/stats", (req, res) => {
|
|
24471
|
+
app.get("/api/lessons/:id/stats", requireAuth(), (req, res) => {
|
|
22582
24472
|
try {
|
|
22583
24473
|
const { id } = req.params;
|
|
22584
24474
|
const whiteboardCount = kernelContainer.db.prepare("SELECT COUNT(*) as count FROM whiteboard_elements WHERE lesson_id = ?").get(id).count;
|
|
@@ -22602,26 +24492,29 @@ Provide a short, friendly, and helpful hint (1-2 sentences) directly related to
|
|
|
22602
24492
|
const newId = (0, import_uuid16.v7)();
|
|
22603
24493
|
const now = Date.now();
|
|
22604
24494
|
const newTitle = `\u526F\u672C-${original.title}`;
|
|
22605
|
-
kernelContainer.db.
|
|
22606
|
-
|
|
22607
|
-
|
|
22608
|
-
|
|
22609
|
-
|
|
22610
|
-
|
|
22611
|
-
|
|
22612
|
-
|
|
22613
|
-
|
|
22614
|
-
|
|
22615
|
-
|
|
22616
|
-
|
|
22617
|
-
|
|
22618
|
-
|
|
22619
|
-
|
|
22620
|
-
|
|
22621
|
-
|
|
22622
|
-
|
|
22623
|
-
|
|
22624
|
-
|
|
24495
|
+
const cloneTx = kernelContainer.db.transaction(() => {
|
|
24496
|
+
kernelContainer.db.prepare(
|
|
24497
|
+
"INSERT INTO lessons (id, title, content, timeline, progress_mode, progress_conditions, creator_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
24498
|
+
).run(
|
|
24499
|
+
newId,
|
|
24500
|
+
newTitle,
|
|
24501
|
+
original.content,
|
|
24502
|
+
original.timeline,
|
|
24503
|
+
original.progress_mode,
|
|
24504
|
+
original.progress_conditions,
|
|
24505
|
+
creatorId,
|
|
24506
|
+
now,
|
|
24507
|
+
now
|
|
24508
|
+
);
|
|
24509
|
+
const whiteboardElements = kernelContainer.db.prepare("SELECT * FROM whiteboard_elements WHERE lesson_id = ?").all(id);
|
|
24510
|
+
const insertElement = kernelContainer.db.prepare(
|
|
24511
|
+
"INSERT INTO whiteboard_elements (id, lesson_id, type, data, created_at) VALUES (?, ?, ?, ?, ?)"
|
|
24512
|
+
);
|
|
24513
|
+
for (const el of whiteboardElements) {
|
|
24514
|
+
insertElement.run((0, import_uuid16.v7)(), newId, el.type, el.data, now);
|
|
24515
|
+
}
|
|
24516
|
+
});
|
|
24517
|
+
cloneTx();
|
|
22625
24518
|
const cloned = kernelContainer.db.prepare(
|
|
22626
24519
|
`
|
|
22627
24520
|
SELECT l.*, u.name as creator_name, 0 as enrollment_count
|
|
@@ -22639,9 +24532,11 @@ Provide a short, friendly, and helpful hint (1-2 sentences) directly related to
|
|
|
22639
24532
|
|
|
22640
24533
|
// server/routes/workspace.ts
|
|
22641
24534
|
var import_path12 = __toESM(require("path"), 1);
|
|
24535
|
+
var DIAGNOSTIC_MIN_INTERVAL_MS = 1e3;
|
|
24536
|
+
var diagnosticLastReportAt = /* @__PURE__ */ new Map();
|
|
22642
24537
|
function registerWorkspaceRoutes(ctx) {
|
|
22643
24538
|
const { app, MF_REMOTE_CACHE: MF_REMOTE_CACHE2 } = ctx;
|
|
22644
|
-
app.get("/api/events", requireAuth("administrator"), (req, res) => {
|
|
24539
|
+
app.get("/api/events", requireAuth("administrator", "teacher"), (req, res) => {
|
|
22645
24540
|
try {
|
|
22646
24541
|
const events = kernelContainer.db.prepare("SELECT * FROM events ORDER BY timestamp DESC LIMIT 50").all();
|
|
22647
24542
|
res.json(events);
|
|
@@ -22649,6 +24544,69 @@ function registerWorkspaceRoutes(ctx) {
|
|
|
22649
24544
|
sendSafeError(res, e);
|
|
22650
24545
|
}
|
|
22651
24546
|
});
|
|
24547
|
+
app.post("/api/diagnostics/report", requireAuth(), (req, res) => {
|
|
24548
|
+
try {
|
|
24549
|
+
const data = req.body || {};
|
|
24550
|
+
const session = req.session || {};
|
|
24551
|
+
const actorId = session.userId || session.studentId || "unknown";
|
|
24552
|
+
if (!data.studentId || !data.error) {
|
|
24553
|
+
return res.json({ success: true });
|
|
24554
|
+
}
|
|
24555
|
+
const isTeacherOrAdmin = session.role === "teacher" || session.role === "administrator";
|
|
24556
|
+
if (!isTeacherOrAdmin && actorId !== data.studentId) {
|
|
24557
|
+
console.warn(`[Diagnostics Security] ${actorId} attempted to report error as ${data.studentId}`);
|
|
24558
|
+
return res.status(403).json({ success: false, error: "Forbidden: studentId mismatch" });
|
|
24559
|
+
}
|
|
24560
|
+
const now = Date.now();
|
|
24561
|
+
const last = diagnosticLastReportAt.get(actorId) || 0;
|
|
24562
|
+
if (now - last < DIAGNOSTIC_MIN_INTERVAL_MS) {
|
|
24563
|
+
return res.status(429).json({ success: false, error: "Too many diagnostic reports" });
|
|
24564
|
+
}
|
|
24565
|
+
diagnosticLastReportAt.set(actorId, now);
|
|
24566
|
+
if (diagnosticLastReportAt.size > 5e3) {
|
|
24567
|
+
for (const [k, v] of diagnosticLastReportAt) {
|
|
24568
|
+
if (now - v > 6e4) diagnosticLastReportAt.delete(k);
|
|
24569
|
+
}
|
|
24570
|
+
}
|
|
24571
|
+
const studentId = String(data.studentId).slice(0, 64);
|
|
24572
|
+
const studentName = isTeacherOrAdmin ? String(data.studentName || studentId).slice(0, 100) : String(session.username || session.studentId || studentId).slice(0, 100);
|
|
24573
|
+
const errorPayload = {
|
|
24574
|
+
type: typeof data.error?.type === "string" ? data.error.type.slice(0, 64) : "runtime",
|
|
24575
|
+
message: typeof data.error?.message === "string" ? data.error.message.slice(0, 2e3) : void 0,
|
|
24576
|
+
title: typeof data.error?.title === "string" ? data.error.title.slice(0, 200) : void 0,
|
|
24577
|
+
timestamp: Number(data.error?.timestamp) || now
|
|
24578
|
+
};
|
|
24579
|
+
console.warn(
|
|
24580
|
+
`[Client Diagnostics HTTP] Student ${studentId} (${studentName}) reported error [${errorPayload.type}]: ${errorPayload.message || errorPayload.title || ""}`
|
|
24581
|
+
);
|
|
24582
|
+
kernelContainer.eventBus.publish({
|
|
24583
|
+
id: `evt_err_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
|
24584
|
+
type: "student.client_error",
|
|
24585
|
+
source: "student_client",
|
|
24586
|
+
payload: {
|
|
24587
|
+
studentId,
|
|
24588
|
+
studentName,
|
|
24589
|
+
lessonId: data.lessonId || null,
|
|
24590
|
+
classId: data.classId || null,
|
|
24591
|
+
error: errorPayload
|
|
24592
|
+
},
|
|
24593
|
+
timestamp: errorPayload.timestamp,
|
|
24594
|
+
correlationId: data.lessonId || void 0
|
|
24595
|
+
});
|
|
24596
|
+
if (ctx.io) {
|
|
24597
|
+
ctx.io.emit("student-error-alert", {
|
|
24598
|
+
studentId,
|
|
24599
|
+
studentName,
|
|
24600
|
+
lessonId: data.lessonId || null,
|
|
24601
|
+
classId: data.classId || null,
|
|
24602
|
+
error: errorPayload
|
|
24603
|
+
});
|
|
24604
|
+
}
|
|
24605
|
+
res.json({ success: true });
|
|
24606
|
+
} catch (e) {
|
|
24607
|
+
sendSafeError(res, e);
|
|
24608
|
+
}
|
|
24609
|
+
});
|
|
22652
24610
|
app.get("/api/mfe/remotes", (req, res) => {
|
|
22653
24611
|
try {
|
|
22654
24612
|
const name = req.query.name;
|
|
@@ -23035,7 +24993,7 @@ var import_crypto9 = __toESM(require("crypto"), 1);
|
|
|
23035
24993
|
var import_bcryptjs2 = __toESM(require("bcryptjs"), 1);
|
|
23036
24994
|
function registerRosterRoutes(ctx) {
|
|
23037
24995
|
const { app, io, loginLimiter } = ctx;
|
|
23038
|
-
app.get("/api/classes", (req, res) => {
|
|
24996
|
+
app.get("/api/classes", requireAuth(), (req, res) => {
|
|
23039
24997
|
try {
|
|
23040
24998
|
const classes = kernelContainer.db.prepare(
|
|
23041
24999
|
`
|
|
@@ -23049,22 +25007,25 @@ function registerRosterRoutes(ctx) {
|
|
|
23049
25007
|
).all();
|
|
23050
25008
|
res.json(classes);
|
|
23051
25009
|
} catch (e) {
|
|
23052
|
-
res
|
|
25010
|
+
sendSafeError(res, e);
|
|
23053
25011
|
}
|
|
23054
25012
|
});
|
|
23055
|
-
app.get("/api/students", (req, res) => {
|
|
25013
|
+
app.get("/api/students", requireAuth(), (req, res) => {
|
|
23056
25014
|
try {
|
|
23057
|
-
const students = kernelContainer.db.prepare(
|
|
25015
|
+
const students = kernelContainer.db.prepare(
|
|
25016
|
+
"SELECT id, student_number, name, email, avatar, locked_lesson_id, private_notes, created_at FROM students ORDER BY created_at DESC"
|
|
25017
|
+
).all();
|
|
23058
25018
|
res.json(students);
|
|
23059
25019
|
} catch (e) {
|
|
23060
|
-
res
|
|
25020
|
+
sendSafeError(res, e);
|
|
23061
25021
|
}
|
|
23062
25022
|
});
|
|
23063
|
-
app.get("/api/classes/:id/students", (req, res) => {
|
|
25023
|
+
app.get("/api/classes/:id/students", requireAuth(), (req, res) => {
|
|
23064
25024
|
try {
|
|
23065
25025
|
const students = kernelContainer.db.prepare(
|
|
23066
25026
|
`
|
|
23067
|
-
SELECT s
|
|
25027
|
+
SELECT s.id, s.student_number, s.name, s.email, s.avatar, s.locked_lesson_id, s.private_notes, s.created_at, cs.joined_at
|
|
25028
|
+
FROM students s
|
|
23068
25029
|
INNER JOIN class_students cs ON s.id = cs.student_id
|
|
23069
25030
|
WHERE cs.class_id = ?
|
|
23070
25031
|
ORDER BY cs.joined_at DESC
|
|
@@ -23541,7 +25502,7 @@ function registerRosterRoutes(ctx) {
|
|
|
23541
25502
|
sendSafeError(res, e);
|
|
23542
25503
|
}
|
|
23543
25504
|
});
|
|
23544
|
-
app.get("/api/students/:id/read_notifications", (req, res) => {
|
|
25505
|
+
app.get("/api/students/:id/read_notifications", requireAuth(), (req, res) => {
|
|
23545
25506
|
try {
|
|
23546
25507
|
const rows = kernelContainer.db.prepare("SELECT notification_id FROM student_read_notifications WHERE student_id = ?").all(req.params.id);
|
|
23547
25508
|
res.json(rows.map((r) => r.notification_id));
|
|
@@ -23663,7 +25624,7 @@ function registerRosterRoutes(ctx) {
|
|
|
23663
25624
|
res.status(550).json({ error: e.message });
|
|
23664
25625
|
}
|
|
23665
25626
|
});
|
|
23666
|
-
app.get("/api/labs", (req, res) => {
|
|
25627
|
+
app.get("/api/labs", requireAuth(), (req, res) => {
|
|
23667
25628
|
try {
|
|
23668
25629
|
const labs = kernelContainer.db.prepare("SELECT * FROM computer_labs ORDER BY created_at DESC").all();
|
|
23669
25630
|
res.json(labs);
|
|
@@ -23699,7 +25660,7 @@ function registerRosterRoutes(ctx) {
|
|
|
23699
25660
|
sendSafeError(res, e);
|
|
23700
25661
|
}
|
|
23701
25662
|
});
|
|
23702
|
-
app.get("/api/classes/:classId/seats", (req, res) => {
|
|
25663
|
+
app.get("/api/classes/:classId/seats", requireAuth(), (req, res) => {
|
|
23703
25664
|
try {
|
|
23704
25665
|
const classInfo = kernelContainer.db.prepare("SELECT lab_id FROM classes WHERE id = ?").get(req.params.classId);
|
|
23705
25666
|
const labId = classInfo ? classInfo.lab_id : null;
|
|
@@ -23784,11 +25745,8 @@ function registerRosterRoutes(ctx) {
|
|
|
23784
25745
|
sendSafeError(res, e);
|
|
23785
25746
|
}
|
|
23786
25747
|
});
|
|
23787
|
-
app.get("/api/students/:id/export", (req, res) => {
|
|
25748
|
+
app.get("/api/students/:id/export", requireAuth("teacher", "administrator"), (req, res) => {
|
|
23788
25749
|
try {
|
|
23789
|
-
if (!checkIsTeacherOrAdmin(req)) {
|
|
23790
|
-
return res.status(403).json({ error: "Only teachers and administrators can export student data" });
|
|
23791
|
-
}
|
|
23792
25750
|
const studentId = req.params.id;
|
|
23793
25751
|
const student = kernelContainer.db.prepare("SELECT * FROM students WHERE id = ?").get(studentId);
|
|
23794
25752
|
if (!student) return res.status(404).json({ error: "Student not found" });
|
|
@@ -23848,10 +25806,16 @@ function registerRosterRoutes(ctx) {
|
|
|
23848
25806
|
sendSafeError(res, e);
|
|
23849
25807
|
}
|
|
23850
25808
|
});
|
|
23851
|
-
app.get("/api/students/:id/progress", (req, res) => {
|
|
25809
|
+
app.get("/api/students/:id/progress", requireAuth(), (req, res) => {
|
|
23852
25810
|
try {
|
|
25811
|
+
const session = req.session;
|
|
25812
|
+
const isPrivileged = session && (session.role === "teacher" || session.role === "administrator");
|
|
25813
|
+
const currentUserId = session?.userId || session?.studentId;
|
|
23853
25814
|
const studentRow = kernelContainer.db.prepare("SELECT id, student_number FROM students WHERE id = ? OR student_number = ?").get(req.params.id, req.params.id);
|
|
23854
25815
|
const studentId = studentRow ? studentRow.id : req.params.id;
|
|
25816
|
+
if (!isPrivileged && currentUserId !== studentId && currentUserId !== (studentRow?.student_number ?? null)) {
|
|
25817
|
+
return res.status(403).json({ error: "Forbidden: Cannot read another student progress" });
|
|
25818
|
+
}
|
|
23855
25819
|
const progress = kernelContainer.db.prepare(
|
|
23856
25820
|
`
|
|
23857
25821
|
SELECT slp.*, l.title as lesson_title
|
|
@@ -23961,7 +25925,7 @@ function registerRosterRoutes(ctx) {
|
|
|
23961
25925
|
sendSafeError(res, e);
|
|
23962
25926
|
}
|
|
23963
25927
|
});
|
|
23964
|
-
app.get("/api/classes/:id/progress", (req, res) => {
|
|
25928
|
+
app.get("/api/classes/:id/progress", requireAuth(), (req, res) => {
|
|
23965
25929
|
try {
|
|
23966
25930
|
const progress = kernelContainer.db.prepare(
|
|
23967
25931
|
`
|
|
@@ -23978,7 +25942,7 @@ function registerRosterRoutes(ctx) {
|
|
|
23978
25942
|
sendSafeError(res, e);
|
|
23979
25943
|
}
|
|
23980
25944
|
});
|
|
23981
|
-
app.get("/api/classes/:classId/lessons/:lessonId/progress", (req, res) => {
|
|
25945
|
+
app.get("/api/classes/:classId/lessons/:lessonId/progress", requireAuth(), (req, res) => {
|
|
23982
25946
|
try {
|
|
23983
25947
|
const progress = kernelContainer.db.prepare(
|
|
23984
25948
|
`
|
|
@@ -24008,7 +25972,7 @@ function registerRosterRoutes(ctx) {
|
|
|
24008
25972
|
sendSafeError(res, e);
|
|
24009
25973
|
}
|
|
24010
25974
|
});
|
|
24011
|
-
app.get("/api/classes/:classId/dashboard", (req, res) => {
|
|
25975
|
+
app.get("/api/classes/:classId/dashboard", requireAuth(), (req, res) => {
|
|
24012
25976
|
try {
|
|
24013
25977
|
const assignments = kernelContainer.db.prepare("SELECT * FROM assignments WHERE class_id = ? ORDER BY created_at DESC").all(req.params.classId);
|
|
24014
25978
|
const recentSubmissions = kernelContainer.db.prepare(
|
|
@@ -24161,7 +26125,7 @@ function registerRosterRoutes(ctx) {
|
|
|
24161
26125
|
// server/routes/assignments.ts
|
|
24162
26126
|
function registerAssignmentsRoutes(ctx) {
|
|
24163
26127
|
const { app } = ctx;
|
|
24164
|
-
app.get("/api/classes/:classId/assignments", (req, res) => {
|
|
26128
|
+
app.get("/api/classes/:classId/assignments", requireAuth(), (req, res) => {
|
|
24165
26129
|
try {
|
|
24166
26130
|
const assignments = kernelContainer.db.prepare("SELECT * FROM assignments WHERE class_id = ? ORDER BY created_at DESC").all(req.params.classId);
|
|
24167
26131
|
res.json(assignments);
|
|
@@ -24299,7 +26263,7 @@ Output pure JSON only without markdown code blocks, matching this structure:
|
|
|
24299
26263
|
sendSafeError(res, e);
|
|
24300
26264
|
}
|
|
24301
26265
|
});
|
|
24302
|
-
app.get("/api/assignments/:id/submissions", (req, res) => {
|
|
26266
|
+
app.get("/api/assignments/:id/submissions", requireAuth(), (req, res) => {
|
|
24303
26267
|
try {
|
|
24304
26268
|
const submissions = kernelContainer.db.prepare(
|
|
24305
26269
|
`
|
|
@@ -24414,10 +26378,389 @@ Provide a grade score (0-100) and brief feedback. Ensure you output in this exac
|
|
|
24414
26378
|
);
|
|
24415
26379
|
}
|
|
24416
26380
|
|
|
26381
|
+
// server/routes/assignment-hub.ts
|
|
26382
|
+
var import_path15 = __toESM(require("path"), 1);
|
|
26383
|
+
var import_fs13 = __toESM(require("fs"), 1);
|
|
26384
|
+
var import_crypto10 = __toESM(require("crypto"), 1);
|
|
26385
|
+
var import_express2 = __toESM(require("express"), 1);
|
|
26386
|
+
|
|
26387
|
+
// server/utils/upload.ts
|
|
26388
|
+
var import_path14 = __toESM(require("path"), 1);
|
|
26389
|
+
var MAGIC_BYTES = {
|
|
26390
|
+
".pdf": [[37, 80, 68, 70]],
|
|
26391
|
+
".pptx": [[80, 75, 3, 4]],
|
|
26392
|
+
".zip": [[80, 75, 3, 4]],
|
|
26393
|
+
".jpg": [[255, 216, 255]],
|
|
26394
|
+
".jpeg": [[255, 216, 255]],
|
|
26395
|
+
".png": [[137, 80, 78, 71]],
|
|
26396
|
+
".gif": [[71, 73, 70, 56]],
|
|
26397
|
+
".webp": [[82, 73, 70, 70]]
|
|
26398
|
+
};
|
|
26399
|
+
var BLOCKED_EXTENSIONS2 = [".exe", ".sh", ".bat", ".cmd", ".dll", ".so", ".dylib", ".scr", ".msi", ".ps1"];
|
|
26400
|
+
function validateMagicBytes2(buffer, fileName) {
|
|
26401
|
+
const ext = import_path14.default.extname(fileName || "").toLowerCase();
|
|
26402
|
+
const signatures = MAGIC_BYTES[ext];
|
|
26403
|
+
if (!signatures) return true;
|
|
26404
|
+
return signatures.some((sig) => sig.every((byte, i) => buffer[i] === byte));
|
|
26405
|
+
}
|
|
26406
|
+
var SIZE_LIMITS = {
|
|
26407
|
+
courseware: 50 * 1024 * 1024,
|
|
26408
|
+
plugin: 10 * 1024 * 1024,
|
|
26409
|
+
assignment: 20 * 1024 * 1024,
|
|
26410
|
+
generic: 10 * 1024 * 1024
|
|
26411
|
+
};
|
|
26412
|
+
|
|
26413
|
+
// server/routes/assignment-hub.ts
|
|
26414
|
+
var ALLOWED_ASSIGNMENT_EXT = /* @__PURE__ */ new Set([
|
|
26415
|
+
".pdf",
|
|
26416
|
+
".doc",
|
|
26417
|
+
".docx",
|
|
26418
|
+
".ppt",
|
|
26419
|
+
".pptx",
|
|
26420
|
+
".xls",
|
|
26421
|
+
".xlsx",
|
|
26422
|
+
".csv",
|
|
26423
|
+
".txt",
|
|
26424
|
+
".md",
|
|
26425
|
+
".rtf",
|
|
26426
|
+
".odt",
|
|
26427
|
+
".odp",
|
|
26428
|
+
".ods",
|
|
26429
|
+
".png",
|
|
26430
|
+
".jpg",
|
|
26431
|
+
".jpeg",
|
|
26432
|
+
".gif",
|
|
26433
|
+
".webp",
|
|
26434
|
+
".bmp",
|
|
26435
|
+
".svg",
|
|
26436
|
+
".heic",
|
|
26437
|
+
".zip",
|
|
26438
|
+
".py",
|
|
26439
|
+
".js",
|
|
26440
|
+
".mjs",
|
|
26441
|
+
".cjs",
|
|
26442
|
+
".ts",
|
|
26443
|
+
".tsx",
|
|
26444
|
+
".jsx",
|
|
26445
|
+
".java",
|
|
26446
|
+
".c",
|
|
26447
|
+
".h",
|
|
26448
|
+
".cpp",
|
|
26449
|
+
".cs",
|
|
26450
|
+
".go",
|
|
26451
|
+
".rs",
|
|
26452
|
+
".rb",
|
|
26453
|
+
".php",
|
|
26454
|
+
".sql",
|
|
26455
|
+
".json",
|
|
26456
|
+
".xml",
|
|
26457
|
+
".html",
|
|
26458
|
+
".css",
|
|
26459
|
+
".ino"
|
|
26460
|
+
]);
|
|
26461
|
+
var ZIP_CONTAINER_EXT = /* @__PURE__ */ new Set([".zip", ".pptx", ".docx", ".xlsx", ".odt", ".odp", ".ods", ".epub"]);
|
|
26462
|
+
var MAX_FILE_SIZE = 50 * 1024 * 1024;
|
|
26463
|
+
function safeSegment(value) {
|
|
26464
|
+
const cleaned = String(value || "").replace(/[^A-Za-z0-9._-]/g, "");
|
|
26465
|
+
return cleaned.length > 0 ? cleaned.slice(0, 96) : "unknown";
|
|
26466
|
+
}
|
|
26467
|
+
function decodeFileName(raw) {
|
|
26468
|
+
const text = String(raw ?? "").trim();
|
|
26469
|
+
if (!text) return "";
|
|
26470
|
+
try {
|
|
26471
|
+
return decodeURIComponent(text);
|
|
26472
|
+
} catch {
|
|
26473
|
+
return text;
|
|
26474
|
+
}
|
|
26475
|
+
}
|
|
26476
|
+
function assignmentStorageDir(assignmentId, studentId) {
|
|
26477
|
+
return import_path15.default.join(process.cwd(), "storage", "assignments", safeSegment(assignmentId), safeSegment(studentId));
|
|
26478
|
+
}
|
|
26479
|
+
function registerAssignmentHubRoutes(ctx) {
|
|
26480
|
+
const { app } = ctx;
|
|
26481
|
+
const describeRequester = (req) => {
|
|
26482
|
+
const session = req.session;
|
|
26483
|
+
const role = session?.role;
|
|
26484
|
+
const isPrivileged = role === "teacher" || role === "administrator" || role === "admin";
|
|
26485
|
+
const studentId = session?.userId || session?.studentId || null;
|
|
26486
|
+
return { isPrivileged, studentId: studentId ? String(studentId) : null };
|
|
26487
|
+
};
|
|
26488
|
+
app.post("/api/assignments", requireAuth("teacher", "administrator"), async (req, res) => {
|
|
26489
|
+
try {
|
|
26490
|
+
const cmd = kernelContainer.commandBus.createCommand("assignment.create", req.body || {}, getActorId(req));
|
|
26491
|
+
const result = await kernelContainer.commandBus.execute(cmd);
|
|
26492
|
+
res.json(result);
|
|
26493
|
+
} catch (e) {
|
|
26494
|
+
sendSafeError(res, e);
|
|
26495
|
+
}
|
|
26496
|
+
});
|
|
26497
|
+
app.get("/api/assignments", requireAuth(), async (req, res) => {
|
|
26498
|
+
try {
|
|
26499
|
+
const payload = {
|
|
26500
|
+
lessonId: req.query.lessonId,
|
|
26501
|
+
classId: req.query.classId,
|
|
26502
|
+
elementId: req.query.elementId,
|
|
26503
|
+
includeArchived: req.query.includeArchived === "1" || req.query.includeArchived === "true"
|
|
26504
|
+
};
|
|
26505
|
+
const { isPrivileged, studentId } = describeRequester(req);
|
|
26506
|
+
const requestedStudent = typeof req.query.studentId === "string" ? req.query.studentId : void 0;
|
|
26507
|
+
if (isPrivileged && requestedStudent) {
|
|
26508
|
+
payload.studentId = requestedStudent;
|
|
26509
|
+
} else if (!isPrivileged && studentId) {
|
|
26510
|
+
payload.studentId = studentId;
|
|
26511
|
+
}
|
|
26512
|
+
const cmd = kernelContainer.commandBus.createCommand("assignment.list", payload, getActorId(req), {
|
|
26513
|
+
silent: true
|
|
26514
|
+
});
|
|
26515
|
+
const result = await kernelContainer.commandBus.execute(cmd);
|
|
26516
|
+
res.json(result);
|
|
26517
|
+
} catch (e) {
|
|
26518
|
+
sendSafeError(res, e);
|
|
26519
|
+
}
|
|
26520
|
+
});
|
|
26521
|
+
app.get("/api/assignments/:assignmentId", requireAuth(), async (req, res) => {
|
|
26522
|
+
try {
|
|
26523
|
+
const { isPrivileged, studentId } = describeRequester(req);
|
|
26524
|
+
const payload = { assignmentId: req.params.assignmentId };
|
|
26525
|
+
const requestedStudent = typeof req.query.studentId === "string" ? req.query.studentId : void 0;
|
|
26526
|
+
if (isPrivileged && requestedStudent) {
|
|
26527
|
+
payload.studentId = requestedStudent;
|
|
26528
|
+
} else if (!isPrivileged && studentId) {
|
|
26529
|
+
payload.studentId = studentId;
|
|
26530
|
+
}
|
|
26531
|
+
if (isPrivileged) {
|
|
26532
|
+
payload.includePeerProgress = true;
|
|
26533
|
+
}
|
|
26534
|
+
const cmd = kernelContainer.commandBus.createCommand("assignment.get", payload, getActorId(req), { silent: true });
|
|
26535
|
+
const result = await kernelContainer.commandBus.execute(cmd);
|
|
26536
|
+
res.json(result);
|
|
26537
|
+
} catch (e) {
|
|
26538
|
+
sendSafeError(res, e);
|
|
26539
|
+
}
|
|
26540
|
+
});
|
|
26541
|
+
app.post(
|
|
26542
|
+
"/api/assignments/:assignmentId/files",
|
|
26543
|
+
requireAuth(),
|
|
26544
|
+
import_express2.default.raw({ type: () => true, limit: MAX_FILE_SIZE + 1024 * 1024 }),
|
|
26545
|
+
async (req, res) => {
|
|
26546
|
+
try {
|
|
26547
|
+
const assignmentId = req.params.assignmentId;
|
|
26548
|
+
const { isPrivileged, studentId: sessionStudentId } = describeRequester(req);
|
|
26549
|
+
const headerStudent = decodeFileName(req.header("x-student-id") || "");
|
|
26550
|
+
const ownerStudentId = isPrivileged ? headerStudent || sessionStudentId : sessionStudentId;
|
|
26551
|
+
if (!ownerStudentId) {
|
|
26552
|
+
return res.status(400).json({ success: false, error: "Missing studentId" });
|
|
26553
|
+
}
|
|
26554
|
+
const assignment = kernelContainer.db.prepare("SELECT id, max_files, max_file_size, status FROM plugin_assignments WHERE id = ?").get(assignmentId);
|
|
26555
|
+
if (!assignment) {
|
|
26556
|
+
return res.status(404).json({ success: false, error: `Assignment not found: ${assignmentId}` });
|
|
26557
|
+
}
|
|
26558
|
+
const fileName = decodeFileName(req.header("x-file-name") || "");
|
|
26559
|
+
if (!fileName) {
|
|
26560
|
+
return res.status(400).json({ success: false, error: "Missing X-File-Name header" });
|
|
26561
|
+
}
|
|
26562
|
+
const ext = import_path15.default.extname(fileName).toLowerCase();
|
|
26563
|
+
if (!ALLOWED_ASSIGNMENT_EXT.has(ext)) {
|
|
26564
|
+
return res.status(400).json({ success: false, error: `File type not allowed: ${ext || "(none)"}` });
|
|
26565
|
+
}
|
|
26566
|
+
if (BLOCKED_EXTENSIONS2.includes(ext)) {
|
|
26567
|
+
return res.status(400).json({ success: false, error: `File type blocked: ${ext}` });
|
|
26568
|
+
}
|
|
26569
|
+
const buffer = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
|
|
26570
|
+
if (buffer.length === 0) {
|
|
26571
|
+
return res.status(400).json({ success: false, error: "Empty file body" });
|
|
26572
|
+
}
|
|
26573
|
+
const sizeLimit = Math.min(Number(assignment.max_file_size) || SIZE_LIMITS.assignment, MAX_FILE_SIZE);
|
|
26574
|
+
if (buffer.length > sizeLimit) {
|
|
26575
|
+
return res.status(413).json({ success: false, error: `File too large (limit ${sizeLimit} bytes)` });
|
|
26576
|
+
}
|
|
26577
|
+
if (ZIP_CONTAINER_EXT.has(ext) && !(buffer[0] === 80 && buffer[1] === 75)) {
|
|
26578
|
+
return res.status(400).json({ success: false, error: "File content does not match its extension" });
|
|
26579
|
+
}
|
|
26580
|
+
if (!validateMagicBytes2(buffer, fileName)) {
|
|
26581
|
+
return res.status(400).json({ success: false, error: "File content does not match its extension" });
|
|
26582
|
+
}
|
|
26583
|
+
const existingCount = kernelContainer.db.prepare(
|
|
26584
|
+
"SELECT COUNT(*) AS c FROM plugin_assignment_files WHERE assignment_id = ? AND student_id = ? AND deleted_at IS NULL"
|
|
26585
|
+
).get(assignmentId, ownerStudentId).c;
|
|
26586
|
+
if (existingCount >= (Number(assignment.max_files) || 10)) {
|
|
26587
|
+
return res.status(409).json({ success: false, error: `At most ${assignment.max_files} files are allowed` });
|
|
26588
|
+
}
|
|
26589
|
+
const dir = assignmentStorageDir(assignmentId, ownerStudentId);
|
|
26590
|
+
import_fs13.default.mkdirSync(dir, { recursive: true });
|
|
26591
|
+
const storedName = `${import_crypto10.default.randomUUID()}${ext}`;
|
|
26592
|
+
const storedPath = import_path15.default.join(dir, storedName);
|
|
26593
|
+
import_fs13.default.writeFileSync(storedPath, buffer);
|
|
26594
|
+
const fileId = "af-" + import_crypto10.default.randomUUID();
|
|
26595
|
+
const sha256 = import_crypto10.default.createHash("sha256").update(buffer).digest("hex");
|
|
26596
|
+
kernelContainer.db.prepare(
|
|
26597
|
+
`INSERT INTO plugin_assignment_files
|
|
26598
|
+
(id, assignment_id, submission_id, version_id, student_id, original_name, stored_path, size, mime, sha256, uploaded_at)
|
|
26599
|
+
VALUES (?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?)`
|
|
26600
|
+
).run(
|
|
26601
|
+
fileId,
|
|
26602
|
+
assignmentId,
|
|
26603
|
+
ownerStudentId,
|
|
26604
|
+
fileName,
|
|
26605
|
+
import_path15.default.relative(process.cwd(), storedPath),
|
|
26606
|
+
buffer.length,
|
|
26607
|
+
req.header("content-type") || null,
|
|
26608
|
+
sha256,
|
|
26609
|
+
Date.now()
|
|
26610
|
+
);
|
|
26611
|
+
res.json({
|
|
26612
|
+
success: true,
|
|
26613
|
+
file: { id: fileId, name: fileName, size: buffer.length, sha256, studentId: ownerStudentId }
|
|
26614
|
+
});
|
|
26615
|
+
} catch (e) {
|
|
26616
|
+
sendSafeError(res, e);
|
|
26617
|
+
}
|
|
26618
|
+
}
|
|
26619
|
+
);
|
|
26620
|
+
app.get("/api/assignments/:assignmentId/files", requireAuth(), async (req, res) => {
|
|
26621
|
+
try {
|
|
26622
|
+
const { isPrivileged, studentId } = describeRequester(req);
|
|
26623
|
+
const rows = kernelContainer.db.prepare(
|
|
26624
|
+
`SELECT id, student_id, original_name, size, mime, sha256, uploaded_at, version_id
|
|
26625
|
+
FROM plugin_assignment_files
|
|
26626
|
+
WHERE assignment_id = ? AND deleted_at IS NULL
|
|
26627
|
+
ORDER BY uploaded_at DESC`
|
|
26628
|
+
).all(req.params.assignmentId);
|
|
26629
|
+
const files = isPrivileged ? rows : rows.filter((r) => r.student_id === studentId);
|
|
26630
|
+
res.json({ success: true, files });
|
|
26631
|
+
} catch (e) {
|
|
26632
|
+
sendSafeError(res, e);
|
|
26633
|
+
}
|
|
26634
|
+
});
|
|
26635
|
+
app.get("/api/assignments/:assignmentId/files/:fileId", requireAuth(), async (req, res) => {
|
|
26636
|
+
try {
|
|
26637
|
+
const file = kernelContainer.db.prepare(
|
|
26638
|
+
`SELECT id, student_id, original_name, stored_path, mime, deleted_at
|
|
26639
|
+
FROM plugin_assignment_files WHERE id = ? AND assignment_id = ?`
|
|
26640
|
+
).get(req.params.fileId, req.params.assignmentId);
|
|
26641
|
+
const { isPrivileged, studentId } = describeRequester(req);
|
|
26642
|
+
if (!file || file.deleted_at) {
|
|
26643
|
+
return res.status(404).json({ success: false, error: "File not found" });
|
|
26644
|
+
}
|
|
26645
|
+
if (!isPrivileged && file.student_id !== studentId) {
|
|
26646
|
+
const assigned = kernelContainer.db.prepare(
|
|
26647
|
+
`SELECT 1 AS ok FROM plugin_assignment_files f
|
|
26648
|
+
JOIN plugin_submission_versions v ON v.id = f.version_id
|
|
26649
|
+
JOIN plugin_peer_review_tasks t ON t.submission_id = v.submission_id
|
|
26650
|
+
WHERE f.id = ? AND t.reviewer_id = ? LIMIT 1`
|
|
26651
|
+
).get(req.params.fileId, studentId);
|
|
26652
|
+
if (!assigned) {
|
|
26653
|
+
return res.status(403).json({ success: false, error: "Forbidden: Cannot read another student file" });
|
|
26654
|
+
}
|
|
26655
|
+
}
|
|
26656
|
+
const absPath = import_path15.default.resolve(process.cwd(), file.stored_path);
|
|
26657
|
+
const root = import_path15.default.join(process.cwd(), "storage", "assignments");
|
|
26658
|
+
if (!absPath.startsWith(root) || !import_fs13.default.existsSync(absPath)) {
|
|
26659
|
+
return res.status(404).json({ success: false, error: "File not found on disk" });
|
|
26660
|
+
}
|
|
26661
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
26662
|
+
res.setHeader("Cache-Control", "private, no-store");
|
|
26663
|
+
res.download(absPath, file.original_name);
|
|
26664
|
+
} catch (e) {
|
|
26665
|
+
sendSafeError(res, e);
|
|
26666
|
+
}
|
|
26667
|
+
});
|
|
26668
|
+
app.delete("/api/assignments/:assignmentId/files/:fileId", requireAuth(), async (req, res) => {
|
|
26669
|
+
try {
|
|
26670
|
+
const file = kernelContainer.db.prepare(
|
|
26671
|
+
`SELECT id, student_id, version_id, deleted_at FROM plugin_assignment_files
|
|
26672
|
+
WHERE id = ? AND assignment_id = ?`
|
|
26673
|
+
).get(req.params.fileId, req.params.assignmentId);
|
|
26674
|
+
const { isPrivileged, studentId } = describeRequester(req);
|
|
26675
|
+
if (!file || file.deleted_at) {
|
|
26676
|
+
return res.status(404).json({ success: false, error: "File not found" });
|
|
26677
|
+
}
|
|
26678
|
+
if (!isPrivileged && file.student_id !== studentId) {
|
|
26679
|
+
return res.status(403).json({ success: false, error: "Forbidden: Cannot delete another student file" });
|
|
26680
|
+
}
|
|
26681
|
+
if (file.version_id) {
|
|
26682
|
+
return res.status(409).json({ success: false, error: "File already attached to a submission version" });
|
|
26683
|
+
}
|
|
26684
|
+
kernelContainer.db.prepare("UPDATE plugin_assignment_files SET deleted_at = ? WHERE id = ?").run(Date.now(), req.params.fileId);
|
|
26685
|
+
res.json({ success: true });
|
|
26686
|
+
} catch (e) {
|
|
26687
|
+
sendSafeError(res, e);
|
|
26688
|
+
}
|
|
26689
|
+
});
|
|
26690
|
+
app.post("/api/assignments/:assignmentId/submit", requireAuth(), async (req, res) => {
|
|
26691
|
+
try {
|
|
26692
|
+
const { isPrivileged, studentId } = describeRequester(req);
|
|
26693
|
+
const body = req.body || {};
|
|
26694
|
+
const targetStudentId = isPrivileged ? String(body.studentId || "") : String(studentId || "");
|
|
26695
|
+
if (!targetStudentId) {
|
|
26696
|
+
return res.status(400).json({ success: false, error: "Missing studentId" });
|
|
26697
|
+
}
|
|
26698
|
+
const cmd = kernelContainer.commandBus.createCommand(
|
|
26699
|
+
"assignment.submit",
|
|
26700
|
+
{
|
|
26701
|
+
assignmentId: req.params.assignmentId,
|
|
26702
|
+
studentId: targetStudentId,
|
|
26703
|
+
fileIds: Array.isArray(body.fileIds) ? body.fileIds : [],
|
|
26704
|
+
textContent: body.textContent,
|
|
26705
|
+
linkUrl: body.linkUrl
|
|
26706
|
+
},
|
|
26707
|
+
getActorId(req)
|
|
26708
|
+
);
|
|
26709
|
+
const result = await kernelContainer.commandBus.execute(cmd);
|
|
26710
|
+
res.json(result);
|
|
26711
|
+
} catch (e) {
|
|
26712
|
+
sendSafeError(res, e);
|
|
26713
|
+
}
|
|
26714
|
+
});
|
|
26715
|
+
app.post("/api/assignments/:assignmentId/peer-review", requireAuth(), async (req, res) => {
|
|
26716
|
+
try {
|
|
26717
|
+
const { isPrivileged, studentId } = describeRequester(req);
|
|
26718
|
+
const body = req.body || {};
|
|
26719
|
+
const reviewerId = isPrivileged ? String(body.reviewerId || body.studentId || "") : String(studentId || "");
|
|
26720
|
+
if (!reviewerId) {
|
|
26721
|
+
return res.status(400).json({ success: false, error: "Missing reviewerId" });
|
|
26722
|
+
}
|
|
26723
|
+
const submissionId = String(body.submissionId || "");
|
|
26724
|
+
if (!submissionId) {
|
|
26725
|
+
return res.status(400).json({ success: false, error: "Missing submissionId" });
|
|
26726
|
+
}
|
|
26727
|
+
const cmd = kernelContainer.commandBus.createCommand(
|
|
26728
|
+
"assignment.peer_review",
|
|
26729
|
+
{
|
|
26730
|
+
submissionId,
|
|
26731
|
+
reviewerId,
|
|
26732
|
+
score: body.score,
|
|
26733
|
+
comment: body.comment,
|
|
26734
|
+
taskId: body.taskId
|
|
26735
|
+
},
|
|
26736
|
+
getActorId(req)
|
|
26737
|
+
);
|
|
26738
|
+
const result = await kernelContainer.commandBus.execute(cmd);
|
|
26739
|
+
res.json(result);
|
|
26740
|
+
} catch (e) {
|
|
26741
|
+
sendSafeError(res, e);
|
|
26742
|
+
}
|
|
26743
|
+
});
|
|
26744
|
+
app.post("/api/assignments/:assignmentId/assign-peer-reviews", requireAuth("teacher", "administrator"), async (req, res) => {
|
|
26745
|
+
try {
|
|
26746
|
+
const body = req.body || {};
|
|
26747
|
+
const cmd = kernelContainer.commandBus.createCommand(
|
|
26748
|
+
"assignment.assign_peer_reviews",
|
|
26749
|
+
{ assignmentId: req.params.assignmentId, reviewerCount: body.reviewerCount, dueAt: body.dueAt },
|
|
26750
|
+
getActorId(req)
|
|
26751
|
+
);
|
|
26752
|
+
const result = await kernelContainer.commandBus.execute(cmd);
|
|
26753
|
+
res.json(result);
|
|
26754
|
+
} catch (e) {
|
|
26755
|
+
sendSafeError(res, e);
|
|
26756
|
+
}
|
|
26757
|
+
});
|
|
26758
|
+
}
|
|
26759
|
+
|
|
24417
26760
|
// server/routes/schedules.ts
|
|
24418
26761
|
function registerSchedulesRoutes(ctx) {
|
|
24419
26762
|
const { app } = ctx;
|
|
24420
|
-
app.get("/api/schedules/today", (req, res) => {
|
|
26763
|
+
app.get("/api/schedules/today", requireAuth(), (req, res) => {
|
|
24421
26764
|
try {
|
|
24422
26765
|
const clientDate = req.query.date || (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
24423
26766
|
const schedules = kernelContainer.db.prepare(
|
|
@@ -24442,10 +26785,10 @@ function registerSchedulesRoutes(ctx) {
|
|
|
24442
26785
|
).all(clientDate, clientDate);
|
|
24443
26786
|
res.json({ success: true, schedules });
|
|
24444
26787
|
} catch (e) {
|
|
24445
|
-
res
|
|
26788
|
+
sendSafeError(res, e);
|
|
24446
26789
|
}
|
|
24447
26790
|
});
|
|
24448
|
-
app.get("/api/schedules", (req, res) => {
|
|
26791
|
+
app.get("/api/schedules", requireAuth(), (req, res) => {
|
|
24449
26792
|
try {
|
|
24450
26793
|
const schedules = kernelContainer.db.prepare(
|
|
24451
26794
|
`
|
|
@@ -24458,10 +26801,10 @@ function registerSchedulesRoutes(ctx) {
|
|
|
24458
26801
|
).all();
|
|
24459
26802
|
res.json(schedules);
|
|
24460
26803
|
} catch (e) {
|
|
24461
|
-
res
|
|
26804
|
+
sendSafeError(res, e);
|
|
24462
26805
|
}
|
|
24463
26806
|
});
|
|
24464
|
-
app.get("/api/classes/:classId/schedules", (req, res) => {
|
|
26807
|
+
app.get("/api/classes/:classId/schedules", requireAuth(), (req, res) => {
|
|
24465
26808
|
try {
|
|
24466
26809
|
const schedules = kernelContainer.db.prepare(
|
|
24467
26810
|
`
|
|
@@ -24474,7 +26817,7 @@ function registerSchedulesRoutes(ctx) {
|
|
|
24474
26817
|
).all(req.params.classId);
|
|
24475
26818
|
res.json(schedules);
|
|
24476
26819
|
} catch (e) {
|
|
24477
|
-
res
|
|
26820
|
+
sendSafeError(res, e);
|
|
24478
26821
|
}
|
|
24479
26822
|
});
|
|
24480
26823
|
app.post("/api/classes/:classId/schedules", requireAuth("teacher", "administrator"), (req, res) => {
|
|
@@ -24509,7 +26852,7 @@ function registerSchedulesRoutes(ctx) {
|
|
|
24509
26852
|
}
|
|
24510
26853
|
});
|
|
24511
26854
|
} catch (e) {
|
|
24512
|
-
res
|
|
26855
|
+
sendSafeError(res, e);
|
|
24513
26856
|
}
|
|
24514
26857
|
});
|
|
24515
26858
|
app.put("/api/classes/:classId/schedules/:scheduleId", requireAuth("teacher", "administrator"), (req, res) => {
|
|
@@ -24532,7 +26875,7 @@ function registerSchedulesRoutes(ctx) {
|
|
|
24532
26875
|
);
|
|
24533
26876
|
res.json({ success: true });
|
|
24534
26877
|
} catch (e) {
|
|
24535
|
-
res
|
|
26878
|
+
sendSafeError(res, e);
|
|
24536
26879
|
}
|
|
24537
26880
|
});
|
|
24538
26881
|
app.delete("/api/classes/:classId/schedules/:scheduleId", requireAuth("teacher", "administrator"), (req, res) => {
|
|
@@ -24541,7 +26884,7 @@ function registerSchedulesRoutes(ctx) {
|
|
|
24541
26884
|
kernelContainer.db.prepare("DELETE FROM attendance WHERE schedule_id = ?").run(req.params.scheduleId);
|
|
24542
26885
|
res.json({ success: true });
|
|
24543
26886
|
} catch (e) {
|
|
24544
|
-
res
|
|
26887
|
+
sendSafeError(res, e);
|
|
24545
26888
|
}
|
|
24546
26889
|
});
|
|
24547
26890
|
app.post("/api/classes/:classId/schedules/batch", requireAuth("teacher", "administrator"), (req, res) => {
|
|
@@ -24570,7 +26913,7 @@ function registerSchedulesRoutes(ctx) {
|
|
|
24570
26913
|
transaction(schedules);
|
|
24571
26914
|
res.json({ success: true, count: schedules.length });
|
|
24572
26915
|
} catch (e) {
|
|
24573
|
-
res
|
|
26916
|
+
sendSafeError(res, e);
|
|
24574
26917
|
}
|
|
24575
26918
|
});
|
|
24576
26919
|
app.post("/api/timetable/ocr", requireAuth("teacher", "administrator"), async (req, res) => {
|
|
@@ -24733,7 +27076,7 @@ function registerSchedulesRoutes(ctx) {
|
|
|
24733
27076
|
stack: e.stack,
|
|
24734
27077
|
cause: e.cause
|
|
24735
27078
|
});
|
|
24736
|
-
res
|
|
27079
|
+
sendSafeError(res, e);
|
|
24737
27080
|
}
|
|
24738
27081
|
});
|
|
24739
27082
|
}
|
|
@@ -24742,7 +27085,7 @@ function registerSchedulesRoutes(ctx) {
|
|
|
24742
27085
|
init_interfaces();
|
|
24743
27086
|
function registerGradingRoutes(ctx) {
|
|
24744
27087
|
const { app } = ctx;
|
|
24745
|
-
app.get("/api/classes/:classId/attendance-summary", (req, res) => {
|
|
27088
|
+
app.get("/api/classes/:classId/attendance-summary", requireAuth(), (req, res) => {
|
|
24746
27089
|
try {
|
|
24747
27090
|
const classId = req.params.classId;
|
|
24748
27091
|
const db2 = kernelContainer.db;
|
|
@@ -24853,10 +27196,10 @@ function registerGradingRoutes(ctx) {
|
|
|
24853
27196
|
}).sort((a, b) => a.date.localeCompare(b.date));
|
|
24854
27197
|
res.json(filtered);
|
|
24855
27198
|
} catch (e) {
|
|
24856
|
-
res
|
|
27199
|
+
sendSafeError(res, e);
|
|
24857
27200
|
}
|
|
24858
27201
|
});
|
|
24859
|
-
app.get("/api/schedules/:scheduleId/attendance", (req, res) => {
|
|
27202
|
+
app.get("/api/schedules/:scheduleId/attendance", requireAuth(), (req, res) => {
|
|
24860
27203
|
try {
|
|
24861
27204
|
const attendance = kernelContainer.db.prepare(
|
|
24862
27205
|
`
|
|
@@ -24868,7 +27211,7 @@ function registerGradingRoutes(ctx) {
|
|
|
24868
27211
|
).all(req.params.scheduleId);
|
|
24869
27212
|
res.json(attendance);
|
|
24870
27213
|
} catch (e) {
|
|
24871
|
-
res
|
|
27214
|
+
sendSafeError(res, e);
|
|
24872
27215
|
}
|
|
24873
27216
|
});
|
|
24874
27217
|
app.post("/api/schedules/:scheduleId/attendance", requireAuth("teacher", "administrator"), (req, res) => {
|
|
@@ -24883,10 +27226,10 @@ function registerGradingRoutes(ctx) {
|
|
|
24883
27226
|
).run(req.params.scheduleId, studentId, status, Date.now());
|
|
24884
27227
|
res.json({ success: true });
|
|
24885
27228
|
} catch (e) {
|
|
24886
|
-
res
|
|
27229
|
+
sendSafeError(res, e);
|
|
24887
27230
|
}
|
|
24888
27231
|
});
|
|
24889
|
-
app.get("/api/classes/:classId/grade-weights", (req, res) => {
|
|
27232
|
+
app.get("/api/classes/:classId/grade-weights", requireAuth(), (req, res) => {
|
|
24890
27233
|
try {
|
|
24891
27234
|
const weights = kernelContainer.db.prepare("SELECT * FROM class_grade_weights WHERE class_id = ?").get(req.params.classId);
|
|
24892
27235
|
if (!weights) {
|
|
@@ -24900,7 +27243,7 @@ function registerGradingRoutes(ctx) {
|
|
|
24900
27243
|
}
|
|
24901
27244
|
res.json(weights);
|
|
24902
27245
|
} catch (e) {
|
|
24903
|
-
res
|
|
27246
|
+
sendSafeError(res, e);
|
|
24904
27247
|
}
|
|
24905
27248
|
});
|
|
24906
27249
|
app.post("/api/classes/:classId/grade-weights", requireAuth("teacher", "administrator"), (req, res) => {
|
|
@@ -24928,15 +27271,15 @@ function registerGradingRoutes(ctx) {
|
|
|
24928
27271
|
).run(req.params.classId, att, prog, assign, ex, Date.now());
|
|
24929
27272
|
res.json({ success: true });
|
|
24930
27273
|
} catch (e) {
|
|
24931
|
-
res
|
|
27274
|
+
sendSafeError(res, e);
|
|
24932
27275
|
}
|
|
24933
27276
|
});
|
|
24934
|
-
app.get("/api/classes/:classId/exams", (req, res) => {
|
|
27277
|
+
app.get("/api/classes/:classId/exams", requireAuth(), (req, res) => {
|
|
24935
27278
|
try {
|
|
24936
27279
|
const exams = kernelContainer.db.prepare("SELECT * FROM exams WHERE class_id = ? ORDER BY created_at DESC").all(req.params.classId);
|
|
24937
27280
|
res.json(exams);
|
|
24938
27281
|
} catch (e) {
|
|
24939
|
-
res
|
|
27282
|
+
sendSafeError(res, e);
|
|
24940
27283
|
}
|
|
24941
27284
|
});
|
|
24942
27285
|
app.post("/api/classes/:classId/exams", requireAuth("teacher", "administrator"), (req, res) => {
|
|
@@ -24952,15 +27295,15 @@ function registerGradingRoutes(ctx) {
|
|
|
24952
27295
|
).run(examId, req.params.classId, title, description || "", max_score || 100, Date.now());
|
|
24953
27296
|
res.json({ success: true, examId });
|
|
24954
27297
|
} catch (e) {
|
|
24955
|
-
res
|
|
27298
|
+
sendSafeError(res, e);
|
|
24956
27299
|
}
|
|
24957
27300
|
});
|
|
24958
|
-
app.get("/api/exams/:examId/scores", (req, res) => {
|
|
27301
|
+
app.get("/api/exams/:examId/scores", requireAuth(), (req, res) => {
|
|
24959
27302
|
try {
|
|
24960
27303
|
const scores = kernelContainer.db.prepare("SELECT * FROM exam_scores WHERE exam_id = ?").all(req.params.examId);
|
|
24961
27304
|
res.json(scores);
|
|
24962
27305
|
} catch (e) {
|
|
24963
|
-
res
|
|
27306
|
+
sendSafeError(res, e);
|
|
24964
27307
|
}
|
|
24965
27308
|
});
|
|
24966
27309
|
app.post("/api/exams/:examId/scores", requireAuth("teacher", "administrator"), (req, res) => {
|
|
@@ -24989,10 +27332,10 @@ function registerGradingRoutes(ctx) {
|
|
|
24989
27332
|
transaction(scores);
|
|
24990
27333
|
res.json({ success: true });
|
|
24991
27334
|
} catch (e) {
|
|
24992
|
-
res
|
|
27335
|
+
sendSafeError(res, e);
|
|
24993
27336
|
}
|
|
24994
27337
|
});
|
|
24995
|
-
app.get("/api/classes/:classId/semester-grades", (req, res) => {
|
|
27338
|
+
app.get("/api/classes/:classId/semester-grades", requireAuth(), (req, res) => {
|
|
24996
27339
|
try {
|
|
24997
27340
|
const classId = req.params.classId;
|
|
24998
27341
|
const semesterName = req.query.semesterName || "2026\u5E74\u6625\u5B63\u5B66\uFFFD?";
|
|
@@ -25134,7 +27477,7 @@ function registerGradingRoutes(ctx) {
|
|
|
25134
27477
|
});
|
|
25135
27478
|
res.json({ success: true, weights, students: result });
|
|
25136
27479
|
} catch (e) {
|
|
25137
|
-
res
|
|
27480
|
+
sendSafeError(res, e);
|
|
25138
27481
|
}
|
|
25139
27482
|
});
|
|
25140
27483
|
app.post("/api/grade-sync", requireAuth("teacher", "administrator"), async (req, res) => {
|
|
@@ -25147,16 +27490,16 @@ function registerGradingRoutes(ctx) {
|
|
|
25147
27490
|
await gradeService.saveSemesterGrade(lessonId, studentId, Math.round(Number(grade)));
|
|
25148
27491
|
res.json({ success: true });
|
|
25149
27492
|
} catch (e) {
|
|
25150
|
-
res
|
|
27493
|
+
sendSafeError(res, e);
|
|
25151
27494
|
}
|
|
25152
27495
|
});
|
|
25153
|
-
app.get("/api/classes/:classId/points-dimensions", async (req, res) => {
|
|
27496
|
+
app.get("/api/classes/:classId/points-dimensions", requireAuth(), async (req, res) => {
|
|
25154
27497
|
try {
|
|
25155
27498
|
const dimensionRegistry = await kernelContainer.serviceRegistry.resolve(IPointsDimensionRegistryToken);
|
|
25156
27499
|
const dimensions = dimensionRegistry.listDimensions();
|
|
25157
27500
|
res.json({ success: true, dimensions });
|
|
25158
27501
|
} catch (e) {
|
|
25159
|
-
res
|
|
27502
|
+
sendSafeError(res, e);
|
|
25160
27503
|
}
|
|
25161
27504
|
});
|
|
25162
27505
|
app.post("/api/students/:studentId/points", requireAuth("teacher", "administrator"), async (req, res) => {
|
|
@@ -25184,10 +27527,10 @@ function registerGradingRoutes(ctx) {
|
|
|
25184
27527
|
});
|
|
25185
27528
|
res.json({ success: true, logItem });
|
|
25186
27529
|
} catch (e) {
|
|
25187
|
-
res
|
|
27530
|
+
sendSafeError(res, e);
|
|
25188
27531
|
}
|
|
25189
27532
|
});
|
|
25190
|
-
app.get("/api/students/:studentId/points-logs", async (req, res) => {
|
|
27533
|
+
app.get("/api/students/:studentId/points-logs", requireAuth(), async (req, res) => {
|
|
25191
27534
|
try {
|
|
25192
27535
|
const { studentId } = req.params;
|
|
25193
27536
|
const { classId } = req.query;
|
|
@@ -25196,7 +27539,7 @@ function registerGradingRoutes(ctx) {
|
|
|
25196
27539
|
const summary = await ledgerService.getStudentDimensionSummary(studentId, classId || "");
|
|
25197
27540
|
res.json({ success: true, logs, summary });
|
|
25198
27541
|
} catch (e) {
|
|
25199
|
-
res
|
|
27542
|
+
sendSafeError(res, e);
|
|
25200
27543
|
}
|
|
25201
27544
|
});
|
|
25202
27545
|
app.post("/api/classes/:classId/semester-reports/archive", requireAuth("teacher", "administrator"), (req, res) => {
|
|
@@ -25249,7 +27592,7 @@ function registerGradingRoutes(ctx) {
|
|
|
25249
27592
|
transaction(reports);
|
|
25250
27593
|
res.json({ success: true });
|
|
25251
27594
|
} catch (e) {
|
|
25252
|
-
res
|
|
27595
|
+
sendSafeError(res, e);
|
|
25253
27596
|
}
|
|
25254
27597
|
});
|
|
25255
27598
|
app.post(
|
|
@@ -25350,16 +27693,16 @@ ${examsText}
|
|
|
25350
27693
|
res.json({ success: true, aiEvaluation: text });
|
|
25351
27694
|
} catch (e) {
|
|
25352
27695
|
console.error("AI Semester Evaluation error:", e);
|
|
25353
|
-
res
|
|
27696
|
+
sendSafeError(res, e);
|
|
25354
27697
|
}
|
|
25355
27698
|
}
|
|
25356
27699
|
);
|
|
25357
27700
|
}
|
|
25358
27701
|
|
|
25359
27702
|
// server/routes/plugins.ts
|
|
25360
|
-
var
|
|
25361
|
-
var
|
|
25362
|
-
var
|
|
27703
|
+
var import_express3 = __toESM(require("express"), 1);
|
|
27704
|
+
var import_path16 = __toESM(require("path"), 1);
|
|
27705
|
+
var import_fs14 = __toESM(require("fs"), 1);
|
|
25363
27706
|
var import_semver3 = __toESM(require("semver"), 1);
|
|
25364
27707
|
|
|
25365
27708
|
// server/services/version-fetcher.ts
|
|
@@ -25380,6 +27723,14 @@ function cached(key) {
|
|
|
25380
27723
|
function setCache(key, result) {
|
|
25381
27724
|
cache.set(key, { result, timestamp: Date.now() });
|
|
25382
27725
|
}
|
|
27726
|
+
var REPO_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
27727
|
+
function normalizeSource(source) {
|
|
27728
|
+
if (!source) return null;
|
|
27729
|
+
if (source.type !== "github-release" && source.type !== "gitee-release") return null;
|
|
27730
|
+
if (typeof source.repo !== "string" || source.repo.length > 140) return null;
|
|
27731
|
+
if (!REPO_PATTERN.test(source.repo)) return null;
|
|
27732
|
+
return { type: source.type, repo: source.repo };
|
|
27733
|
+
}
|
|
25383
27734
|
function repoUrl(source) {
|
|
25384
27735
|
if (source.type === "github-release") {
|
|
25385
27736
|
return `https://github.com/${source.repo}.git`;
|
|
@@ -25456,9 +27807,6 @@ function latestFromReleases(releases, installed, source) {
|
|
|
25456
27807
|
return { version: null, isPrerelease: false, downloadUrl: null, changelog: null };
|
|
25457
27808
|
}
|
|
25458
27809
|
async function checkVersion(source, installedVersion) {
|
|
25459
|
-
const key = cacheKey(source);
|
|
25460
|
-
const hit = cached(key);
|
|
25461
|
-
if (hit) return { ...hit, installedVersion };
|
|
25462
27810
|
const base = {
|
|
25463
27811
|
hasUpdate: false,
|
|
25464
27812
|
installedVersion,
|
|
@@ -25467,9 +27815,16 @@ async function checkVersion(source, installedVersion) {
|
|
|
25467
27815
|
downloadUrl: null,
|
|
25468
27816
|
changelog: null
|
|
25469
27817
|
};
|
|
27818
|
+
const safeSource = normalizeSource(source);
|
|
27819
|
+
if (!safeSource) {
|
|
27820
|
+
return { ...base, error: "\u65E0\u6548\u7684\u66F4\u65B0\u6E90\uFF1A\u4EC5\u652F\u6301 owner/name \u5F62\u5F0F\u7684 GitHub / Gitee \u4ED3\u5E93" };
|
|
27821
|
+
}
|
|
27822
|
+
const key = cacheKey(safeSource);
|
|
27823
|
+
const hit = cached(key);
|
|
27824
|
+
if (hit) return { ...hit, installedVersion };
|
|
25470
27825
|
try {
|
|
25471
|
-
const url = repoUrl(
|
|
25472
|
-
const stdout = (0, import_child_process2.
|
|
27826
|
+
const url = repoUrl(safeSource);
|
|
27827
|
+
const stdout = (0, import_child_process2.execFileSync)("git", ["ls-remote", "--tags", url], {
|
|
25473
27828
|
timeout: 8e3,
|
|
25474
27829
|
encoding: "utf-8",
|
|
25475
27830
|
stdio: ["ignore", "pipe", "pipe"]
|
|
@@ -25490,7 +27845,7 @@ async function checkVersion(source, installedVersion) {
|
|
|
25490
27845
|
} catch {
|
|
25491
27846
|
}
|
|
25492
27847
|
try {
|
|
25493
|
-
const apiUrl = apiReleasesUrl(
|
|
27848
|
+
const apiUrl = apiReleasesUrl(safeSource);
|
|
25494
27849
|
const resp = await fetch(apiUrl, {
|
|
25495
27850
|
headers: { "User-Agent": "OpenLearnV2-PluginUpdater/1.0" },
|
|
25496
27851
|
signal: AbortSignal.timeout(1e4)
|
|
@@ -25499,7 +27854,11 @@ async function checkVersion(source, installedVersion) {
|
|
|
25499
27854
|
return { ...base, error: `\u66F4\u65B0\u6E90\u54CD\u5E94\u5F02\u5E38: HTTP ${resp.status}` };
|
|
25500
27855
|
}
|
|
25501
27856
|
const releases = await resp.json();
|
|
25502
|
-
const { version, isPrerelease, downloadUrl, changelog } = latestFromReleases(
|
|
27857
|
+
const { version, isPrerelease, downloadUrl, changelog } = latestFromReleases(
|
|
27858
|
+
releases,
|
|
27859
|
+
installedVersion,
|
|
27860
|
+
safeSource
|
|
27861
|
+
);
|
|
25503
27862
|
const result = {
|
|
25504
27863
|
...base,
|
|
25505
27864
|
hasUpdate: version !== null,
|
|
@@ -25517,7 +27876,7 @@ async function checkVersion(source, installedVersion) {
|
|
|
25517
27876
|
}
|
|
25518
27877
|
|
|
25519
27878
|
// server/routes/plugin-api-gateway.ts
|
|
25520
|
-
var
|
|
27879
|
+
var import_node_path5 = __toESM(require("node:path"), 1);
|
|
25521
27880
|
init_http_router();
|
|
25522
27881
|
var RESERVED_ACTIONS = /* @__PURE__ */ new Set(["config", "toggle", "contributions", "check-update", "one-click-update"]);
|
|
25523
27882
|
var FORBIDDEN_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
|
|
@@ -25651,7 +28010,7 @@ async function pluginApiGatewayMiddleware(req, res, next) {
|
|
|
25651
28010
|
res.status(400).json({ success: false, error: "Path traversal attempt detected" });
|
|
25652
28011
|
return;
|
|
25653
28012
|
}
|
|
25654
|
-
const normalizedSubPath =
|
|
28013
|
+
const normalizedSubPath = import_node_path5.default.posix.normalize("/" + rawSubPath);
|
|
25655
28014
|
if (normalizedSubPath.includes("..")) {
|
|
25656
28015
|
res.status(400).json({ success: false, error: "Path traversal attempt detected" });
|
|
25657
28016
|
return;
|
|
@@ -25871,16 +28230,16 @@ data: ${JSON.stringify({ error: msg })}
|
|
|
25871
28230
|
|
|
25872
28231
|
// server/routes/plugins.ts
|
|
25873
28232
|
function findLocalPluginSource(manifestId) {
|
|
25874
|
-
const root =
|
|
25875
|
-
if (!
|
|
28233
|
+
const root = import_path16.default.resolve(process.cwd(), "v2_plugins");
|
|
28234
|
+
if (!import_fs14.default.existsSync(root)) return null;
|
|
25876
28235
|
let best = null;
|
|
25877
28236
|
try {
|
|
25878
|
-
for (const entry of
|
|
28237
|
+
for (const entry of import_fs14.default.readdirSync(root, { withFileTypes: true })) {
|
|
25879
28238
|
if (!entry.isDirectory()) continue;
|
|
25880
|
-
const manifestPath =
|
|
25881
|
-
if (!
|
|
28239
|
+
const manifestPath = import_path16.default.join(root, entry.name, "manifest.json");
|
|
28240
|
+
if (!import_fs14.default.existsSync(manifestPath)) continue;
|
|
25882
28241
|
try {
|
|
25883
|
-
const m = JSON.parse(
|
|
28242
|
+
const m = JSON.parse(import_fs14.default.readFileSync(manifestPath, "utf-8"));
|
|
25884
28243
|
if (m?.id !== manifestId || !m?.version) continue;
|
|
25885
28244
|
if (!best || import_semver3.default.gt(m.version, best.version)) best = { dir: entry.name, version: m.version };
|
|
25886
28245
|
} catch {
|
|
@@ -25916,11 +28275,11 @@ function registerPluginsRoutes(ctx) {
|
|
|
25916
28275
|
const { app, buildOpenAITools: buildOpenAITools2, executeAgentToolCall: executeAgentToolCall2, buildOpenAIChatUrl: buildOpenAIChatUrl2, runGeminiAgentChat: runGeminiAgentChat2, runOpenAIAgentChat: runOpenAIAgentChat2 } = ctx;
|
|
25917
28276
|
app.get("/api/docs/plugin-guide", (req, res) => {
|
|
25918
28277
|
try {
|
|
25919
|
-
const docPath =
|
|
25920
|
-
if (!
|
|
28278
|
+
const docPath = import_path16.default.join(process.cwd(), "docs_plugin_guide.md");
|
|
28279
|
+
if (!import_fs14.default.existsSync(docPath)) {
|
|
25921
28280
|
return res.status(404).json({ success: false, error: "Document not found" });
|
|
25922
28281
|
}
|
|
25923
|
-
const content =
|
|
28282
|
+
const content = import_fs14.default.readFileSync(docPath, "utf-8");
|
|
25924
28283
|
res.json({ success: true, content });
|
|
25925
28284
|
} catch (e) {
|
|
25926
28285
|
sendSafeError(res, e);
|
|
@@ -25931,11 +28290,11 @@ function registerPluginsRoutes(ctx) {
|
|
|
25931
28290
|
const limit = parseInt(req.query.limit) || 200;
|
|
25932
28291
|
const component = req.query.component;
|
|
25933
28292
|
const levelFilter = req.query.level;
|
|
25934
|
-
const logFile2 =
|
|
25935
|
-
if (!
|
|
28293
|
+
const logFile2 = import_path16.default.resolve(process.cwd(), "logs", "openlearn.log");
|
|
28294
|
+
if (!import_fs14.default.existsSync(logFile2)) {
|
|
25936
28295
|
return res.json({ success: true, logs: [] });
|
|
25937
28296
|
}
|
|
25938
|
-
const content =
|
|
28297
|
+
const content = import_fs14.default.readFileSync(logFile2, "utf-8");
|
|
25939
28298
|
const lines = content.split("\n").filter(Boolean);
|
|
25940
28299
|
let parsedLogs = lines.map((line) => {
|
|
25941
28300
|
try {
|
|
@@ -25996,7 +28355,7 @@ function registerPluginsRoutes(ctx) {
|
|
|
25996
28355
|
sendSafeError(res, e);
|
|
25997
28356
|
}
|
|
25998
28357
|
});
|
|
25999
|
-
app.get("/api/plugins/market", async (req, res) => {
|
|
28358
|
+
app.get("/api/plugins/market", requireAuth(), async (req, res) => {
|
|
26000
28359
|
try {
|
|
26001
28360
|
const plugins = kernelContainer.db.prepare("SELECT id, manifest FROM plugins WHERE manifest LIKE '%updateSource%'").all();
|
|
26002
28361
|
const results = [];
|
|
@@ -26022,7 +28381,7 @@ function registerPluginsRoutes(ctx) {
|
|
|
26022
28381
|
sendSafeError(res, e);
|
|
26023
28382
|
}
|
|
26024
28383
|
});
|
|
26025
|
-
app.post("/api/plugins/:id(*)/check-update", async (req, res) => {
|
|
28384
|
+
app.post("/api/plugins/:id(*)/check-update", requireAuth(), async (req, res) => {
|
|
26026
28385
|
try {
|
|
26027
28386
|
const rawId = decodeURIComponent(req.params.id);
|
|
26028
28387
|
const pluginId = kernelContainer.pluginHost.resolvePluginUuid(rawId);
|
|
@@ -26225,7 +28584,7 @@ function registerPluginsRoutes(ctx) {
|
|
|
26225
28584
|
app.post(
|
|
26226
28585
|
"/api/plugins/upload-zip-raw",
|
|
26227
28586
|
requireAuth("administrator"),
|
|
26228
|
-
|
|
28587
|
+
import_express3.default.raw({ type: "application/octet-stream", limit: "400mb" }),
|
|
26229
28588
|
async (req, res) => {
|
|
26230
28589
|
try {
|
|
26231
28590
|
const zipBuffer = req.body;
|
|
@@ -26272,7 +28631,7 @@ function registerPluginsRoutes(ctx) {
|
|
|
26272
28631
|
app.post(
|
|
26273
28632
|
"/api/plugins/:id(*)/update-zip-raw",
|
|
26274
28633
|
requireAuth("administrator"),
|
|
26275
|
-
|
|
28634
|
+
import_express3.default.raw({ type: "application/octet-stream", limit: "400mb" }),
|
|
26276
28635
|
async (req, res) => {
|
|
26277
28636
|
try {
|
|
26278
28637
|
const targetPluginId = decodeURIComponent(req.params.id);
|
|
@@ -26303,7 +28662,7 @@ function registerPluginsRoutes(ctx) {
|
|
|
26303
28662
|
}
|
|
26304
28663
|
}
|
|
26305
28664
|
);
|
|
26306
|
-
app.post("/api/plugins/execute-command", async (req, res) => {
|
|
28665
|
+
app.post("/api/plugins/execute-command", requireAuth(), async (req, res) => {
|
|
26307
28666
|
try {
|
|
26308
28667
|
const { type, payload } = req.body;
|
|
26309
28668
|
if (!type) {
|
|
@@ -26497,7 +28856,7 @@ async function startServer() {
|
|
|
26497
28856
|
config: { port: Number(process.env.PORT) || 9e3 }
|
|
26498
28857
|
});
|
|
26499
28858
|
try {
|
|
26500
|
-
const migrationsDir =
|
|
28859
|
+
const migrationsDir = import_path17.default.join(process.cwd(), "migrations");
|
|
26501
28860
|
const migrations = loadMigrationsFromDirectory(migrationsDir);
|
|
26502
28861
|
if (migrations.length > 0) {
|
|
26503
28862
|
runMigrations(kernelContainer.db, migrations);
|
|
@@ -26511,7 +28870,7 @@ async function startServer() {
|
|
|
26511
28870
|
registerOfficialActivities(activityRegistry, kernelContainer.actionRegistry);
|
|
26512
28871
|
await kernelContainer.serviceRegistry.register(IActivityRegistryToken, activityRegistry);
|
|
26513
28872
|
console.log(`[ActivityEcosystem] Registered ${activityRegistry.listProviders().length} official activity providers.`);
|
|
26514
|
-
const app = (0,
|
|
28873
|
+
const app = (0, import_express4.default)();
|
|
26515
28874
|
kernelContainer.pluginHost.setExpressApp(app);
|
|
26516
28875
|
const PORT = parseInt(process.env.PORT || "9000", 10);
|
|
26517
28876
|
app.set("trust proxy", 1);
|
|
@@ -26559,10 +28918,25 @@ async function startServer() {
|
|
|
26559
28918
|
standardHeaders: true,
|
|
26560
28919
|
legacyHeaders: false
|
|
26561
28920
|
});
|
|
26562
|
-
app.use(
|
|
26563
|
-
app.use(
|
|
26564
|
-
app.use(
|
|
26565
|
-
|
|
28921
|
+
app.use(import_express4.default.json({ limit: "10mb" }));
|
|
28922
|
+
app.use(import_express4.default.urlencoded({ limit: "10mb", extended: true }));
|
|
28923
|
+
app.use(
|
|
28924
|
+
"/uploads",
|
|
28925
|
+
(req, res, next) => {
|
|
28926
|
+
const token = req.headers.cookie?.match?.(/edu_os_token=([^;]+)/)?.[1];
|
|
28927
|
+
if (!token) {
|
|
28928
|
+
}
|
|
28929
|
+
next();
|
|
28930
|
+
},
|
|
28931
|
+
import_express4.default.static(import_path17.default.join(process.cwd(), "uploads"), {
|
|
28932
|
+
// 禁用目录索引与隐藏文件
|
|
28933
|
+
index: false,
|
|
28934
|
+
dotfiles: "ignore",
|
|
28935
|
+
// 缓存控制:静态资源可缓存 1h,接口不受影响
|
|
28936
|
+
maxAge: "1h"
|
|
28937
|
+
})
|
|
28938
|
+
);
|
|
28939
|
+
app.use("/plugins", import_express4.default.static(import_path17.default.join(process.cwd(), "plugins"), { index: false, dotfiles: "ignore" }));
|
|
26566
28940
|
const configuredOrigins = process.env.ALLOWED_ORIGINS ? process.env.ALLOWED_ORIGINS.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
|
26567
28941
|
const isOriginAllowed = (origin, hostHeader) => {
|
|
26568
28942
|
if (!origin) return true;
|
|
@@ -26687,6 +29061,7 @@ async function startServer() {
|
|
|
26687
29061
|
registerAdminRoutes(ctx);
|
|
26688
29062
|
registerRosterRoutes(ctx);
|
|
26689
29063
|
registerAssignmentsRoutes(ctx);
|
|
29064
|
+
registerAssignmentHubRoutes(ctx);
|
|
26690
29065
|
registerSchedulesRoutes(ctx);
|
|
26691
29066
|
registerGradingRoutes(ctx);
|
|
26692
29067
|
registerPluginsRoutes(ctx);
|
|
@@ -26735,10 +29110,10 @@ async function startServer() {
|
|
|
26735
29110
|
});
|
|
26736
29111
|
app.use(vite.middlewares);
|
|
26737
29112
|
} else {
|
|
26738
|
-
const distPath = __dirname ||
|
|
26739
|
-
app.use(
|
|
29113
|
+
const distPath = __dirname || import_path17.default.join(process.cwd(), "dist");
|
|
29114
|
+
app.use(import_express4.default.static(distPath));
|
|
26740
29115
|
app.get("*", (req, res) => {
|
|
26741
|
-
res.sendFile(
|
|
29116
|
+
res.sendFile(import_path17.default.join(distPath, "index.html"));
|
|
26742
29117
|
});
|
|
26743
29118
|
}
|
|
26744
29119
|
const HOST = process.env.HOST || "0.0.0.0";
|
|
@@ -26795,24 +29170,22 @@ async function startServer() {
|
|
|
26795
29170
|
const green = "\x1B[32m";
|
|
26796
29171
|
const cyan = "\x1B[36m";
|
|
26797
29172
|
const dim = "\x1B[2m";
|
|
29173
|
+
const hyperlink = Boolean(process.stdout.isTTY) && process.env.TERM !== "dumb";
|
|
29174
|
+
const link = (url) => hyperlink ? `${OSC}${url}${ST}${url}${OSC}${ST}` : url;
|
|
26798
29175
|
console.log(`
|
|
26799
|
-
${bold}${green}
|
|
29176
|
+
${bold}${green}OpenLearn Next${reset} v${PLATFORM_VERSION} ready:
|
|
26800
29177
|
`);
|
|
26801
|
-
console.log(
|
|
26802
|
-
` ${dim}\u279C${reset} ${bold}Local:${reset} ${bold}${cyan}${OSC}${localUrl}${ST}${localUrl}${OSC}${ST}${reset}`
|
|
26803
|
-
);
|
|
29178
|
+
console.log(` ${dim}\u279C${reset} ${bold}Local:${reset} ${bold}${cyan}${link(localUrl)}${reset}`);
|
|
26804
29179
|
if (isAnyHost) {
|
|
26805
29180
|
const netIps = getNetworkIps();
|
|
26806
29181
|
for (const ip of netIps) {
|
|
26807
29182
|
const netUrl = `http://${ip}:${PORT}`;
|
|
26808
|
-
console.log(
|
|
26809
|
-
` ${dim}\u279C${reset} ${bold}Network:${reset} ${bold}${cyan}${OSC}${netUrl}${ST}${netUrl}${OSC}${ST}${reset}`
|
|
26810
|
-
);
|
|
29183
|
+
console.log(` ${dim}\u279C${reset} ${bold}Network:${reset} ${bold}${cyan}${link(netUrl)}${reset}`);
|
|
26811
29184
|
}
|
|
26812
29185
|
}
|
|
26813
29186
|
console.log("");
|
|
26814
29187
|
if (process.env.OPEN_BROWSER === "true") {
|
|
26815
|
-
console.log(` ${dim}\u279C Auto-opening browser: ${primaryUrl}${reset}
|
|
29188
|
+
console.log(` ${dim}\u279C Auto-opening browser: ${cyan}${link(primaryUrl)}${reset}
|
|
26816
29189
|
`);
|
|
26817
29190
|
openBrowser(primaryUrl);
|
|
26818
29191
|
}
|