// Package upload 提供平台总后台的受控文件上传服务。 package upload import ( "io" "log" "net/http" "os" "path/filepath" "strings" "time" "git.apinb.com/bsm-sdk/core/errcode" "git.apinb.com/bsm-sdk/core/infra" sdkmiddleware "git.apinb.com/bsm-sdk/core/middleware" "git.apinb.com/heqiapp/platforms/backend/api/internal/config" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/gin-gonic/gin" ) const maxUploadSize int64 = 10 << 20 var allowedExtensions = map[string]bool{ ".jpg": false, ".jpeg": false, ".png": false, ".webp": false, ".pdf": false, ".mp4": true, ".mov": true, } // UploadFileReply 是文件上传完成后返回的受控资源标识。 type UploadFileReply struct { URI string `json:"uri"` // 资源访问标识,后续可由对象存储适配层解析 OriginalName string `json:"original_name"` // 原始文件名,仅用于展示 ContentType string `json:"content_type"` // 客户端声明的媒体类型 Size int64 `json:"size"` // 文件字节数 } // UploadFile 将允许类型的文件保存至本地 Mock 存储,不直接暴露绝对磁盘路径。 func UploadFile(ctx *gin.Context) { ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, config.Spec.Global.UploadVideoMaxSize) fileHeader, err := ctx.FormFile("file") if err != nil || fileHeader == nil || fileHeader.Size <= 0 { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } extension := strings.ToLower(filepath.Ext(fileHeader.Filename)) isVideo, allowed := allowedExtensions[extension] if !allowed || (!isVideo && fileHeader.Size > maxUploadSize) || (isVideo && fileHeader.Size > config.Spec.Global.UploadVideoMaxSize) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } file, err := fileHeader.Open() if err != nil { infra.Response.Error(ctx, err) return } defer file.Close() datePath := time.Now().Format("2006/01/02") filename := models.NewIdentity() + extension directory := filepath.Join(uploadRoot(), filepath.FromSlash(datePath)) if err := os.MkdirAll(directory, 0o750); err != nil { infra.Response.Error(ctx, err) return } target, err := os.OpenFile(filepath.Join(directory, filename), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640) if err != nil { infra.Response.Error(ctx, err) return } defer target.Close() if _, err := io.Copy(target, file); err != nil { infra.Response.Error(ctx, err) return } infra.Response.Success(ctx, UploadFileReply{ URI: "/uploads/" + datePath + "/" + filename, OriginalName: fileHeader.Filename, ContentType: fileHeader.Header.Get("Content-Type"), Size: fileHeader.Size, }) if claims, err := sdkmiddleware.ParseAuth(ctx); err == nil { log.Printf("upload client=%s account=%s type=%s size=%d uri=%s", claims.Client, claims.Identity, fileHeader.Header.Get("Content-Type"), fileHeader.Size, "/uploads/"+datePath+"/"+filename) } } // uploadRoot 返回本地 Mock 存储根目录;生产环境可通过环境变量映射到受控挂载目录。 func uploadRoot() string { if directory := strings.TrimSpace(os.Getenv("HEQI_UPLOAD_DIR")); directory != "" { return directory } return filepath.Join("runtime", "uploads") }