package controllers import ( "crypto/md5" "crypto/rand" "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 shareCodeExpiry = 5 * time.Minute shareCodeDigits = 6 ) type LessonPlanController struct { DB *gorm.DB } type lessonPlanPaginationParams struct { PageNum int `form:"pageNum,default=1"` PageSize int `form:"pageSize,default=10"` } type lessonPlanShareCodeResponse struct { Code string `json:"code"` ExpiresAt int64 `json:"expiresAt"` FileID uint `json:"fileId"` } 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() tempClosed := false defer func() { if !tempClosed { _ = 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 := tempFile.Close(); err != nil { writeError(c, http.StatusInternalServerError, "failed to finalize upload") return } tempClosed = true 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) GenerateShareCode(c *gin.Context) { record, err := lc.findLessonPlan(c.Param("id")) if err != nil { respondLessonPlanLookupError(c, err) return } userID, username, 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() expiresAt := now.Add(shareCodeExpiry).UnixMilli() var created models.AppFileShareCode if err := lc.DB.Transaction(func(tx *gorm.DB) error { if err := tx.Model(&models.AppFileShareCode{}). Where("app_file_id = ? AND is_active = ?", record.ID, true). Update("is_active", false).Error; err != nil { return err } shareCode, err := generateUniqueShareCode(tx, now.UnixMilli()) if err != nil { return err } created = models.AppFileShareCode{ AppFileID: record.ID, ShareCode: shareCode, GeneratedByID: userID, GeneratedByName: username, ExpiresAt: expiresAt, IsActive: true, } return tx.Create(&created).Error }); err != nil { writeError(c, http.StatusInternalServerError, "failed to generate share code") return } writeSuccess(c, http.StatusOK, "share code generated", lessonPlanShareCodeResponse{ Code: created.ShareCode, ExpiresAt: created.ExpiresAt, FileID: record.ID, }) } func (lc *LessonPlanController) DownloadByShareCode(c *gin.Context) { shareCode := strings.TrimSpace(c.Param("code")) if len(shareCode) != shareCodeDigits { writeError(c, http.StatusBadRequest, "invalid share code") return } now := time.Now().UnixMilli() var share models.AppFileShareCode if err := lc.DB. Where("share_code = ? AND is_active = ? AND expires_at > ?", shareCode, true, now). Order("id DESC"). First(&share).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { writeError(c, http.StatusNotFound, "share code expired or not found") return } writeError(c, http.StatusInternalServerError, "failed to query share code") return } var record models.AppFile if err := lc.DB.Where("id = ? AND file_type = ?", share.AppFileID, models.AppFileTypeLessonPlan).First(&record).Error; err != nil { respondLessonPlanLookupError(c, err) return } 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 := db.Where("app_file_id = ?", record.ID).Delete(&models.AppFileShareCode{}).Error; err != nil { return fmt.Errorf("failed to delete share codes: %w", err) } 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") } func generateUniqueShareCode(db *gorm.DB, nowMillis int64) (string, error) { for i := 0; i < 10; i++ { shareCode, err := randomNumericCode(shareCodeDigits) if err != nil { return "", err } var count int64 if err := db.Model(&models.AppFileShareCode{}). Where("share_code = ? AND is_active = ? AND expires_at > ?", shareCode, true, nowMillis). Count(&count).Error; err != nil { return "", err } if count == 0 { return shareCode, nil } } return "", fmt.Errorf("failed to allocate unique share code") } func randomNumericCode(length int) (string, error) { if length <= 0 { return "", fmt.Errorf("invalid code length") } buffer := make([]byte, length) if _, err := rand.Read(buffer); err != nil { return "", err } digits := make([]byte, length) for i, value := range buffer { digits[i] = byte('0' + (value % 10)) } return string(digits), nil }