This commit is contained in:
commit
57eba68496
21
.drone.yml
Normal file
21
.drone.yml
Normal file
@ -0,0 +1,21 @@
|
||||
kind: pipeline
|
||||
name: deploy
|
||||
|
||||
steps:
|
||||
- name: deploy
|
||||
image: appleboy/drone-ssh
|
||||
settings:
|
||||
host: 89.104.69.222
|
||||
username: root
|
||||
password:
|
||||
from_secret: ssh_password
|
||||
script:
|
||||
- rm -fr /home/tailly_clips
|
||||
- mkdir /home/tailly_clips
|
||||
- cd /home/tailly_clips
|
||||
- git clone https://admin:2bfa8b81e8787c9c0bb89e1a7bbd929b2d63aaf2@git.altomta.ru/admin/tailly_clips . || true
|
||||
- git pull
|
||||
- docker stop tailly_clips || true
|
||||
- docker rm tailly_clips || true
|
||||
- DOCKER_BUILDKIT=1 docker build -t tailly_clips .
|
||||
- docker run -d --name tailly_clips --network tailly_net -p 50054:50054 tailly_clips
|
||||
13
.env
Normal file
13
.env
Normal file
@ -0,0 +1,13 @@
|
||||
DB_HOST=79.174.89.104
|
||||
DB_PORT=15452
|
||||
DB_USER=tailly_clips
|
||||
DB_PASSWORD=3Xtk9l0k&DSV789XJ
|
||||
DB_NAME=tailly_clips
|
||||
|
||||
S3_ENDPOINT=https://s3.regru.cloud
|
||||
S3_BUCKET=tailly
|
||||
S3_REGION=ru-central1
|
||||
S3_ACCESS_KEY=TJ946G2S1Z5FEI3I7DQQ
|
||||
S3_SECRET_KEY=C2H2aITHRDpek8H921yhnrINZwDoADsjW3F6HURl
|
||||
|
||||
MODERATION_ADDR=tailly_censor:50051
|
||||
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/tailly_clips.iml" filepath="$PROJECT_DIR$/.idea/tailly_clips.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
9
.idea/tailly_clips.iml
generated
Normal file
9
.idea/tailly_clips.iml
generated
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="Go" enabled="true" />
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
42
Dockerfile
Normal file
42
Dockerfile
Normal file
@ -0,0 +1,42 @@
|
||||
FROM golang:1.21-alpine AS builder
|
||||
|
||||
# Устанавливаем FFmpeg и зависимости
|
||||
RUN apk add --no-cache \
|
||||
ffmpeg \
|
||||
build-base \
|
||||
git
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Копируем зависимости
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Копируем исходный код
|
||||
COPY . .
|
||||
|
||||
# Генерируем gRPC код
|
||||
RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.28
|
||||
RUN go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.2
|
||||
RUN protoc --go_out=./gen --go_opt=paths=source_relative \
|
||||
--go-grpc_out=./gen --go-grpc_opt=paths=source_relative \
|
||||
proto/clip.proto
|
||||
|
||||
# Собираем приложение
|
||||
RUN go build -o clip-service ./cmd/server
|
||||
|
||||
FROM alpine:3.18
|
||||
|
||||
# Устанавливаем FFmpeg и зависимости времени выполнения
|
||||
RUN apk add --no-cache \
|
||||
ffmpeg \
|
||||
ca-certificates
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Копируем бинарник из builder stage
|
||||
COPY --from=builder /app/clip-service .
|
||||
|
||||
EXPOSE 50054
|
||||
|
||||
CMD ["./clip-service"]
|
||||
548
clips_test.go
Normal file
548
clips_test.go
Normal file
@ -0,0 +1,548 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"tailly_clips/internal/moderation"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"tailly_clips/internal/ffmpeg"
|
||||
"tailly_clips/internal/handler"
|
||||
"tailly_clips/internal/repository"
|
||||
"tailly_clips/internal/service"
|
||||
"tailly_clips/internal/storage"
|
||||
"tailly_clips/proto"
|
||||
|
||||
"github.com/caarlos0/env/v8"
|
||||
"github.com/joho/godotenv"
|
||||
_ "github.com/lib/pq"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
type RealTestSuite struct {
|
||||
grpcClient proto.ClipServiceClient
|
||||
grpcConn *grpc.ClientConn
|
||||
grpcServer *grpc.Server
|
||||
testUserID int32
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
DBHost string `env:"DB_HOST" env-default:"localhost"`
|
||||
DBPort string `env:"DB_PORT" env-default:"5432"`
|
||||
DBUser string `env:"DB_USER" env-default:"postgres"`
|
||||
DBPassword string `env:"DB_PASSWORD" env-default:"postgres"`
|
||||
DBName string `env:"DB_NAME" env-default:"clip_service"`
|
||||
|
||||
S3Endpoint string `env:"S3_ENDPOINT,required"`
|
||||
S3Bucket string `env:"S3_BUCKET,required"`
|
||||
S3Region string `env:"S3_REGION,required"`
|
||||
S3AccessKey string `env:"S3_ACCESS_KEY,required"`
|
||||
S3SecretKey string `env:"S3_SECRET_KEY,required"`
|
||||
|
||||
ModerationAddr string `env:"MODERATION_ADDR" env-default:"localhost:50051"`
|
||||
}
|
||||
|
||||
func loadConfig() (*Config, error) {
|
||||
// Загружаем .env файл (игнорируем ошибку если файла нет)
|
||||
_ = godotenv.Load()
|
||||
|
||||
cfg := &Config{}
|
||||
if err := env.Parse(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func initDatabase(cfg *Config) (*sql.DB, error) {
|
||||
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
cfg.DBHost, cfg.DBPort, cfg.DBUser, cfg.DBPassword, cfg.DBName)
|
||||
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
if err = db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(25)
|
||||
db.SetMaxIdleConns(25)
|
||||
db.SetConnMaxLifetime(5 * time.Minute)
|
||||
|
||||
log.Println("Database connected successfully")
|
||||
return db, nil
|
||||
}
|
||||
func setupRealTestSuite(t *testing.T) *RealTestSuite {
|
||||
// Загружаем конфигурацию из основного .env файла
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
// Инициализация базы данных
|
||||
db, err := initDatabase(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize database: %v", err)
|
||||
}
|
||||
|
||||
// Инициализация S3 storage
|
||||
s3Storage := storage.NewS3Storage(storage.S3Config{
|
||||
Endpoint: cfg.S3Endpoint,
|
||||
Bucket: cfg.S3Bucket,
|
||||
Region: cfg.S3Region,
|
||||
AccessKey: cfg.S3AccessKey,
|
||||
SecretKey: cfg.S3SecretKey,
|
||||
})
|
||||
|
||||
modClient, err := moderation.NewModerationClient(cfg.ModerationAddr)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create moderation client: %v", err)
|
||||
}
|
||||
|
||||
// Инициализация сервисов
|
||||
videoProcessor := ffmpeg.NewVideoProcessor()
|
||||
clipRepo := repository.NewClipRepository(db)
|
||||
likeRepo := repository.NewLikeRepository(db)
|
||||
commentRepo := repository.NewCommentRepository(db)
|
||||
|
||||
clipService := service.NewClipService(clipRepo, s3Storage, videoProcessor, modClient)
|
||||
likeService := service.NewLikeService(likeRepo, clipRepo)
|
||||
commentService := service.NewCommentService(commentRepo, clipRepo)
|
||||
|
||||
grpcHandler := handler.NewGRPCHandler(clipService, likeService, commentService)
|
||||
|
||||
// Запускаем gRPC сервер на случайном порту
|
||||
listener, err := net.Listen("tcp", ":0")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to listen: %v", err)
|
||||
}
|
||||
|
||||
server := grpc.NewServer(
|
||||
grpc.MaxRecvMsgSize(50*1024*1024), // 50MB
|
||||
grpc.MaxSendMsgSize(50*1024*1024), // 50MB
|
||||
)
|
||||
proto.RegisterClipServiceServer(server, grpcHandler)
|
||||
|
||||
go func() {
|
||||
if err := server.Serve(listener); err != nil {
|
||||
log.Printf("Server exited with error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Подключаемся к серверу с увеличенным размером сообщения
|
||||
conn, err := grpc.Dial(listener.Addr().String(),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithDefaultCallOptions(
|
||||
grpc.MaxCallRecvMsgSize(50*1024*1024),
|
||||
grpc.MaxCallSendMsgSize(50*1024*1024),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to dial: %v", err)
|
||||
}
|
||||
|
||||
client := proto.NewClipServiceClient(conn)
|
||||
|
||||
// Ждем немного для инициализации сервера
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
return &RealTestSuite{
|
||||
grpcClient: client,
|
||||
grpcConn: conn,
|
||||
grpcServer: server,
|
||||
testUserID: 9999, // Используем специальный ID для тестов
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RealTestSuite) cleanup() {
|
||||
if s.grpcServer != nil {
|
||||
s.grpcServer.Stop()
|
||||
}
|
||||
if s.grpcConn != nil {
|
||||
s.grpcConn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func getRealVideoFiles(t *testing.T) []string {
|
||||
// Укажите путь к директории с реальными видео файлами
|
||||
videoDir := "./test_videos" // Измените на ваш путь
|
||||
if _, err := os.Stat(videoDir); os.IsNotExist(err) {
|
||||
t.Skipf("Video directory %s does not exist, skipping test", videoDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
files, err := ioutil.ReadDir(videoDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read video directory: %v", err)
|
||||
}
|
||||
|
||||
var videoFiles []string
|
||||
for _, file := range files {
|
||||
if !file.IsDir() {
|
||||
ext := filepath.Ext(file.Name())
|
||||
if ext == ".mp4" || ext == ".mov" || ext == ".avi" || ext == ".mkv" {
|
||||
videoFiles = append(videoFiles, filepath.Join(videoDir, file.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(videoFiles) == 0 {
|
||||
t.Skip("No video files found in test directory")
|
||||
}
|
||||
|
||||
// Ограничиваем максимум 10 файлами для теста
|
||||
if len(videoFiles) > 10 {
|
||||
videoFiles = videoFiles[:10]
|
||||
}
|
||||
|
||||
return videoFiles
|
||||
}
|
||||
|
||||
func TestRealIntegration_UploadMultipleVideos(t *testing.T) {
|
||||
suite := setupRealTestSuite(t)
|
||||
defer suite.cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Получаем реальные видео файлы
|
||||
videoFiles := getRealVideoFiles(t)
|
||||
if videoFiles == nil {
|
||||
return
|
||||
}
|
||||
|
||||
t.Logf("Found %d video files for testing", len(videoFiles))
|
||||
|
||||
// Загружаем каждое видео
|
||||
for i, videoPath := range videoFiles {
|
||||
t.Run(fmt.Sprintf("Upload_%s", filepath.Base(videoPath)), func(t *testing.T) {
|
||||
// Читаем видео файл
|
||||
videoData, err := ioutil.ReadFile(videoPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read video file %s: %v", videoPath, err)
|
||||
}
|
||||
|
||||
// Создаем уникальное название для избежания конфликтов
|
||||
uniqueTitle := fmt.Sprintf("Test Clip %d - %s - %d",
|
||||
i+1,
|
||||
filepath.Base(videoPath),
|
||||
time.Now().Unix())
|
||||
|
||||
// Создаем запрос
|
||||
req := &proto.CreateClipRequest{
|
||||
UserId: suite.testUserID,
|
||||
Title: uniqueTitle,
|
||||
VideoData: videoData,
|
||||
FileName: filepath.Base(videoPath),
|
||||
ContentType: "video/mp4",
|
||||
}
|
||||
|
||||
t.Logf("Uploading %s (%d bytes)", req.FileName, len(videoData))
|
||||
|
||||
// Вызываем gRPC метод
|
||||
startTime := time.Now()
|
||||
resp, err := suite.grpcClient.CreateClip(ctx, req)
|
||||
uploadTime := time.Since(startTime)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create clip: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Successfully uploaded in %v: %s (ID: %d)",
|
||||
uploadTime, resp.Clip.Title, resp.Clip.Id)
|
||||
|
||||
// Проверяем результат
|
||||
if resp.Clip == nil {
|
||||
t.Fatal("Expected clip in response")
|
||||
}
|
||||
|
||||
if resp.Clip.Title != uniqueTitle {
|
||||
t.Errorf("Expected title %s, got %s", uniqueTitle, resp.Clip.Title)
|
||||
}
|
||||
|
||||
if resp.Clip.AuthorId != suite.testUserID {
|
||||
t.Errorf("Expected author ID %d, got %d", suite.testUserID, resp.Clip.AuthorId)
|
||||
}
|
||||
|
||||
if resp.Clip.Duration <= 0 {
|
||||
t.Error("Expected positive duration")
|
||||
}
|
||||
|
||||
if resp.Clip.VideoUrl == "" {
|
||||
t.Error("Expected video URL")
|
||||
}
|
||||
|
||||
if resp.Clip.ThumbnailUrl == "" {
|
||||
t.Error("Expected thumbnail URL")
|
||||
}
|
||||
|
||||
t.Logf("Video URL: %s", resp.Clip.VideoUrl)
|
||||
t.Logf("Thumbnail URL: %s", resp.Clip.ThumbnailUrl)
|
||||
t.Logf("Duration: %d seconds", resp.Clip.Duration)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealIntegration_ClipOperations(t *testing.T) {
|
||||
suite := setupRealTestSuite(t)
|
||||
defer suite.cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Сначала загружаем тестовое видео
|
||||
videoFiles := getRealVideoFiles(t)
|
||||
if videoFiles == nil || len(videoFiles) == 0 {
|
||||
t.Skip("No video files available for testing")
|
||||
return
|
||||
}
|
||||
|
||||
videoData, err := ioutil.ReadFile(videoFiles[0])
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read test video: %v", err)
|
||||
}
|
||||
|
||||
// Создаем клип
|
||||
createResp, err := suite.grpcClient.CreateClip(ctx, &proto.CreateClipRequest{
|
||||
UserId: suite.testUserID,
|
||||
Title: fmt.Sprintf("Integration Test Clip - %d", time.Now().Unix()),
|
||||
VideoData: videoData,
|
||||
FileName: filepath.Base(videoFiles[0]),
|
||||
ContentType: "video/mp4",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create clip: %v", err)
|
||||
}
|
||||
|
||||
clipID := createResp.Clip.Id
|
||||
t.Logf("Created test clip with ID: %d", clipID)
|
||||
|
||||
// Тест 1: Получение клипа
|
||||
t.Run("Get_clip", func(t *testing.T) {
|
||||
resp, err := suite.grpcClient.GetClip(ctx, &proto.GetClipRequest{ClipId: clipID})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get clip: %v", err)
|
||||
}
|
||||
|
||||
if resp.Clip.Id != clipID {
|
||||
t.Errorf("Expected clip ID %d, got %d", clipID, resp.Clip.Id)
|
||||
}
|
||||
|
||||
t.Logf("Successfully retrieved clip: %s", resp.Clip.Title)
|
||||
})
|
||||
|
||||
// Тест 2: Лайк клипа
|
||||
t.Run("Like_clip", func(t *testing.T) {
|
||||
_, err := suite.grpcClient.LikeClip(ctx, &proto.LikeClipRequest{
|
||||
ClipId: clipID,
|
||||
UserId: suite.testUserID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to like clip: %v", err)
|
||||
}
|
||||
|
||||
// Проверяем что лайк добавился
|
||||
checkResp, err := suite.grpcClient.CheckIfLiked(ctx, &proto.CheckIfLikedRequest{
|
||||
ClipId: clipID,
|
||||
UserId: suite.testUserID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check like: %v", err)
|
||||
}
|
||||
|
||||
if !checkResp.IsLiked {
|
||||
t.Error("Expected clip to be liked")
|
||||
}
|
||||
|
||||
t.Logf("Successfully liked clip %d", clipID)
|
||||
})
|
||||
|
||||
// Тест 3: Комментарий
|
||||
t.Run("Add_comment", func(t *testing.T) {
|
||||
commentText := fmt.Sprintf("Test comment at %s", time.Now().Format(time.RFC3339))
|
||||
commentResp, err := suite.grpcClient.CreateComment(ctx, &proto.CreateCommentRequest{
|
||||
ClipId: clipID,
|
||||
UserId: suite.testUserID,
|
||||
Content: commentText,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to add comment: %v", err)
|
||||
}
|
||||
|
||||
if commentResp.Comment.Content != commentText {
|
||||
t.Errorf("Expected comment '%s', got '%s'", commentText, commentResp.Comment.Content)
|
||||
}
|
||||
|
||||
t.Logf("Successfully added comment with ID: %d", commentResp.Comment.Id)
|
||||
})
|
||||
|
||||
// Тест 4: Получение комментариев
|
||||
t.Run("Get_comments", func(t *testing.T) {
|
||||
resp, err := suite.grpcClient.GetClipComments(ctx, &proto.GetClipCommentsRequest{
|
||||
ClipId: clipID,
|
||||
Limit: 10,
|
||||
Offset: 0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get comments: %v", err)
|
||||
}
|
||||
|
||||
if len(resp.Comments) == 0 {
|
||||
t.Error("Expected at least one comment")
|
||||
} else {
|
||||
t.Logf("Found %d comments for clip %d", len(resp.Comments), clipID)
|
||||
}
|
||||
})
|
||||
|
||||
// Тест 5: Получение списка клипов пользователя
|
||||
t.Run("Get_user_clips", func(t *testing.T) {
|
||||
resp, err := suite.grpcClient.GetUserClips(ctx, &proto.GetUserClipsRequest{
|
||||
UserId: suite.testUserID,
|
||||
Limit: 10,
|
||||
Offset: 0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get user clips: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("User %d has %d clips (total: %d)",
|
||||
suite.testUserID, len(resp.Clips), resp.TotalCount)
|
||||
})
|
||||
|
||||
// Тест 6: Получение всех клипов
|
||||
t.Run("Get_all_clips", func(t *testing.T) {
|
||||
resp, err := suite.grpcClient.GetClips(ctx, &proto.GetClipsRequest{
|
||||
Limit: 20,
|
||||
Offset: 0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get all clips: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Total clips in system: %d", resp.TotalCount)
|
||||
if len(resp.Clips) > 0 {
|
||||
t.Logf("Retrieved %d clips", len(resp.Clips))
|
||||
}
|
||||
})
|
||||
|
||||
// NOTE: Удаление клипа закомментировано, так как данные не должны удаляться
|
||||
/*
|
||||
// Тест 7: Удаление клипа
|
||||
t.Run("Delete_clip", func(t *testing.T) {
|
||||
_, err := suite.grpcClient.DeleteClip(ctx, &proto.DeleteClipRequest{
|
||||
ClipId: clipID,
|
||||
UserId: suite.testUserID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to delete clip: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Successfully deleted clip %d", clipID)
|
||||
})
|
||||
*/
|
||||
}
|
||||
|
||||
func TestRealIntegration_PerformanceTest(t *testing.T) {
|
||||
suite := setupRealTestSuite(t)
|
||||
defer suite.cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
videoFiles := getRealVideoFiles(t)
|
||||
if videoFiles == nil || len(videoFiles) < 2 {
|
||||
t.Skip("Not enough video files for performance test")
|
||||
return
|
||||
}
|
||||
|
||||
// Тестируем производительность на первом видео
|
||||
videoPath := videoFiles[0]
|
||||
videoData, err := ioutil.ReadFile(videoPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read test video: %v", err)
|
||||
}
|
||||
|
||||
t.Run("Upload_performance", func(t *testing.T) {
|
||||
// Замеряем время загрузки
|
||||
startTime := time.Now()
|
||||
|
||||
_, err := suite.grpcClient.CreateClip(ctx, &proto.CreateClipRequest{
|
||||
UserId: suite.testUserID,
|
||||
Title: fmt.Sprintf("Performance Test - %d", time.Now().UnixNano()),
|
||||
VideoData: videoData,
|
||||
FileName: filepath.Base(videoPath),
|
||||
ContentType: "video/mp4",
|
||||
})
|
||||
|
||||
uploadTime := time.Since(startTime)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Upload failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Upload performance: %v for %d bytes (%.2f MB/s)",
|
||||
uploadTime,
|
||||
len(videoData),
|
||||
float64(len(videoData))/1024/1024/uploadTime.Seconds())
|
||||
})
|
||||
|
||||
t.Run("Concurrent_uploads", func(t *testing.T) {
|
||||
// Тестируем конкурентные загрузки (ограниченное количество)
|
||||
concurrentUploads := 3
|
||||
if len(videoFiles) < concurrentUploads {
|
||||
concurrentUploads = len(videoFiles)
|
||||
}
|
||||
|
||||
results := make(chan time.Duration, concurrentUploads)
|
||||
errors := make(chan error, concurrentUploads)
|
||||
|
||||
for i := 0; i < concurrentUploads; i++ {
|
||||
go func(idx int) {
|
||||
videoData, err := ioutil.ReadFile(videoFiles[idx])
|
||||
if err != nil {
|
||||
errors <- err
|
||||
return
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
_, err = suite.grpcClient.CreateClip(ctx, &proto.CreateClipRequest{
|
||||
UserId: suite.testUserID,
|
||||
Title: fmt.Sprintf("Concurrent %d - %d", idx, time.Now().UnixNano()),
|
||||
VideoData: videoData,
|
||||
FileName: filepath.Base(videoFiles[idx]),
|
||||
ContentType: "video/mp4",
|
||||
})
|
||||
uploadTime := time.Since(startTime)
|
||||
|
||||
if err != nil {
|
||||
errors <- err
|
||||
return
|
||||
}
|
||||
|
||||
results <- uploadTime
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Собираем результаты
|
||||
var totalTime time.Duration
|
||||
for i := 0; i < concurrentUploads; i++ {
|
||||
select {
|
||||
case duration := <-results:
|
||||
totalTime += duration
|
||||
t.Logf("Concurrent upload %d: %v", i+1, duration)
|
||||
case err := <-errors:
|
||||
t.Errorf("Concurrent upload failed: %v", err)
|
||||
case <-time.After(2 * time.Minute):
|
||||
t.Error("Concurrent upload timeout")
|
||||
}
|
||||
}
|
||||
|
||||
avgTime := totalTime / time.Duration(concurrentUploads)
|
||||
t.Logf("Average concurrent upload time: %v", avgTime)
|
||||
})
|
||||
}
|
||||
124
cmd/server/main.go
Normal file
124
cmd/server/main.go
Normal file
@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"tailly_clips/internal/ffmpeg"
|
||||
"tailly_clips/internal/handler"
|
||||
"tailly_clips/internal/moderation"
|
||||
"tailly_clips/internal/repository"
|
||||
"tailly_clips/internal/service"
|
||||
"tailly_clips/internal/storage"
|
||||
"tailly_clips/proto"
|
||||
"time"
|
||||
|
||||
"github.com/caarlos0/env/v8"
|
||||
"github.com/joho/godotenv"
|
||||
_ "github.com/lib/pq"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// Config структура конфигурации
|
||||
type Config struct {
|
||||
DBHost string `env:"DB_HOST" env-default:"localhost"`
|
||||
DBPort string `env:"DB_PORT" env-default:"5432"`
|
||||
DBUser string `env:"DB_USER" env-default:"postgres"`
|
||||
DBPassword string `env:"DB_PASSWORD" env-default:"postgres"`
|
||||
DBName string `env:"DB_NAME" env-default:"clip_service"`
|
||||
|
||||
S3Endpoint string `env:"S3_ENDPOINT,required"`
|
||||
S3Bucket string `env:"S3_BUCKET,required"`
|
||||
S3Region string `env:"S3_REGION,required"`
|
||||
S3AccessKey string `env:"S3_ACCESS_KEY,required"`
|
||||
S3SecretKey string `env:"S3_SECRET_KEY,required"`
|
||||
|
||||
ModerationAddr string `env:"MODERATION_ADDR" env-default:"localhost:50051"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Загрузка конфигурации
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
// Инициализация базы данных
|
||||
db, err := initDatabase(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Инициализация S3 storage
|
||||
s3Storage := storage.NewS3Storage(storage.S3Config{
|
||||
Endpoint: cfg.S3Endpoint,
|
||||
Bucket: cfg.S3Bucket,
|
||||
Region: cfg.S3Region,
|
||||
AccessKey: cfg.S3AccessKey,
|
||||
SecretKey: cfg.S3SecretKey,
|
||||
})
|
||||
|
||||
modClient, err := moderation.NewModerationClient(cfg.ModerationAddr)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create moderation client: %v", err)
|
||||
}
|
||||
defer modClient.Close()
|
||||
|
||||
videoProcessor := ffmpeg.NewVideoProcessor()
|
||||
clipRepo := repository.NewClipRepository(db)
|
||||
likeRepo := repository.NewLikeRepository(db)
|
||||
commentRepo := repository.NewCommentRepository(db)
|
||||
|
||||
clipService := service.NewClipService(clipRepo, s3Storage, videoProcessor, modClient)
|
||||
likeService := service.NewLikeService(likeRepo, clipRepo)
|
||||
commentService := service.NewCommentService(commentRepo, clipRepo)
|
||||
|
||||
grpcHandler := handler.NewGRPCHandler(clipService, likeService, commentService)
|
||||
|
||||
server := grpc.NewServer()
|
||||
proto.RegisterClipServiceServer(server, grpcHandler)
|
||||
|
||||
lis, err := net.Listen("tcp", ":50054")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to listen: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Clip service started on :50054")
|
||||
if err := server.Serve(lis); err != nil {
|
||||
log.Fatalf("Failed to serve: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig() (*Config, error) {
|
||||
// Загружаем .env файл (игнорируем ошибку если файла нет)
|
||||
_ = godotenv.Load()
|
||||
|
||||
cfg := &Config{}
|
||||
if err := env.Parse(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func initDatabase(cfg *Config) (*sql.DB, error) {
|
||||
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
cfg.DBHost, cfg.DBPort, cfg.DBUser, cfg.DBPassword, cfg.DBName)
|
||||
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
if err = db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(25)
|
||||
db.SetMaxIdleConns(25)
|
||||
db.SetConnMaxLifetime(5 * time.Minute)
|
||||
|
||||
log.Println("Database connected successfully")
|
||||
return db, nil
|
||||
}
|
||||
24
go.mod
Normal file
24
go.mod
Normal file
@ -0,0 +1,24 @@
|
||||
module tailly_clips
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go v1.49.6
|
||||
github.com/caarlos0/env/v8 v8.0.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
google.golang.org/grpc v1.67.0
|
||||
google.golang.org/protobuf v1.36.8
|
||||
)
|
||||
|
||||
require github.com/lib/pq v1.10.9
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
36
go.sum
Normal file
36
go.sum
Normal file
@ -0,0 +1,36 @@
|
||||
github.com/aws/aws-sdk-go v1.49.6 h1:yNldzF5kzLBRvKlKz1S0bkvc2+04R1kt13KfBWQBfFA=
|
||||
github.com/aws/aws-sdk-go v1.49.6/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk=
|
||||
github.com/caarlos0/env/v8 v8.0.0 h1:POhxHhSpuxrLMIdvTGARuZqR4Jjm8AYmoi/JKlcScs0=
|
||||
github.com/caarlos0/env/v8 v8.0.0/go.mod h1:7K4wMY9bH0esiXSSHlfHLX5xKGQMnkH5Fk4TDSSSzfo=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||
google.golang.org/grpc v1.67.0 h1:IdH9y6PF5MPSdAntIcpjQ+tXO41pcQsfZV2RxtQgVcw=
|
||||
google.golang.org/grpc v1.67.0/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
40
internal/domain/models.go
Normal file
40
internal/domain/models.go
Normal file
@ -0,0 +1,40 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type Clip struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
VideoURL string `json:"video_url"`
|
||||
ThumbnailURL string `json:"thumbnail_url"`
|
||||
Duration int `json:"duration"` // seconds
|
||||
AuthorID int `json:"author_id"`
|
||||
LikesCount int `json:"likes_count"`
|
||||
CommentsCount int `json:"comments_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ClipLike struct {
|
||||
ID int `json:"id"`
|
||||
ClipID int `json:"clip_id"`
|
||||
UserID int `json:"user_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type ClipComment struct {
|
||||
ID int `json:"id"`
|
||||
ClipID int `json:"clip_id"`
|
||||
AuthorID int `json:"author_id"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CreateClipRequest struct {
|
||||
UserID int
|
||||
Title string
|
||||
VideoData []byte
|
||||
FileName string
|
||||
ContentType string
|
||||
}
|
||||
114
internal/ffmpeg/processor.go
Normal file
114
internal/ffmpeg/processor.go
Normal file
@ -0,0 +1,114 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type VideoProcessor interface {
|
||||
GenerateThumbnail(videoData []byte) ([]byte, error)
|
||||
GetDuration(videoData []byte) (int, error)
|
||||
TrimVideo(videoData []byte, maxDuration int) ([]byte, error)
|
||||
}
|
||||
|
||||
type videoProcessor struct {
|
||||
}
|
||||
|
||||
func NewVideoProcessor() VideoProcessor {
|
||||
return &videoProcessor{}
|
||||
}
|
||||
|
||||
func (vp *videoProcessor) GenerateThumbnail(videoData []byte) ([]byte, error) {
|
||||
cmd := exec.Command("ffmpeg",
|
||||
"-i", "pipe:0", // читаем из stdin
|
||||
"-ss", "00:00:01",
|
||||
"-vframes", "1",
|
||||
"-q:v", "2",
|
||||
"-f", "image2pipe", // вывод в pipe
|
||||
"-c:v", "mjpeg",
|
||||
"pipe:1", // пишем в stdout
|
||||
)
|
||||
|
||||
cmd.Stdin = bytes.NewReader(videoData)
|
||||
var output bytes.Buffer
|
||||
cmd.Stdout = &output
|
||||
cmd.Stderr = &output // capture stderr for error messages
|
||||
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ffmpeg failed: %s, error: %w", output.String(), err)
|
||||
}
|
||||
|
||||
if output.Len() == 0 {
|
||||
return nil, fmt.Errorf("thumbnail generation produced empty output")
|
||||
}
|
||||
|
||||
return output.Bytes(), nil
|
||||
}
|
||||
|
||||
func (vp *videoProcessor) GetDuration(videoData []byte) (int, error) {
|
||||
cmd := exec.Command("ffprobe",
|
||||
"-i", "pipe:0",
|
||||
"-show_entries", "format=duration",
|
||||
"-v", "quiet",
|
||||
"-of", "csv=p=0",
|
||||
)
|
||||
|
||||
cmd.Stdin = bytes.NewReader(videoData)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
debugCmd := exec.Command("ffprobe", "-i", "pipe:0")
|
||||
debugCmd.Stdin = bytes.NewReader(videoData)
|
||||
debugOutput, _ := debugCmd.CombinedOutput()
|
||||
return 0, fmt.Errorf("ffprobe failed: %w, debug output: %s", err, string(debugOutput))
|
||||
}
|
||||
|
||||
durationStr := strings.TrimSpace(string(output))
|
||||
duration, err := strconv.ParseFloat(durationStr, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to parse duration '%s': %w", durationStr, err)
|
||||
}
|
||||
|
||||
return int(duration), nil
|
||||
}
|
||||
|
||||
func (vp *videoProcessor) TrimVideo(videoData []byte, maxDuration int) ([]byte, error) {
|
||||
// Сначала получаем длительность
|
||||
duration, err := vp.GetDuration(videoData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get video duration: %w", err)
|
||||
}
|
||||
|
||||
// Если видео короче или равно лимиту, возвращаем оригинал
|
||||
if duration <= maxDuration {
|
||||
return videoData, nil
|
||||
}
|
||||
|
||||
// Обрезаем видео
|
||||
cmd := exec.Command("ffmpeg",
|
||||
"-i", "pipe:0",
|
||||
"-t", strconv.Itoa(maxDuration),
|
||||
"-c", "copy",
|
||||
"-f", "mp4",
|
||||
"pipe:1",
|
||||
)
|
||||
|
||||
cmd.Stdin = bytes.NewReader(videoData)
|
||||
var output bytes.Buffer
|
||||
cmd.Stdout = &output
|
||||
cmd.Stderr = &output
|
||||
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ffmpeg trim failed: %s, error: %w", output.String(), err)
|
||||
}
|
||||
|
||||
if output.Len() == 0 {
|
||||
return nil, fmt.Errorf("trimmed video is empty")
|
||||
}
|
||||
|
||||
return output.Bytes(), nil
|
||||
}
|
||||
243
internal/handler/grpc_handler.go
Normal file
243
internal/handler/grpc_handler.go
Normal file
@ -0,0 +1,243 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"tailly_clips/internal/domain"
|
||||
"tailly_clips/internal/service"
|
||||
"tailly_clips/proto"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
type GRPCHandler struct {
|
||||
proto.UnimplementedClipServiceServer
|
||||
clipService service.ClipService
|
||||
likeService service.LikeService
|
||||
commentService service.CommentService
|
||||
}
|
||||
|
||||
func NewGRPCHandler(
|
||||
clipService service.ClipService,
|
||||
likeService service.LikeService,
|
||||
commentService service.CommentService,
|
||||
) *GRPCHandler {
|
||||
return &GRPCHandler{
|
||||
clipService: clipService,
|
||||
likeService: likeService,
|
||||
commentService: commentService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *GRPCHandler) CreateClip(ctx context.Context, req *proto.CreateClipRequest) (*proto.CreateClipResponse, error) {
|
||||
createReq := domain.CreateClipRequest{
|
||||
UserID: int(req.UserId),
|
||||
Title: req.Title,
|
||||
VideoData: req.VideoData,
|
||||
FileName: req.FileName,
|
||||
ContentType: req.ContentType,
|
||||
}
|
||||
|
||||
clip, err := h.clipService.CreateClip(ctx, createReq)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to create clip: %v", err)
|
||||
}
|
||||
|
||||
return &proto.CreateClipResponse{
|
||||
Clip: h.clipToProto(clip),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *GRPCHandler) GetClip(ctx context.Context, req *proto.GetClipRequest) (*proto.GetClipResponse, error) {
|
||||
clip, err := h.clipService.GetClip(ctx, int(req.ClipId))
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.NotFound, "clip not found: %v", err)
|
||||
}
|
||||
|
||||
return &proto.GetClipResponse{
|
||||
Clip: h.clipToProto(clip),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *GRPCHandler) GetUserClips(ctx context.Context, req *proto.GetUserClipsRequest) (*proto.GetUserClipsResponse, error) {
|
||||
clips, totalCount, err := h.clipService.GetUserClips(ctx, int(req.UserId), int(req.Limit), int(req.Offset))
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get user clips: %v", err)
|
||||
}
|
||||
|
||||
return &proto.GetUserClipsResponse{
|
||||
Clips: h.clipsToProto(clips),
|
||||
TotalCount: int32(totalCount),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *GRPCHandler) GetClips(ctx context.Context, req *proto.GetClipsRequest) (*proto.GetClipsResponse, error) {
|
||||
clips, totalCount, err := h.clipService.GetClips(ctx, int(req.Limit), int(req.Offset))
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get clips: %v", err)
|
||||
}
|
||||
|
||||
return &proto.GetClipsResponse{
|
||||
Clips: h.clipsToProto(clips),
|
||||
TotalCount: int32(totalCount),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *GRPCHandler) DeleteClip(ctx context.Context, req *proto.DeleteClipRequest) (*emptypb.Empty, error) {
|
||||
err := h.clipService.DeleteClip(ctx, int(req.ClipId), int(req.UserId))
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to delete clip: %v", err)
|
||||
}
|
||||
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
func (h *GRPCHandler) LikeClip(ctx context.Context, req *proto.LikeClipRequest) (*proto.LikeClipResponse, error) {
|
||||
like, err := h.likeService.LikeClip(ctx, int(req.ClipId), int(req.UserId))
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to like clip: %v", err)
|
||||
}
|
||||
|
||||
return &proto.LikeClipResponse{
|
||||
Like: h.likeToProto(like),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *GRPCHandler) UnlikeClip(ctx context.Context, req *proto.UnlikeClipRequest) (*emptypb.Empty, error) {
|
||||
err := h.likeService.UnlikeClip(ctx, int(req.ClipId), int(req.UserId))
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to unlike clip: %v", err)
|
||||
}
|
||||
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
func (h *GRPCHandler) GetClipLikes(ctx context.Context, req *proto.GetClipLikesRequest) (*proto.GetClipLikesResponse, error) {
|
||||
likes, totalCount, err := h.likeService.GetClipLikes(ctx, int(req.ClipId), int(req.Limit), int(req.Offset))
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get clip likes: %v", err)
|
||||
}
|
||||
|
||||
return &proto.GetClipLikesResponse{
|
||||
Likes: h.likesToProto(likes),
|
||||
TotalCount: int32(totalCount),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *GRPCHandler) CheckIfLiked(ctx context.Context, req *proto.CheckIfLikedRequest) (*proto.CheckIfLikedResponse, error) {
|
||||
isLiked, err := h.likeService.CheckIfLiked(ctx, int(req.ClipId), int(req.UserId))
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to check like status: %v", err)
|
||||
}
|
||||
|
||||
return &proto.CheckIfLikedResponse{
|
||||
IsLiked: isLiked,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *GRPCHandler) CreateComment(ctx context.Context, req *proto.CreateCommentRequest) (*proto.CreateCommentResponse, error) {
|
||||
comment, err := h.commentService.CreateComment(ctx, int(req.ClipId), int(req.UserId), req.Content)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to create comment: %v", err)
|
||||
}
|
||||
|
||||
return &proto.CreateCommentResponse{
|
||||
Comment: h.commentToProto(comment),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *GRPCHandler) GetClipComments(ctx context.Context, req *proto.GetClipCommentsRequest) (*proto.GetClipCommentsResponse, error) {
|
||||
comments, totalCount, err := h.commentService.GetClipComments(ctx, int(req.ClipId), int(req.Limit), int(req.Offset))
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get clip comments: %v", err)
|
||||
}
|
||||
|
||||
return &proto.GetClipCommentsResponse{
|
||||
Comments: h.commentsToProto(comments),
|
||||
TotalCount: int32(totalCount),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *GRPCHandler) DeleteComment(ctx context.Context, req *proto.DeleteCommentRequest) (*emptypb.Empty, error) {
|
||||
err := h.commentService.DeleteComment(ctx, int(req.CommentId), int(req.UserId))
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to delete comment: %v", err)
|
||||
}
|
||||
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
// Вспомогательные методы для конвертации
|
||||
|
||||
func (h *GRPCHandler) clipToProto(clip *domain.Clip) *proto.Clip {
|
||||
if clip == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &proto.Clip{
|
||||
Id: int32(clip.ID),
|
||||
Title: clip.Title,
|
||||
VideoUrl: clip.VideoURL,
|
||||
ThumbnailUrl: clip.ThumbnailURL,
|
||||
Duration: int32(clip.Duration),
|
||||
AuthorId: int32(clip.AuthorID),
|
||||
LikesCount: int32(clip.LikesCount),
|
||||
CommentsCount: int32(clip.CommentsCount),
|
||||
CreatedAt: timestamppb.New(clip.CreatedAt),
|
||||
UpdatedAt: timestamppb.New(clip.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *GRPCHandler) clipsToProto(clips []*domain.Clip) []*proto.Clip {
|
||||
var protoClips []*proto.Clip
|
||||
for _, clip := range clips {
|
||||
protoClips = append(protoClips, h.clipToProto(clip))
|
||||
}
|
||||
return protoClips
|
||||
}
|
||||
|
||||
func (h *GRPCHandler) likeToProto(like *domain.ClipLike) *proto.ClipLike {
|
||||
if like == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &proto.ClipLike{
|
||||
Id: int32(like.ID),
|
||||
ClipId: int32(like.ClipID),
|
||||
UserId: int32(like.UserID),
|
||||
CreatedAt: timestamppb.New(like.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *GRPCHandler) likesToProto(likes []*domain.ClipLike) []*proto.ClipLike {
|
||||
var protoLikes []*proto.ClipLike
|
||||
for _, like := range likes {
|
||||
protoLikes = append(protoLikes, h.likeToProto(like))
|
||||
}
|
||||
return protoLikes
|
||||
}
|
||||
|
||||
func (h *GRPCHandler) commentToProto(comment *domain.ClipComment) *proto.ClipComment {
|
||||
if comment == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &proto.ClipComment{
|
||||
Id: int32(comment.ID),
|
||||
ClipId: int32(comment.ClipID),
|
||||
AuthorId: int32(comment.AuthorID),
|
||||
Content: comment.Content,
|
||||
CreatedAt: timestamppb.New(comment.CreatedAt),
|
||||
UpdatedAt: timestamppb.New(comment.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *GRPCHandler) commentsToProto(comments []*domain.ClipComment) []*proto.ClipComment {
|
||||
var protoComments []*proto.ClipComment
|
||||
for _, comment := range comments {
|
||||
protoComments = append(protoComments, h.commentToProto(comment))
|
||||
}
|
||||
return protoComments
|
||||
}
|
||||
39
internal/moderation/moderation.go
Normal file
39
internal/moderation/moderation.go
Normal file
@ -0,0 +1,39 @@
|
||||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
pb "tailly_clips/internal/moderation/proto"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type ModerationClient struct {
|
||||
conn *grpc.ClientConn
|
||||
client pb.ModerationServiceClient
|
||||
}
|
||||
|
||||
func NewModerationClient(addr string) (*ModerationClient, error) {
|
||||
conn, err := grpc.Dial(addr, grpc.WithInsecure())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ModerationClient{
|
||||
conn: conn,
|
||||
client: pb.NewModerationServiceClient(conn),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *ModerationClient) Close() {
|
||||
c.conn.Close()
|
||||
}
|
||||
func (c *ModerationClient) CheckVideoUrl(ctx context.Context, videoUrl string) (bool, error) {
|
||||
resp, err := c.client.CheckVideo(ctx, &pb.VideoRequest{
|
||||
VideoUrl: videoUrl,
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return resp.OverallDecision == "allowed", nil
|
||||
}
|
||||
400
internal/moderation/proto/moderation.pb.go
Normal file
400
internal/moderation/proto/moderation.pb.go
Normal file
@ -0,0 +1,400 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.6
|
||||
// protoc v3.21.12
|
||||
// source: moderation.proto
|
||||
|
||||
package __
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
unsafe "unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type ImageRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ImageData []byte `protobuf:"bytes,1,opt,name=image_data,json=imageData,proto3" json:"image_data,omitempty"`
|
||||
ImageUrl string `protobuf:"bytes,2,opt,name=image_url,json=imageUrl,proto3" json:"image_url,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ImageRequest) Reset() {
|
||||
*x = ImageRequest{}
|
||||
mi := &file_moderation_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ImageRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ImageRequest) ProtoMessage() {}
|
||||
|
||||
func (x *ImageRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_moderation_proto_msgTypes[0]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ImageRequest.ProtoReflect.Descriptor instead.
|
||||
func (*ImageRequest) Descriptor() ([]byte, []int) {
|
||||
return file_moderation_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *ImageRequest) GetImageData() []byte {
|
||||
if x != nil {
|
||||
return x.ImageData
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *ImageRequest) GetImageUrl() string {
|
||||
if x != nil {
|
||||
return x.ImageUrl
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type VideoRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
VideoUrl string `protobuf:"bytes,1,opt,name=video_url,json=videoUrl,proto3" json:"video_url,omitempty"`
|
||||
FrameSampleRate int32 `protobuf:"varint,2,opt,name=frame_sample_rate,json=frameSampleRate,proto3" json:"frame_sample_rate,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *VideoRequest) Reset() {
|
||||
*x = VideoRequest{}
|
||||
mi := &file_moderation_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *VideoRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*VideoRequest) ProtoMessage() {}
|
||||
|
||||
func (x *VideoRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_moderation_proto_msgTypes[1]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use VideoRequest.ProtoReflect.Descriptor instead.
|
||||
func (*VideoRequest) Descriptor() ([]byte, []int) {
|
||||
return file_moderation_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *VideoRequest) GetVideoUrl() string {
|
||||
if x != nil {
|
||||
return x.VideoUrl
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *VideoRequest) GetFrameSampleRate() int32 {
|
||||
if x != nil {
|
||||
return x.FrameSampleRate
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type ModerationResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
OverallDecision string `protobuf:"bytes,1,opt,name=overall_decision,json=overallDecision,proto3" json:"overall_decision,omitempty"`
|
||||
Predictions []*Prediction `protobuf:"bytes,2,rep,name=predictions,proto3" json:"predictions,omitempty"`
|
||||
Message string `protobuf:"bytes,3,opt,name=message,proto3" json:"message,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ModerationResponse) Reset() {
|
||||
*x = ModerationResponse{}
|
||||
mi := &file_moderation_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ModerationResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ModerationResponse) ProtoMessage() {}
|
||||
|
||||
func (x *ModerationResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_moderation_proto_msgTypes[2]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ModerationResponse.ProtoReflect.Descriptor instead.
|
||||
func (*ModerationResponse) Descriptor() ([]byte, []int) {
|
||||
return file_moderation_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
func (x *ModerationResponse) GetOverallDecision() string {
|
||||
if x != nil {
|
||||
return x.OverallDecision
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ModerationResponse) GetPredictions() []*Prediction {
|
||||
if x != nil {
|
||||
return x.Predictions
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *ModerationResponse) GetMessage() string {
|
||||
if x != nil {
|
||||
return x.Message
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type Prediction struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Category string `protobuf:"bytes,1,opt,name=category,proto3" json:"category,omitempty"`
|
||||
Score float32 `protobuf:"fixed32,2,opt,name=score,proto3" json:"score,omitempty"`
|
||||
Decision string `protobuf:"bytes,3,opt,name=decision,proto3" json:"decision,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *Prediction) Reset() {
|
||||
*x = Prediction{}
|
||||
mi := &file_moderation_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *Prediction) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*Prediction) ProtoMessage() {}
|
||||
|
||||
func (x *Prediction) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_moderation_proto_msgTypes[3]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use Prediction.ProtoReflect.Descriptor instead.
|
||||
func (*Prediction) Descriptor() ([]byte, []int) {
|
||||
return file_moderation_proto_rawDescGZIP(), []int{3}
|
||||
}
|
||||
|
||||
func (x *Prediction) GetCategory() string {
|
||||
if x != nil {
|
||||
return x.Category
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *Prediction) GetScore() float32 {
|
||||
if x != nil {
|
||||
return x.Score
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *Prediction) GetDecision() string {
|
||||
if x != nil {
|
||||
return x.Decision
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ModerationSettings struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
CategoryThresholds map[string]float32 `protobuf:"bytes,1,rep,name=category_thresholds,json=categoryThresholds,proto3" json:"category_thresholds,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"fixed32,2,opt,name=value"`
|
||||
AlwaysBlockCategories []string `protobuf:"bytes,2,rep,name=always_block_categories,json=alwaysBlockCategories,proto3" json:"always_block_categories,omitempty"`
|
||||
AlwaysAllowCategories []string `protobuf:"bytes,3,rep,name=always_allow_categories,json=alwaysAllowCategories,proto3" json:"always_allow_categories,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ModerationSettings) Reset() {
|
||||
*x = ModerationSettings{}
|
||||
mi := &file_moderation_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *ModerationSettings) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*ModerationSettings) ProtoMessage() {}
|
||||
|
||||
func (x *ModerationSettings) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_moderation_proto_msgTypes[4]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use ModerationSettings.ProtoReflect.Descriptor instead.
|
||||
func (*ModerationSettings) Descriptor() ([]byte, []int) {
|
||||
return file_moderation_proto_rawDescGZIP(), []int{4}
|
||||
}
|
||||
|
||||
func (x *ModerationSettings) GetCategoryThresholds() map[string]float32 {
|
||||
if x != nil {
|
||||
return x.CategoryThresholds
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *ModerationSettings) GetAlwaysBlockCategories() []string {
|
||||
if x != nil {
|
||||
return x.AlwaysBlockCategories
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *ModerationSettings) GetAlwaysAllowCategories() []string {
|
||||
if x != nil {
|
||||
return x.AlwaysAllowCategories
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var File_moderation_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_moderation_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x10moderation.proto\x12\n" +
|
||||
"moderation\"J\n" +
|
||||
"\fImageRequest\x12\x1d\n" +
|
||||
"\n" +
|
||||
"image_data\x18\x01 \x01(\fR\timageData\x12\x1b\n" +
|
||||
"\timage_url\x18\x02 \x01(\tR\bimageUrl\"W\n" +
|
||||
"\fVideoRequest\x12\x1b\n" +
|
||||
"\tvideo_url\x18\x01 \x01(\tR\bvideoUrl\x12*\n" +
|
||||
"\x11frame_sample_rate\x18\x02 \x01(\x05R\x0fframeSampleRate\"\x93\x01\n" +
|
||||
"\x12ModerationResponse\x12)\n" +
|
||||
"\x10overall_decision\x18\x01 \x01(\tR\x0foverallDecision\x128\n" +
|
||||
"\vpredictions\x18\x02 \x03(\v2\x16.moderation.PredictionR\vpredictions\x12\x18\n" +
|
||||
"\amessage\x18\x03 \x01(\tR\amessage\"Z\n" +
|
||||
"\n" +
|
||||
"Prediction\x12\x1a\n" +
|
||||
"\bcategory\x18\x01 \x01(\tR\bcategory\x12\x14\n" +
|
||||
"\x05score\x18\x02 \x01(\x02R\x05score\x12\x1a\n" +
|
||||
"\bdecision\x18\x03 \x01(\tR\bdecision\"\xb4\x02\n" +
|
||||
"\x12ModerationSettings\x12g\n" +
|
||||
"\x13category_thresholds\x18\x01 \x03(\v26.moderation.ModerationSettings.CategoryThresholdsEntryR\x12categoryThresholds\x126\n" +
|
||||
"\x17always_block_categories\x18\x02 \x03(\tR\x15alwaysBlockCategories\x126\n" +
|
||||
"\x17always_allow_categories\x18\x03 \x03(\tR\x15alwaysAllowCategories\x1aE\n" +
|
||||
"\x17CategoryThresholdsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\x02R\x05value:\x028\x012\xa7\x01\n" +
|
||||
"\x11ModerationService\x12H\n" +
|
||||
"\n" +
|
||||
"CheckImage\x12\x18.moderation.ImageRequest\x1a\x1e.moderation.ModerationResponse\"\x00\x12H\n" +
|
||||
"\n" +
|
||||
"CheckVideo\x12\x18.moderation.VideoRequest\x1a\x1e.moderation.ModerationResponse\"\x00B\x03Z\x01.b\x06proto3"
|
||||
|
||||
var (
|
||||
file_moderation_proto_rawDescOnce sync.Once
|
||||
file_moderation_proto_rawDescData []byte
|
||||
)
|
||||
|
||||
func file_moderation_proto_rawDescGZIP() []byte {
|
||||
file_moderation_proto_rawDescOnce.Do(func() {
|
||||
file_moderation_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_moderation_proto_rawDesc), len(file_moderation_proto_rawDesc)))
|
||||
})
|
||||
return file_moderation_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_moderation_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
|
||||
var file_moderation_proto_goTypes = []any{
|
||||
(*ImageRequest)(nil), // 0: moderation.ImageRequest
|
||||
(*VideoRequest)(nil), // 1: moderation.VideoRequest
|
||||
(*ModerationResponse)(nil), // 2: moderation.ModerationResponse
|
||||
(*Prediction)(nil), // 3: moderation.Prediction
|
||||
(*ModerationSettings)(nil), // 4: moderation.ModerationSettings
|
||||
nil, // 5: moderation.ModerationSettings.CategoryThresholdsEntry
|
||||
}
|
||||
var file_moderation_proto_depIdxs = []int32{
|
||||
3, // 0: moderation.ModerationResponse.predictions:type_name -> moderation.Prediction
|
||||
5, // 1: moderation.ModerationSettings.category_thresholds:type_name -> moderation.ModerationSettings.CategoryThresholdsEntry
|
||||
0, // 2: moderation.ModerationService.CheckImage:input_type -> moderation.ImageRequest
|
||||
1, // 3: moderation.ModerationService.CheckVideo:input_type -> moderation.VideoRequest
|
||||
2, // 4: moderation.ModerationService.CheckImage:output_type -> moderation.ModerationResponse
|
||||
2, // 5: moderation.ModerationService.CheckVideo:output_type -> moderation.ModerationResponse
|
||||
4, // [4:6] is the sub-list for method output_type
|
||||
2, // [2:4] is the sub-list for method input_type
|
||||
2, // [2:2] is the sub-list for extension type_name
|
||||
2, // [2:2] is the sub-list for extension extendee
|
||||
0, // [0:2] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_moderation_proto_init() }
|
||||
func file_moderation_proto_init() {
|
||||
if File_moderation_proto != nil {
|
||||
return
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_moderation_proto_rawDesc), len(file_moderation_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 6,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
GoTypes: file_moderation_proto_goTypes,
|
||||
DependencyIndexes: file_moderation_proto_depIdxs,
|
||||
MessageInfos: file_moderation_proto_msgTypes,
|
||||
}.Build()
|
||||
File_moderation_proto = out.File
|
||||
file_moderation_proto_goTypes = nil
|
||||
file_moderation_proto_depIdxs = nil
|
||||
}
|
||||
37
internal/moderation/proto/moderation.proto
Normal file
37
internal/moderation/proto/moderation.proto
Normal file
@ -0,0 +1,37 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package moderation;
|
||||
option go_package = ".";
|
||||
|
||||
service ModerationService {
|
||||
rpc CheckImage (ImageRequest) returns (ModerationResponse) {}
|
||||
rpc CheckVideo (VideoRequest) returns (ModerationResponse) {}
|
||||
}
|
||||
|
||||
message ImageRequest {
|
||||
bytes image_data = 1;
|
||||
string image_url = 2;
|
||||
}
|
||||
|
||||
message VideoRequest {
|
||||
string video_url = 1;
|
||||
int32 frame_sample_rate = 2;
|
||||
}
|
||||
|
||||
message ModerationResponse {
|
||||
string overall_decision = 1;
|
||||
repeated Prediction predictions = 2;
|
||||
string message = 3;
|
||||
}
|
||||
|
||||
message Prediction {
|
||||
string category = 1;
|
||||
float score = 2;
|
||||
string decision = 3;
|
||||
}
|
||||
|
||||
message ModerationSettings {
|
||||
map<string, float> category_thresholds = 1;
|
||||
repeated string always_block_categories = 2;
|
||||
repeated string always_allow_categories = 3;
|
||||
}
|
||||
159
internal/moderation/proto/moderation_grpc.pb.go
Normal file
159
internal/moderation/proto/moderation_grpc.pb.go
Normal file
@ -0,0 +1,159 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc v3.21.12
|
||||
// source: moderation.proto
|
||||
|
||||
package __
|
||||
|
||||
import (
|
||||
context "context"
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the grpc package it is being compiled against.
|
||||
// Requires gRPC-Go v1.64.0 or later.
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
ModerationService_CheckImage_FullMethodName = "/moderation.ModerationService/CheckImage"
|
||||
ModerationService_CheckVideo_FullMethodName = "/moderation.ModerationService/CheckVideo"
|
||||
)
|
||||
|
||||
// ModerationServiceClient is the client API for ModerationService service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
type ModerationServiceClient interface {
|
||||
CheckImage(ctx context.Context, in *ImageRequest, opts ...grpc.CallOption) (*ModerationResponse, error)
|
||||
CheckVideo(ctx context.Context, in *VideoRequest, opts ...grpc.CallOption) (*ModerationResponse, error)
|
||||
}
|
||||
|
||||
type moderationServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewModerationServiceClient(cc grpc.ClientConnInterface) ModerationServiceClient {
|
||||
return &moderationServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *moderationServiceClient) CheckImage(ctx context.Context, in *ImageRequest, opts ...grpc.CallOption) (*ModerationResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ModerationResponse)
|
||||
err := c.cc.Invoke(ctx, ModerationService_CheckImage_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *moderationServiceClient) CheckVideo(ctx context.Context, in *VideoRequest, opts ...grpc.CallOption) (*ModerationResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ModerationResponse)
|
||||
err := c.cc.Invoke(ctx, ModerationService_CheckVideo_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ModerationServiceServer is the server API for ModerationService service.
|
||||
// All implementations must embed UnimplementedModerationServiceServer
|
||||
// for forward compatibility.
|
||||
type ModerationServiceServer interface {
|
||||
CheckImage(context.Context, *ImageRequest) (*ModerationResponse, error)
|
||||
CheckVideo(context.Context, *VideoRequest) (*ModerationResponse, error)
|
||||
mustEmbedUnimplementedModerationServiceServer()
|
||||
}
|
||||
|
||||
// UnimplementedModerationServiceServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedModerationServiceServer struct{}
|
||||
|
||||
func (UnimplementedModerationServiceServer) CheckImage(context.Context, *ImageRequest) (*ModerationResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CheckImage not implemented")
|
||||
}
|
||||
func (UnimplementedModerationServiceServer) CheckVideo(context.Context, *VideoRequest) (*ModerationResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CheckVideo not implemented")
|
||||
}
|
||||
func (UnimplementedModerationServiceServer) mustEmbedUnimplementedModerationServiceServer() {}
|
||||
func (UnimplementedModerationServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeModerationServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to ModerationServiceServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeModerationServiceServer interface {
|
||||
mustEmbedUnimplementedModerationServiceServer()
|
||||
}
|
||||
|
||||
func RegisterModerationServiceServer(s grpc.ServiceRegistrar, srv ModerationServiceServer) {
|
||||
// If the following call pancis, it indicates UnimplementedModerationServiceServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&ModerationService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _ModerationService_CheckImage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ImageRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ModerationServiceServer).CheckImage(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ModerationService_CheckImage_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ModerationServiceServer).CheckImage(ctx, req.(*ImageRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _ModerationService_CheckVideo_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(VideoRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ModerationServiceServer).CheckVideo(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ModerationService_CheckVideo_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ModerationServiceServer).CheckVideo(ctx, req.(*VideoRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// ModerationService_ServiceDesc is the grpc.ServiceDesc for ModerationService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var ModerationService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "moderation.ModerationService",
|
||||
HandlerType: (*ModerationServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "CheckImage",
|
||||
Handler: _ModerationService_CheckImage_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CheckVideo",
|
||||
Handler: _ModerationService_CheckVideo_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "moderation.proto",
|
||||
}
|
||||
335
internal/repository/clip_repository.go
Normal file
335
internal/repository/clip_repository.go
Normal file
@ -0,0 +1,335 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"tailly_clips/internal/domain"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrClipNotFound = errors.New("clip not found")
|
||||
)
|
||||
|
||||
type ClipRepository interface {
|
||||
Create(ctx context.Context, clip *domain.Clip) error
|
||||
GetByID(ctx context.Context, id int) (*domain.Clip, error)
|
||||
GetByAuthorID(ctx context.Context, authorID, limit, offset int) ([]*domain.Clip, int, error)
|
||||
GetAll(ctx context.Context, limit, offset int) ([]*domain.Clip, int, error)
|
||||
Delete(ctx context.Context, id int) error
|
||||
IncrementLikesCount(ctx context.Context, clipID int) error
|
||||
DecrementLikesCount(ctx context.Context, clipID int) error
|
||||
IncrementCommentsCount(ctx context.Context, clipID int) error
|
||||
DecrementCommentsCount(ctx context.Context, clipID int) error
|
||||
GetClipURLs(ctx context.Context, clipID int) (string, string, error)
|
||||
GetClipWithURLs(ctx context.Context, clipID int) (*domain.Clip, error)
|
||||
}
|
||||
|
||||
type clipRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewClipRepository(db *sql.DB) ClipRepository {
|
||||
return &clipRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *clipRepository) Create(ctx context.Context, clip *domain.Clip) error {
|
||||
query := `
|
||||
INSERT INTO clips (title, video_url, thumbnail_url, duration, author_id, likes_count, comments_count, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query,
|
||||
clip.Title,
|
||||
clip.VideoURL,
|
||||
clip.ThumbnailURL,
|
||||
clip.Duration,
|
||||
clip.AuthorID,
|
||||
clip.LikesCount,
|
||||
clip.CommentsCount,
|
||||
clip.CreatedAt,
|
||||
clip.UpdatedAt,
|
||||
).Scan(&clip.ID)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create clip: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *clipRepository) GetByID(ctx context.Context, id int) (*domain.Clip, error) {
|
||||
query := `
|
||||
SELECT id, title, video_url, thumbnail_url, duration, author_id,
|
||||
likes_count, comments_count, created_at, updated_at
|
||||
FROM clips
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
clip := &domain.Clip{}
|
||||
err := r.db.QueryRowContext(ctx, query, id).Scan(
|
||||
&clip.ID,
|
||||
&clip.Title,
|
||||
&clip.VideoURL,
|
||||
&clip.ThumbnailURL,
|
||||
&clip.Duration,
|
||||
&clip.AuthorID,
|
||||
&clip.LikesCount,
|
||||
&clip.CommentsCount,
|
||||
&clip.CreatedAt,
|
||||
&clip.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrClipNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get clip: %w", err)
|
||||
}
|
||||
|
||||
return clip, nil
|
||||
}
|
||||
|
||||
func (r *clipRepository) GetByAuthorID(ctx context.Context, authorID, limit, offset int) ([]*domain.Clip, int, error) {
|
||||
// Получаем общее количество
|
||||
countQuery := `SELECT COUNT(*) FROM clips WHERE author_id = $1 AND deleted_at IS NULL`
|
||||
var totalCount int
|
||||
err := r.db.QueryRowContext(ctx, countQuery, authorID).Scan(&totalCount)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to get total count: %w", err)
|
||||
}
|
||||
|
||||
// Получаем клипы
|
||||
query := `
|
||||
SELECT id, title, video_url, thumbnail_url, duration, author_id,
|
||||
likes_count, comments_count, created_at, updated_at
|
||||
FROM clips
|
||||
WHERE author_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, authorID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to get clips: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var clips []*domain.Clip
|
||||
for rows.Next() {
|
||||
clip := &domain.Clip{}
|
||||
err := rows.Scan(
|
||||
&clip.ID,
|
||||
&clip.Title,
|
||||
&clip.VideoURL,
|
||||
&clip.ThumbnailURL,
|
||||
&clip.Duration,
|
||||
&clip.AuthorID,
|
||||
&clip.LikesCount,
|
||||
&clip.CommentsCount,
|
||||
&clip.CreatedAt,
|
||||
&clip.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to scan clip: %w", err)
|
||||
}
|
||||
clips = append(clips, clip)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, 0, fmt.Errorf("rows error: %w", err)
|
||||
}
|
||||
|
||||
return clips, totalCount, nil
|
||||
}
|
||||
|
||||
func (r *clipRepository) GetAll(ctx context.Context, limit, offset int) ([]*domain.Clip, int, error) {
|
||||
// Получаем общее количество
|
||||
countQuery := `SELECT COUNT(*) FROM clips WHERE deleted_at IS NULL`
|
||||
var totalCount int
|
||||
err := r.db.QueryRowContext(ctx, countQuery).Scan(&totalCount)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to get total count: %w", err)
|
||||
}
|
||||
|
||||
// Получаем клипы
|
||||
query := `
|
||||
SELECT id, title, video_url, thumbnail_url, duration, author_id,
|
||||
likes_count, comments_count, created_at, updated_at
|
||||
FROM clips
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to get clips: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var clips []*domain.Clip
|
||||
for rows.Next() {
|
||||
clip := &domain.Clip{}
|
||||
err := rows.Scan(
|
||||
&clip.ID,
|
||||
&clip.Title,
|
||||
&clip.VideoURL,
|
||||
&clip.ThumbnailURL,
|
||||
&clip.Duration,
|
||||
&clip.AuthorID,
|
||||
&clip.LikesCount,
|
||||
&clip.CommentsCount,
|
||||
&clip.CreatedAt,
|
||||
&clip.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to scan clip: %w", err)
|
||||
}
|
||||
clips = append(clips, clip)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, 0, fmt.Errorf("rows error: %w", err)
|
||||
}
|
||||
|
||||
return clips, totalCount, nil
|
||||
}
|
||||
|
||||
func (r *clipRepository) Delete(ctx context.Context, id int) error {
|
||||
query := `
|
||||
UPDATE clips
|
||||
SET deleted_at = $1
|
||||
WHERE id = $2 AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query, time.Now(), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete clip: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get rows affected: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return ErrClipNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *clipRepository) IncrementLikesCount(ctx context.Context, clipID int) error {
|
||||
query := `
|
||||
UPDATE clips
|
||||
SET likes_count = likes_count + 1, updated_at = $1
|
||||
WHERE id = $2 AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query, time.Now(), clipID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to increment likes count: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *clipRepository) DecrementLikesCount(ctx context.Context, clipID int) error {
|
||||
query := `
|
||||
UPDATE clips
|
||||
SET likes_count = GREATEST(likes_count - 1, 0), updated_at = $1
|
||||
WHERE id = $2 AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query, time.Now(), clipID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrement likes count: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *clipRepository) IncrementCommentsCount(ctx context.Context, clipID int) error {
|
||||
query := `
|
||||
UPDATE clips
|
||||
SET comments_count = comments_count + 1, updated_at = $1
|
||||
WHERE id = $2 AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query, time.Now(), clipID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to increment comments count: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *clipRepository) DecrementCommentsCount(ctx context.Context, clipID int) error {
|
||||
query := `
|
||||
UPDATE clips
|
||||
SET comments_count = GREATEST(comments_count - 1, 0), updated_at = $1
|
||||
WHERE id = $2 AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query, time.Now(), clipID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrement comments count: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
func (r *clipRepository) GetClipURLs(ctx context.Context, clipID int) (string, string, error) {
|
||||
query := `
|
||||
SELECT video_url, thumbnail_url
|
||||
FROM clips
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
var videoURL, thumbnailURL string
|
||||
err := r.db.QueryRowContext(ctx, query, clipID).Scan(&videoURL, &thumbnailURL)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", "", ErrClipNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to get clip URLs: %w", err)
|
||||
}
|
||||
|
||||
return videoURL, thumbnailURL, nil
|
||||
}
|
||||
|
||||
// GetClipWithURLs возвращает клип с URL
|
||||
func (r *clipRepository) GetClipWithURLs(ctx context.Context, clipID int) (*domain.Clip, error) {
|
||||
query := `
|
||||
SELECT id, title, video_url, thumbnail_url, duration, author_id,
|
||||
likes_count, comments_count, created_at, updated_at
|
||||
FROM clips
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
clip := &domain.Clip{}
|
||||
err := r.db.QueryRowContext(ctx, query, clipID).Scan(
|
||||
&clip.ID,
|
||||
&clip.Title,
|
||||
&clip.VideoURL,
|
||||
&clip.ThumbnailURL,
|
||||
&clip.Duration,
|
||||
&clip.AuthorID,
|
||||
&clip.LikesCount,
|
||||
&clip.CommentsCount,
|
||||
&clip.CreatedAt,
|
||||
&clip.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrClipNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get clip: %w", err)
|
||||
}
|
||||
|
||||
return clip, nil
|
||||
}
|
||||
262
internal/repository/comment_repository.go
Normal file
262
internal/repository/comment_repository.go
Normal file
@ -0,0 +1,262 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"tailly_clips/internal/domain"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCommentNotFound = errors.New("comment not found")
|
||||
)
|
||||
|
||||
type CommentRepository interface {
|
||||
Create(ctx context.Context, comment *domain.ClipComment) error
|
||||
GetByID(ctx context.Context, id int) (*domain.ClipComment, error)
|
||||
GetByClipID(ctx context.Context, clipID, limit, offset int) ([]*domain.ClipComment, int, error)
|
||||
GetByAuthorID(ctx context.Context, authorID, limit, offset int) ([]*domain.ClipComment, int, error)
|
||||
Update(ctx context.Context, comment *domain.ClipComment) error
|
||||
Delete(ctx context.Context, id int) error
|
||||
DeleteByClipID(ctx context.Context, clipID int) error
|
||||
DeleteByAuthorID(ctx context.Context, authorID int) error
|
||||
}
|
||||
|
||||
type commentRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewCommentRepository(db *sql.DB) CommentRepository {
|
||||
return &commentRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *commentRepository) Create(ctx context.Context, comment *domain.ClipComment) error {
|
||||
query := `
|
||||
INSERT INTO clip_comments (clip_id, author_id, content, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query,
|
||||
comment.ClipID,
|
||||
comment.AuthorID,
|
||||
comment.Content,
|
||||
comment.CreatedAt,
|
||||
comment.UpdatedAt,
|
||||
).Scan(&comment.ID)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create comment: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *commentRepository) GetByID(ctx context.Context, id int) (*domain.ClipComment, error) {
|
||||
query := `
|
||||
SELECT id, clip_id, author_id, content, created_at, updated_at
|
||||
FROM clip_comments
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
comment := &domain.ClipComment{}
|
||||
err := r.db.QueryRowContext(ctx, query, id).Scan(
|
||||
&comment.ID,
|
||||
&comment.ClipID,
|
||||
&comment.AuthorID,
|
||||
&comment.Content,
|
||||
&comment.CreatedAt,
|
||||
&comment.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrCommentNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get comment: %w", err)
|
||||
}
|
||||
|
||||
return comment, nil
|
||||
}
|
||||
|
||||
func (r *commentRepository) GetByClipID(ctx context.Context, clipID, limit, offset int) ([]*domain.ClipComment, int, error) {
|
||||
// Получаем общее количество
|
||||
countQuery := `SELECT COUNT(*) FROM clip_comments WHERE clip_id = $1 AND deleted_at IS NULL`
|
||||
var totalCount int
|
||||
err := r.db.QueryRowContext(ctx, countQuery, clipID).Scan(&totalCount)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to get total count: %w", err)
|
||||
}
|
||||
|
||||
// Получаем комментарии
|
||||
query := `
|
||||
SELECT id, clip_id, author_id, content, created_at, updated_at
|
||||
FROM clip_comments
|
||||
WHERE clip_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY created_at ASC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, clipID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to get comments: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var comments []*domain.ClipComment
|
||||
for rows.Next() {
|
||||
comment := &domain.ClipComment{}
|
||||
err := rows.Scan(
|
||||
&comment.ID,
|
||||
&comment.ClipID,
|
||||
&comment.AuthorID,
|
||||
&comment.Content,
|
||||
&comment.CreatedAt,
|
||||
&comment.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to scan comment: %w", err)
|
||||
}
|
||||
comments = append(comments, comment)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, 0, fmt.Errorf("rows error: %w", err)
|
||||
}
|
||||
|
||||
return comments, totalCount, nil
|
||||
}
|
||||
|
||||
func (r *commentRepository) GetByAuthorID(ctx context.Context, authorID, limit, offset int) ([]*domain.ClipComment, int, error) {
|
||||
// Получаем общее количество
|
||||
countQuery := `SELECT COUNT(*) FROM clip_comments WHERE author_id = $1 AND deleted_at IS NULL`
|
||||
var totalCount int
|
||||
err := r.db.QueryRowContext(ctx, countQuery, authorID).Scan(&totalCount)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to get total count: %w", err)
|
||||
}
|
||||
|
||||
// Получаем комментарии
|
||||
query := `
|
||||
SELECT id, clip_id, author_id, content, created_at, updated_at
|
||||
FROM clip_comments
|
||||
WHERE author_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, authorID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to get comments: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var comments []*domain.ClipComment
|
||||
for rows.Next() {
|
||||
comment := &domain.ClipComment{}
|
||||
err := rows.Scan(
|
||||
&comment.ID,
|
||||
&comment.ClipID,
|
||||
&comment.AuthorID,
|
||||
&comment.Content,
|
||||
&comment.CreatedAt,
|
||||
&comment.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to scan comment: %w", err)
|
||||
}
|
||||
comments = append(comments, comment)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, 0, fmt.Errorf("rows error: %w", err)
|
||||
}
|
||||
|
||||
return comments, totalCount, nil
|
||||
}
|
||||
|
||||
func (r *commentRepository) Update(ctx context.Context, comment *domain.ClipComment) error {
|
||||
query := `
|
||||
UPDATE clip_comments
|
||||
SET content = $1, updated_at = $2
|
||||
WHERE id = $3 AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query,
|
||||
comment.Content,
|
||||
time.Now(),
|
||||
comment.ID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update comment: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get rows affected: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return ErrCommentNotFound
|
||||
}
|
||||
|
||||
comment.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *commentRepository) Delete(ctx context.Context, id int) error {
|
||||
query := `
|
||||
UPDATE clip_comments
|
||||
SET deleted_at = $1
|
||||
WHERE id = $2 AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query, time.Now(), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete comment: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get rows affected: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return ErrCommentNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *commentRepository) DeleteByClipID(ctx context.Context, clipID int) error {
|
||||
query := `
|
||||
UPDATE clip_comments
|
||||
SET deleted_at = $1
|
||||
WHERE clip_id = $2 AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query, time.Now(), clipID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete comments by clip ID: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *commentRepository) DeleteByAuthorID(ctx context.Context, authorID int) error {
|
||||
query := `
|
||||
UPDATE clip_comments
|
||||
SET deleted_at = $1
|
||||
WHERE author_id = $2 AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query, time.Now(), authorID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete comments by author ID: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
242
internal/repository/like_repository.go
Normal file
242
internal/repository/like_repository.go
Normal file
@ -0,0 +1,242 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"tailly_clips/internal/domain"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrLikeNotFound = errors.New("like not found")
|
||||
ErrLikeAlreadyExists = errors.New("like already exists")
|
||||
)
|
||||
|
||||
type LikeRepository interface {
|
||||
Create(ctx context.Context, like *domain.ClipLike) error
|
||||
GetByID(ctx context.Context, id int) (*domain.ClipLike, error)
|
||||
GetByClipID(ctx context.Context, clipID, limit, offset int) ([]*domain.ClipLike, int, error)
|
||||
GetByUserID(ctx context.Context, userID, limit, offset int) ([]*domain.ClipLike, int, error)
|
||||
CheckExists(ctx context.Context, clipID, userID int) (bool, error)
|
||||
Delete(ctx context.Context, clipID, userID int) error
|
||||
DeleteByClipID(ctx context.Context, clipID int) error
|
||||
DeleteByUserID(ctx context.Context, userID int) error
|
||||
}
|
||||
|
||||
type likeRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewLikeRepository(db *sql.DB) LikeRepository {
|
||||
return &likeRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *likeRepository) Create(ctx context.Context, like *domain.ClipLike) error {
|
||||
query := `
|
||||
INSERT INTO clip_likes (clip_id, user_id, created_at)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query,
|
||||
like.ClipID,
|
||||
like.UserID,
|
||||
like.CreatedAt,
|
||||
).Scan(&like.ID)
|
||||
|
||||
if err != nil {
|
||||
if isDuplicateKeyError(err) {
|
||||
return ErrLikeAlreadyExists
|
||||
}
|
||||
return fmt.Errorf("failed to create like: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *likeRepository) GetByID(ctx context.Context, id int) (*domain.ClipLike, error) {
|
||||
query := `
|
||||
SELECT id, clip_id, user_id, created_at
|
||||
FROM clip_likes
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
like := &domain.ClipLike{}
|
||||
err := r.db.QueryRowContext(ctx, query, id).Scan(
|
||||
&like.ID,
|
||||
&like.ClipID,
|
||||
&like.UserID,
|
||||
&like.CreatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrLikeNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get like: %w", err)
|
||||
}
|
||||
|
||||
return like, nil
|
||||
}
|
||||
|
||||
func (r *likeRepository) GetByClipID(ctx context.Context, clipID, limit, offset int) ([]*domain.ClipLike, int, error) {
|
||||
// Получаем общее количество
|
||||
countQuery := `SELECT COUNT(*) FROM clip_likes WHERE clip_id = $1`
|
||||
var totalCount int
|
||||
err := r.db.QueryRowContext(ctx, countQuery, clipID).Scan(&totalCount)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to get total count: %w", err)
|
||||
}
|
||||
|
||||
// Получаем лайки
|
||||
query := `
|
||||
SELECT id, clip_id, user_id, created_at
|
||||
FROM clip_likes
|
||||
WHERE clip_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, clipID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to get likes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var likes []*domain.ClipLike
|
||||
for rows.Next() {
|
||||
like := &domain.ClipLike{}
|
||||
err := rows.Scan(
|
||||
&like.ID,
|
||||
&like.ClipID,
|
||||
&like.UserID,
|
||||
&like.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to scan like: %w", err)
|
||||
}
|
||||
likes = append(likes, like)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, 0, fmt.Errorf("rows error: %w", err)
|
||||
}
|
||||
|
||||
return likes, totalCount, nil
|
||||
}
|
||||
|
||||
func (r *likeRepository) GetByUserID(ctx context.Context, userID, limit, offset int) ([]*domain.ClipLike, int, error) {
|
||||
// Получаем общее количество
|
||||
countQuery := `SELECT COUNT(*) FROM clip_likes WHERE user_id = $1`
|
||||
var totalCount int
|
||||
err := r.db.QueryRowContext(ctx, countQuery, userID).Scan(&totalCount)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to get total count: %w", err)
|
||||
}
|
||||
|
||||
// Получаем лайки
|
||||
query := `
|
||||
SELECT id, clip_id, user_id, created_at
|
||||
FROM clip_likes
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, userID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to get likes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var likes []*domain.ClipLike
|
||||
for rows.Next() {
|
||||
like := &domain.ClipLike{}
|
||||
err := rows.Scan(
|
||||
&like.ID,
|
||||
&like.ClipID,
|
||||
&like.UserID,
|
||||
&like.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to scan like: %w", err)
|
||||
}
|
||||
likes = append(likes, like)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, 0, fmt.Errorf("rows error: %w", err)
|
||||
}
|
||||
|
||||
return likes, totalCount, nil
|
||||
}
|
||||
|
||||
func (r *likeRepository) CheckExists(ctx context.Context, clipID, userID int) (bool, error) {
|
||||
query := `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM clip_likes
|
||||
WHERE clip_id = $1 AND user_id = $2
|
||||
)
|
||||
`
|
||||
|
||||
var exists bool
|
||||
err := r.db.QueryRowContext(ctx, query, clipID, userID).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check like existence: %w", err)
|
||||
}
|
||||
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func (r *likeRepository) Delete(ctx context.Context, clipID, userID int) error {
|
||||
query := `
|
||||
DELETE FROM clip_likes
|
||||
WHERE clip_id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query, clipID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete like: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get rows affected: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return ErrLikeNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *likeRepository) DeleteByClipID(ctx context.Context, clipID int) error {
|
||||
query := `DELETE FROM clip_likes WHERE clip_id = $1`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query, clipID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete likes by clip ID: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *likeRepository) DeleteByUserID(ctx context.Context, userID int) error {
|
||||
query := `DELETE FROM clip_likes WHERE user_id = $1`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete likes by user ID: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isDuplicateKeyError(err error) bool {
|
||||
// Проверка на дубликат ключа (зависит от драйвера БД)
|
||||
// Для PostgreSQL обычно содержит "duplicate key"
|
||||
return err != nil && (err.Error() == "pq: duplicate key value violates unique constraint" ||
|
||||
err.Error() == "ERROR: duplicate key value violates unique constraint")
|
||||
}
|
||||
217
internal/service/clip_service.go
Normal file
217
internal/service/clip_service.go
Normal file
@ -0,0 +1,217 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"tailly_clips/internal/domain"
|
||||
"tailly_clips/internal/ffmpeg"
|
||||
"tailly_clips/internal/moderation"
|
||||
"tailly_clips/internal/repository"
|
||||
"tailly_clips/internal/storage"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ModerationClient interface {
|
||||
CheckVideoUrl(ctx context.Context, videoUrl string) (bool, error)
|
||||
Close()
|
||||
}
|
||||
type ClipService interface {
|
||||
CreateClip(ctx context.Context, req domain.CreateClipRequest) (*domain.Clip, error)
|
||||
GetClip(ctx context.Context, clipID int) (*domain.Clip, error)
|
||||
GetUserClips(ctx context.Context, userID, limit, offset int) ([]*domain.Clip, int, error)
|
||||
GetClips(ctx context.Context, limit, offset int) ([]*domain.Clip, int, error)
|
||||
DeleteClip(ctx context.Context, clipID, userID int) error
|
||||
}
|
||||
|
||||
type clipService struct {
|
||||
repo repository.ClipRepository
|
||||
storage storage.Storage
|
||||
videoProcessor ffmpeg.VideoProcessor
|
||||
modClient *moderation.ModerationClient
|
||||
}
|
||||
|
||||
func NewClipService(repo repository.ClipRepository, storage storage.Storage, processor ffmpeg.VideoProcessor, modClient *moderation.ModerationClient) ClipService {
|
||||
return &clipService{
|
||||
repo: repo,
|
||||
storage: storage,
|
||||
videoProcessor: processor,
|
||||
modClient: modClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *clipService) CreateClip(ctx context.Context, req domain.CreateClipRequest) (*domain.Clip, error) {
|
||||
const op = "service/clipService.CreateClip"
|
||||
|
||||
// 1. Обрезаем видео до 30 секунд в памяти
|
||||
trimmedVideoData, err := s.videoProcessor.TrimVideo(req.VideoData, 30)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: failed to trim video: %w", op, err)
|
||||
}
|
||||
|
||||
// 2. Параллельно выполняем операции
|
||||
videoURLChan := make(chan string, 1)
|
||||
videoErrChan := make(chan error, 1)
|
||||
thumbnailChan := make(chan string, 1)
|
||||
thumbnailErrChan := make(chan error, 1)
|
||||
durationChan := make(chan int, 1)
|
||||
durationErrChan := make(chan error, 1)
|
||||
|
||||
// Горутина для загрузки видео в S3
|
||||
go func() {
|
||||
videoURL, err := s.storage.UploadVideo(ctx, trimmedVideoData, req.FileName, req.UserID)
|
||||
if err != nil {
|
||||
videoErrChan <- fmt.Errorf("%s: failed to upload video: %w", op, err)
|
||||
return
|
||||
}
|
||||
videoURLChan <- videoURL
|
||||
}()
|
||||
|
||||
// Горутина для генерации превью
|
||||
go func() {
|
||||
thumbnailData, err := s.videoProcessor.GenerateThumbnail(trimmedVideoData)
|
||||
if err != nil {
|
||||
thumbnailErrChan <- fmt.Errorf("%s: failed to generate thumbnail: %w", op, err)
|
||||
return
|
||||
}
|
||||
|
||||
thumbnailURL, err := s.storage.UploadThumbnail(ctx, thumbnailData, req.UserID)
|
||||
if err != nil {
|
||||
thumbnailErrChan <- fmt.Errorf("%s: failed to upload thumbnail: %w", op, err)
|
||||
return
|
||||
}
|
||||
thumbnailChan <- thumbnailURL
|
||||
}()
|
||||
|
||||
// Горутина для получения длительности
|
||||
go func() {
|
||||
duration, err := s.videoProcessor.GetDuration(trimmedVideoData)
|
||||
if err != nil {
|
||||
durationErrChan <- fmt.Errorf("%s: failed to get duration: %w", op, err)
|
||||
return
|
||||
}
|
||||
durationChan <- duration
|
||||
}()
|
||||
|
||||
// 3. Ждем результаты всех операций
|
||||
var videoURL, thumbnailURL string
|
||||
var duration int
|
||||
|
||||
// Ждем загрузки видео
|
||||
select {
|
||||
case videoURL = <-videoURLChan:
|
||||
case err := <-videoErrChan:
|
||||
return nil, err
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("%s: operation cancelled", op)
|
||||
}
|
||||
|
||||
// Ждем генерации превью
|
||||
select {
|
||||
case thumbnailURL = <-thumbnailChan:
|
||||
case err := <-thumbnailErrChan:
|
||||
s.storage.DeleteVideo(ctx, videoURL)
|
||||
return nil, err
|
||||
case <-ctx.Done():
|
||||
s.storage.DeleteVideo(ctx, videoURL)
|
||||
return nil, fmt.Errorf("%s: operation cancelled", op)
|
||||
}
|
||||
|
||||
// Ждем получения длительности
|
||||
select {
|
||||
case duration = <-durationChan:
|
||||
case err := <-durationErrChan:
|
||||
s.storage.DeleteVideo(ctx, videoURL)
|
||||
s.storage.DeleteThumbnail(ctx, thumbnailURL)
|
||||
return nil, err
|
||||
case <-ctx.Done():
|
||||
s.storage.DeleteVideo(ctx, videoURL)
|
||||
s.storage.DeleteThumbnail(ctx, thumbnailURL)
|
||||
return nil, fmt.Errorf("%s: operation cancelled", op)
|
||||
}
|
||||
|
||||
// 4. Модерируем обрезанное видео по URL
|
||||
videoAllowed, err := s.modClient.CheckVideoUrl(ctx, videoURL)
|
||||
if err != nil {
|
||||
s.storage.DeleteVideo(ctx, videoURL)
|
||||
s.storage.DeleteThumbnail(ctx, thumbnailURL)
|
||||
return nil, fmt.Errorf("%s: video moderation failed: %w", op, err)
|
||||
}
|
||||
if !videoAllowed {
|
||||
s.storage.DeleteVideo(ctx, videoURL)
|
||||
s.storage.DeleteThumbnail(ctx, thumbnailURL)
|
||||
return nil, fmt.Errorf("%s: video rejected by moderation service", op)
|
||||
}
|
||||
|
||||
// 5. Создаем клип в БД
|
||||
clip := &domain.Clip{
|
||||
Title: req.Title,
|
||||
VideoURL: videoURL,
|
||||
ThumbnailURL: thumbnailURL,
|
||||
Duration: duration,
|
||||
AuthorID: req.UserID,
|
||||
LikesCount: 0,
|
||||
CommentsCount: 0,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.repo.Create(ctx, clip); err != nil {
|
||||
s.storage.DeleteVideo(ctx, videoURL)
|
||||
s.storage.DeleteThumbnail(ctx, thumbnailURL)
|
||||
return nil, fmt.Errorf("failed to create clip: %w", err)
|
||||
}
|
||||
|
||||
return clip, nil
|
||||
}
|
||||
|
||||
func (s *clipService) GetClip(ctx context.Context, clipID int) (*domain.Clip, error) {
|
||||
clip, err := s.repo.GetByID(ctx, clipID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get clip: %w", err)
|
||||
}
|
||||
return clip, nil
|
||||
}
|
||||
|
||||
func (s *clipService) GetUserClips(ctx context.Context, userID, limit, offset int) ([]*domain.Clip, int, error) {
|
||||
clips, totalCount, err := s.repo.GetByAuthorID(ctx, userID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to get user clips: %w", err)
|
||||
}
|
||||
return clips, totalCount, nil
|
||||
}
|
||||
|
||||
func (s *clipService) GetClips(ctx context.Context, limit, offset int) ([]*domain.Clip, int, error) {
|
||||
clips, totalCount, err := s.repo.GetAll(ctx, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to get clips: %w", err)
|
||||
}
|
||||
return clips, totalCount, nil
|
||||
}
|
||||
|
||||
func (s *clipService) DeleteClip(ctx context.Context, clipID, userID int) error {
|
||||
// Получаем клип для проверки прав и получения URL
|
||||
clip, err := s.repo.GetByID(ctx, clipID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get clip: %w", err)
|
||||
}
|
||||
|
||||
// Проверяем права доступа
|
||||
if clip.AuthorID != userID {
|
||||
return fmt.Errorf("user %d is not the author of clip %d", userID, clipID)
|
||||
}
|
||||
|
||||
// Удаляем файлы из S3
|
||||
fileURLs := []string{clip.VideoURL, clip.ThumbnailURL}
|
||||
if err := s.storage.BulkDelete(ctx, fileURLs); err != nil {
|
||||
return fmt.Errorf("failed to delete files from S3: %w", err)
|
||||
}
|
||||
|
||||
// Удаляем клип из БД
|
||||
if err := s.repo.Delete(ctx, clipID); err != nil {
|
||||
// Логируем ошибку но не возвращаем её, так как файлы уже удалены
|
||||
log.Printf("WARNING: Failed to delete clip %d from database after S3 deletion: %v", clipID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
103
internal/service/comment_service.go
Normal file
103
internal/service/comment_service.go
Normal file
@ -0,0 +1,103 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"tailly_clips/internal/domain"
|
||||
"tailly_clips/internal/repository"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CommentService interface {
|
||||
CreateComment(ctx context.Context, clipID, userID int, content string) (*domain.ClipComment, error)
|
||||
GetClipComments(ctx context.Context, clipID, limit, offset int) ([]*domain.ClipComment, int, error)
|
||||
DeleteComment(ctx context.Context, commentID, userID int) error
|
||||
}
|
||||
|
||||
type commentService struct {
|
||||
commentRepo repository.CommentRepository
|
||||
clipRepo repository.ClipRepository
|
||||
}
|
||||
|
||||
func NewCommentService(commentRepo repository.CommentRepository, clipRepo repository.ClipRepository) CommentService {
|
||||
return &commentService{
|
||||
commentRepo: commentRepo,
|
||||
clipRepo: clipRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *commentService) CreateComment(ctx context.Context, clipID, userID int, content string) (*domain.ClipComment, error) {
|
||||
// Проверяем существование клипа
|
||||
if _, err := s.clipRepo.GetByID(ctx, clipID); err != nil {
|
||||
return nil, fmt.Errorf("clip not found: %w", err)
|
||||
}
|
||||
|
||||
// Валидация контента
|
||||
if content == "" {
|
||||
return nil, fmt.Errorf("comment content cannot be empty")
|
||||
}
|
||||
if len(content) > 1000 {
|
||||
return nil, fmt.Errorf("comment content too long")
|
||||
}
|
||||
|
||||
// Создаем комментарий
|
||||
comment := &domain.ClipComment{
|
||||
ClipID: clipID,
|
||||
AuthorID: userID,
|
||||
Content: content,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.commentRepo.Create(ctx, comment); err != nil {
|
||||
return nil, fmt.Errorf("failed to create comment: %w", err)
|
||||
}
|
||||
|
||||
// Увеличиваем счетчик комментариев
|
||||
if err := s.clipRepo.IncrementCommentsCount(ctx, clipID); err != nil {
|
||||
// Откатываем создание комментария при ошибке
|
||||
s.commentRepo.Delete(ctx, comment.ID)
|
||||
return nil, fmt.Errorf("failed to increment comments count: %w", err)
|
||||
}
|
||||
|
||||
return comment, nil
|
||||
}
|
||||
|
||||
func (s *commentService) GetClipComments(ctx context.Context, clipID, limit, offset int) ([]*domain.ClipComment, int, error) {
|
||||
// Проверяем существование клипа
|
||||
if _, err := s.clipRepo.GetByID(ctx, clipID); err != nil {
|
||||
return nil, 0, fmt.Errorf("clip not found: %w", err)
|
||||
}
|
||||
|
||||
comments, totalCount, err := s.commentRepo.GetByClipID(ctx, clipID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to get clip comments: %w", err)
|
||||
}
|
||||
|
||||
return comments, totalCount, nil
|
||||
}
|
||||
|
||||
func (s *commentService) DeleteComment(ctx context.Context, commentID, userID int) error {
|
||||
// Получаем комментарий для проверки прав
|
||||
comment, err := s.commentRepo.GetByID(ctx, commentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get comment: %w", err)
|
||||
}
|
||||
|
||||
// Проверяем права доступа
|
||||
if comment.AuthorID != userID {
|
||||
return fmt.Errorf("user %d is not the author of comment %d", userID, commentID)
|
||||
}
|
||||
|
||||
// Удаляем комментарий
|
||||
if err := s.commentRepo.Delete(ctx, commentID); err != nil {
|
||||
return fmt.Errorf("failed to delete comment: %w", err)
|
||||
}
|
||||
|
||||
// Уменьшаем счетчик комментариев
|
||||
if err := s.clipRepo.DecrementCommentsCount(ctx, comment.ClipID); err != nil {
|
||||
return fmt.Errorf("failed to decrement comments count: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
111
internal/service/like_service.go
Normal file
111
internal/service/like_service.go
Normal file
@ -0,0 +1,111 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"tailly_clips/internal/domain"
|
||||
"tailly_clips/internal/repository"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LikeService interface {
|
||||
LikeClip(ctx context.Context, clipID, userID int) (*domain.ClipLike, error)
|
||||
UnlikeClip(ctx context.Context, clipID, userID int) error
|
||||
GetClipLikes(ctx context.Context, clipID, limit, offset int) ([]*domain.ClipLike, int, error)
|
||||
CheckIfLiked(ctx context.Context, clipID, userID int) (bool, error)
|
||||
}
|
||||
|
||||
type likeService struct {
|
||||
likeRepo repository.LikeRepository
|
||||
clipRepo repository.ClipRepository
|
||||
}
|
||||
|
||||
func NewLikeService(likeRepo repository.LikeRepository, clipRepo repository.ClipRepository) LikeService {
|
||||
return &likeService{
|
||||
likeRepo: likeRepo,
|
||||
clipRepo: clipRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *likeService) LikeClip(ctx context.Context, clipID, userID int) (*domain.ClipLike, error) {
|
||||
// Проверяем существование клипа
|
||||
if _, err := s.clipRepo.GetByID(ctx, clipID); err != nil {
|
||||
return nil, fmt.Errorf("clip not found: %w", err)
|
||||
}
|
||||
|
||||
// Проверяем не лайкнул ли уже пользователь
|
||||
alreadyLiked, err := s.likeRepo.CheckExists(ctx, clipID, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check like existence: %w", err)
|
||||
}
|
||||
if alreadyLiked {
|
||||
return nil, fmt.Errorf("user already liked this clip")
|
||||
}
|
||||
|
||||
// Создаем лайк
|
||||
like := &domain.ClipLike{
|
||||
ClipID: clipID,
|
||||
UserID: userID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.likeRepo.Create(ctx, like); err != nil {
|
||||
return nil, fmt.Errorf("failed to create like: %w", err)
|
||||
}
|
||||
|
||||
// Увеличиваем счетчик лайков
|
||||
if err := s.clipRepo.IncrementLikesCount(ctx, clipID); err != nil {
|
||||
// Откатываем создание лайка при ошибке
|
||||
s.likeRepo.Delete(ctx, clipID, userID)
|
||||
return nil, fmt.Errorf("failed to increment likes count: %w", err)
|
||||
}
|
||||
|
||||
return like, nil
|
||||
}
|
||||
|
||||
func (s *likeService) UnlikeClip(ctx context.Context, clipID, userID int) error {
|
||||
// Проверяем существование клипа
|
||||
if _, err := s.clipRepo.GetByID(ctx, clipID); err != nil {
|
||||
return fmt.Errorf("clip not found: %w", err)
|
||||
}
|
||||
|
||||
// Удаляем лайк
|
||||
if err := s.likeRepo.Delete(ctx, clipID, userID); err != nil {
|
||||
return fmt.Errorf("failed to delete like: %w", err)
|
||||
}
|
||||
|
||||
// Уменьшаем счетчик лайков
|
||||
if err := s.clipRepo.DecrementLikesCount(ctx, clipID); err != nil {
|
||||
return fmt.Errorf("failed to decrement likes count: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *likeService) GetClipLikes(ctx context.Context, clipID, limit, offset int) ([]*domain.ClipLike, int, error) {
|
||||
// Проверяем существование клипа
|
||||
if _, err := s.clipRepo.GetByID(ctx, clipID); err != nil {
|
||||
return nil, 0, fmt.Errorf("clip not found: %w", err)
|
||||
}
|
||||
|
||||
likes, totalCount, err := s.likeRepo.GetByClipID(ctx, clipID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to get clip likes: %w", err)
|
||||
}
|
||||
|
||||
return likes, totalCount, nil
|
||||
}
|
||||
|
||||
func (s *likeService) CheckIfLiked(ctx context.Context, clipID, userID int) (bool, error) {
|
||||
// Проверяем существование клипа
|
||||
if _, err := s.clipRepo.GetByID(ctx, clipID); err != nil {
|
||||
return false, fmt.Errorf("clip not found: %w", err)
|
||||
}
|
||||
|
||||
isLiked, err := s.likeRepo.CheckExists(ctx, clipID, userID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check like status: %w", err)
|
||||
}
|
||||
|
||||
return isLiked, nil
|
||||
}
|
||||
240
internal/storage/s3_storage.go
Normal file
240
internal/storage/s3_storage.go
Normal file
@ -0,0 +1,240 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/aws/aws-sdk-go/service/s3/s3manager"
|
||||
)
|
||||
|
||||
type Storage interface {
|
||||
UploadVideo(ctx context.Context, videoData []byte, fileName string, userID int) (string, error)
|
||||
UploadThumbnail(ctx context.Context, thumbnailData []byte, userID int) (string, error)
|
||||
Delete(ctx context.Context, fileURL string) error
|
||||
DeleteVideo(ctx context.Context, videoURL string) error
|
||||
DeleteThumbnail(ctx context.Context, thumbnailURL string) error
|
||||
BulkDelete(ctx context.Context, fileURLs []string) error
|
||||
CheckObjectExists(ctx context.Context, fileURL string) (bool, error)
|
||||
}
|
||||
|
||||
type S3Config struct {
|
||||
Endpoint string
|
||||
Bucket string
|
||||
Region string
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
}
|
||||
|
||||
type s3Storage struct {
|
||||
uploader *s3manager.Uploader
|
||||
s3Client *s3.S3
|
||||
bucket string
|
||||
region string
|
||||
endpoint string
|
||||
}
|
||||
|
||||
// NewS3Storage создает S3 storage - теперь требует конфигурацию
|
||||
func NewS3Storage(config S3Config) Storage {
|
||||
sess := session.Must(session.NewSession(&aws.Config{
|
||||
Region: aws.String(config.Region),
|
||||
Endpoint: aws.String(config.Endpoint),
|
||||
S3ForcePathStyle: aws.Bool(true),
|
||||
Credentials: credentials.NewStaticCredentials(
|
||||
config.AccessKey,
|
||||
config.SecretKey,
|
||||
"",
|
||||
),
|
||||
}))
|
||||
|
||||
uploader := s3manager.NewUploader(sess)
|
||||
s3Client := s3.New(sess)
|
||||
|
||||
return &s3Storage{
|
||||
uploader: uploader,
|
||||
s3Client: s3Client,
|
||||
bucket: config.Bucket,
|
||||
region: config.Region,
|
||||
endpoint: config.Endpoint,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *s3Storage) UploadVideo(ctx context.Context, videoData []byte, fileName string, userID int) (string, error) {
|
||||
ext := strings.ToLower(filepath.Ext(fileName))
|
||||
timestamp := time.Now().UnixNano()
|
||||
s3Key := fmt.Sprintf("clips/%d/videos/%d%s", userID, timestamp, ext)
|
||||
|
||||
_, err := s.uploader.UploadWithContext(ctx, &s3manager.UploadInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(s3Key),
|
||||
Body: bytes.NewReader(videoData),
|
||||
ContentType: aws.String(s.getVideoContentType(ext)),
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to upload video to S3: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/%s/%s", s.endpoint, s.bucket, s3Key), nil
|
||||
}
|
||||
|
||||
func (s *s3Storage) UploadThumbnail(ctx context.Context, thumbnailData []byte, userID int) (string, error) {
|
||||
timestamp := time.Now().UnixNano()
|
||||
s3Key := fmt.Sprintf("clips/%d/thumbnails/%d.jpg", userID, timestamp)
|
||||
|
||||
_, err := s.uploader.Upload(&s3manager.UploadInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(s3Key),
|
||||
Body: aws.ReadSeekCloser(strings.NewReader(string(thumbnailData))),
|
||||
ContentType: aws.String("image/jpeg"),
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to upload thumbnail to S3: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/%s/%s", s.endpoint, s.bucket, s3Key), nil
|
||||
}
|
||||
|
||||
func (s *s3Storage) Delete(ctx context.Context, fileURL string) error {
|
||||
s3Key, err := s.extractS3Key(fileURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.deleteObject(s3Key)
|
||||
}
|
||||
|
||||
func (s *s3Storage) DeleteVideo(ctx context.Context, videoURL string) error {
|
||||
s3Key, err := s.extractS3Key(videoURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.deleteObject(s3Key)
|
||||
}
|
||||
|
||||
func (s *s3Storage) DeleteThumbnail(ctx context.Context, thumbnailURL string) error {
|
||||
s3Key, err := s.extractS3Key(thumbnailURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.deleteObject(s3Key)
|
||||
}
|
||||
|
||||
func (s *s3Storage) deleteObject(s3Key string) error {
|
||||
_, err := s.s3Client.DeleteObject(&s3.DeleteObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(s3Key),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete object from S3: %w", err)
|
||||
}
|
||||
|
||||
// Ждем пока объект действительно удалится
|
||||
err = s.s3Client.WaitUntilObjectNotExists(&s3.HeadObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(s3Key),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to wait for object deletion: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *s3Storage) extractS3Key(fileURL string) (string, error) {
|
||||
// Убираем протокол и endpoint
|
||||
cleanURL := strings.TrimPrefix(fileURL, s.endpoint+"/")
|
||||
|
||||
// Убираем bucket name
|
||||
parts := strings.SplitN(cleanURL, "/", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("invalid S3 URL format: %s", fileURL)
|
||||
}
|
||||
|
||||
if parts[0] != s.bucket {
|
||||
return "", fmt.Errorf("invalid bucket in URL: expected %s, got %s", s.bucket, parts[0])
|
||||
}
|
||||
|
||||
return parts[1], nil
|
||||
}
|
||||
|
||||
func (s *s3Storage) getVideoContentType(ext string) string {
|
||||
switch ext {
|
||||
case ".mp4":
|
||||
return "video/mp4"
|
||||
case ".webm":
|
||||
return "video/webm"
|
||||
case ".mov":
|
||||
return "video/quicktime"
|
||||
case ".avi":
|
||||
return "video/x-msvideo"
|
||||
case ".mkv":
|
||||
return "video/x-matroska"
|
||||
case ".flv":
|
||||
return "video/x-flv"
|
||||
case ".wmv":
|
||||
return "video/x-ms-wmv"
|
||||
default:
|
||||
return "video/mp4"
|
||||
}
|
||||
}
|
||||
|
||||
// BulkDelete удаляет несколько файлов
|
||||
func (s *s3Storage) BulkDelete(ctx context.Context, fileURLs []string) error {
|
||||
if len(fileURLs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var objects []*s3.ObjectIdentifier
|
||||
for _, url := range fileURLs {
|
||||
s3Key, err := s.extractS3Key(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
objects = append(objects, &s3.ObjectIdentifier{
|
||||
Key: aws.String(s3Key),
|
||||
})
|
||||
}
|
||||
|
||||
_, err := s.s3Client.DeleteObjects(&s3.DeleteObjectsInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Delete: &s3.Delete{
|
||||
Objects: objects,
|
||||
Quiet: aws.Bool(false),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete objects from S3: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckObjectExists проверяет существует ли объект в S3
|
||||
func (s *s3Storage) CheckObjectExists(ctx context.Context, fileURL string) (bool, error) {
|
||||
s3Key, err := s.extractS3Key(fileURL)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
_, err = s.s3Client.HeadObject(&s3.HeadObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(s3Key),
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "NotFound") {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("failed to check object existence: %w", err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
16
internal/utils/error_utils.go
Normal file
16
internal/utils/error_utils.go
Normal file
@ -0,0 +1,16 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func IsDuplicateKeyError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Для PostgreSQL
|
||||
return strings.Contains(err.Error(), "duplicate key") ||
|
||||
strings.Contains(err.Error(), "violates unique constraint")
|
||||
|
||||
}
|
||||
3
migrations/0001_initial_clips_schema.down.sql
Normal file
3
migrations/0001_initial_clips_schema.down.sql
Normal file
@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS clips CASCADE;
|
||||
DROP TABLE IF EXISTS clip_likes CASCADE;
|
||||
DROP TABLE IF EXISTS clip_comments CASCADE;
|
||||
43
migrations/0001_initial_clips_schema.up.sql
Normal file
43
migrations/0001_initial_clips_schema.up.sql
Normal file
@ -0,0 +1,43 @@
|
||||
-- Клипы
|
||||
CREATE TABLE clips (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
video_url TEXT NOT NULL,
|
||||
thumbnail_url TEXT NOT NULL,
|
||||
duration INT NOT NULL,
|
||||
author_id INTEGER NOT NULL,
|
||||
likes_count INTEGER DEFAULT 0,
|
||||
comments_count INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- Лайки клипов
|
||||
CREATE TABLE clip_likes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
clip_id INTEGER NOT NULL REFERENCES clips(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(clip_id, user_id)
|
||||
);
|
||||
|
||||
-- Комментарии к клипам
|
||||
CREATE TABLE clip_comments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
clip_id INTEGER NOT NULL REFERENCES clips(id) ON DELETE CASCADE,
|
||||
author_id INTEGER NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- Индексы для производительности
|
||||
CREATE INDEX idx_clips_author_id ON clips(author_id);
|
||||
CREATE INDEX idx_clip_likes_clip_id ON clip_likes(clip_id);
|
||||
CREATE INDEX idx_clip_likes_user_id ON clip_likes(user_id);
|
||||
CREATE INDEX idx_clip_comments_clip_id ON clip_comments(clip_id);
|
||||
CREATE INDEX idx_clip_comments_author_id ON clip_comments(author_id);
|
||||
CREATE INDEX idx_clips_deleted_at ON clips(deleted_at);
|
||||
CREATE INDEX idx_clip_comments_deleted_at ON clip_comments(deleted_at);
|
||||
1625
proto/clip.pb.go
Normal file
1625
proto/clip.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
163
proto/clip.proto
Normal file
163
proto/clip.proto
Normal file
@ -0,0 +1,163 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package proto;
|
||||
|
||||
option go_package = ".;proto";
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "google/protobuf/empty.proto";
|
||||
|
||||
service ClipService {
|
||||
// Клипы
|
||||
rpc CreateClip(CreateClipRequest) returns (CreateClipResponse);
|
||||
rpc GetClip(GetClipRequest) returns (GetClipResponse);
|
||||
rpc GetUserClips(GetUserClipsRequest) returns (GetUserClipsResponse);
|
||||
rpc GetClips(GetClipsRequest) returns (GetClipsResponse);
|
||||
rpc DeleteClip(DeleteClipRequest) returns (google.protobuf.Empty);
|
||||
|
||||
// Лайки
|
||||
rpc LikeClip(LikeClipRequest) returns (LikeClipResponse);
|
||||
rpc UnlikeClip(UnlikeClipRequest) returns (google.protobuf.Empty);
|
||||
rpc GetClipLikes(GetClipLikesRequest) returns (GetClipLikesResponse);
|
||||
rpc CheckIfLiked(CheckIfLikedRequest) returns (CheckIfLikedResponse);
|
||||
|
||||
// Комментарии
|
||||
rpc CreateComment(CreateCommentRequest) returns (CreateCommentResponse);
|
||||
rpc GetClipComments(GetClipCommentsRequest) returns (GetClipCommentsResponse);
|
||||
rpc DeleteComment(DeleteCommentRequest) returns (google.protobuf.Empty);
|
||||
}
|
||||
|
||||
message CreateClipRequest {
|
||||
int32 user_id = 1;
|
||||
string title = 2;
|
||||
bytes video_data = 3;
|
||||
string file_name = 4;
|
||||
string content_type = 5;
|
||||
}
|
||||
|
||||
message CreateClipResponse {
|
||||
Clip clip = 1;
|
||||
}
|
||||
|
||||
message GetClipRequest {
|
||||
int32 clip_id = 1;
|
||||
}
|
||||
|
||||
message GetClipResponse {
|
||||
Clip clip = 1;
|
||||
}
|
||||
|
||||
message GetUserClipsRequest {
|
||||
int32 user_id = 1;
|
||||
int32 limit = 2;
|
||||
int32 offset = 3;
|
||||
}
|
||||
|
||||
message GetUserClipsResponse {
|
||||
repeated Clip clips = 1;
|
||||
int32 total_count = 2;
|
||||
}
|
||||
|
||||
message GetClipsRequest {
|
||||
int32 limit = 1;
|
||||
int32 offset = 2;
|
||||
}
|
||||
|
||||
message GetClipsResponse {
|
||||
repeated Clip clips = 1;
|
||||
int32 total_count = 2;
|
||||
}
|
||||
|
||||
message DeleteClipRequest {
|
||||
int32 clip_id = 1;
|
||||
int32 user_id = 2;
|
||||
}
|
||||
|
||||
message LikeClipRequest {
|
||||
int32 clip_id = 1;
|
||||
int32 user_id = 2;
|
||||
}
|
||||
|
||||
message LikeClipResponse {
|
||||
ClipLike like = 1;
|
||||
}
|
||||
|
||||
message UnlikeClipRequest {
|
||||
int32 clip_id = 1;
|
||||
int32 user_id = 2;
|
||||
}
|
||||
|
||||
message GetClipLikesRequest {
|
||||
int32 clip_id = 1;
|
||||
int32 limit = 2;
|
||||
int32 offset = 3;
|
||||
}
|
||||
|
||||
message GetClipLikesResponse {
|
||||
repeated ClipLike likes = 1;
|
||||
int32 total_count = 2;
|
||||
}
|
||||
|
||||
message CheckIfLikedRequest {
|
||||
int32 clip_id = 1;
|
||||
int32 user_id = 2;
|
||||
}
|
||||
|
||||
message CheckIfLikedResponse {
|
||||
bool is_liked = 1;
|
||||
}
|
||||
|
||||
message CreateCommentRequest {
|
||||
int32 clip_id = 1;
|
||||
int32 user_id = 2;
|
||||
string content = 3;
|
||||
}
|
||||
|
||||
message CreateCommentResponse {
|
||||
ClipComment comment = 1;
|
||||
}
|
||||
|
||||
message GetClipCommentsRequest {
|
||||
int32 clip_id = 1;
|
||||
int32 limit = 2;
|
||||
int32 offset = 3;
|
||||
}
|
||||
|
||||
message GetClipCommentsResponse {
|
||||
repeated ClipComment comments = 1;
|
||||
int32 total_count = 2;
|
||||
}
|
||||
|
||||
message DeleteCommentRequest {
|
||||
int32 comment_id = 1;
|
||||
int32 user_id = 2;
|
||||
}
|
||||
|
||||
message Clip {
|
||||
int32 id = 1;
|
||||
string title = 2;
|
||||
string video_url = 3;
|
||||
string thumbnail_url = 4;
|
||||
int32 duration = 5;
|
||||
int32 author_id = 6;
|
||||
int32 likes_count = 7;
|
||||
int32 comments_count = 8;
|
||||
google.protobuf.Timestamp created_at = 9;
|
||||
google.protobuf.Timestamp updated_at = 10;
|
||||
}
|
||||
|
||||
message ClipLike {
|
||||
int32 id = 1;
|
||||
int32 clip_id = 2;
|
||||
int32 user_id = 3;
|
||||
google.protobuf.Timestamp created_at = 4;
|
||||
}
|
||||
|
||||
message ClipComment {
|
||||
int32 id = 1;
|
||||
int32 clip_id = 2;
|
||||
int32 author_id = 3;
|
||||
string content = 4;
|
||||
google.protobuf.Timestamp created_at = 5;
|
||||
google.protobuf.Timestamp updated_at = 6;
|
||||
}
|
||||
546
proto/clip_grpc.pb.go
Normal file
546
proto/clip_grpc.pb.go
Normal file
@ -0,0 +1,546 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc v3.21.12
|
||||
// source: clip.proto
|
||||
|
||||
package proto
|
||||
|
||||
import (
|
||||
context "context"
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
emptypb "google.golang.org/protobuf/types/known/emptypb"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the grpc package it is being compiled against.
|
||||
// Requires gRPC-Go v1.64.0 or later.
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
ClipService_CreateClip_FullMethodName = "/proto.ClipService/CreateClip"
|
||||
ClipService_GetClip_FullMethodName = "/proto.ClipService/GetClip"
|
||||
ClipService_GetUserClips_FullMethodName = "/proto.ClipService/GetUserClips"
|
||||
ClipService_GetClips_FullMethodName = "/proto.ClipService/GetClips"
|
||||
ClipService_DeleteClip_FullMethodName = "/proto.ClipService/DeleteClip"
|
||||
ClipService_LikeClip_FullMethodName = "/proto.ClipService/LikeClip"
|
||||
ClipService_UnlikeClip_FullMethodName = "/proto.ClipService/UnlikeClip"
|
||||
ClipService_GetClipLikes_FullMethodName = "/proto.ClipService/GetClipLikes"
|
||||
ClipService_CheckIfLiked_FullMethodName = "/proto.ClipService/CheckIfLiked"
|
||||
ClipService_CreateComment_FullMethodName = "/proto.ClipService/CreateComment"
|
||||
ClipService_GetClipComments_FullMethodName = "/proto.ClipService/GetClipComments"
|
||||
ClipService_DeleteComment_FullMethodName = "/proto.ClipService/DeleteComment"
|
||||
)
|
||||
|
||||
// ClipServiceClient is the client API for ClipService service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
type ClipServiceClient interface {
|
||||
// Клипы
|
||||
CreateClip(ctx context.Context, in *CreateClipRequest, opts ...grpc.CallOption) (*CreateClipResponse, error)
|
||||
GetClip(ctx context.Context, in *GetClipRequest, opts ...grpc.CallOption) (*GetClipResponse, error)
|
||||
GetUserClips(ctx context.Context, in *GetUserClipsRequest, opts ...grpc.CallOption) (*GetUserClipsResponse, error)
|
||||
GetClips(ctx context.Context, in *GetClipsRequest, opts ...grpc.CallOption) (*GetClipsResponse, error)
|
||||
DeleteClip(ctx context.Context, in *DeleteClipRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||
// Лайки
|
||||
LikeClip(ctx context.Context, in *LikeClipRequest, opts ...grpc.CallOption) (*LikeClipResponse, error)
|
||||
UnlikeClip(ctx context.Context, in *UnlikeClipRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||
GetClipLikes(ctx context.Context, in *GetClipLikesRequest, opts ...grpc.CallOption) (*GetClipLikesResponse, error)
|
||||
CheckIfLiked(ctx context.Context, in *CheckIfLikedRequest, opts ...grpc.CallOption) (*CheckIfLikedResponse, error)
|
||||
// Комментарии
|
||||
CreateComment(ctx context.Context, in *CreateCommentRequest, opts ...grpc.CallOption) (*CreateCommentResponse, error)
|
||||
GetClipComments(ctx context.Context, in *GetClipCommentsRequest, opts ...grpc.CallOption) (*GetClipCommentsResponse, error)
|
||||
DeleteComment(ctx context.Context, in *DeleteCommentRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||
}
|
||||
|
||||
type clipServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewClipServiceClient(cc grpc.ClientConnInterface) ClipServiceClient {
|
||||
return &clipServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *clipServiceClient) CreateClip(ctx context.Context, in *CreateClipRequest, opts ...grpc.CallOption) (*CreateClipResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CreateClipResponse)
|
||||
err := c.cc.Invoke(ctx, ClipService_CreateClip_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *clipServiceClient) GetClip(ctx context.Context, in *GetClipRequest, opts ...grpc.CallOption) (*GetClipResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetClipResponse)
|
||||
err := c.cc.Invoke(ctx, ClipService_GetClip_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *clipServiceClient) GetUserClips(ctx context.Context, in *GetUserClipsRequest, opts ...grpc.CallOption) (*GetUserClipsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetUserClipsResponse)
|
||||
err := c.cc.Invoke(ctx, ClipService_GetUserClips_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *clipServiceClient) GetClips(ctx context.Context, in *GetClipsRequest, opts ...grpc.CallOption) (*GetClipsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetClipsResponse)
|
||||
err := c.cc.Invoke(ctx, ClipService_GetClips_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *clipServiceClient) DeleteClip(ctx context.Context, in *DeleteClipRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(emptypb.Empty)
|
||||
err := c.cc.Invoke(ctx, ClipService_DeleteClip_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *clipServiceClient) LikeClip(ctx context.Context, in *LikeClipRequest, opts ...grpc.CallOption) (*LikeClipResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(LikeClipResponse)
|
||||
err := c.cc.Invoke(ctx, ClipService_LikeClip_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *clipServiceClient) UnlikeClip(ctx context.Context, in *UnlikeClipRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(emptypb.Empty)
|
||||
err := c.cc.Invoke(ctx, ClipService_UnlikeClip_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *clipServiceClient) GetClipLikes(ctx context.Context, in *GetClipLikesRequest, opts ...grpc.CallOption) (*GetClipLikesResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetClipLikesResponse)
|
||||
err := c.cc.Invoke(ctx, ClipService_GetClipLikes_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *clipServiceClient) CheckIfLiked(ctx context.Context, in *CheckIfLikedRequest, opts ...grpc.CallOption) (*CheckIfLikedResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CheckIfLikedResponse)
|
||||
err := c.cc.Invoke(ctx, ClipService_CheckIfLiked_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *clipServiceClient) CreateComment(ctx context.Context, in *CreateCommentRequest, opts ...grpc.CallOption) (*CreateCommentResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CreateCommentResponse)
|
||||
err := c.cc.Invoke(ctx, ClipService_CreateComment_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *clipServiceClient) GetClipComments(ctx context.Context, in *GetClipCommentsRequest, opts ...grpc.CallOption) (*GetClipCommentsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetClipCommentsResponse)
|
||||
err := c.cc.Invoke(ctx, ClipService_GetClipComments_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *clipServiceClient) DeleteComment(ctx context.Context, in *DeleteCommentRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(emptypb.Empty)
|
||||
err := c.cc.Invoke(ctx, ClipService_DeleteComment_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ClipServiceServer is the server API for ClipService service.
|
||||
// All implementations must embed UnimplementedClipServiceServer
|
||||
// for forward compatibility.
|
||||
type ClipServiceServer interface {
|
||||
// Клипы
|
||||
CreateClip(context.Context, *CreateClipRequest) (*CreateClipResponse, error)
|
||||
GetClip(context.Context, *GetClipRequest) (*GetClipResponse, error)
|
||||
GetUserClips(context.Context, *GetUserClipsRequest) (*GetUserClipsResponse, error)
|
||||
GetClips(context.Context, *GetClipsRequest) (*GetClipsResponse, error)
|
||||
DeleteClip(context.Context, *DeleteClipRequest) (*emptypb.Empty, error)
|
||||
// Лайки
|
||||
LikeClip(context.Context, *LikeClipRequest) (*LikeClipResponse, error)
|
||||
UnlikeClip(context.Context, *UnlikeClipRequest) (*emptypb.Empty, error)
|
||||
GetClipLikes(context.Context, *GetClipLikesRequest) (*GetClipLikesResponse, error)
|
||||
CheckIfLiked(context.Context, *CheckIfLikedRequest) (*CheckIfLikedResponse, error)
|
||||
// Комментарии
|
||||
CreateComment(context.Context, *CreateCommentRequest) (*CreateCommentResponse, error)
|
||||
GetClipComments(context.Context, *GetClipCommentsRequest) (*GetClipCommentsResponse, error)
|
||||
DeleteComment(context.Context, *DeleteCommentRequest) (*emptypb.Empty, error)
|
||||
mustEmbedUnimplementedClipServiceServer()
|
||||
}
|
||||
|
||||
// UnimplementedClipServiceServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedClipServiceServer struct{}
|
||||
|
||||
func (UnimplementedClipServiceServer) CreateClip(context.Context, *CreateClipRequest) (*CreateClipResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CreateClip not implemented")
|
||||
}
|
||||
func (UnimplementedClipServiceServer) GetClip(context.Context, *GetClipRequest) (*GetClipResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetClip not implemented")
|
||||
}
|
||||
func (UnimplementedClipServiceServer) GetUserClips(context.Context, *GetUserClipsRequest) (*GetUserClipsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetUserClips not implemented")
|
||||
}
|
||||
func (UnimplementedClipServiceServer) GetClips(context.Context, *GetClipsRequest) (*GetClipsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetClips not implemented")
|
||||
}
|
||||
func (UnimplementedClipServiceServer) DeleteClip(context.Context, *DeleteClipRequest) (*emptypb.Empty, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method DeleteClip not implemented")
|
||||
}
|
||||
func (UnimplementedClipServiceServer) LikeClip(context.Context, *LikeClipRequest) (*LikeClipResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method LikeClip not implemented")
|
||||
}
|
||||
func (UnimplementedClipServiceServer) UnlikeClip(context.Context, *UnlikeClipRequest) (*emptypb.Empty, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method UnlikeClip not implemented")
|
||||
}
|
||||
func (UnimplementedClipServiceServer) GetClipLikes(context.Context, *GetClipLikesRequest) (*GetClipLikesResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetClipLikes not implemented")
|
||||
}
|
||||
func (UnimplementedClipServiceServer) CheckIfLiked(context.Context, *CheckIfLikedRequest) (*CheckIfLikedResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CheckIfLiked not implemented")
|
||||
}
|
||||
func (UnimplementedClipServiceServer) CreateComment(context.Context, *CreateCommentRequest) (*CreateCommentResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CreateComment not implemented")
|
||||
}
|
||||
func (UnimplementedClipServiceServer) GetClipComments(context.Context, *GetClipCommentsRequest) (*GetClipCommentsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetClipComments not implemented")
|
||||
}
|
||||
func (UnimplementedClipServiceServer) DeleteComment(context.Context, *DeleteCommentRequest) (*emptypb.Empty, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method DeleteComment not implemented")
|
||||
}
|
||||
func (UnimplementedClipServiceServer) mustEmbedUnimplementedClipServiceServer() {}
|
||||
func (UnimplementedClipServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeClipServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to ClipServiceServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeClipServiceServer interface {
|
||||
mustEmbedUnimplementedClipServiceServer()
|
||||
}
|
||||
|
||||
func RegisterClipServiceServer(s grpc.ServiceRegistrar, srv ClipServiceServer) {
|
||||
// If the following call pancis, it indicates UnimplementedClipServiceServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&ClipService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _ClipService_CreateClip_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CreateClipRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ClipServiceServer).CreateClip(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ClipService_CreateClip_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ClipServiceServer).CreateClip(ctx, req.(*CreateClipRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _ClipService_GetClip_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetClipRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ClipServiceServer).GetClip(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ClipService_GetClip_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ClipServiceServer).GetClip(ctx, req.(*GetClipRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _ClipService_GetUserClips_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetUserClipsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ClipServiceServer).GetUserClips(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ClipService_GetUserClips_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ClipServiceServer).GetUserClips(ctx, req.(*GetUserClipsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _ClipService_GetClips_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetClipsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ClipServiceServer).GetClips(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ClipService_GetClips_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ClipServiceServer).GetClips(ctx, req.(*GetClipsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _ClipService_DeleteClip_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(DeleteClipRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ClipServiceServer).DeleteClip(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ClipService_DeleteClip_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ClipServiceServer).DeleteClip(ctx, req.(*DeleteClipRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _ClipService_LikeClip_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(LikeClipRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ClipServiceServer).LikeClip(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ClipService_LikeClip_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ClipServiceServer).LikeClip(ctx, req.(*LikeClipRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _ClipService_UnlikeClip_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(UnlikeClipRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ClipServiceServer).UnlikeClip(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ClipService_UnlikeClip_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ClipServiceServer).UnlikeClip(ctx, req.(*UnlikeClipRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _ClipService_GetClipLikes_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetClipLikesRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ClipServiceServer).GetClipLikes(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ClipService_GetClipLikes_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ClipServiceServer).GetClipLikes(ctx, req.(*GetClipLikesRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _ClipService_CheckIfLiked_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CheckIfLikedRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ClipServiceServer).CheckIfLiked(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ClipService_CheckIfLiked_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ClipServiceServer).CheckIfLiked(ctx, req.(*CheckIfLikedRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _ClipService_CreateComment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CreateCommentRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ClipServiceServer).CreateComment(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ClipService_CreateComment_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ClipServiceServer).CreateComment(ctx, req.(*CreateCommentRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _ClipService_GetClipComments_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetClipCommentsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ClipServiceServer).GetClipComments(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ClipService_GetClipComments_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ClipServiceServer).GetClipComments(ctx, req.(*GetClipCommentsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _ClipService_DeleteComment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(DeleteCommentRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ClipServiceServer).DeleteComment(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ClipService_DeleteComment_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ClipServiceServer).DeleteComment(ctx, req.(*DeleteCommentRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// ClipService_ServiceDesc is the grpc.ServiceDesc for ClipService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var ClipService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "proto.ClipService",
|
||||
HandlerType: (*ClipServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "CreateClip",
|
||||
Handler: _ClipService_CreateClip_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetClip",
|
||||
Handler: _ClipService_GetClip_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetUserClips",
|
||||
Handler: _ClipService_GetUserClips_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetClips",
|
||||
Handler: _ClipService_GetClips_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "DeleteClip",
|
||||
Handler: _ClipService_DeleteClip_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "LikeClip",
|
||||
Handler: _ClipService_LikeClip_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "UnlikeClip",
|
||||
Handler: _ClipService_UnlikeClip_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetClipLikes",
|
||||
Handler: _ClipService_GetClipLikes_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CheckIfLiked",
|
||||
Handler: _ClipService_CheckIfLiked_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CreateComment",
|
||||
Handler: _ClipService_CreateComment_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetClipComments",
|
||||
Handler: _ClipService_GetClipComments_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "DeleteComment",
|
||||
Handler: _ClipService_DeleteComment_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "clip.proto",
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user