package files import ( "errors" "log" "net/http" "path/filepath" "time" "github.com/gin-gonic/gin" "gorm.io/gorm" "senlinai-agent/backend/internal/httpx" "senlinai-agent/backend/internal/logic/auth" "senlinai-agent/backend/internal/logic/projects" "senlinai-agent/backend/internal/models" ) // SourceDTO 是文件资料写接口的稳定响应,仅返回 opaque storage key,不暴露物理目录布局。 type SourceDTO struct { ID string `json:"id"` ProjectID string `json:"projectId"` Kind string `json:"kind"` Title string `json:"title"` StorageKey string `json:"storageKey"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } // Handler 负责 multipart 边界,文件名清理和本地路径构造始终委托给 Service。 type Handler struct { service *Service maxUploadBytes int64 } // DefaultMaxUploadBytes 是未显式配置时的请求体上限,覆盖完整 multipart 内容。 const DefaultMaxUploadBytes int64 = 32 << 20 // NewHandler 创建文件资料 HTTP registrar。 func NewHandler(service *Service, limits ...int64) *Handler { limit := DefaultMaxUploadBytes if len(limits) > 0 && limits[0] > 0 { limit = limits[0] } return &Handler{service: service, maxUploadBytes: limit} } // Register 将文件资料上传接口注册到上层提供的 /api/v1 路由组。 func (h *Handler) Register(router gin.IRouter) { router.POST("/projects/:id/sources", h.upload) } func (h *Handler) upload(c *gin.Context) { userID, ok := auth.CurrentUserID(c) if !ok { httpx.Error(c, http.StatusUnauthorized, "unauthorized", "未登录或登录已失效") return } projectIdentity, ok := httpx.IdentityParam(c, "id") if !ok { return } // 所有权必须在写磁盘前校验,防止越权请求留下孤立文件。 project, err := projects.FindOwnedProject(userID, projectIdentity) if err != nil { writeSourceError(c, err) return } // MaxBytesReader 必须在任何 multipart 解析前安装,限制字段、边界和文件内容的总请求量。 c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.maxUploadBytes) fileHeader, err := c.FormFile("file") if err != nil { var maxBytesError *http.MaxBytesError if errors.As(err, &maxBytesError) { httpx.Error(c, http.StatusRequestEntityTooLarge, "payload_too_large", "上传内容超过大小限制") return } httpx.Error(c, http.StatusBadRequest, "invalid_request", "请选择要上传的文件") return } file, err := fileHeader.Open() if err != nil { httpx.Error(c, http.StatusBadRequest, "invalid_request", "文件读取失败") return } defer file.Close() // 先由文件服务保存内容,再用其返回的相对路径创建资料记录;handler 不拼接任何本地路径。 stored, err := h.service.Save(project.Identity, fileHeader.Filename, file) if err != nil { httpx.Error(c, http.StatusInternalServerError, "internal_error", "文件保存失败") return } source, err := h.service.CreateSource(userID, project, c.PostForm("title"), stored) if err != nil { if cleanupErr := h.service.Remove(stored); cleanupErr != nil { // 清理错误仅写服务端日志,响应继续使用稳定中文错误,不暴露 storage root。 log.Printf("source file cleanup failed: %v", cleanupErr) } writeSourceError(c, err) return } c.JSON(http.StatusCreated, sourceDTO(*source)) } func sourceDTO(source models.SenlinAgentSource) SourceDTO { return SourceDTO{ ID: source.Identity, ProjectID: source.ProjectIdentity, Kind: source.Kind, Title: source.Title, StorageKey: filepath.Base(filepath.FromSlash(source.FilePath)), CreatedAt: source.CreatedAt.UTC(), UpdatedAt: source.UpdatedAt.UTC(), } } func writeSourceError(c *gin.Context, err error) { switch { case errors.Is(err, gorm.ErrRecordNotFound): httpx.Error(c, http.StatusNotFound, "not_found", "项目不存在") case errors.Is(err, ErrSourceTitleRequired), errors.Is(err, ErrSourcePathRequired): httpx.Error(c, http.StatusBadRequest, "invalid_request", "资料参数无效") default: httpx.Error(c, http.StatusInternalServerError, "internal_error", "资料创建失败") } }