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"` Keyword string `form:"keyword"` // 文件名模糊搜索 UploaderName string `form:"uploaderName"` // 上传者名模糊搜索 RegionID uint32 `form:"regionId"` // 按幼儿园(区域)筛选 SortBy string `form:"sortBy"` // file_size | created_at SortOrder string `form:"sortOrder"` // asc | desc } 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) { userID, _, ok := currentUser(c) if !ok { writeError(c, http.StatusUnauthorized, "invalid user context") return } role := currentUserRole(c) query := lc.DB.Where("file_type = ?", models.AppFileTypeLessonPlan) if role != models.UserRoleSuperAdmin && role != models.UserRoleRegionAdmin { query = query.Where("uploader_id = ?", userID) } var records []models.AppFile if err := query.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) { userID, _, ok := currentUser(c) if !ok { writeError(c, http.StatusUnauthorized, "invalid user context") return } role := currentUserRole(c) 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 role != models.UserRoleSuperAdmin && role != models.UserRoleRegionAdmin { query = query.Where("uploader_id = ?", userID) } // 文件名模糊搜索 if params.Keyword != "" { query = query.Where("original_filename LIKE ?", "%"+params.Keyword+"%") } // 上传者名模糊搜索 if params.UploaderName != "" { query = query.Where("uploader_name LIKE ?", "%"+params.UploaderName+"%") } // 按区域(幼儿园)筛选:通过 user_region_bindings 查 uploader_id if params.RegionID > 0 { query = query.Where("uploader_id IN (SELECT user_id FROM user_region_bindings WHERE region_id = ?)", params.RegionID) } // 排序 orderClause := "created_at DESC" if params.SortBy != "" { switch params.SortBy { case "file_size": orderClause = "file_size" case "created_at": orderClause = "created_at" } if params.SortOrder == "asc" { orderClause += " ASC" } else { orderClause += " DESC" } } if err := query.Count(&total).Error; err != nil { writeError(c, http.StatusInternalServerError, "failed to count lesson plans") return } if err := query.Order(orderClause).Offset(offset).Limit(params.PageSize).Find(&records).Error; err != nil { writeError(c, http.StatusInternalServerError, "failed to query lesson plans") return } // 管理员可见所属幼儿园 isAdmin := role == models.UserRoleSuperAdmin || role == models.UserRoleRegionAdmin type lessonPlanItem struct { models.AppFile KindergartenName string `json:"kindergartenName"` } items := make([]lessonPlanItem, len(records)) for i, r := range records { items[i] = lessonPlanItem{AppFile: r} } if isAdmin && len(records) > 0 { uploaderIDs := make([]uint, len(records)) for i, r := range records { uploaderIDs[i] = r.UploaderID } type uploaderRegion struct { UserID uint RegionID uint32 } var bindings []uploaderRegion if err := lc.DB.Table("user_region_bindings"). Select("user_id, region_id"). Where("user_id IN ?", uploaderIDs). Scan(&bindings).Error; err != nil { writeError(c, http.StatusInternalServerError, "failed to query user regions") return } regionIDs := make([]uint32, len(bindings)) userRegionMap := make(map[uint]uint32, len(bindings)) for _, b := range bindings { userRegionMap[b.UserID] = b.RegionID regionIDs = append(regionIDs, b.RegionID) } type regionKindergarten struct { RegionID uint32 Name string } var kindergartens []regionKindergarten if err := lc.DB.Table("kindergartens"). Select("region_id, name"). Where("region_id IN ?", regionIDs). Scan(&kindergartens).Error; err != nil { writeError(c, http.StatusInternalServerError, "failed to query kindergartens") return } regionNameMap := make(map[uint32]string, len(kindergartens)) for _, k := range kindergartens { regionNameMap[k.RegionID] = k.Name } for i, r := range records { if rid, ok := userRegionMap[r.UploaderID]; ok { if name, ok2 := regionNameMap[rid]; ok2 { items[i].KindergartenName = name } } } } writeSuccess(c, http.StatusOK, "query success", gin.H{ "list": items, "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 }