From 8803cd622a6e2ca44f31823ad39932739900c231 Mon Sep 17 00:00:00 2001 From: PhuocNTB Date: Mon, 13 Jul 2026 09:04:09 +0700 Subject: [PATCH] tam 2 --- client/app.go | 490 ++++++++++++++++-- client/frontend/src/main.js | 2 +- client/internal/guard/guard_darwin.go | 10 +- client/internal/guard/guard_linux.go | 8 +- client/internal/guard/guard_other.go | 2 +- client/internal/guard/guard_windows.go | 38 +- client/main.go | 10 + management/src/api.ts | 49 ++ management/src/components/ChatWidget.tsx | 11 +- management/src/components/ClassWorkspace.tsx | 29 +- management/src/components/ExamGridProctor.tsx | 26 +- .../src/components/ExamRoomWorkspace.tsx | 36 +- .../src/components/ProctorStreamPanels.tsx | 22 +- management/src/components/ViolationsPanel.tsx | 127 +++++ management/src/hooks/useStaffChatSocket.ts | 112 +++- management/src/index.css | 9 + management/src/utils/notifySound.ts | 26 + server/internal/db/db.go | 1 + server/internal/handlers/handlers_learning.go | 221 ++++++++ server/internal/models/models.go | 14 + server/internal/websocket/websocket.go | 197 +++++-- server/main.go | 3 + 22 files changed, 1322 insertions(+), 121 deletions(-) create mode 100644 management/src/components/ViolationsPanel.tsx diff --git a/client/app.go b/client/app.go index be526a4..8f6b3c2 100644 --- a/client/app.go +++ b/client/app.go @@ -125,20 +125,25 @@ type App struct { sessionPath string statsPath string webviewDataPath string + pendingViolPath string + runLockPath string wsConn *websocket.Conn - wsConnected bool - wifiSSID string - wifiBSSID string - allowedApps string - onlineSecs int - offlineSecs int - unsyncedOn int - unsyncedOff int - lastSyncTime time.Time - isStreamingSc bool - streamScStop chan struct{} - isStreamingCam bool - streamCamStop chan struct{} + wsConnected bool + wsConnecting bool + wsBackoff time.Duration + wsWriteMu sync.Mutex + wifiSSID string + wifiBSSID string + allowedApps string + onlineSecs int + offlineSecs int + unsyncedOn int + unsyncedOff int + lastSyncTime time.Time + isStreamingSc bool + streamScStop chan struct{} + isStreamingCam bool + streamCamStop chan struct{} statusMsg string expectingLogin bool needsClearPortalStorage bool @@ -152,7 +157,6 @@ type App struct { chatUnread int replyStaffID uint fetchAppsMu sync.Mutex - wsWriteMu sync.Mutex } type LocalStats struct { @@ -213,11 +217,13 @@ func NewApp() *App { _ = os.MkdirAll(webviewDir, 0755) return &App{ - sessionPath: filepath.Join(appDir, "student_session.json"), - statsPath: filepath.Join(appDir, "student_stats.json"), - webviewDataPath: webviewDir, - lastSyncTime: time.Now(), - expectingLogin: false, + sessionPath: filepath.Join(appDir, "student_session.json"), + statsPath: filepath.Join(appDir, "student_stats.json"), + webviewDataPath: webviewDir, + pendingViolPath: filepath.Join(appDir, "pending_violations.json"), + runLockPath: filepath.Join(appDir, "monitoring.lock"), + lastSyncTime: time.Now(), + expectingLogin: false, } } @@ -226,6 +232,10 @@ func (a *App) startup(ctx context.Context) { a.loadSession() a.loadStats() + // Task Manager / kill process lần trước → còn file lock → báo unclean_shutdown + a.detectUncleanShutdown() + go a.flushPendingViolations() + // Request Location Access (macOS) winapi.RequestLocationAccess() winapi.RequestCameraAndMicAccess() @@ -268,10 +278,35 @@ func (a *App) startup(ctx context.Context) { go a.authStorageScanner() } -func (a *App) handleGuardViolation(reason string) { +func (a *App) handleGuardViolation(kind, reason string) { + if kind == "" { + kind = "guard" + } + a.reportViolation(kind, reason) a.showQuitDialog("Vi phạm giám sát", reason) } +// HandleBeforeClose — SV bấm X / Alt+F4 khi đang giám sát = vi phạm. +// Trả về false để cho phép đóng sau khi đã báo cáo. +func (a *App) HandleBeforeClose() (prevent bool) { + if !a.CheckLoginStatus() || !a.isMonitoringActive() { + a.clearRunLock() + return false + } + a.mu.Lock() + mode := a.dashboard.MonitorMode + a.mu.Unlock() + if mode == "" || mode == "not_configured" { + a.clearRunLock() + return false + } + + a.reportViolation("app_closed", "Sinh viên tự đóng ứng dụng khi đang giám sát ("+mode+")") + a.tearDownBeforeQuit() + a.clearRunLock() + return false +} + // tearDownBeforeQuit ngắt mọi kênh giám sát ngay — trước khi hiện dialog (tránh treo OK để duy trì kết nối). func (a *App) tearDownBeforeQuit() { a.mu.Lock() @@ -287,6 +322,7 @@ func (a *App) tearDownBeforeQuit() { a.stopWebcamStream() a.disconnectWS() guard.Stop() + a.clearRunLock() } func (a *App) isMonitoringActive() bool { @@ -315,6 +351,162 @@ func (a *App) showQuitDialog(title, message string) { runtime.Quit(a.ctx) } +type pendingViolation struct { + Kind string `json:"kind"` + Reason string `json:"reason"` + MonitorMode string `json:"monitorMode"` + ClientAt string `json:"clientAt"` + StudentRkID int64 `json:"studentRkId"` +} + +func (a *App) markRunLock() { + a.mu.Lock() + studentID := int64(0) + mode := a.dashboard.MonitorMode + if a.student != nil { + studentID = a.student.StudentID + } + a.mu.Unlock() + if studentID <= 0 || mode == "" || mode == "not_configured" { + return + } + payload, _ := json.Marshal(map[string]any{ + "studentRkId": studentID, + "monitorMode": mode, + "startedAt": time.Now().Format(time.RFC3339), + }) + _ = os.WriteFile(a.runLockPath, payload, 0644) +} + +func (a *App) clearRunLock() { + _ = os.Remove(a.runLockPath) +} + +func (a *App) detectUncleanShutdown() { + data, err := os.ReadFile(a.runLockPath) + if err != nil { + return + } + _ = os.Remove(a.runLockPath) + + var meta struct { + StudentRkID int64 `json:"studentRkId"` + MonitorMode string `json:"monitorMode"` + StartedAt string `json:"startedAt"` + } + _ = json.Unmarshal(data, &meta) + if meta.StudentRkID <= 0 { + a.mu.Lock() + if a.student != nil { + meta.StudentRkID = a.student.StudentID + } + a.mu.Unlock() + } + if meta.StudentRkID <= 0 { + return + } + reason := "Ứng dụng bị tắt đột ngột (Task Manager / kill process) khi đang giám sát" + if meta.MonitorMode != "" { + reason += " (" + meta.MonitorMode + ")" + } + a.enqueuePendingViolation(pendingViolation{ + Kind: "unclean_shutdown", + Reason: reason, + MonitorMode: meta.MonitorMode, + ClientAt: time.Now().Format(time.RFC3339), + StudentRkID: meta.StudentRkID, + }) +} + +func (a *App) enqueuePendingViolation(v pendingViolation) { + list := a.loadPendingViolations() + list = append(list, v) + raw, _ := json.Marshal(list) + _ = os.WriteFile(a.pendingViolPath, raw, 0644) +} + +func (a *App) loadPendingViolations() []pendingViolation { + data, err := os.ReadFile(a.pendingViolPath) + if err != nil { + return nil + } + var list []pendingViolation + if json.Unmarshal(data, &list) != nil { + return nil + } + return list +} + +func (a *App) flushPendingViolations() { + list := a.loadPendingViolations() + if len(list) == 0 { + return + } + remaining := make([]pendingViolation, 0) + for _, v := range list { + if !a.postViolation(v) { + remaining = append(remaining, v) + } + } + if len(remaining) == 0 { + _ = os.Remove(a.pendingViolPath) + return + } + raw, _ := json.Marshal(remaining) + _ = os.WriteFile(a.pendingViolPath, raw, 0644) +} + +func (a *App) postViolation(v pendingViolation) bool { + if v.StudentRkID <= 0 { + return true + } + payload := map[string]any{ + "studentRkId": v.StudentRkID, + "kind": v.Kind, + "reason": v.Reason, + "monitorMode": v.MonitorMode, + "clientAt": v.ClientAt, + } + bodyBytes, _ := json.Marshal(payload) + client := http.Client{Timeout: 5 * time.Second} + resp, err := client.Post(API_BASE+"/api/student/report-violation", "application/json", bytes.NewBuffer(bodyBytes)) + if err != nil { + log.Printf("[VIOLATION] report failed: %v", err) + return false + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + log.Printf("[VIOLATION] report status %d: %s", resp.StatusCode, string(body)) + return false + } + log.Printf("[VIOLATION] reported kind=%s student=%d", v.Kind, v.StudentRkID) + return true +} + +func (a *App) reportViolation(kind, reason string) { + a.mu.Lock() + studentID := int64(0) + mode := a.dashboard.MonitorMode + if a.student != nil { + studentID = a.student.StudentID + } + a.mu.Unlock() + if studentID <= 0 { + return + } + v := pendingViolation{ + Kind: kind, + Reason: reason, + MonitorMode: mode, + ClientAt: time.Now().Format(time.RFC3339), + StudentRkID: studentID, + } + if !a.postViolation(v) { + a.enqueuePendingViolation(v) + } +} + // startLocalServer khởi chạy server lắng nghe callback nhận thông tin sinh viên từ webview func (a *App) startLocalServer() { mux := http.NewServeMux() @@ -392,6 +584,16 @@ func (a *App) startLocalServer() { }() w.Write([]byte("ok")) }) + mux.HandleFunc("/exam-clear-cache", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + if r.Method == "OPTIONS" { + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + return + } + // Chỉ purge disk — JS trên trang exam-view tự clear storage + reload iframe. + go a.purgeWebViewDiskCache() + w.Write([]byte("ok")) + }) go func() { _ = server.ListenAndServe() @@ -408,26 +610,95 @@ const examViewHTML = `
- -%s + +%s +Nếu trang lỗi / trắng: F5 hoặc Xóa cache + +
- +
` @@ -1017,6 +1288,7 @@ func (a *App) rejectUnauthorizedWifi(ssid, bssid string) { a.wifiRejected = true a.mu.Unlock() + a.reportViolation("wifi", fmt.Sprintf("WiFi không được phép: %s (%s)", ssid, bssid)) a.showQuitDialog( "WiFi không được phép", fmt.Sprintf("Điểm phát WiFi \"%s\" (%s) không được phép.\n\nCó thể là mạng giả mạo (hotspot trùng tên). Hãy kết nối đúng WiFi của trường rồi mở lại ứng dụng.", ssid, bssid), @@ -1132,40 +1404,101 @@ func (a *App) connectWS() { return } a.mu.Lock() - if a.wsConnected || a.student == nil { + if a.wsConnected || a.wsConnecting || a.student == nil { a.mu.Unlock() return } + a.wsConnecting = true student := a.student classID := a.dashboard.ClassRkID if classID <= 0 { classID = student.SystemID } + backoff := a.wsBackoff + if backoff <= 0 { + backoff = 500 * time.Millisecond + } a.mu.Unlock() wsUrl := fmt.Sprintf("%s/ws?role=student&studentId=%d&classId=%d", getWsUrl(API_BASE), student.StudentID, classID) dialer := websocket.Dialer{HandshakeTimeout: 4 * time.Second} conn, _, err := dialer.Dial(wsUrl, nil) if err != nil { + a.mu.Lock() + a.wsConnecting = false + delay := backoff + next := delay * 2 + if next > 10*time.Second { + next = 10 * time.Second + } + a.wsBackoff = next + a.mu.Unlock() + log.Printf("[WS] Dial failed: %v — retry in %v", err, delay) + time.AfterFunc(delay, func() { + if a.isMonitoringActive() && a.CheckLoginStatus() { + a.connectWS() + } + }) return } + _ = conn.SetReadDeadline(time.Now().Add(45 * time.Second)) + conn.SetPongHandler(func(string) error { + return conn.SetReadDeadline(time.Now().Add(45 * time.Second)) + }) + a.mu.Lock() a.wsConn = conn a.wsConnected = true + a.wsConnecting = false + a.wsBackoff = 500 * time.Millisecond a.mu.Unlock() log.Println("[WS] Connected to proctor websocket hub.") + a.markRunLock() + + pingDone := make(chan struct{}) + go func() { + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + for { + select { + case <-pingDone: + return + case <-ticker.C: + a.wsWriteMu.Lock() + err := conn.WriteControl(websocket.PingMessage, []byte("ping"), time.Now().Add(5*time.Second)) + a.wsWriteMu.Unlock() + if err != nil { + return + } + // App-level ping (một số proxy không forward WS control frames đúng). + _ = a.safeWriteWS(map[string]any{"event": "client:ping", "data": map[string]any{}}) + } + } + }() go func() { + defer close(pingDone) defer func() { a.disconnectWS(conn) - go func() { - time.Sleep(500 * time.Millisecond) + a.mu.Lock() + delay := a.wsBackoff + if delay <= 0 { + delay = 500 * time.Millisecond + } + next := delay * 2 + if next > 10*time.Second { + next = 10 * time.Second + } + a.wsBackoff = next + a.mu.Unlock() + log.Printf("[WS] Disconnected — reconnect in %v", delay) + time.AfterFunc(delay, func() { if a.isMonitoringActive() && a.CheckLoginStatus() { a.connectWS() } - }() + }) }() for { var msg struct { @@ -1177,6 +1510,10 @@ func (a *App) connectWS() { log.Printf("[WS] ReadJSON error: %v", err) break } + _ = conn.SetReadDeadline(time.Now().Add(45 * time.Second)) + if msg.Event == "client:pong" { + continue + } log.Printf("[WS] Received event: %s", msg.Event) switch msg.Event { @@ -1238,6 +1575,7 @@ func (a *App) disconnectWS(expectedConn ...*websocket.Conn) { a.wsConn = nil } a.wsConnected = false + a.wsConnecting = false shouldStopStreams = a.isStreamingSc || a.isStreamingCam a.mu.Unlock() @@ -1523,6 +1861,83 @@ func (a *App) ReturnToDashboard() { runtime.WindowReloadApp(a.ctx) } +// ReloadExamPage — F5: làm mới trang / iframe đang mở (trắc nghiệm). +func (a *App) ReloadExamPage() { + guard.SuppressFor(3 * time.Second) + runtime.WindowExecJS(a.ctx, ` + (function(){ + try { + if (typeof window.refreshExam === 'function') { window.refreshExam(); return; } + var f = document.getElementById('exam-frame'); + if (f) { + try { f.contentWindow.location.reload(); } + catch (e) { var s = f.src; f.src = 'about:blank'; setTimeout(function(){ f.src = s; }, 50); } + return; + } + } catch (e) {} + location.reload(); + })(); + `) +} + +// ClearExamBrowserCache — xóa cache WebView (storage + disk cache) rồi làm mới trang thi. +func (a *App) ClearExamBrowserCache() { + guard.SuppressFor(8 * time.Second) + a.purgeWebViewDiskCache() + runtime.WindowExecJS(a.ctx, ` + (async function(){ + try { + if (window.caches) { + var keys = await caches.keys(); + await Promise.all(keys.map(function(k){ return caches.delete(k); })); + } + } catch (e) {} + try { localStorage.clear(); } catch (e) {} + try { sessionStorage.clear(); } catch (e) {} + try { + var f = document.getElementById('exam-frame'); + if (f) { + try { f.contentWindow.localStorage.clear(); } catch (e) {} + try { f.contentWindow.sessionStorage.clear(); } catch (e) {} + try { + var u = new URL(f.src, window.location.href); + u.searchParams.set('_sc_cb', String(Date.now())); + f.src = u.toString(); + return; + } catch (e) { + try { f.contentWindow.location.reload(); } catch (e2) { + var s = f.src; f.src = 'about:blank'; setTimeout(function(){ f.src = s; }, 50); + } + return; + } + } + } catch (e) {} + location.reload(); + })(); + `) +} + +func (a *App) purgeWebViewDiskCache() { + if a.webviewDataPath == "" { + return + } + subs := []string{ + filepath.Join("EBWebView", "Default", "Cache"), + filepath.Join("EBWebView", "Default", "Code Cache"), + filepath.Join("EBWebView", "Default", "GPUCache"), + filepath.Join("EBWebView", "Default", "Service Worker", "CacheStorage"), + filepath.Join("EBWebView", "Default", "Service Worker", "ScriptCache"), + } + for _, sub := range subs { + path := filepath.Join(a.webviewDataPath, sub) + if err := os.RemoveAll(path); err != nil { + log.Printf("[CACHE] purge %s: %v", path, err) + } else { + log.Printf("[CACHE] purged %s", path) + } + } +} + func isLocalExamURL(raw string) bool { u, err := url.Parse(strings.TrimSpace(raw)) if err != nil || u.Host == "" { @@ -1533,11 +1948,17 @@ func isLocalExamURL(raw string) bool { } func (a *App) openExamWebView(targetURL, title string) error { + return a.openExamWebViewOpts(targetURL, title, false) +} + +// openExamWebViewOpts mở nội dung trong WebView. +// forceWrap=true luôn dùng khung exam-view (có nút F5 / xóa cache) — dùng cho trắc nghiệm. +func (a *App) openExamWebViewOpts(targetURL, title string, forceWrap bool) error { targetURL = strings.TrimSpace(targetURL) if targetURL == "" { return errors.New("không có nội dung để mở") } - if isLocalExamURL(targetURL) { + if forceWrap || isLocalExamURL(targetURL) { wrapper := fmt.Sprintf( "http://127.0.0.1:34115/exam-view?url=%s&title=%s", url.QueryEscape(targetURL), @@ -1547,7 +1968,7 @@ func (a *App) openExamWebView(targetURL, title string) error { runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", wrapper)) return nil } - // Trang thi bên ngoài: mở trực tiếp trong WebView (first-party cookies → giữ phiên đăng nhập). + // Trang thi bên ngoài (đề/tài nguyên): mở trực tiếp để giữ first-party cookies. guard.SuppressFor(5 * time.Second) runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", targetURL)) return nil @@ -1639,7 +2060,8 @@ func (a *App) OpenExamQuiz() error { if err != nil { return err } - return a.openExamWebView(viewURL, "Làm trắc nghiệm") + // Luôn mở trong khung có nút F5 / Xóa cache để SV tự xử lý khi trang lỗi. + return a.openExamWebViewOpts(viewURL, "Làm trắc nghiệm", true) } func (a *App) OpenExamResource(fileID uint) error { diff --git a/client/frontend/src/main.js b/client/frontend/src/main.js index 948b937..3eba79f 100644 --- a/client/frontend/src/main.js +++ b/client/frontend/src/main.js @@ -413,7 +413,7 @@ function renderExamPanel() {
📝 ${ex.examName || 'Phòng thi'}
-

Đề PDF và tài nguyên xem ngay trong app. Link trắc nghiệm mở trang thi — xong thì menu Simple Care → Về trang chính (Ctrl+H).

+

Đề PDF và tài nguyên xem trong app. Trắc nghiệm có nút F5 / Xóa cache trên thanh công cụ. Xong: menu Simple Care → Về trang chính (Ctrl+H).

${paperBtn} ${quizBtn} diff --git a/client/internal/guard/guard_darwin.go b/client/internal/guard/guard_darwin.go index 8e973c9..a34456c 100644 --- a/client/internal/guard/guard_darwin.go +++ b/client/internal/guard/guard_darwin.go @@ -11,7 +11,7 @@ import ( var ( guardOnce sync.Once - guardViolation func(string) + guardViolation func(kind, reason string) guardStop chan struct{} suppressMu sync.Mutex suppressViolationsUntil time.Time @@ -36,8 +36,8 @@ func violationsSuppressed() bool { return time.Now().Before(suppressViolationsUntil) } -// Start giám sát môi trường macOS — vi phạm thì gọi onViolation (đa màn hình). -func Start(onViolation func(reason string)) { +// Start giám sát môi trường macOS — vi phạm thì gọi onViolation(kind, reason). +func Start(onViolation func(kind, reason string)) { guardOnce.Do(func() { if onViolation == nil { return @@ -46,7 +46,7 @@ func Start(onViolation func(reason string)) { guardStop = make(chan struct{}) if screenshot.NumActiveDisplays() > 1 { - onViolation("Phát hiện nhiều hơn 1 màn hình. Vui lòng chỉ dùng một màn hình khi chạy Simple Care.") + onViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng chỉ dùng một màn hình khi chạy Simple Care.") return } @@ -83,7 +83,7 @@ func checkEnvironment() { return } if n := screenshot.NumActiveDisplays(); n > 1 { - guardViolation("Phát hiện nhiều hơn 1 màn hình. Vui lòng rút/bật tắt màn hình phụ.") + guardViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng rút/bật tắt màn hình phụ.") return } } diff --git a/client/internal/guard/guard_linux.go b/client/internal/guard/guard_linux.go index 4eda870..5d569de 100644 --- a/client/internal/guard/guard_linux.go +++ b/client/internal/guard/guard_linux.go @@ -11,7 +11,7 @@ import ( var ( guardOnce sync.Once - guardViolation func(string) + guardViolation func(kind, reason string) guardStop chan struct{} suppressMu sync.Mutex suppressViolationsUntil time.Time @@ -37,7 +37,7 @@ func violationsSuppressed() bool { } // Start monitors the Linux desktop environment (multi-display check) -func Start(onViolation func(reason string)) { +func Start(onViolation func(kind, reason string)) { guardOnce.Do(func() { if onViolation == nil { return @@ -46,7 +46,7 @@ func Start(onViolation func(reason string)) { guardStop = make(chan struct{}) if screenshot.NumActiveDisplays() > 1 { - onViolation("Phát hiện nhiều hơn 1 màn hình. Vui lòng chỉ dùng một màn hình khi chạy Simple Care.") + onViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng chỉ dùng một màn hình khi chạy Simple Care.") return } @@ -84,7 +84,7 @@ func checkEnvironment() { return } if n := screenshot.NumActiveDisplays(); n > 1 { - guardViolation("Phát hiện nhiều hơn 1 màn hình. Vui lòng rút/bật tắt màn hình phụ.") + guardViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng rút/bật tắt màn hình phụ.") return } } diff --git a/client/internal/guard/guard_other.go b/client/internal/guard/guard_other.go index 74d99a6..57277f3 100644 --- a/client/internal/guard/guard_other.go +++ b/client/internal/guard/guard_other.go @@ -4,6 +4,6 @@ package guard import "time" -func Start(onViolation func(reason string)) {} +func Start(onViolation func(kind, reason string)) {} func Stop() {} func SuppressFor(_ time.Duration) {} diff --git a/client/internal/guard/guard_windows.go b/client/internal/guard/guard_windows.go index d205b08..732d230 100644 --- a/client/internal/guard/guard_windows.go +++ b/client/internal/guard/guard_windows.go @@ -72,15 +72,15 @@ type msg struct { } var ( - guardOnce sync.Once - guardViolation func(string) - guardStop chan struct{} - guardBaselineUser string - guardBaselineSession uint32 - desktopHook uintptr - guardClassAtom uint16 - suppressMu sync.Mutex - suppressViolationsUntil time.Time + guardOnce sync.Once + guardViolation func(kind, reason string) + guardStop chan struct{} + guardBaselineUser string + guardBaselineSession uint32 + desktopHook uintptr + guardClassAtom uint16 + suppressMu sync.Mutex + suppressViolationsUntil time.Time ) // SuppressFor tạm không thoát app khi WebView/Explorer chuyển màn hình nội bộ. @@ -102,8 +102,8 @@ func violationsSuppressed() bool { return time.Now().Before(suppressViolationsUntil) } -// Start giám sát môi trường Windows — vi phạm thì gọi onViolation (đổi user, đa màn hình, đổi desktop ảo). -func Start(onViolation func(reason string)) { +// Start giám sát môi trường Windows — vi phạm thì gọi onViolation(kind, reason). +func Start(onViolation func(kind, reason string)) { guardOnce.Do(func() { if onViolation == nil { return @@ -114,7 +114,7 @@ func Start(onViolation func(reason string)) { guardBaselineSession = currentSessionID() if monitorCount() > 1 { - onViolation("Phát hiện nhiều hơn 1 màn hình. Vui lòng chỉ dùng một màn hình khi chạy Simple Care.") + onViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng chỉ dùng một màn hình khi chạy Simple Care.") return } @@ -147,27 +147,27 @@ func checkEnvironment() { return } if n := monitorCount(); n > 1 { - guardViolation("Phát hiện nhiều hơn 1 màn hình. Vui lòng rút/bật tắt màn hình phụ.") + guardViolation("multi_monitor", "Phát hiện nhiều hơn 1 màn hình. Vui lòng rút/bật tắt màn hình phụ.") return } user := currentUsername() if user != "" && guardBaselineUser != "" && user != guardBaselineUser { - guardViolation("Phát hiện đổi tài khoản Windows. Ứng dụng sẽ thoát.") + guardViolation("user_switch", "Phát hiện đổi tài khoản Windows. Ứng dụng sẽ thoát.") return } sid := currentSessionID() if sid != 0 && guardBaselineSession != 0 && sid != guardBaselineSession { - guardViolation("Phiên đăng nhập Windows đã thay đổi. Ứng dụng sẽ thoát.") + guardViolation("session_change", "Phiên đăng nhập Windows đã thay đổi. Ứng dụng sẽ thoát.") } } -func triggerViolation(reason string) { +func triggerViolation(kind, reason string) { if violationsSuppressed() { log.Printf("[GUARD] suppressed: %s", reason) return } if guardViolation != nil { - guardViolation(reason) + guardViolation(kind, reason) } } @@ -262,7 +262,7 @@ func guardWndProc(hwnd, msg, wParam, lParam uintptr) uintptr { case wmWtsSessionChange: switch uint32(wParam) { case wtsSessionLock, wtsSessionLogoff, wtsConsoleDisconnect, wtsRemoteDisconnect: - triggerViolation("Phiên Windows bị khóa, đăng xuất hoặc chuyển người dùng. Ứng dụng sẽ thoát.") + triggerViolation("session_change", "Phiên Windows bị khóa, đăng xuất hoặc chuyển người dùng. Ứng dụng sẽ thoát.") } } r, _, _ := procDefWindowProcW.Call(hwnd, msg, wParam, lParam) @@ -271,7 +271,7 @@ func guardWndProc(hwnd, msg, wParam, lParam uintptr) uintptr { func desktopSwitchCallback(hWinEventHook, event, hwnd, idObject, idChild, idEventThread, dwmsEventTime uintptr) uintptr { if event == eventSystemDesktopSwitch { - triggerViolation("Không được chuyển Desktop ảo (Win+Tab). Ứng dụng sẽ thoát.") + triggerViolation("virtual_desktop", "Không được chuyển Desktop ảo (Win+Tab). Ứng dụng sẽ thoát.") } return 0 } diff --git a/client/main.go b/client/main.go index 3009dbe..b5fecd1 100644 --- a/client/main.go +++ b/client/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "embed" "log" "os" @@ -46,6 +47,12 @@ func main() { fileMenu.AddText("Về trang chính", keys.CmdOrCtrl("h"), func(_ *menu.CallbackData) { app.ReturnToDashboard() }) + fileMenu.AddText("Làm mới trang (F5)", keys.Key("f5"), func(_ *menu.CallbackData) { + app.ReloadExamPage() + }) + fileMenu.AddText("Xóa cache trình duyệt", keys.CmdOrCtrl("Delete"), func(_ *menu.CallbackData) { + app.ClearExamBrowserCache() + }) // Create application with options err := wails.Run(&options.App{ @@ -58,6 +65,9 @@ func main() { }, BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, OnStartup: app.startup, + OnBeforeClose: func(ctx context.Context) (prevent bool) { + return app.HandleBeforeClose() + }, Windows: &windows.Options{ WebviewUserDataPath: app.webviewDataPath, }, diff --git a/management/src/api.ts b/management/src/api.ts index 40acb1e..1a84d03 100644 --- a/management/src/api.ts +++ b/management/src/api.ts @@ -381,6 +381,31 @@ export interface StudentSessionLogItem { lastActiveAt?: string; } +export interface StudentViolationItem { + id: number; + studentRkId: number; + studentCode: string; + fullName: string; + classRkId: number; + kind: string; + reason: string; + monitorMode: string; + clientAt?: string; + createdAt: string; +} + +export const VIOLATION_KIND_OPTIONS = [ + { value: '', label: 'Tất cả loại' }, + { value: 'app_closed', label: 'Tự đóng app' }, + { value: 'unclean_shutdown', label: 'Tắt đột ngột' }, + { value: 'multi_monitor', label: 'Nhiều màn hình' }, + { value: 'user_switch', label: 'Đổi user' }, + { value: 'session_change', label: 'Khóa / đổi phiên' }, + { value: 'virtual_desktop', label: 'Desktop ảo' }, + { value: 'wifi', label: 'WiFi trái phép' }, + { value: 'guard', label: 'Vi phạm môi trường (cũ)' }, +] as const; + export const api = { getStats: async (): Promise => { const res = await staffFetch('/stats'); @@ -716,6 +741,30 @@ export const apiFetchClassSessionLogs = async ( return res.json(); }; +export const apiFetchClassViolations = async ( + rkId: number, + date: string, + kind = '' +): Promise<{ data: StudentViolationItem[]; date: string }> => { + const params = new URLSearchParams({ date }); + if (kind) params.set('kind', kind); + const res = await staffFetch(`/classes/${rkId}/violations?${params}`); + if (!res.ok) throw new Error('Failed to fetch class violations'); + return res.json(); +}; + +export const apiFetchExamViolations = async ( + examId: number, + date: string, + kind = '' +): Promise<{ data: StudentViolationItem[]; date: string }> => { + const params = new URLSearchParams({ date }); + if (kind) params.set('kind', kind); + const res = await staffFetch(`/exam-rooms/${examId}/violations?${params}`); + if (!res.ok) throw new Error('Failed to fetch exam violations'); + return res.json(); +}; + export const apiFetchOnlineStudents = async (rkId: number): Promise<{ onlineStudentIds: number[] }> => { const res = await staffFetch(`/classes/${rkId}/online-students`); if (!res.ok) throw new Error('Failed to fetch online students list'); diff --git a/management/src/components/ChatWidget.tsx b/management/src/components/ChatWidget.tsx index df72f98..e514568 100644 --- a/management/src/components/ChatWidget.tsx +++ b/management/src/components/ChatWidget.tsx @@ -3,7 +3,7 @@ import { createPortal } from 'react-dom'; import { apiChat, type ChatConversation, type ChatMessage, type ChatStudent } from '../api'; import { type StaffChatOpenDetail } from '../chatEvents'; import { useAuth } from '../auth/AuthContext'; -import { onStaffChatMessage, playChatSound } from '../hooks/useStaffChatSocket'; +import { onStaffChatMessage, onStudentViolation, playChatSound, kindLabel } from '../hooks/useStaffChatSocket'; export function ChatWidget() { const { staff } = useAuth(); @@ -83,6 +83,13 @@ export function ChatWidget() { return () => { off(); }; }, [handleIncoming]); + useEffect(() => { + return onStudentViolation((v) => { + const name = v.studentName || v.studentCode || `SV #${v.studentId}`; + showToast(`⚠ ${kindLabel(v.kind)}`, `${name}: ${v.reason || 'Vi phạm giám sát'}`); + }); + }, []); + useEffect(() => { const onOpen = (e: Event) => { const detail = (e as CustomEvent).detail; @@ -152,7 +159,7 @@ export function ChatWidget() { const dock = (
{toast && ( -
+
{toast.title} {toast.body}
diff --git a/management/src/components/ClassWorkspace.tsx b/management/src/components/ClassWorkspace.tsx index b34fc1c..402c929 100644 --- a/management/src/components/ClassWorkspace.tsx +++ b/management/src/components/ClassWorkspace.tsx @@ -23,6 +23,8 @@ import { AttendancePanel } from './AttendancePanel'; import { StudentDetailModal } from './StudentDetailModal'; import { ExamGridProctor } from './ExamGridProctor'; import { AppPoolModal } from './AppPoolModal'; +import { onStudentPresence } from '../hooks/useStaffChatSocket'; +import { ViolationsPanel } from './ViolationsPanel'; import { AppTemplatePickerModal } from './AppTemplatePickerModal'; import { mergeKeywordCSV } from '../utils/appKeywords'; import { WorkspaceSeatingChart } from './WorkspaceSeatingChart'; @@ -52,7 +54,7 @@ export const ClassWorkspace: React.FC = ({ classId, sourceT const [loading, setLoading] = useState(true); const [logsLoading, setLogsLoading] = useState(false); const [savingApps, setSavingApps] = useState(false); - const [activeSubTab, setActiveSubTab] = useState<'roster' | 'grid' | 'logs' | 'attendance'>('roster'); + const [activeSubTab, setActiveSubTab] = useState<'roster' | 'grid' | 'logs' | 'attendance' | 'violations'>('roster'); const [configOpen, setConfigOpen] = useState(false); const [configTab, setConfigTab] = useState<'apps' | 'schedule'>('schedule'); const [appPoolOpen, setAppPoolOpen] = useState(false); @@ -144,10 +146,21 @@ export const ClassWorkspace: React.FC = ({ classId, sourceT const interval = setInterval(() => { fetchOnlineStatus(); fetchLogs(logDate, logPeriod); - }, 5000); + }, 8000); return () => clearInterval(interval); }, [classId, logDate, logPeriod]); + useEffect(() => { + return onStudentPresence(({ studentId, online }) => { + setOnlineIds((prev) => { + const has = prev.includes(studentId); + if (online && !has) return [...prev, studentId]; + if (!online && has) return prev.filter((id) => id !== studentId); + return prev; + }); + }); + }, []); + useEffect(() => { loadLogShifts(logDate); if (activeSubTab === 'logs') { @@ -400,6 +413,12 @@ export const ClassWorkspace: React.FC = ({ classId, sourceT > Nhật ký theo ca +
{/* Student Search */} - {activeSubTab !== 'attendance' && activeSubTab !== 'grid' &&
+ {activeSubTab !== 'attendance' && activeSubTab !== 'grid' && activeSubTab !== 'violations' &&
= ({ classId, sourceT
-
+
{activeSubTab === 'attendance' ? ( + ) : activeSubTab === 'violations' ? ( + ) : activeSubTab === 'grid' ? ( ({ diff --git a/management/src/components/ExamGridProctor.tsx b/management/src/components/ExamGridProctor.tsx index 5085207..9a134d8 100644 --- a/management/src/components/ExamGridProctor.tsx +++ b/management/src/components/ExamGridProctor.tsx @@ -56,6 +56,15 @@ export const ExamGridProctor: React.FC = ({ const wsUrl = getWsUrl('/ws?role=teacher'); let closed = false; let retryTimer: ReturnType | null = null; + let pingTimer: ReturnType | null = null; + let attempt = 0; + + const clearPing = () => { + if (pingTimer) { + clearInterval(pingTimer); + pingTimer = null; + } + }; const connect = () => { if (closed) return; @@ -64,11 +73,21 @@ export const ExamGridProctor: React.FC = ({ wsRef.current = ws; subscribedRef.current = new Set(); - ws.onopen = () => syncSubscriptions(ws); + ws.onopen = () => { + attempt = 0; + syncSubscriptions(ws); + clearPing(); + pingTimer = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ event: 'client:ping', data: {} })); + } + }, 15000); + }; ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); + if (msg.event === 'client:pong') return; if (msg.event === 'teacher:screenshot-stream-frame') { const { studentId, imageBuffer } = msg.data; setScreenFrames((prev) => ({ ...prev, [studentId]: imageBuffer })); @@ -94,9 +113,11 @@ export const ExamGridProctor: React.FC = ({ }; ws.onclose = () => { + clearPing(); subscribedRef.current = new Set(); if (!closed) { - retryTimer = setTimeout(connect, 3000); + const delay = Math.min(1000 * 2 ** Math.min(attempt++, 4), 10000); + retryTimer = setTimeout(connect, delay); } }; }; @@ -105,6 +126,7 @@ export const ExamGridProctor: React.FC = ({ return () => { closed = true; + clearPing(); if (retryTimer) clearTimeout(retryTimer); const ws = wsRef.current; if (ws?.readyState === WebSocket.OPEN) { diff --git a/management/src/components/ExamRoomWorkspace.tsx b/management/src/components/ExamRoomWorkspace.tsx index 4ee6947..1d5d426 100644 --- a/management/src/components/ExamRoomWorkspace.tsx +++ b/management/src/components/ExamRoomWorkspace.tsx @@ -20,6 +20,8 @@ import { StudentDetailModal } from './StudentDetailModal'; import { fmtTime, localInputToISO, toLocalInput } from './ExamsTab'; import { ExamGridProctor } from './ExamGridProctor'; import { WorkspaceSeatingChart } from './WorkspaceSeatingChart'; +import { onStudentPresence } from '../hooks/useStaffChatSocket'; +import { ViolationsPanel } from './ViolationsPanel'; const EXAM_APP_SUGGESTIONS = [...new Set([...BASE_APP_SUGGESTIONS, 'msedge', 'edge', 'acrobat', 'foxit'])]; @@ -88,7 +90,7 @@ export const ExamRoomWorkspace: React.FC = ({ examId, onBack }) => { const [onlineIds, setOnlineIds] = useState([]); const [searchQuery, setSearchQuery] = useState(''); const [selectedStudent, setSelectedStudent] = useState(null); - const [activeSubTab, setActiveSubTab] = useState<'roster' | 'detail' | 'submissions' | 'grid'>('roster'); + const [activeSubTab, setActiveSubTab] = useState<'roster' | 'detail' | 'submissions' | 'grid' | 'violations'>('roster'); const [configOpen, setConfigOpen] = useState(false); const [configTab, setConfigTab] = useState<'info' | 'apps' | 'papers'>('info'); const [papersModalOpen, setPapersModalOpen] = useState(false); @@ -163,10 +165,23 @@ export const ExamRoomWorkspace: React.FC = ({ examId, onBack }) => { useEffect(() => { fetchOnline(); - const t = setInterval(fetchOnline, 5000); + const t = setInterval(fetchOnline, 8000); return () => clearInterval(t); }, [fetchOnline]); + useEffect(() => { + const roster = new Set(students.map((s) => s.studentRkId)); + return onStudentPresence(({ studentId, online }) => { + if (!roster.has(studentId)) return; + setOnlineIds((prev) => { + const has = prev.includes(studentId); + if (online && !has) return [...prev, studentId]; + if (!online && has) return prev.filter((id) => id !== studentId); + return prev; + }); + }); + }, [students]); + useEffect(() => { if (!studentPickerOpen || !searchQ.trim()) { if (!studentPickerOpen) setSearchHits([]); @@ -448,6 +463,12 @@ export const ExamRoomWorkspace: React.FC = ({ examId, onBack }) => { }; const handlePublish = async () => { + if (!quizUrl.trim()) { + const ok = confirm( + 'Phòng thi chưa có link trắc nghiệm.\n\nBạn có chắc chắn muốn đẩy phòng thi không có phần thi trắc nghiệm không?' + ); + if (!ok) return; + } setErr(''); setMsg(''); try { await apiExam.publish(examId); @@ -880,11 +901,18 @@ export const ExamRoomWorkspace: React.FC = ({ examId, onBack }) => { > Bài nộp ({submissions.length}) +
- {activeSubTab !== 'submissions' && activeSubTab !== 'grid' && ( + {activeSubTab !== 'submissions' && activeSubTab !== 'grid' && activeSubTab !== 'violations' && (
= ({ examId, onBack }) => { )}
+ ) : activeSubTab === 'violations' ? ( + ) : (
{submissions.length > 0 && ( diff --git a/management/src/components/ProctorStreamPanels.tsx b/management/src/components/ProctorStreamPanels.tsx index 527f979..8794493 100644 --- a/management/src/components/ProctorStreamPanels.tsx +++ b/management/src/components/ProctorStreamPanels.tsx @@ -33,6 +33,8 @@ export const ProctorStreamPanels: React.FC = ({ useEffect(() => { const wsUrl = getWsUrl('/ws?role=teacher'); let retryTimer: ReturnType | null = null; + let pingTimer: ReturnType | null = null; + let attempt = 0; intentionalClose.current = false; hasOpened.current = false; @@ -50,6 +52,13 @@ export const ProctorStreamPanels: React.FC = ({ } }; + const clearPing = () => { + if (pingTimer) { + clearInterval(pingTimer); + pingTimer = null; + } + }; + const connect = () => { if (intentionalClose.current) return; @@ -57,15 +66,23 @@ export const ProctorStreamPanels: React.FC = ({ wsRef.current = ws; ws.onopen = () => { + attempt = 0; hasOpened.current = true; setStreaming(true); setErrorMessage(null); ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId } })); + clearPing(); + pingTimer = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ event: 'client:ping', data: {} })); + } + }, 15000); }; ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); + if (msg.event === 'client:pong') return; if (msg.event === 'teacher:screenshot-stream-frame' && msg.data.studentId === studentId) { setScreenFrame(msg.data.imageBuffer); markStreaming(); @@ -86,6 +103,7 @@ export const ProctorStreamPanels: React.FC = ({ ws.onerror = () => {}; ws.onclose = () => { + clearPing(); if (intentionalClose.current) return; setStreaming(false); if (!hasOpened.current && !hasFrames.current) { @@ -93,7 +111,8 @@ export const ProctorStreamPanels: React.FC = ({ } else { setErrorMessage('Mất kết nối giám sát — đang thử lại...'); } - retryTimer = setTimeout(connect, 3000); + const delay = Math.min(1000 * 2 ** Math.min(attempt++, 4), 10000); + retryTimer = setTimeout(connect, delay); }; }; @@ -101,6 +120,7 @@ export const ProctorStreamPanels: React.FC = ({ return () => { intentionalClose.current = true; + clearPing(); if (retryTimer) clearTimeout(retryTimer); if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId } })); diff --git a/management/src/components/ViolationsPanel.tsx b/management/src/components/ViolationsPanel.tsx new file mode 100644 index 0000000..cbdde27 --- /dev/null +++ b/management/src/components/ViolationsPanel.tsx @@ -0,0 +1,127 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + apiFetchClassViolations, + apiFetchExamViolations, + VIOLATION_KIND_OPTIONS, + type StudentViolationItem, +} from '../api'; +import { kindLabel } from '../hooks/useStaffChatSocket'; + +type Props = + | { mode: 'class'; classId: number } + | { mode: 'exam'; examId: number }; + +function todayLocal(): string { + const d = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +function formatTime(iso?: string): string { + if (!iso) return '—'; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleString('vi-VN'); +} + +export const ViolationsPanel: React.FC = (props) => { + const [date, setDate] = useState(todayLocal); + const [kind, setKind] = useState(''); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [err, setErr] = useState(''); + + const load = useCallback(async () => { + setLoading(true); + setErr(''); + try { + const res = + props.mode === 'class' + ? await apiFetchClassViolations(props.classId, date, kind) + : await apiFetchExamViolations(props.examId, date, kind); + setRows(res.data || []); + } catch (e: any) { + setErr(e?.message || 'Không tải được danh sách vi phạm'); + setRows([]); + } finally { + setLoading(false); + } + }, [props, date, kind]); + + useEffect(() => { + void load(); + }, [load]); + + return ( +
+
+ + + + + {rows.length} vi phạm + +
+ + {err &&
{err}
} + +
+ {loading && rows.length === 0 ? ( +
+
+

Đang tải vi phạm...

+
+ ) : rows.length === 0 ? ( +
+

Không có vi phạm trong ngày đã chọn.

+
+ ) : ( + + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + ))} + +
Thời gianSinh viênMã SVLoạiChi tiếtChế độ
+ {formatTime(r.createdAt || r.clientAt)} + {r.fullName || '—'}{r.studentCode || r.studentRkId} + + {kindLabel(r.kind)} + + + {r.reason} + {r.monitorMode || '—'}
+ )} +
+
+ ); +}; diff --git a/management/src/hooks/useStaffChatSocket.ts b/management/src/hooks/useStaffChatSocket.ts index 2571a07..9f5c4a3 100644 --- a/management/src/hooks/useStaffChatSocket.ts +++ b/management/src/hooks/useStaffChatSocket.ts @@ -1,18 +1,59 @@ import { useCallback, useEffect, useRef } from 'react'; import { type ChatMessage, getWsUrl } from '../api'; import { useAuth } from '../auth/AuthContext'; -import { playChatSound } from '../utils/notifySound'; +import { playChatSound, playAlertSound } from '../utils/notifySound'; type ChatIncomingHandler = (msg: ChatMessage) => void; +export type PresenceUpdate = { studentId: number; online: boolean; classId?: number }; +export type StudentViolationEvent = { + studentId: number; + studentName?: string; + studentCode?: string; + kind: string; + reason: string; + monitorMode?: string; + classId?: number; +}; -const handlers = new Set(); +const chatHandlers = new Set(); +const presenceHandlers = new Set<(p: PresenceUpdate) => void>(); +const violationHandlers = new Set<(v: StudentViolationEvent) => void>(); export function onStaffChatMessage(handler: ChatIncomingHandler): () => void { - handlers.add(handler); - return () => { handlers.delete(handler); }; + chatHandlers.add(handler); + return () => { chatHandlers.delete(handler); }; } -/** WebSocket luôn bật khi đã login — nhận tin sinh viên realtime */ +export function onStudentPresence(handler: (p: PresenceUpdate) => void): () => void { + presenceHandlers.add(handler); + return () => { presenceHandlers.delete(handler); }; +} + +export function onStudentViolation(handler: (v: StudentViolationEvent) => void): () => void { + violationHandlers.add(handler); + return () => { violationHandlers.delete(handler); }; +} + +function scheduleBackoff(attempt: number): number { + const base = Math.min(1000 * 2 ** Math.min(attempt, 4), 10000); + return base + Math.floor(Math.random() * 250); +} + +export function kindLabel(kind: string): string { + switch (kind) { + case 'app_closed': return 'Tự đóng app'; + case 'unclean_shutdown': return 'Tắt đột ngột'; + case 'multi_monitor': return 'Nhiều màn hình'; + case 'user_switch': return 'Đổi user'; + case 'session_change': return 'Khóa / đổi phiên'; + case 'virtual_desktop': return 'Desktop ảo'; + case 'wifi': return 'WiFi trái phép'; + case 'guard': return 'Vi phạm môi trường'; + default: return kind || 'Vi phạm'; + } +} + +/** WebSocket luôn bật khi đã login — chat + presence + violation + keepalive. */ export function StaffChatSocket() { const { staff, token } = useAuth(); const staffIdRef = useRef(0); @@ -21,8 +62,8 @@ export function StaffChatSocket() { staffIdRef.current = staff?.id ?? 0; }, [staff?.id]); - const dispatch = useCallback((msg: ChatMessage) => { - handlers.forEach((h) => h(msg)); + const dispatchChat = useCallback((msg: ChatMessage) => { + chatHandlers.forEach((h) => h(msg)); }, []); useEffect(() => { @@ -31,26 +72,74 @@ export function StaffChatSocket() { const wsUrl = getWsUrl(`/ws?role=teacher&staffId=${staff.id}`); let ws: WebSocket | null = null; let retryTimer: ReturnType | null = null; + let pingTimer: ReturnType | null = null; let closed = false; + let attempt = 0; + + const clearPing = () => { + if (pingTimer) { + clearInterval(pingTimer); + pingTimer = null; + } + }; const connect = () => { if (closed) return; ws = new WebSocket(wsUrl); + + ws.onopen = () => { + attempt = 0; + clearPing(); + pingTimer = setInterval(() => { + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ event: 'client:ping', data: {} })); + } + }, 15000); + }; + ws.onmessage = (ev) => { try { const payload = JSON.parse(ev.data); + if (payload.event === 'client:pong') return; + if (payload.event === 'presence:update') { + const studentId = Number(payload.data?.studentId ?? 0); + if (!studentId) return; + presenceHandlers.forEach((h) => h({ + studentId, + online: !!payload.data?.online, + classId: Number(payload.data?.classId ?? 0) || undefined, + })); + return; + } + if (payload.event === 'teacher:student-violation') { + const studentId = Number(payload.data?.studentId ?? 0); + if (!studentId) return; + playAlertSound(); + violationHandlers.forEach((h) => h({ + studentId, + studentName: payload.data?.studentName || '', + studentCode: payload.data?.studentCode || '', + kind: String(payload.data?.kind || ''), + reason: String(payload.data?.reason || ''), + monitorMode: payload.data?.monitorMode || '', + classId: Number(payload.data?.classId ?? 0) || undefined, + })); + return; + } if (payload.event !== 'chat:message') return; const msg = payload.data as ChatMessage; const targetStaff = Number(msg.targetStaffId ?? msg.staffId ?? 0); if (targetStaff > 0 && targetStaff !== staffIdRef.current) return; - dispatch(msg); + dispatchChat(msg); } catch { /* ignore */ } }; + ws.onclose = () => { + clearPing(); if (!closed) { - retryTimer = setTimeout(connect, 3000); + retryTimer = setTimeout(connect, scheduleBackoff(attempt++)); } }; }; @@ -59,12 +148,13 @@ export function StaffChatSocket() { return () => { closed = true; + clearPing(); if (retryTimer) clearTimeout(retryTimer); ws?.close(); }; - }, [token, staff?.id, dispatch]); + }, [token, staff?.id, dispatchChat]); return null; } -export { playChatSound }; +export { playChatSound, playAlertSound }; diff --git a/management/src/index.css b/management/src/index.css index 17b2c71..ffe8bd1 100644 --- a/management/src/index.css +++ b/management/src/index.css @@ -3874,6 +3874,15 @@ input:checked + .slider:before { color: #93c5fd; } +.chat-toast-alert { + background: #7f1d1d; + border: 1px solid #f87171; +} + +.chat-toast-alert strong { + color: #fecaca; +} + @keyframes chat-toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } diff --git a/management/src/utils/notifySound.ts b/management/src/utils/notifySound.ts index 59f0d6c..f21572e 100644 --- a/management/src/utils/notifySound.ts +++ b/management/src/utils/notifySound.ts @@ -35,3 +35,29 @@ export function playChatSound() { playTone(880, t, 0.12); playTone(1174, t + 0.14, 0.14); } + +/** Louder alert for student violations (quit / kill app) */ +export function playAlertSound() { + const ctx = getAudioCtx(); + if (!ctx) return; + if (ctx.state === 'suspended') { + void ctx.resume(); + } + const playTone = (freq: number, start: number, duration: number) => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'square'; + osc.frequency.value = freq; + gain.gain.setValueAtTime(0.0001, start); + gain.gain.exponentialRampToValueAtTime(0.1, start + 0.02); + gain.gain.exponentialRampToValueAtTime(0.0001, start + duration); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(start); + osc.stop(start + duration + 0.02); + }; + const t = ctx.currentTime; + playTone(520, t, 0.16); + playTone(390, t + 0.18, 0.2); + playTone(520, t + 0.4, 0.18); +} diff --git a/server/internal/db/db.go b/server/internal/db/db.go index df418a0..1e85df9 100644 --- a/server/internal/db/db.go +++ b/server/internal/db/db.go @@ -53,6 +53,7 @@ func AutoMigrate(db *gorm.DB) error { &models.WifiPoolEntry{}, &models.AcceptedWifi{}, &models.StudentSession{}, + &models.StudentViolation{}, &models.AttendanceResult{}, &models.StaffAccount{}, &models.EmailDomain{}, diff --git a/server/internal/handlers/handlers_learning.go b/server/internal/handlers/handlers_learning.go index 45482bb..746e9af 100644 --- a/server/internal/handlers/handlers_learning.go +++ b/server/internal/handlers/handlers_learning.go @@ -735,6 +735,227 @@ func ReportBlockedAppHandler(db *gorm.DB) fiber.Handler { } } +// POST /api/student/report-violation — SV tắt app / vi phạm khi đang giám sát +func ReportViolationHandler(db *gorm.DB) fiber.Handler { + return func(c *fiber.Ctx) error { + var req struct { + StudentRkID int64 `json:"studentRkId"` + Kind string `json:"kind"` + Reason string `json:"reason"` + MonitorMode string `json:"monitorMode"` + ClientAt string `json:"clientAt"` // RFC3339 optional + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload format"}) + } + if req.StudentRkID <= 0 || strings.TrimSpace(req.Kind) == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "studentRkId and kind are required"}) + } + + kind := strings.ToLower(strings.TrimSpace(req.Kind)) + reason := strings.TrimSpace(req.Reason) + if reason == "" { + reason = kind + } + classID := internalDb.FindActiveClassForStudent(db, req.StudentRkID) + clientAt := time.Now() + if t, err := time.Parse(time.RFC3339, strings.TrimSpace(req.ClientAt)); err == nil { + clientAt = t + } + + row := models.StudentViolation{ + StudentRkID: req.StudentRkID, + ClassRkID: classID, + Kind: kind, + Reason: reason, + MonitorMode: strings.TrimSpace(req.MonitorMode), + ClientAt: clientAt, + } + if err := db.Create(&row).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + studentName := "" + studentCode := "" + var st models.Student + if err := db.Where("rk_id = ?", req.StudentRkID).First(&st).Error; err == nil { + studentName = st.FullName + studentCode = st.StudentCode + } + + // Cố ý tắt / crash / môi trường → offline ngay, không dùng grace 8s + switch kind { + case "app_closed", "unclean_shutdown", "wifi", + "guard", "multi_monitor", "user_switch", "session_change", "virtual_desktop": + internalWs.Hub.ForceStudentOffline(req.StudentRkID) + } + + internalWs.Hub.BroadcastStudentViolation(map[string]any{ + "id": row.ID, + "studentId": req.StudentRkID, + "studentName": studentName, + "studentCode": studentCode, + "classId": classID, + "kind": kind, + "reason": reason, + "monitorMode": row.MonitorMode, + "clientAt": clientAt.Format(time.RFC3339), + "createdAt": row.CreatedAt.Format(time.RFC3339), + }) + + return c.JSON(fiber.Map{"ok": true, "id": row.ID}) + } +} + +type studentViolationItem struct { + ID uint `json:"id"` + StudentRkID int64 `json:"studentRkId"` + StudentCode string `json:"studentCode"` + FullName string `json:"fullName"` + ClassRkID int64 `json:"classRkId"` + Kind string `json:"kind"` + Reason string `json:"reason"` + MonitorMode string `json:"monitorMode"` + ClientAt time.Time `json:"clientAt"` + CreatedAt time.Time `json:"createdAt"` +} + +// GET /api/classes/:rkId/violations?date=YYYY-MM-DD&kind= +func ListClassViolationsHandler(db *gorm.DB) fiber.Handler { + return func(c *fiber.Ctx) error { + classRkID, err := strconv.ParseInt(c.Params("rkId"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid class rkId"}) + } + date := c.Query("date") + if date == "" { + date = time.Now().Format("2006-01-02") + } + kindFilter := strings.TrimSpace(c.Query("kind", "")) + + var mappings []models.ClassStudent + if err := db.Where("class_rk_id = ?", classRkID).Find(&mappings).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + if len(mappings) == 0 { + return c.JSON(fiber.Map{"data": []any{}}) + } + studentIDs := make([]int64, 0, len(mappings)) + for _, m := range mappings { + studentIDs = append(studentIDs, m.StudentRkID) + } + + var students []models.Student + if err := db.Where("rk_id IN ?", studentIDs).Find(&students).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + nameByID := map[int64]models.Student{} + for _, st := range students { + nameByID[st.RkID] = st + } + + dayStart, err := time.ParseInLocation("2006-01-02", date, time.Local) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid date"}) + } + dayEnd := dayStart.Add(24 * time.Hour) + + q := db.Where("student_rk_id IN ? AND created_at >= ? AND created_at < ?", studentIDs, dayStart, dayEnd) + if kindFilter != "" { + q = q.Where("kind = ?", kindFilter) + } + var rows []models.StudentViolation + if err := q.Order("created_at desc").Limit(500).Find(&rows).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + out := make([]studentViolationItem, 0, len(rows)) + for _, r := range rows { + st := nameByID[r.StudentRkID] + out = append(out, studentViolationItem{ + ID: r.ID, + StudentRkID: r.StudentRkID, + StudentCode: st.StudentCode, + FullName: st.FullName, + ClassRkID: r.ClassRkID, + Kind: r.Kind, + Reason: r.Reason, + MonitorMode: r.MonitorMode, + ClientAt: r.ClientAt, + CreatedAt: r.CreatedAt, + }) + } + return c.JSON(fiber.Map{"data": out, "date": date}) + } +} + +// GET /api/exam-rooms/:id/violations?date=YYYY-MM-DD +func ListExamRoomViolationsHandler(db *gorm.DB) fiber.Handler { + return func(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid exam room id"}) + } + date := c.Query("date") + if date == "" { + date = time.Now().Format("2006-01-02") + } + kindFilter := strings.TrimSpace(c.Query("kind", "")) + + var roster []models.ExamRoomStudent + if err := db.Where("exam_room_id = ?", id).Find(&roster).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + if len(roster) == 0 { + return c.JSON(fiber.Map{"data": []any{}}) + } + studentIDs := make([]int64, 0, len(roster)) + for _, s := range roster { + studentIDs = append(studentIDs, s.StudentRkID) + } + + var students []models.Student + _ = db.Where("rk_id IN ?", studentIDs).Find(&students) + nameByID := map[int64]models.Student{} + for _, st := range students { + nameByID[st.RkID] = st + } + + dayStart, err := time.ParseInLocation("2006-01-02", date, time.Local) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid date"}) + } + dayEnd := dayStart.Add(24 * time.Hour) + + q := db.Where("student_rk_id IN ? AND created_at >= ? AND created_at < ?", studentIDs, dayStart, dayEnd) + if kindFilter != "" { + q = q.Where("kind = ?", kindFilter) + } + var rows []models.StudentViolation + if err := q.Order("created_at desc").Limit(500).Find(&rows).Error; err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + out := make([]studentViolationItem, 0, len(rows)) + for _, r := range rows { + st := nameByID[r.StudentRkID] + out = append(out, studentViolationItem{ + ID: r.ID, + StudentRkID: r.StudentRkID, + StudentCode: st.StudentCode, + FullName: st.FullName, + ClassRkID: r.ClassRkID, + Kind: r.Kind, + Reason: r.Reason, + MonitorMode: r.MonitorMode, + ClientAt: r.ClientAt, + CreatedAt: r.CreatedAt, + }) + } + return c.JSON(fiber.Map{"data": out, "date": date}) + } +} + // GET /api/app-pool — kho app bị chặn toàn hệ thống (?q= tìm kiếm, limit= mặc định 50) func ListAppPoolHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { diff --git a/server/internal/models/models.go b/server/internal/models/models.go index 9aaeb81..c78ffc0 100644 --- a/server/internal/models/models.go +++ b/server/internal/models/models.go @@ -179,6 +179,20 @@ type StudentSession struct { func (StudentSession) TableName() string { return "student_sessions" } +// StudentViolation — ghi nhận SV tắt app / vi phạm môi trường khi đang giám sát +type StudentViolation struct { + ID uint `gorm:"primaryKey" json:"id"` + CreatedAt time.Time `json:"createdAt"` + StudentRkID int64 `gorm:"column:student_rk_id;not null;index" json:"studentRkId"` + ClassRkID int64 `gorm:"column:class_rk_id;index" json:"classRkId"` + Kind string `gorm:"column:kind;size:64;not null;index" json:"kind"` // app_closed | unclean_shutdown | multi_monitor | user_switch | session_change | virtual_desktop | wifi | guard + Reason string `gorm:"column:reason;type:text" json:"reason"` + MonitorMode string `gorm:"column:monitor_mode;size:32" json:"monitorMode"` + ClientAt time.Time `gorm:"column:client_at" json:"clientAt"` +} + +func (StudentViolation) TableName() string { return "student_violations" } + // SystemSetting lưu các cấu hình hệ thống động type SystemSetting struct { SettingKey string `gorm:"primaryKey;column:setting_key;size:128" json:"settingKey"` diff --git a/server/internal/websocket/websocket.go b/server/internal/websocket/websocket.go index fd339ed..2077854 100644 --- a/server/internal/websocket/websocket.go +++ b/server/internal/websocket/websocket.go @@ -13,8 +13,11 @@ import ( ) const ( - wsPingInterval = 30 * time.Second - wsPongWait = 60 * time.Second + // Heartbeat kiểu game: phát hiện mất kết nối trong ~30–45s. + wsPingInterval = 15 * time.Second + wsPongWait = 45 * time.Second + // Grace khi SV reconnect — tránh nhấp nháy online/offline. + studentOfflineGrace = 8 * time.Second ) type SocketMsg struct { @@ -44,6 +47,10 @@ func (c *SocketClient) WriteRaw(msg []byte) error { return c.Conn.WriteMessage(websocket.TextMessage, msg) } +type offlineGrace struct { + until time.Time + classID int64 +} type WsHub struct { mu sync.RWMutex @@ -51,6 +58,8 @@ type WsHub struct { teachers map[string]*SocketClient teachersByStaff map[uint][]string // staffId -> teacher connection addresses subscribers map[int64][]string // studentId -> list of teacher connection addresses + grace map[int64]offlineGrace + graceTimers map[int64]*time.Timer } var Hub = &WsHub{ @@ -58,13 +67,18 @@ var Hub = &WsHub{ teachers: make(map[string]*SocketClient), teachersByStaff: make(map[uint][]string), subscribers: make(map[int64][]string), + grace: make(map[int64]offlineGrace), + graceTimers: make(map[int64]*time.Timer), } func (h *WsHub) IsStudentOnline(studentRkID int64) bool { h.mu.RLock() defer h.mu.RUnlock() - _, ok := h.students[studentRkID] - return ok + if _, ok := h.students[studentRkID]; ok { + return true + } + g, ok := h.grace[studentRkID] + return ok && time.Now().Before(g.until) } func (h *WsHub) PushExamToStudent(studentRkID int64, data map[string]any) { @@ -102,16 +116,126 @@ func (h *WsHub) PushChatToStaff(staffID uint, data map[string]any) { } } +func (h *WsHub) BroadcastStudentViolation(data map[string]any) { + msg := SocketMsg{Event: "teacher:student-violation", Data: data} + msgBytes, err := json.Marshal(msg) + if err != nil { + return + } + h.mu.RLock() + defer h.mu.RUnlock() + for _, t := range h.teachers { + if t != nil { + _ = t.WriteRaw(msgBytes) + } + } +} + +func (h *WsHub) broadcastPresence(studentID int64, online bool, classID int64) { + msg := SocketMsg{ + Event: "presence:update", + Data: map[string]any{ + "studentId": studentID, + "online": online, + "classId": classID, + }, + } + msgBytes, err := json.Marshal(msg) + if err != nil { + return + } + for _, t := range h.teachers { + if t != nil { + _ = t.WriteRaw(msgBytes) + } + } +} + +// ForceStudentOffline — tắt ngay (không chờ grace), dùng khi SV cố ý đóng app / vi phạm. +func (h *WsHub) ForceStudentOffline(studentID int64) { + h.mu.Lock() + defer h.mu.Unlock() + + classID := int64(0) + if client, ok := h.students[studentID]; ok && client != nil { + classID = client.ClassID + delete(h.students, studentID) + } + if g, ok := h.grace[studentID]; ok { + if classID == 0 { + classID = g.classID + } + } + h.cancelGraceLocked(studentID) + + log.Printf("[WS] Student %d force offline (intentional quit/violation)", studentID) + h.broadcastPresence(studentID, false, classID) + if teachers, exists := h.subscribers[studentID]; exists { + for _, tAddr := range teachers { + if t, found := h.teachers[tAddr]; found { + _ = t.WriteJSON(SocketMsg{ + Event: "teacher:stream-stopped", + Data: map[string]any{"studentId": studentID}, + }) + } + } + } +} + +func (h *WsHub) cancelGraceLocked(studentID int64) { + if t, ok := h.graceTimers[studentID]; ok { + t.Stop() + delete(h.graceTimers, studentID) + } + delete(h.grace, studentID) +} + +func (h *WsHub) finalizeStudentOffline(studentID int64, classID int64) { + h.mu.Lock() + defer h.mu.Unlock() + + if _, online := h.students[studentID]; online { + return + } + if _, ok := h.grace[studentID]; !ok { + return + } + delete(h.grace, studentID) + delete(h.graceTimers, studentID) + + log.Printf("[WS] Student %d offline after grace", studentID) + h.broadcastPresence(studentID, false, classID) + + if teachers, exists := h.subscribers[studentID]; exists { + for _, tAddr := range teachers { + if t, found := h.teachers[tAddr]; found { + _ = t.WriteJSON(SocketMsg{ + Event: "teacher:stream-stopped", + Data: map[string]any{"studentId": studentID}, + }) + } + } + } +} + func (h *WsHub) GetOnlineStudentIDs(classID int64) []int64 { h.mu.RLock() defer h.mu.RUnlock() + seen := map[int64]bool{} var ids []int64 + now := time.Now() for _, client := range h.students { if client.ClassID == classID { + seen[client.StudentID] = true ids = append(ids, client.StudentID) } } + for sid, g := range h.grace { + if now.Before(g.until) && g.classID == classID && !seen[sid] { + ids = append(ids, sid) + } + } if ids == nil { return []int64{} } @@ -124,8 +248,14 @@ func (h *WsHub) Register(c *SocketClient) { c.Addr = c.Conn.RemoteAddr().String() if c.Role == "student" { + wasInGrace := false + if _, ok := h.grace[c.StudentID]; ok { + wasInGrace = true + } + h.cancelGraceLocked(c.StudentID) h.students[c.StudentID] = c - log.Printf("[WS] Student %d registered (Address: %s, Class: %d)", c.StudentID, c.Addr, c.ClassID) + log.Printf("[WS] Student %d registered (Address: %s, Class: %d, reconnectGrace=%v)", c.StudentID, c.Addr, c.ClassID, wasInGrace) + h.broadcastPresence(c.StudentID, true, c.ClassID) if subs, exists := h.subscribers[c.StudentID]; exists && len(subs) > 0 { _ = c.WriteJSON(SocketMsg{Event: "start_screenshot_stream"}) _ = c.WriteJSON(SocketMsg{Event: "start_webcam_stream"}) @@ -151,19 +281,17 @@ func (h *WsHub) Unregister(c *SocketClient) { return } delete(h.students, c.StudentID) - log.Printf("[WS] Student %d disconnected", c.StudentID) + classID := c.ClassID + studentID := c.StudentID - // Báo cho các giáo viên đang xem là stream của học sinh đã dừng - if teachers, exists := h.subscribers[c.StudentID]; exists { - for _, tAddr := range teachers { - if t, found := h.teachers[tAddr]; found { - _ = t.WriteJSON(SocketMsg{ - Event: "teacher:stream-stopped", - Data: map[string]any{"studentId": c.StudentID}, - }) - } - } - } + h.cancelGraceLocked(studentID) + deadline := time.Now().Add(studentOfflineGrace) + h.grace[studentID] = offlineGrace{until: deadline, classID: classID} + h.graceTimers[studentID] = time.AfterFunc(studentOfflineGrace, func() { + h.finalizeStudentOffline(studentID, classID) + }) + log.Printf("[WS] Student %d disconnected — grace %v before offline", studentID, studentOfflineGrace) + // Chưa broadcast offline / stream-stopped — chờ grace (reconnect nhanh như game). } else if c.Role == "teacher" { delete(h.teachers, c.Addr) if c.StaffID > 0 { @@ -182,7 +310,6 @@ func (h *WsHub) Unregister(c *SocketClient) { } log.Printf("[WS] Teacher %s disconnected", c.Addr) - // Dọn dẹp subscriptions của giáo viên này for sID, teachersList := range h.subscribers { newList := []string{} for _, addr := range teachersList { @@ -192,7 +319,6 @@ func (h *WsHub) Unregister(c *SocketClient) { } if len(newList) == 0 { delete(h.subscribers, sID) - // Nếu không còn ai xem học sinh này, gửi lệnh tắt camera/screen cho client học sinh if student, exists := h.students[sID]; exists { _ = student.WriteJSON(SocketMsg{Event: "stop_screenshot_stream"}) _ = student.WriteJSON(SocketMsg{Event: "stop_webcam_stream"}) @@ -204,12 +330,10 @@ func (h *WsHub) Unregister(c *SocketClient) { } } -// Teacher bắt đầu xem stream của Student func (h *WsHub) Subscribe(teacherAddr string, studentID int64) { h.mu.Lock() defer h.mu.Unlock() - // Thêm giáo viên vào list người xem của học sinh teachersList := h.subscribers[studentID] alreadySubscribed := false for _, addr := range teachersList { @@ -223,14 +347,12 @@ func (h *WsHub) Subscribe(teacherAddr string, studentID int64) { log.Printf("[WS] Teacher %s subscribed to student %d stream", teacherAddr, studentID) } - // Phát lệnh cho máy học sinh bật stream (nếu học sinh đang online) if student, exists := h.students[studentID]; exists { _ = student.WriteJSON(SocketMsg{Event: "start_screenshot_stream"}) _ = student.WriteJSON(SocketMsg{Event: "start_webcam_stream"}) } } -// Teacher dừng xem stream của Student func (h *WsHub) Unsubscribe(teacherAddr string, studentID int64) { h.mu.Lock() defer h.mu.Unlock() @@ -250,8 +372,6 @@ func (h *WsHub) Unsubscribe(teacherAddr string, studentID int64) { if len(newList) == 0 { delete(h.subscribers, studentID) log.Printf("[WS] Student %d has no more proctor subscribers. Stopping streams.", studentID) - - // Báo học sinh tắt camera & screen stream để tiết kiệm mạng và CPU if student, exists := h.students[studentID]; exists { _ = student.WriteJSON(SocketMsg{Event: "stop_screenshot_stream"}) _ = student.WriteJSON(SocketMsg{Event: "stop_webcam_stream"}) @@ -261,7 +381,6 @@ func (h *WsHub) Unsubscribe(teacherAddr string, studentID int64) { } } -// Chuyển tiếp frame ảnh từ Student đến các Teacher đã subscribe func (h *WsHub) RelayFrame(studentID int64, event string, data map[string]any) { h.mu.RLock() teachersList, exists := h.subscribers[studentID] @@ -269,7 +388,6 @@ func (h *WsHub) RelayFrame(studentID int64, event string, data map[string]any) { h.mu.RUnlock() return } - // Snapshot subscriber addresses while holding read lock addrs := make([]string, len(teachersList)) copy(addrs, teachersList) h.mu.RUnlock() @@ -291,18 +409,30 @@ func (h *WsHub) RelayFrame(studentID int64, event string, data map[string]any) { return } + var dead []string h.mu.RLock() - defer h.mu.RUnlock() for _, addr := range addrs { if t, found := h.teachers[addr]; found { if err := t.WriteRaw(msgBytes); err != nil { log.Printf("[WS] Relay to teacher %s failed: %v", addr, err) + dead = append(dead, addr) } } } + h.mu.RUnlock() + + for _, addr := range dead { + if t, ok := func() (*SocketClient, bool) { + h.mu.RLock() + defer h.mu.RUnlock() + t, ok := h.teachers[addr] + return t, ok + }(); ok && t != nil { + _ = t.Conn.Close() + } + } } -// WebSocket handler cho Fiber route func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) { return func(c *websocket.Conn) { role := c.Query("role", "student") @@ -351,7 +481,7 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) { return case <-ticker.C: client.writeMu.Lock() - err := c.WriteMessage(websocket.PingMessage, nil) + err := c.WriteControl(websocket.PingMessage, []byte("ping"), time.Now().Add(5*time.Second)) client.writeMu.Unlock() if err != nil { return @@ -372,14 +502,14 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) { continue } - // Xử lý các sự kiện switch msg.Event { + case "client:ping": + _ = client.WriteJSON(SocketMsg{Event: "client:pong", Data: map[string]any{"t": time.Now().UnixMilli()}}) + case "screenshot_stream_frame", "webcam_stream_frame": - // Nhận frame từ học sinh, chuyển tiếp về các thầy cô Hub.RelayFrame(client.StudentID, msg.Event, msg.Data) case "teacher:subscribe": - // Giáo viên đăng ký xem học sinh cụ thể if client.Role == "teacher" { if sIDVal, ok := msg.Data["studentId"]; ok { var sID int64 @@ -396,7 +526,6 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) { } case "teacher:unsubscribe": - // Giáo viên hủy đăng ký if client.Role == "teacher" { if sIDVal, ok := msg.Data["studentId"]; ok { var sID int64 diff --git a/server/main.go b/server/main.go index 4ff149a..e0efae4 100644 --- a/server/main.go +++ b/server/main.go @@ -93,6 +93,7 @@ func main() { api.Get("/student/wifi-policy", handlers.GetStudentWifiPolicyHandler(gormDB)) api.Post("/student/report-wifi", handlers.ReportWifiHandler(gormDB)) api.Post("/student/report-blocked-app", handlers.ReportBlockedAppHandler(gormDB)) + api.Post("/student/report-violation", handlers.ReportViolationHandler(gormDB)) api.Get("/student/chat/messages", handlers.StudentListChatHandler(gormDB)) api.Post("/student/chat/messages", handlers.StudentSendChatHandler(gormDB)) api.Get("/student/chat/conversations", handlers.StudentListChatConversationsHandler(gormDB)) @@ -156,6 +157,7 @@ func main() { staff.Post("/network/accepted-wifis/add", handlers.AddAcceptedWifiHandler(gormDB)) staff.Delete("/network/accepted-wifis/:id", handlers.DeleteAcceptedWifiHandler(gormDB)) staff.Get("/classes/:rkId/session-logs", handlers.ListClassSessionLogsHandler(gormDB)) + staff.Get("/classes/:rkId/violations", handlers.ListClassViolationsHandler(gormDB)) staff.Get("/classes/:rkId/online-students", handlers.GetOnlineStudentsHandler(gormDB)) staff.Get("/classes/:rkId/schedule-conflicts", handlers.GetClassScheduleConflictsHandler(gormDB)) @@ -187,6 +189,7 @@ func main() { staff.Get("/exam-rooms/search-students", handlers.SearchExamStudentsHandler(gormDB)) staff.Get("/exam-rooms/:id", handlers.GetExamRoomHandler(gormDB)) staff.Get("/exam-rooms/:id/online-students", handlers.GetExamRoomOnlineStudentsHandler(gormDB)) + staff.Get("/exam-rooms/:id/violations", handlers.ListExamRoomViolationsHandler(gormDB)) staff.Patch("/exam-rooms/:id", handlers.UpdateExamRoomHandler(gormDB)) staff.Delete("/exam-rooms/:id", handlers.DeleteExamRoomHandler(gormDB)) staff.Post("/exam-rooms/:id/students", handlers.AddExamRoomStudentsHandler(gormDB))