package controllers import ( "crypto/md5" "encoding/hex" "errors" "fmt" "hr_receiver/config" "hr_receiver/models" "io" "mime/multipart" "net/http" "os" "path/filepath" "strings" "time" "github.com/gin-gonic/gin" "gorm.io/gorm" ) const ( lessonPlanMaxSize = 10 * 1024 * 1024 lessonPlanStorageDir = "storage/lesson_plans" lessonPlanFieldName = "file" lessonPlanCleanupDays = 30 ) type LessonPlanController struct { DB *gorm.DB } type lessonPlanPaginationParams struct { PageNum int `form:"pageNum,default=1"` PageSize int `form:"pageSize,default=10"` } func NewLessonPlanController() *LessonPlanController { return &LessonPlanController{DB: config.DB} } func (lc *LessonPlanController) Upload(c *gin.Context) { fileHeader, err := c.FormFile(lessonPlanFieldName) if err != nil { writeError(c, http.StatusBadRequest, "missing file") return } if err := validateLessonPlanFileHeader(fileHeader); err != nil { writeError(c, http.StatusBadRequest, err.Error()) return } uploaderID, uploaderName, ok := currentUser(c) if !ok { writeError(c, http.StatusUnauthorized, "invalid user context") return } src, err := fileHeader.Open() if err != nil { writeError(c, http.StatusInternalServerError, "failed to open upload") return } defer src.Close() if err := os.MkdirAll(lessonPlanStorageDir, 0o755); err != nil { writeError(c, http.StatusInternalServerError, "failed to initialize storage") return } tempFile, err := os.CreateTemp(lessonPlanStorageDir, "upload-*.docx") if err != nil { writeError(c, http.StatusInternalServerError, "failed to create temp file") return } tempPath := tempFile.Name() defer func() { _ = tempFile.Close() if _, statErr := os.Stat(tempPath); statErr == nil { _ = os.Remove(tempPath) } }() hasher := md5.New() size, err := io.Copy(io.MultiWriter(tempFile, hasher), src) if err != nil { writeError(c, http.StatusInternalServerError, "failed to save upload") return } if size > lessonPlanMaxSize { writeError(c, http.StatusBadRequest, "file exceeds 10MB limit") return } md5Value := hex.EncodeToString(hasher.Sum(nil)) var existing models.AppFile if err := lc.DB.Where("md5 = ? AND file_type = ?", md5Value, models.AppFileTypeLessonPlan).First(&existing).Error; err == nil { writeSuccess(c, http.StatusConflict, "duplicate file", existing) return } else if !errors.Is(err, gorm.ErrRecordNotFound) { writeError(c, http.StatusInternalServerError, "failed to check duplicate file") return } storedFilename := buildStoredLessonPlanFilename(md5Value, fileHeader.Filename) finalPath := filepath.Join(lessonPlanStorageDir, storedFilename) if err := os.Rename(tempPath, finalPath); err != nil { writeError(c, http.StatusInternalServerError, "failed to finalize upload") return } record := models.AppFile{ FileType: models.AppFileTypeLessonPlan, OriginalFilename: fileHeader.Filename, StoredFilename: storedFilename, FilePath: finalPath, ContentType: normalizeLessonPlanContentType(fileHeader.Header.Get("Content-Type")), FileSize: size, MD5: md5Value, UploaderID: uploaderID, UploaderName: uploaderName, } if err := lc.DB.Create(&record).Error; err != nil { _ = os.Remove(finalPath) writeError(c, http.StatusInternalServerError, "failed to persist file metadata") return } writeSuccess(c, http.StatusCreated, "upload success", record) } func (lc *LessonPlanController) List(c *gin.Context) { var records []models.AppFile if err := lc.DB.Where("file_type = ?", models.AppFileTypeLessonPlan).Order("created_at DESC").Find(&records).Error; err != nil { writeError(c, http.StatusInternalServerError, "failed to list lesson plans") return } writeSuccess(c, http.StatusOK, "query success", records) } func (lc *LessonPlanController) Page(c *gin.Context) { var params lessonPlanPaginationParams if err := c.ShouldBindQuery(¶ms); err != nil { writeError(c, http.StatusBadRequest, err.Error()) return } if params.PageNum < 1 { params.PageNum = 1 } if params.PageSize < 1 || params.PageSize > 100 { params.PageSize = 10 } offset := (params.PageNum - 1) * params.PageSize var total int64 var records []models.AppFile query := lc.DB.Model(&models.AppFile{}).Where("file_type = ?", models.AppFileTypeLessonPlan) if err := query.Count(&total).Error; err != nil { writeError(c, http.StatusInternalServerError, "failed to count lesson plans") return } if err := query.Order("created_at DESC").Offset(offset).Limit(params.PageSize).Find(&records).Error; err != nil { writeError(c, http.StatusInternalServerError, "failed to query lesson plans") return } writeSuccess(c, http.StatusOK, "query success", gin.H{ "list": records, "pagination": gin.H{ "currentPage": params.PageNum, "pageSize": params.PageSize, "totalList": total, "totalPage": int((total + int64(params.PageSize) - 1) / int64(params.PageSize)), }, }) } func (lc *LessonPlanController) Download(c *gin.Context) { record, err := lc.findLessonPlan(c.Param("id")) if err != nil { respondLessonPlanLookupError(c, err) return } userID, _, ok := currentUser(c) if !ok { writeError(c, http.StatusUnauthorized, "invalid user context") return } role := currentUserRole(c) if !canAccessLessonPlan(record, userID, role) { writeError(c, http.StatusForbidden, "permission denied") return } now := time.Now().UnixMilli() if err := lc.DB.Model(&models.AppFile{}). Where("id = ?", record.ID). Updates(map[string]interface{}{ "download_count": gorm.Expr("download_count + 1"), "last_download_at": now, }).Error; err != nil { writeError(c, http.StatusInternalServerError, "failed to update download stats") return } c.FileAttachment(record.FilePath, record.OriginalFilename) } func (lc *LessonPlanController) Delete(c *gin.Context) { record, err := lc.findLessonPlan(c.Param("id")) if err != nil { respondLessonPlanLookupError(c, err) return } userID, _, ok := currentUser(c) if !ok { writeError(c, http.StatusUnauthorized, "invalid user context") return } role := currentUserRole(c) if !canAccessLessonPlan(record, userID, role) { writeError(c, http.StatusForbidden, "permission denied") return } if err := deleteLessonPlanRecord(lc.DB, &record); err != nil { writeError(c, http.StatusInternalServerError, err.Error()) return } writeSuccess(c, http.StatusOK, "delete success", nil) } func (lc *LessonPlanController) CleanupExpiredFiles() error { cutoffMillis := time.Now().AddDate(0, 0, -lessonPlanCleanupDays).UnixMilli() cutoffTime := time.Now().AddDate(0, 0, -lessonPlanCleanupDays) var records []models.AppFile if err := lc.DB. Where("file_type = ?", models.AppFileTypeLessonPlan). Where("(last_download_at IS NOT NULL AND last_download_at < ?) OR (last_download_at IS NULL AND created_at < ?)", cutoffMillis, cutoffTime). Find(&records).Error; err != nil { return err } for i := range records { if err := deleteLessonPlanRecord(lc.DB, &records[i]); err != nil { return err } } return nil } func StartLessonPlanCleanupJob(db *gorm.DB) { controller := &LessonPlanController{DB: db} go func() { runCleanup := func() { if err := controller.CleanupExpiredFiles(); err != nil { fmt.Printf("lesson plan cleanup failed: %v\n", err) } } runCleanup() ticker := time.NewTicker(24 * time.Hour) defer ticker.Stop() for range ticker.C { runCleanup() } }() } func validateLessonPlanFileHeader(fileHeader *multipart.FileHeader) error { if fileHeader.Size > lessonPlanMaxSize { return fmt.Errorf("file exceeds 10MB limit") } if !strings.EqualFold(filepath.Ext(fileHeader.Filename), ".docx") { return fmt.Errorf("only .docx files are allowed") } contentType := strings.ToLower(strings.TrimSpace(fileHeader.Header.Get("Content-Type"))) if contentType != "" && contentType != "application/vnd.openxmlformats-officedocument.wordprocessingml.document" { return fmt.Errorf("content-type must be .docx") } return nil } func buildStoredLessonPlanFilename(md5Value, originalName string) string { return fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), md5Value, strings.ToLower(filepath.Ext(originalName))) } func normalizeLessonPlanContentType(contentType string) string { if strings.TrimSpace(contentType) == "" { return "application/vnd.openxmlformats-officedocument.wordprocessingml.document" } return contentType } func currentUser(c *gin.Context) (uint, string, bool) { userIDValue, idExists := c.Get("userID") usernameValue, usernameExists := c.Get("username") if !idExists || !usernameExists { return 0, "", false } userID, ok := userIDValue.(uint) if !ok { return 0, "", false } username, ok := usernameValue.(string) if !ok { return 0, "", false } return userID, username, true } func currentUserRole(c *gin.Context) models.UserRole { roleValue, exists := c.Get("role") if !exists { return "" } role, _ := roleValue.(models.UserRole) return role } func canAccessLessonPlan(record models.AppFile, userID uint, role models.UserRole) bool { if record.UploaderID == userID { return true } return role == models.UserRoleSuperAdmin || role == models.UserRoleRegionAdmin } func deleteLessonPlanRecord(db *gorm.DB, record *models.AppFile) error { if err := os.Remove(record.FilePath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to remove file: %w", err) } if err := db.Unscoped().Delete(&models.AppFile{}, record.ID).Error; err != nil { return fmt.Errorf("failed to delete metadata: %w", err) } return nil } func (lc *LessonPlanController) findLessonPlan(id string) (models.AppFile, error) { var record models.AppFile if err := lc.DB.Where("id = ? AND file_type = ?", id, models.AppFileTypeLessonPlan).First(&record).Error; err != nil { return record, err } return record, nil } func respondLessonPlanLookupError(c *gin.Context, err error) { if errors.Is(err, gorm.ErrRecordNotFound) { writeError(c, http.StatusNotFound, "lesson plan not found") return } writeError(c, http.StatusInternalServerError, "failed to query lesson plan") }