This commit is contained in:
@@ -744,6 +744,8 @@ func ReportViolationHandler(db *gorm.DB) fiber.Handler {
|
||||
Reason string `json:"reason"`
|
||||
MonitorMode string `json:"monitorMode"`
|
||||
ClientAt string `json:"clientAt"` // RFC3339 optional
|
||||
ClassRkID int64 `json:"classRkId"`
|
||||
ExamRoomID uint `json:"examRoomId"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload format"})
|
||||
@@ -763,7 +765,19 @@ func ReportViolationHandler(db *gorm.DB) fiber.Handler {
|
||||
if reason == "" {
|
||||
reason = kind
|
||||
}
|
||||
classID := internalDb.FindActiveClassForStudent(db, req.StudentRkID)
|
||||
|
||||
classID := req.ClassRkID
|
||||
if classID <= 0 {
|
||||
classID = internalDb.FindActiveClassForStudent(db, req.StudentRkID)
|
||||
}
|
||||
|
||||
examRoomID := req.ExamRoomID
|
||||
if examRoomID == 0 && monitorMode == "exam" {
|
||||
if examInfo := internalDb.FindActiveExamForStudent(db, req.StudentRkID); examInfo != nil {
|
||||
examRoomID = examInfo.Room.ID
|
||||
}
|
||||
}
|
||||
|
||||
clientAt := time.Now()
|
||||
if t, err := time.Parse(time.RFC3339, strings.TrimSpace(req.ClientAt)); err == nil {
|
||||
clientAt = t
|
||||
@@ -772,6 +786,7 @@ func ReportViolationHandler(db *gorm.DB) fiber.Handler {
|
||||
row := models.StudentViolation{
|
||||
StudentRkID: req.StudentRkID,
|
||||
ClassRkID: classID,
|
||||
ExamRoomID: examRoomID,
|
||||
Kind: kind,
|
||||
Reason: reason,
|
||||
MonitorMode: monitorMode,
|
||||
@@ -802,6 +817,7 @@ func ReportViolationHandler(db *gorm.DB) fiber.Handler {
|
||||
"studentName": studentName,
|
||||
"studentCode": studentCode,
|
||||
"classId": classID,
|
||||
"examRoomId": examRoomID,
|
||||
"kind": kind,
|
||||
"reason": reason,
|
||||
"monitorMode": row.MonitorMode,
|
||||
@@ -809,7 +825,7 @@ func ReportViolationHandler(db *gorm.DB) fiber.Handler {
|
||||
"createdAt": row.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
|
||||
return c.JSON(fiber.Map{"ok": true, "id": row.ID})
|
||||
return c.JSON(fiber.Map{"ok": true, "id": row.ID, "classRkId": classID, "examRoomId": examRoomID})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -819,6 +835,7 @@ type studentViolationItem struct {
|
||||
StudentCode string `json:"studentCode"`
|
||||
FullName string `json:"fullName"`
|
||||
ClassRkID int64 `json:"classRkId"`
|
||||
ExamRoomID uint `json:"examRoomId"`
|
||||
Kind string `json:"kind"`
|
||||
Reason string `json:"reason"`
|
||||
MonitorMode string `json:"monitorMode"`
|
||||
@@ -844,7 +861,7 @@ func ListClassViolationsHandler(db *gorm.DB) fiber.Handler {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if len(mappings) == 0 {
|
||||
return c.JSON(fiber.Map{"data": []any{}})
|
||||
return c.JSON(fiber.Map{"data": []any{}, "date": date})
|
||||
}
|
||||
studentIDs := make([]int64, 0, len(mappings))
|
||||
for _, m := range mappings {
|
||||
@@ -866,7 +883,11 @@ func ListClassViolationsHandler(db *gorm.DB) fiber.Handler {
|
||||
}
|
||||
dayEnd := dayStart.Add(24 * time.Hour)
|
||||
|
||||
q := db.Where("student_rk_id IN ? AND created_at >= ? AND created_at < ?", studentIDs, dayStart, dayEnd)
|
||||
// Ưu tiên vi phạm gắn đúng lớp; fallback bản ghi cũ (class_rk_id=0) của SV trong lớp khi đang học
|
||||
q := db.Where(
|
||||
"created_at >= ? AND created_at < ? AND ((class_rk_id = ?) OR (class_rk_id = 0 AND monitor_mode = ? AND student_rk_id IN ?))",
|
||||
dayStart, dayEnd, classRkID, "learning", studentIDs,
|
||||
)
|
||||
if kindFilter != "" {
|
||||
q = q.Where("kind = ?", kindFilter)
|
||||
}
|
||||
@@ -878,12 +899,16 @@ func ListClassViolationsHandler(db *gorm.DB) fiber.Handler {
|
||||
out := make([]studentViolationItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
st := nameByID[r.StudentRkID]
|
||||
if st.RkID == 0 {
|
||||
_ = db.Where("rk_id = ?", r.StudentRkID).First(&st).Error
|
||||
}
|
||||
out = append(out, studentViolationItem{
|
||||
ID: r.ID,
|
||||
StudentRkID: r.StudentRkID,
|
||||
StudentCode: st.StudentCode,
|
||||
FullName: st.FullName,
|
||||
ClassRkID: r.ClassRkID,
|
||||
ExamRoomID: r.ExamRoomID,
|
||||
Kind: r.Kind,
|
||||
Reason: r.Reason,
|
||||
MonitorMode: r.MonitorMode,
|
||||
@@ -913,7 +938,7 @@ func ListExamRoomViolationsHandler(db *gorm.DB) fiber.Handler {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if len(roster) == 0 {
|
||||
return c.JSON(fiber.Map{"data": []any{}})
|
||||
return c.JSON(fiber.Map{"data": []any{}, "date": date})
|
||||
}
|
||||
studentIDs := make([]int64, 0, len(roster))
|
||||
for _, s := range roster {
|
||||
@@ -933,7 +958,11 @@ func ListExamRoomViolationsHandler(db *gorm.DB) fiber.Handler {
|
||||
}
|
||||
dayEnd := dayStart.Add(24 * time.Hour)
|
||||
|
||||
q := db.Where("student_rk_id IN ? AND created_at >= ? AND created_at < ?", studentIDs, dayStart, dayEnd)
|
||||
// Ưu tiên exam_room_id; fallback bản ghi cũ (exam_room_id=0, mode=exam) của SV trong phòng
|
||||
q := db.Where(
|
||||
"created_at >= ? AND created_at < ? AND ((exam_room_id = ?) OR (exam_room_id = 0 AND monitor_mode = ? AND student_rk_id IN ?))",
|
||||
dayStart, dayEnd, id, "exam", studentIDs,
|
||||
)
|
||||
if kindFilter != "" {
|
||||
q = q.Where("kind = ?", kindFilter)
|
||||
}
|
||||
@@ -951,6 +980,7 @@ func ListExamRoomViolationsHandler(db *gorm.DB) fiber.Handler {
|
||||
StudentCode: st.StudentCode,
|
||||
FullName: st.FullName,
|
||||
ClassRkID: r.ClassRkID,
|
||||
ExamRoomID: r.ExamRoomID,
|
||||
Kind: r.Kind,
|
||||
Reason: r.Reason,
|
||||
MonitorMode: r.MonitorMode,
|
||||
|
||||
@@ -11,10 +11,16 @@ import (
|
||||
func RequireStaff() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
header := c.Get("Authorization")
|
||||
if header == "" || !strings.HasPrefix(header, "Bearer ") {
|
||||
var token string
|
||||
if header != "" && strings.HasPrefix(header, "Bearer ") {
|
||||
token = strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
|
||||
} else {
|
||||
token = c.Query("token")
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"})
|
||||
}
|
||||
token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
|
||||
claims, err := auth.ParseToken(token)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired token"})
|
||||
|
||||
@@ -203,6 +203,7 @@ type StudentViolation struct {
|
||||
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"`
|
||||
ExamRoomID uint `gorm:"column:exam_room_id;index;default:0" json:"examRoomId"`
|
||||
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"`
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
internalDb "server/internal/db"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/websocket/v2"
|
||||
"gorm.io/gorm"
|
||||
internalDb "server/internal/db"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -53,26 +59,30 @@ type offlineGrace struct {
|
||||
}
|
||||
|
||||
type WsHub struct {
|
||||
mu sync.RWMutex
|
||||
students map[int64]*SocketClient
|
||||
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
|
||||
subscriberModes map[string]string // "teacherAddr_studentId" -> "grid"|"focus"
|
||||
lastRelayed map[string]time.Time // "teacherAddr_studentId_event" -> time
|
||||
mu sync.RWMutex
|
||||
students map[int64]*SocketClient
|
||||
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
|
||||
subscriberModes map[string]string // "teacherAddr_studentId" -> "grid"|"focus"
|
||||
lastRelayed map[string]time.Time // "teacherAddr_studentId_event" -> time
|
||||
httpScreenSubscribers map[int64][]chan []byte // studentId -> list of channels for screen MJPEG
|
||||
httpWebcamSubscribers map[int64][]chan []byte // studentId -> list of channels for webcam MJPEG
|
||||
}
|
||||
|
||||
var Hub = &WsHub{
|
||||
students: make(map[int64]*SocketClient),
|
||||
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),
|
||||
subscriberModes: make(map[string]string),
|
||||
lastRelayed: make(map[string]time.Time),
|
||||
students: make(map[int64]*SocketClient),
|
||||
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),
|
||||
subscriberModes: make(map[string]string),
|
||||
lastRelayed: make(map[string]time.Time),
|
||||
httpScreenSubscribers: make(map[int64][]chan []byte),
|
||||
httpWebcamSubscribers: make(map[int64][]chan []byte),
|
||||
}
|
||||
|
||||
func (h *WsHub) IsStudentOnline(studentRkID int64) bool {
|
||||
@@ -403,6 +413,45 @@ func (h *WsHub) RelayFrameRaw(studentID int64, event string, rawImageBuffer json
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
// Broadcast to HTTP subscribers if any
|
||||
if event == "screenshot_stream_frame" {
|
||||
subs, exists := h.httpScreenSubscribers[studentID]
|
||||
if exists && len(subs) > 0 {
|
||||
var b64Str string
|
||||
if err := json.Unmarshal(rawImageBuffer, &b64Str); err == nil && len(b64Str) > 0 {
|
||||
if idx := strings.Index(b64Str, ","); idx != -1 {
|
||||
b64Str = b64Str[idx+1:]
|
||||
}
|
||||
if rawBytes, err := base64.StdEncoding.DecodeString(b64Str); err == nil {
|
||||
for _, ch := range subs {
|
||||
select {
|
||||
case ch <- rawBytes:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if event == "webcam_stream_frame" {
|
||||
subs, exists := h.httpWebcamSubscribers[studentID]
|
||||
if exists && len(subs) > 0 {
|
||||
var b64Str string
|
||||
if err := json.Unmarshal(rawImageBuffer, &b64Str); err == nil && len(b64Str) > 0 {
|
||||
if idx := strings.Index(b64Str, ","); idx != -1 {
|
||||
b64Str = b64Str[idx+1:]
|
||||
}
|
||||
if rawBytes, err := base64.StdEncoding.DecodeString(b64Str); err == nil {
|
||||
for _, ch := range subs {
|
||||
select {
|
||||
case ch <- rawBytes:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
teachersList, exists := h.subscribers[studentID]
|
||||
if !exists || len(teachersList) == 0 {
|
||||
return
|
||||
@@ -568,7 +617,7 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
|
||||
case string:
|
||||
sID, _ = strconv.ParseInt(v, 10, 64)
|
||||
}
|
||||
|
||||
|
||||
// Nhận chế độ subscription (mặc định là "focus")
|
||||
mode := "focus"
|
||||
if mVal, ok := msg.Data["mode"].(string); ok && mVal != "" {
|
||||
@@ -600,3 +649,134 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WsHub) RegisterHttpSubscriber(studentID int64, kind string) chan []byte {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
ch := make(chan []byte, 16)
|
||||
if kind == "screen" {
|
||||
h.httpScreenSubscribers[studentID] = append(h.httpScreenSubscribers[studentID], ch)
|
||||
} else if kind == "webcam" {
|
||||
h.httpWebcamSubscribers[studentID] = append(h.httpWebcamSubscribers[studentID], ch)
|
||||
}
|
||||
|
||||
// Always trigger streams start if there's any HTTP subscriber
|
||||
if student, exists := h.students[studentID]; exists {
|
||||
_ = student.WriteJSON(SocketMsg{Event: "start_screenshot_stream"})
|
||||
_ = student.WriteJSON(SocketMsg{Event: "start_webcam_stream"})
|
||||
}
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
func (h *WsHub) UnregisterHttpSubscriber(studentID int64, kind string, ch chan []byte) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if kind == "screen" {
|
||||
subs := h.httpScreenSubscribers[studentID]
|
||||
var next []chan []byte
|
||||
for _, c := range subs {
|
||||
if c != ch {
|
||||
next = append(next, c)
|
||||
}
|
||||
}
|
||||
if len(next) == 0 {
|
||||
delete(h.httpScreenSubscribers, studentID)
|
||||
} else {
|
||||
h.httpScreenSubscribers[studentID] = next
|
||||
}
|
||||
} else if kind == "webcam" {
|
||||
subs := h.httpWebcamSubscribers[studentID]
|
||||
var next []chan []byte
|
||||
for _, c := range subs {
|
||||
if c != ch {
|
||||
next = append(next, c)
|
||||
}
|
||||
}
|
||||
if len(next) == 0 {
|
||||
delete(h.httpWebcamSubscribers, studentID)
|
||||
} else {
|
||||
h.httpWebcamSubscribers[studentID] = next
|
||||
}
|
||||
}
|
||||
|
||||
// If no subscribers left (WS or HTTP), stop student's stream
|
||||
wsSubs := h.subscribers[studentID]
|
||||
httpScSubs := h.httpScreenSubscribers[studentID]
|
||||
httpCamSubs := h.httpWebcamSubscribers[studentID]
|
||||
|
||||
if len(wsSubs) == 0 && len(httpScSubs) == 0 && len(httpCamSubs) == 0 {
|
||||
if student, exists := h.students[studentID]; exists {
|
||||
_ = student.WriteJSON(SocketMsg{Event: "stop_screenshot_stream"})
|
||||
_ = student.WriteJSON(SocketMsg{Event: "stop_webcam_stream"})
|
||||
}
|
||||
}
|
||||
|
||||
close(ch)
|
||||
}
|
||||
|
||||
func GetStudentScreenStreamHandler(c *fiber.Ctx) error {
|
||||
studentIDVal := c.Params("studentId")
|
||||
studentID, err := strconv.ParseInt(studentIDVal, 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).SendString("Invalid student ID")
|
||||
}
|
||||
|
||||
ch := Hub.RegisterHttpSubscriber(studentID, "screen")
|
||||
c.Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
||||
c.Set("Cache-Control", "no-cache")
|
||||
c.Set("Connection", "keep-alive")
|
||||
c.Set("Pragma", "no-cache")
|
||||
|
||||
c.Status(fiber.StatusOK)
|
||||
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
|
||||
defer Hub.UnregisterHttpSubscriber(studentID, "screen", ch)
|
||||
|
||||
for frame := range ch {
|
||||
_, _ = fmt.Fprintf(w, "--frame\r\n")
|
||||
_, _ = fmt.Fprintf(w, "Content-Type: image/jpeg\r\n")
|
||||
_, _ = fmt.Fprintf(w, "Content-Length: %d\r\n\r\n", len(frame))
|
||||
_, _ = w.Write(frame)
|
||||
_, _ = fmt.Fprintf(w, "\r\n")
|
||||
if err := w.Flush(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetStudentWebcamStreamHandler(c *fiber.Ctx) error {
|
||||
studentIDVal := c.Params("studentId")
|
||||
studentID, err := strconv.ParseInt(studentIDVal, 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).SendString("Invalid student ID")
|
||||
}
|
||||
|
||||
ch := Hub.RegisterHttpSubscriber(studentID, "webcam")
|
||||
c.Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
||||
c.Set("Cache-Control", "no-cache")
|
||||
c.Set("Connection", "keep-alive")
|
||||
c.Set("Pragma", "no-cache")
|
||||
|
||||
c.Status(fiber.StatusOK)
|
||||
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
|
||||
defer Hub.UnregisterHttpSubscriber(studentID, "webcam", ch)
|
||||
|
||||
for frame := range ch {
|
||||
_, _ = fmt.Fprintf(w, "--frame\r\n")
|
||||
_, _ = fmt.Fprintf(w, "Content-Type: image/jpeg\r\n")
|
||||
_, _ = fmt.Fprintf(w, "Content-Length: %d\r\n\r\n", len(frame))
|
||||
_, _ = w.Write(frame)
|
||||
_, _ = fmt.Fprintf(w, "\r\n")
|
||||
if err := w.Flush(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user