// 创作业务接口处理器,负责项目、剧集、资产、分镜和媒体请求的参数与响应处理。 package handler import ( "crypto/sha256" "encoding/hex" "errors" "fmt" "image" "io" "mime/multipart" "net/http" "path/filepath" "strconv" "strings" mediakey "juhe-factory/api/internal/media" "juhe-factory/api/internal/model" "juhe-factory/api/internal/modules/prompt" "juhe-factory/api/internal/service" "juhe-factory/api/internal/storage" "github.com/gin-gonic/gin" "github.com/google/uuid" "gorm.io/gorm" ) type Creative struct { service *service.Creative prompts *prompt.Service cos *storage.COS } func NewCreative(service *service.Creative, prompts *prompt.Service, cos *storage.COS) *Creative { return &Creative{service: service, prompts: prompts, cos: cos} } func (h *Creative) ListProjects(c *gin.Context) { items, err := h.service.ListProjects(currentWebUser(c).ID, c.Query("project_type"), c.Query("keyword")) if err != nil { h.internal(c, err) return } c.JSON(http.StatusOK, gin.H{"data": items}) } func (h *Creative) CreateProject(c *gin.Context) { var input service.ProjectInput if err := c.ShouldBindJSON(&input); err != nil { h.bad(c, err) return } project, err := h.service.CreateProject(currentWebUser(c).ID, input) if err != nil { h.bad(c, err) return } c.JSON(http.StatusCreated, gin.H{"data": project}) } func (h *Creative) GetProject(c *gin.Context) { projectID, ok := h.projectID(c) if !ok { return } project, err := h.service.GetProject(currentWebUser(c).ID, projectID) if err != nil { h.respondError(c, err) return } c.JSON(http.StatusOK, gin.H{"data": project}) } func (h *Creative) UpdateProject(c *gin.Context) { projectID, ok := h.projectID(c) if !ok { return } var input service.ProjectInput if err := c.ShouldBindJSON(&input); err != nil { h.bad(c, err) return } if err := h.service.UpdateProject(currentWebUser(c).ID, projectID, input); err != nil { h.respondError(c, err) return } c.Status(http.StatusNoContent) } func (h *Creative) DeleteProject(c *gin.Context) { if !h.objectStorageAvailable(c) { return } projectID, ok := h.projectID(c) if !ok { return } objectKeys, err := h.service.DeleteProject(currentWebUser(c).ID, projectID) if err != nil { h.respondError(c, err) return } if !h.deleteStoredObjects(c, objectKeys) { return } c.Status(http.StatusNoContent) } func (h *Creative) SaveModelConfigs(c *gin.Context) { projectID, ok := h.projectID(c) if !ok { return } var values map[string]any if err := c.ShouldBindJSON(&values); err != nil { h.bad(c, err) return } if err := h.service.SaveModelConfigs(currentWebUser(c).ID, projectID, values); err != nil { h.respondError(c, err) return } c.Status(http.StatusNoContent) } func (h *Creative) ListUserModelPreferences(c *gin.Context) { items, err := h.service.ListUserModelPreferences(currentWebUser(c).ID, c.Param("scope")) if err != nil { h.respondError(c, err) return } c.JSON(http.StatusOK, gin.H{"data": items}) } func (h *Creative) SaveUserModelPreference(c *gin.Context) { var input struct { ModelID string `json:"model_id"` } if err := c.ShouldBindJSON(&input); err != nil { h.bad(c, err) return } if err := h.service.SaveUserModelPreference(currentWebUser(c).ID, c.Param("scope"), c.Param("model_type"), input.ModelID); err != nil { h.respondError(c, err) return } c.Status(http.StatusNoContent) } // ListUserPrompts 返回指定类型的可用提示词及当前用户选择。 func (h *Creative) ListUserPrompts(c *gin.Context) { result, err := h.prompts.ListUserPrompts(currentWebUser(c).ID, c.Param("prompt_type")) if err != nil { h.internal(c, err) return } c.JSON(http.StatusOK, gin.H{"data": gin.H{"prompts": result.Prompts, "selected_id": result.SelectedID}}) } // SelectUserPrompt 更新当前用户对指定提示词类型的选择。 func (h *Creative) SelectUserPrompt(c *gin.Context) { userID := currentWebUser(c).ID promptType := c.Param("prompt_type") var body struct { PromptID *uuid.UUID `json:"prompt_id"` } if err := c.ShouldBindJSON(&body); err != nil { h.bad(c, errors.New("prompt_id无效")) return } if err := h.prompts.SelectUserPrompt(userID, promptType, body.PromptID); err != nil { if prompt.IsPersistenceError(err) { h.internal(c, err) return } h.respondError(c, err) return } if body.PromptID == nil { c.JSON(http.StatusOK, gin.H{"data": gin.H{"status": "disabled"}}) return } c.JSON(http.StatusOK, gin.H{"data": gin.H{"status": "ok"}}) } // CreateCustomPrompt 创建当前用户拥有的自定义提示词。 func (h *Creative) CreateCustomPrompt(c *gin.Context) { var body struct { Name string `json:"name"` Type string `json:"type"` Content string `json:"content"` } if err := c.ShouldBindJSON(&body); err != nil { h.bad(c, err) return } item, err := h.prompts.CreateCustomPrompt(currentWebUser(c).ID, prompt.CustomPromptInput{Name: body.Name, Type: body.Type, Content: body.Content}) if err != nil { h.respondError(c, err) return } c.JSON(http.StatusCreated, gin.H{"data": item}) } // UpdateCustomPrompt 更新当前用户拥有的自定义提示词。 func (h *Creative) UpdateCustomPrompt(c *gin.Context) { userID := currentWebUser(c).ID id, err := uuid.Parse(c.Param("id")) if err != nil { h.bad(c, err) return } var body struct { Name string `json:"name"` Type string `json:"type"` Content string `json:"content"` } if err := c.ShouldBindJSON(&body); err != nil { h.bad(c, err) return } item, err := h.prompts.UpdateCustomPrompt(userID, id, prompt.CustomPromptInput{Name: body.Name, Type: body.Type, Content: body.Content}) if err != nil { h.respondError(c, err) return } c.JSON(http.StatusOK, gin.H{"data": item}) } // DeleteCustomPrompt 删除当前用户拥有的自定义提示词及其选择关系。 func (h *Creative) DeleteCustomPrompt(c *gin.Context) { userID := currentWebUser(c).ID id, err := uuid.Parse(c.Param("id")) if err != nil { h.bad(c, err) return } if err := h.prompts.DeleteCustomPrompt(userID, id); err != nil { h.respondError(c, err) return } c.Status(http.StatusNoContent) } func (h *Creative) CreateEpisode(c *gin.Context) { projectID, ok := h.projectID(c) if !ok { return } var input service.EpisodeInput if err := c.ShouldBindJSON(&input); err != nil { h.bad(c, err) return } episode, err := h.service.CreateEpisode(currentWebUser(c).ID, projectID, input) if err != nil { h.respondError(c, err) return } c.JSON(http.StatusCreated, gin.H{"data": episode}) } func (h *Creative) UpdateEpisodeName(c *gin.Context) { projectID, ok := h.projectID(c) if !ok { return } episodeID, err := service.ParseUUID(c.Param("episode_id"), "剧集") if err != nil { h.bad(c, err) return } var body struct { Name string `json:"name"` } if err := c.ShouldBindJSON(&body); err != nil { h.bad(c, err) return } episode, err := h.service.UpdateEpisodeName(currentWebUser(c).ID, projectID, episodeID, body.Name) if err != nil { h.respondError(c, err) return } c.JSON(http.StatusOK, gin.H{"data": episode}) } func (h *Creative) DeleteEpisode(c *gin.Context) { if !h.objectStorageAvailable(c) { return } projectID, ok := h.projectID(c) if !ok { return } episodeID, err := service.ParseUUID(c.Param("episode_id"), "剧集") if err != nil { h.bad(c, err) return } objectKeys, err := h.service.DeleteEpisode(currentWebUser(c).ID, projectID, episodeID) if err != nil { h.respondError(c, err) return } if !h.deleteStoredObjects(c, objectKeys) { return } c.Status(http.StatusNoContent) } func (h *Creative) GetWorkbench(c *gin.Context) { projectID, ok := h.projectID(c) if !ok { return } episodeID, err := service.ParseUUID(c.Param("episode_id"), "剧集") if err != nil { h.bad(c, err) return } data, err := h.service.GetWorkbench(currentWebUser(c).ID, projectID, episodeID) if err != nil { h.respondError(c, err) return } c.JSON(http.StatusOK, gin.H{"data": data}) } func (h *Creative) CreateAsset(c *gin.Context) { projectID, ok := h.projectID(c) if !ok { return } var body struct { AssetType string `json:"asset_type"` Name string `json:"name"` } if err := c.ShouldBindJSON(&body); err != nil { h.bad(c, err) return } asset, err := h.service.CreateAsset(currentWebUser(c).ID, projectID, body.AssetType, body.Name) if err != nil { h.respondError(c, err) return } c.JSON(http.StatusCreated, gin.H{"data": asset}) } func (h *Creative) UpdateAsset(c *gin.Context) { projectID, ok := h.projectID(c) if !ok { return } assetID, err := service.ParseUUID(c.Param("asset_id"), "资产") if err != nil { h.bad(c, err) return } var body map[string]any if err := c.ShouldBindJSON(&body); err != nil { h.bad(c, err) return } if err := h.service.UpdateAsset(currentWebUser(c).ID, projectID, assetID, body); err != nil { h.respondError(c, err) return } c.Status(http.StatusNoContent) } func (h *Creative) DeleteAsset(c *gin.Context) { if !h.objectStorageAvailable(c) { return } projectID, ok := h.projectID(c) if !ok { return } assetID, err := service.ParseUUID(c.Param("asset_id"), "璧勪骇") if err != nil { h.bad(c, err) return } objectKeys, err := h.service.DeleteAsset(currentWebUser(c).ID, projectID, assetID) if err != nil { h.respondError(c, err) return } if !h.deleteStoredObjects(c, objectKeys) { return } c.Status(http.StatusNoContent) } func (h *Creative) UpdateStoryboard(c *gin.Context) { projectID, ok := h.projectID(c) if !ok { return } storyboardID, err := service.ParseUUID(c.Param("storyboard_id"), "分镜") if err != nil { h.bad(c, err) return } var body map[string]any if err := c.ShouldBindJSON(&body); err != nil { h.bad(c, err) return } if err := h.service.UpdateStoryboard(currentWebUser(c).ID, projectID, storyboardID, body); err != nil { h.respondError(c, err) return } c.Status(http.StatusNoContent) } func (h *Creative) InsertStoryboard(c *gin.Context) { projectID, ok := h.projectID(c) if !ok { return } storyboardID, err := service.ParseUUID(c.Param("storyboard_id"), "分镜") if err != nil { h.bad(c, err) return } var body struct { Position string `json:"position"` } if err := c.ShouldBindJSON(&body); err != nil { h.bad(c, err) return } storyboard, err := h.service.InsertStoryboard(currentWebUser(c).ID, projectID, storyboardID, body.Position) if err != nil { h.respondError(c, err) return } c.JSON(http.StatusCreated, gin.H{"data": storyboard}) } func (h *Creative) DeleteStoryboard(c *gin.Context) { if !h.objectStorageAvailable(c) { return } projectID, ok := h.projectID(c) if !ok { return } storyboardID, err := service.ParseUUID(c.Param("storyboard_id"), "分镜") if err != nil { h.bad(c, err) return } objectKeys, err := h.service.DeleteStoryboard(currentWebUser(c).ID, projectID, storyboardID) if err != nil { h.respondError(c, err) return } if !h.deleteStoredObjects(c, objectKeys) { return } c.Status(http.StatusNoContent) } func (h *Creative) QueueGeneration(c *gin.Context) { projectID, ok := h.projectID(c) if !ok { return } episodeID, err := service.ParseUUID(c.Param("episode_id"), "剧集") if err != nil { h.bad(c, err) return } storyboardID, err := service.ParseUUID(c.Param("storyboard_id"), "分镜") if err != nil { h.bad(c, err) return } var body struct { TaskType string `json:"task_type"` Input map[string]any `json:"input"` } if err := c.ShouldBindJSON(&body); err != nil { h.bad(c, err) return } task, err := h.service.QueueGeneration(currentWebUser(c).ID, projectID, &episodeID, storyboardID, body.TaskType, body.Input) if err != nil { h.respondError(c, err) return } c.JSON(http.StatusAccepted, gin.H{"data": task}) } func (h *Creative) QueueAnalysis(c *gin.Context) { projectID, ok := h.projectID(c) if !ok { return } episodeID, err := service.ParseUUID(c.Param("episode_id"), "剧集") if err != nil { h.bad(c, err) return } task, err := h.service.QueueAnalysis(currentWebUser(c).ID, projectID, episodeID) if err != nil { h.respondError(c, err) return } c.JSON(http.StatusAccepted, gin.H{"data": task}) } func (h *Creative) Reanalyze(c *gin.Context) { if !h.objectStorageAvailable(c) { return } projectID, ok := h.projectID(c) if !ok { return } episodeID, err := service.ParseUUID(c.Param("episode_id"), "剧集") if err != nil { h.bad(c, err) return } objectKeys, err := h.service.ResetEpisodeAnalysis(currentWebUser(c).ID, projectID, episodeID) if err != nil { h.respondError(c, err) return } if !h.deleteStoredObjects(c, objectKeys) { return } task, err := h.service.QueueAnalysis(currentWebUser(c).ID, projectID, episodeID) if err != nil { h.respondError(c, err) return } c.JSON(http.StatusAccepted, gin.H{"data": task}) } func (h *Creative) QueueStoryboardAnalysis(c *gin.Context) { projectID, ok := h.projectID(c) if !ok { return } episodeID, err := service.ParseUUID(c.Param("episode_id"), "剧集") if err != nil { h.bad(c, err) return } storyboardID, err := service.ParseUUID(c.Param("storyboard_id"), "分镜") if err != nil { h.bad(c, err) return } task, err := h.service.QueueStoryboardAnalysis(currentWebUser(c).ID, projectID, episodeID, storyboardID) if err != nil { h.respondError(c, err) return } c.JSON(http.StatusAccepted, gin.H{"data": task}) } func (h *Creative) UpdateEpisodeAnalysisSettings(c *gin.Context) { projectID, ok := h.projectID(c) if !ok { return } episodeID, err := service.ParseUUID(c.Param("episode_id"), "剧集") if err != nil { h.bad(c, err) return } var body struct { AudioSource string `json:"audio_source"` SourceLanguage string `json:"source_language"` } if err := c.ShouldBindJSON(&body); err != nil { h.bad(c, err) return } if err := h.service.UpdateEpisodeAnalysisSettings(currentWebUser(c).ID, projectID, episodeID, body.AudioSource, body.SourceLanguage); err != nil { h.respondError(c, err) return } c.Status(http.StatusNoContent) } func (h *Creative) ListOptions(c *gin.Context) { data, err := h.service.ListOptions() if err != nil { h.internal(c, err) return } c.JSON(http.StatusOK, gin.H{"data": data}) } func (h *Creative) ListTasks(c *gin.Context) { projectID, ok := h.projectID(c) if !ok { return } var episodeID *uuid.UUID if raw := strings.TrimSpace(c.Query("episode_id")); raw != "" { parsed, err := service.ParseUUID(raw, "剧集") if err != nil { h.bad(c, err) return } episodeID = &parsed } items, err := h.service.ListTasks(currentWebUser(c).ID, projectID, episodeID) if err != nil { h.internal(c, err) return } c.JSON(http.StatusOK, gin.H{"data": items}) } func (h *Creative) CancelTask(c *gin.Context) { projectID, ok := h.projectID(c) if !ok { return } taskID, err := service.ParseUUID(c.Param("task_id"), "任务") if err != nil { h.bad(c, err) return } if err := h.service.CancelTask(currentWebUser(c).ID, projectID, taskID); err != nil { h.respondError(c, err) return } c.Status(http.StatusNoContent) } func (h *Creative) ListStoryboardOutputs(c *gin.Context) { projectID, ok := h.projectID(c) if !ok { return } storyboardID, err := service.ParseUUID(c.Param("storyboard_id"), "分镜") if err != nil { h.bad(c, err) return } items, err := h.service.ListStoryboardOutputs(currentWebUser(c).ID, projectID, storyboardID) if err != nil { h.respondError(c, err) return } c.JSON(http.StatusOK, gin.H{"data": items}) } // RemoveStoryboardImage 将当前分镜图移入历史记录并清空主图引用。 func (h *Creative) RemoveStoryboardImage(c *gin.Context) { projectID, ok := h.projectID(c) if !ok { return } storyboardID, err := service.ParseUUID(c.Param("storyboard_id"), "分镜") if err != nil { h.bad(c, err) return } if err := h.service.RemoveStoryboardImage(currentWebUser(c).ID, projectID, storyboardID); err != nil { h.respondError(c, err) return } c.Status(http.StatusNoContent) } func (h *Creative) ActivateStoryboardOutput(c *gin.Context) { projectID, ok := h.projectID(c) if !ok { return } storyboardID, err := service.ParseUUID(c.Param("storyboard_id"), "分镜") if err != nil { h.bad(c, err) return } outputID, err := service.ParseUUID(c.Param("output_id"), "生成结果") if err != nil { h.bad(c, err) return } if err := h.service.ActivateStoryboardOutput(currentWebUser(c).ID, projectID, storyboardID, outputID); err != nil { h.respondError(c, err) return } c.Status(http.StatusNoContent) } func (h *Creative) AddStoryboardCandidate(c *gin.Context) { projectID, ok := h.projectID(c) if !ok { return } storyboardID, err := service.ParseUUID(c.Param("storyboard_id"), "分镜") if err != nil { h.bad(c, err) return } outputID, err := service.ParseUUID(c.Param("output_id"), "生成结果") if err != nil { h.bad(c, err) return } if err := h.service.AddStoryboardCandidate(currentWebUser(c).ID, projectID, storyboardID, outputID); err != nil { h.respondError(c, err) return } c.Status(http.StatusNoContent) } func (h *Creative) RemoveStoryboardCandidate(c *gin.Context) { projectID, ok := h.projectID(c) if !ok { return } storyboardID, err := service.ParseUUID(c.Param("storyboard_id"), "分镜") if err != nil { h.bad(c, err) return } outputID, err := service.ParseUUID(c.Param("output_id"), "生成结果") if err != nil { h.bad(c, err) return } if err := h.service.RemoveStoryboardCandidate(currentWebUser(c).ID, projectID, storyboardID, outputID); err != nil { h.respondError(c, err) return } c.Status(http.StatusNoContent) } func (h *Creative) DeleteStoryboardOutput(c *gin.Context) { if !h.objectStorageAvailable(c) { return } projectID, ok := h.projectID(c) if !ok { return } storyboardID, err := service.ParseUUID(c.Param("storyboard_id"), "分镜") if err != nil { h.bad(c, err) return } outputID, err := service.ParseUUID(c.Param("output_id"), "历史记录") if err != nil { h.bad(c, err) return } objectKey, err := h.service.DeleteStoryboardOutput(currentWebUser(c).ID, projectID, storyboardID, outputID) if err != nil { h.respondError(c, err) return } if !h.deleteStoredObjects(c, []string{objectKey}) { return } c.Status(http.StatusNoContent) } func (h *Creative) ListAssetOutputs(c *gin.Context) { projectID, ok := h.projectID(c) if !ok { return } assetID, err := service.ParseUUID(c.Param("asset_id"), "璧勪骇") if err != nil { h.bad(c, err) return } items, err := h.service.ListAssetOutputs(currentWebUser(c).ID, projectID, assetID) if err != nil { h.respondError(c, err) return } c.JSON(http.StatusOK, gin.H{"data": items}) } func (h *Creative) ActivateAssetOutput(c *gin.Context) { projectID, ok := h.projectID(c) if !ok { return } assetID, err := service.ParseUUID(c.Param("asset_id"), "璧勪骇") if err != nil { h.bad(c, err) return } outputID, err := service.ParseUUID(c.Param("output_id"), "鐢熸垚缁撴灉") if err != nil { h.bad(c, err) return } if err := h.service.ActivateAssetOutput(currentWebUser(c).ID, projectID, assetID, outputID); err != nil { h.respondError(c, err) return } c.Status(http.StatusNoContent) } func (h *Creative) DeleteAssetOutput(c *gin.Context) { if !h.objectStorageAvailable(c) { return } projectID, ok := h.projectID(c) if !ok { return } assetID, err := service.ParseUUID(c.Param("asset_id"), "璧勪骇") if err != nil { h.bad(c, err) return } outputID, err := service.ParseUUID(c.Param("output_id"), "鍘嗗彶璁板綍") if err != nil { h.bad(c, err) return } objectKey, err := h.service.DeleteAssetOutput(currentWebUser(c).ID, projectID, assetID, outputID) if err != nil { h.respondError(c, err) return } if !h.deleteStoredObjects(c, []string{objectKey}) { return } c.Status(http.StatusNoContent) } func (h *Creative) UploadEpisodeSource(c *gin.Context) { h.uploadEpisodeMedia(c, "source") } func (h *Creative) UploadEpisodeSubtitle(c *gin.Context) { h.uploadEpisodeMedia(c, "subtitle") } func (h *Creative) uploadEpisodeMedia(c *gin.Context, kind string) { if h.cos == nil { fail(c, http.StatusServiceUnavailable, "cos_not_configured", "COS 对象存储未配置") return } projectID, ok := h.projectID(c) if !ok { return } episodeID, err := service.ParseUUID(c.Param("episode_id"), "剧集") if err != nil { h.bad(c, err) return } target, err := h.service.PrepareEpisodeMediaUpload(currentWebUser(c).ID, projectID, episodeID, kind) if err != nil { h.respondError(c, err) return } episode := target.Episode file, header, err := c.Request.FormFile("file") if err != nil { h.bad(c, errors.New("请选择文件")) return } defer file.Close() contentType := cleanContentType(header.Header.Get("Content-Type")) maxBytes := int64(50 * 1024 * 1024) ext := strings.ToLower(filepath.Ext(header.Filename)) allowedVideoTypes := map[string]bool{"video/mp4": true, "video/webm": true, "video/quicktime": true, "video/x-m4v": true} allowed := allowedVideoTypes[contentType] && map[string]bool{".mp4": true, ".webm": true, ".mov": true, ".m4v": true}[ext] if kind == "subtitle" { maxBytes = 2 * 1024 * 1024 ext := strings.ToLower(filepath.Ext(header.Filename)) allowed = ext == ".srt" || ext == ".vtt" || ext == ".txt" if contentType == "" { contentType = "text/plain" } } if !allowed || header.Size <= 0 || header.Size > maxBytes { h.bad(c, fmt.Errorf("%s文件格式或大小不符合要求", map[bool]string{true: "字幕", false: "视频"}[kind == "subtitle"])) return } oldMediaID := target.OldMediaID if kind == "source" && episode.SourceVideoAssetID != nil { objectKeys, err := h.service.ResetEpisodeAnalysis(currentWebUser(c).ID, projectID, episodeID) if err != nil { h.respondError(c, err) return } if !h.deleteStoredObjects(c, objectKeys) { return } } mediaID := uuid.New() key := mediakey.EpisodeSource(projectID, episode.EpisodeNo, episodeID, mediaID, header.Filename, contentType) displayName := fmt.Sprintf("第%03d集原视频", episode.EpisodeNo) status := "uploaded" if kind == "subtitle" { key = mediakey.EpisodeSubtitle(projectID, episode.EpisodeNo, episodeID, mediaID, header.Filename, contentType) displayName = fmt.Sprintf("第%03d集字幕", episode.EpisodeNo) status = episode.Status } asset, err := h.storeUpload(c, mediaID, currentWebUser(c).ID, file, header, key, displayName, contentType, nil) if err != nil { h.internal(c, err) return } if err := h.service.PersistEpisodeMediaUpload(episodeID, kind, status, asset, oldMediaID); err != nil { _ = h.cos.Delete(c.Request.Context(), asset.ObjectKey) h.internal(c, err) return } if target.OldObjectKey != "" && !h.deleteStoredObjects(c, []string{target.OldObjectKey}) { return } c.JSON(http.StatusCreated, gin.H{"data": asset}) } func (h *Creative) UploadAssetImage(c *gin.Context) { h.uploadAssetMedia(c, false) } func (h *Creative) GetMedia(c *gin.Context) { if h.cos == nil { fail(c, http.StatusServiceUnavailable, "cos_not_configured", "COS 未配置") return } mediaID, err := service.ParseUUID(c.Param("media_id"), "媒体") if err != nil { h.bad(c, err) return } objectKey, err := h.service.MediaObject(currentWebUser(c).ID, mediaID) if err != nil { h.respondError(c, err) return } body, contentType, size, err := h.cos.Open(c, objectKey) if err != nil { h.internal(c, err) return } defer body.Close() if contentType == "" { contentType = "application/octet-stream" } c.Header("Cache-Control", "private, max-age=3600") c.DataFromReader(http.StatusOK, size, contentType, body, nil) } func (h *Creative) RemoveAssetImage(c *gin.Context) { projectID, ok := h.projectID(c) if !ok { return } assetID, err := service.ParseUUID(c.Param("asset_id"), "资产") if err != nil { h.bad(c, err) return } if err := h.service.RemoveAssetImage(currentWebUser(c).ID, projectID, assetID); err != nil { h.respondError(c, err) return } c.Status(http.StatusNoContent) } func (h *Creative) UploadAssetAudio(c *gin.Context) { h.uploadAssetMedia(c, true) } func (h *Creative) uploadAssetMedia(c *gin.Context, audio bool) { if h.cos == nil { fail(c, http.StatusServiceUnavailable, "cos_not_configured", "COS 对象存储未配置") return } projectID, ok := h.projectID(c) if !ok { return } assetID, err := service.ParseUUID(c.Param("asset_id"), "资产") if err != nil { h.bad(c, err) return } target, err := h.service.PrepareAssetMediaUpload(currentWebUser(c).ID, projectID, assetID) if err != nil { h.respondError(c, err) return } asset := target.Asset file, header, err := c.Request.FormFile("file") if err != nil { h.bad(c, errors.New("请选择文件")) return } defer file.Close() contentType := cleanContentType(header.Header.Get("Content-Type")) maxBytes := h.cos.MaxImageBytes() allowed := map[string]bool{"image/jpeg": true, "image/png": true, "image/webp": true}[contentType] displaySuffix := "资产图" if audio { maxBytes = h.cos.MaxAudioBytes() allowed = strings.HasPrefix(contentType, "audio/") displaySuffix = "参考音频" } if !allowed || header.Size <= 0 || header.Size > maxBytes { h.bad(c, errors.New("文件格式或大小不符合要求")) return } mediaID := uuid.New() key := mediakey.AssetReference(projectID, asset.AssetType, asset.ID, mediaID, header.Filename, contentType) var dimensions *[2]int if !audio { width, height, err := uploadDimensions(file) if err != nil { h.bad(c, errors.New("图片内容无法解析")) return } dimensions = &[2]int{width, height} if _, err := file.Seek(0, io.SeekStart); err != nil { h.internal(c, err) return } } stored, err := h.storeUpload(c, mediaID, currentWebUser(c).ID, file, header, key, asset.Name+displaySuffix, contentType, dimensions) if err != nil { h.internal(c, err) return } if audio { if rawDuration := strings.TrimSpace(c.PostForm("duration_ms")); rawDuration != "" { durationMS, parseErr := strconv.ParseInt(rawDuration, 10, 64) if parseErr != nil || durationMS < 0 { _ = h.cos.Delete(c.Request.Context(), stored.ObjectKey) h.bad(c, errors.New("音频时长无效")) return } stored.DurationMS = &durationMS } } persist := func() error { if !audio { return h.service.AttachUploadedAssetImage(currentWebUser(c).ID, projectID, assetID, stored) } return h.service.AttachUploadedAssetAudio(assetID, stored) } if err := persist(); err != nil { _ = h.cos.Delete(c.Request.Context(), stored.ObjectKey) h.internal(c, err) return } c.JSON(http.StatusCreated, gin.H{"data": stored}) } func (h *Creative) storeUpload(c *gin.Context, mediaID, userID uuid.UUID, file multipart.File, header *multipart.FileHeader, key, displayName, contentType string, dimensions *[2]int) (*model.MediaAsset, error) { hash := sha256.New() if _, err := io.Copy(hash, file); err != nil { return nil, err } if _, err := file.Seek(0, io.SeekStart); err != nil { return nil, err } url, err := h.cos.Put(c, key, contentType, file, header.Size) if err != nil { return nil, err } asset := &model.MediaAsset{ID: mediaID, OwnerUserID: &userID, StorageProvider: "cos", ObjectKey: key, PublicURL: url, OriginalName: header.Filename, DisplayName: displayName, MimeType: contentType, SizeBytes: header.Size, SHA256: hex.EncodeToString(hash.Sum(nil))} if dimensions != nil { asset.Width, asset.Height = &dimensions[0], &dimensions[1] } return asset, nil } func uploadDimensions(file multipart.File) (int, int, error) { config, _, err := image.DecodeConfig(file) return config.Width, config.Height, err } func cleanContentType(value string) string { return strings.TrimSpace(strings.Split(value, ";")[0]) } func (h *Creative) objectStorageAvailable(c *gin.Context) bool { if h.cos != nil { return true } fail(c, http.StatusServiceUnavailable, "cos_not_configured", "COS 对象存储未配置") return false } func (h *Creative) deleteStoredObjects(c *gin.Context, objectKeys []string) bool { var firstErr error seen := make(map[string]struct{}, len(objectKeys)) for _, key := range objectKeys { key = strings.TrimSpace(key) if key == "" { continue } if _, exists := seen[key]; exists { continue } seen[key] = struct{}{} if err := h.cos.Delete(c.Request.Context(), key); err != nil && firstErr == nil { firstErr = err } } if firstErr != nil { h.internal(c, firstErr) return false } return true } func (h *Creative) projectID(c *gin.Context) (uuid.UUID, bool) { id, err := service.ParseUUID(c.Param("project_id"), "项目") if err != nil { h.bad(c, err) return uuid.Nil, false } return id, true } func (h *Creative) bad(c *gin.Context, err error) { fail(c, http.StatusBadRequest, "invalid_request", err.Error()) } func (h *Creative) internal(c *gin.Context, _ error) { fail(c, http.StatusInternalServerError, "internal_error", "操作失败,请稍后重试") } func (h *Creative) respondError(c *gin.Context, err error) { if errors.Is(err, gorm.ErrRecordNotFound) { fail(c, http.StatusNotFound, "not_found", "数据不存在或无权访问") return } h.bad(c, err) }