diff --git a/build.gradle b/build.gradle index f952978..8255f90 100644 --- a/build.gradle +++ b/build.gradle @@ -44,6 +44,7 @@ dependencies { // S3 / MinIO implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3' implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.minio:minio:8.6.0' // JWT implementation 'io.jsonwebtoken:jjwt-api:0.12.5' diff --git a/docker-compose.yaml b/docker-compose.yaml index 11491d7..515a399 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,5 +1,3 @@ -version: '3.8' - services: postgres: image: postgres:15 @@ -16,20 +14,23 @@ services: - "6379:6379" minio: - # Используемый образ Docker. Рекомендуется указывать конкретный тег для стабильности. - image: minio/minio:RELEASE.2024-04-18T19-09-19Z + image: quay.io/minio/minio:RELEASE.2024-04-18T19-09-19Z container_name: minio-example command: server /data --address ":9010" --console-address ":9011" restart: unless-stopped environment: MINIO_ACCESS_KEY: admin MINIO_SECRET_KEY: admin123 - # Проброс портов с хост-машины в контейнер. ports: - "9010:9010" # API port - "9011:9011" # Console port volumes: - minio-data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9010/minio/health/live"] + interval: 5s + timeout: 3s + retries: 10 deploy: resources: limits: @@ -39,5 +40,23 @@ services: options: max-size: "10m" max-file: "5" + + mc-init: + image: quay.io/minio/mc:latest + depends_on: + minio: + condition: service_healthy + volumes: + - ./minio/cors.json:/cors.json:ro + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9010 admin admin123 && + mc mb --ignore-existing local/ads-media && + mc mb --ignore-existing local/refound-images && + mc cors set local/ads-media /cors.json && + echo 'MinIO buckets and CORS configured successfully' + " + restart: "no" + volumes: minio-data: \ No newline at end of file diff --git a/minio/cors.json b/minio/cors.json new file mode 100644 index 0000000..7aadfc2 --- /dev/null +++ b/minio/cors.json @@ -0,0 +1,10 @@ +{ + "CORSRules": [ + { + "AllowedOrigins": ["http://localhost:5173", "http://localhost:3000"], + "AllowedMethods": ["PUT", "GET"], + "AllowedHeaders": ["*"], + "MaxAgeSeconds": 3000 + } + ] +} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/config/AsyncConfig.java b/src/main/java/com/krylov/refound/config/AsyncConfig.java new file mode 100644 index 0000000..031d701 --- /dev/null +++ b/src/main/java/com/krylov/refound/config/AsyncConfig.java @@ -0,0 +1,25 @@ +package com.krylov.refound.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +public class AsyncConfig { + + @Bean(name = "ffmpegExecutor") + public Executor ffmpegExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + // FFmpeg сам многопоточный внутри — не нужно много параллельных процессов + executor.setCorePoolSize(2); + executor.setMaxPoolSize(2); + executor.setQueueCapacity(20); + executor.setThreadNamePrefix("ffmpeg-"); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/com/krylov/refound/config/S3ClientConfig.java b/src/main/java/com/krylov/refound/config/S3ClientConfig.java index 4926e34..c698b70 100644 --- a/src/main/java/com/krylov/refound/config/S3ClientConfig.java +++ b/src/main/java/com/krylov/refound/config/S3ClientConfig.java @@ -10,10 +10,12 @@ import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.S3Configuration; import java.net.URI; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; @Configuration @RequiredArgsConstructor public class S3ClientConfig { + private final MinioProperties properties; @Bean @@ -21,12 +23,29 @@ public class S3ClientConfig { return S3Client.builder() .endpointOverride(URI.create(properties.endpoint())) .region(Region.of(properties.region())) - .credentialsProvider(StaticCredentialsProvider.create( - AwsBasicCredentials.create(properties.accessKey(), properties.secretKey()))) - // ВАЖНО для MinIO: path-style вместо virtual-hosted style - .serviceConfiguration(S3Configuration.builder() - .pathStyleAccessEnabled(true) - .build()) + .credentialsProvider(credentialsProvider()) + .serviceConfiguration(pathStyleConfiguration()) + .build(); + } + + @Bean + public S3Presigner s3Presigner() { + return S3Presigner.builder() + .endpointOverride(URI.create(properties.endpoint())) + .region(Region.of(properties.region())) + .credentialsProvider(credentialsProvider()) + .serviceConfiguration(pathStyleConfiguration()) + .build(); + } + + private StaticCredentialsProvider credentialsProvider() { + return StaticCredentialsProvider.create( + AwsBasicCredentials.create(properties.accessKey(), properties.secretKey())); + } + + private S3Configuration pathStyleConfiguration() { + return S3Configuration.builder() + .pathStyleAccessEnabled(true) .build(); } diff --git a/src/main/java/com/krylov/refound/controller/AuthController.java b/src/main/java/com/krylov/refound/controller/AuthController.java index 3ba5568..f03ab09 100644 --- a/src/main/java/com/krylov/refound/controller/AuthController.java +++ b/src/main/java/com/krylov/refound/controller/AuthController.java @@ -5,7 +5,7 @@ import com.krylov.refound.dto.AuthResponse; import com.krylov.refound.dto.CheckLoginDto; import com.krylov.refound.dto.RefreshRequest; import com.krylov.refound.dto.RegisterRequest; -import com.krylov.refound.dto.UserRegisterDto; +import com.krylov.refound.dto.user.UserRegisterDto; import com.krylov.refound.service.AuthService; import java.util.Map; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/krylov/refound/controller/ChatController.java b/src/main/java/com/krylov/refound/controller/ChatController.java index 966c1c6..30d85d9 100644 --- a/src/main/java/com/krylov/refound/controller/ChatController.java +++ b/src/main/java/com/krylov/refound/controller/ChatController.java @@ -1,8 +1,10 @@ package com.krylov.refound.controller; -import com.krylov.refound.dto.*; +import com.krylov.refound.dto.ChatDto; +import com.krylov.refound.dto.MessageDto; import com.krylov.refound.service.ChatService; import com.krylov.refound.service.UserService; +import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -10,9 +12,13 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.bind.annotation.*; -import java.util.List; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; @Slf4j @RestController @@ -61,10 +67,13 @@ public class ChatController { chatService.markAsRead(chatId, currentUserId); } - private Long extractUserId(Authentication auth) { - // Подставь свою логику получения userId из JWT/Authentication - String login = auth.getName(); - log.debug("extractUserId by login {}", login); + private Long extractUserId(Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated() || "anonymousUser".equals(authentication.getPrincipal())) { + return null; + } + + String login = authentication.getName(); + log.info("extractUserId by login {}", login); return userService.getUserIdByLogin(login); } diff --git a/src/main/java/com/krylov/refound/controller/UserController.java b/src/main/java/com/krylov/refound/controller/UserController.java index 6414415..8ec8c14 100644 --- a/src/main/java/com/krylov/refound/controller/UserController.java +++ b/src/main/java/com/krylov/refound/controller/UserController.java @@ -1,7 +1,7 @@ package com.krylov.refound.controller; -import com.krylov.refound.dto.UserResponseDto; -import com.krylov.refound.dto.UserUpdateDto; +import com.krylov.refound.dto.user.UserResponseDto; +import com.krylov.refound.dto.user.UserUpdateDto; import com.krylov.refound.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/krylov/refound/controller/advertising/AdvertisingAdminController.java b/src/main/java/com/krylov/refound/controller/advertising/AdvertisingAdminController.java new file mode 100644 index 0000000..7ce8705 --- /dev/null +++ b/src/main/java/com/krylov/refound/controller/advertising/AdvertisingAdminController.java @@ -0,0 +1,160 @@ +package com.krylov.refound.controller.advertising; + +import com.krylov.refound.dto.advertising.AdImageUploadResponseDto; +import com.krylov.refound.dto.advertising.AdvertisingAdminResponseDto; +import com.krylov.refound.dto.advertising.AdvertisingCreateRequestDto; +import com.krylov.refound.dto.advertising.AdvertisingUpdateRequestDto; +import com.krylov.refound.dto.advertising.ConfirmUploadRequestDto; +import com.krylov.refound.dto.advertising.PresignUploadRequestDto; +import com.krylov.refound.dto.advertising.PresignUploadResponseDto; +import com.krylov.refound.dto.advertising.TranscodingStatusDto; +import com.krylov.refound.dto.advertising.VideoUploadConfirmedEvent; +import com.krylov.refound.entity.advertising.Advertising; +import com.krylov.refound.entity.advertising.Campaign; +import com.krylov.refound.enums.advertising.AdvertisingType; +import com.krylov.refound.repository.advertising.AdvertisingRepository; +import com.krylov.refound.repository.advertising.CampaignRepository; +import com.krylov.refound.service.advertising.AdvertisingAdminService; +import com.krylov.refound.service.advertising.AdvertisingMediaService; +import jakarta.persistence.EntityNotFoundException; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@RestController +@RequestMapping("/api/v1/admin/ads") +@RequiredArgsConstructor +public class AdvertisingAdminController { + + private static final int PRESIGN_URL_EXPIRY_MINUTES = 30; + + private final AdvertisingRepository advertisingRepository; + private final AdvertisingMediaService mediaService; + private final ApplicationEventPublisher eventPublisher; + private final CampaignRepository campaignRepository; + private final AdvertisingAdminService advertisingAdminService; + + /** + * Шаг 1: фронтенд запрашивает ссылку для прямой загрузки в MinIO. + * Файл ещё не передан — только резервируем ключ и отдаём подписанный URL. + */ + @PostMapping("/{advertisingId}/video/presign-upload") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity presignUpload(@PathVariable Long advertisingId, @RequestBody PresignUploadRequestDto request) { + log.info("presignUpload advertisingId {}", advertisingId); + if (!advertisingRepository.existsById(advertisingId)) { + log.warn("Advertising not found: {}", advertisingId); + throw new EntityNotFoundException("Advertising not found: " + advertisingId); + } + boolean allowedContentType = advertisingAdminService.isAllowedContentType(request.contentType()); + if (!allowedContentType) { + log.warn("allowedContentType for advertisingId {}", advertisingId); + return ResponseEntity.badRequest().build(); + } + + String sourceKey = advertisingAdminService.getSourceKey(advertisingId, request); + String uploadUrl = mediaService.getPresignedUploadUrl(sourceKey, request.contentType()); + return ResponseEntity.ok(new PresignUploadResponseDto(uploadUrl, sourceKey, PRESIGN_URL_EXPIRY_MINUTES)); + } + + /** + * Шаг 2: фронтенд сам сделал PUT на presigned URL, теперь сообщает, + * что файл загружен — только после этого бэкенд узнаёт про файл и + * запускает транскодинг. + */ + @PostMapping("/{advertisingId}/video/confirm-upload") + @PreAuthorize("hasRole('ADMIN')") + @Transactional + public ResponseEntity confirmUpload(@PathVariable Long advertisingId, @RequestBody ConfirmUploadRequestDto request) { + log.info("confirmUpload for advertisingId {}", advertisingId); + Advertising advertising = advertisingRepository.findById(advertisingId) + .orElseThrow(() -> new EntityNotFoundException("Advertising not found: " + advertisingId)); + + advertisingAdminService.updateTranscodingStatus(advertising.getId()); + eventPublisher.publishEvent(new VideoUploadConfirmedEvent(advertisingId, request.sourceKey())); + return ResponseEntity.status(HttpStatus.ACCEPTED).build(); + } + + @GetMapping + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> getAdvertisingsByCampaign(@RequestParam Long campaignId) { + log.info("getAdvertisingsByCampaign {}", campaignId); + List advertisings = advertisingAdminService.getAdvertisingsByCampaign(campaignId); + return ResponseEntity.ok(advertisings); + } + + @GetMapping("/{advertisingId}/status") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity getStatus(@PathVariable Long advertisingId) { + log.info("getStatus for advertising {}", advertisingId); + Advertising advertising = advertisingRepository.findById(advertisingId) + .orElseThrow(() -> new EntityNotFoundException("Advertising not found: " + advertisingId)); + + return ResponseEntity.ok(new TranscodingStatusDto(advertising.getId(), advertising.getTranscodingStatus())); + } + + /** + * PUT /api/v1/admin/ads/{advertisingId} — обновление полей объявления + * (targetUrl, weight). Тип/медиа/статус транскодинга не меняются. + * Ответ 200: AdvertisingAdminResponseDto. 400 — невалидные данные. + */ + @PutMapping("/{advertisingId}") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity updateAdvertising( + @PathVariable Long advertisingId, + @Valid @RequestBody AdvertisingUpdateRequestDto request) { + log.info("updateAdvertising {}", advertisingId); + if (request.weight() <= 0) { + return ResponseEntity.badRequest().build(); + } + AdvertisingAdminResponseDto dto = advertisingAdminService.updateAdvertising(advertisingId, request); + return ResponseEntity.ok(dto); + } + + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity createAdvertising(@Valid @RequestBody AdvertisingCreateRequestDto request) { + log.info("createAdvertising {}", request); + Campaign campaign = campaignRepository.findById(request.campaignId()) + .orElseThrow(() -> new EntityNotFoundException("Campaign not found: " + request.campaignId())); + + if (request.weight() <= 0) { + log.warn("weight AdvertisingCreateRequestDto {} <= 0", request); + return ResponseEntity.badRequest().build(); + } + + Advertising advertising = advertisingAdminService.createAdvertising(campaign, request); + if (request.type() == AdvertisingType.IMAGE && (request.mediaKey() == null || request.mediaKey().isBlank())) { + log.warn("advertising image has bad mediaKey() or mediaKey isBlank"); + return ResponseEntity.badRequest().build(); + } + + AdvertisingAdminResponseDto adminDto = advertisingAdminService.toAdminDto(advertising); + return ResponseEntity.status(HttpStatus.CREATED).body(adminDto); + } + + @PostMapping(value = "/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity uploadAdImage(@RequestParam("file") MultipartFile file) { + String mediaKey = mediaService.uploadImage(file); + return ResponseEntity.ok(new AdImageUploadResponseDto(mediaKey)); + } + +} diff --git a/src/main/java/com/krylov/refound/controller/advertising/AdvertisingController.java b/src/main/java/com/krylov/refound/controller/advertising/AdvertisingController.java new file mode 100644 index 0000000..7228e8d --- /dev/null +++ b/src/main/java/com/krylov/refound/controller/advertising/AdvertisingController.java @@ -0,0 +1,67 @@ +package com.krylov.refound.controller.advertising; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.krylov.refound.dto.advertising.AdvertisingResponseDto; +import com.krylov.refound.dto.advertising.ClickRequestDto; +import com.krylov.refound.dto.advertising.ImpressionRequestDto; +import com.krylov.refound.dto.advertising.SelectedAdvertisingDto; +import com.krylov.refound.service.advertising.AdvertisingClickService; +import com.krylov.refound.service.advertising.AdvertisingImpressionService; +import com.krylov.refound.service.advertising.AdvertisingMediaService; +import com.krylov.refound.service.advertising.AdvertisingSelectionService; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor +public class AdvertisingController { + + private final AdvertisingSelectionService advertisingSelectionService; + private final AdvertisingMediaService advertisingMediaService; + private final AdvertisingImpressionService advertisingImpressionService; + private final AdvertisingClickService advertisingClickService; + private final ObjectMapper objectMapper; + + @GetMapping("/api/ads/next") + public ResponseEntity getNextAdvertising(Authentication authentication, @RequestHeader(value = "X-Session-Id", required = false) String sessionId, HttpServletRequest request) { + log.info("getNextAdvertising"); + Optional selected = advertisingSelectionService.selectAdvertising(authentication, sessionId, request); + if (selected.isEmpty()) { + log.warn("failed get advertising"); + return ResponseEntity.noContent().build(); + } + + SelectedAdvertisingDto advertising = selected.get(); + String mediaUrl = advertisingMediaService.getMediaUrl(advertising.mediaKey()); + + return ResponseEntity.ok(new AdvertisingResponseDto(advertising.advertisingId(), advertising.type(), mediaUrl, advertising.targetUrl())); + } + + @PostMapping("/api/ads/impression") + public ResponseEntity registerImpression(@RequestBody String body, Authentication authentication) throws JsonProcessingException { + log.info("registerImpression for body {}", body); + ImpressionRequestDto request = objectMapper.readValue(body, ImpressionRequestDto.class); + advertisingImpressionService.recordImpression(request.advertisingId(), authentication, request.sessionId()); + return ResponseEntity.accepted().build(); + } + + @PostMapping("/api/ads/click") + public ResponseEntity registerClick(@RequestBody ClickRequestDto request, Authentication authentication) { + log.info("registerClick"); + advertisingClickService.recordClick(request.advertisingId(), authentication, request.sessionId()); + return ResponseEntity.accepted().build(); + } + +} diff --git a/src/main/java/com/krylov/refound/controller/advertising/AdvertisingMediaController.java b/src/main/java/com/krylov/refound/controller/advertising/AdvertisingMediaController.java new file mode 100644 index 0000000..2743e48 --- /dev/null +++ b/src/main/java/com/krylov/refound/controller/advertising/AdvertisingMediaController.java @@ -0,0 +1,31 @@ +package com.krylov.refound.controller.advertising; + +import com.krylov.refound.service.advertising.AdvertisingMediaService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Публичный прокси-эндпоинт медиа рекламы. Ключи объектов содержат слэши + * (например "images/.jpg"), поэтому используем wildcard {*key}, + * как в FileStorageController для картинок постов. + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/ads-media") +@RequiredArgsConstructor +public class AdvertisingMediaController { + + private final AdvertisingMediaService mediaService; + + @GetMapping("/{*key}") + public ResponseEntity getMedia(@PathVariable String key) { + String mediaKey = key.startsWith("/") ? key.substring(1) : key; + log.info("GET ads-media: key={} -> mediaKey={}", key, mediaKey); + return mediaService.downloadMedia(mediaKey); + } +} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/controller/advertising/CampaignAdminController.java b/src/main/java/com/krylov/refound/controller/advertising/CampaignAdminController.java new file mode 100644 index 0000000..f71709e --- /dev/null +++ b/src/main/java/com/krylov/refound/controller/advertising/CampaignAdminController.java @@ -0,0 +1,82 @@ +package com.krylov.refound.controller.advertising; + +import com.krylov.refound.dto.advertising.CampaignCreateRequestDto; +import com.krylov.refound.dto.advertising.CampaignResponseDto; +import com.krylov.refound.dto.advertising.CampaignStatusUpdateRequestDto; +import com.krylov.refound.dto.advertising.CampaignUpdateRequestDto; +import com.krylov.refound.service.advertising.CampaignService; +import jakarta.persistence.EntityNotFoundException; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequestMapping("/api/v1/admin/campaigns") +@RequiredArgsConstructor +public class CampaignAdminController { + + private final CampaignService campaignService; + + @GetMapping + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> getAllCampaigns() { + List campaigns = campaignService.getCampaign(); + return ResponseEntity .ok(campaigns); + } + + @GetMapping("/{campaignId}") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity getCampaignById(@PathVariable Long campaignId) { + log.info("getCampaignById {}", campaignId); + CampaignResponseDto campaign = campaignService.getCampaignById(campaignId) + .orElseThrow(() -> new EntityNotFoundException("Campaign not found: " + campaignId)); + return ResponseEntity.ok(campaign); + } + + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity createCampaign(@Valid @RequestBody CampaignCreateRequestDto request) { + log.info("createCampaign {}", request); + if (!request.startDate().isBefore(request.endDate())) { + return ResponseEntity.badRequest().build(); + } + if (request.dailyImpressionCap() <= 0) { + return ResponseEntity.badRequest().build(); + } + + CampaignResponseDto campaign = campaignService.createCampaign(request); + return ResponseEntity.status(HttpStatus.CREATED).body(campaign); + } + + @PutMapping("/{campaignId}") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity updateCampaign(@PathVariable Long campaignId, @Valid @RequestBody CampaignUpdateRequestDto request) { + log.info("updateCampaign {}", campaignId); + CampaignResponseDto campaign = campaignService.updateCampaign(campaignId, request); + return ResponseEntity.ok(campaign); + } + + @PatchMapping("/{campaignId}/status") + @PreAuthorize("hasRole('ADMIN')") + @Transactional + public ResponseEntity updateStatus(@PathVariable Long campaignId, @Valid @RequestBody CampaignStatusUpdateRequestDto request) { + log.info("updateStatus company {}", campaignId); + CampaignResponseDto campaign = campaignService.updateCampaignStatus(campaignId, request); + return ResponseEntity.ok(campaign); + } + +} diff --git a/src/main/java/com/krylov/refound/controller/advertising/FullscreenAdAdminController.java b/src/main/java/com/krylov/refound/controller/advertising/FullscreenAdAdminController.java new file mode 100644 index 0000000..619654f --- /dev/null +++ b/src/main/java/com/krylov/refound/controller/advertising/FullscreenAdAdminController.java @@ -0,0 +1,143 @@ +package com.krylov.refound.controller.advertising; + +import com.krylov.refound.dto.advertising.*; +import com.krylov.refound.entity.advertising.FullscreenAdvertising; +import com.krylov.refound.enums.advertising.FullscreenAdStatus; +import com.krylov.refound.enums.advertising.TranscodingStatus; +import com.krylov.refound.repository.advertising.FullscreenAdvertisingRepository; +import com.krylov.refound.service.advertising.AdvertisingMediaService; +import jakarta.persistence.EntityNotFoundException; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@Slf4j +@RestController +@RequestMapping("/api/v1/admin/fullscreen-ads") +@RequiredArgsConstructor +public class FullscreenAdAdminController { + + private static final int PRESIGN_URL_EXPIRY_MINUTES = 30; + + private final FullscreenAdvertisingRepository repository; + private final AdvertisingMediaService mediaService; + private final ApplicationEventPublisher eventPublisher; + + @GetMapping + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> getAll() { + List ads = repository.findAllByOrderByCreatedAtDesc() + .stream().map(this::toDto).toList(); + return ResponseEntity.ok(ads); + } + + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity create(@RequestBody FullscreenAdCreateRequestDto request) { + FullscreenAdvertising ad = new FullscreenAdvertising(); + ad.setTargetUrl(request.targetUrl()); + FullscreenAdvertising saved = repository.save(ad); + return ResponseEntity.status(HttpStatus.CREATED).body(toDto(saved)); + } + + @PostMapping("/{id}/video/presign-upload") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity presignUpload( + @PathVariable Long id, @RequestBody PresignUploadRequestDto request) { + + if (!repository.existsById(id)) { + throw new EntityNotFoundException("Fullscreen ad not found: " + id); + } + if (!isAllowedContentType(request.contentType())) { + return ResponseEntity.badRequest().build(); + } + + String sourceKey = "fullscreen-sources/%d/%s%s".formatted( + id, UUID.randomUUID(), extensionFor(request.contentType())); + String uploadUrl = mediaService.getPresignedUploadUrl(sourceKey, request.contentType()); + + return ResponseEntity.ok(new PresignUploadResponseDto(uploadUrl, sourceKey, PRESIGN_URL_EXPIRY_MINUTES)); + } + + @PostMapping("/{id}/video/confirm-upload") + @PreAuthorize("hasRole('ADMIN')") + @Transactional + public ResponseEntity confirmUpload( + @PathVariable Long id, @RequestBody ConfirmUploadRequestDto request) { + + FullscreenAdvertising ad = repository.findById(id) + .orElseThrow(() -> new EntityNotFoundException("Fullscreen ad not found: " + id)); + + ad.setTranscodingStatus(TranscodingStatus.PROCESSING); + repository.save(ad); + + eventPublisher.publishEvent(new FullscreenAdVideoUploadConfirmedEvent(id, request.sourceKey())); + return ResponseEntity.status(HttpStatus.ACCEPTED).build(); + } + + @GetMapping("/{id}/status") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity getStatus(@PathVariable Long id) { + FullscreenAdvertising ad = repository.findById(id) + .orElseThrow(() -> new EntityNotFoundException("Fullscreen ad not found: " + id)); + return ResponseEntity.ok(new TranscodingStatusDto(ad.getId(), ad.getTranscodingStatus())); + } + + /** + * Делает указанный ролик единственным активным. Все остальные ACTIVE + * переводятся в INACTIVE — партиционный уникальный индекс в БД гарантирует + * это ограничение даже при гонке параллельных запросов. + */ + @PatchMapping("/{id}/activate") + @PreAuthorize("hasRole('ADMIN')") + @Transactional + public ResponseEntity activate(@PathVariable Long id) { + FullscreenAdvertising target = repository.findById(id) + .orElseThrow(() -> new EntityNotFoundException("Fullscreen ad not found: " + id)); + + if (target.getTranscodingStatus() != TranscodingStatus.READY) { + return ResponseEntity.unprocessableEntity().build(); + } + + repository.findByStatus(FullscreenAdStatus.ACTIVE) + .ifPresent(current -> current.setStatus(FullscreenAdStatus.INACTIVE)); + + target.setStatus(FullscreenAdStatus.ACTIVE); + return ResponseEntity.ok(toDto(target)); + } + + @PatchMapping("/{id}/deactivate") + @PreAuthorize("hasRole('ADMIN')") + @Transactional + public ResponseEntity deactivate(@PathVariable Long id) { + FullscreenAdvertising ad = repository.findById(id) + .orElseThrow(() -> new EntityNotFoundException("Fullscreen ad not found: " + id)); + ad.setStatus(FullscreenAdStatus.INACTIVE); + return ResponseEntity.ok(toDto(ad)); + } + + private boolean isAllowedContentType(String contentType) { + return contentType != null && (contentType.equals("video/mp4") + || contentType.equals("video/quicktime") || contentType.equals("video/webm")); + } + + private String extensionFor(String contentType) { + return switch (contentType) { + case "video/quicktime" -> ".mov"; + case "video/webm" -> ".webm"; + default -> ".mp4"; + }; + } + + private FullscreenAdAdminResponseDto toDto(FullscreenAdvertising ad) { + return new FullscreenAdAdminResponseDto(ad.getId(), ad.getTargetUrl(), ad.getStatus(), ad.getTranscodingStatus()); + } +} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/controller/advertising/FullscreenAdController.java b/src/main/java/com/krylov/refound/controller/advertising/FullscreenAdController.java new file mode 100644 index 0000000..0d24d87 --- /dev/null +++ b/src/main/java/com/krylov/refound/controller/advertising/FullscreenAdController.java @@ -0,0 +1,47 @@ +package com.krylov.refound.controller.advertising; + +import com.krylov.refound.dto.advertising.FullscreenAdPublicResponseDto; +import com.krylov.refound.entity.advertising.FullscreenAdvertising; +import com.krylov.refound.enums.advertising.FullscreenAdStatus; +import com.krylov.refound.repository.advertising.FullscreenAdvertisingRepository; +import com.krylov.refound.service.advertising.AdvertisingMediaService; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor +public class FullscreenAdController { + + private final FullscreenAdvertisingRepository repository; + private final AdvertisingMediaService mediaService; + + @GetMapping("/api/fullscreen-ad") + public ResponseEntity getCurrent() { + log.info("GET /api/fullscreen-ad called"); + + Optional active = repository.findByStatus(FullscreenAdStatus.ACTIVE); + if (active.isEmpty()) { + // Реклама не настроена (нет ACTIVE) — фронт помечает сессию и больше не запрашивает + log.info("fullscreen-ad: no ACTIVE record, returning 204"); + return ResponseEntity.noContent().build(); + } + + FullscreenAdvertising ad = active.get(); + log.info("fullscreen-ad: ACTIVE id={}, transcodeStatus={}, mediaKey={}", + ad.getId(), ad.getTranscodingStatus(), ad.getMediaKey()); + + if (ad.getMediaKey() == null || ad.getMediaKey().isBlank()) { + log.warn("fullscreen-ad: ACTIVE id={} has empty mediaKey, returning 204", ad.getId()); + return ResponseEntity.noContent().build(); + } + + String mediaUrl = mediaService.getMediaUrl(ad.getMediaKey()); + log.info("fullscreen-ad: response mediaUrl={}, targetUrl={}", mediaUrl, ad.getTargetUrl()); + return ResponseEntity.ok(new FullscreenAdPublicResponseDto(mediaUrl, ad.getTargetUrl())); + } +} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/dto/AuthResponse.java b/src/main/java/com/krylov/refound/dto/AuthResponse.java index 69ea889..7fc47c9 100644 --- a/src/main/java/com/krylov/refound/dto/AuthResponse.java +++ b/src/main/java/com/krylov/refound/dto/AuthResponse.java @@ -1,5 +1,6 @@ package com.krylov.refound.dto; +import com.krylov.refound.dto.user.UserDto; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/src/main/java/com/krylov/refound/dto/advertising/AdImageUploadResponseDto.java b/src/main/java/com/krylov/refound/dto/advertising/AdImageUploadResponseDto.java new file mode 100644 index 0000000..1b75106 --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/advertising/AdImageUploadResponseDto.java @@ -0,0 +1,5 @@ +package com.krylov.refound.dto.advertising; + +public record AdImageUploadResponseDto(String mediaKey) { + +} diff --git a/src/main/java/com/krylov/refound/dto/advertising/AdvertisingAdminResponseDto.java b/src/main/java/com/krylov/refound/dto/advertising/AdvertisingAdminResponseDto.java new file mode 100644 index 0000000..b9dee96 --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/advertising/AdvertisingAdminResponseDto.java @@ -0,0 +1,15 @@ +package com.krylov.refound.dto.advertising; + +import com.krylov.refound.enums.advertising.AdvertisingType; +import com.krylov.refound.enums.advertising.TranscodingStatus; + +public record AdvertisingAdminResponseDto( + Long id, + Long campaignId, + AdvertisingType type, + String targetUrl, + int weight, + TranscodingStatus transcodingStatus +) { + +} diff --git a/src/main/java/com/krylov/refound/dto/advertising/AdvertisingCreateRequestDto.java b/src/main/java/com/krylov/refound/dto/advertising/AdvertisingCreateRequestDto.java new file mode 100644 index 0000000..24b5259 --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/advertising/AdvertisingCreateRequestDto.java @@ -0,0 +1,13 @@ +package com.krylov.refound.dto.advertising; + +import com.krylov.refound.enums.advertising.AdvertisingType; + +public record AdvertisingCreateRequestDto( + Long campaignId, + AdvertisingType type, + String mediaKey, // обязателен только для IMAGE, для VIDEO — null + String targetUrl, + int weight +) { + +} diff --git a/src/main/java/com/krylov/refound/dto/advertising/AdvertisingResponseDto.java b/src/main/java/com/krylov/refound/dto/advertising/AdvertisingResponseDto.java new file mode 100644 index 0000000..3ffff13 --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/advertising/AdvertisingResponseDto.java @@ -0,0 +1,10 @@ +package com.krylov.refound.dto.advertising; + +public record AdvertisingResponseDto( + Long advertisingId, + String type, + String mediaUrl, + String targetUrl +) { + +} diff --git a/src/main/java/com/krylov/refound/dto/advertising/AdvertisingUpdateRequestDto.java b/src/main/java/com/krylov/refound/dto/advertising/AdvertisingUpdateRequestDto.java new file mode 100644 index 0000000..58c4d25 --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/advertising/AdvertisingUpdateRequestDto.java @@ -0,0 +1,13 @@ +package com.krylov.refound.dto.advertising; + +/** + * Обновление полей объявления: только targetUrl и weight. + * Тип, медиа и статус транскодинга при создании фиксируются + * (смена креатива делается через новое объявление). + */ +public record AdvertisingUpdateRequestDto( + String targetUrl, + int weight +) { + +} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/dto/advertising/CampaignCreateRequestDto.java b/src/main/java/com/krylov/refound/dto/advertising/CampaignCreateRequestDto.java new file mode 100644 index 0000000..357b812 --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/advertising/CampaignCreateRequestDto.java @@ -0,0 +1,12 @@ +package com.krylov.refound.dto.advertising; + +import java.time.LocalDateTime; + +public record CampaignCreateRequestDto( + String name, + LocalDateTime startDate, + LocalDateTime endDate, + int dailyImpressionCap +) { + +} diff --git a/src/main/java/com/krylov/refound/dto/advertising/CampaignResponseDto.java b/src/main/java/com/krylov/refound/dto/advertising/CampaignResponseDto.java new file mode 100644 index 0000000..9e73e6b --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/advertising/CampaignResponseDto.java @@ -0,0 +1,15 @@ +package com.krylov.refound.dto.advertising; + +import com.krylov.refound.enums.advertising.CampaignStatus; +import java.time.LocalDateTime; + +public record CampaignResponseDto( + Long id, + String name, + CampaignStatus status, + LocalDateTime startDate, + LocalDateTime endDate, + int dailyImpressionCap +) { + +} diff --git a/src/main/java/com/krylov/refound/dto/advertising/CampaignStatusUpdateRequestDto.java b/src/main/java/com/krylov/refound/dto/advertising/CampaignStatusUpdateRequestDto.java new file mode 100644 index 0000000..5882cbd --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/advertising/CampaignStatusUpdateRequestDto.java @@ -0,0 +1,9 @@ +package com.krylov.refound.dto.advertising; + +import com.krylov.refound.enums.advertising.CampaignStatus; + +public record CampaignStatusUpdateRequestDto( + CampaignStatus status +) { + +} diff --git a/src/main/java/com/krylov/refound/dto/advertising/CampaignUpdateRequestDto.java b/src/main/java/com/krylov/refound/dto/advertising/CampaignUpdateRequestDto.java new file mode 100644 index 0000000..50ca0b3 --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/advertising/CampaignUpdateRequestDto.java @@ -0,0 +1,11 @@ +package com.krylov.refound.dto.advertising; + +import java.time.LocalDateTime; + +public record CampaignUpdateRequestDto( + String name, + LocalDateTime startDate, + LocalDateTime endDate, + int dailyImpressionCap +) { +} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/dto/advertising/ClickRequestDto.java b/src/main/java/com/krylov/refound/dto/advertising/ClickRequestDto.java new file mode 100644 index 0000000..cba3b5b --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/advertising/ClickRequestDto.java @@ -0,0 +1,8 @@ +package com.krylov.refound.dto.advertising; + +public record ClickRequestDto( + Long advertisingId, + String sessionId +) { + +} diff --git a/src/main/java/com/krylov/refound/dto/advertising/ConfirmUploadRequestDto.java b/src/main/java/com/krylov/refound/dto/advertising/ConfirmUploadRequestDto.java new file mode 100644 index 0000000..e8e472b --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/advertising/ConfirmUploadRequestDto.java @@ -0,0 +1,5 @@ +package com.krylov.refound.dto.advertising; + +public record ConfirmUploadRequestDto(String sourceKey) { + +} diff --git a/src/main/java/com/krylov/refound/dto/advertising/FullscreenAdActivateRequestDto.java b/src/main/java/com/krylov/refound/dto/advertising/FullscreenAdActivateRequestDto.java new file mode 100644 index 0000000..0506954 --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/advertising/FullscreenAdActivateRequestDto.java @@ -0,0 +1,5 @@ +package com.krylov.refound.dto.advertising; + +public record FullscreenAdActivateRequestDto(boolean active) { + +} diff --git a/src/main/java/com/krylov/refound/dto/advertising/FullscreenAdAdminResponseDto.java b/src/main/java/com/krylov/refound/dto/advertising/FullscreenAdAdminResponseDto.java new file mode 100644 index 0000000..5c4017b --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/advertising/FullscreenAdAdminResponseDto.java @@ -0,0 +1,11 @@ +package com.krylov.refound.dto.advertising; + +import com.krylov.refound.enums.advertising.FullscreenAdStatus; +import com.krylov.refound.enums.advertising.TranscodingStatus; + +public record FullscreenAdAdminResponseDto( + Long id, String targetUrl, FullscreenAdStatus status, + TranscodingStatus transcodingStatus) { + +} + diff --git a/src/main/java/com/krylov/refound/dto/advertising/FullscreenAdCreateRequestDto.java b/src/main/java/com/krylov/refound/dto/advertising/FullscreenAdCreateRequestDto.java new file mode 100644 index 0000000..a52e4fd --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/advertising/FullscreenAdCreateRequestDto.java @@ -0,0 +1,3 @@ +package com.krylov.refound.dto.advertising; + +public record FullscreenAdCreateRequestDto(String targetUrl) {} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/dto/advertising/FullscreenAdPublicResponseDto.java b/src/main/java/com/krylov/refound/dto/advertising/FullscreenAdPublicResponseDto.java new file mode 100644 index 0000000..4e65e22 --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/advertising/FullscreenAdPublicResponseDto.java @@ -0,0 +1,7 @@ +package com.krylov.refound.dto.advertising; + +public record FullscreenAdPublicResponseDto( + String mediaUrl, + String targetUrl) { + +} diff --git a/src/main/java/com/krylov/refound/dto/advertising/FullscreenAdVideoUploadConfirmedEvent.java b/src/main/java/com/krylov/refound/dto/advertising/FullscreenAdVideoUploadConfirmedEvent.java new file mode 100644 index 0000000..ec89d24 --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/advertising/FullscreenAdVideoUploadConfirmedEvent.java @@ -0,0 +1,6 @@ +package com.krylov.refound.dto.advertising; + +public record FullscreenAdVideoUploadConfirmedEvent( + Long fullscreenAdId, + String sourceKey) { +} diff --git a/src/main/java/com/krylov/refound/dto/advertising/ImpressionRequestDto.java b/src/main/java/com/krylov/refound/dto/advertising/ImpressionRequestDto.java new file mode 100644 index 0000000..696d9b2 --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/advertising/ImpressionRequestDto.java @@ -0,0 +1,7 @@ +package com.krylov.refound.dto.advertising; + +public record ImpressionRequestDto( + Long advertisingId, + String sessionId +) { +} diff --git a/src/main/java/com/krylov/refound/dto/advertising/PresignUploadRequestDto.java b/src/main/java/com/krylov/refound/dto/advertising/PresignUploadRequestDto.java new file mode 100644 index 0000000..ed8ecf5 --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/advertising/PresignUploadRequestDto.java @@ -0,0 +1,5 @@ +package com.krylov.refound.dto.advertising; + +public record PresignUploadRequestDto(String contentType) { + +} diff --git a/src/main/java/com/krylov/refound/dto/advertising/PresignUploadResponseDto.java b/src/main/java/com/krylov/refound/dto/advertising/PresignUploadResponseDto.java new file mode 100644 index 0000000..f7817e1 --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/advertising/PresignUploadResponseDto.java @@ -0,0 +1,9 @@ +package com.krylov.refound.dto.advertising; + +public record PresignUploadResponseDto( + String uploadUrl, + String sourceKey, + int expiryMinutes +) { + +} diff --git a/src/main/java/com/krylov/refound/dto/advertising/SelectedAdvertisingDto.java b/src/main/java/com/krylov/refound/dto/advertising/SelectedAdvertisingDto.java new file mode 100644 index 0000000..6eb82c6 --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/advertising/SelectedAdvertisingDto.java @@ -0,0 +1,10 @@ +package com.krylov.refound.dto.advertising; + +public record SelectedAdvertisingDto( + Long advertisingId, + String type, + String mediaKey, + String targetUrl +) { + +} diff --git a/src/main/java/com/krylov/refound/dto/advertising/TranscodingStatusDto.java b/src/main/java/com/krylov/refound/dto/advertising/TranscodingStatusDto.java new file mode 100644 index 0000000..d5f86a8 --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/advertising/TranscodingStatusDto.java @@ -0,0 +1,7 @@ +package com.krylov.refound.dto.advertising; + +import com.krylov.refound.enums.advertising.TranscodingStatus; + +public record TranscodingStatusDto(Long advertisingId, TranscodingStatus transcodingStatus) { + +} diff --git a/src/main/java/com/krylov/refound/dto/advertising/VideoUploadConfirmedEvent.java b/src/main/java/com/krylov/refound/dto/advertising/VideoUploadConfirmedEvent.java new file mode 100644 index 0000000..17d7ee7 --- /dev/null +++ b/src/main/java/com/krylov/refound/dto/advertising/VideoUploadConfirmedEvent.java @@ -0,0 +1,8 @@ +package com.krylov.refound.dto.advertising; + +public record VideoUploadConfirmedEvent( + Long advertisingId, + String sourceKey +) { + +} diff --git a/src/main/java/com/krylov/refound/dto/UserDto.java b/src/main/java/com/krylov/refound/dto/user/UserDto.java similarity index 90% rename from src/main/java/com/krylov/refound/dto/UserDto.java rename to src/main/java/com/krylov/refound/dto/user/UserDto.java index 7b7707e..d4b9920 100644 --- a/src/main/java/com/krylov/refound/dto/UserDto.java +++ b/src/main/java/com/krylov/refound/dto/user/UserDto.java @@ -1,4 +1,4 @@ -package com.krylov.refound.dto; +package com.krylov.refound.dto.user; import com.krylov.refound.enums.Role; import lombok.AllArgsConstructor; diff --git a/src/main/java/com/krylov/refound/dto/UserRegisterDto.java b/src/main/java/com/krylov/refound/dto/user/UserRegisterDto.java similarity index 70% rename from src/main/java/com/krylov/refound/dto/UserRegisterDto.java rename to src/main/java/com/krylov/refound/dto/user/UserRegisterDto.java index f1c9ff8..c46b930 100644 --- a/src/main/java/com/krylov/refound/dto/UserRegisterDto.java +++ b/src/main/java/com/krylov/refound/dto/user/UserRegisterDto.java @@ -1,4 +1,4 @@ -package com.krylov.refound.dto; +package com.krylov.refound.dto.user; public record UserRegisterDto(UserDto userDto, String token, String refreshToken) { diff --git a/src/main/java/com/krylov/refound/dto/UserResponseDto.java b/src/main/java/com/krylov/refound/dto/user/UserResponseDto.java similarity index 72% rename from src/main/java/com/krylov/refound/dto/UserResponseDto.java rename to src/main/java/com/krylov/refound/dto/user/UserResponseDto.java index 2185727..b368384 100644 --- a/src/main/java/com/krylov/refound/dto/UserResponseDto.java +++ b/src/main/java/com/krylov/refound/dto/user/UserResponseDto.java @@ -1,5 +1,6 @@ -package com.krylov.refound.dto; +package com.krylov.refound.dto.user; +import com.krylov.refound.enums.Role; import lombok.Builder; import lombok.Data; @@ -13,4 +14,5 @@ public class UserResponseDto { private String phone; private String email; private String avatar; + private Role role; } diff --git a/src/main/java/com/krylov/refound/dto/UserUpdateDto.java b/src/main/java/com/krylov/refound/dto/user/UserUpdateDto.java similarity index 89% rename from src/main/java/com/krylov/refound/dto/UserUpdateDto.java rename to src/main/java/com/krylov/refound/dto/user/UserUpdateDto.java index f045691..73fa33a 100644 --- a/src/main/java/com/krylov/refound/dto/UserUpdateDto.java +++ b/src/main/java/com/krylov/refound/dto/user/UserUpdateDto.java @@ -1,4 +1,4 @@ -package com.krylov.refound.dto; +package com.krylov.refound.dto.user; import lombok.Data; import org.springframework.web.multipart.MultipartFile; diff --git a/src/main/java/com/krylov/refound/entity/advertising/Advertising.java b/src/main/java/com/krylov/refound/entity/advertising/Advertising.java new file mode 100644 index 0000000..4737ead --- /dev/null +++ b/src/main/java/com/krylov/refound/entity/advertising/Advertising.java @@ -0,0 +1,66 @@ +package com.krylov.refound.entity.advertising; + +import com.krylov.refound.enums.advertising.AdvertisingType; +import com.krylov.refound.enums.advertising.TranscodingStatus; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "advertising") +@Getter +@Setter +public class Advertising { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "campaign_id", nullable = false) + private Campaign campaign; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 10) + private AdvertisingType type; + + @Column(name = "media_key", nullable = false, length = 512) + private String mediaKey; + + @Column(name = "target_url", nullable = false, length = 1024) + private String targetUrl; + + @Column(nullable = false) + private Integer weight = 1; + + @Enumerated(EnumType.STRING) + @Column(name = "transcoding_status", nullable = false, length = 20) + private TranscodingStatus transcodingStatus = TranscodingStatus.PENDING; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @OneToMany( + mappedBy = "advertising", + cascade = CascadeType.ALL, + orphanRemoval = true + ) + private List impressions = new ArrayList<>(); + + @OneToMany( + mappedBy = "advertising", + cascade = CascadeType.ALL, + orphanRemoval = true + ) + private List clicks = new ArrayList<>(); + + @PrePersist + protected void onCreate() { + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } + } +} diff --git a/src/main/java/com/krylov/refound/entity/advertising/Campaign.java b/src/main/java/com/krylov/refound/entity/advertising/Campaign.java new file mode 100644 index 0000000..b49a1ba --- /dev/null +++ b/src/main/java/com/krylov/refound/entity/advertising/Campaign.java @@ -0,0 +1,54 @@ +package com.krylov.refound.entity.advertising; + +import com.krylov.refound.enums.advertising.CampaignStatus; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "campaign") +@Getter +@Setter +public class Campaign { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 255) + private String name; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private CampaignStatus status = CampaignStatus.PAUSED; + + @Column(name = "start_date", nullable = false) + private LocalDateTime startDate; + + @Column(name = "end_date", nullable = false) + private LocalDateTime endDate; + + @Column(name = "daily_impression_cap", nullable = false) + private Integer dailyImpressionCap = 10; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @OneToMany( + mappedBy = "campaign", + cascade = CascadeType.ALL, + orphanRemoval = true + ) + private List ads = new ArrayList<>(); + + @PrePersist + protected void onCreate() { + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/entity/advertising/Click.java b/src/main/java/com/krylov/refound/entity/advertising/Click.java new file mode 100644 index 0000000..1bc9bae --- /dev/null +++ b/src/main/java/com/krylov/refound/entity/advertising/Click.java @@ -0,0 +1,38 @@ +package com.krylov.refound.entity.advertising; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "click") +@Getter +@Setter +public class Click { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "advertising_id", nullable = false) + private Advertising advertising; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "clicked_at", nullable = false) + private LocalDateTime clickedAt; + + @Column(name = "session_id") + private String sessionId; + + @PrePersist + protected void onCreate() { + if (clickedAt == null) { + clickedAt = LocalDateTime.now(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/entity/advertising/FullscreenAdvertising.java b/src/main/java/com/krylov/refound/entity/advertising/FullscreenAdvertising.java new file mode 100644 index 0000000..b1f057b --- /dev/null +++ b/src/main/java/com/krylov/refound/entity/advertising/FullscreenAdvertising.java @@ -0,0 +1,44 @@ +package com.krylov.refound.entity.advertising; + +import com.krylov.refound.enums.advertising.FullscreenAdStatus; +import com.krylov.refound.enums.advertising.TranscodingStatus; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "fullscreen_advertising") +@Getter +@Setter +public class FullscreenAdvertising { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "media_key") + private String mediaKey; + + @Column(name = "target_url") + private String targetUrl; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private FullscreenAdStatus status = FullscreenAdStatus.DRAFT; + + @Enumerated(EnumType.STRING) + @Column(name = "transcoding_status", nullable = false) + private TranscodingStatus transcodingStatus = TranscodingStatus.PENDING; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/entity/advertising/Impression.java b/src/main/java/com/krylov/refound/entity/advertising/Impression.java new file mode 100644 index 0000000..8feb061 --- /dev/null +++ b/src/main/java/com/krylov/refound/entity/advertising/Impression.java @@ -0,0 +1,38 @@ +package com.krylov.refound.entity.advertising; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "impression") +@Getter +@Setter +public class Impression { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "advertising_id", nullable = false) + private Advertising advertising; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "session_id", nullable = false, length = 64) + private String sessionId; + + @Column(name = "shown_at", nullable = false) + private LocalDateTime shownAt; + + @PrePersist + protected void onCreate() { + if (shownAt == null) { + shownAt = LocalDateTime.now(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/enums/ErrorCode.java b/src/main/java/com/krylov/refound/enums/ErrorCode.java index f8aa7cf..7c356bd 100644 --- a/src/main/java/com/krylov/refound/enums/ErrorCode.java +++ b/src/main/java/com/krylov/refound/enums/ErrorCode.java @@ -7,5 +7,6 @@ public enum ErrorCode { BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, - CONTENT_BLOCKED + CONTENT_BLOCKED, + INVALID_STATUS_TRANSITION } diff --git a/src/main/java/com/krylov/refound/enums/advertising/AdvertisingType.java b/src/main/java/com/krylov/refound/enums/advertising/AdvertisingType.java new file mode 100644 index 0000000..547508a --- /dev/null +++ b/src/main/java/com/krylov/refound/enums/advertising/AdvertisingType.java @@ -0,0 +1,6 @@ +package com.krylov.refound.enums.advertising; + +public enum AdvertisingType { + IMAGE, + VIDEO +} diff --git a/src/main/java/com/krylov/refound/enums/advertising/CampaignStatus.java b/src/main/java/com/krylov/refound/enums/advertising/CampaignStatus.java new file mode 100644 index 0000000..23fd4c6 --- /dev/null +++ b/src/main/java/com/krylov/refound/enums/advertising/CampaignStatus.java @@ -0,0 +1,8 @@ +package com.krylov.refound.enums.advertising; + +public enum CampaignStatus { + ACTIVE, + PAUSED, + DRAFT, + FINISHED +} diff --git a/src/main/java/com/krylov/refound/enums/advertising/FullscreenAdStatus.java b/src/main/java/com/krylov/refound/enums/advertising/FullscreenAdStatus.java new file mode 100644 index 0000000..b3a44d7 --- /dev/null +++ b/src/main/java/com/krylov/refound/enums/advertising/FullscreenAdStatus.java @@ -0,0 +1,5 @@ +package com.krylov.refound.enums.advertising; + +public enum FullscreenAdStatus { + DRAFT, ACTIVE, INACTIVE +} diff --git a/src/main/java/com/krylov/refound/enums/advertising/TranscodingStatus.java b/src/main/java/com/krylov/refound/enums/advertising/TranscodingStatus.java new file mode 100644 index 0000000..cca63b0 --- /dev/null +++ b/src/main/java/com/krylov/refound/enums/advertising/TranscodingStatus.java @@ -0,0 +1,8 @@ +package com.krylov.refound.enums.advertising; + +public enum TranscodingStatus { + PENDING, + PROCESSING, + READY, + FAILED +} diff --git a/src/main/java/com/krylov/refound/exception/GlobalExceptionHandler.java b/src/main/java/com/krylov/refound/exception/GlobalExceptionHandler.java index 58a9251..5af0380 100644 --- a/src/main/java/com/krylov/refound/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/krylov/refound/exception/GlobalExceptionHandler.java @@ -68,14 +68,7 @@ public class GlobalExceptionHandler { return new ResponseEntity<>(response, status); } - private ResponseEntity buildResponse( - HttpStatus status, - ErrorCode code, - String message, - HttpServletRequest request, - Object details - ) { - + private ResponseEntity buildResponse(HttpStatus status, ErrorCode code, String message, HttpServletRequest request, Object details) { ErrorResponse response = ErrorResponse.builder() .timestamp(LocalDateTime.now()) .status(status.value()) @@ -91,11 +84,7 @@ public class GlobalExceptionHandler { } @ExceptionHandler(ContentBlockedException.class) - public ResponseEntity handleBlocked( - ContentBlockedException ex, - HttpServletRequest request - ) { - + public ResponseEntity handleBlocked(ContentBlockedException ex, HttpServletRequest request) { return buildResponse( HttpStatus.BAD_REQUEST, ErrorCode.CONTENT_BLOCKED, @@ -107,4 +96,17 @@ public class GlobalExceptionHandler { ); } + + @ExceptionHandler(InvalidCampaignStatusTransitionException.class) + public ResponseEntity handleInvalidStatusTransition( + InvalidCampaignStatusTransitionException ex, HttpServletRequest request) { + return buildResponse(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.INVALID_STATUS_TRANSITION, ex.getMessage(), request); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument( + IllegalArgumentException ex, HttpServletRequest request) { + return buildResponse(HttpStatus.BAD_REQUEST, ErrorCode.VALIDATION_ERROR, ex.getMessage(), request); + } + } diff --git a/src/main/java/com/krylov/refound/exception/InvalidCampaignStatusTransitionException.java b/src/main/java/com/krylov/refound/exception/InvalidCampaignStatusTransitionException.java new file mode 100644 index 0000000..c0ad88c --- /dev/null +++ b/src/main/java/com/krylov/refound/exception/InvalidCampaignStatusTransitionException.java @@ -0,0 +1,8 @@ +package com.krylov.refound.exception; + +public class InvalidCampaignStatusTransitionException extends RuntimeException { + + public InvalidCampaignStatusTransitionException(String message) { + super(message); + } +} diff --git a/src/main/java/com/krylov/refound/health/StorageStructureService.java b/src/main/java/com/krylov/refound/health/StorageStructureService.java index 1806d73..0d96632 100644 --- a/src/main/java/com/krylov/refound/health/StorageStructureService.java +++ b/src/main/java/com/krylov/refound/health/StorageStructureService.java @@ -75,6 +75,7 @@ public class StorageStructureService { .status(ExpirationStatus.ENABLED) .filter(f -> f.prefix("")) .expiration(exp -> exp.days(180)) + .noncurrentVersionExpiration(ncv -> ncv.noncurrentDays(180)) .build(); BucketLifecycleConfiguration lifecycleConfig = BucketLifecycleConfiguration.builder() diff --git a/src/main/java/com/krylov/refound/repository/advertising/AdvertisingRepository.java b/src/main/java/com/krylov/refound/repository/advertising/AdvertisingRepository.java new file mode 100644 index 0000000..faf205d --- /dev/null +++ b/src/main/java/com/krylov/refound/repository/advertising/AdvertisingRepository.java @@ -0,0 +1,33 @@ +package com.krylov.refound.repository.advertising; + +import com.krylov.refound.entity.advertising.Advertising; +import com.krylov.refound.enums.advertising.TranscodingStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; + +public interface AdvertisingRepository extends JpaRepository { + + @Query(""" + SELECT a FROM Advertising a JOIN FETCH a.campaign c + WHERE c.status = 'ACTIVE' + AND a.transcodingStatus = 'READY' + AND :now BETWEEN c.startDate AND c.endDate + """) + List findEligibleAds(@Param("now") LocalDateTime now); + + @Modifying + @Query(""" + UPDATE Advertising a + SET a.transcodingStatus = :status + WHERE a.id = :id + """) + void updateTranscodingStatus(@Param("id") Long id, @Param("status") TranscodingStatus status); + + List findAllByCampaignIdOrderByCreatedAtDesc(Long campaignId); + +} diff --git a/src/main/java/com/krylov/refound/repository/advertising/CampaignRepository.java b/src/main/java/com/krylov/refound/repository/advertising/CampaignRepository.java new file mode 100644 index 0000000..0c2c47e --- /dev/null +++ b/src/main/java/com/krylov/refound/repository/advertising/CampaignRepository.java @@ -0,0 +1,23 @@ +package com.krylov.refound.repository.advertising; + +import com.krylov.refound.entity.advertising.Campaign; +import com.krylov.refound.enums.advertising.CampaignStatus; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface CampaignRepository extends JpaRepository { + + @Modifying + @Query(""" + UPDATE Campaign c + SET c.status = :status + WHERE c.id = :campaignId + """) + void updateStatus(@Param("campaignId") Long campaignId, @Param("status") CampaignStatus status); + + List findAllByOrderByCreatedAtDesc(); + +} diff --git a/src/main/java/com/krylov/refound/repository/advertising/ClickRepository.java b/src/main/java/com/krylov/refound/repository/advertising/ClickRepository.java new file mode 100644 index 0000000..2cb982a --- /dev/null +++ b/src/main/java/com/krylov/refound/repository/advertising/ClickRepository.java @@ -0,0 +1,7 @@ +package com.krylov.refound.repository.advertising; + +import com.krylov.refound.entity.advertising.Click; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ClickRepository extends JpaRepository { +} diff --git a/src/main/java/com/krylov/refound/repository/advertising/FullscreenAdvertisingRepository.java b/src/main/java/com/krylov/refound/repository/advertising/FullscreenAdvertisingRepository.java new file mode 100644 index 0000000..943d768 --- /dev/null +++ b/src/main/java/com/krylov/refound/repository/advertising/FullscreenAdvertisingRepository.java @@ -0,0 +1,15 @@ +package com.krylov.refound.repository.advertising; + +import com.krylov.refound.entity.advertising.FullscreenAdvertising; +import com.krylov.refound.enums.advertising.FullscreenAdStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface FullscreenAdvertisingRepository extends JpaRepository { + + Optional findByStatus(FullscreenAdStatus status); + + List findAllByOrderByCreatedAtDesc(); +} diff --git a/src/main/java/com/krylov/refound/repository/advertising/ImpressionRepository.java b/src/main/java/com/krylov/refound/repository/advertising/ImpressionRepository.java new file mode 100644 index 0000000..54b495f --- /dev/null +++ b/src/main/java/com/krylov/refound/repository/advertising/ImpressionRepository.java @@ -0,0 +1,7 @@ +package com.krylov.refound.repository.advertising; + +import com.krylov.refound.entity.advertising.Impression; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ImpressionRepository extends JpaRepository { +} diff --git a/src/main/java/com/krylov/refound/security/SecurityConfig.java b/src/main/java/com/krylov/refound/security/SecurityConfig.java index 5a7a89d..eb3f403 100644 --- a/src/main/java/com/krylov/refound/security/SecurityConfig.java +++ b/src/main/java/com/krylov/refound/security/SecurityConfig.java @@ -12,6 +12,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -26,6 +27,7 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @Configuration @EnableWebSecurity +@EnableMethodSecurity @RequiredArgsConstructor public class SecurityConfig { @@ -49,6 +51,7 @@ public class SecurityConfig { .requestMatchers("/api/v1/auth/**").permitAll() .requestMatchers("/api/v1/test/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/files/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/ads-media/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/posts/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/reviews/**").permitAll() .requestMatchers("/files/**", "/uploads/**").permitAll() @@ -64,8 +67,15 @@ public class SecurityConfig { .requestMatchers(HttpMethod.PUT, "/api/v1/posts/**").authenticated() .requestMatchers(HttpMethod.DELETE, "/api/v1/posts/**").authenticated() + // реклама + .requestMatchers(HttpMethod.GET, "/api/ads/next").permitAll() + .requestMatchers(HttpMethod.POST, "/api/ads/impression").permitAll() + .requestMatchers(HttpMethod.POST, "/api/ads/impression/beacon").permitAll() + .requestMatchers(HttpMethod.POST, "/api/ads/click").permitAll() + .requestMatchers(HttpMethod.GET, "/api/fullscreen-ad").permitAll() + // пример разграничения по ролям — раскомментировать и адаптировать под свои admin-эндпоинты - // .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") + .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) diff --git a/src/main/java/com/krylov/refound/service/AuthService.java b/src/main/java/com/krylov/refound/service/AuthService.java index c0f3983..3afa637 100644 --- a/src/main/java/com/krylov/refound/service/AuthService.java +++ b/src/main/java/com/krylov/refound/service/AuthService.java @@ -5,8 +5,8 @@ import com.krylov.refound.dto.AuthResponse; import com.krylov.refound.dto.CheckLoginDto; import com.krylov.refound.dto.RefreshRequest; import com.krylov.refound.dto.RegisterRequest; -import com.krylov.refound.dto.UserDto; -import com.krylov.refound.dto.UserRegisterDto; +import com.krylov.refound.dto.user.UserDto; +import com.krylov.refound.dto.user.UserRegisterDto; import com.krylov.refound.entity.User; import com.krylov.refound.exception.InvalidCredentialsException; import com.krylov.refound.exception.LoginAlreadyExistsException; diff --git a/src/main/java/com/krylov/refound/service/UserService.java b/src/main/java/com/krylov/refound/service/UserService.java index 56ad629..4ed4457 100644 --- a/src/main/java/com/krylov/refound/service/UserService.java +++ b/src/main/java/com/krylov/refound/service/UserService.java @@ -1,7 +1,7 @@ package com.krylov.refound.service; -import com.krylov.refound.dto.UserResponseDto; -import com.krylov.refound.dto.UserUpdateDto; +import com.krylov.refound.dto.user.UserResponseDto; +import com.krylov.refound.dto.user.UserUpdateDto; import com.krylov.refound.entity.User; import com.krylov.refound.enums.ErrorCode; import com.krylov.refound.exception.ApiException; @@ -104,6 +104,7 @@ public class UserService { .email(user.getEmail()) .lastName(user.getLastName()) .avatar(user.getAvatarUrl()) + .role(user.getRole()) .build(); } diff --git a/src/main/java/com/krylov/refound/service/advertising/AdvertisingAdminService.java b/src/main/java/com/krylov/refound/service/advertising/AdvertisingAdminService.java new file mode 100644 index 0000000..9549b69 --- /dev/null +++ b/src/main/java/com/krylov/refound/service/advertising/AdvertisingAdminService.java @@ -0,0 +1,102 @@ +package com.krylov.refound.service.advertising; + +import com.krylov.refound.dto.advertising.AdvertisingAdminResponseDto; +import com.krylov.refound.dto.advertising.AdvertisingCreateRequestDto; +import com.krylov.refound.dto.advertising.AdvertisingUpdateRequestDto; +import com.krylov.refound.dto.advertising.PresignUploadRequestDto; +import com.krylov.refound.entity.advertising.Advertising; +import com.krylov.refound.entity.advertising.Campaign; +import com.krylov.refound.enums.advertising.AdvertisingType; +import com.krylov.refound.enums.advertising.TranscodingStatus; +import com.krylov.refound.repository.advertising.AdvertisingRepository; +import jakarta.persistence.EntityNotFoundException; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AdvertisingAdminService { + + private final AdvertisingRepository advertisingRepository; + + public Advertising createAdvertising(Campaign campaign, AdvertisingCreateRequestDto request){ + Advertising advertising = new Advertising(); + advertising.setCampaign(campaign); + advertising.setType(request.type()); + advertising.setTargetUrl(request.targetUrl()); + advertising.setWeight(request.weight()); + if (request.type() == AdvertisingType.IMAGE) { + advertising.setMediaKey(request.mediaKey()); + advertising.setTranscodingStatus(TranscodingStatus.READY); + } else { + // VIDEO — media_key появится только после presign-upload → confirm-upload → транскодинга + advertising.setMediaKey(null); + advertising.setTranscodingStatus(TranscodingStatus.PENDING); + } + log.info("Advertising {} created", advertising); + return advertising; + } + + public void updateTranscodingStatus(Long advertisingId){ + advertisingRepository.updateTranscodingStatus(advertisingId, TranscodingStatus.PROCESSING); + } + + public String getSourceKey(Long advertisingId,PresignUploadRequestDto request){ + String extensionFor = extensionFor(request.contentType()); + return "sources/%d/%s%s".formatted(advertisingId, UUID.randomUUID(), extensionFor); + } + + public String extensionFor(String contentType) { + return switch (contentType) { + case "video/quicktime" -> ".mov"; + case "video/webm" -> ".webm"; + default -> ".mp4"; + }; + } + + + public boolean isAllowedContentType(String contentType) { + return contentType != null && (contentType.equals("video/mp4") + || contentType.equals("video/quicktime") + || contentType.equals("video/webm")); + } + + + public AdvertisingAdminResponseDto toAdminDto(Advertising advertising) { + Advertising saved = advertisingRepository.save(advertising); + return mapToAdminDto(saved); + } + + public List getAdvertisingsByCampaign(Long campaignId) { + return advertisingRepository.findAllByCampaignIdOrderByCreatedAtDesc(campaignId) + .stream() + .map(this::mapToAdminDto) + .toList(); + } + + public AdvertisingAdminResponseDto updateAdvertising(Long advertisingId, AdvertisingUpdateRequestDto request) { + Advertising advertising = advertisingRepository.findById(advertisingId) + .orElseThrow(() -> new EntityNotFoundException("Advertising not found: " + advertisingId)); + advertising.setTargetUrl(request.targetUrl()); + advertising.setWeight(request.weight()); + Advertising saved = advertisingRepository.save(advertising); + log.info("Advertising {} updated", saved.getId()); + return mapToAdminDto(saved); + } + + private AdvertisingAdminResponseDto mapToAdminDto(Advertising advertising) { + return new AdvertisingAdminResponseDto( + advertising.getId(), + advertising.getCampaign().getId(), + advertising.getType(), + advertising.getTargetUrl(), + advertising.getWeight(), + advertising.getTranscodingStatus()); + } + + +} diff --git a/src/main/java/com/krylov/refound/service/advertising/AdvertisingClickService.java b/src/main/java/com/krylov/refound/service/advertising/AdvertisingClickService.java new file mode 100644 index 0000000..04c2d73 --- /dev/null +++ b/src/main/java/com/krylov/refound/service/advertising/AdvertisingClickService.java @@ -0,0 +1,54 @@ +package com.krylov.refound.service.advertising; + + +import com.krylov.refound.entity.advertising.Advertising; +import com.krylov.refound.entity.advertising.Click; +import com.krylov.refound.repository.advertising.AdvertisingRepository; +import com.krylov.refound.repository.advertising.ClickRepository; +import com.krylov.refound.service.UserService; +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AdvertisingClickService { + + private final UserService userService; + private final ClickRepository clickRepository; + private final AdvertisingRepository advertisingRepository; + + @Transactional + public void recordClick(Long advertisingId, Authentication authentication, String sessionId) { + Long userId = extractUserId(authentication); + try { + Advertising advertising = advertisingRepository.getReferenceById(advertisingId); + + Click click = new Click(); + click.setAdvertising(advertising); + click.setUserId(userId); + click.setSessionId(sessionId); + click.setAdvertising(advertising); + + Click save = clickRepository.save(click); + log.info("click by advertising {}", save.getId()); + } catch (EntityNotFoundException e) { + log.warn("Click skipped: advertisingId={} no longer exists", advertisingId); + } + } + + private Long extractUserId(Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated() || "anonymousUser".equals(authentication.getPrincipal())) { + return null; + } + + String login = authentication.getName(); + log.info("extractUserId by login {}", login); + return userService.getUserIdByLogin(login); + } + +} diff --git a/src/main/java/com/krylov/refound/service/advertising/AdvertisingFrequencyService.java b/src/main/java/com/krylov/refound/service/advertising/AdvertisingFrequencyService.java new file mode 100644 index 0000000..776554f --- /dev/null +++ b/src/main/java/com/krylov/refound/service/advertising/AdvertisingFrequencyService.java @@ -0,0 +1,68 @@ +package com.krylov.refound.service.advertising; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.Instant; + +@Service +@RequiredArgsConstructor +public class AdvertisingFrequencyService { + + private static final Duration FREQ_TTL = Duration.ofHours(24); + private static final Duration SESSION_COOLDOWN = Duration.ofSeconds(30); + + private final StringRedisTemplate redisTemplate; + + /** + * Проверяет и атомарно увеличивает счётчик показов объявления пользователю + * (или анонимной сессии, если userId отсутствует). + * Возвращает true, если показ укладывается в dailyCap. + */ + public boolean tryRegisterImpression(Long userId, String sessionId, Long adId, int dailyCap) { + String key = freqKey(userId, sessionId, adId); + Long count = redisTemplate.opsForValue().increment(key); + if (count == null) { + // Redis недоступен — не блокируем показ, решение остаётся за вызывающим кодом + return true; + } + if (count == 1L) { + redisTemplate.expire(key, FREQ_TTL); + } + return count <= dailyCap; + } + + /** + * Откатывает счётчик, если после инкремента показ всё же не состоялся + * (например, объявление не прошло другие проверки в AdSelectionService). + */ + public void rollback(Long userId, String sessionId, Long adId) { + String key = freqKey(userId, sessionId, adId); + Long count = redisTemplate.opsForValue().decrement(key); + if (count != null && count <= 0) { + redisTemplate.delete(key); + } + } + + /** + * Минимальный интервал между показами любой рекламы в рамках одной сессии, + * независимо от дневного капа конкретного объявления. + */ + public boolean isSessionCooldownPassed(String sessionId) { + String key = sessionCooldownKey(sessionId); + Boolean wasAbsent = redisTemplate.opsForValue() + .setIfAbsent(key, Instant.now().toString(), SESSION_COOLDOWN); + return Boolean.TRUE.equals(wasAbsent); + } + + private String freqKey(Long userId, String sessionId, Long adId) { + String subject = userId != null ? "u" + userId : "s" + sessionId; + return "ad:freq:%s:%d".formatted(subject, adId); + } + + private String sessionCooldownKey(String sessionId) { + return "ad:session:%s:cooldown".formatted(sessionId); + } +} diff --git a/src/main/java/com/krylov/refound/service/advertising/AdvertisingImpressionService.java b/src/main/java/com/krylov/refound/service/advertising/AdvertisingImpressionService.java new file mode 100644 index 0000000..1f8d6d6 --- /dev/null +++ b/src/main/java/com/krylov/refound/service/advertising/AdvertisingImpressionService.java @@ -0,0 +1,52 @@ +package com.krylov.refound.service.advertising; + +import com.krylov.refound.entity.advertising.Advertising; +import com.krylov.refound.entity.advertising.Impression; +import com.krylov.refound.repository.advertising.AdvertisingRepository; +import com.krylov.refound.repository.advertising.ImpressionRepository; +import com.krylov.refound.service.UserService; +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AdvertisingImpressionService { + private final UserService userService; + private final ImpressionRepository impressionRepository; + private final AdvertisingRepository advertisingRepository; + + @Transactional + public void recordImpression(Long advertisingId, Authentication authentication, String sessionId) { + Long userId = extractUserId(authentication); + try { + Advertising advertising = advertisingRepository.getReferenceById(advertisingId); + + Impression impression = new Impression(); + impression.setAdvertising(advertising); + impression.setUserId(userId); + impression.setSessionId(sessionId); + + Impression save = impressionRepository.save(impression); + log.info("impression is {}", save); + } catch (EntityNotFoundException e) { + // advertisingId мог устареть (кампания удалена между показом и трекингом) — + // не роняем запрос трекинга из-за гонки данных + log.warn("Impression skipped: advertisingId={} no longer exists", advertisingId); + } + } + + private Long extractUserId(Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated() || "anonymousUser".equals(authentication.getPrincipal())) { + return null; + } + + String login = authentication.getName(); + log.info("extractUserId by login {}", login); + return userService.getUserIdByLogin(login); + } +} diff --git a/src/main/java/com/krylov/refound/service/advertising/AdvertisingMediaService.java b/src/main/java/com/krylov/refound/service/advertising/AdvertisingMediaService.java new file mode 100644 index 0000000..7e4e246 --- /dev/null +++ b/src/main/java/com/krylov/refound/service/advertising/AdvertisingMediaService.java @@ -0,0 +1,186 @@ +package com.krylov.refound.service.advertising; + +import com.krylov.refound.enums.ErrorCode; +import com.krylov.refound.exception.ApiException; +import java.io.IOException; +import java.io.InputStream; +import java.time.Duration; +import java.util.Set; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AdvertisingMediaService { + + private final S3Presigner s3Presigner; + private final S3Client s3Client; + + @Value("${minio.ads-bucket}") + private String adsBucket; + + private static final long MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB + private static final Set ALLOWED_IMAGE_TYPES = Set.of("image/jpeg", "image/png", "image/webp"); + + /** + * Загружает изображение рекламного объявления в ads-media. + * Валидация полностью повторяет FileStorageService (10MB, jpeg/png/webp) — + * но целевой бакет другой, поэтому логика не переиспользуется напрямую. + */ + public String uploadImage(MultipartFile file) { + validateImage(file); + + String extension = extractExtension(file.getOriginalFilename()); + String mediaKey = "images/%s%s".formatted(UUID.randomUUID(), extension); + + try { + PutObjectRequest request = PutObjectRequest.builder() + .bucket(adsBucket) + .key(mediaKey) + .contentType(file.getContentType()) + .build(); + + s3Client.putObject(request, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + log.info("Ad image uploaded: {}", mediaKey); + return mediaKey; + + } catch (IOException e) { + log.error("Failed to read image file for upload: {}", e.getMessage(), e); + throw new ApiException(ErrorCode.INTERNAL_ERROR, "Ошибка при обработке файла.", HttpStatus.INTERNAL_SERVER_ERROR); + } catch (S3Exception | SdkClientException e) { + log.error("Failed to upload ad image to S3/MinIO: {}", e.getMessage(), e); + throw new ApiException(ErrorCode.INTERNAL_ERROR, "Ошибка при загрузке файла.", HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + private void validateImage(MultipartFile file) { + if (file.isEmpty()) { + throw new ApiException(ErrorCode.VALIDATION_ERROR, "Файл не может быть пустым.", HttpStatus.BAD_REQUEST); + } + + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null || originalFilename.isBlank()) { + throw new ApiException(ErrorCode.VALIDATION_ERROR, "Имя файла отсутствует.", HttpStatus.BAD_REQUEST); + } + + if (file.getSize() > MAX_IMAGE_SIZE) { + throw new ApiException(ErrorCode.VALIDATION_ERROR, "Файл слишком большой (максимум 10 МБ).", HttpStatus.BAD_REQUEST); + } + + String contentType = file.getContentType(); + if (contentType == null || !ALLOWED_IMAGE_TYPES.contains(contentType)) { + throw new ApiException(ErrorCode.VALIDATION_ERROR, "Недопустимый тип файла.", HttpStatus.BAD_REQUEST); + } + } + + private String extractExtension(String originalFilename) { + if (originalFilename != null && originalFilename.contains(".")) { + return originalFilename.substring(originalFilename.lastIndexOf(".")); + } + return ""; + } + + /** + * URL, по которому фронтенд сможет получить медиа на любом устройстве. + * Presigned URL непригоден: он подписан под host MinIO (localhost:9010), + * который на клиенте из другой сети не резолвится (а правка host ломает + * подпись). Поэтому отдаём относительный путь на прокси-эндпоинт бэкенда + * (тот же подход, что и для картинок постов через /api/v1/files). + */ + public String getMediaUrl(String mediaKey) { + return "/api/v1/ads-media/" + mediaKey; + } + + /** + * Отдаёт медиа рекламы из ads-media потоком/байтами через бэкенд — + * без presigned URL, доступно с любого устройства. + */ + public ResponseEntity downloadMedia(String mediaKey) { + try { + HeadObjectResponse head = s3Client.headObject( + HeadObjectRequest.builder().bucket(adsBucket).key(mediaKey).build()); + + String contentType = head.contentType() != null + ? head.contentType() + : MediaType.APPLICATION_OCTET_STREAM_VALUE; + + byte[] content = s3Client.getObjectAsBytes( + GetObjectRequest.builder().bucket(adsBucket).key(mediaKey).build()).asByteArray(); + + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.CONTENT_DISPOSITION, "inline"); + + log.info("downloadMedia: served mediaKey={}, contentType={}, {} bytes", mediaKey, contentType, content.length); + return ResponseEntity.ok() + .headers(headers) + .contentType(MediaType.parseMediaType(contentType)) + .body(content); + } catch (NoSuchKeyException e) { + log.warn("Ad media not found: {}", mediaKey); + throw new ApiException(ErrorCode.NOT_FOUND, "Медиа не найдено.", HttpStatus.NOT_FOUND); + } catch (S3Exception | SdkClientException e) { + log.error("Failed to download ad media mediaKey={}", mediaKey, e); + throw new ApiException(ErrorCode.INTERNAL_ERROR, "Ошибка при скачивании медиа.", HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + /** + * Presigned URL для прямой загрузки исходника с фронтенда в обход бэкенда. + * TTL длиннее, чем у GET — загрузка большого видео может занять время + * на медленном интернете у рекламодателя. + */ + public String getPresignedUploadUrl(String mediaKey, String contentType) { + log.info("PresignedUploadUrl {}", mediaKey); + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(adsBucket) + .key(mediaKey) + .contentType(contentType) + .build(); + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(30)) + .putObjectRequest(putObjectRequest) + .build(); + + return s3Presigner.presignPutObject(presignRequest).url().toString(); + } + + /** + * Скачивает исходник из MinIO во временный поток для последующего + * транскодинга — вызывается уже на бэкенде, после подтверждения загрузки. + */ + public InputStream downloadSource(String mediaKey) { + return s3Client.getObject(GetObjectRequest.builder() + .bucket(adsBucket) + .key(mediaKey) + .build()); + } + + public void deleteObject(String mediaKey) { + try { + s3Client.deleteObject(builder -> builder.bucket(adsBucket).key(mediaKey)); + } catch (Exception e) { + log.warn("Failed to delete object mediaKey={}", mediaKey, e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/service/advertising/AdvertisingSelectionService.java b/src/main/java/com/krylov/refound/service/advertising/AdvertisingSelectionService.java new file mode 100644 index 0000000..7482474 --- /dev/null +++ b/src/main/java/com/krylov/refound/service/advertising/AdvertisingSelectionService.java @@ -0,0 +1,97 @@ +package com.krylov.refound.service.advertising; + +import com.krylov.refound.dto.advertising.SelectedAdvertisingDto; +import com.krylov.refound.entity.advertising.Advertising; +import com.krylov.refound.repository.advertising.AdvertisingRepository; +import com.krylov.refound.service.UserService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ThreadLocalRandom; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AdvertisingSelectionService { + + private final UserService userService; + private final AdvertisingRepository adRepository; + private final AdvertisingFrequencyService frequencyService; + + /** + * Выбирает одно объявление для показа пользователю/сессии с учётом + * частотного капа и весов кампаний. Перебирает кандидатов в случайном + * порядке, взвешенном по weight, пока не найдёт первое разрешённое. + */ + public Optional selectAdvertising(Authentication authentication, String sessionId, HttpServletRequest request) { + + Long userId = extractUserId(authentication); + String resolvedSessionId = sessionId != null ? sessionId : request.getSession(true).getId(); + + if (!frequencyService.isSessionCooldownPassed(resolvedSessionId)) { + log.warn("isSessionCooldownPassed by {} resolvedSessionId is empty", resolvedSessionId); + return Optional.empty(); + } + + List eligible = adRepository.findEligibleAds(LocalDateTime.now()); + if (eligible.isEmpty()) { + log.warn("findEligibleAds is empty"); + return Optional.empty(); + } + + for (Advertising candidate : weightedOrder(eligible)) { + int dailyCap = candidate.getCampaign().getDailyImpressionCap(); + boolean allowed = frequencyService.tryRegisterImpression(userId, resolvedSessionId, candidate.getId(), dailyCap); + + if (allowed) { + log.info("advertising candidate {}", candidate); + return Optional.of(toDto(candidate)); + } + log.info("rollback dailyCap for candidate {}", candidate.getId()); + frequencyService.rollback(userId, resolvedSessionId, candidate.getId()); + } + + log.info("No ad passed frequency cap for userId={}, sessionId={}", userId, resolvedSessionId); + return Optional.empty(); + } + + private Long extractUserId(Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated() || "anonymousUser".equals(authentication.getPrincipal())) { + return null; + } + + String login = authentication.getName(); + log.info("extractUserId by login {}", login); + return userService.getUserIdByLogin(login); + } + + /** + * Взвешенная выборка без повторов (алгоритм Ефраимидиса-Спиракиса): + * каждому объявлению присваивается ключ u^(1/weight), сортировка по + * убыванию ключа даёт порядок, эквивалентный последовательному + * взвешенному семплированию без возврата. + */ + private List weightedOrder(List ads) { + ThreadLocalRandom random = ThreadLocalRandom.current(); + return ads.stream() + .sorted(Comparator.comparingDouble( + (Advertising ad) -> Math.pow(random.nextDouble(), 1.0 / Math.max(ad.getWeight(), 1))) + .reversed()) + .toList(); + } + + private SelectedAdvertisingDto toDto(Advertising ad) { + return new SelectedAdvertisingDto( + ad.getId(), + ad.getType().name(), + ad.getMediaKey(), + ad.getTargetUrl()); + } +} diff --git a/src/main/java/com/krylov/refound/service/advertising/AdvertisingStatusService.java b/src/main/java/com/krylov/refound/service/advertising/AdvertisingStatusService.java new file mode 100644 index 0000000..fc656d7 --- /dev/null +++ b/src/main/java/com/krylov/refound/service/advertising/AdvertisingStatusService.java @@ -0,0 +1,39 @@ +package com.krylov.refound.service.advertising; + +import com.krylov.refound.dto.advertising.TranscodingStatusDto; +import com.krylov.refound.enums.advertising.TranscodingStatus; +import com.krylov.refound.repository.advertising.AdvertisingRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AdvertisingStatusService { + + private final AdvertisingRepository advertisingRepository; + private final SimpMessagingTemplate messagingTemplate; + + @Transactional + public void markReady(Long advertisingId, String mediaKey) { + advertisingRepository.findById(advertisingId).ifPresent(ad -> { + ad.setMediaKey(mediaKey); + ad.setTranscodingStatus(TranscodingStatus.READY); + }); + notifyStatusChange(advertisingId, TranscodingStatus.READY); + } + + @Transactional + public void markFailed(Long advertisingId) { + advertisingRepository.findById(advertisingId).ifPresent(ad -> + ad.setTranscodingStatus(TranscodingStatus.FAILED)); + notifyStatusChange(advertisingId, TranscodingStatus.FAILED); + } + + private void notifyStatusChange(Long advertisingId, TranscodingStatus status) { + messagingTemplate.convertAndSend( + "/topic/ads/%d/status".formatted(advertisingId), + new TranscodingStatusDto(advertisingId, status)); + } +} diff --git a/src/main/java/com/krylov/refound/service/advertising/AdvertisingTranscodingService.java b/src/main/java/com/krylov/refound/service/advertising/AdvertisingTranscodingService.java new file mode 100644 index 0000000..b2e03c5 --- /dev/null +++ b/src/main/java/com/krylov/refound/service/advertising/AdvertisingTranscodingService.java @@ -0,0 +1,27 @@ +package com.krylov.refound.service.advertising; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AdvertisingTranscodingService { + + private final VideoTranscoderService videoTranscoderService; + private final AdvertisingStatusService statusService; + + @Async("ffmpegExecutor") + public void transcodeFromStorage(Long advertisingId, String sourceKey) { + try { + String mediaKey = videoTranscoderService.transcode(sourceKey, "videos"); + statusService.markReady(advertisingId, mediaKey); + log.info("Transcoding finished for advertisingId={}, mediaKey={}", advertisingId, mediaKey); + } catch (Exception e) { + log.error("Transcoding failed for advertisingId={}", advertisingId, e); + statusService.markFailed(advertisingId); + } + } +} diff --git a/src/main/java/com/krylov/refound/service/advertising/CampaignService.java b/src/main/java/com/krylov/refound/service/advertising/CampaignService.java new file mode 100644 index 0000000..0128689 --- /dev/null +++ b/src/main/java/com/krylov/refound/service/advertising/CampaignService.java @@ -0,0 +1,114 @@ +package com.krylov.refound.service.advertising; + +import com.krylov.refound.dto.advertising.CampaignCreateRequestDto; +import com.krylov.refound.dto.advertising.CampaignResponseDto; +import com.krylov.refound.dto.advertising.CampaignStatusUpdateRequestDto; +import com.krylov.refound.dto.advertising.CampaignUpdateRequestDto; +import com.krylov.refound.entity.advertising.Campaign; +import com.krylov.refound.enums.advertising.CampaignStatus; +import com.krylov.refound.exception.InvalidCampaignStatusTransitionException; +import com.krylov.refound.repository.advertising.CampaignRepository; +import jakarta.persistence.EntityNotFoundException; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CampaignService { + + private final CampaignRepository campaignRepository; + + public List getCampaign() { + return campaignRepository.findAllByOrderByCreatedAtDesc() + .stream() + .map(this::toDto) + .toList(); + } + + public Optional getCampaignById(Long campaignId) { + return campaignRepository.findById(campaignId).map(this::toDto); + } + + public CampaignResponseDto createCampaign(CampaignCreateRequestDto request){ + Campaign campaign = new Campaign(); + campaign.setName(request.name()); + campaign.setStatus(CampaignStatus.DRAFT); + campaign.setStartDate(request.startDate()); + campaign.setEndDate(request.endDate()); + campaign.setDailyImpressionCap(request.dailyImpressionCap()); + campaignRepository.save(campaign); + log.info("campaign {} created", campaign); + return toDto(campaign); + } + + @Transactional + public CampaignResponseDto updateCampaign(Long campaignId, CampaignUpdateRequestDto request) { + Campaign campaign = campaignRepository.findById(campaignId) + .orElseThrow(() -> new EntityNotFoundException("Campaign not found: " + campaignId)); + + if (!request.startDate().isBefore(request.endDate())) { + throw new IllegalArgumentException("startDate must be before endDate"); + } + if (request.dailyImpressionCap() <= 0) { + throw new IllegalArgumentException("dailyImpressionCap must be positive"); + } + + campaign.setName(request.name()); + campaign.setStartDate(request.startDate()); + campaign.setEndDate(request.endDate()); + campaign.setDailyImpressionCap(request.dailyImpressionCap()); + + log.info("campaign {} updated", campaignId); + return toDto(campaign); + } + + @Transactional + public CampaignResponseDto updateCampaignStatus(Long campaignId, CampaignStatusUpdateRequestDto request) { + Campaign campaign = campaignRepository.findById(campaignId) + .orElseThrow(() -> new EntityNotFoundException("Campaign not found: " + campaignId)); + + if (!isValidTransition(campaign.getStatus(), request.status())) { + throw new InvalidCampaignStatusTransitionException( + "Invalid status transition: " + + campaign.getStatus() + + " -> " + + request.status() + ); + } + + campaignRepository.updateStatus(campaignId, request.status()); + return toDto(campaign); + } + + /** + * Разрешённые переходы статуса кампании. Не даёт, например, воскресить + * FINISHED обратно в ACTIVE или перепрыгнуть из DRAFT сразу в FINISHED. + */ + private boolean isValidTransition(CampaignStatus from, CampaignStatus to) { + if (from == to) { + return false; + } + return switch (from) { + case DRAFT -> to == CampaignStatus.ACTIVE; + case ACTIVE -> to == CampaignStatus.PAUSED || to == CampaignStatus.FINISHED; + case PAUSED -> to == CampaignStatus.ACTIVE || to == CampaignStatus.FINISHED; + case FINISHED -> false; + }; + } + + private CampaignResponseDto toDto(Campaign campaign) { + return new CampaignResponseDto( + campaign.getId(), + campaign.getName(), + campaign.getStatus(), + campaign.getStartDate(), + campaign.getEndDate(), + campaign.getDailyImpressionCap()); + } + +} diff --git a/src/main/java/com/krylov/refound/service/advertising/FullscreenAdStatusService.java b/src/main/java/com/krylov/refound/service/advertising/FullscreenAdStatusService.java new file mode 100644 index 0000000..8299f00 --- /dev/null +++ b/src/main/java/com/krylov/refound/service/advertising/FullscreenAdStatusService.java @@ -0,0 +1,37 @@ +package com.krylov.refound.service.advertising; + +import com.krylov.refound.enums.advertising.TranscodingStatus; +import com.krylov.refound.repository.advertising.FullscreenAdvertisingRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class FullscreenAdStatusService { + + private final FullscreenAdvertisingRepository repository; + private final SimpMessagingTemplate messagingTemplate; + + @Transactional + public void markReady(Long id, String mediaKey) { + repository.findById(id).ifPresent(ad -> { + ad.setMediaKey(mediaKey); + ad.setTranscodingStatus(TranscodingStatus.READY); + }); + notifyStatusChange(id, TranscodingStatus.READY); + } + + @Transactional + public void markFailed(Long id) { + repository.findById(id).ifPresent(ad -> ad.setTranscodingStatus(TranscodingStatus.FAILED)); + notifyStatusChange(id, TranscodingStatus.FAILED); + } + + private void notifyStatusChange(Long id, TranscodingStatus status) { + messagingTemplate.convertAndSend( + "/topic/fullscreen-ads/%d/status".formatted(id), + new com.krylov.refound.dto.advertising.TranscodingStatusDto(id, status)); + } +} diff --git a/src/main/java/com/krylov/refound/service/advertising/FullscreenAdTranscodingService.java b/src/main/java/com/krylov/refound/service/advertising/FullscreenAdTranscodingService.java new file mode 100644 index 0000000..a3d985b --- /dev/null +++ b/src/main/java/com/krylov/refound/service/advertising/FullscreenAdTranscodingService.java @@ -0,0 +1,27 @@ +package com.krylov.refound.service.advertising; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FullscreenAdTranscodingService { + + private final VideoTranscoderService videoTranscoderService; + private final FullscreenAdStatusService statusService; + + @Async("ffmpegExecutor") + public void transcodeFromStorage(Long fullscreenAdId, String sourceKey) { + try { + String mediaKey = videoTranscoderService.transcode(sourceKey, "fullscreen"); + statusService.markReady(fullscreenAdId, mediaKey); + log.info("Fullscreen ad transcoding finished for id={}, mediaKey={}", fullscreenAdId, mediaKey); + } catch (Exception e) { + log.error("Fullscreen ad transcoding failed for id={}", fullscreenAdId, e); + statusService.markFailed(fullscreenAdId); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/service/advertising/FullscreenAdVideoUploadEventListener.java b/src/main/java/com/krylov/refound/service/advertising/FullscreenAdVideoUploadEventListener.java new file mode 100644 index 0000000..44ed75b --- /dev/null +++ b/src/main/java/com/krylov/refound/service/advertising/FullscreenAdVideoUploadEventListener.java @@ -0,0 +1,19 @@ +package com.krylov.refound.service.advertising; + +import com.krylov.refound.dto.advertising.FullscreenAdVideoUploadConfirmedEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class FullscreenAdVideoUploadEventListener { + + private final FullscreenAdTranscodingService transcodingService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onVideoUploadConfirmed(FullscreenAdVideoUploadConfirmedEvent event) { + transcodingService.transcodeFromStorage(event.fullscreenAdId(), event.sourceKey()); + } +} diff --git a/src/main/java/com/krylov/refound/service/advertising/VideoTranscoderService.java b/src/main/java/com/krylov/refound/service/advertising/VideoTranscoderService.java new file mode 100644 index 0000000..93b695d --- /dev/null +++ b/src/main/java/com/krylov/refound/service/advertising/VideoTranscoderService.java @@ -0,0 +1,119 @@ +package com.krylov.refound.service.advertising; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +/** + * Чистая логика транскодинга видео: скачать исходник → прогнать через + * ffmpeg → залить результат. Не знает ничего про Advertising, кампании + * или статусы конкретных сущностей — вызывающий код сам решает, что + * делать с результатом (mediaKey) или с исключением при неудаче. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class VideoTranscoderService { + + private final AdvertisingMediaService mediaService; + private final S3Client s3Client; + + @Value("${minio.ads-bucket}") + private String adsBucket; + + @Value("${ffmpeg.binary-path:ffmpeg}") + private String ffmpegPath; + + /** + * @return ключ (mediaKey) готового видео в MinIO + */ + public String transcode(String sourceKey, String outputPrefix) throws IOException, InterruptedException { + Path sourcePath = null; + Path outputPath = null; + try { + sourcePath = downloadToTemp(sourceKey); + outputPath = Files.createTempFile("transcoded-", ".mp4"); + + boolean success = runFfmpeg(sourcePath, outputPath); + if (!success) { + throw new IOException("ffmpeg exited with non-zero status or timed out"); + } + + String mediaKey = "%s/%s.mp4".formatted(outputPrefix, UUID.randomUUID()); + uploadToStorage(outputPath, mediaKey); + return mediaKey; + } finally { + if (sourcePath != null) cleanup(sourcePath); + if (outputPath != null) cleanup(outputPath); + mediaService.deleteObject(sourceKey); + } + } + + private Path downloadToTemp(String sourceKey) throws IOException { + Path tempFile = Files.createTempFile("source-", extractExtension(sourceKey)); + try (InputStream in = mediaService.downloadSource(sourceKey)) { + Files.copy(in, tempFile, StandardCopyOption.REPLACE_EXISTING); + } + return tempFile; + } + + private String extractExtension(String key) { + int dot = key.lastIndexOf('.'); + return dot >= 0 ? key.substring(dot) : ".tmp"; + } + + private boolean runFfmpeg(Path input, Path output) throws IOException, InterruptedException { + List command = List.of( + ffmpegPath, "-y", "-i", input.toString(), + "-vf", "scale=-2:720", + "-c:v", "libx264", "-preset", "medium", + "-b:v", "1500k", "-maxrate", "2000k", "-bufsize", "3000k", + "-c:a", "aac", "-b:a", "128k", + "-movflags", "+faststart", + output.toString() + ); + + ProcessBuilder builder = new ProcessBuilder(command); + builder.redirectErrorStream(true); + Process process = builder.start(); + + try (var reader = process.inputReader()) { + reader.lines().forEach(line -> log.debug("[ffmpeg] {}", line)); + } + + boolean finished = process.waitFor(5, TimeUnit.MINUTES); + if (!finished) { + process.destroyForcibly(); + log.error("FFmpeg timed out, killed process"); + return false; + } + return process.exitValue() == 0; + } + + private void uploadToStorage(Path filePath, String mediaKey) throws IOException { + s3Client.putObject( + PutObjectRequest.builder().bucket(adsBucket).key(mediaKey).contentType("video/mp4").build(), + RequestBody.fromFile(filePath)); + } + + private void cleanup(Path path) { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + log.warn("Failed to delete temp file: {}", path, e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/service/advertising/VideoUploadEventListener.java b/src/main/java/com/krylov/refound/service/advertising/VideoUploadEventListener.java new file mode 100644 index 0000000..bc171b5 --- /dev/null +++ b/src/main/java/com/krylov/refound/service/advertising/VideoUploadEventListener.java @@ -0,0 +1,19 @@ +package com.krylov.refound.service.advertising; + +import com.krylov.refound.dto.advertising.VideoUploadConfirmedEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class VideoUploadEventListener { + + private final AdvertisingTranscodingService transcodingService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onVideoUploadConfirmed(VideoUploadConfirmedEvent event) { + transcodingService.transcodeFromStorage(event.advertisingId(), event.sourceKey()); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index adb0c1e..0fdfa71 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,3 +1,6 @@ +ffmpeg: + binary-path: C:\ffmpeg\ffmpeg-9.0.2-essentials_build\bin\ffmpeg.exe + spring: servlet: multipart: @@ -29,6 +32,7 @@ spring: minio: endpoint: ${MINIO_ENDPOINT:http://localhost:9010} bucket: ${MINIO_BUCKET:refound-images} + ads-bucket: ${MINIO_ADS_BUCKET:ads-media} access-key: ${MINIO_ACCESS_KEY} secret-key: ${MINIO_SECRET_KEY} region: ${MINIO_REGION:us-east-1} diff --git a/src/main/resources/db/changelog/add/016-add-sessionid-click.yaml b/src/main/resources/db/changelog/add/016-add-sessionid-click.yaml new file mode 100644 index 0000000..434a76f --- /dev/null +++ b/src/main/resources/db/changelog/add/016-add-sessionid-click.yaml @@ -0,0 +1,13 @@ +databaseChangeLog: + - changeSet: + id: 2026-09-13-01-add-session-id-to-click + author: a.krylov + changes: + - addColumn: + tableName: click + columns: + - column: + name: session_id + type: VARCHAR(64) + constraints: + nullable: true \ No newline at end of file diff --git a/src/main/resources/db/changelog/alter/012-advertising-media_key.yaml b/src/main/resources/db/changelog/alter/012-advertising-media_key.yaml new file mode 100644 index 0000000..8acf813 --- /dev/null +++ b/src/main/resources/db/changelog/alter/012-advertising-media_key.yaml @@ -0,0 +1,14 @@ +databaseChangeLog: + - changeSet: + id: 012-advertising-media_key + author: a.krylov + changes: + - dropNotNullConstraint: + tableName: advertising + columnName: media_key + columnDataType: VARCHAR(512) + rollback: + - addNotNullConstraint: + tableName: advertising + columnName: media_key + columnDataType: VARCHAR(512) \ No newline at end of file diff --git a/src/main/resources/db/changelog/alter/013-del-ad_id-click-impression.yaml b/src/main/resources/db/changelog/alter/013-del-ad_id-click-impression.yaml new file mode 100644 index 0000000..936f3de --- /dev/null +++ b/src/main/resources/db/changelog/alter/013-del-ad_id-click-impression.yaml @@ -0,0 +1,38 @@ +databaseChangeLog: + - changeSet: + id: 2026-09-22-01-drop-ad-id-from-impression + author: a.krylov + preConditions: + - onFail: MARK_RAN + - columnExists: + tableName: impression + columnName: ad_id + changes: + - sql: + sql: ALTER TABLE impression DROP COLUMN ad_id CASCADE + rollback: + - addColumn: + tableName: impression + columns: + - column: + name: ad_id + type: BIGINT + + - changeSet: + id: 2026-09-22-02-drop-ad-id-from-click + author: a.krylov + preConditions: + - onFail: MARK_RAN + - columnExists: + tableName: click + columnName: ad_id + changes: + - sql: + sql: ALTER TABLE click DROP COLUMN ad_id CASCADE + rollback: + - addColumn: + tableName: click + columns: + - column: + name: ad_id + type: BIGINT \ No newline at end of file diff --git a/src/main/resources/db/changelog/create/010-add-advertising.yaml b/src/main/resources/db/changelog/create/010-add-advertising.yaml new file mode 100644 index 0000000..1f32001 --- /dev/null +++ b/src/main/resources/db/changelog/create/010-add-advertising.yaml @@ -0,0 +1,215 @@ +databaseChangeLog: + - changeSet: + id: 2026-09-11-01-create-campaign-table + author: a.krylov + changes: + - createTable: + tableName: campaign + columns: + - column: + name: id + type: BIGSERIAL + constraints: + primaryKey: true + nullable: false + - column: + name: name + type: VARCHAR(255) + constraints: + nullable: false + - column: + name: status + type: VARCHAR(20) + defaultValue: PAUSED + constraints: + nullable: false + - column: + name: start_date + type: TIMESTAMP + constraints: + nullable: false + - column: + name: end_date + type: TIMESTAMP + constraints: + nullable: false + - column: + name: daily_impression_cap + type: INTEGER + defaultValueNumeric: 10 + constraints: + nullable: false + - column: + name: created_at + type: TIMESTAMP + defaultValueComputed: CURRENT_TIMESTAMP + constraints: + nullable: false + - sql: + sql: >- + ALTER TABLE campaign + ADD CONSTRAINT chk_campaign_status + CHECK (status IN ('ACTIVE', 'PAUSED', 'DRAFT', 'FINISHED')) + rollback: + - sql: + sql: ALTER TABLE campaign DROP CONSTRAINT chk_campaign_status + + - changeSet: + id: 2026-09-11-02-create-advertising-table + author: a.krylov + changes: + - createTable: + tableName: advertising + columns: + - column: + name: id + type: BIGSERIAL + constraints: + primaryKey: true + nullable: false + - column: + name: campaign_id + type: BIGINT + constraints: + nullable: false + foreignKeyName: fk_advertising_campaign + references: campaign(id) + - column: + name: type + type: VARCHAR(10) + constraints: + nullable: false + - column: + name: media_key + type: VARCHAR(512) + constraints: + nullable: false + - column: + name: target_url + type: VARCHAR(1024) + constraints: + nullable: false + - column: + name: weight + type: INTEGER + defaultValueNumeric: 1 + constraints: + nullable: false + - column: + name: transcoding_status + type: VARCHAR(20) + defaultValue: PENDING + constraints: + nullable: false + - column: + name: created_at + type: TIMESTAMP + defaultValueComputed: CURRENT_TIMESTAMP + constraints: + nullable: false + - sql: + sql: >- + ALTER TABLE advertising + ADD CONSTRAINT chk_advertising_type + CHECK (type IN ('IMAGE', 'VIDEO')) + - sql: + sql: >- + ALTER TABLE advertising + ADD CONSTRAINT chk_advertising_transcoding_status + CHECK (transcoding_status IN ('PENDING', 'PROCESSING', 'READY', 'FAILED')) + - createIndex: + tableName: advertising + indexName: idx_advertising_campaign_id + columns: + - column: + name: campaign_id + rollback: + - sql: + sql: ALTER TABLE advertising DROP CONSTRAINT chk_advertising_type + - sql: + sql: ALTER TABLE advertising DROP CONSTRAINT chk_advertising_transcoding_status + + - changeSet: + id: 2026-09-11-03-create-impression-table + author: a.krylov + changes: + - createTable: + tableName: impression + columns: + - column: + name: id + type: BIGSERIAL + constraints: + primaryKey: true + nullable: false + - column: + name: advertising_id + type: BIGINT + constraints: + nullable: false + foreignKeyName: fk_impression_advertising + references: advertising(id) + - column: + name: user_id + type: BIGINT + - column: + name: session_id + type: VARCHAR(64) + constraints: + nullable: false + - column: + name: shown_at + type: TIMESTAMP + defaultValueComputed: CURRENT_TIMESTAMP + constraints: + nullable: false + - createIndex: + tableName: impression + indexName: idx_impression_advertising_id + columns: + - column: + name: advertising_id + - createIndex: + tableName: impression + indexName: idx_impression_user_shown_at + columns: + - column: + name: user_id + - column: + name: shown_at + + - changeSet: + id: 2026-09-11-04-create-click-table + author: a.krylov + changes: + - createTable: + tableName: click + columns: + - column: + name: id + type: BIGSERIAL + constraints: + primaryKey: true + nullable: false + - column: + name: advertising_id + type: BIGINT + constraints: + nullable: false + foreignKeyName: fk_click_advertising + references: advertising(id) + - column: + name: user_id + type: BIGINT + - column: + name: clicked_at + type: TIMESTAMP + defaultValueComputed: CURRENT_TIMESTAMP + constraints: + nullable: false + - createIndex: + tableName: click + indexName: idx_click_advertising_id + columns: + - column: + name: advertising_id \ No newline at end of file diff --git a/src/main/resources/db/changelog/create/011-create-fullscreen-ads.yaml b/src/main/resources/db/changelog/create/011-create-fullscreen-ads.yaml new file mode 100644 index 0000000..32fa44a --- /dev/null +++ b/src/main/resources/db/changelog/create/011-create-fullscreen-ads.yaml @@ -0,0 +1,66 @@ +databaseChangeLog: + - changeSet: + id: 2026-09-22-01-create-fullscreen-advertising-table + author: a.krylov + changes: + - createTable: + tableName: fullscreen_advertising + columns: + - column: + name: id + type: BIGSERIAL + constraints: + primaryKey: true + nullable: false + - column: + name: media_key + type: VARCHAR(512) + - column: + name: target_url + type: VARCHAR(1024) + - column: + name: status + type: VARCHAR(20) + defaultValue: DRAFT + constraints: + nullable: false + - column: + name: transcoding_status + type: VARCHAR(20) + defaultValue: PENDING + constraints: + nullable: false + - column: + name: created_at + type: TIMESTAMP + defaultValueComputed: CURRENT_TIMESTAMP + constraints: + nullable: false + - sql: + sql: >- + ALTER TABLE fullscreen_advertising + ADD CONSTRAINT chk_fullscreen_ad_status + CHECK (status IN ('DRAFT', 'ACTIVE', 'INACTIVE')) + - sql: + sql: >- + ALTER TABLE fullscreen_advertising + ADD CONSTRAINT chk_fullscreen_ad_transcoding_status + CHECK (transcoding_status IN ('PENDING', 'PROCESSING', 'READY', 'FAILED')) + # Гарантирует ровно один активный ролик на уровне БД, не только в коде: + # партиционный уникальный индекс — если ДВЕ строки одновременно имеют + # status='ACTIVE', обе будут иметь одинаковое значение 'ACTIVE' в этом + # индексе, что нарушит уникальность. + - sql: + sql: >- + CREATE UNIQUE INDEX uq_fullscreen_ad_single_active + ON fullscreen_advertising (status) + WHERE status = 'ACTIVE' + rollback: + - sql: + sql: DROP INDEX uq_fullscreen_ad_single_active + - sql: + sql: ALTER TABLE fullscreen_advertising DROP CONSTRAINT chk_fullscreen_ad_status + - sql: + sql: ALTER TABLE fullscreen_advertising DROP CONSTRAINT chk_fullscreen_ad_transcoding_status + - dropTable: + tableName: fullscreen_advertising \ No newline at end of file diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml index 07f8019..bfe7db1 100644 --- a/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -19,4 +19,9 @@ databaseChangeLog: - include: { file: db/changelog/create/009-create-statistic.yaml } - include: { file: db/changelog/alter/011-alter-posts-district-length.yaml} - include: { file: db/changelog/add/014-add-reward-posts.yaml } - - include: { file: db/changelog/add/015-add-desc-reward-posts.yaml } \ No newline at end of file + - include: { file: db/changelog/add/015-add-desc-reward-posts.yaml } + - include: { file: db/changelog/create/010-add-advertising.yaml } + - include: { file: db/changelog/add/016-add-sessionid-click.yaml } + - include: { file: db/changelog/alter/012-advertising-media_key.yaml } + - include: { file: db/changelog/alter/013-del-ad_id-click-impression.yaml } + - include: { file: db/changelog/create/011-create-fullscreen-ads.yaml } \ No newline at end of file