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
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
|
|
}
|
|
|