This commit is contained in:
2026-07-13 08:12:04 +07:00
parent 2bb856f2fd
commit 719704f19e
4 changed files with 215 additions and 93 deletions

View File

@@ -578,6 +578,7 @@ func (a *App) Logout() {
blocker.Instance.Stop()
a.disconnectWS()
a.stopScreenshotStream()
a.stopWebcamStream()
guard.SuppressFor(5 * time.Second)
runtime.WindowExecJS(a.ctx, "window.location.href = 'https://portal.rikkei.edu.vn/dangnhap'")
@@ -1157,7 +1158,15 @@ func (a *App) connectWS() {
log.Println("[WS] Connected to proctor websocket hub.")
go func() {
defer a.disconnectWS()
defer func() {
a.disconnectWS(conn)
go func() {
time.Sleep(500 * time.Millisecond)
if a.isMonitoringActive() && a.CheckLoginStatus() {
a.connectWS()
}
}()
}()
for {
var msg struct {
Event string `json:"event"`
@@ -1214,15 +1223,28 @@ func (a *App) connectWS() {
}()
}
func (a *App) disconnectWS() {
a.mu.Lock()
defer a.mu.Unlock()
func (a *App) disconnectWS(expectedConn ...*websocket.Conn) {
var shouldStopStreams bool
a.mu.Lock()
if len(expectedConn) > 0 && expectedConn[0] != nil {
if a.wsConn != expectedConn[0] {
a.mu.Unlock()
return
}
}
if a.wsConn != nil {
_ = a.wsConn.Close()
a.wsConn = nil
}
a.wsConnected = false
shouldStopStreams = a.isStreamingSc || a.isStreamingCam
a.mu.Unlock()
if shouldStopStreams {
a.stopScreenshotStream()
a.stopWebcamStream()
}
}
func (a *App) startScreenshotStream() {

View File

@@ -22,30 +22,49 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
const wsRef = useRef<WebSocket | null>(null);
const subscribedRef = useRef<Set<number>>(new Set());
const gridContainerRef = useRef<HTMLDivElement>(null);
const studentIdsString = useMemo(
() => students.map((s) => s.studentRkId).join(','),
[students]
);
const onlineKey = useMemo(() => onlineIds.join(','), [onlineIds]);
const syncSubscriptions = (ws: WebSocket) => {
if (ws.readyState !== WebSocket.OPEN) return;
const target = new Set(onlineIds);
const prev = subscribedRef.current;
prev.forEach((id) => {
if (!target.has(id)) {
ws.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId: id } }));
prev.delete(id);
}
});
onlineIds.forEach((id) => {
if (!prev.has(id)) {
ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId: id } }));
prev.add(id);
}
});
};
useEffect(() => {
if (students.length === 0) return;
const wsUrl = getWsUrl('/ws?role=teacher');
let closed = false;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
const connect = () => {
if (closed) return;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
subscribedRef.current = new Set();
ws.onopen = () => {
students.forEach((s) => {
ws.send(
JSON.stringify({
event: 'teacher:subscribe',
data: { studentId: s.studentRkId },
})
);
});
};
ws.onopen = () => syncSubscriptions(ws);
ws.onmessage = (event) => {
try {
@@ -74,21 +93,36 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
}
};
ws.onclose = () => {
subscribedRef.current = new Set();
if (!closed) {
retryTimer = setTimeout(connect, 3000);
}
};
};
connect();
return () => {
if (ws.readyState === WebSocket.OPEN) {
students.forEach((s) => {
ws.send(
JSON.stringify({
event: 'teacher:unsubscribe',
data: { studentId: s.studentRkId },
})
);
closed = true;
if (retryTimer) clearTimeout(retryTimer);
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) {
subscribedRef.current.forEach((id) => {
ws.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId: id } }));
});
}
ws.close();
ws?.close();
wsRef.current = null;
subscribedRef.current = new Set();
};
}, [studentIdsString]);
useEffect(() => {
const ws = wsRef.current;
if (ws) syncSubscriptions(ws);
}, [onlineKey]);
// Clean up frames when students are removed or go offline
useEffect(() => {
setScreenFrames((prev) => {

View File

@@ -32,6 +32,7 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
useEffect(() => {
const wsUrl = getWsUrl('/ws?role=teacher');
let retryTimer: ReturnType<typeof setTimeout> | null = null;
intentionalClose.current = false;
hasOpened.current = false;
@@ -41,9 +42,6 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
setScreenFrame(null);
setWebcamFrame(null);
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
const markStreaming = () => {
if (!hasFrames.current) {
hasFrames.current = true;
@@ -52,9 +50,16 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
}
};
const connect = () => {
if (intentionalClose.current) return;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
hasOpened.current = true;
setStreaming(true);
setErrorMessage(null);
ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId } }));
};
@@ -85,15 +90,23 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
setStreaming(false);
if (!hasOpened.current && !hasFrames.current) {
setErrorMessage('Không thể kết nối máy chủ giám sát');
} else {
setErrorMessage('Mất kết nối giám sát — đang thử lại...');
}
retryTimer = setTimeout(connect, 3000);
};
};
connect();
return () => {
intentionalClose.current = true;
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId } }));
if (retryTimer) clearTimeout(retryTimer);
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId } }));
}
ws.close();
wsRef.current?.close();
wsRef.current = null;
};
}, [studentId]);

View File

@@ -5,12 +5,18 @@ import (
"log"
"strconv"
"sync"
"time"
"github.com/gofiber/websocket/v2"
"gorm.io/gorm"
internalDb "server/internal/db"
)
const (
wsPingInterval = 30 * time.Second
wsPongWait = 60 * time.Second
)
type SocketMsg struct {
Event string `json:"event"`
Data map[string]any `json:"data"`
@@ -32,6 +38,12 @@ func (c *SocketClient) WriteJSON(v any) error {
return c.Conn.WriteJSON(v)
}
func (c *SocketClient) WriteRaw(msg []byte) error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
return c.Conn.WriteMessage(websocket.TextMessage, msg)
}
type WsHub struct {
mu sync.RWMutex
@@ -133,6 +145,11 @@ func (h *WsHub) Unregister(c *SocketClient) {
defer h.mu.Unlock()
if c.Role == "student" {
current, exists := h.students[c.StudentID]
if !exists || current != c {
log.Printf("[WS] Student %d stale disconnect ignored (replaced by newer connection)", c.StudentID)
return
}
delete(h.students, c.StudentID)
log.Printf("[WS] Student %d disconnected", c.StudentID)
@@ -146,7 +163,6 @@ func (h *WsHub) Unregister(c *SocketClient) {
})
}
}
delete(h.subscribers, c.StudentID)
}
} else if c.Role == "teacher" {
delete(h.teachers, c.Addr)
@@ -248,12 +264,15 @@ 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()
defer h.mu.RUnlock()
teachersList, exists := h.subscribers[studentID]
if !exists || len(teachersList) == 0 {
h.mu.RUnlock()
return
}
// Snapshot subscriber addresses while holding read lock
addrs := make([]string, len(teachersList))
copy(addrs, teachersList)
h.mu.RUnlock()
relayEvent := "teacher:screenshot-stream-frame"
if event == "webcam_stream_frame" {
@@ -267,10 +286,18 @@ func (h *WsHub) RelayFrame(studentID int64, event string, data map[string]any) {
"imageBuffer": data["imageBuffer"],
},
}
msgBytes, err := json.Marshal(msg)
if err != nil {
return
}
for _, addr := range teachersList {
h.mu.RLock()
defer h.mu.RUnlock()
for _, addr := range addrs {
if t, found := h.teachers[addr]; found {
_ = t.WriteJSON(msg)
if err := t.WriteRaw(msgBytes); err != nil {
log.Printf("[WS] Relay to teacher %s failed: %v", addr, err)
}
}
}
}
@@ -308,11 +335,37 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
c.Close()
}()
c.SetReadDeadline(time.Now().Add(wsPongWait))
c.SetPongHandler(func(string) error {
return c.SetReadDeadline(time.Now().Add(wsPongWait))
})
pingDone := make(chan struct{})
defer close(pingDone)
go func() {
ticker := time.NewTicker(wsPingInterval)
defer ticker.Stop()
for {
select {
case <-pingDone:
return
case <-ticker.C:
client.writeMu.Lock()
err := c.WriteMessage(websocket.PingMessage, nil)
client.writeMu.Unlock()
if err != nil {
return
}
}
}
}()
for {
_, msgBytes, err := c.ReadMessage()
if err != nil {
break
}
_ = c.SetReadDeadline(time.Now().Add(wsPongWait))
var msg SocketMsg
if err := json.Unmarshal(msgBytes, &msg); err != nil {