Files
rikkei_simple_care/server/internal/handlers/handlers_exam.go
2026-06-30 14:46:19 +07:00

1091 lines
36 KiB
Go

package handlers
import (
"archive/zip"
"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 defaultExamAllowedApps = "chrome,msedge,edge,acrobat,acrord32,foxit,wails,simple_care,client"
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 paperCount int64
_ = db.Model(&models.ExamPaper{}).Where("exam_room_id = ?", room.ID).Count(&paperCount).Error
if paperCount == 0 {
issues = append(issues, "Chưa có gói đề (PDF + tài nguyên)")
}
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")
}
unassigned := 0
for _, s := range students {
if s.AssignedPaperID == nil {
unassigned++
}
}
if unassigned > 0 {
issues = append(issues, fmt.Sprintf("Còn %d sinh viên chưa được chia gói đề", unassigned))
}
return issues
}
// 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)
if apps == "" {
apps = defaultExamAllowedApps
}
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"})
}
if !internalDb.ExamRoomEditable(room) {
return c.Status(403).JSON(fiber.Map{"error": "Chỉ sửa được phòng thi ở trạng thái tạm thời"})
}
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 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 {
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{}})
}
pat := "%" + q + "%"
var ids []int64
_ = db.Model(&models.Student{}).
Where("full_name LIKE ? OR student_code LIKE ? OR email LIKE ?", pat, pat, pat).
Limit(30).
Pluck("rk_id", &ids).Error
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) > 40 {
uniq = uniq[:40]
}
var students []models.Student
if len(uniq) > 0 {
_ = db.Where("rk_id IN ?", uniq).Limit(40).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"})
}
now := time.Now()
if !internalDb.ExamRoomPrepEditable(room, now) {
return c.Status(403).JSON(fiber.Map{"error": "Chỉ thêm gói đề khi phòng tạm thời hoặc sẵn sàng (trước giờ thi)"})
}
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 !internalDb.ExamRoomPrepEditable(room, time.Now()) {
return c.Status(403).JSON(fiber.Map{"error": "Phòng thi đã bắt đầu — không thể thêm tài nguyên"})
}
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 !internalDb.ExamRoomPrepEditable(room, time.Now()) {
return c.Status(403).JSON(fiber.Map{"error": "Phòng thi đã bắt đầu — không thể xóa gói đề"})
}
var assigned int64
_ = db.Model(&models.ExamRoomStudent{}).Where("exam_room_id = ? AND assigned_paper_id = ?", roomID, paperID).Count(&assigned).Error
if assigned > 0 {
return c.Status(403).JSON(fiber.Map{"error": "Gói đề đã được gán cho sinh viên — không thể xóa"})
}
_ = 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 !internalDb.ExamRoomPrepEditable(room, time.Now()) {
return c.Status(403).JSON(fiber.Map{"error": "Chỉ chia gói đề khi phòng tạm thời hoặc sẵn sàng (trước giờ thi)"})
}
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/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 {
c.Set("Content-Type", examViewMimeType(fileName))
c.Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", fileName))
return c.SendFile(filePath)
}
// 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
})
}