package service import ( "errors" "fmt" "net/http" "time" "gorm.io/gorm" "laic-backend/common" "laic-backend/logger" "laic-backend/model" "laic-backend/tool" "laic-backend/vo" ) type VideoService struct{} var DefaultVideoService = &VideoService{} const downloadURLExpire = 300 // 预签名下载地址有效期(秒) type OriginalVideoEvent struct { Extension string `json:"extension"` EventID string `json:"eventId"` EventType string `json:"eventType"` VideoID int64 `json:"videoId"` ExecutionID int64 `json:"executionId"` TaskID string `json:"taskId"` CommandID string `json:"commandId"` FileSize int64 `json:"fileSize"` Duration int `json:"duration"` ErrorCode string `json:"errorCode"` } func (s *VideoService) CompleteOriginalVideo(dockID string, event *OriginalVideoEvent) error { if event.Extension != "laic.mock.original-video.v1" || event.EventID == "" || event.VideoID <= 0 || event.ExecutionID <= 0 { return errors.New("invalid original video event") } var video model.Video if err := common.DB.First(&video, event.VideoID).Error; err != nil { return err } if video.ExecutionID != event.ExecutionID { return errors.New("video execution mismatch") } var execution model.TaskExecution if err := common.DB.First(&execution, event.ExecutionID).Error; err != nil { return err } if execution.DockID != dockID || execution.TaskID != event.TaskID || execution.CommandID != event.CommandID { return errors.New("execution association mismatch") } var task model.TaskPlan if err := common.DB.First(&task, "id = ?", execution.TaskID).Error; err != nil { return err } if task.DockID != dockID || task.VideoPolicy != "raw" { return errors.New("task video policy mismatch") } if event.EventType == "failed" { return common.DB.Model(&video).Where("id = ? AND status <> ?", video.ID, "ready").Update("status", "failed").Error } if event.EventType != "completed" || video.Status == "ready" { return nil } if event.FileSize <= 0 || event.FileSize > 100<<20 { return errors.New("invalid video size") } if err := headOSS(video.OssKey); err != nil { return err } updates := map[string]any{"status": "ready", "file_size": event.FileSize} if event.Duration > 0 { updates["duration"] = event.Duration } return common.DB.Model(&video).Where("id = ? AND status IN ?", video.ID, []string{"pending", "uploading"}).Updates(updates).Error } func headOSS(objectKey string) error { url, err := common.PresignOSS("HEAD", objectKey, 60) if err != nil { return err } req, err := http.NewRequest(http.MethodHead, url, nil) if err != nil { return err } resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { return fmt.Errorf("oss object unavailable: %s", resp.Status) } return nil } // CreateUpload 申请视频上传:落库 + 返回 OSS 预签名 PUT URL func (s *VideoService) CreateUpload(userID int64, req *vo.VideoUploadReq) (*vo.VideoUploadVO, *common.BusiError) { id, err := tool.NextID() if err != nil { return nil, common.ErrInternal } ossKey := fmt.Sprintf("media/%d/%s", id, req.FileName) expireAt := time.Now().Add(10 * time.Minute) video := &model.Video{ ID: id, UserID: userID, DroneSN: req.DroneSN, ExecutionID: req.ExecutionID, FileName: req.FileName, FileSize: req.FileSize, OssKey: ossKey, OssBucket: common.AppConf.OSS.Bucket, Status: "uploading", UploadExpireAt: &expireAt, CreatedAt: time.Now(), } if err := common.DB.Create(video).Error; err != nil { logger.ERROR("创建视频记录失败", err) return nil, common.ErrInternal } uploadURL, err := common.PresignOSS("PUT", ossKey, 600) if err != nil { logger.ERROR("生成上传预签名 URL 失败", err) return nil, common.ErrInternal } return &vo.VideoUploadVO{Video: video, UploadURL: uploadURL, ExpireAt: &expireAt}, nil } // Complete 上传完成确认:更新视频状态为 ready func (s *VideoService) Complete(userID int64, videoID int64, req *vo.VideoCompleteReq) (*model.Video, *common.BusiError) { var video model.Video if err := common.DB.Where("id = ? AND user_id = ?", videoID, userID).First(&video).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, common.ErrVideoNotFound } return nil, common.ErrInternal } if video.Status == "ready" { return &video, nil } if video.Status != "uploading" && video.Status != "pending" { return nil, common.ErrVideoUploading } if video.UploadExpireAt != nil && time.Now().After(*video.UploadExpireAt) { return nil, common.NewBusiError(common.ParamError, "视频上传已过期,请重新申请上传") } if err := headOSS(video.OssKey); err != nil { logger.ERROR("确认视频对象失败", err) return nil, common.NewBusiError(common.ParamError, "视频文件尚未上传完成") } updates := map[string]any{"status": "ready"} if req.FileSize > 0 { updates["file_size"] = req.FileSize } if req.Duration > 0 { updates["duration"] = req.Duration } if result := common.DB.Model(&video).Where("id = ? AND user_id = ? AND status IN ?", videoID, userID, []string{"pending", "uploading"}).Updates(updates); result.Error != nil { return nil, common.ErrInternal } else if result.RowsAffected == 0 { common.DB.First(&video, videoID) return &video, nil } common.DB.First(&video, videoID) return &video, nil } // GetPage 视频分页 func (s *VideoService) GetPage(userID int64, isAdmin bool, req *vo.VideoPageReq) (*common.PageResponse[model.Video], *common.BusiError) { db := common.DB.Model(&model.Video{}).Scopes(withUserFilter(userID, isAdmin)) if req.Status != "" { db = db.Where("status = ?", req.Status) } if req.Keyword != "" { db = db.Where("file_name LIKE ?", "%"+req.Keyword+"%") } var total int64 if err := db.Count(&total).Error; err != nil { return nil, common.ErrInternal } var list []model.Video if err := db.Scopes(req.Paginate).Order("id DESC").Find(&list).Error; err != nil { return nil, common.ErrInternal } return common.Page(req.Pagination, total, list), nil } // GetDetail 视频详情 func (s *VideoService) GetDetail(userID int64, isAdmin bool, videoID int64) (*model.Video, *common.BusiError) { var video model.Video if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&video, videoID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, common.ErrVideoNotFound } return nil, common.ErrInternal } return &video, nil } // Download 下载视频:校验状态 → 流量扣减 → OSS 预签名 GET → 下载日志 func (s *VideoService) Download(userID int64, isAdmin bool, videoID int64) (*vo.VideoDownloadVO, *common.BusiError) { var video model.Video if err := common.DB.Scopes(withUserFilter(userID, isAdmin)).First(&video, videoID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, common.ErrVideoNotFound } return nil, common.ErrInternal } if video.Status != "ready" { return nil, common.ErrVideoUploading } // 流量扣减(账本落库) if ok, busiErr := DefaultBillingService.Deduct(userID, video.FileSize, "download", videoID); !ok { return nil, busiErr } downloadURL, err := common.PresignOSS("GET", video.OssKey, downloadURLExpire) if err != nil { logger.ERROR("生成下载预签名 URL 失败", err) return nil, common.ErrInternal } log := &model.DownloadLog{ ID: mustID(), VideoID: videoID, UserID: userID, Bytes: video.FileSize, CreatedAt: time.Now(), } if err := common.DB.Create(log).Error; err != nil { logger.ERROR("下载日志落库失败", err) } return &vo.VideoDownloadVO{ DownloadURL: downloadURL, ExpireAt: time.Now().Add(downloadURLExpire * time.Second).Unix(), Bytes: video.FileSize, }, nil }