add
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
52
server/internal/db/exam_active.go
Normal file
52
server/internal/db/exam_active.go
Normal 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
|
||||
}
|
||||
63
server/internal/db/exam_status.go
Normal file
63
server/internal/db/exam_status.go
Normal 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
|
||||
}
|
||||
1047
server/internal/handlers/handlers_exam.go
Normal file
1047
server/internal/handlers/handlers_exam.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
70
server/internal/models/exam.go
Normal file
70
server/internal/models/exam.go
Normal 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" }
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
BIN
server/uploads/exams/1/papers/2/main.pdf
Normal file
BIN
server/uploads/exams/1/papers/2/main.pdf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user