diff --git a/cmd/shortener/main.go b/cmd/shortener/main.go index bc2747e..d143804 100644 --- a/cmd/shortener/main.go +++ b/cmd/shortener/main.go @@ -20,7 +20,9 @@ func main() { go func() { if err := application.Run(); err != nil { - log.Fatalf("Error running application.\n%s", err.Error()) + if err.Error() != "http: Server closed" { + log.Printf("Error running application: %s", err.Error()) + } } }() @@ -40,7 +42,6 @@ func main() { case <-shut: log.Print("Shutdown process is completed!") case <-ctx.Done(): - log.Print("Shutdown process is failed! Force shutdown") - os.Exit(1) + log.Print("Shutdown process timeout - exiting anyway") } } diff --git a/internal/app/app.go b/internal/app/app.go index 311e5be..f612c62 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -19,12 +19,14 @@ import ( "github.com/anon-d/urlshortener/internal/repository/local" "github.com/anon-d/urlshortener/internal/service" serviceCache "github.com/anon-d/urlshortener/internal/service/cache" + "github.com/anon-d/urlshortener/internal/worker" ) type App struct { - server *http.Server - router *gin.Engine - urlHandler *handler.URLHandler + server *http.Server + router *gin.Engine + urlHandler *handler.URLHandler + deleteWorker *worker.DeleteWorker } func New() (*App, error) { @@ -72,7 +74,27 @@ func New() (*App, error) { } svc := service.New(cacheService, storage, log) - urlHandler := handler.NewURLHandler(svc, cfg.AddrURL, log) + + // worker + deleteWorkerAdapter := &deleteStorageAdapter{storage: storage} + deleteWorker := worker.NewDeleteWorker( + deleteWorkerAdapter, + 100, // buffer size + 500*time.Millisecond, // flush interval + log, + ) + + // Создаем динамическое количество каналов на основе конфига + deleteChannels := make([]chan handler.DeleteRequest, cfg.DeleteWorkerCount) + for i := 0; i < cfg.DeleteWorkerCount; i++ { + deleteChannels[i] = make(chan handler.DeleteRequest, cfg.DeleteChannelSize) + workerChan := convertDeleteChannel(deleteChannels[i]) + deleteWorker.AddChannel(workerChan) + } + deleteWorker.Start() + + // канал для handler + urlHandler := handler.NewURLHandler(svc, cfg.AddrURL, log, deleteChannels[0]) // init Gin and http if cfg.Env == "release" { @@ -102,6 +124,7 @@ func New() (*App, error) { // middleware router.Use(middleware.GlobalMiddleware(log)...) + router.Use(middleware.AuthMiddleware(cfg.SecretKey)) router.HandleMethodNotAllowed = true @@ -111,12 +134,37 @@ func New() (*App, error) { } return &App{ - server: httpServer, - router: router, - urlHandler: urlHandler, + server: httpServer, + router: router, + urlHandler: urlHandler, + deleteWorker: deleteWorker, }, nil } +// deleteStorageAdapter адаптер для работы с Storage через интерфейс DeleteStorage +type deleteStorageAdapter struct { + storage repository.Storage +} + +func (d *deleteStorageAdapter) BatchMarkAsDeleted(ctx context.Context, requests []worker.DeleteRequest) error { + return d.storage.BatchMarkAsDeleted(ctx, requests) +} + +// handler.DeleteRequest -> worker.DeleteRequest +func convertDeleteChannel(in <-chan handler.DeleteRequest) <-chan worker.DeleteRequest { + out := make(chan worker.DeleteRequest) + go func() { + for req := range in { + out <- worker.DeleteRequest{ + UserID: req.UserID, + ShortURL: req.ShortURL, + } + } + close(out) + }() + return out +} + func (a *App) SetupRoutes() { maintenance := a.router.Group("/maintenance") { @@ -128,6 +176,8 @@ func (a *App) SetupRoutes() { a.router.POST("/api/shorten", a.urlHandler.Shorten) a.router.GET("/ping", a.urlHandler.PingDB) a.router.POST("/api/shorten/batch", a.urlHandler.BatchShorten) + a.router.GET("/api/user/urls", a.urlHandler.GetUserURLs) + a.router.DELETE("/api/user/urls", a.urlHandler.DeleteURLs) a.router.NoMethod(a.urlHandler.NotAllowed) a.router.NoRoute(a.urlHandler.NotFound) @@ -138,4 +188,10 @@ func (a *App) Run() error { } func (a *App) Shutdown(ctx context.Context) { + if a.deleteWorker != nil { + a.deleteWorker.Stop() + } + if err := a.server.Shutdown(ctx); err != nil { + fmt.Println("Failed shutdown. error: ", err) + } } diff --git a/internal/config/flag/config.go b/internal/config/flag/config.go index 616ddb6..3254e90 100644 --- a/internal/config/flag/config.go +++ b/internal/config/flag/config.go @@ -3,24 +3,31 @@ package config import ( "flag" "os" + "strconv" "sync" ) type ServerConfig struct { - AddrServer string `env:"SERVER_ADDRESS"` - AddrURL string `env:"BASE_URL"` - Env string `env:"ENV"` - File string `env:"FILE_STORAGE_PATH"` - DSN string `env:"DATABASE_DSN"` + AddrServer string `env:"SERVER_ADDRESS"` + AddrURL string `env:"BASE_URL"` + Env string `env:"ENV"` + File string `env:"FILE_STORAGE_PATH"` + DSN string `env:"DATABASE_DSN"` + DeleteWorkerCount int `env:"DELETE_WORKER_COUNT"` + DeleteChannelSize int `env:"DELETE_CHANNEL_SIZE"` + SecretKey string `env:"SECRET_KEY"` } var ( - addrServer *string - addrURL *string - envValue *string - fileValue *string - dsnValue *string - flagsOnce sync.Once + addrServer *string + addrURL *string + envValue *string + fileValue *string + dsnValue *string + deleteWorkerCount *int + deleteChannelSize *int + secretKey *string + flagsOnce sync.Once ) func initFlags() { @@ -29,6 +36,9 @@ func initFlags() { envValue = flag.String("e", "dev", "environment") fileValue = flag.String("f", "data.json", "file to store data") dsnValue = flag.String("d", "", "database DSN") + deleteWorkerCount = flag.Int("w", 2, "number of delete worker channels") + deleteChannelSize = flag.Int("c", 1000, "size of each delete channel buffer") + secretKey = flag.String("s", "my-super-secret-key-change-in-production", "secret key for signing cookies") } func NewServerConfig() *ServerConfig { @@ -70,5 +80,35 @@ func NewServerConfig() *ServerConfig { cfg.DSN = *dsnValue } + if envWorkerCount, ok := os.LookupEnv("DELETE_WORKER_COUNT"); ok { + if count, err := parseIntFromEnv(envWorkerCount); err == nil { + cfg.DeleteWorkerCount = count + } else { + cfg.DeleteWorkerCount = *deleteWorkerCount + } + } else { + cfg.DeleteWorkerCount = *deleteWorkerCount + } + + if envChannelSize, ok := os.LookupEnv("DELETE_CHANNEL_SIZE"); ok { + if size, err := parseIntFromEnv(envChannelSize); err == nil { + cfg.DeleteChannelSize = size + } else { + cfg.DeleteChannelSize = *deleteChannelSize + } + } else { + cfg.DeleteChannelSize = *deleteChannelSize + } + + if envSecretKey, ok := os.LookupEnv("SECRET_KEY"); ok { + cfg.SecretKey = envSecretKey + } else { + cfg.SecretKey = *secretKey + } + return cfg } + +func parseIntFromEnv(s string) (int, error) { + return strconv.Atoi(s) +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 738f2d8..addc2ab 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -29,17 +29,30 @@ type ItemBatchResponse struct { ShortURL string `json:"short_url"` } +type UserURLResponse struct { + ShortURL string `json:"short_url"` + OriginalURL string `json:"original_url"` +} + type URLHandler struct { - Service *service.Service - URLAddr string - logger *zap.SugaredLogger + Service *service.Service + URLAddr string + logger *zap.SugaredLogger + DeleteChannel chan<- DeleteRequest +} + +// DeleteRequest представляет запрос на удаление URL +type DeleteRequest struct { + UserID string + ShortURL string } -func NewURLHandler(service *service.Service, urlAddr string, logger *zap.SugaredLogger) *URLHandler { +func NewURLHandler(service *service.Service, urlAddr string, logger *zap.SugaredLogger, deleteChan chan<- DeleteRequest) *URLHandler { return &URLHandler{ - Service: service, - URLAddr: urlAddr, - logger: logger, + Service: service, + URLAddr: urlAddr, + logger: logger, + DeleteChannel: deleteChan, } } @@ -77,7 +90,9 @@ func (u *URLHandler) PostURL(c *gin.Context) { c.String(http.StatusBadRequest, "empty request body") return } - id, err := u.Service.ShortenURL(c, body) + + userID, _ := getUserID(c) + id, err := u.Service.ShortenURL(c, body, userID) if err != nil { var conflictErr *service.ConflictError if errors.As(err, &conflictErr) { @@ -108,22 +123,29 @@ func (u *URLHandler) PostURL(c *gin.Context) { // GetURL принимает запрос на получение оригинальной ссылки по корневому пути, // shortURL берется из параметра запроса (id). // Оригинальный URL возвращается в заголовке Location со статусом 307. +// Если URL помечен как удаленный, возвращается 410 Gone. func (u *URLHandler) GetURL(c *gin.Context) { id := c.Param("id") if id == "" { c.String(http.StatusBadRequest, "missing id parameter") return } - urlLong, err := u.Service.GetURL(c, id) + + // Получаем полные данные URL + data, err := u.Service.GetURLByShortURL(c, id) if err != nil { - if errors.Is(err, errors.New("not found")) { - c.String(http.StatusNotFound, err.Error()) - return - } + u.logger.Errorw("failed to get URL", "error", err, "short_url", id) c.String(http.StatusBadRequest, http.StatusText(http.StatusBadRequest)) return } - c.Redirect(http.StatusTemporaryRedirect, urlLong) + + // Проверка на is_deleted + if data.IsDeleted { + c.String(http.StatusGone, http.StatusText(http.StatusGone)) + return + } + + c.Redirect(http.StatusTemporaryRedirect, data.OriginalURL) } // Shorten принимает запрос на создание короткой ссылки по пути "/api/shorten", @@ -135,8 +157,10 @@ func (u *URLHandler) Shorten(c *gin.Context) { c.String(http.StatusBadRequest, http.StatusText(http.StatusBadRequest)) return } + + userID, _ := getUserID(c) targetURL := request.URL - id, err := u.Service.ShortenURL(c, []byte(targetURL)) + id, err := u.Service.ShortenURL(c, []byte(targetURL), userID) shortURL, joinErr := url.JoinPath(u.URLAddr, string(id)) if joinErr != nil { @@ -179,11 +203,13 @@ func (u *URLHandler) BatchShorten(c *gin.Context) { return } + userID, _ := getUserID(c) + batchURLsMap := make(map[string]string, len(request)) for _, item := range request { batchURLsMap[item.CorrelationID] = item.OriginalURL } - batchURLsMap, err := u.Service.ShortenBatchURL(c, batchURLsMap) + batchURLsMap, err := u.Service.ShortenBatchURL(c, batchURLsMap, userID) if err != nil { u.logger.Errorw("failed to shorten batch URLs", "error", err) c.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) @@ -225,3 +251,72 @@ func (u *URLHandler) PingDB(c *gin.Context) { } c.String(http.StatusOK, http.StatusText(http.StatusOK)) } + +// GetUserURLs возвращает все URL, созданные пользователем +func (u *URLHandler) GetUserURLs(c *gin.Context) { + userID, ok := requireUserID(c) + if !ok { + return + } + + urls, err := u.Service.GetURLsByUser(c, userID) + if err != nil { + u.logger.Errorw("failed to get user URLs", "error", err) + c.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) + return + } + + if len(urls) == 0 { + c.AbortWithStatus(http.StatusNoContent) + return + } + + response := make([]UserURLResponse, 0, len(urls)) + for _, item := range urls { + shortURL, err := url.JoinPath(u.URLAddr, item.ShortURL) + if err != nil { + u.logger.Errorw("failed to join URL path", "error", err) + c.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) + return + } + response = append(response, UserURLResponse{ + ShortURL: shortURL, + OriginalURL: item.OriginalURL, + }) + } + + c.JSON(http.StatusOK, response) +} + +// DeleteURLs принимает запрос на асинхронное удаление URL +func (u *URLHandler) DeleteURLs(c *gin.Context) { + userID, ok := requireUserID(c) + if !ok { + return + } + + // Парсим массив shortURL из тела запроса + var shortURLs []string + if err := c.ShouldBindJSON(&shortURLs); err != nil { + u.logger.Errorw("failed to parse delete request", "error", err) + c.String(http.StatusBadRequest, http.StatusText(http.StatusBadRequest)) + return + } + + if len(shortURLs) == 0 { + c.String(http.StatusBadRequest, "empty URL list") + return + } + + // Отправляем запросы на удаление в канал для асинхронной обработки + // Блокирующая отправка + for _, shortURL := range shortURLs { + req := DeleteRequest{ + UserID: userID, + ShortURL: shortURL, + } + u.DeleteChannel <- req + } + + c.Status(http.StatusAccepted) +} diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go index a588065..483001a 100644 --- a/internal/handler/handler_test.go +++ b/internal/handler/handler_test.go @@ -10,6 +10,7 @@ import ( "github.com/anon-d/urlshortener/internal/model" "github.com/anon-d/urlshortener/internal/service" + "github.com/anon-d/urlshortener/internal/worker" "github.com/gin-gonic/gin" "go.uber.org/zap" ) @@ -67,6 +68,51 @@ func (m *mockStorage) GetURLByOriginal(ctx context.Context, originalURL string) return "existing-short-url", nil } +func (m *mockStorage) UpdateBatch(ctx context.Context, urls []string) error { + if m.shouldFail { + return errors.New("update batch error") + } + return nil +} + +func (m *mockStorage) GetURLsByUser(ctx context.Context, userID string) ([]model.Data, error) { + if m.shouldFail { + return nil, errors.New("get urls by user error") + } + return []model.Data{ + { + ID: "abc123", + ShortURL: "abc123", + OriginalURL: "https://example1.com", + UserID: userID, + }, + { + ID: "def456", + ShortURL: "def456", + OriginalURL: "https://example2.com", + UserID: userID, + }, + }, nil +} + +func (m *mockStorage) GetURLByShortURL(ctx context.Context, shortURL string) (model.Data, error) { + if m.shouldFail { + return model.Data{}, errors.New("get url error") + } + return model.Data{ + ShortURL: shortURL, + OriginalURL: "https://example.com", + IsDeleted: false, + }, nil +} + +func (m *mockStorage) BatchMarkAsDeleted(ctx context.Context, requests []worker.DeleteRequest) error { + if m.shouldFail { + return errors.New("batch delete error") + } + return nil +} + func (m *mockStorage) Ping(ctx context.Context) error { if m.shouldFail { return errors.New("ping error") @@ -80,7 +126,8 @@ func TestPostURL_Success(t *testing.T) { cache := &mockCacheService{} svc := service.New(cache, nil, testLogger) - handler := NewURLHandler(svc, "http://localhost:8080", testLogger) + deleteChan := make(chan DeleteRequest, 10) + handler := NewURLHandler(svc, "http://localhost:8080", testLogger, deleteChan) gin.SetMode(gin.TestMode) w := httptest.NewRecorder() @@ -107,7 +154,8 @@ func TestPostURL_EmptyBody(t *testing.T) { cache := &mockCacheService{} svc := service.New(cache, nil, testLogger) - handler := NewURLHandler(svc, "http://localhost:8080", testLogger) + deleteChan := make(chan DeleteRequest, 10) + handler := NewURLHandler(svc, "http://localhost:8080", testLogger, deleteChan) gin.SetMode(gin.TestMode) w := httptest.NewRecorder() @@ -127,7 +175,8 @@ func TestPostURL_DiskError(t *testing.T) { cache := &mockCacheService{} svc := service.New(cache, &mockStorage{shouldFail: true}, testLogger) - handler := NewURLHandler(svc, "http://localhost:8080", testLogger) + deleteChan := make(chan DeleteRequest, 10) + handler := NewURLHandler(svc, "http://localhost:8080", testLogger, deleteChan) gin.SetMode(gin.TestMode) w := httptest.NewRecorder() @@ -150,7 +199,8 @@ func TestPostURL_WithDB_Success(t *testing.T) { cache := &mockCacheService{} svc := service.New(cache, &mockStorage{shouldFail: false}, testLogger) - handler := NewURLHandler(svc, "http://localhost:8080", testLogger) + deleteChan := make(chan DeleteRequest, 10) + handler := NewURLHandler(svc, "http://localhost:8080", testLogger, deleteChan) gin.SetMode(gin.TestMode) w := httptest.NewRecorder() @@ -172,7 +222,8 @@ func TestPostURL_WithDB_Error(t *testing.T) { cache := &mockCacheService{} svc := service.New(cache, &mockStorage{shouldFail: true}, testLogger) - handler := NewURLHandler(svc, "http://localhost:8080", testLogger) + deleteChan := make(chan DeleteRequest, 10) + handler := NewURLHandler(svc, "http://localhost:8080", testLogger, deleteChan) gin.SetMode(gin.TestMode) w := httptest.NewRecorder() @@ -199,7 +250,8 @@ func TestGetURL_Success(t *testing.T) { } svc := service.New(cache, nil, testLogger) - handler := NewURLHandler(svc, "http://localhost:8080", testLogger) + deleteChan := make(chan DeleteRequest, 10) + handler := NewURLHandler(svc, "http://localhost:8080", testLogger, deleteChan) gin.SetMode(gin.TestMode) w := httptest.NewRecorder() @@ -225,7 +277,8 @@ func TestGetURL_NotFound(t *testing.T) { cache := &mockCacheService{} svc := service.New(cache, nil, testLogger) - handler := NewURLHandler(svc, "http://localhost:8080", testLogger) + deleteChan := make(chan DeleteRequest, 10) + handler := NewURLHandler(svc, "http://localhost:8080", testLogger, deleteChan) gin.SetMode(gin.TestMode) w := httptest.NewRecorder() @@ -246,7 +299,8 @@ func TestGetURL_EmptyID(t *testing.T) { cache := &mockCacheService{} svc := service.New(cache, nil, testLogger) - handler := NewURLHandler(svc, "http://localhost:8080", testLogger) + deleteChan := make(chan DeleteRequest, 10) + handler := NewURLHandler(svc, "http://localhost:8080", testLogger, deleteChan) gin.SetMode(gin.TestMode) w := httptest.NewRecorder() @@ -267,7 +321,8 @@ func TestShorten_Success(t *testing.T) { cache := &mockCacheService{} svc := service.New(cache, nil, testLogger) - handler := NewURLHandler(svc, "http://localhost:8080", testLogger) + deleteChan := make(chan DeleteRequest, 10) + handler := NewURLHandler(svc, "http://localhost:8080", testLogger, deleteChan) gin.SetMode(gin.TestMode) w := httptest.NewRecorder() @@ -301,7 +356,8 @@ func TestShorten_InvalidJSON(t *testing.T) { cache := &mockCacheService{} svc := service.New(cache, nil, testLogger) - handler := NewURLHandler(svc, "http://localhost:8080", testLogger) + deleteChan := make(chan DeleteRequest, 10) + handler := NewURLHandler(svc, "http://localhost:8080", testLogger, deleteChan) gin.SetMode(gin.TestMode) w := httptest.NewRecorder() @@ -324,7 +380,8 @@ func TestShorten_MissingURL(t *testing.T) { cache := &mockCacheService{} svc := service.New(cache, nil, testLogger) - handler := NewURLHandler(svc, "http://localhost:8080", testLogger) + deleteChan := make(chan DeleteRequest, 10) + handler := NewURLHandler(svc, "http://localhost:8080", testLogger, deleteChan) gin.SetMode(gin.TestMode) w := httptest.NewRecorder() @@ -348,7 +405,8 @@ func TestPingDB_Success(t *testing.T) { cache := &mockCacheService{} svc := service.New(cache, &mockStorage{shouldFail: false}, testLogger) - handler := NewURLHandler(svc, "http://localhost:8080", testLogger) + deleteChan := make(chan DeleteRequest, 10) + handler := NewURLHandler(svc, "http://localhost:8080", testLogger, deleteChan) gin.SetMode(gin.TestMode) w := httptest.NewRecorder() @@ -369,7 +427,8 @@ func TestPingDB_Error(t *testing.T) { cache := &mockCacheService{} svc := service.New(cache, &mockStorage{shouldFail: false}, testLogger) - handler := NewURLHandler(svc, "http://localhost:8080", testLogger) + deleteChan := make(chan DeleteRequest, 10) + handler := NewURLHandler(svc, "http://localhost:8080", testLogger, deleteChan) gin.SetMode(gin.TestMode) w := httptest.NewRecorder() @@ -391,7 +450,8 @@ func TestPingDB_DBNotConnected(t *testing.T) { cache := &mockCacheService{} svc := service.New(cache, nil, testLogger) - handler := NewURLHandler(svc, "http://localhost:8080", testLogger) + deleteChan := make(chan DeleteRequest, 10) + handler := NewURLHandler(svc, "http://localhost:8080", testLogger, deleteChan) gin.SetMode(gin.TestMode) w := httptest.NewRecorder() @@ -413,7 +473,8 @@ func TestBatchShorten_Success(t *testing.T) { cache := &mockCacheService{} svc := service.New(cache, &mockStorage{shouldFail: false}, testLogger) - handler := NewURLHandler(svc, "http://localhost:8080", testLogger) + deleteChan := make(chan DeleteRequest, 10) + handler := NewURLHandler(svc, "http://localhost:8080", testLogger, deleteChan) gin.SetMode(gin.TestMode) w := httptest.NewRecorder() @@ -450,7 +511,8 @@ func TestBatchShorten_EmptyBatch(t *testing.T) { cache := &mockCacheService{} svc := service.New(cache, nil, testLogger) - handler := NewURLHandler(svc, "http://localhost:8080", testLogger) + deleteChan := make(chan DeleteRequest, 10) + handler := NewURLHandler(svc, "http://localhost:8080", testLogger, deleteChan) gin.SetMode(gin.TestMode) w := httptest.NewRecorder() @@ -474,7 +536,8 @@ func TestBatchShorten_InvalidJSON(t *testing.T) { cache := &mockCacheService{} svc := service.New(cache, nil, testLogger) - handler := NewURLHandler(svc, "http://localhost:8080", testLogger) + deleteChan := make(chan DeleteRequest, 10) + handler := NewURLHandler(svc, "http://localhost:8080", testLogger, deleteChan) gin.SetMode(gin.TestMode) w := httptest.NewRecorder() @@ -493,11 +556,12 @@ func TestBatchShorten_InvalidJSON(t *testing.T) { func TestBatchShorten_DBError(t *testing.T) { testLogger := zap.NewNop().Sugar() - + cache := &mockCacheService{} - + svc := service.New(cache, &mockStorage{shouldFail: false}, testLogger) - handler := NewURLHandler(svc, "http://localhost:8080", testLogger) + deleteChan := make(chan DeleteRequest, 10) + handler := NewURLHandler(svc, "http://localhost:8080", testLogger, deleteChan) gin.SetMode(gin.TestMode) w := httptest.NewRecorder() @@ -515,3 +579,171 @@ func TestBatchShorten_DBError(t *testing.T) { t.Errorf("expected status %d, got %d", http.StatusCreated, w.Code) } } + +// для хендлера с URL пользователя +func TestGetUserURLs_Success(t *testing.T) { + testLogger := zap.NewNop().Sugar() + + cache := &mockCacheService{} + + svc := service.New(cache, &mockStorage{shouldFail: false}, testLogger) + deleteChan := make(chan DeleteRequest, 10) + handler := NewURLHandler(svc, "http://localhost:8080", testLogger, deleteChan) + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + // Устанавливаем user_id в контекст + c.Set("user_id", "test-user-123") + c.Request = httptest.NewRequest(http.MethodGet, "/api/user/urls", nil) + + handler.GetUserURLs(c) + + if w.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, w.Code) + } + + respBody := w.Body.String() + if !strings.Contains(respBody, "short_url") { + t.Errorf("expected response to contain 'short_url' field, got %s", respBody) + } + if !strings.Contains(respBody, "original_url") { + t.Errorf("expected response to contain 'original_url' field, got %s", respBody) + } +} + +func TestGetUserURLs_NoUserID(t *testing.T) { + testLogger := zap.NewNop().Sugar() + + cache := &mockCacheService{} + + svc := service.New(cache, &mockStorage{shouldFail: false}, testLogger) + deleteChan := make(chan DeleteRequest, 10) + handler := NewURLHandler(svc, "http://localhost:8080", testLogger, deleteChan) + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + // без user_id + c.Request = httptest.NewRequest(http.MethodGet, "/api/user/urls", nil) + + handler.GetUserURLs(c) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected status %d, got %d", http.StatusUnauthorized, w.Code) + } +} + +func TestGetUserURLs_EmptyUserID(t *testing.T) { + testLogger := zap.NewNop().Sugar() + + cache := &mockCacheService{} + + svc := service.New(cache, &mockStorage{shouldFail: false}, testLogger) + deleteChan := make(chan DeleteRequest, 10) + handler := NewURLHandler(svc, "http://localhost:8080", testLogger, deleteChan) + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + // пустой user_id + c.Set("user_id", "") + c.Request = httptest.NewRequest(http.MethodGet, "/api/user/urls", nil) + + handler.GetUserURLs(c) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected status %d, got %d", http.StatusUnauthorized, w.Code) + } +} + +func TestGetUserURLs_NoContent(t *testing.T) { + testLogger := zap.NewNop().Sugar() + + cache := &mockCacheService{} + + // пстой список + emptyStorage := &mockStorageEmpty{} + + svc := service.New(cache, emptyStorage, testLogger) + deleteChan := make(chan DeleteRequest, 10) + handler := NewURLHandler(svc, "http://localhost:8080", testLogger, deleteChan) + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + c.Set("user_id", "test-user-123") + c.Request = httptest.NewRequest(http.MethodGet, "/api/user/urls", nil) + + handler.GetUserURLs(c) + + if w.Code != http.StatusNoContent { + t.Errorf("expected status %d, got %d", http.StatusNoContent, w.Code) + } +} + +func TestGetUserURLs_StorageError(t *testing.T) { + testLogger := zap.NewNop().Sugar() + + cache := &mockCacheService{} + + svc := service.New(cache, &mockStorage{shouldFail: true}, testLogger) + deleteChan := make(chan DeleteRequest, 10) + handler := NewURLHandler(svc, "http://localhost:8080", testLogger, deleteChan) + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + c.Set("user_id", "test-user-123") + c.Request = httptest.NewRequest(http.MethodGet, "/api/user/urls", nil) + + handler.GetUserURLs(c) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code) + } +} + +// мок для тестирования пустого списка URL +type mockStorageEmpty struct{} + +func (m *mockStorageEmpty) Insert(ctx context.Context, data model.Data) error { + return nil +} + +func (m *mockStorageEmpty) InsertBatch(ctx context.Context, dataList []model.Data) error { + return nil +} + +func (m *mockStorageEmpty) Select(ctx context.Context) ([]model.Data, error) { + return []model.Data{}, nil +} + +func (m *mockStorageEmpty) GetURLByOriginal(ctx context.Context, originalURL string) (string, error) { + return "", errors.New("not found") +} + +func (m *mockStorageEmpty) GetURLsByUser(ctx context.Context, userID string) ([]model.Data, error) { + return []model.Data{}, nil +} + +func (m *mockStorageEmpty) GetURLByShortURL(ctx context.Context, shortURL string) (model.Data, error) { + return model.Data{}, errors.New("not found") +} + +func (m *mockStorageEmpty) BatchMarkAsDeleted(ctx context.Context, requests []worker.DeleteRequest) error { + return nil +} + +func (m *mockStorageEmpty) UpdateBatch(ctx context.Context, urls []string) error { + return nil +} + +func (m *mockStorageEmpty) Ping(ctx context.Context) error { + return nil +} diff --git a/internal/handler/helpers.go b/internal/handler/helpers.go new file mode 100644 index 0000000..c726bf5 --- /dev/null +++ b/internal/handler/helpers.go @@ -0,0 +1,34 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// getUserID извлекает user_id из контекста Gin +// Возвращает userID и true если успешно, пустую строку и false если нет +func getUserID(c *gin.Context) (string, bool) { + userID, exists := c.Get("user_id") + if !exists { + return "", false + } + + userIDStr, ok := userID.(string) + if !ok || userIDStr == "" { + return "", false + } + + return userIDStr, true +} + +// requireUserID проверяет наличие user_id в контексте и возвращает его +// Если user_id отсутствует, отправляет 401 +func requireUserID(c *gin.Context) (string, bool) { + userID, ok := getUserID(c) + if !ok { + c.String(http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized)) + return "", false + } + return userID, true +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index f52e631..2cbfaac 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -2,6 +2,10 @@ package middleware import ( "compress/gzip" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" "net/http" "strings" "time" @@ -20,6 +24,7 @@ func GlobalMiddleware(logger *zap.SugaredLogger) []gin.HandlerFunc { } } +// отлов паники func PanicMiddleware(logger *zap.SugaredLogger) gin.HandlerFunc { return func(c *gin.Context) { defer func() { @@ -35,6 +40,7 @@ func PanicMiddleware(logger *zap.SugaredLogger) gin.HandlerFunc { } } +// служебки func RequestMiddleware(logger *zap.SugaredLogger) gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() @@ -88,6 +94,7 @@ func (g *gzipResponseWriter) Write(b []byte) (int, error) { return g.ResponseWriter.Write(b) } +// для сжатия func CompressionResponse() gin.HandlerFunc { return func(c *gin.Context) { acceptEncoding := c.Request.Header.Get("Accept-Encoding") @@ -113,6 +120,7 @@ func CompressionResponse() gin.HandlerFunc { } } +// распаковка func DecompressionRequest() gin.HandlerFunc { return func(c *gin.Context) { encodingType := c.Request.Header.Get("Content-Encoding") @@ -133,3 +141,71 @@ func DecompressionRequest() gin.HandlerFunc { c.Next() } } + +const ( + UserIDCookieName = "user_id" // куки + UserIDContextKey = "user_id" // ключ контекста +) + +// AuthMiddleware проверяет наличие подписанной куки с user_id. +// Если куки нет или подпись недействительна, создается новый user_id и устанавливается подписанная кука. +func AuthMiddleware(secretKey string) gin.HandlerFunc { + return func(c *gin.Context) { + var userID string + + // получаем, проверяем + cookie, err := c.Cookie(UserIDCookieName) + if err == nil && cookie != "" { + if validUserID, valid := validateSignedValue(cookie, secretKey); valid { + userID = validUserID + } + } + + // если нет, то генерим + if userID == "" { + userID = generateUserID() + signedValue := signValue(userID, secretKey) + c.SetCookie(UserIDCookieName, signedValue, 3600*24*365, "/", "", false, true) + } + // в контекст + c.Set(UserIDContextKey, userID) + c.Next() + } +} + +// generateUserID генерирует уникальный идентификатор пользователя +func generateUserID() string { + b := make([]byte, 16) + rand.Read(b) + return base64.URLEncoding.EncodeToString(b) +} + +// signValue подписывает значение с помощью HMAC-SHA256 +func signValue(value string, secretKey string) string { + h := hmac.New(sha256.New, []byte(secretKey)) + h.Write([]byte(value)) + signature := base64.URLEncoding.EncodeToString(h.Sum(nil)) + return value + "." + signature +} + +// validateSignedValue проверяет подпись и возвращает оригинальное значение +func validateSignedValue(signedValue string, secretKey string) (string, bool) { + parts := strings.Split(signedValue, ".") + if len(parts) != 2 { + return "", false + } + + value := parts[0] + providedSignature := parts[1] + + // Вычисляем ожидаемую подпись + h := hmac.New(sha256.New, []byte(secretKey)) + h.Write([]byte(value)) + expectedSignature := base64.URLEncoding.EncodeToString(h.Sum(nil)) + + if hmac.Equal([]byte(expectedSignature), []byte(providedSignature)) { + return value, true + } + + return "", false +} diff --git a/internal/model/data.go b/internal/model/data.go index 70d7311..e6f7a94 100644 --- a/internal/model/data.go +++ b/internal/model/data.go @@ -4,6 +4,8 @@ type Data struct { ID string `json:"uuid"` ShortURL string `json:"short_url"` OriginalURL string `json:"original_url"` + UserID string `json:"user_id,omitempty"` + IsDeleted bool `json:"is_deleted,omitempty"` } func NewData(id, short, original string) Data { diff --git a/internal/repository/db/postgres/postgres.go b/internal/repository/db/postgres/postgres.go index b281dbe..2449b3f 100644 --- a/internal/repository/db/postgres/postgres.go +++ b/internal/repository/db/postgres/postgres.go @@ -14,6 +14,7 @@ import ( "go.uber.org/zap" "github.com/anon-d/urlshortener/internal/repository" + "github.com/anon-d/urlshortener/internal/worker" "github.com/anon-d/urlshortener/migrations" ) @@ -64,9 +65,9 @@ func (r *Repository) migrate(ctx context.Context) error { return nil } -func (r *Repository) InsertURL(ctx context.Context, id, shortURL, originalURL string) error { - query := "INSERT INTO urls (id, short_url, original_url) VALUES ($1, $2, $3)" - _, err := r.db.ExecContext(ctx, query, id, shortURL, originalURL) +func (r *Repository) InsertURL(ctx context.Context, id, shortURL, originalURL, userID string) error { + query := "INSERT INTO urls (id, short_url, original_url, user_id) VALUES ($1, $2, $3, $4)" + _, err := r.db.ExecContext(ctx, query, id, shortURL, originalURL, userID) if err != nil { if isUniqueViolation(err) { return fmt.Errorf("failed to insert URL (id=%s) in InsertURL: %w", id, ErrUniqueViolation) @@ -101,7 +102,7 @@ func isUniqueViolation(err error) bool { } func (r *Repository) GetURLs(ctx context.Context) ([]repository.Data, error) { - query := "SELECT * FROM urls" + query := "SELECT id, short_url, original_url, COALESCE(user_id, ''), COALESCE(is_deleted, false) FROM urls" data := make([]repository.Data, 0) rows, err := r.db.QueryContext(ctx, query) if err != nil { @@ -114,11 +115,12 @@ func (r *Repository) GetURLs(ctx context.Context) ([]repository.Data, error) { defer rows.Close() for rows.Next() { - var id, shortURL, originalURL string - if err := rows.Scan(&id, &shortURL, &originalURL); err != nil { + var id, shortURL, originalURL, userID string + var isDeleted bool + if err := rows.Scan(&id, &shortURL, &originalURL, &userID, &isDeleted); err != nil { return data, fmt.Errorf("failed to scan row in GetURLs: %w", err) } - data = append(data, repository.Data{ID: id, ShortURL: shortURL, OriginalURL: originalURL}) + data = append(data, repository.Data{ID: id, ShortURL: shortURL, OriginalURL: originalURL, UserID: userID, IsDeleted: isDeleted}) } if err := rows.Err(); err != nil { @@ -143,16 +145,16 @@ func (r *Repository) InsertURLsBatch(ctx context.Context, data []repository.Data pgConn := driverConn.(*stdlib.Conn).Conn() batch := &pgx.Batch{} - query := "INSERT INTO urls (id, short_url, original_url) VALUES ($1, $2, $3)" + query := "INSERT INTO urls (id, short_url, original_url, user_id) VALUES ($1, $2, $3, $4)" for _, item := range data { - batch.Queue(query, item.ID, item.ShortURL, item.OriginalURL) + batch.Queue(query, item.ID, item.ShortURL, item.OriginalURL, item.UserID) } br := pgConn.SendBatch(ctx, batch) defer br.Close() // Process results - for i := 0; i < len(data); i++ { + for i := 0; i < len(data); i++ { // можно делать for idx, _ := range data {}, но так понятнее _, err := br.Exec() if err != nil { if isUniqueViolation(err) { @@ -170,3 +172,84 @@ func (r *Repository) InsertURLsBatch(ctx context.Context, data []repository.Data } return nil } + +// GetURLsByUser возвращает все URL, созданные определенным пользователем +func (r *Repository) GetURLsByUser(ctx context.Context, userID string) ([]repository.Data, error) { + query := "SELECT id, short_url, original_url, user_id, COALESCE(is_deleted, false) FROM urls WHERE user_id = $1" + data := make([]repository.Data, 0) + rows, err := r.db.QueryContext(ctx, query, userID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return data, nil + } + return data, fmt.Errorf("failed to query URLs by user in GetURLsByUser: %w", err) + } + defer rows.Close() + + for rows.Next() { + var id, shortURL, originalURL, uid string + var isDeleted bool + if err := rows.Scan(&id, &shortURL, &originalURL, &uid, &isDeleted); err != nil { + return data, fmt.Errorf("failed to scan row in GetURLsByUser: %w", err) + } + data = append(data, repository.Data{ID: id, ShortURL: shortURL, OriginalURL: originalURL, UserID: uid, IsDeleted: isDeleted}) + } + + if err := rows.Err(); err != nil { + return data, fmt.Errorf("rows iteration error in GetURLsByUser: %w", err) + } + + return data, nil +} + +// BatchMarkAsDeleted помечает URLs как удаленные (batch update) +func (r *Repository) BatchMarkAsDeleted(ctx context.Context, requests []worker.DeleteRequest) error { + if len(requests) == 0 { + return nil + } + + urlsByUser := make(map[string][]string) + for _, req := range requests { + urlsByUser[req.UserID] = append(urlsByUser[req.UserID], req.ShortURL) + } + + for userID, urls := range urlsByUser { + query := ` + UPDATE urls + SET is_deleted = true + WHERE user_id = $1 AND short_url = ANY($2) + ` + _, err := r.db.ExecContext(ctx, query, userID, urls) + if err != nil { + return fmt.Errorf("failed to batch mark as deleted for user %s: %w", userID, err) + } + } + + return nil +} + +// GetURLByShortURL получает URL по короткой ссылке +func (r *Repository) GetURLByShortURL(ctx context.Context, shortURL string) (repository.Data, error) { + query := "SELECT id, short_url, original_url, COALESCE(user_id, ''), COALESCE(is_deleted, false) FROM urls WHERE short_url = $1" + var data repository.Data + var id, short, original, userID string + var isDeleted bool + + err := r.db.QueryRowContext(ctx, query, shortURL).Scan(&id, &short, &original, &userID, &isDeleted) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return data, repository.ErrNotFound + } + return data, fmt.Errorf("failed to get URL by short URL: %w", err) + } + + data = repository.Data{ + ID: id, + ShortURL: short, + OriginalURL: original, + UserID: userID, + IsDeleted: isDeleted, + } + + return data, nil +} diff --git a/internal/repository/models.go b/internal/repository/models.go index 6104dee..7c6873b 100644 --- a/internal/repository/models.go +++ b/internal/repository/models.go @@ -8,4 +8,6 @@ type Data struct { ID string OriginalURL string ShortURL string + UserID string + IsDeleted bool } diff --git a/internal/repository/storage.go b/internal/repository/storage.go index f7cbe01..c4a5a65 100644 --- a/internal/repository/storage.go +++ b/internal/repository/storage.go @@ -4,6 +4,7 @@ import ( "context" "github.com/anon-d/urlshortener/internal/model" + "github.com/anon-d/urlshortener/internal/worker" ) // Storage единый интерфейс для работы с хранилищем (БД или локальное) @@ -12,6 +13,9 @@ type Storage interface { InsertBatch(ctx context.Context, dataList []model.Data) error Select(ctx context.Context) ([]model.Data, error) GetURLByOriginal(ctx context.Context, originalURL string) (string, error) + GetURLsByUser(ctx context.Context, userID string) ([]model.Data, error) + GetURLByShortURL(ctx context.Context, shortURL string) (model.Data, error) + BatchMarkAsDeleted(ctx context.Context, requests []worker.DeleteRequest) error Ping(ctx context.Context) error } @@ -25,7 +29,7 @@ func NewDBAdapter(db DB) *DBAdapter { } func (d *DBAdapter) Insert(ctx context.Context, data model.Data) error { - return d.db.InsertURL(ctx, data.ID, data.ShortURL, data.OriginalURL) + return d.db.InsertURL(ctx, data.ID, data.ShortURL, data.OriginalURL, data.UserID) } func (d *DBAdapter) InsertBatch(ctx context.Context, dataList []model.Data) error { @@ -35,6 +39,7 @@ func (d *DBAdapter) InsertBatch(ctx context.Context, dataList []model.Data) erro ID: item.ID, ShortURL: item.ShortURL, OriginalURL: item.OriginalURL, + UserID: item.UserID, } } return d.db.InsertURLsBatch(ctx, data) @@ -51,6 +56,8 @@ func (d *DBAdapter) Select(ctx context.Context) ([]model.Data, error) { ID: item.ID, ShortURL: item.ShortURL, OriginalURL: item.OriginalURL, + UserID: item.UserID, + IsDeleted: item.IsDeleted, } } return result, nil @@ -60,6 +67,42 @@ func (d *DBAdapter) GetURLByOriginal(ctx context.Context, originalURL string) (s return d.db.GetURLByOriginal(ctx, originalURL) } +func (d *DBAdapter) GetURLsByUser(ctx context.Context, userID string) ([]model.Data, error) { + data, err := d.db.GetURLsByUser(ctx, userID) + if err != nil { + return nil, err + } + result := make([]model.Data, len(data)) + for i, item := range data { + result[i] = model.Data{ + ID: item.ID, + ShortURL: item.ShortURL, + OriginalURL: item.OriginalURL, + UserID: item.UserID, + IsDeleted: item.IsDeleted, + } + } + return result, nil +} + +func (d *DBAdapter) GetURLByShortURL(ctx context.Context, shortURL string) (model.Data, error) { + data, err := d.db.GetURLByShortURL(ctx, shortURL) + if err != nil { + return model.Data{}, err + } + return model.Data{ + ID: data.ID, + ShortURL: data.ShortURL, + OriginalURL: data.OriginalURL, + UserID: data.UserID, + IsDeleted: data.IsDeleted, + }, nil +} + +func (d *DBAdapter) BatchMarkAsDeleted(ctx context.Context, requests []worker.DeleteRequest) error { + return d.db.BatchMarkAsDeleted(ctx, requests) +} + func (d *DBAdapter) Ping(ctx context.Context) error { return d.db.Ping(ctx) } @@ -104,15 +147,68 @@ func (l *LocalAdapter) GetURLByOriginal(ctx context.Context, originalURL string) return "", ErrNotFound } +func (l *LocalAdapter) GetURLsByUser(ctx context.Context, userID string) ([]model.Data, error) { + data, err := l.local.Load() + if err != nil { + return nil, err + } + result := make([]model.Data, 0) + for _, item := range data { + if item.UserID == userID { + result = append(result, item) + } + } + return result, nil +} + +func (l *LocalAdapter) GetURLByShortURL(ctx context.Context, shortURL string) (model.Data, error) { + data, err := l.local.Load() + if err != nil { + return model.Data{}, err + } + for _, item := range data { + if item.ShortURL == shortURL { + return item, nil + } + } + return model.Data{}, ErrNotFound +} + +func (l *LocalAdapter) BatchMarkAsDeleted(ctx context.Context, requests []worker.DeleteRequest) error { + // Для локального хранилища помечаем URL как удаленные + data, err := l.local.Load() + if err != nil { + return err + } + + // Создаем мапу для быстрого поиска + deleteMap := make(map[string]string) + for _, req := range requests { + deleteMap[req.ShortURL] = req.UserID + } + + // Обновляем флаг is_deleted + for i := range data { + if userID, exists := deleteMap[data[i].ShortURL]; exists && data[i].UserID == userID { + data[i].IsDeleted = true + } + } + + return l.local.Save(data) +} + func (l *LocalAdapter) Ping(ctx context.Context) error { return nil } type DB interface { - InsertURL(ctx context.Context, id, shortURL, originalURL string) error + InsertURL(ctx context.Context, id, shortURL, originalURL, userID string) error InsertURLsBatch(ctx context.Context, data []Data) error GetURLs(ctx context.Context) ([]Data, error) GetURLByOriginal(ctx context.Context, originalURL string) (string, error) + GetURLsByUser(ctx context.Context, userID string) ([]Data, error) + GetURLByShortURL(ctx context.Context, shortURL string) (Data, error) + BatchMarkAsDeleted(ctx context.Context, requests []worker.DeleteRequest) error Ping(ctx context.Context) error } diff --git a/internal/service/cache/service.go b/internal/service/cache/service.go index 402a7f4..10dd4fd 100644 --- a/internal/service/cache/service.go +++ b/internal/service/cache/service.go @@ -37,7 +37,11 @@ func toFileData(cache map[string]any) []model.Data { id := 1 data := make([]model.Data, 0, len(cache)) for shortURL, originalURL := range cache { - data = append(data, model.NewData(strconv.Itoa(id), shortURL, originalURL.(string))) + originalURLStr, ok := originalURL.(string) + if !ok { + continue + } + data = append(data, model.NewData(strconv.Itoa(id), shortURL, originalURLStr)) id++ } return data diff --git a/internal/service/service.go b/internal/service/service.go index 3bc2966..f9ce323 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -43,12 +43,14 @@ func New(cache CacheService, storage repository.Storage, logger *zap.SugaredLogg } } -func (s *Service) ShortenURL(ctx context.Context, longURL []byte) ([]byte, error) { +func (s *Service) ShortenURL(ctx context.Context, longURL []byte, userID string) ([]byte, error) { urlID := generateID() + data := model.Data{ ID: urlID, ShortURL: urlID, OriginalURL: string(longURL), + UserID: userID, } s.Cache.Set(&data) @@ -77,10 +79,53 @@ func (s *Service) GetURL(ctx context.Context, shortURL string) (string, error) { if !exists { return "", errors.New("URL not found") } - return originURL.(string), nil + originURLStr, ok := originURL.(string) + if !ok { + return "", errors.New("invalid URL data in cache") + } + return originURLStr, nil +} + +// GetURLsByUser возвращает все URL, созданные пользователем +func (s *Service) GetURLsByUser(ctx context.Context, userID string) ([]model.Data, error) { + if s.Storage == nil { + return nil, errors.New("storage is not available") + } + return s.Storage.GetURLsByUser(ctx, userID) +} + +// GetURLByShortURL получает полные данные URL по короткой ссылке +func (s *Service) GetURLByShortURL(ctx context.Context, shortURL string) (model.Data, error) { + // Сначала проверяем кэш + originURL, exists := s.Cache.Get(shortURL) + if exists { + // Если есть в кэше, проверяем в storage для is_deleted + if s.Storage != nil { + data, err := s.Storage.GetURLByShortURL(ctx, shortURL) + if err == nil { + return data, nil + } + } + // Если storage недоступен, возвращаем из кэша + originURLStr, ok := originURL.(string) + if !ok { + return model.Data{}, errors.New("invalid URL data in cache") + } + return model.Data{ + ShortURL: shortURL, + OriginalURL: originURLStr, + }, nil + } + + // Если нет в кэше, проверяем storage + if s.Storage != nil { + return s.Storage.GetURLByShortURL(ctx, shortURL) + } + + return model.Data{}, errors.New("URL not found") } -func (s *Service) ShortenBatchURL(ctx context.Context, dataMap map[string]string) (map[string]string, error) { +func (s *Service) ShortenBatchURL(ctx context.Context, dataMap map[string]string, userID string) (map[string]string, error) { dataMapResult := make(map[string]string, len(dataMap)) dataList := make([]model.Data, 0, len(dataMap)) for key, value := range dataMap { @@ -89,6 +134,7 @@ func (s *Service) ShortenBatchURL(ctx context.Context, dataMap map[string]string ID: urlID, ShortURL: urlID, OriginalURL: value, + UserID: userID, } s.Cache.Set(&data) dataList = append(dataList, data) diff --git a/internal/worker/delete_worker.go b/internal/worker/delete_worker.go new file mode 100644 index 0000000..c96b50e --- /dev/null +++ b/internal/worker/delete_worker.go @@ -0,0 +1,154 @@ +package worker + +import ( + "context" + "sync" + "time" + + "go.uber.org/zap" +) + +// DeleteRequest представляет запрос на удаление URL +type DeleteRequest struct { + UserID string + ShortURL string +} + +// DeleteStorage интерфейс для batch удаления +type DeleteStorage interface { + BatchMarkAsDeleted(ctx context.Context, requests []DeleteRequest) error +} + +// DeleteWorker обрабатывает запросы на удаление с буферизацией +type DeleteWorker struct { + storage DeleteStorage + inputChannels []<-chan DeleteRequest + bufferSize int + flushInterval time.Duration + logger *zap.SugaredLogger + ctx context.Context + cancel context.CancelFunc +} + +// NewDeleteWorker создает новый worker для удаления +func NewDeleteWorker( + storage DeleteStorage, + bufferSize int, + flushInterval time.Duration, + logger *zap.SugaredLogger, +) *DeleteWorker { + ctx, cancel := context.WithCancel(context.Background()) + return &DeleteWorker{ + storage: storage, + inputChannels: make([]<-chan DeleteRequest, 0), + bufferSize: bufferSize, + flushInterval: flushInterval, + logger: logger, + ctx: ctx, + cancel: cancel, + } +} + +// AddChannel добавляет новый канал для обработки +func (w *DeleteWorker) AddChannel(ch <-chan DeleteRequest) { + w.inputChannels = append(w.inputChannels, ch) +} + +func (w *DeleteWorker) Start() { + mergedChan := w.fanIn(w.inputChannels...) + + go w.processDeletes(mergedChan) +} + +// fanIn объединяет несколько каналов в один +func (w *DeleteWorker) fanIn(channels ...<-chan DeleteRequest) <-chan DeleteRequest { + out := make(chan DeleteRequest, w.bufferSize) + var wg sync.WaitGroup + + for _, ch := range channels { + wg.Add(1) + go func(c <-chan DeleteRequest) { + defer wg.Done() + for { + select { + case <-w.ctx.Done(): + return + case req, ok := <-c: + if !ok { + return + } + select { + case out <- req: + case <-w.ctx.Done(): + return + } + } + } + }(ch) + } + + // Закрытие выходного канала после завершения всех входных + go func() { + wg.Wait() + close(out) + }() + + return out +} + +// processDeletes обрабатывает запросы с буферизацией +func (w *DeleteWorker) processDeletes(input <-chan DeleteRequest) { + buffer := make([]DeleteRequest, 0, w.bufferSize) + ticker := time.NewTicker(w.flushInterval) + defer ticker.Stop() + + for { + select { + case <-w.ctx.Done(): + if len(buffer) > 0 { + w.flush(buffer) + } + return + + case req, ok := <-input: + if !ok { + if len(buffer) > 0 { + w.flush(buffer) + } + return + } + + buffer = append(buffer, req) + + // Если буфер заполнен, сразу отправляем в БД + if len(buffer) >= w.bufferSize { + w.flush(buffer) + buffer = buffer[:0] + } + + case <-ticker.C: + // По таймеру отправляем накопленное + if len(buffer) > 0 { + w.flush(buffer) + buffer = buffer[:0] + } + } + } +} + +// flush выполняет batch update в БД +func (w *DeleteWorker) flush(requests []DeleteRequest) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := w.storage.BatchMarkAsDeleted(ctx, requests); err != nil { + w.logger.Errorw("failed to batch delete URLs", "error", err, "count", len(requests)) + return + } + + w.logger.Infow("batch deleted URLs", "count", len(requests)) +} + +func (w *DeleteWorker) Stop() { + w.cancel() +} diff --git a/migrations/00002_add_user_id.sql b/migrations/00002_add_user_id.sql new file mode 100644 index 0000000..f66c614 --- /dev/null +++ b/migrations/00002_add_user_id.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TABLE urls ADD COLUMN user_id VARCHAR(255); + +-- +goose Down +ALTER TABLE urls DROP COLUMN user_id; diff --git a/migrations/00003_add_is_deleted.sql b/migrations/00003_add_is_deleted.sql new file mode 100644 index 0000000..e152d49 --- /dev/null +++ b/migrations/00003_add_is_deleted.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TABLE urls ADD COLUMN is_deleted BOOLEAN DEFAULT FALSE NOT NULL; + +-- +goose Down +ALTER TABLE urls DROP COLUMN is_deleted; diff --git a/shortener b/shortener deleted file mode 100755 index 2cf68db..0000000 Binary files a/shortener and /dev/null differ