feat: file upload and download

This commit is contained in:
2026-04-28 19:38:02 +08:00
parent f9077dafcf
commit f6c06bd7ad
7 changed files with 512 additions and 8 deletions
+49 -8
View File
@@ -4,10 +4,12 @@ package controllers
import ( import (
"context" // 在此处添加 context 导入 "context" // 在此处添加 context 导入
"errors"
"fmt" "fmt"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/sashabaranov/go-openai" "github.com/sashabaranov/go-openai"
"hr_receiver/config" "hr_receiver/config"
"hr_receiver/models"
"hr_receiver/util" "hr_receiver/util"
"io" "io"
"io/ioutil" "io/ioutil"
@@ -15,6 +17,9 @@ import (
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"os" "os"
"strings"
"gorm.io/gorm"
) )
const ( const (
@@ -57,6 +62,14 @@ func readDocxContent(fileHeader *multipart.FileHeader) (string, error) {
return str, nil return str, nil
} }
func readDocxContentFromPath(filePath string) (string, error) {
str, err := util.DocxToStructuredPrompt(filePath)
if err != nil {
return "", fmt.Errorf("failed to parse docx with go-docx: %w", err)
}
return str, nil
}
// readCSVContent 读取 .csv 文件内容 // readCSVContent 读取 .csv 文件内容
// 修改为先保存临时文件再读取 // 修改为先保存临时文件再读取
func readCSVContent(fileHeader *multipart.FileHeader) (string, error) { func readCSVContent(fileHeader *multipart.FileHeader) (string, error) {
@@ -246,16 +259,19 @@ func (tc *TrainingController) AnalyzeByAI(c *gin.Context) {
} }
// 2. 获取文件列表 // 2. 获取文件列表
docxFiles := form.File["teaching_plan"] // 假设前端字段名为 'teaching_plan'
csvFiles := form.File["heart_rate_data"] // 假设前端字段名为 'heart_rate_data' csvFiles := form.File["heart_rate_data"] // 假设前端字段名为 'heart_rate_data'
stepFiles := form.File["step_data"] stepFiles := form.File["step_data"]
analysisType := c.PostForm("analysis_type") analysisType := c.PostForm("analysis_type")
teachingPlanSource := c.PostForm("teaching_plan_source")
if analysisType == "" { if analysisType == "" {
analysisType = analysisTypeHeartRateOnly analysisType = analysisTypeHeartRateOnly
} }
if teachingPlanSource == "" {
teachingPlanSource = "upload"
}
if len(docxFiles) == 0 || len(csvFiles) == 0 { if len(csvFiles) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing required files: teaching_plan (.docx) or heart_rate_data (.csv)"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Missing required file: heart_rate_data (.csv)"})
return return
} }
if analysisType == analysisTypeHeartRateWithSteps && len(stepFiles) == 0 { if analysisType == analysisTypeHeartRateWithSteps && len(stepFiles) == 0 {
@@ -265,13 +281,15 @@ func (tc *TrainingController) AnalyzeByAI(c *gin.Context) {
// 3. 读取文件内容 // 3. 读取文件内容
// 注意:这里我们只取第一个上传的文件 // 注意:这里我们只取第一个上传的文件
teachingPlanFileHeader := docxFiles[0]
heartRateFileHeader := csvFiles[0] heartRateFileHeader := csvFiles[0]
teachingPlanContent, err := resolveTeachingPlanContent(c, form, teachingPlanSource)
teachingPlanContent, err := readDocxContent(teachingPlanFileHeader)
if err != nil { if err != nil {
log.Printf("Error reading teaching plan file (%s): %v", teachingPlanFileHeader.Filename, err) log.Printf("Error resolving teaching plan: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to process teaching plan file: %v", err)}) if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "Cloud teaching plan file not found"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -314,3 +332,26 @@ func (tc *TrainingController) AnalyzeByAI(c *gin.Context) {
}) })
} }
func resolveTeachingPlanContent(c *gin.Context, form *multipart.Form, source string) (string, error) {
switch strings.ToLower(strings.TrimSpace(source)) {
case "upload":
docxFiles := form.File["teaching_plan"]
if len(docxFiles) == 0 {
return "", fmt.Errorf("Missing required file: teaching_plan (.docx)")
}
return readDocxContent(docxFiles[0])
case "cloud":
lessonPlanID := c.PostForm("lesson_plan_id")
if strings.TrimSpace(lessonPlanID) == "" {
return "", fmt.Errorf("missing required field: lesson_plan_id")
}
var fileRecord models.AppFile
if err := config.DB.Where("id = ? AND file_type = ?", lessonPlanID, models.AppFileTypeLessonPlan).First(&fileRecord).Error; err != nil {
return "", err
}
return readDocxContentFromPath(fileRecord.FilePath)
default:
return "", fmt.Errorf("invalid teaching_plan_source, expected upload or cloud")
}
}
+354
View File
@@ -0,0 +1,354 @@
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(&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 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")
}
+17
View File
@@ -0,0 +1,17 @@
package controllers
import "github.com/gin-gonic/gin"
type APIResponse struct {
Data interface{} `json:"data"`
Msg string `json:"msg"`
Code int `json:"code"`
}
func writeSuccess(c *gin.Context, httpStatus int, msg string, data interface{}) {
c.JSON(httpStatus, APIResponse{Data: data, Msg: msg, Code: httpStatus})
}
func writeError(c *gin.Context, httpStatus int, msg string) {
c.JSON(httpStatus, APIResponse{Data: nil, Msg: msg, Code: httpStatus})
}
+3
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"hr_receiver/config" "hr_receiver/config"
"hr_receiver/controllers"
"hr_receiver/models" "hr_receiver/models"
"hr_receiver/mqtt" "hr_receiver/mqtt"
"hr_receiver/routes" "hr_receiver/routes"
@@ -28,6 +29,7 @@ func main() {
&models.RegressionResult{}, &models.RegressionResult{},
&models.User{}, &models.User{},
&models.UserRegionBinding{}, &models.UserRegionBinding{},
&models.AppFile{},
&models.MqttHeartRateRecord{}, &models.MqttHeartRateRecord{},
&models.MqttStepCountRecord{}, &models.MqttStepCountRecord{},
&models.MqttGatewayStatusRecord{}, &models.MqttGatewayStatusRecord{},
@@ -40,6 +42,7 @@ func main() {
if err := mqtt.Start(config.DB, config.App.MQTT); err != nil { if err := mqtt.Start(config.DB, config.App.MQTT); err != nil {
log.Printf("mqtt listener start failed: %v", err) log.Printf("mqtt listener start failed: %v", err)
} }
controllers.StartLessonPlanCleanupJob(config.DB)
// 启动服务 // 启动服务
r := routes.SetupRouter() r := routes.SetupRouter()
+52
View File
@@ -0,0 +1,52 @@
package middleware
import (
"hr_receiver/models"
"net/http"
"github.com/gin-gonic/gin"
)
func RequireHeartRateOperatorOrHigher() gin.HandlerFunc {
return func(c *gin.Context) {
roleValue, exists := c.Get("role")
if !exists {
c.JSON(http.StatusForbidden, gin.H{"error": "missing user role"})
c.Abort()
return
}
role, ok := roleValue.(models.UserRole)
if !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "invalid user role"})
c.Abort()
return
}
if role != models.UserRoleOperator &&
role != models.UserRoleRegionAdmin &&
role != models.UserRoleSuperAdmin {
c.JSON(http.StatusForbidden, gin.H{"error": "insufficient role permissions"})
c.Abort()
return
}
flavorValue, exists := c.Get("flavorType")
if !exists {
c.JSON(http.StatusForbidden, gin.H{"error": "missing user flavor"})
c.Abort()
return
}
flavorType, ok := flavorValue.(models.UserFlavorType)
if !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "invalid user flavor"})
c.Abort()
return
}
if flavorType != models.UserFlavorHeartRate && flavorType != models.UserFlavorAll {
c.JSON(http.StatusForbidden, gin.H{"error": "insufficient flavor permissions"})
c.Abort()
return
}
c.Next()
}
}
+28
View File
@@ -0,0 +1,28 @@
package models
import "gorm.io/gorm"
type AppFileType string
const (
AppFileTypeLessonPlan AppFileType = "lesson_plan"
)
type AppFile struct {
gorm.Model
FileType AppFileType `gorm:"type:varchar(64);not null;index" json:"fileType"`
OriginalFilename string `gorm:"size:255;not null" json:"originalFilename"`
StoredFilename string `gorm:"size:255;not null;uniqueIndex" json:"storedFilename"`
FilePath string `gorm:"size:1024;not null" json:"filePath"`
ContentType string `gorm:"size:255;not null" json:"contentType"`
FileSize int64 `gorm:"not null" json:"fileSize"`
MD5 string `gorm:"size:32;not null;uniqueIndex" json:"md5"`
UploaderID uint `gorm:"not null;index" json:"uploaderId"`
UploaderName string `gorm:"size:255;not null;index" json:"uploaderName"`
DownloadCount int64 `gorm:"not null;default:0" json:"downloadCount"`
LastDownloadAt *int64 `gorm:"type:bigint;index" json:"lastDownloadAt"`
}
func (AppFile) TableName() string {
return "app_files"
}
+9
View File
@@ -13,6 +13,7 @@ func SetupRouter() *gin.Engine {
r.Use(middleware.GzipMiddleware()) r.Use(middleware.GzipMiddleware())
trainingController := controllers.NewTrainingController() trainingController := controllers.NewTrainingController()
stepTrainController := controllers.NewStepTrainingController() stepTrainController := controllers.NewStepTrainingController()
lessonPlanController := controllers.NewLessonPlanController()
v1 := r.Group("/api/v1") v1 := r.Group("/api/v1")
{ {
@@ -31,6 +32,14 @@ func SetupRouter() *gin.Engine {
steps.GET("train-rank/:trainId", stepTrainController.GetTrainingRank) steps.GET("train-rank/:trainId", stepTrainController.GetTrainingRank)
// 可扩展其他路由GET, PUT, DELETE等 // 可扩展其他路由GET, PUT, DELETE等
} }
lessonPlans := v1.Group("/lesson-plans").Use(middleware.JWTAuth())
{
lessonPlans.POST("/upload", middleware.RequireHeartRateOperatorOrHigher(), lessonPlanController.Upload)
lessonPlans.GET("", middleware.RequireHeartRateOperatorOrHigher(), lessonPlanController.List)
lessonPlans.GET("/page", middleware.RequireHeartRateOperatorOrHigher(), lessonPlanController.Page)
lessonPlans.GET("/:id/download", lessonPlanController.Download)
lessonPlans.DELETE("/:id", lessonPlanController.Delete)
}
public := v1.Group("") public := v1.Group("")
{ {
public.POST("/register", controllers.Register) public.POST("/register", controllers.Register)