Merge pull request #35 from SlimusMinus/add-advertising
added advertising
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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:
|
||||
10
minio/cors.json
Normal file
10
minio/cors.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"CORSRules": [
|
||||
{
|
||||
"AllowedOrigins": ["http://localhost:5173", "http://localhost:3000"],
|
||||
"AllowedMethods": ["PUT", "GET"],
|
||||
"AllowedHeaders": ["*"],
|
||||
"MaxAgeSeconds": 3000
|
||||
}
|
||||
]
|
||||
}
|
||||
25
src/main/java/com/krylov/refound/config/AsyncConfig.java
Normal file
25
src/main/java/com/krylov/refound/config/AsyncConfig.java
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<PresignUploadResponseDto> 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<Void> 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<List<AdvertisingAdminResponseDto>> getAdvertisingsByCampaign(@RequestParam Long campaignId) {
|
||||
log.info("getAdvertisingsByCampaign {}", campaignId);
|
||||
List<AdvertisingAdminResponseDto> advertisings = advertisingAdminService.getAdvertisingsByCampaign(campaignId);
|
||||
return ResponseEntity.ok(advertisings);
|
||||
}
|
||||
|
||||
@GetMapping("/{advertisingId}/status")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<TranscodingStatusDto> 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<AdvertisingAdminResponseDto> 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<AdvertisingAdminResponseDto> 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<AdImageUploadResponseDto> uploadAdImage(@RequestParam("file") MultipartFile file) {
|
||||
String mediaKey = mediaService.uploadImage(file);
|
||||
return ResponseEntity.ok(new AdImageUploadResponseDto(mediaKey));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<AdvertisingResponseDto> getNextAdvertising(Authentication authentication, @RequestHeader(value = "X-Session-Id", required = false) String sessionId, HttpServletRequest request) {
|
||||
log.info("getNextAdvertising");
|
||||
Optional<SelectedAdvertisingDto> 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<Void> 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<Void> registerClick(@RequestBody ClickRequestDto request, Authentication authentication) {
|
||||
log.info("registerClick");
|
||||
advertisingClickService.recordClick(request.advertisingId(), authentication, request.sessionId());
|
||||
return ResponseEntity.accepted().build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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/<uuid>.jpg"), поэтому используем wildcard {*key},
|
||||
* как в FileStorageController для картинок постов.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/ads-media")
|
||||
@RequiredArgsConstructor
|
||||
public class AdvertisingMediaController {
|
||||
|
||||
private final AdvertisingMediaService mediaService;
|
||||
|
||||
@GetMapping("/{*key}")
|
||||
public ResponseEntity<byte[]> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<List<CampaignResponseDto>> getAllCampaigns() {
|
||||
List<CampaignResponseDto> campaigns = campaignService.getCampaign();
|
||||
return ResponseEntity .ok(campaigns);
|
||||
}
|
||||
|
||||
@GetMapping("/{campaignId}")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<CampaignResponseDto> 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<CampaignResponseDto> 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<CampaignResponseDto> 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<CampaignResponseDto> updateStatus(@PathVariable Long campaignId, @Valid @RequestBody CampaignStatusUpdateRequestDto request) {
|
||||
log.info("updateStatus company {}", campaignId);
|
||||
CampaignResponseDto campaign = campaignService.updateCampaignStatus(campaignId, request);
|
||||
return ResponseEntity.ok(campaign);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<List<FullscreenAdAdminResponseDto>> getAll() {
|
||||
List<FullscreenAdAdminResponseDto> ads = repository.findAllByOrderByCreatedAtDesc()
|
||||
.stream().map(this::toDto).toList();
|
||||
return ResponseEntity.ok(ads);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ResponseEntity<FullscreenAdAdminResponseDto> 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<PresignUploadResponseDto> 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<Void> 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<TranscodingStatusDto> 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<FullscreenAdAdminResponseDto> 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<FullscreenAdAdminResponseDto> 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());
|
||||
}
|
||||
}
|
||||
@@ -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<FullscreenAdPublicResponseDto> getCurrent() {
|
||||
log.info("GET /api/fullscreen-ad called");
|
||||
|
||||
Optional<FullscreenAdvertising> 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()));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.krylov.refound.dto;
|
||||
|
||||
import com.krylov.refound.dto.user.UserDto;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.krylov.refound.dto.advertising;
|
||||
|
||||
public record AdImageUploadResponseDto(String mediaKey) {
|
||||
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.krylov.refound.dto.advertising;
|
||||
|
||||
public record AdvertisingResponseDto(
|
||||
Long advertisingId,
|
||||
String type,
|
||||
String mediaUrl,
|
||||
String targetUrl
|
||||
) {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.krylov.refound.dto.advertising;
|
||||
|
||||
/**
|
||||
* Обновление полей объявления: только targetUrl и weight.
|
||||
* Тип, медиа и статус транскодинга при создании фиксируются
|
||||
* (смена креатива делается через новое объявление).
|
||||
*/
|
||||
public record AdvertisingUpdateRequestDto(
|
||||
String targetUrl,
|
||||
int weight
|
||||
) {
|
||||
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.krylov.refound.dto.advertising;
|
||||
|
||||
import com.krylov.refound.enums.advertising.CampaignStatus;
|
||||
|
||||
public record CampaignStatusUpdateRequestDto(
|
||||
CampaignStatus status
|
||||
) {
|
||||
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.krylov.refound.dto.advertising;
|
||||
|
||||
public record ClickRequestDto(
|
||||
Long advertisingId,
|
||||
String sessionId
|
||||
) {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.krylov.refound.dto.advertising;
|
||||
|
||||
public record ConfirmUploadRequestDto(String sourceKey) {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.krylov.refound.dto.advertising;
|
||||
|
||||
public record FullscreenAdActivateRequestDto(boolean active) {
|
||||
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.krylov.refound.dto.advertising;
|
||||
|
||||
public record FullscreenAdCreateRequestDto(String targetUrl) {}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.krylov.refound.dto.advertising;
|
||||
|
||||
public record FullscreenAdPublicResponseDto(
|
||||
String mediaUrl,
|
||||
String targetUrl) {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.krylov.refound.dto.advertising;
|
||||
|
||||
public record FullscreenAdVideoUploadConfirmedEvent(
|
||||
Long fullscreenAdId,
|
||||
String sourceKey) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.krylov.refound.dto.advertising;
|
||||
|
||||
public record ImpressionRequestDto(
|
||||
Long advertisingId,
|
||||
String sessionId
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.krylov.refound.dto.advertising;
|
||||
|
||||
public record PresignUploadRequestDto(String contentType) {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.krylov.refound.dto.advertising;
|
||||
|
||||
public record PresignUploadResponseDto(
|
||||
String uploadUrl,
|
||||
String sourceKey,
|
||||
int expiryMinutes
|
||||
) {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.krylov.refound.dto.advertising;
|
||||
|
||||
public record SelectedAdvertisingDto(
|
||||
Long advertisingId,
|
||||
String type,
|
||||
String mediaKey,
|
||||
String targetUrl
|
||||
) {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.krylov.refound.dto.advertising;
|
||||
|
||||
import com.krylov.refound.enums.advertising.TranscodingStatus;
|
||||
|
||||
public record TranscodingStatusDto(Long advertisingId, TranscodingStatus transcodingStatus) {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.krylov.refound.dto.advertising;
|
||||
|
||||
public record VideoUploadConfirmedEvent(
|
||||
Long advertisingId,
|
||||
String sourceKey
|
||||
) {
|
||||
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.krylov.refound.dto;
|
||||
package com.krylov.refound.dto.user;
|
||||
|
||||
import com.krylov.refound.enums.Role;
|
||||
import lombok.AllArgsConstructor;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.krylov.refound.dto;
|
||||
package com.krylov.refound.dto.user;
|
||||
|
||||
public record UserRegisterDto(UserDto userDto, String token, String refreshToken) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.krylov.refound.dto;
|
||||
package com.krylov.refound.dto.user;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -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<Impression> impressions = new ArrayList<>();
|
||||
|
||||
@OneToMany(
|
||||
mappedBy = "advertising",
|
||||
cascade = CascadeType.ALL,
|
||||
orphanRemoval = true
|
||||
)
|
||||
private List<Click> clicks = new ArrayList<>();
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
if (createdAt == null) {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Advertising> ads = new ArrayList<>();
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
if (createdAt == null) {
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,6 @@ public enum ErrorCode {
|
||||
BAD_REQUEST,
|
||||
UNAUTHORIZED,
|
||||
FORBIDDEN,
|
||||
CONTENT_BLOCKED
|
||||
CONTENT_BLOCKED,
|
||||
INVALID_STATUS_TRANSITION
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.krylov.refound.enums.advertising;
|
||||
|
||||
public enum AdvertisingType {
|
||||
IMAGE,
|
||||
VIDEO
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.krylov.refound.enums.advertising;
|
||||
|
||||
public enum CampaignStatus {
|
||||
ACTIVE,
|
||||
PAUSED,
|
||||
DRAFT,
|
||||
FINISHED
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.krylov.refound.enums.advertising;
|
||||
|
||||
public enum FullscreenAdStatus {
|
||||
DRAFT, ACTIVE, INACTIVE
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.krylov.refound.enums.advertising;
|
||||
|
||||
public enum TranscodingStatus {
|
||||
PENDING,
|
||||
PROCESSING,
|
||||
READY,
|
||||
FAILED
|
||||
}
|
||||
@@ -68,14 +68,7 @@ public class GlobalExceptionHandler {
|
||||
return new ResponseEntity<>(response, status);
|
||||
}
|
||||
|
||||
private ResponseEntity<ErrorResponse> buildResponse(
|
||||
HttpStatus status,
|
||||
ErrorCode code,
|
||||
String message,
|
||||
HttpServletRequest request,
|
||||
Object details
|
||||
) {
|
||||
|
||||
private ResponseEntity<ErrorResponse> 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<ErrorResponse> handleBlocked(
|
||||
ContentBlockedException ex,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
|
||||
public ResponseEntity<ErrorResponse> 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<ErrorResponse> handleInvalidStatusTransition(
|
||||
InvalidCampaignStatusTransitionException ex, HttpServletRequest request) {
|
||||
return buildResponse(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.INVALID_STATUS_TRANSITION, ex.getMessage(), request);
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<ErrorResponse> handleIllegalArgument(
|
||||
IllegalArgumentException ex, HttpServletRequest request) {
|
||||
return buildResponse(HttpStatus.BAD_REQUEST, ErrorCode.VALIDATION_ERROR, ex.getMessage(), request);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.krylov.refound.exception;
|
||||
|
||||
public class InvalidCampaignStatusTransitionException extends RuntimeException {
|
||||
|
||||
public InvalidCampaignStatusTransitionException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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<Advertising, Long> {
|
||||
|
||||
@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<Advertising> 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<Advertising> findAllByCampaignIdOrderByCreatedAtDesc(Long campaignId);
|
||||
|
||||
}
|
||||
@@ -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<Campaign, Long> {
|
||||
|
||||
@Modifying
|
||||
@Query("""
|
||||
UPDATE Campaign c
|
||||
SET c.status = :status
|
||||
WHERE c.id = :campaignId
|
||||
""")
|
||||
void updateStatus(@Param("campaignId") Long campaignId, @Param("status") CampaignStatus status);
|
||||
|
||||
List<Campaign> findAllByOrderByCreatedAtDesc();
|
||||
|
||||
}
|
||||
@@ -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<Click, Long> {
|
||||
}
|
||||
@@ -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<FullscreenAdvertising, Long> {
|
||||
|
||||
Optional<FullscreenAdvertising> findByStatus(FullscreenAdStatus status);
|
||||
|
||||
List<FullscreenAdvertising> findAllByOrderByCreatedAtDesc();
|
||||
}
|
||||
@@ -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<Impression, Long> {
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AdvertisingAdminResponseDto> 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());
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<byte[]> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<SelectedAdvertisingDto> 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<Advertising> 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<Advertising> weightedOrder(List<Advertising> 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());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<CampaignResponseDto> getCampaign() {
|
||||
return campaignRepository.findAllByOrderByCreatedAtDesc()
|
||||
.stream()
|
||||
.map(this::toDto)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public Optional<CampaignResponseDto> 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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
215
src/main/resources/db/changelog/create/010-add-advertising.yaml
Normal file
215
src/main/resources/db/changelog/create/010-add-advertising.yaml
Normal file
@@ -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
|
||||
@@ -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
|
||||
@@ -20,3 +20,8 @@ databaseChangeLog:
|
||||
- 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 }
|
||||
- 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 }
|
||||
Reference in New Issue
Block a user