package handlers import ( "archive/zip" "encoding/base64" "fmt" "io" "math/rand" "mime/multipart" "os" "path/filepath" "strconv" "strings" "time" internalDb "server/internal/db" "server/internal/models" internalWs "server/internal/websocket" "github.com/gofiber/fiber/v2" "gorm.io/gorm" ) const systemAllowedApps = "explorer.exe,taskmgr.exe,cmd.exe,powershell.exe,conhost.exe,client.exe,simple_care_v1.0.exe,simple_care.exe,wails.exe,msedgewebview2.exe,code.exe,cursor.exe,windsurf.exe,goland.exe,goland64.exe,idea64.exe,clion64.exe,webstorm64.exe,pycharm64.exe,rider64.exe,studio64.exe,eclipse.exe,sublime_text.exe,notepad++.exe,devenv.exe,git-bash.exe,bash.exe,chsime.exe,ctfmon.exe,unikey.exe,unikeyvnt.exe,gotiengviet.exe,openkey.exe,openkey64.exe,applicationframehost.exe,dwm.exe,csrss.exe,consent.exe,finder,dock,windowserver,loginwindow,systemuiserver,controlcenter,notificationcenter,spotlight,vietnamese,pboard,securityagent,keychain,coreaudiod,terminal,iterm,iterm2,zsh,sh,osascript,system events,gnome-shell,mutter,xwayland,xorg,plasmashell,hyprland,sway,waybar,rofi,gdm3,sddm,lightdm,gnome-terminal,ptyxis,konsole,fcitx5,fcitx,ibus-daemon,ibus-x11,ibus-,dbus-daemon,systemd,nm-applet,blueman-applet,kdeconnectd,shellexperiencehost.exe,startmenuexperiencehost.exe,searchhost.exe,searchapp.exe,searchindexer.exe,sihost.exe,taskhostw.exe,taskbar.exe,lockapp.exe,logonui.exe,winlogon.exe,userinit.exe,runtimebroker.exe,backgroundtaskhost.exe,wwahost.exe,microsoftedgecp.exe,textinputhost.exe,chsime.exe,imetip.exe,imecmnt.exe,googlepinyin.exe,baidu.exe,credentialuibroker.exe,smartscreen.exe,securityhealthsystray.exe,securityhealthservice.exe,wscsvc.exe,msseces.exe,msmpeng.exe,nisSrv.exe,notificationplatformcontroller,systemsettings.exe,settingssynchostservice.exe,settingssynchost.exe,regsvc.exe,spoolsv.exe,tabtip.exe,tabtip32.exe,onedrive.exe,onedriveupdater.exe,narrator.exe,magnify.exe,osk.exe,audiodg.exe,sndvol.exe,cmediaaudiocontrolpanel.exe,wuauclt.exe,musnotifyicon.exe,windowsstore.exe,winstore.app.exe,nvdisplay.container.exe,nvcontainer.exe,nvinject.exe,nvtelemetrycontainer.exe,nvvsvc.exe,nvspcaps64.exe,geforce experience.exe,radeonsoftware.exe,amddvr.exe,igfxtray.exe,igfxem.exe,igfxhk.exe,hkcmd.exe,atk hub.exe,atkex.exe,asusoptimization.exe,avastui.exe,avgui.exe,mbam.exe,mbamtray.exe,bdagent.exe,uiseagnt.exe,eguiproxy.exe,resmon.exe,perfmon.exe,mmc.exe,pwsh.exe,windowsterminal.exe,wt.exe,mintty.exe,wsl.exe,wslhost.exe,launchpad,mission control,exposé,universalaccessd,accessibilityuiagent,inputmethodkit,ibus,hiragana kakomi input,kinput2,squirrel,scim,kotoeri,pinyin - simplified,zhuyin - traditional,abc,keychainservicesagent,trustd,opendirectoryd,authorizationhost,securityd,biometricd,touchidd,audioundockhelper,audio midi setup,noiseremoval,networkd,nesessionmanager,scutil,configd,mDNSResponder,mds,mds_stores,mdworker,mdworker_shared,bird,cloudd,com.apple.icloud,cloudphotod,nsurlsessiond,system preferences,system settings,software update,app store,screensaver engine,screensharingd,com.apple.screensaver,colorsyncd,colorsync utility,nightshift,display menu,printingproxy,cupsd,crashreporter,diagnosticsd,spindump,reportmemoryexception,webkit,com.apple.webkit,com.apple.webkit.networking,applescriptkit,applescript runner,rustdesk,anydesk,teamviewer,screen sharing,wezterm,kitty,alacritty,hyper,git,git-credential-manager,git-credential-osxkeychain,github desktop,sourcetree,fork,docker,docker desktop,com.docker,mutter-x11-fram,xorg,Xorg,x11,kwin_wayland,kwin_x11,wayfire,river,labwc,openbox,i3,i3bar,awesome,bspwm,xfwm4,marco,compiz,picom,compton,wl-paste,wl-copy,wlr-randr,kanshi,wlsunset,gammastep,swaylock,swayidle,swaybg,swaync,eww,ags,wofi,fuzzel,tofi,dmenu,bemenu,gdm,lxdm,slim,greetd,gnome-session-bi,lxsession,startx,xinit,gnome-terminal-,foot,xfce4-terminal,tilix,terminator,urxvt,rxvt,sakura,st,fish,dash,ksh,fcitx-,uim,sogou-qimpanel,gcin,kimpanel,gjs,gsd-,goa-identity-ser,evolution-,gnome-keyring-d,gnome-keyring,polkit-gnome-au,polkitd,gnome-settings-d,gnome-initial-se,gnome-control-ce,gvfsd,gvfsd-,tracker-miner-,tracker3,zeitgeist,zeitgeist-,accounts-daemon,colord,power-profiles-,fprintd,fwupd,udisksd,upowerd,packagekitd,nm-dispatcher,at-spi,at-spi-bus-laun,at-spi2-registr,dbus-launch,snapd-,snapd,nm-tray,network-manager-,blueman-tray,blueman-manager,kdeconnect-indi,dunst,mako,notify-osd,xfce4-notifyd,fnott,lxqt-policykit-,xfce-polkit,mate-polkit,pkttyagent,copyq,clipit,xclip,xsel,clipman,greenclip,glycin,zenity,yad,kdialog,webkit2gtk,WebKitWebProcess,WebKitNetworkPro,rustdesk-bin,remmina,clion,webstorm,pycharm,rider,studio,simple_care_v1.0,egui.exe,microsoftsecurityapp.exe,msascuil.exe,pickerhost.exe,seccenter.exe,mc-web-view.exe,rsappui.exe,avpui.exe,ksdeui.exe,n360.exe,nortonsecurity.exe,sophos ui.exe,bdwtxag.exe,activity monitor,passwords,xprotectservice,xprotect,malware removal tool,mrt,avast,bitdefender,norton,sophos,malwarebytes,eset,little snitch,lulu,update-notifier,apport-gtk,kgpg,ksecretd,clamtk,clamav,seahorse,kwalletd,gufw,firewalld,pkexec" func examUploadRoot() string { if v := os.Getenv("UPLOAD_DIR"); v != "" { return v } return "uploads" } func examRoomDir(roomID uint) string { return filepath.Join(examUploadRoot(), "exams", fmt.Sprintf("%d", roomID)) } func validateExamRoomForPublish(db *gorm.DB, room *models.ExamRoom) []string { var issues []string now := time.Now() if strings.TrimSpace(room.Name) == "" { issues = append(issues, "Thiếu tên phòng thi") } if !room.EndTime.After(room.StartTime) { issues = append(issues, "Giờ kết thúc phải sau giờ bắt đầu") } if !room.EndTime.After(now) { issues = append(issues, "Giờ kết thúc phải ở tương lai") } if strings.TrimSpace(room.AllowedApps) == "" { issues = append(issues, "Chưa cấu hình ứng dụng được phép khi thi") } var students []models.ExamRoomStudent _ = db.Where("exam_room_id = ?", room.ID).Find(&students).Error if len(students) == 0 { issues = append(issues, "Chưa có sinh viên trong phòng thi") } return issues } func canModifyExamPapers(db *gorm.DB, room models.ExamRoom) bool { if room.Status != models.ExamStatusDraft && room.Status != models.ExamStatusReady { return false } var sentCount int64 _ = db.Model(&models.ExamRoomStudent{}).Where("exam_room_id = ? AND paper_sent_at IS NOT NULL", room.ID).Count(&sentCount).Error return sentCount == 0 } // ProcessScheduledExamSends gửi đề đã hẹn giờ — gọi định kỳ từ main. func ProcessScheduledExamSends(db *gorm.DB) { internalDb.ProcessExamRoomLifecycle(db) now := time.Now() var rows []models.ExamRoomStudent _ = db.Table("exam_room_students AS ers"). Select("ers.*"). Joins("JOIN exam_rooms er ON er.id = ers.exam_room_id"). Where("ers.paper_scheduled_at IS NOT NULL AND ers.paper_scheduled_at <= ? AND ers.paper_sent_at IS NULL", now). Where("er.status = ?", models.ExamStatusReady). Find(&rows).Error for _, row := range rows { deliverExamPaper(db, row.ID) } } func deliverExamPaper(db *gorm.DB, enrollmentID uint) { var en models.ExamRoomStudent if err := db.First(&en, enrollmentID).Error; err != nil { return } if en.AssignedPaperID == nil { return } now := time.Now() en.PaperSentAt = &now en.PaperScheduledAt = nil _ = db.Save(&en).Error var room models.ExamRoom var paper models.ExamPaper if err := db.First(&room, en.ExamRoomID).Error; err != nil { return } if room.Status != models.ExamStatusReady { return } _ = db.First(&paper, *en.AssignedPaperID).Error internalWs.Hub.PushExamToStudent(en.StudentRkID, map[string]any{ "examRoomId": room.ID, "examName": room.Name, "paperId": paper.ID, "paperTitle": paper.Title, "paperSentAt": now.Format(time.RFC3339), }) } // GET /api/exam-rooms func ListExamRoomsHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { var rooms []models.ExamRoom if err := db.Order("start_time desc").Find(&rooms).Error; err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } type row struct { models.ExamRoom StudentCount int `json:"studentCount"` PaperCount int `json:"paperCount"` DisplayStatus string `json:"displayStatus"` } out := make([]row, 0, len(rooms)) now := time.Now() for _, r := range rooms { if r.Status == "" { r.Status = models.ExamStatusDraft } var sc, pc int64 _ = db.Model(&models.ExamRoomStudent{}).Where("exam_room_id = ?", r.ID).Count(&sc).Error _ = db.Model(&models.ExamPaper{}).Where("exam_room_id = ?", r.ID).Count(&pc).Error out = append(out, row{ ExamRoom: r, StudentCount: int(sc), PaperCount: int(pc), DisplayStatus: internalDb.ExamDisplayStatus(r, now), }) } return c.JSON(fiber.Map{"data": out}) } } // POST /api/exam-rooms func CreateExamRoomHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { var req struct { Name string `json:"name"` StartTime string `json:"startTime"` EndTime string `json:"endTime"` AllowedApps string `json:"allowedApps"` QuizURL string `json:"quizUrl"` } if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid body"}) } req.Name = strings.TrimSpace(req.Name) if req.Name == "" { return c.Status(400).JSON(fiber.Map{"error": "Tên phòng thi bắt buộc"}) } start, err1 := time.Parse(time.RFC3339, req.StartTime) end, err2 := time.Parse(time.RFC3339, req.EndTime) if err1 != nil || err2 != nil { return c.Status(400).JSON(fiber.Map{"error": "Thời gian không hợp lệ (RFC3339)"}) } if !end.After(start) { return c.Status(400).JSON(fiber.Map{"error": "Giờ kết thúc phải sau giờ bắt đầu"}) } apps := strings.TrimSpace(req.AllowedApps) room := models.ExamRoom{ Name: req.Name, StartTime: start, EndTime: end, AllowedApps: apps, QuizURL: strings.TrimSpace(req.QuizURL), Status: models.ExamStatusDraft, } if err := db.Create(&room).Error; err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } _ = os.MkdirAll(examRoomDir(room.ID), 0755) return c.Status(201).JSON(room) } } // GET /api/exam-rooms/:id func GetExamRoomHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { id, err := parseUintParam(c, "id") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid id"}) } var room models.ExamRoom if err := db.First(&room, id).Error; err != nil { return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy phòng thi"}) } var papers []models.ExamPaper _ = db.Where("exam_room_id = ?", id).Order("sort_order asc, id asc").Find(&papers).Error type paperRow struct { models.ExamPaper Resources []models.ExamPaperResource `json:"resources"` } paperRows := make([]paperRow, 0, len(papers)) for _, p := range papers { var res []models.ExamPaperResource _ = db.Where("exam_paper_id = ?", p.ID).Find(&res).Error paperRows = append(paperRows, paperRow{ExamPaper: p, Resources: res}) } var students []models.ExamRoomStudent _ = db.Where("exam_room_id = ?", id).Find(&students).Error type studentRow struct { models.ExamRoomStudent FullName string `json:"fullName"` StudentCode string `json:"studentCode"` Email string `json:"email"` Avatar string `json:"avatar,omitempty"` PaperTitle string `json:"paperTitle,omitempty"` Submitted bool `json:"submitted"` } stRows := make([]studentRow, 0, len(students)) for _, s := range students { row := studentRow{ExamRoomStudent: s} var st models.Student if err := db.Where("rk_id = ?", s.StudentRkID).First(&st).Error; err == nil { row.FullName = st.FullName row.StudentCode = st.StudentCode row.Email = st.Email if st.Avatar != nil { row.Avatar = *st.Avatar } } if s.AssignedPaperID != nil { var p models.ExamPaper if err := db.First(&p, *s.AssignedPaperID).Error; err == nil { row.PaperTitle = p.Title } } var subCount int64 _ = db.Model(&models.ExamSubmission{}).Where("exam_room_id = ? AND student_rk_id = ?", id, s.StudentRkID).Count(&subCount).Error row.Submitted = subCount > 0 stRows = append(stRows, row) } st := internalDb.ExamDisplayStatus(room, time.Now()) now := time.Now() return c.JSON(fiber.Map{ "room": room, "papers": paperRows, "students": stRows, "displayStatus": st, "editable": internalDb.ExamRoomEditable(room), "prepEditable": internalDb.ExamRoomPrepEditable(room, now), "canPublish": room.Status == models.ExamStatusDraft, "canUnpublish": internalDb.ExamRoomCanUnpublish(room, time.Now()), "canCancel": internalDb.ExamRoomCanCancel(room, time.Now()), }) } } // GET /api/exam-rooms/:id/online-students func GetExamRoomOnlineStudentsHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { id, err := parseUintParam(c, "id") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid id"}) } var students []models.ExamRoomStudent if err := db.Where("exam_room_id = ?", id).Find(&students).Error; err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } online := make([]int64, 0, len(students)) for _, s := range students { if internalWs.Hub.IsStudentOnline(s.StudentRkID) { online = append(online, s.StudentRkID) } } return c.JSON(fiber.Map{"onlineStudentIds": online}) } } // PATCH /api/exam-rooms/:id func UpdateExamRoomHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { id, err := parseUintParam(c, "id") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid id"}) } var room models.ExamRoom if err := db.First(&room, id).Error; err != nil { return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"}) } var req struct { Name *string `json:"name"` StartTime *string `json:"startTime"` EndTime *string `json:"endTime"` AllowedApps *string `json:"allowedApps"` QuizURL *string `json:"quizUrl"` } if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid body"}) } if !internalDb.ExamRoomEditable(room) { if room.Status == models.ExamStatusReady { if req.Name != nil || req.StartTime != nil || req.EndTime != nil || req.QuizURL != nil { return c.Status(403).JSON(fiber.Map{"error": "Chỉ sửa được cấu hình ứng dụng được phép khi phòng thi đã đẩy hoặc đang thi"}) } } else { return c.Status(403).JSON(fiber.Map{"error": "Chỉ sửa được phòng thi ở trạng thái tạm thời hoặc đang sẵn sàng/đang thi"}) } } if req.Name != nil { room.Name = strings.TrimSpace(*req.Name) } if req.StartTime != nil { t, err := time.Parse(time.RFC3339, *req.StartTime) if err != nil { return c.Status(400).JSON(fiber.Map{"error": "startTime không hợp lệ"}) } room.StartTime = t } if req.EndTime != nil { t, err := time.Parse(time.RFC3339, *req.EndTime) if err != nil { return c.Status(400).JSON(fiber.Map{"error": "endTime không hợp lệ"}) } room.EndTime = t } if !room.EndTime.After(room.StartTime) { return c.Status(400).JSON(fiber.Map{"error": "Giờ kết thúc phải sau giờ bắt đầu"}) } if req.AllowedApps != nil { room.AllowedApps = strings.TrimSpace(*req.AllowedApps) } if req.QuizURL != nil { room.QuizURL = strings.TrimSpace(*req.QuizURL) } if err := db.Save(&room).Error; err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(room) } } // POST /api/exam-rooms/:id/publish — đẩy phòng thi (validate → ready) func PublishExamRoomHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { id, err := parseUintParam(c, "id") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid id"}) } var room models.ExamRoom if err := db.First(&room, id).Error; err != nil { return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"}) } if room.Status != models.ExamStatusDraft { return c.Status(403).JSON(fiber.Map{"error": "Chỉ đẩy được phòng thi ở trạng thái tạm thời"}) } if issues := validateExamRoomForPublish(db, &room); len(issues) > 0 { return c.Status(400).JSON(fiber.Map{"error": "Chưa đủ điều kiện", "issues": issues}) } room.Status = models.ExamStatusReady if err := db.Save(&room).Error; err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"ok": true, "room": room, "displayStatus": internalDb.ExamDisplayStatus(room, time.Now())}) } } // POST /api/exam-rooms/:id/unpublish — thu hồi lệnh đẩy (ready → draft, trước giờ thi) func UnpublishExamRoomHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { id, err := parseUintParam(c, "id") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid id"}) } var room models.ExamRoom if err := db.First(&room, id).Error; err != nil { return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"}) } if !internalDb.ExamRoomCanUnpublish(room, time.Now()) { return c.Status(403).JSON(fiber.Map{"error": "Không thể thu hồi — phòng đã vào giờ thi hoặc không ở trạng thái sẵn sàng"}) } room.Status = models.ExamStatusDraft if err := db.Save(&room).Error; err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"ok": true, "room": room, "displayStatus": models.ExamStatusDraft}) } } // POST /api/exam-rooms/:id/cancel — hủy phòng thi sớm (đang diễn ra) func CancelExamRoomHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { id, err := parseUintParam(c, "id") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid id"}) } var room models.ExamRoom if err := db.First(&room, id).Error; err != nil { return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"}) } now := time.Now() if !internalDb.ExamRoomCanCancel(room, now) { return c.Status(403).JSON(fiber.Map{"error": "Chỉ hủy được phòng thi đang diễn ra"}) } room.Status = models.ExamStatusCancelled room.EndTime = now if err := db.Save(&room).Error; err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"ok": true, "room": room, "displayStatus": models.ExamStatusCancelled}) } } // DELETE /api/exam-rooms/:id func DeleteExamRoomHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { email, _ := c.Locals("staffEmail").(string) if email != "phuocntb@rikkeiacademy.com" { return c.Status(403).JSON(fiber.Map{"error": "Bạn không có quyền thực hiện chức năng này"}) } id, err := parseUintParam(c, "id") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid id"}) } var room models.ExamRoom if err := db.First(&room, id).Error; err != nil { return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"}) } if internalDb.ExamRoomIsLive(room, time.Now()) { return c.Status(403).JSON(fiber.Map{"error": "Không thể xóa phòng thi đang diễn ra — hãy hủy phòng trước"}) } if room.Status == models.ExamStatusReady { return c.Status(403).JSON(fiber.Map{"error": "Phòng đã đẩy — thu hồi lệnh đẩy trước khi xóa"}) } _ = os.RemoveAll(examRoomDir(id)) _ = db.Where("exam_room_id = ?", id).Delete(&models.ExamSubmission{}).Error _ = db.Where("exam_room_id = ?", id).Delete(&models.ExamRoomStudent{}).Error var papers []models.ExamPaper _ = db.Where("exam_room_id = ?", id).Find(&papers).Error for _, p := range papers { _ = db.Where("exam_paper_id = ?", p.ID).Delete(&models.ExamPaperResource{}).Error } _ = db.Where("exam_room_id = ?", id).Delete(&models.ExamPaper{}).Error _ = db.Delete(&room).Error return c.JSON(fiber.Map{"ok": true}) } } // GET /api/exam-rooms/search-students?q= func SearchExamStudentsHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { q := strings.TrimSpace(c.Query("q")) if q == "" { return c.JSON(fiber.Map{"data": []any{}}) } // Split search query by newline, comma, or semicolon var terms []string for _, line := range strings.FieldsFunc(q, func(r rune) bool { return r == '\n' || r == '\r' || r == ';' || r == ',' }) { t := strings.TrimSpace(line) if t != "" { terms = append(terms, t) } } if len(terms) == 0 { return c.JSON(fiber.Map{"data": []any{}}) } if len(terms) > 100 { terms = terms[:100] } var ids []int64 for _, term := range terms { pat := "%" + term + "%" var termIDs []int64 _ = db.Model(&models.Student{}). Where("full_name LIKE ? OR student_code LIKE ? OR email LIKE ?", pat, pat, pat). Limit(30). Pluck("rk_id", &termIDs).Error ids = append(ids, termIDs...) var classIDs []int64 _ = db.Model(&models.Class{}).Where("class_code LIKE ? OR name LIKE ?", pat, pat).Pluck("rk_id", &classIDs).Error if len(classIDs) > 0 { var more []int64 _ = db.Model(&models.ClassStudent{}).Where("class_rk_id IN ?", classIDs).Distinct().Pluck("student_rk_id", &more).Error ids = append(ids, more...) } } seen := map[int64]bool{} uniq := make([]int64, 0) for _, id := range ids { if id > 0 && !seen[id] { seen[id] = true uniq = append(uniq, id) } } if len(uniq) > 100 { uniq = uniq[:100] } var students []models.Student if len(uniq) > 0 { _ = db.Where("rk_id IN ?", uniq).Limit(100).Find(&students).Error } type row struct { StudentRkID int64 `json:"studentRkId"` FullName string `json:"fullName"` StudentCode string `json:"studentCode"` Email string `json:"email"` ClassCodes string `json:"classCodes"` } out := make([]row, 0, len(students)) for _, st := range students { var codes []string _ = db.Table("class_students cs"). Select("c.class_code"). Joins("JOIN classes c ON c.rk_id = cs.class_rk_id"). Where("cs.student_rk_id = ?", st.RkID). Pluck("class_code", &codes).Error out = append(out, row{ StudentRkID: st.RkID, FullName: st.FullName, StudentCode: st.StudentCode, Email: st.Email, ClassCodes: strings.Join(codes, ", "), }) } return c.JSON(fiber.Map{"data": out}) } } // POST /api/exam-rooms/:id/students func AddExamRoomStudentsHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { roomID, err := parseUintParam(c, "id") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid id"}) } var room models.ExamRoom if err := db.First(&room, roomID).Error; err != nil { return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"}) } if !internalDb.ExamRoomPrepEditable(room, time.Now()) { return c.Status(403).JSON(fiber.Map{"error": "Phòng thi đã bắt đầu — không thể thêm sinh viên"}) } var req struct { StudentRkIDs []int64 `json:"studentRkIds"` } if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid body"}) } added := 0 for _, sid := range req.StudentRkIDs { if sid <= 0 { continue } var cnt int64 _ = db.Model(&models.ExamRoomStudent{}).Where("exam_room_id = ? AND student_rk_id = ?", roomID, sid).Count(&cnt).Error if cnt > 0 { continue } _ = db.Create(&models.ExamRoomStudent{ExamRoomID: roomID, StudentRkID: sid}).Error added++ } return c.JSON(fiber.Map{"added": added}) } } // DELETE /api/exam-rooms/:id/students/:studentRkId func RemoveExamRoomStudentHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { roomID, err := parseUintParam(c, "id") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid id"}) } studentRkID, err := strconv.ParseInt(c.Params("studentRkId"), 10, 64) if err != nil || studentRkID <= 0 { return c.Status(400).JSON(fiber.Map{"error": "Invalid studentRkId"}) } var room models.ExamRoom if err := db.First(&room, roomID).Error; err != nil { return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"}) } if !internalDb.ExamRoomPrepEditable(room, time.Now()) { return c.Status(403).JSON(fiber.Map{"error": "Phòng thi đã bắt đầu — không thể xóa sinh viên"}) } _ = db.Where("exam_room_id = ? AND student_rk_id = ?", roomID, studentRkID).Delete(&models.ExamRoomStudent{}).Error return c.JSON(fiber.Map{"ok": true}) } } // POST /api/exam-rooms/:id/papers — multipart: title, pdf, resources[] (gói đề) func UploadExamPaperHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { roomID, err := parseUintParam(c, "id") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid id"}) } var room models.ExamRoom if err := db.First(&room, roomID).Error; err != nil { return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"}) } if !canModifyExamPapers(db, room) { return c.Status(403).JSON(fiber.Map{"error": "Không thể thêm gói đề khi đề đã được gửi cho sinh viên hoặc phòng thi đã kết thúc"}) } form, err := c.MultipartForm() if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Không đọc được dữ liệu upload — kiểm tra file PDF"}) } var pdfFiles []*multipart.FileHeader if form != nil { pdfFiles = form.File["pdf"] } if len(pdfFiles) == 0 { return c.Status(400).JSON(fiber.Map{"error": "Thiếu file PDF trong gói đề (field: pdf)"}) } file := pdfFiles[0] title := strings.TrimSpace(c.FormValue("title")) if title == "" { title = strings.TrimSuffix(filepath.Base(file.Filename), filepath.Ext(file.Filename)) } if title == "" { title = "Gói đề" } if !strings.HasSuffix(strings.ToLower(file.Filename), ".pdf") { return c.Status(400).JSON(fiber.Map{"error": "File đề chính phải là PDF"}) } var cnt int64 _ = db.Model(&models.ExamPaper{}).Where("exam_room_id = ?", roomID).Count(&cnt).Error paper := models.ExamPaper{ExamRoomID: roomID, Title: title, SortOrder: int(cnt)} if err := db.Create(&paper).Error; err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } dir := filepath.Join(examRoomDir(roomID), "papers", fmt.Sprintf("%d", paper.ID)) if err := os.MkdirAll(dir, 0755); err != nil { _ = db.Delete(&paper).Error return c.Status(500).JSON(fiber.Map{"error": "Không tạo được thư mục lưu file"}) } dest := filepath.Join(dir, "main.pdf") if err := c.SaveFile(file, dest); err != nil { _ = os.RemoveAll(dir) _ = db.Delete(&paper).Error return c.Status(500).JSON(fiber.Map{"error": "Lưu file PDF thất bại"}) } paper.PdfPath = dest if err := db.Save(&paper).Error; err != nil { _ = os.RemoveAll(dir) _ = db.Delete(&paper).Error return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } var resourceHeaders []*multipart.FileHeader if form != nil { resourceHeaders = form.File["resources"] } savedResources := saveExamPaperResources(db, c, roomID, paper.ID, resourceHeaders) type pkgResp struct { models.ExamPaper Resources []models.ExamPaperResource `json:"resources"` } return c.Status(201).JSON(pkgResp{ExamPaper: paper, Resources: savedResources}) } } func saveExamPaperResources(db *gorm.DB, c *fiber.Ctx, roomID, paperID uint, onlyFiles []*multipart.FileHeader) []models.ExamPaperResource { headers := onlyFiles if len(headers) == 0 { return nil } if len(headers) == 0 { return nil } dir := filepath.Join(examRoomDir(roomID), "papers", fmt.Sprintf("%d", paperID), "resources") _ = os.MkdirAll(dir, 0755) out := make([]models.ExamPaperResource, 0, len(headers)) for _, file := range headers { safeName := filepath.Base(file.Filename) if safeName == "" || safeName == "." { continue } dest := filepath.Join(dir, safeName) if err := c.SaveFile(file, dest); err != nil { continue } res := models.ExamPaperResource{ExamPaperID: paperID, FilePath: dest, FileName: safeName} if err := db.Create(&res).Error; err != nil { continue } out = append(out, res) } return out } // POST /api/exam-rooms/:id/papers/:paperId/resources — thêm tài nguyên vào gói đề func UploadExamPaperResourceHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { roomID, err := parseUintParam(c, "id") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid id"}) } paperID, err := parseUintParam(c, "paperId") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid paperId"}) } var room models.ExamRoom if err := db.First(&room, roomID).Error; err != nil { return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy phòng"}) } if !canModifyExamPapers(db, room) { return c.Status(403).JSON(fiber.Map{"error": "Không thể thêm tài nguyên khi đề đã được gửi cho sinh viên hoặc phòng thi đã kết thúc"}) } var paper models.ExamPaper if err := db.Where("id = ? AND exam_room_id = ?", paperID, roomID).First(&paper).Error; err != nil { return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy gói đề"}) } form, _ := c.MultipartForm() var files []*multipart.FileHeader if form != nil { if batch, ok := form.File["resources"]; ok && len(batch) > 0 { files = batch } else if batch, ok := form.File["file"]; ok && len(batch) > 0 { files = batch } } if len(files) == 0 { if file, err := c.FormFile("file"); err == nil { files = []*multipart.FileHeader{file} } } if len(files) == 0 { return c.Status(400).JSON(fiber.Map{"error": "Thiếu file tài nguyên"}) } saved := saveExamPaperResources(db, c, roomID, paperID, files) if len(saved) == 0 { return c.Status(500).JSON(fiber.Map{"error": "Lưu tài nguyên thất bại"}) } if len(saved) == 1 { return c.Status(201).JSON(saved[0]) } return c.Status(201).JSON(fiber.Map{"data": saved}) } } // DELETE /api/exam-rooms/:id/papers/:paperId func DeleteExamPaperHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { roomID, err := parseUintParam(c, "id") paperID, err2 := parseUintParam(c, "paperId") if err != nil || err2 != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid id"}) } var room models.ExamRoom if err := db.First(&room, roomID).Error; err != nil { return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"}) } if !canModifyExamPapers(db, room) { return c.Status(403).JSON(fiber.Map{"error": "Không thể xóa gói đề khi đề đã được gửi cho sinh viên hoặc phòng thi đã kết thúc"}) } // Clear student assignments for this paper first _ = db.Model(&models.ExamRoomStudent{}).Where("exam_room_id = ? AND assigned_paper_id = ?", roomID, paperID).Update("assigned_paper_id", nil).Error _ = os.RemoveAll(filepath.Join(examRoomDir(roomID), "papers", fmt.Sprintf("%d", paperID))) _ = db.Where("exam_paper_id = ?", paperID).Delete(&models.ExamPaperResource{}).Error _ = db.Where("id = ? AND exam_room_id = ?", paperID, roomID).Delete(&models.ExamPaper{}).Error return c.JSON(fiber.Map{"ok": true}) } } // POST /api/exam-rooms/:id/assign-random func RandomAssignExamPapersHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { roomID, err := parseUintParam(c, "id") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid id"}) } var room models.ExamRoom if err := db.First(&room, roomID).Error; err != nil { return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"}) } if !canModifyExamPapers(db, room) { return c.Status(403).JSON(fiber.Map{"error": "Không thể chia gói đề khi đề đã được gửi cho sinh viên hoặc phòng thi đã kết thúc"}) } var papers []models.ExamPaper if err := db.Where("exam_room_id = ?", roomID).Order("sort_order asc, id asc").Find(&papers).Error; err != nil || len(papers) == 0 { return c.Status(400).JSON(fiber.Map{"error": "Chưa có gói đề để chia"}) } var students []models.ExamRoomStudent if err := db.Where("exam_room_id = ?", roomID).Find(&students).Error; err != nil || len(students) == 0 { return c.Status(400).JSON(fiber.Map{"error": "Chưa có sinh viên"}) } rand.Shuffle(len(students), func(i, j int) { students[i], students[j] = students[j], students[i] }) for i := range students { p := papers[i%len(papers)] pid := p.ID students[i].AssignedPaperID = &pid _ = db.Save(&students[i]).Error } return c.JSON(fiber.Map{"assigned": len(students), "packages": len(papers)}) } } // POST /api/exam-rooms/:id/assign-papers-batch — chia đề theo sơ đồ chỗ ngồi (frontend tính toán) func AssignExamPapersBatchHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { roomID, err := parseUintParam(c, "id") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid id"}) } var room models.ExamRoom if err := db.First(&room, roomID).Error; err != nil { return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy phòng"}) } if !canModifyExamPapers(db, room) { return c.Status(403).JSON(fiber.Map{"error": "Không thể chia gói đề khi đề đã gửi hoặc phòng thi đã kết thúc"}) } var req struct { Assignments []struct { StudentRkID int64 `json:"studentRkId"` PaperID uint `json:"paperId"` } `json:"assignments"` } if err := c.BodyParser(&req); err != nil || len(req.Assignments) == 0 { return c.Status(400).JSON(fiber.Map{"error": "assignments rỗng hoặc không hợp lệ"}) } count := 0 for _, a := range req.Assignments { pid := a.PaperID res := db.Model(&models.ExamRoomStudent{}). Where("exam_room_id = ? AND student_rk_id = ?", roomID, a.StudentRkID). Update("assigned_paper_id", pid) if res.RowsAffected > 0 { count++ } } return c.JSON(fiber.Map{"assigned": count}) } } // POST /api/exam-rooms/:id/send-papers func SendExamPapersHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { roomID, err := parseUintParam(c, "id") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid id"}) } var room models.ExamRoom if err := db.First(&room, roomID).Error; err != nil { return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy phòng"}) } now := time.Now() ds := internalDb.ExamDisplayStatus(room, now) if ds != models.ExamStatusReady && ds != "active" { return c.Status(403).JSON(fiber.Map{"error": "Chỉ gửi đề khi phòng đã đẩy và trong/ trước khung giờ thi"}) } var req struct { ScheduledAt *string `json:"scheduledAt"` StudentRkIDs []int64 `json:"studentRkIds"` } if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid body"}) } var scheduled *time.Time if req.ScheduledAt != nil && strings.TrimSpace(*req.ScheduledAt) != "" { t, err := time.Parse(time.RFC3339, strings.TrimSpace(*req.ScheduledAt)) if err != nil { return c.Status(400).JSON(fiber.Map{"error": "scheduledAt không hợp lệ"}) } scheduled = &t } q := db.Where("exam_room_id = ?", roomID) if len(req.StudentRkIDs) > 0 { q = q.Where("student_rk_id IN ?", req.StudentRkIDs) } var rows []models.ExamRoomStudent if err := q.Find(&rows).Error; err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } sent, scheduledCount := 0, 0 for _, row := range rows { if row.AssignedPaperID == nil { continue } if scheduled != nil && scheduled.After(time.Now()) { row.PaperScheduledAt = scheduled _ = db.Save(&row).Error scheduledCount++ continue } deliverExamPaper(db, row.ID) sent++ } return c.JSON(fiber.Map{"sent": sent, "scheduled": scheduledCount}) } } // GET /api/exam-rooms/:id/submissions func ListExamSubmissionsHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { roomID, err := parseUintParam(c, "id") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid id"}) } var subs []models.ExamSubmission _ = db.Where("exam_room_id = ?", roomID).Order("created_at desc").Find(&subs).Error type row struct { models.ExamSubmission FullName string `json:"fullName"` StudentCode string `json:"studentCode"` } out := make([]row, 0, len(subs)) for _, s := range subs { r := row{ExamSubmission: s} var st models.Student if err := db.Where("rk_id = ?", s.StudentRkID).First(&st).Error; err == nil { r.FullName = st.FullName r.StudentCode = st.StudentCode } out = append(out, r) } return c.JSON(fiber.Map{"data": out}) } } // GET /api/student/exam func GetStudentExamHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { studentID, err := strconv.ParseInt(c.Query("studentRkId", "0"), 10, 64) if err != nil || studentID <= 0 { return c.Status(400).JSON(fiber.Map{"error": "studentRkId required"}) } info := internalDb.FindActiveExamForStudent(db, studentID) if info == nil { return c.JSON(fiber.Map{"active": false}) } paperSent := info.Enrollment.PaperSentAt != nil var subCount int64 _ = db.Model(&models.ExamSubmission{}).Where("exam_room_id = ? AND student_rk_id = ?", info.Room.ID, studentID).Count(&subCount).Error resp := fiber.Map{ "active": true, "examRoomId": info.Room.ID, "examName": info.Room.Name, "startTime": info.Room.StartTime, "endTime": info.Room.EndTime, "quizUrl": info.Room.QuizURL, "paperSent": paperSent, "submitted": subCount > 0, } if paperSent && info.Paper != nil { resp["paperId"] = info.Paper.ID resp["paperTitle"] = info.Paper.Title } return c.JSON(resp) } } // GET /api/student/exam/paper-files?studentRkId= func GetStudentExamPaperFilesHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { studentID, _ := strconv.ParseInt(c.Query("studentRkId", "0"), 10, 64) if studentID <= 0 { return c.Status(400).JSON(fiber.Map{"error": "studentRkId required"}) } info := internalDb.FindActiveExamForStudent(db, studentID) if info == nil || info.Enrollment.PaperSentAt == nil || info.Paper == nil { return c.Status(403).JSON(fiber.Map{"error": "Chưa có đề được gửi"}) } var resources []models.ExamPaperResource _ = db.Where("exam_paper_id = ?", info.Paper.ID).Find(&resources).Error resList := make([]fiber.Map, 0, len(resources)) for _, r := range resources { resList = append(resList, fiber.Map{ "id": r.ID, "fileName": r.FileName, "url": fmt.Sprintf("/api/student/exam/download?studentRkId=%d&kind=resource&fileId=%d&view=1", studentID, r.ID), "downloadUrl": fmt.Sprintf("/api/student/exam/download?studentRkId=%d&kind=resource&fileId=%d", studentID, r.ID), }) } return c.JSON(fiber.Map{ "paperTitle": info.Paper.Title, "pdfUrl": fmt.Sprintf("/api/student/exam/download?studentRkId=%d&kind=pdf&view=1", studentID), "resources": resList, }) } } // GET /api/student/exam/download func DownloadStudentExamFileHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { studentID, _ := strconv.ParseInt(c.Query("studentRkId", "0"), 10, 64) if studentID <= 0 { return c.Status(400).JSON(fiber.Map{"error": "studentRkId required"}) } info := internalDb.FindActiveExamForStudent(db, studentID) if info == nil || info.Enrollment.PaperSentAt == nil || info.Paper == nil { return c.Status(403).JSON(fiber.Map{"error": "Không có quyền"}) } inline := c.Query("view") == "1" || c.Query("inline") == "1" kind := c.Query("kind", "pdf") if kind == "pdf" { name := filepath.Base(info.Paper.PdfPath) if inline { return sendExamFileInline(c, info.Paper.PdfPath, name) } return c.Download(info.Paper.PdfPath, name) } fileID, _ := strconv.ParseUint(c.Query("fileId", "0"), 10, 64) var res models.ExamPaperResource if err := db.Where("id = ? AND exam_paper_id = ?", fileID, info.Paper.ID).First(&res).Error; err != nil { return c.Status(404).JSON(fiber.Map{"error": "File không tồn tại"}) } if inline && examFileCanViewInline(res.FileName) { return sendExamFileInline(c, res.FilePath, res.FileName) } return c.Download(res.FilePath, res.FileName) } } func examFileCanViewInline(fileName string) bool { switch strings.ToLower(filepath.Ext(fileName)) { case ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".txt": return true default: return false } } func examViewMimeType(fileName string) string { switch strings.ToLower(filepath.Ext(fileName)) { case ".pdf": return "application/pdf" case ".png": return "image/png" case ".jpg", ".jpeg": return "image/jpeg" case ".gif": return "image/gif" case ".webp": return "image/webp" case ".txt": return "text/plain; charset=utf-8" default: return "application/octet-stream" } } func sendExamFileInline(c *fiber.Ctx, filePath, fileName string) error { data, err := os.ReadFile(filePath) if err != nil { return c.Status(404).JSON(fiber.Map{"error": "Không đọc được file"}) } c.Set("Content-Type", examViewMimeType(fileName)) c.Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", fileName)) return c.Send(data) } func encodeExamFileViewPayload(filePath, fileName, title string) (fiber.Map, error) { data, err := os.ReadFile(filePath) if err != nil { return nil, err } if fileName == "" { fileName = filepath.Base(filePath) } return fiber.Map{ "title": title, "fileName": fileName, "mime": examViewMimeType(fileName), "data": base64.StdEncoding.EncodeToString(data), }, nil } // GET /api/exam-rooms/:id/papers/:paperId/view — JSON base64, dùng cho xem trên web (không tải file) func ViewStaffExamPaperHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { roomID, err := parseUintParam(c, "id") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid id"}) } paperID, err := parseUintParam(c, "paperId") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid paperId"}) } var paper models.ExamPaper if err := db.Where("id = ? AND exam_room_id = ?", paperID, roomID).First(&paper).Error; err != nil { return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy gói đề"}) } payload, err := encodeExamFileViewPayload(paper.PdfPath, "de-thi.pdf", paper.Title) if err != nil { return c.Status(404).JSON(fiber.Map{"error": "File PDF đề không tồn tại trên server"}) } return c.JSON(payload) } } // GET /api/exam-rooms/:id/papers/:paperId/resources/:resId/view func ViewStaffExamPaperResourceHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { roomID, err := parseUintParam(c, "id") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid id"}) } paperID, err := parseUintParam(c, "paperId") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid paperId"}) } resID, err := parseUintParam(c, "resId") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid resId"}) } var paper models.ExamPaper if err := db.Where("id = ? AND exam_room_id = ?", paperID, roomID).First(&paper).Error; err != nil { return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy gói đề"}) } var res models.ExamPaperResource if err := db.Where("id = ? AND exam_paper_id = ?", resID, paper.ID).First(&res).Error; err != nil { return c.Status(404).JSON(fiber.Map{"error": "File tài nguyên không tồn tại"}) } payload, err := encodeExamFileViewPayload(res.FilePath, res.FileName, res.FileName) if err != nil { return c.Status(404).JSON(fiber.Map{"error": "File tài nguyên không tồn tại trên server"}) } return c.JSON(payload) } } // GET /api/exam-rooms/:id/papers/:paperId/download?kind=pdf|resource&fileId=&view=1 func DownloadStaffExamPaperFileHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { roomID, err := parseUintParam(c, "id") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid id"}) } paperID, err := parseUintParam(c, "paperId") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid paperId"}) } var paper models.ExamPaper if err := db.Where("id = ? AND exam_room_id = ?", paperID, roomID).First(&paper).Error; err != nil { return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy gói đề"}) } inline := c.Query("view") == "1" || c.Query("inline") == "1" kind := c.Query("kind", "pdf") if kind == "pdf" { if _, statErr := os.Stat(paper.PdfPath); statErr != nil { return c.Status(404).JSON(fiber.Map{"error": "File PDF đề không tồn tại trên server"}) } name := filepath.Base(paper.PdfPath) if name == "" || name == "." { name = "de-thi.pdf" } if inline { return sendExamFileInline(c, paper.PdfPath, name) } return c.Download(paper.PdfPath, name) } fileID, _ := strconv.ParseUint(c.Query("fileId", "0"), 10, 64) var res models.ExamPaperResource if err := db.Where("id = ? AND exam_paper_id = ?", fileID, paper.ID).First(&res).Error; err != nil { return c.Status(404).JSON(fiber.Map{"error": "File tài nguyên không tồn tại"}) } if _, statErr := os.Stat(res.FilePath); statErr != nil { return c.Status(404).JSON(fiber.Map{"error": "File tài nguyên không tồn tại trên server"}) } if inline && examFileCanViewInline(res.FileName) { return sendExamFileInline(c, res.FilePath, res.FileName) } return c.Download(res.FilePath, res.FileName) } } // POST /api/student/exam/submit — multipart zip func SubmitStudentExamHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { studentID, _ := strconv.ParseInt(c.FormValue("studentRkId"), 10, 64) if studentID <= 0 { return c.Status(400).JSON(fiber.Map{"error": "studentRkId required"}) } info := internalDb.FindActiveExamForStudent(db, studentID) if info == nil { return c.Status(403).JSON(fiber.Map{"error": "Không trong giờ thi"}) } file, err := c.FormFile("submission") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Thiếu file nộp bài"}) } dir := filepath.Join(examRoomDir(info.Room.ID), "submissions", fmt.Sprintf("%d", studentID)) _ = os.MkdirAll(dir, 0755) name := fmt.Sprintf("%s_%d.zip", time.Now().Format("20060102_150405"), studentID) dest := filepath.Join(dir, name) if err := c.SaveFile(file, dest); err != nil { return c.Status(500).JSON(fiber.Map{"error": "Lưu bài thất bại"}) } _ = db.Where("exam_room_id = ? AND student_rk_id = ?", info.Room.ID, studentID).Delete(&models.ExamSubmission{}).Error sub := models.ExamSubmission{ ExamRoomID: info.Room.ID, StudentRkID: studentID, FilePath: dest, FileName: name, } _ = db.Create(&sub).Error return c.JSON(fiber.Map{"ok": true, "fileName": name}) } } // GET /api/exam-rooms/:id/submissions/:subId/download — staff func DownloadExamSubmissionHandler(db *gorm.DB) fiber.Handler { return func(c *fiber.Ctx) error { subID, err := parseUintParam(c, "subId") if err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid subId"}) } var sub models.ExamSubmission if err := db.First(&sub, subID).Error; err != nil { return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"}) } return c.Download(sub.FilePath, sub.FileName) } } func parseUintParam(c *fiber.Ctx, name string) (uint, error) { v, err := strconv.ParseUint(c.Params(name), 10, 64) return uint(v), err } // ZipFolder tạo file zip từ thư mục (dùng từ client hoặc test). func ZipFolder(srcDir, destZip string) error { f, err := os.Create(destZip) if err != nil { return err } defer f.Close() w := zip.NewWriter(f) defer w.Close() return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { return err } rel, err := filepath.Rel(srcDir, path) if err != nil { return err } rel = filepath.ToSlash(rel) hdr, err := zip.FileInfoHeader(info) if err != nil { return err } hdr.Name = rel hdr.Method = zip.Deflate writer, err := w.CreateHeader(hdr) if err != nil { return err } rf, err := os.Open(path) if err != nil { return err } _, copyErr := io.Copy(writer, rf) rf.Close() return copyErr }) }