package video import ( "context" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "strings" "time" common "media/controller/common" dto "media/model/dto/video" service "media/service/video" "gitea.com/red-future/common/beans" "gitea.com/red-future/common/utils" "github.com/gogf/gf/v2/frame/g" ) type video struct{} var Concat = new(video) // Concat 视频拼接(URL模式) POST /video/concat func (c *video) Concat(ctx context.Context, req *dto.ConcatReq) (res *dto.ConcatRes, err error) { ctx = withUser(ctx) g.Log().Infof(ctx, "[视频拼接] 收到请求 入参: method=%s, upload=%v, video_urls=%v", req.Method, req.Upload, req.VideoURLs) if req.Method == "" { req.Method = "auto" } savePaths, err := downloadVideos(ctx, req.VideoURLs) if err != nil { return nil, err } defer cleanupConcat(savePaths) svcRes, err := service.Concat.Concat(ctx, &service.ConcatReq{ VideoPaths: savePaths, Method: req.Method, Upload: req.Upload, }) if err != nil { return nil, err } defer os.Remove(svcRes.OutputPath) return toDTORes(svcRes), nil } // ConcatAsync 视频拼接-异步(URL模式) POST /video/concat/async func (c *video) ConcatAsync(ctx context.Context, req *dto.ConcatAsyncReq) (res *dto.CreateConcatTaskRes, err error) { ctx = withUser(ctx) g.Log().Infof(ctx, "[视频拼接-异步] 收到请求 入参: method=%s, upload=%v, callback=%s, video_urls=%v", req.Method, req.Upload, req.CallbackURL, req.VideoURLs) if req.Method == "" { req.Method = "auto" } taskID, taskErr := service.Concat.CreateAsyncTask(ctx, req.VideoURLs, req.Method, req.Upload, req.CallbackURL) if taskErr != nil { return nil, taskErr } return &dto.CreateConcatTaskRes{TaskID: taskID}, nil } // ConcatUpload 视频拼接(文件上传模式) POST /video/concat/upload func (c *video) ConcatUpload(ctx context.Context, req *dto.ConcatUploadReq) (res *dto.ConcatRes, err error) { ctx = withUser(ctx) g.Log().Infof(ctx, "[视频拼接-上传] 收到请求 入参: method=%s, upload=%v", req.Method, req.Upload) savePaths, err := common.SaveUploadedFilesFromCtx(ctx) if err != nil || len(savePaths) < 2 { return nil, fmt.Errorf("至少需要2个视频,当前%d个", len(savePaths)) } defer service.CleanupConcat(savePaths) if req.Method == "" { req.Method = "auto" } svcRes, err := service.Concat.Concat(ctx, &service.ConcatReq{ VideoPaths: savePaths, Method: req.Method, Upload: req.Upload, }) if err != nil { return nil, err } defer os.Remove(svcRes.OutputPath) return toDTORes(svcRes), nil } // ConcatUploadAsync 视频拼接-异步(文件上传模式) POST /video/concat/upload/async func (c *video) ConcatUploadAsync(ctx context.Context, req *dto.ConcatUploadAsyncReq) (res *dto.CreateConcatTaskRes, err error) { ctx = withUser(ctx) g.Log().Infof(ctx, "[视频拼接-上传-异步] 收到请求 入参: method=%s, upload=%v, callback=%s", req.Method, req.Upload, req.CallbackURL) savePaths, err := common.SaveUploadedFilesFromCtx(ctx) if err != nil || len(savePaths) < 2 { return nil, fmt.Errorf("至少需要2个视频,当前%d个", len(savePaths)) } defer service.CleanupConcat(savePaths) if req.Method == "" { req.Method = "auto" } taskID, taskErr := service.Concat.CreateAsyncTaskWithFiles(ctx, savePaths, req.Method, req.Upload, req.CallbackURL) if taskErr != nil { return nil, taskErr } return &dto.CreateConcatTaskRes{TaskID: taskID}, nil } // GetConcatTask 查询异步拼接任务结果 GET /video/concat/task/{taskId} func (c *video) GetConcatTask(ctx context.Context, req *dto.GetConcatTaskReq) (res *dto.GetConcatTaskRes, err error) { ctx = withUser(ctx) return service.Concat.GetTaskResult(ctx, req.TaskID) } // withUser 优先从请求头/X-User-Info/Token 提取用户信息,没有则用默认 admin func withUser(ctx context.Context) context.Context { if ctx.Value("user") != nil { return ctx } // 调试:打印 Authorization 头 if req := g.RequestFromCtx(ctx); req != nil { g.Log().Debugf(ctx, "[withUser] Authorization头=%q", req.Header.Get("Authorization")) } user, err := utils.GetUserInfo(ctx) if err == nil && user != nil && user.TenantId > 0 { g.Log().Infof(ctx, "[用户信息] 从请求头解析到用户: userName=%s, tenantId=%d", user.UserName, user.TenantId) ctx = context.WithValue(ctx, "user", user) return ctx } if err != nil { g.Log().Debugf(ctx, "[用户信息] 解析失败(%v), 使用默认admin/tenant=1", err) } ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin", TenantId: 1}) return ctx } // toDTORes 将 Service 内部响应类型转换为 DTO 响应类型 func toDTORes(svcRes *service.ConcatRes) *dto.ConcatRes { return &dto.ConcatRes{ OutputPath: svcRes.OutputPath, FileSize: svcRes.FileSize, Duration: svcRes.Duration, DurationStr: svcRes.DurationStr, MethodUsed: svcRes.MethodUsed, InputFiles: svcRes.InputFiles, FileURL: svcRes.FileURL, } } // downloadVideos 下载视频URL列表 func downloadVideos(ctx context.Context, videoURLs []string) ([]string, error) { tempDir := getTempDir(ctx) os.MkdirAll(tempDir, 0755) var savePaths []string for _, videoURL := range videoURLs { savePath, dlErr := downloadFromURL(ctx, videoURL, tempDir) if dlErr != nil { continue } savePaths = append(savePaths, savePath) } if len(savePaths) < 2 { return savePaths, fmt.Errorf("成功下载的视频不足2个") } return savePaths, nil } func downloadFromURL(ctx context.Context, rawURL, tempDir string) (string, error) { parsedURL, err := url.Parse(rawURL) if err != nil { return "", err } segments := strings.Split(parsedURL.Path, "/") fileName := segments[len(segments)-1] if fileName == "" { fileName = fmt.Sprintf("video_%d.mp4", time.Now().UnixMilli()) } savePath := filepath.Join(tempDir, fmt.Sprintf("%d_%s", time.Now().UnixMilli(), fileName)) client := &http.Client{Timeout: 10 * time.Minute} resp, err := client.Get(rawURL) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("HTTP %d", resp.StatusCode) } out, err := os.Create(savePath) if err != nil { return "", err } defer out.Close() _, err = io.Copy(out, resp.Body) if err != nil { os.Remove(savePath) } return savePath, err } func cleanupConcat(paths []string) { for _, p := range paths { os.Remove(p) } } func getTempDir(ctx context.Context) string { tempDir := g.Cfg().MustGet(ctx, "ffmpeg.temp_dir", "resource/temp").String() if tempDir == "" { tempDir = "resource/temp" } if !filepath.IsAbs(tempDir) { absDir, _ := filepath.Abs(tempDir) tempDir = absDir } return tempDir }