1211 lines
41 KiB
Go
1211 lines
41 KiB
Go
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 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 {
|
|
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
|
|
})
|
|
}
|