Merge pull request #35 from SlimusMinus/add-advertising

added advertising
This commit is contained in:
SlimusMinus
2026-09-23 23:36:55 +03:00
committed by GitHub
83 changed files with 2546 additions and 46 deletions

View File

@@ -44,6 +44,7 @@ dependencies {
// S3 / MinIO
implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.minio:minio:8.6.0'
// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.12.5'

View File

@@ -1,5 +1,3 @@
version: '3.8'
services:
postgres:
image: postgres:15
@@ -16,20 +14,23 @@ services:
- "6379:6379"
minio:
# Используемый образ Docker. Рекомендуется указывать конкретный тег для стабильности.
image: minio/minio:RELEASE.2024-04-18T19-09-19Z
image: quay.io/minio/minio:RELEASE.2024-04-18T19-09-19Z
container_name: minio-example
command: server /data --address ":9010" --console-address ":9011"
restart: unless-stopped
environment:
MINIO_ACCESS_KEY: admin
MINIO_SECRET_KEY: admin123
# Проброс портов с хост-машины в контейнер.
ports:
- "9010:9010" # API port
- "9011:9011" # Console port
volumes:
- minio-data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9010/minio/health/live"]
interval: 5s
timeout: 3s
retries: 10
deploy:
resources:
limits:
@@ -39,5 +40,23 @@ services:
options:
max-size: "10m"
max-file: "5"
mc-init:
image: quay.io/minio/mc:latest
depends_on:
minio:
condition: service_healthy
volumes:
- ./minio/cors.json:/cors.json:ro
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9010 admin admin123 &&
mc mb --ignore-existing local/ads-media &&
mc mb --ignore-existing local/refound-images &&
mc cors set local/ads-media /cors.json &&
echo 'MinIO buckets and CORS configured successfully'
"
restart: "no"
volumes:
minio-data:

10
minio/cors.json Normal file
View File

@@ -0,0 +1,10 @@
{
"CORSRules": [
{
"AllowedOrigins": ["http://localhost:5173", "http://localhost:3000"],
"AllowedMethods": ["PUT", "GET"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 3000
}
]
}

View 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;
}
}

View File

@@ -10,10 +10,12 @@ import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.S3Configuration;
import java.net.URI;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
@Configuration
@RequiredArgsConstructor
public class S3ClientConfig {
private final MinioProperties properties;
@Bean
@@ -21,12 +23,29 @@ public class S3ClientConfig {
return S3Client.builder()
.endpointOverride(URI.create(properties.endpoint()))
.region(Region.of(properties.region()))
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(properties.accessKey(), properties.secretKey())))
// ВАЖНО для MinIO: path-style вместо virtual-hosted style
.serviceConfiguration(S3Configuration.builder()
.credentialsProvider(credentialsProvider())
.serviceConfiguration(pathStyleConfiguration())
.build();
}
@Bean
public S3Presigner s3Presigner() {
return S3Presigner.builder()
.endpointOverride(URI.create(properties.endpoint()))
.region(Region.of(properties.region()))
.credentialsProvider(credentialsProvider())
.serviceConfiguration(pathStyleConfiguration())
.build();
}
private StaticCredentialsProvider credentialsProvider() {
return StaticCredentialsProvider.create(
AwsBasicCredentials.create(properties.accessKey(), properties.secretKey()));
}
private S3Configuration pathStyleConfiguration() {
return S3Configuration.builder()
.pathStyleAccessEnabled(true)
.build())
.build();
}

View File

@@ -5,7 +5,7 @@ import com.krylov.refound.dto.AuthResponse;
import com.krylov.refound.dto.CheckLoginDto;
import com.krylov.refound.dto.RefreshRequest;
import com.krylov.refound.dto.RegisterRequest;
import com.krylov.refound.dto.UserRegisterDto;
import com.krylov.refound.dto.user.UserRegisterDto;
import com.krylov.refound.service.AuthService;
import java.util.Map;
import lombok.RequiredArgsConstructor;

View File

@@ -1,8 +1,10 @@
package com.krylov.refound.controller;
import com.krylov.refound.dto.*;
import com.krylov.refound.dto.ChatDto;
import com.krylov.refound.dto.MessageDto;
import com.krylov.refound.service.ChatService;
import com.krylov.refound.service.UserService;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
@@ -10,9 +12,13 @@ import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
@@ -61,10 +67,13 @@ public class ChatController {
chatService.markAsRead(chatId, currentUserId);
}
private Long extractUserId(Authentication auth) {
// Подставь свою логику получения userId из JWT/Authentication
String login = auth.getName();
log.debug("extractUserId by login {}", login);
private Long extractUserId(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated() || "anonymousUser".equals(authentication.getPrincipal())) {
return null;
}
String login = authentication.getName();
log.info("extractUserId by login {}", login);
return userService.getUserIdByLogin(login);
}

View File

@@ -1,7 +1,7 @@
package com.krylov.refound.controller;
import com.krylov.refound.dto.UserResponseDto;
import com.krylov.refound.dto.UserUpdateDto;
import com.krylov.refound.dto.user.UserResponseDto;
import com.krylov.refound.dto.user.UserUpdateDto;
import com.krylov.refound.service.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

View File

@@ -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));
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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());
}
}

View File

@@ -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()));
}
}

View File

@@ -1,5 +1,6 @@
package com.krylov.refound.dto;
import com.krylov.refound.dto.user.UserDto;
import lombok.AllArgsConstructor;
import lombok.Data;

View File

@@ -0,0 +1,5 @@
package com.krylov.refound.dto.advertising;
public record AdImageUploadResponseDto(String mediaKey) {
}

View File

@@ -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
) {
}

View File

@@ -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
) {
}

View File

@@ -0,0 +1,10 @@
package com.krylov.refound.dto.advertising;
public record AdvertisingResponseDto(
Long advertisingId,
String type,
String mediaUrl,
String targetUrl
) {
}

View File

@@ -0,0 +1,13 @@
package com.krylov.refound.dto.advertising;
/**
* Обновление полей объявления: только targetUrl и weight.
* Тип, медиа и статус транскодинга при создании фиксируются
* (смена креатива делается через новое объявление).
*/
public record AdvertisingUpdateRequestDto(
String targetUrl,
int weight
) {
}

View File

@@ -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
) {
}

View File

@@ -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
) {
}

View File

@@ -0,0 +1,9 @@
package com.krylov.refound.dto.advertising;
import com.krylov.refound.enums.advertising.CampaignStatus;
public record CampaignStatusUpdateRequestDto(
CampaignStatus status
) {
}

View File

@@ -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
) {
}

View File

@@ -0,0 +1,8 @@
package com.krylov.refound.dto.advertising;
public record ClickRequestDto(
Long advertisingId,
String sessionId
) {
}

View File

@@ -0,0 +1,5 @@
package com.krylov.refound.dto.advertising;
public record ConfirmUploadRequestDto(String sourceKey) {
}

View File

@@ -0,0 +1,5 @@
package com.krylov.refound.dto.advertising;
public record FullscreenAdActivateRequestDto(boolean active) {
}

View File

@@ -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) {
}

View File

@@ -0,0 +1,3 @@
package com.krylov.refound.dto.advertising;
public record FullscreenAdCreateRequestDto(String targetUrl) {}

View File

@@ -0,0 +1,7 @@
package com.krylov.refound.dto.advertising;
public record FullscreenAdPublicResponseDto(
String mediaUrl,
String targetUrl) {
}

View File

@@ -0,0 +1,6 @@
package com.krylov.refound.dto.advertising;
public record FullscreenAdVideoUploadConfirmedEvent(
Long fullscreenAdId,
String sourceKey) {
}

View File

@@ -0,0 +1,7 @@
package com.krylov.refound.dto.advertising;
public record ImpressionRequestDto(
Long advertisingId,
String sessionId
) {
}

View File

@@ -0,0 +1,5 @@
package com.krylov.refound.dto.advertising;
public record PresignUploadRequestDto(String contentType) {
}

View File

@@ -0,0 +1,9 @@
package com.krylov.refound.dto.advertising;
public record PresignUploadResponseDto(
String uploadUrl,
String sourceKey,
int expiryMinutes
) {
}

View File

@@ -0,0 +1,10 @@
package com.krylov.refound.dto.advertising;
public record SelectedAdvertisingDto(
Long advertisingId,
String type,
String mediaKey,
String targetUrl
) {
}

View File

@@ -0,0 +1,7 @@
package com.krylov.refound.dto.advertising;
import com.krylov.refound.enums.advertising.TranscodingStatus;
public record TranscodingStatusDto(Long advertisingId, TranscodingStatus transcodingStatus) {
}

View File

@@ -0,0 +1,8 @@
package com.krylov.refound.dto.advertising;
public record VideoUploadConfirmedEvent(
Long advertisingId,
String sourceKey
) {
}

View File

@@ -1,4 +1,4 @@
package com.krylov.refound.dto;
package com.krylov.refound.dto.user;
import com.krylov.refound.enums.Role;
import lombok.AllArgsConstructor;

View File

@@ -1,4 +1,4 @@
package com.krylov.refound.dto;
package com.krylov.refound.dto.user;
public record UserRegisterDto(UserDto userDto, String token, String refreshToken) {

View File

@@ -1,5 +1,6 @@
package com.krylov.refound.dto;
package com.krylov.refound.dto.user;
import com.krylov.refound.enums.Role;
import lombok.Builder;
import lombok.Data;
@@ -13,4 +14,5 @@ public class UserResponseDto {
private String phone;
private String email;
private String avatar;
private Role role;
}

View File

@@ -1,4 +1,4 @@
package com.krylov.refound.dto;
package com.krylov.refound.dto.user;
import lombok.Data;
import org.springframework.web.multipart.MultipartFile;

View File

@@ -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();
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -7,5 +7,6 @@ public enum ErrorCode {
BAD_REQUEST,
UNAUTHORIZED,
FORBIDDEN,
CONTENT_BLOCKED
CONTENT_BLOCKED,
INVALID_STATUS_TRANSITION
}

View File

@@ -0,0 +1,6 @@
package com.krylov.refound.enums.advertising;
public enum AdvertisingType {
IMAGE,
VIDEO
}

View File

@@ -0,0 +1,8 @@
package com.krylov.refound.enums.advertising;
public enum CampaignStatus {
ACTIVE,
PAUSED,
DRAFT,
FINISHED
}

View File

@@ -0,0 +1,5 @@
package com.krylov.refound.enums.advertising;
public enum FullscreenAdStatus {
DRAFT, ACTIVE, INACTIVE
}

View File

@@ -0,0 +1,8 @@
package com.krylov.refound.enums.advertising;
public enum TranscodingStatus {
PENDING,
PROCESSING,
READY,
FAILED
}

View File

@@ -68,14 +68,7 @@ public class GlobalExceptionHandler {
return new ResponseEntity<>(response, status);
}
private ResponseEntity<ErrorResponse> buildResponse(
HttpStatus status,
ErrorCode code,
String message,
HttpServletRequest request,
Object details
) {
private ResponseEntity<ErrorResponse> buildResponse(HttpStatus status, ErrorCode code, String message, HttpServletRequest request, Object details) {
ErrorResponse response = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(status.value())
@@ -91,11 +84,7 @@ public class GlobalExceptionHandler {
}
@ExceptionHandler(ContentBlockedException.class)
public ResponseEntity<ErrorResponse> handleBlocked(
ContentBlockedException ex,
HttpServletRequest request
) {
public ResponseEntity<ErrorResponse> handleBlocked(ContentBlockedException ex, HttpServletRequest request) {
return buildResponse(
HttpStatus.BAD_REQUEST,
ErrorCode.CONTENT_BLOCKED,
@@ -107,4 +96,17 @@ public class GlobalExceptionHandler {
);
}
@ExceptionHandler(InvalidCampaignStatusTransitionException.class)
public ResponseEntity<ErrorResponse> handleInvalidStatusTransition(
InvalidCampaignStatusTransitionException ex, HttpServletRequest request) {
return buildResponse(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.INVALID_STATUS_TRANSITION, ex.getMessage(), request);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleIllegalArgument(
IllegalArgumentException ex, HttpServletRequest request) {
return buildResponse(HttpStatus.BAD_REQUEST, ErrorCode.VALIDATION_ERROR, ex.getMessage(), request);
}
}

View File

@@ -0,0 +1,8 @@
package com.krylov.refound.exception;
public class InvalidCampaignStatusTransitionException extends RuntimeException {
public InvalidCampaignStatusTransitionException(String message) {
super(message);
}
}

View File

@@ -75,6 +75,7 @@ public class StorageStructureService {
.status(ExpirationStatus.ENABLED)
.filter(f -> f.prefix(""))
.expiration(exp -> exp.days(180))
.noncurrentVersionExpiration(ncv -> ncv.noncurrentDays(180))
.build();
BucketLifecycleConfiguration lifecycleConfig = BucketLifecycleConfiguration.builder()

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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> {
}

View File

@@ -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();
}

View File

@@ -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> {
}

View File

@@ -12,6 +12,7 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
@@ -26,6 +27,7 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
@@ -49,6 +51,7 @@ public class SecurityConfig {
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/api/v1/test/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/files/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/ads-media/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/posts/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/reviews/**").permitAll()
.requestMatchers("/files/**", "/uploads/**").permitAll()
@@ -64,8 +67,15 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.PUT, "/api/v1/posts/**").authenticated()
.requestMatchers(HttpMethod.DELETE, "/api/v1/posts/**").authenticated()
// реклама
.requestMatchers(HttpMethod.GET, "/api/ads/next").permitAll()
.requestMatchers(HttpMethod.POST, "/api/ads/impression").permitAll()
.requestMatchers(HttpMethod.POST, "/api/ads/impression/beacon").permitAll()
.requestMatchers(HttpMethod.POST, "/api/ads/click").permitAll()
.requestMatchers(HttpMethod.GET, "/api/fullscreen-ad").permitAll()
// пример разграничения по ролям — раскомментировать и адаптировать под свои admin-эндпоинты
// .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)

View File

@@ -5,8 +5,8 @@ import com.krylov.refound.dto.AuthResponse;
import com.krylov.refound.dto.CheckLoginDto;
import com.krylov.refound.dto.RefreshRequest;
import com.krylov.refound.dto.RegisterRequest;
import com.krylov.refound.dto.UserDto;
import com.krylov.refound.dto.UserRegisterDto;
import com.krylov.refound.dto.user.UserDto;
import com.krylov.refound.dto.user.UserRegisterDto;
import com.krylov.refound.entity.User;
import com.krylov.refound.exception.InvalidCredentialsException;
import com.krylov.refound.exception.LoginAlreadyExistsException;

View File

@@ -1,7 +1,7 @@
package com.krylov.refound.service;
import com.krylov.refound.dto.UserResponseDto;
import com.krylov.refound.dto.UserUpdateDto;
import com.krylov.refound.dto.user.UserResponseDto;
import com.krylov.refound.dto.user.UserUpdateDto;
import com.krylov.refound.entity.User;
import com.krylov.refound.enums.ErrorCode;
import com.krylov.refound.exception.ApiException;
@@ -104,6 +104,7 @@ public class UserService {
.email(user.getEmail())
.lastName(user.getLastName())
.avatar(user.getAvatarUrl())
.role(user.getRole())
.build();
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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());
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}
}

View File

@@ -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());
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}
}

View File

@@ -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());
}
}

View File

@@ -1,3 +1,6 @@
ffmpeg:
binary-path: C:\ffmpeg\ffmpeg-9.0.2-essentials_build\bin\ffmpeg.exe
spring:
servlet:
multipart:
@@ -29,6 +32,7 @@ spring:
minio:
endpoint: ${MINIO_ENDPOINT:http://localhost:9010}
bucket: ${MINIO_BUCKET:refound-images}
ads-bucket: ${MINIO_ADS_BUCKET:ads-media}
access-key: ${MINIO_ACCESS_KEY}
secret-key: ${MINIO_SECRET_KEY}
region: ${MINIO_REGION:us-east-1}

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -20,3 +20,8 @@ databaseChangeLog:
- include: { file: db/changelog/alter/011-alter-posts-district-length.yaml}
- include: { file: db/changelog/add/014-add-reward-posts.yaml }
- include: { file: db/changelog/add/015-add-desc-reward-posts.yaml }
- include: { file: db/changelog/create/010-add-advertising.yaml }
- include: { file: db/changelog/add/016-add-sessionid-click.yaml }
- include: { file: db/changelog/alter/012-advertising-media_key.yaml }
- include: { file: db/changelog/alter/013-del-ad_id-click-impression.yaml }
- include: { file: db/changelog/create/011-create-fullscreen-ads.yaml }