This commit is contained in:
2026-06-30 11:51:34 +07:00
parent fd08bee7ab
commit 0abf48ea2d
28 changed files with 3514 additions and 37 deletions

View File

@@ -57,6 +57,11 @@ func AutoMigrate(db *gorm.DB) error {
&models.EmailDomain{},
&models.PasswordResetToken{},
&models.ChatMessage{},
&models.ExamRoom{},
&models.ExamPaper{},
&models.ExamPaperResource{},
&models.ExamRoomStudent{},
&models.ExamSubmission{},
); err != nil {
return err
}

View File

@@ -0,0 +1,52 @@
package db
import (
"time"
"server/internal/models"
"gorm.io/gorm"
)
// ActiveExamInfo — phòng thi đang diễn ra cho sinh viên
type ActiveExamInfo struct {
Room models.ExamRoom
Enrollment models.ExamRoomStudent
Paper *models.ExamPaper
}
// FindActiveExamForStudent trả về phòng thi nếu sinh viên đang trong khung giờ thi.
func FindActiveExamForStudent(db *gorm.DB, studentRkID int64) *ActiveExamInfo {
if studentRkID <= 0 {
return nil
}
now := time.Now()
var enrollment models.ExamRoomStudent
err := db.Table("exam_room_students AS ers").
Select("ers.*").
Joins("JOIN exam_rooms er ON er.id = ers.exam_room_id").
Where("ers.student_rk_id = ? AND er.status = ? AND er.start_time <= ? AND er.end_time >= ?",
studentRkID, models.ExamStatusReady, now, now).
Order("er.start_time DESC").
First(&enrollment).Error
if err != nil {
return nil
}
var room models.ExamRoom
if err := db.First(&room, enrollment.ExamRoomID).Error; err != nil {
return nil
}
info := &ActiveExamInfo{Room: room, Enrollment: enrollment}
if enrollment.AssignedPaperID != nil {
var paper models.ExamPaper
if err := db.First(&paper, *enrollment.AssignedPaperID).Error; err == nil {
info.Paper = &paper
}
}
return info
}
// IsStudentInActiveExam kiểm tra nhanh sinh viên có đang thi không.
func IsStudentInActiveExam(db *gorm.DB, studentRkID int64) bool {
return FindActiveExamForStudent(db, studentRkID) != nil
}

View File

@@ -0,0 +1,63 @@
package db
import (
"time"
"server/internal/models"
"gorm.io/gorm"
)
// ExamDisplayStatus trạng thái hiển thị (tính từ DB status + thời gian).
func ExamDisplayStatus(room models.ExamRoom, now time.Time) string {
switch room.Status {
case models.ExamStatusCancelled:
return models.ExamStatusCancelled
case models.ExamStatusEnded:
return models.ExamStatusEnded
case models.ExamStatusDraft:
return models.ExamStatusDraft
case models.ExamStatusReady:
if now.After(room.EndTime) {
return models.ExamStatusEnded
}
if !now.Before(room.StartTime) {
return "active"
}
return models.ExamStatusReady
default:
return models.ExamStatusDraft
}
}
func ExamRoomEditable(room models.ExamRoom) bool {
return room.Status == models.ExamStatusDraft
}
// ExamRoomPrepEditable — chỉnh gói đề, sinh viên trước khi thi bắt đầu.
func ExamRoomPrepEditable(room models.ExamRoom, now time.Time) bool {
if room.Status == models.ExamStatusDraft {
return true
}
return room.Status == models.ExamStatusReady && now.Before(room.StartTime)
}
func ExamRoomCanUnpublish(room models.ExamRoom, now time.Time) bool {
return room.Status == models.ExamStatusReady && now.Before(room.StartTime)
}
func ExamRoomIsLive(room models.ExamRoom, now time.Time) bool {
return ExamDisplayStatus(room, now) == "active"
}
func ExamRoomCanCancel(room models.ExamRoom, now time.Time) bool {
return ExamRoomIsLive(room, now)
}
// ProcessExamRoomLifecycle đánh dấu phòng ready đã quá giờ kết thúc.
func ProcessExamRoomLifecycle(db *gorm.DB) {
now := time.Now()
_ = db.Model(&models.ExamRoom{}).
Where("status = ? AND end_time < ?", models.ExamStatusReady, now).
Update("status", models.ExamStatusEnded).Error
}

File diff suppressed because it is too large Load Diff

View File

@@ -225,6 +225,24 @@ func GetAllowedAppsHandler(db *gorm.DB) fiber.Handler {
studentIDStr := c.Query("studentId", "0")
studentID, _ := strconv.ParseInt(studentIDStr, 10, 64)
// Phòng thi ưu tiên hơn lịch học
if studentID > 0 {
if examInfo := internalDb.FindActiveExamForStudent(db, studentID); examInfo != nil {
keywords := strings.TrimSpace(examInfo.Room.AllowedApps)
if keywords == "" {
keywords = defaultExamAllowedApps
}
return c.JSON(fiber.Map{
"classRkId": rkID,
"keywords": keywords,
"exit": false,
"examMode": true,
"examRoomId": examInfo.Room.ID,
})
}
}
if studentID > 0 {
resolvedClassID := internalDb.FindActiveClassForStudent(db, studentID)
if resolvedClassID > 0 {

View File

@@ -109,7 +109,7 @@ func GetStudentStatusHandler(db *gorm.DB) fiber.Handler {
monitorLabel = fmt.Sprintf("Đang giám sát — Ca %d", currentPeriod)
}
}
// monitorMode = "exam" — dành cho phòng thi (sẽ bổ sung sau)
// monitorMode = "exam" — xử lý ở nhánh FindActiveExamForStudent phía trên
sessionMap := map[int]models.StudentSession{}
attMap := map[int]models.AttendanceResult{}
@@ -169,6 +169,47 @@ func GetStudentStatusHandler(db *gorm.DB) fiber.Handler {
})
}
// Ưu tiên phòng thi nếu sinh viên đang trong khung giờ thi
if examInfo := internalDb.FindActiveExamForStudent(db, studentID); examInfo != nil {
examKeywords := strings.TrimSpace(examInfo.Room.AllowedApps)
if examKeywords == "" {
examKeywords = defaultExamAllowedApps
}
paperSent := examInfo.Enrollment.PaperSentAt != nil
var subCount int64
_ = db.Model(&models.ExamSubmission{}).Where("exam_room_id = ? AND student_rk_id = ?", examInfo.Room.ID, studentID).Count(&subCount).Error
examPayload := fiber.Map{
"examRoomId": examInfo.Room.ID,
"examName": examInfo.Room.Name,
"startTime": examInfo.Room.StartTime,
"endTime": examInfo.Room.EndTime,
"quizUrl": examInfo.Room.QuizURL,
"paperSent": paperSent,
"submitted": subCount > 0,
}
if paperSent && examInfo.Paper != nil {
examPayload["paperId"] = examInfo.Paper.ID
examPayload["paperTitle"] = examInfo.Paper.Title
}
return c.JSON(fiber.Map{
"sessionDate": today,
"monitorMode": "exam",
"monitorLabel": fmt.Sprintf("Phòng thi: %s", examInfo.Room.Name),
"classRkId": classID,
"className": className,
"classCode": classCode,
"currentPeriod": 0,
"currentCourseName": "",
"currentShiftStart": "",
"currentShiftEnd": "",
"inScheduleNow": false,
"blockerActive": examKeywords != "",
"allowedKeywords": examKeywords,
"shifts": shifts,
"exam": examPayload,
})
}
return c.JSON(fiber.Map{
"sessionDate": today,
"monitorMode": monitorMode,

View File

@@ -0,0 +1,70 @@
package models
import "time"
const (
ExamStatusDraft = "draft"
ExamStatusReady = "ready"
ExamStatusEnded = "ended"
ExamStatusCancelled = "cancelled"
)
// ExamRoom — phòng thi độc lập với lớp học
type ExamRoom struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Name string `gorm:"column:name;size:255;not null" json:"name"`
StartTime time.Time `gorm:"column:start_time;not null;index" json:"startTime"`
EndTime time.Time `gorm:"column:end_time;not null;index" json:"endTime"`
AllowedApps string `gorm:"column:allowed_apps;type:text" json:"allowedApps"`
QuizURL string `gorm:"column:quiz_url;size:1024" json:"quizUrl"`
Status string `gorm:"column:status;size:16;not null;default:draft;index" json:"status"`
}
func (ExamRoom) TableName() string { return "exam_rooms" }
// ExamPaper — gói đề (1 PDF chính + tài nguyên kèm theo)
type ExamPaper struct {
ID uint `gorm:"primaryKey" json:"id"`
ExamRoomID uint `gorm:"column:exam_room_id;not null;index" json:"examRoomId"`
Title string `gorm:"column:title;size:128;not null" json:"title"`
PdfPath string `gorm:"column:pdf_path;size:512;not null" json:"pdfPath"`
SortOrder int `gorm:"column:sort_order;default:0" json:"sortOrder"`
}
func (ExamPaper) TableName() string { return "exam_papers" }
// ExamPaperResource — file tài nguyên kèm đề (đuôi bất kỳ)
type ExamPaperResource struct {
ID uint `gorm:"primaryKey" json:"id"`
ExamPaperID uint `gorm:"column:exam_paper_id;not null;index" json:"examPaperId"`
FilePath string `gorm:"column:file_path;size:512;not null" json:"filePath"`
FileName string `gorm:"column:file_name;size:255;not null" json:"fileName"`
}
func (ExamPaperResource) TableName() string { return "exam_paper_resources" }
// ExamRoomStudent — sinh viên trong phòng thi
type ExamRoomStudent struct {
ID uint `gorm:"primaryKey" json:"id"`
ExamRoomID uint `gorm:"column:exam_room_id;not null;uniqueIndex:idx_exam_room_student,priority:1" json:"examRoomId"`
StudentRkID int64 `gorm:"column:student_rk_id;not null;uniqueIndex:idx_exam_room_student,priority:2" json:"studentRkId"`
AssignedPaperID *uint `gorm:"column:assigned_paper_id;index" json:"assignedPaperId,omitempty"`
PaperSentAt *time.Time `gorm:"column:paper_sent_at" json:"paperSentAt,omitempty"`
PaperScheduledAt *time.Time `gorm:"column:paper_scheduled_at;index" json:"paperScheduledAt,omitempty"`
}
func (ExamRoomStudent) TableName() string { return "exam_room_students" }
// ExamSubmission — bài nộp (zip folder) của sinh viên
type ExamSubmission struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"createdAt"`
ExamRoomID uint `gorm:"column:exam_room_id;not null;index" json:"examRoomId"`
StudentRkID int64 `gorm:"column:student_rk_id;not null;index" json:"studentRkId"`
FilePath string `gorm:"column:file_path;size:512;not null" json:"filePath"`
FileName string `gorm:"column:file_name;size:255;not null" json:"fileName"`
}
func (ExamSubmission) TableName() string { return "exam_submissions" }

View File

@@ -47,6 +47,16 @@ func (h *WsHub) IsStudentOnline(studentRkID int64) bool {
return ok
}
func (h *WsHub) PushExamToStudent(studentRkID int64, data map[string]any) {
h.mu.RLock()
client, ok := h.students[studentRkID]
h.mu.RUnlock()
if !ok || client == nil {
return
}
_ = client.Conn.WriteJSON(SocketMsg{Event: "exam:paper-sent", Data: data})
}
func (h *WsHub) PushChatToStudent(studentRkID int64, data map[string]any) {
h.mu.RLock()
client, ok := h.students[studentRkID]

View File

@@ -3,6 +3,7 @@ package main
import (
"log"
"os"
"time"
"server/internal/db"
"server/internal/handlers"
@@ -52,7 +53,8 @@ func main() {
mailer := mail.LoadConfigFromEnv()
app := fiber.New(fiber.Config{
AppName: "Simple Care Sync Backend",
AppName: "Simple Care Sync Backend",
BodyLimit: 100 * 1024 * 1024, // 100MB — upload PDF gói đề
})
app.Use(cors.New(cors.Config{
@@ -89,7 +91,10 @@ func main() {
api.Post("/student/report-blocked-app", handlers.ReportBlockedAppHandler(gormDB))
api.Get("/student/chat/messages", handlers.StudentListChatHandler(gormDB))
api.Get("/student/chat/conversations", handlers.StudentListChatConversationsHandler(gormDB))
api.Post("/student/chat/messages", handlers.StudentSendChatHandler(gormDB))
api.Get("/student/exam", handlers.GetStudentExamHandler(gormDB))
api.Get("/student/exam/paper-files", handlers.GetStudentExamPaperFilesHandler(gormDB))
api.Get("/student/exam/download", handlers.DownloadStudentExamFileHandler(gormDB))
api.Post("/student/exam/submit", handlers.SubmitStudentExamHandler(gormDB))
// Health check (public)
api.Get("/health", func(c *fiber.Ctx) error {
@@ -152,6 +157,35 @@ func main() {
staff.Delete("/admin/email-domains/:id", handlers.DeleteEmailDomainHandler(gormDB))
staff.Get("/admin/staff", handlers.ListStaffHandler(gormDB))
// Phòng thi
staff.Get("/exam-rooms", handlers.ListExamRoomsHandler(gormDB))
staff.Post("/exam-rooms", handlers.CreateExamRoomHandler(gormDB))
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.Patch("/exam-rooms/:id", handlers.UpdateExamRoomHandler(gormDB))
staff.Delete("/exam-rooms/:id", handlers.DeleteExamRoomHandler(gormDB))
staff.Post("/exam-rooms/:id/students", handlers.AddExamRoomStudentsHandler(gormDB))
staff.Delete("/exam-rooms/:id/students/:studentRkId", handlers.RemoveExamRoomStudentHandler(gormDB))
staff.Post("/exam-rooms/:id/papers", handlers.UploadExamPaperHandler(gormDB))
staff.Post("/exam-rooms/:id/papers/:paperId/resources", handlers.UploadExamPaperResourceHandler(gormDB))
staff.Delete("/exam-rooms/:id/papers/:paperId", handlers.DeleteExamPaperHandler(gormDB))
staff.Post("/exam-rooms/:id/publish", handlers.PublishExamRoomHandler(gormDB))
staff.Post("/exam-rooms/:id/unpublish", handlers.UnpublishExamRoomHandler(gormDB))
staff.Post("/exam-rooms/:id/cancel", handlers.CancelExamRoomHandler(gormDB))
staff.Post("/exam-rooms/:id/assign-random", handlers.RandomAssignExamPapersHandler(gormDB))
staff.Post("/exam-rooms/:id/send-papers", handlers.SendExamPapersHandler(gormDB))
staff.Get("/exam-rooms/:id/submissions", handlers.ListExamSubmissionsHandler(gormDB))
staff.Get("/exam-rooms/:id/submissions/:subId/download", handlers.DownloadExamSubmissionHandler(gormDB))
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
handlers.ProcessScheduledExamSends(gormDB)
}
}()
port := getEnv("PORT", "8080")
log.Printf("Server starting on port %s...", port)
if err := app.Listen(":" + port); err != nil {

Binary file not shown.