Merge pull request #35 from SlimusMinus/add-advertising
added advertising
This commit is contained in:
@@ -44,6 +44,7 @@ dependencies {
|
|||||||
// S3 / MinIO
|
// S3 / MinIO
|
||||||
implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3'
|
implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||||
|
implementation 'io.minio:minio:8.6.0'
|
||||||
|
|
||||||
// JWT
|
// JWT
|
||||||
implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
|
implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15
|
image: postgres:15
|
||||||
@@ -16,20 +14,23 @@ services:
|
|||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
|
|
||||||
minio:
|
minio:
|
||||||
# Используемый образ Docker. Рекомендуется указывать конкретный тег для стабильности.
|
image: quay.io/minio/minio:RELEASE.2024-04-18T19-09-19Z
|
||||||
image: minio/minio:RELEASE.2024-04-18T19-09-19Z
|
|
||||||
container_name: minio-example
|
container_name: minio-example
|
||||||
command: server /data --address ":9010" --console-address ":9011"
|
command: server /data --address ":9010" --console-address ":9011"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
MINIO_ACCESS_KEY: admin
|
MINIO_ACCESS_KEY: admin
|
||||||
MINIO_SECRET_KEY: admin123
|
MINIO_SECRET_KEY: admin123
|
||||||
# Проброс портов с хост-машины в контейнер.
|
|
||||||
ports:
|
ports:
|
||||||
- "9010:9010" # API port
|
- "9010:9010" # API port
|
||||||
- "9011:9011" # Console port
|
- "9011:9011" # Console port
|
||||||
volumes:
|
volumes:
|
||||||
- minio-data:/data
|
- minio-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:9010/minio/health/live"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
@@ -39,5 +40,23 @@ services:
|
|||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "5"
|
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:
|
volumes:
|
||||||
minio-data:
|
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 software.amazon.awssdk.services.s3.S3Configuration;
|
||||||
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
|
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class S3ClientConfig {
|
public class S3ClientConfig {
|
||||||
|
|
||||||
private final MinioProperties properties;
|
private final MinioProperties properties;
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@@ -21,12 +23,29 @@ public class S3ClientConfig {
|
|||||||
return S3Client.builder()
|
return S3Client.builder()
|
||||||
.endpointOverride(URI.create(properties.endpoint()))
|
.endpointOverride(URI.create(properties.endpoint()))
|
||||||
.region(Region.of(properties.region()))
|
.region(Region.of(properties.region()))
|
||||||
.credentialsProvider(StaticCredentialsProvider.create(
|
.credentialsProvider(credentialsProvider())
|
||||||
AwsBasicCredentials.create(properties.accessKey(), properties.secretKey())))
|
.serviceConfiguration(pathStyleConfiguration())
|
||||||
// ВАЖНО для MinIO: path-style вместо virtual-hosted style
|
.build();
|
||||||
.serviceConfiguration(S3Configuration.builder()
|
}
|
||||||
.pathStyleAccessEnabled(true)
|
|
||||||
.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();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import com.krylov.refound.dto.AuthResponse;
|
|||||||
import com.krylov.refound.dto.CheckLoginDto;
|
import com.krylov.refound.dto.CheckLoginDto;
|
||||||
import com.krylov.refound.dto.RefreshRequest;
|
import com.krylov.refound.dto.RefreshRequest;
|
||||||
import com.krylov.refound.dto.RegisterRequest;
|
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 com.krylov.refound.service.AuthService;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package com.krylov.refound.controller;
|
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.ChatService;
|
||||||
import com.krylov.refound.service.UserService;
|
import com.krylov.refound.service.UserService;
|
||||||
|
import java.util.List;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.data.domain.Page;
|
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.domain.Sort;
|
||||||
import org.springframework.data.web.PageableDefault;
|
import org.springframework.data.web.PageableDefault;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import java.util.List;
|
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
|
@Slf4j
|
||||||
@RestController
|
@RestController
|
||||||
@@ -61,10 +67,13 @@ public class ChatController {
|
|||||||
chatService.markAsRead(chatId, currentUserId);
|
chatService.markAsRead(chatId, currentUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Long extractUserId(Authentication auth) {
|
private Long extractUserId(Authentication authentication) {
|
||||||
// Подставь свою логику получения userId из JWT/Authentication
|
if (authentication == null || !authentication.isAuthenticated() || "anonymousUser".equals(authentication.getPrincipal())) {
|
||||||
String login = auth.getName();
|
return null;
|
||||||
log.debug("extractUserId by login {}", login);
|
}
|
||||||
|
|
||||||
|
String login = authentication.getName();
|
||||||
|
log.info("extractUserId by login {}", login);
|
||||||
return userService.getUserIdByLogin(login);
|
return userService.getUserIdByLogin(login);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.krylov.refound.controller;
|
package com.krylov.refound.controller;
|
||||||
|
|
||||||
import com.krylov.refound.dto.UserResponseDto;
|
import com.krylov.refound.dto.user.UserResponseDto;
|
||||||
import com.krylov.refound.dto.UserUpdateDto;
|
import com.krylov.refound.dto.user.UserUpdateDto;
|
||||||
import com.krylov.refound.service.UserService;
|
import com.krylov.refound.service.UserService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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;
|
package com.krylov.refound.dto;
|
||||||
|
|
||||||
|
import com.krylov.refound.dto.user.UserDto;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Data;
|
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 com.krylov.refound.enums.Role;
|
||||||
import lombok.AllArgsConstructor;
|
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) {
|
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.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
@@ -13,4 +14,5 @@ public class UserResponseDto {
|
|||||||
private String phone;
|
private String phone;
|
||||||
private String email;
|
private String email;
|
||||||
private String avatar;
|
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 lombok.Data;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
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,
|
BAD_REQUEST,
|
||||||
UNAUTHORIZED,
|
UNAUTHORIZED,
|
||||||
FORBIDDEN,
|
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);
|
return new ResponseEntity<>(response, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ResponseEntity<ErrorResponse> buildResponse(
|
private ResponseEntity<ErrorResponse> buildResponse(HttpStatus status, ErrorCode code, String message, HttpServletRequest request, Object details) {
|
||||||
HttpStatus status,
|
|
||||||
ErrorCode code,
|
|
||||||
String message,
|
|
||||||
HttpServletRequest request,
|
|
||||||
Object details
|
|
||||||
) {
|
|
||||||
|
|
||||||
ErrorResponse response = ErrorResponse.builder()
|
ErrorResponse response = ErrorResponse.builder()
|
||||||
.timestamp(LocalDateTime.now())
|
.timestamp(LocalDateTime.now())
|
||||||
.status(status.value())
|
.status(status.value())
|
||||||
@@ -91,11 +84,7 @@ public class GlobalExceptionHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(ContentBlockedException.class)
|
@ExceptionHandler(ContentBlockedException.class)
|
||||||
public ResponseEntity<ErrorResponse> handleBlocked(
|
public ResponseEntity<ErrorResponse> handleBlocked(ContentBlockedException ex, HttpServletRequest request) {
|
||||||
ContentBlockedException ex,
|
|
||||||
HttpServletRequest request
|
|
||||||
) {
|
|
||||||
|
|
||||||
return buildResponse(
|
return buildResponse(
|
||||||
HttpStatus.BAD_REQUEST,
|
HttpStatus.BAD_REQUEST,
|
||||||
ErrorCode.CONTENT_BLOCKED,
|
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)
|
.status(ExpirationStatus.ENABLED)
|
||||||
.filter(f -> f.prefix(""))
|
.filter(f -> f.prefix(""))
|
||||||
.expiration(exp -> exp.days(180))
|
.expiration(exp -> exp.days(180))
|
||||||
|
.noncurrentVersionExpiration(ncv -> ncv.noncurrentDays(180))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
BucketLifecycleConfiguration lifecycleConfig = BucketLifecycleConfiguration.builder()
|
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.HttpMethod;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.MediaType;
|
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.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
@@ -26,6 +27,7 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
|||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
|
@EnableMethodSecurity
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
@@ -49,6 +51,7 @@ public class SecurityConfig {
|
|||||||
.requestMatchers("/api/v1/auth/**").permitAll()
|
.requestMatchers("/api/v1/auth/**").permitAll()
|
||||||
.requestMatchers("/api/v1/test/**").permitAll()
|
.requestMatchers("/api/v1/test/**").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/v1/files/**").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/posts/**").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/v1/reviews/**").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/v1/reviews/**").permitAll()
|
||||||
.requestMatchers("/files/**", "/uploads/**").permitAll()
|
.requestMatchers("/files/**", "/uploads/**").permitAll()
|
||||||
@@ -64,8 +67,15 @@ public class SecurityConfig {
|
|||||||
.requestMatchers(HttpMethod.PUT, "/api/v1/posts/**").authenticated()
|
.requestMatchers(HttpMethod.PUT, "/api/v1/posts/**").authenticated()
|
||||||
.requestMatchers(HttpMethod.DELETE, "/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-эндпоинты
|
// пример разграничения по ролям — раскомментировать и адаптировать под свои admin-эндпоинты
|
||||||
// .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
|
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
|
||||||
|
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import com.krylov.refound.dto.AuthResponse;
|
|||||||
import com.krylov.refound.dto.CheckLoginDto;
|
import com.krylov.refound.dto.CheckLoginDto;
|
||||||
import com.krylov.refound.dto.RefreshRequest;
|
import com.krylov.refound.dto.RefreshRequest;
|
||||||
import com.krylov.refound.dto.RegisterRequest;
|
import com.krylov.refound.dto.RegisterRequest;
|
||||||
import com.krylov.refound.dto.UserDto;
|
import com.krylov.refound.dto.user.UserDto;
|
||||||
import com.krylov.refound.dto.UserRegisterDto;
|
import com.krylov.refound.dto.user.UserRegisterDto;
|
||||||
import com.krylov.refound.entity.User;
|
import com.krylov.refound.entity.User;
|
||||||
import com.krylov.refound.exception.InvalidCredentialsException;
|
import com.krylov.refound.exception.InvalidCredentialsException;
|
||||||
import com.krylov.refound.exception.LoginAlreadyExistsException;
|
import com.krylov.refound.exception.LoginAlreadyExistsException;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.krylov.refound.service;
|
package com.krylov.refound.service;
|
||||||
|
|
||||||
import com.krylov.refound.dto.UserResponseDto;
|
import com.krylov.refound.dto.user.UserResponseDto;
|
||||||
import com.krylov.refound.dto.UserUpdateDto;
|
import com.krylov.refound.dto.user.UserUpdateDto;
|
||||||
import com.krylov.refound.entity.User;
|
import com.krylov.refound.entity.User;
|
||||||
import com.krylov.refound.enums.ErrorCode;
|
import com.krylov.refound.enums.ErrorCode;
|
||||||
import com.krylov.refound.exception.ApiException;
|
import com.krylov.refound.exception.ApiException;
|
||||||
@@ -104,6 +104,7 @@ public class UserService {
|
|||||||
.email(user.getEmail())
|
.email(user.getEmail())
|
||||||
.lastName(user.getLastName())
|
.lastName(user.getLastName())
|
||||||
.avatar(user.getAvatarUrl())
|
.avatar(user.getAvatarUrl())
|
||||||
|
.role(user.getRole())
|
||||||
.build();
|
.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:
|
spring:
|
||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
@@ -29,6 +32,7 @@ spring:
|
|||||||
minio:
|
minio:
|
||||||
endpoint: ${MINIO_ENDPOINT:http://localhost:9010}
|
endpoint: ${MINIO_ENDPOINT:http://localhost:9010}
|
||||||
bucket: ${MINIO_BUCKET:refound-images}
|
bucket: ${MINIO_BUCKET:refound-images}
|
||||||
|
ads-bucket: ${MINIO_ADS_BUCKET:ads-media}
|
||||||
access-key: ${MINIO_ACCESS_KEY}
|
access-key: ${MINIO_ACCESS_KEY}
|
||||||
secret-key: ${MINIO_SECRET_KEY}
|
secret-key: ${MINIO_SECRET_KEY}
|
||||||
region: ${MINIO_REGION:us-east-1}
|
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/alter/011-alter-posts-district-length.yaml}
|
||||||
- include: { file: db/changelog/add/014-add-reward-posts.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/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