Files
hr_data_analyzer/controllers/lesson_plan.go
T
2026-05-03 10:09:52 +08:00

526 lines
15 KiB
Go

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) {
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(&params); 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 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
}