低空智控平台 后端go
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

231 lines
7.1 KiB

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
}
updates := map[string]any{"status": "ready"}
if req.FileSize > 0 {
updates["file_size"] = req.FileSize
}
if req.Duration > 0 {
updates["duration"] = req.Duration
}
if err := common.DB.Model(&video).Updates(updates).Error; err != nil {
return nil, common.ErrInternal
}
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
}