target fix
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
package kz.konturai.parser.controller;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import kz.konturai.parser.dto.targeting.AdCreativeDto;
|
||||
import kz.konturai.parser.dto.targeting.CreativeRequest;
|
||||
import kz.konturai.parser.dto.targeting.MarketingAnalysisDto;
|
||||
import kz.konturai.parser.dto.targeting.MarketingStrategyDto;
|
||||
import kz.konturai.parser.dto.targeting.StrategyRequest;
|
||||
import kz.konturai.parser.dto.targeting.TargetingLaunchResultDto;
|
||||
import kz.konturai.parser.dto.targeting.TopicRequest;
|
||||
import kz.konturai.parser.service.AiTargetingSystemService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Stateless AI Targeting System API.
|
||||
*
|
||||
* Flow: POST /analysis → POST /strategy → POST /creative
|
||||
*
|
||||
* No database involved — each endpoint takes the previous step's output
|
||||
* as part of the request body and enriches it with AI-generated content.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/targeting")
|
||||
@RequiredArgsConstructor
|
||||
public class AiTargetingSystemController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(AiTargetingSystemController.class);
|
||||
|
||||
private final AiTargetingSystemService aiTargetingSystemService;
|
||||
|
||||
/**
|
||||
* STEP 1 — Marketing Analysis.
|
||||
*
|
||||
* Accepts a business topic and returns a structured marketing analysis
|
||||
* (target audience, platforms, budget, key messages, etc.).
|
||||
*
|
||||
* POST /api/targeting/analysis
|
||||
* Body: { "topic": "детский сад в Алматы" }
|
||||
*/
|
||||
@PostMapping("/analysis")
|
||||
public ResponseEntity<MarketingAnalysisDto> generateAnalysis(
|
||||
@RequestBody @Valid TopicRequest request) {
|
||||
|
||||
logger.info("POST /api/targeting/analysis — topic: {}", request.getTopic());
|
||||
MarketingAnalysisDto result = aiTargetingSystemService.generateAnalysis(request.getTopic());
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* STEP 2 — Marketing Strategy.
|
||||
*
|
||||
* Accepts the full analysis DTO and returns a 3-phase 30-day campaign strategy
|
||||
* with ad creatives brief and expected KPIs.
|
||||
*
|
||||
* POST /api/targeting/strategy
|
||||
* Body: { "analysis": { ...MarketingAnalysisDto... } }
|
||||
*/
|
||||
@PostMapping("/strategy")
|
||||
public ResponseEntity<MarketingStrategyDto> generateStrategy(
|
||||
@RequestBody @Valid StrategyRequest request) {
|
||||
|
||||
logger.info("POST /api/targeting/strategy — topic: {}",
|
||||
request.getAnalysis().getTopic());
|
||||
MarketingStrategyDto result = aiTargetingSystemService.generateStrategy(request.getAnalysis());
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* STEP 3 — Ad Creative.
|
||||
*
|
||||
* Accepts a creative brief (captionIdea + full analysis + full strategy)
|
||||
* and returns a ready-to-launch ad post: headline, caption, hashtags,
|
||||
* CTA button, and targeting settings.
|
||||
*
|
||||
* POST /api/targeting/creative
|
||||
* Body: {
|
||||
* "captionIdea": "string",
|
||||
* "analysis": { ...MarketingAnalysisDto... },
|
||||
* "strategy": { ...MarketingStrategyDto... }
|
||||
* }
|
||||
*/
|
||||
@PostMapping("/creative")
|
||||
public ResponseEntity<AdCreativeDto> generateCreative(
|
||||
@RequestBody @Valid CreativeRequest request) {
|
||||
|
||||
logger.info("POST /api/targeting/creative — captionIdea: {}", request.getCaptionIdea());
|
||||
AdCreativeDto result = aiTargetingSystemService.generateCreative(
|
||||
request.getCaptionIdea(),
|
||||
request.getAnalysis(),
|
||||
request.getStrategy());
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* STEP V3 — Launch Targeting from existing V3 Strategy.
|
||||
*
|
||||
* Берёт готовую V3-стратегию из MongoDB, генерирует настройки таргетинга
|
||||
* через AI на основе V3-анализа и публикует первый пост из postCalendar в Facebook.
|
||||
*
|
||||
* Требования: стратегия должна иметь статус "completed" и непустой postCalendar.
|
||||
*
|
||||
* POST /api/targeting/launch/{strategyId}
|
||||
*/
|
||||
@PostMapping("/launch/{strategyId}")
|
||||
public ResponseEntity<TargetingLaunchResultDto> launchFromStrategy(
|
||||
@PathVariable String strategyId) {
|
||||
|
||||
logger.info("POST /api/targeting/launch/{} — launching targeting from V3 strategy", strategyId);
|
||||
TargetingLaunchResultDto result = aiTargetingSystemService.launchTargetingFromStrategy(strategyId);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package kz.konturai.parser.controller;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
|
||||
@ExceptionHandler(IllegalStateException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleIllegalState(IllegalStateException e) {
|
||||
logger.error("AI processing error: {}", e.getMessage());
|
||||
return ResponseEntity.status(500).body(errorBody("AI_PROCESSING_ERROR", e.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleIllegalArgument(IllegalArgumentException e) {
|
||||
logger.warn("Invalid argument: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(errorBody("INVALID_ARGUMENT", e.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException e) {
|
||||
Map<String, String> fieldErrors = e.getBindingResult().getFieldErrors()
|
||||
.stream()
|
||||
.collect(Collectors.toMap(
|
||||
FieldError::getField,
|
||||
fe -> fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Некорректное значение",
|
||||
(existing, duplicate) -> existing
|
||||
));
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("error", "VALIDATION_ERROR");
|
||||
body.put("fields", fieldErrors);
|
||||
return ResponseEntity.badRequest().body(body);
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<Map<String, Object>> handleGeneral(Exception e) {
|
||||
logger.error("Unexpected error", e);
|
||||
return ResponseEntity.status(500).body(errorBody("INTERNAL_ERROR", e.getMessage()));
|
||||
}
|
||||
|
||||
private Map<String, Object> errorBody(String code, String message) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("error", code);
|
||||
body.put("message", message);
|
||||
return body;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package kz.konturai.parser.dto.targeting;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class AdCreativeDto {
|
||||
|
||||
private String headline;
|
||||
private String caption;
|
||||
private List<String> hashtags;
|
||||
private String ctaButton;
|
||||
private TargetingSettingsDto targetingSettings;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package kz.konturai.parser.dto.targeting;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class AdCreativeNeedDto {
|
||||
|
||||
private int id;
|
||||
private String type;
|
||||
private String theme;
|
||||
private String captionIdea;
|
||||
private String imageDescription;
|
||||
private String cta;
|
||||
private String targetSegment;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package kz.konturai.parser.dto.targeting;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class BudgetRecommendationDto {
|
||||
|
||||
private String minDaily;
|
||||
private String optimalDaily;
|
||||
private String monthly;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package kz.konturai.parser.dto.targeting;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class CreativeRequest {
|
||||
|
||||
@NotBlank(message = "Идея подписи не может быть пустой")
|
||||
private String captionIdea;
|
||||
|
||||
@NotNull(message = "Данные анализа обязательны")
|
||||
@Valid
|
||||
private MarketingAnalysisDto analysis;
|
||||
|
||||
@NotNull(message = "Данные стратегии обязательны")
|
||||
@Valid
|
||||
private MarketingStrategyDto strategy;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package kz.konturai.parser.dto.targeting;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class ExpectedResultsDto {
|
||||
|
||||
private String reach;
|
||||
private String clicks;
|
||||
private String conversions;
|
||||
private String cpl;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package kz.konturai.parser.dto.targeting;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class MarketingAnalysisDto {
|
||||
|
||||
private String topic;
|
||||
private String marketOverview;
|
||||
private TargetAudienceDto targetAudience;
|
||||
private List<PlatformDto> platforms;
|
||||
private List<String> bestAdFormats;
|
||||
private List<String> postingTimes;
|
||||
private BudgetRecommendationDto budgetRecommendation;
|
||||
private List<String> keyMessages;
|
||||
private String competitorsInsight;
|
||||
private String recommendedVisuals;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package kz.konturai.parser.dto.targeting;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class MarketingStrategyDto {
|
||||
|
||||
private String strategyName;
|
||||
private String goal;
|
||||
private String duration;
|
||||
private List<PhaseDto> phases;
|
||||
private List<AdCreativeNeedDto> adCreativesNeeded;
|
||||
private String totalBudget;
|
||||
private ExpectedResultsDto expectedResults;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package kz.konturai.parser.dto.targeting;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class PhaseDto {
|
||||
|
||||
private int phase;
|
||||
private String name;
|
||||
private String duration;
|
||||
private String objective;
|
||||
private List<String> tactics;
|
||||
private String kpi;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package kz.konturai.parser.dto.targeting;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class PlatformDto {
|
||||
|
||||
private String name;
|
||||
private String priority;
|
||||
private String reason;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package kz.konturai.parser.dto.targeting;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class StrategyRequest {
|
||||
|
||||
@NotNull(message = "Данные анализа обязательны")
|
||||
@Valid
|
||||
private MarketingAnalysisDto analysis;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package kz.konturai.parser.dto.targeting;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class TargetAudienceDto {
|
||||
|
||||
private String primary;
|
||||
private String secondary;
|
||||
private String ageRange;
|
||||
private String gender;
|
||||
private String income;
|
||||
private List<String> interests;
|
||||
private List<String> painPoints;
|
||||
private List<String> motivations;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package kz.konturai.parser.dto.targeting;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TargetingLaunchResultDto {
|
||||
|
||||
// ── Источник ──────────────────────────────────────────────────────────────
|
||||
private String strategyId;
|
||||
private String analysisId;
|
||||
private String scoringModelName;
|
||||
private String scoringModelTitle;
|
||||
|
||||
// ── Первый опубликованный пост ────────────────────────────────────────────
|
||||
private int postIndex;
|
||||
private String platform;
|
||||
private String contentType; // "фото" / "видео"
|
||||
private String theme;
|
||||
private String postText;
|
||||
private List<String> hashtags;
|
||||
private String imageUrl;
|
||||
private boolean imagePosted;
|
||||
|
||||
// ── Facebook ──────────────────────────────────────────────────────────────
|
||||
private String facebookPostId;
|
||||
private String facebookStatus; // "PUBLISHED" | "FAILED" | "TEXT_ONLY"
|
||||
|
||||
// ── Таргетинг (генерируется AI из V3 анализа) ────────────────────────────
|
||||
private TargetingSettingsDto targetingSettings;
|
||||
|
||||
// ── Сводка ───────────────────────────────────────────────────────────────
|
||||
private String message;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package kz.konturai.parser.dto.targeting;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class TargetingSettingsDto {
|
||||
|
||||
private String audience;
|
||||
private String age;
|
||||
private List<String> interests;
|
||||
private String placement;
|
||||
private String budgetPerDay;
|
||||
private String estimatedReach;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package kz.konturai.parser.dto.targeting;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class TopicRequest {
|
||||
|
||||
@NotBlank(message = "Тема не может быть пустой")
|
||||
@Size(min = 3, max = 300, message = "Тема должна содержать от 3 до 300 символов")
|
||||
private String topic;
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
package kz.konturai.parser.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import kz.konturai.parser.dto.MarketingAnalysisV3Request;
|
||||
import kz.konturai.parser.dto.MarketingAnalysisV3Result;
|
||||
import kz.konturai.parser.dto.targeting.AdCreativeDto;
|
||||
import kz.konturai.parser.dto.targeting.MarketingAnalysisDto;
|
||||
import kz.konturai.parser.dto.targeting.MarketingStrategyDto;
|
||||
import kz.konturai.parser.dto.targeting.TargetingLaunchResultDto;
|
||||
import kz.konturai.parser.dto.targeting.TargetingSettingsDto;
|
||||
import kz.konturai.parser.model.MarketingAnalysisV3Document;
|
||||
import kz.konturai.parser.model.MarketingStrategy;
|
||||
import kz.konturai.parser.repository.MarketingAnalysisV3Repository;
|
||||
import kz.konturai.parser.repository.MarketingStrategyRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* AI Targeting System Service.
|
||||
*
|
||||
* Два режима работы:
|
||||
* 1. STATELESS — generateAnalysis / generateStrategy / generateCreative
|
||||
* Для React-приложения: принимает тему, возвращает структуры без БД.
|
||||
*
|
||||
* 2. V3-LINKED — launchTargetingFromStrategy(strategyId)
|
||||
* Для полного цикла: берёт готовую V3-стратегию из MongoDB,
|
||||
* генерирует настройки таргетинга из V3-анализа через AI
|
||||
* и сразу публикует первый пост в Facebook.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AiTargetingSystemService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(AiTargetingSystemService.class);
|
||||
|
||||
// ── Существующие сервисы ──────────────────────────────────────────────────
|
||||
private final OpenAIAnalyticsService openAIAnalyticsService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final MarketingStrategyRepository strategyRepository;
|
||||
private final MarketingAnalysisV3Repository analysisV3Repository;
|
||||
private final FacebookPostingService facebookPostingService;
|
||||
private final MinIOService minIOService;
|
||||
|
||||
// ── Конфиг моделей (из application.properties targeting.ai.*) ────────────
|
||||
@Value("${targeting.ai.audience-model:gpt-4o}")
|
||||
private String analysisModel;
|
||||
|
||||
@Value("${targeting.ai.creatives-model:gpt-4o}")
|
||||
private String strategyModel;
|
||||
|
||||
@Value("${targeting.ai.creatives-model:gpt-4o}")
|
||||
private String creativeModel;
|
||||
|
||||
@Value("${targeting.ai.max-audience-tokens:2500}")
|
||||
private int analysisMaxTokens;
|
||||
|
||||
@Value("${targeting.ai.max-creatives-tokens:2000}")
|
||||
private int strategyMaxTokens;
|
||||
|
||||
@Value("${targeting.ai.max-creatives-tokens:2000}")
|
||||
private int creativeMaxTokens;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// V3-LINKED: Запуск таргетинга из существующей V3-стратегии
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Запускает таргетинг на основе готовой V3-стратегии:
|
||||
* 1. Загружает {@link MarketingStrategy} по strategyId из MongoDB.
|
||||
* 2. Загружает связанный {@link MarketingAnalysisV3Document}.
|
||||
* 3. Берёт первый пост из postCalendar.
|
||||
* 4. Генерирует настройки таргетинга через AI на основе V3-анализа.
|
||||
* 5. Публикует пост в Facebook (с изображением если есть).
|
||||
*
|
||||
* @param strategyId ID стратегии из коллекции marketing_strategy
|
||||
* @return результат запуска с Facebook postId и настройками таргетинга
|
||||
*/
|
||||
public TargetingLaunchResultDto launchTargetingFromStrategy(String strategyId) {
|
||||
logger.info("Launching targeting from V3 strategy: {}", strategyId);
|
||||
|
||||
// 1. Загружаем стратегию
|
||||
MarketingStrategy strategy = strategyRepository.findById(strategyId)
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Стратегия не найдена: " + strategyId));
|
||||
|
||||
if (!"completed".equalsIgnoreCase(strategy.getStatus())) {
|
||||
throw new IllegalStateException(
|
||||
"Стратегия ещё не готова. Текущий статус: " + strategy.getStatus());
|
||||
}
|
||||
|
||||
List<MarketingStrategy.PostCalendarItem> calendar = strategy.getPostCalendar();
|
||||
if (calendar == null || calendar.isEmpty()) {
|
||||
throw new IllegalStateException("Календарь постов пуст для стратегии: " + strategyId);
|
||||
}
|
||||
|
||||
// 2. Загружаем анализ
|
||||
MarketingAnalysisV3Document analysis = analysisV3Repository
|
||||
.findById(strategy.getAnalysisId())
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Анализ не найден: " + strategy.getAnalysisId()));
|
||||
|
||||
// 3. Берём первый пост из календаря
|
||||
MarketingStrategy.PostCalendarItem firstPost = calendar.get(0);
|
||||
logger.info("First post: platform={}, theme={}", firstPost.getPlatform(), firstPost.getTheme());
|
||||
|
||||
// 4. Генерируем настройки таргетинга через AI
|
||||
TargetingSettingsDto targetingSettings = generateTargetingSettingsFromV3(analysis);
|
||||
|
||||
// 5. Публикуем в Facebook
|
||||
String facebookPostId = null;
|
||||
boolean imagePosted = false;
|
||||
String facebookStatus;
|
||||
|
||||
try {
|
||||
byte[] imageBytes = downloadPostImage(firstPost.getImageFilename());
|
||||
|
||||
if (imageBytes != null && imageBytes.length > 0) {
|
||||
facebookPostId = facebookPostingService.postToPageWithImage(
|
||||
null,
|
||||
firstPost.getPostText(),
|
||||
firstPost.getHashtags(),
|
||||
imageBytes);
|
||||
imagePosted = true;
|
||||
facebookStatus = "PUBLISHED";
|
||||
logger.info("Published to Facebook WITH image. Post ID: {}", facebookPostId);
|
||||
} else {
|
||||
facebookPostId = facebookPostingService.postToPage(
|
||||
null,
|
||||
firstPost.getPostText(),
|
||||
firstPost.getHashtags());
|
||||
facebookStatus = "TEXT_ONLY";
|
||||
logger.info("Published to Facebook TEXT ONLY. Post ID: {}", facebookPostId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("Facebook posting failed for strategy {}: {}", strategyId, e.getMessage(), e);
|
||||
facebookStatus = "FAILED";
|
||||
}
|
||||
|
||||
return TargetingLaunchResultDto.builder()
|
||||
.strategyId(strategyId)
|
||||
.analysisId(strategy.getAnalysisId())
|
||||
.scoringModelName(strategy.getScoringModelName())
|
||||
.scoringModelTitle(strategy.getScoringModelTitle())
|
||||
.postIndex(0)
|
||||
.platform(firstPost.getPlatform())
|
||||
.contentType(firstPost.getContentType())
|
||||
.theme(firstPost.getTheme())
|
||||
.postText(firstPost.getPostText())
|
||||
.hashtags(firstPost.getHashtags())
|
||||
.imageUrl(firstPost.getImageUrl())
|
||||
.imagePosted(imagePosted)
|
||||
.facebookPostId(facebookPostId)
|
||||
.facebookStatus(facebookStatus)
|
||||
.targetingSettings(targetingSettings)
|
||||
.message(buildLaunchMessage(facebookStatus, facebookPostId))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерирует настройки таргетинга через AI из V3-анализа.
|
||||
* Использует requestData (ниша, города, поведение) + resultData (CII, конкуренты)
|
||||
* чтобы сформировать рекомендации по аудитории, бюджету и размещению.
|
||||
*/
|
||||
private TargetingSettingsDto generateTargetingSettingsFromV3(MarketingAnalysisV3Document analysis) {
|
||||
MarketingAnalysisV3Request req = analysis.getRequestData();
|
||||
MarketingAnalysisV3Result res = analysis.getResultData();
|
||||
|
||||
String systemPrompt = """
|
||||
Ты — эксперт по платному трафику и таргетированной рекламе на рынке Казахстана.
|
||||
На основе данных маркетингового анализа сформируй оптимальные настройки таргетинга.
|
||||
|
||||
Возвращай ТОЛЬКО валидный JSON без markdown, без пояснений, без блоков кода.
|
||||
Чистый JSON-объект и ничего более. Весь текст — на русском языке.
|
||||
""";
|
||||
|
||||
String instruction = String.format("""
|
||||
Сформируй настройки таргетинга для Facebook/Instagram рекламы и верни строго следующий JSON:
|
||||
{
|
||||
"audience": "однострочное описание целевой аудитории",
|
||||
"age": "возрастной диапазон, например 25-44",
|
||||
"interests": ["интерес1", "интерес2", "интерес3", "интерес4"],
|
||||
"placement": "Instagram Лента + Facebook Лента",
|
||||
"budgetPerDay": "рекомендуемый дневной бюджет в KZT",
|
||||
"estimatedReach": "оценочный охват в день, например 8 000 - 20 000 человек/день"
|
||||
}
|
||||
|
||||
Данные для анализа:
|
||||
Ниша: %s
|
||||
Продукт: %s
|
||||
Цель: %s
|
||||
Города: %s
|
||||
Тип клиентов: %s
|
||||
Средний чек: %s
|
||||
Статус SMM: %s
|
||||
""",
|
||||
safeGet(req != null ? req.getBusinessNiche() : null, "Не указано"),
|
||||
safeGet(req != null ? req.getProductName() : null, "Не указано"),
|
||||
safeGet(req != null ? req.getGoal() : null, "Не указано"),
|
||||
formatList(req != null ? req.getPromotionCities() : null),
|
||||
safeGet(req != null && req.getClientTarget() != null
|
||||
? req.getClientTarget().name() : null, "Не указано"),
|
||||
safeGet(req != null && req.getAverageCheck() != null
|
||||
? req.getAverageCheck().name() : null, "Не указано"),
|
||||
safeGet(req != null && req.getSmmStatus() != null
|
||||
? req.getSmmStatus().name() : null, "Не указано")
|
||||
);
|
||||
|
||||
// Обогащаем контекст данными из resultData если доступны
|
||||
String context = buildV3AnalysisContext(res);
|
||||
|
||||
String rawResponse = openAIAnalyticsService.generateWithInstructionWithModel(
|
||||
context, instruction, "ru", creativeModel, systemPrompt, 800, 60000L);
|
||||
|
||||
if (rawResponse == null) {
|
||||
logger.warn("AI did not return targeting settings, using defaults");
|
||||
return buildDefaultTargetingSettings(req);
|
||||
}
|
||||
|
||||
try {
|
||||
String json = extractJson(rawResponse);
|
||||
return objectMapper.readValue(json, TargetingSettingsDto.class);
|
||||
} catch (Exception e) {
|
||||
logger.warn("Failed to parse targeting settings from AI, using defaults. Error: {}", e.getMessage());
|
||||
return buildDefaultTargetingSettings(req);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Формирует читаемый контекст из resultData V3-анализа для AI-промпта.
|
||||
*/
|
||||
private String buildV3AnalysisContext(MarketingAnalysisV3Result res) {
|
||||
if (res == null) return "";
|
||||
|
||||
StringBuilder ctx = new StringBuilder();
|
||||
|
||||
if (res.getExecutiveSummary() != null) {
|
||||
MarketingAnalysisV3Result.ExecutiveSummary es = res.getExecutiveSummary();
|
||||
ctx.append("Уровень конкуренции: ").append(safeGet(es.getCompetitionLevel(), "")).append("\n");
|
||||
ctx.append("Тренд спроса: ").append(safeGet(es.getDemandTrend(), "")).append("\n");
|
||||
}
|
||||
|
||||
if (res.getCompetitionIntensity() != null) {
|
||||
ctx.append("Индекс конкурентности (CII): ")
|
||||
.append(res.getCompetitionIntensity().getCiiIndex()).append("/100\n");
|
||||
}
|
||||
|
||||
if (res.getContentProfile() != null && res.getContentProfile().getTopHashtags() != null) {
|
||||
ctx.append("Топ хештеги конкурентов: ")
|
||||
.append(String.join(", ", res.getContentProfile().getTopHashtags())).append("\n");
|
||||
}
|
||||
|
||||
return ctx.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Дефолтные настройки таргетинга если AI недоступен.
|
||||
*/
|
||||
private TargetingSettingsDto buildDefaultTargetingSettings(MarketingAnalysisV3Request req) {
|
||||
String city = req != null && req.getPromotionCities() != null && !req.getPromotionCities().isEmpty()
|
||||
? req.getPromotionCities().get(0) : "Алматы";
|
||||
String niche = req != null ? safeGet(req.getBusinessNiche(), "бизнес") : "бизнес";
|
||||
|
||||
return TargetingSettingsDto.builder()
|
||||
.audience("Жители " + city + ", интересующиеся " + niche)
|
||||
.age("25-44")
|
||||
.interests(List.of(niche, "Услуги", "Казахстан", "Качество"))
|
||||
.placement("Instagram Лента + Facebook Лента")
|
||||
.budgetPerDay("5 000 - 15 000 KZT")
|
||||
.estimatedReach("5 000 - 20 000 человек/день")
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачивает изображение из MinIO по имени файла.
|
||||
* Возвращает null если файл не найден или имя пустое.
|
||||
*/
|
||||
private byte[] downloadPostImage(String imageFilename) {
|
||||
if (imageFilename == null || imageFilename.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
InputStream stream = minIOService.downloadFile(imageFilename);
|
||||
if (stream == null) return null;
|
||||
byte[] bytes = stream.readAllBytes();
|
||||
stream.close();
|
||||
logger.info("Downloaded image from MinIO: {} ({} bytes)", imageFilename, bytes.length);
|
||||
return bytes;
|
||||
} catch (Exception e) {
|
||||
logger.warn("Could not download image '{}' from MinIO: {}", imageFilename, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String buildLaunchMessage(String facebookStatus, String postId) {
|
||||
return switch (facebookStatus) {
|
||||
case "PUBLISHED" -> "✅ Пост с изображением успешно опубликован в Facebook. ID: " + postId;
|
||||
case "TEXT_ONLY" -> "✅ Текстовый пост опубликован в Facebook. ID: " + postId;
|
||||
case "FAILED" -> "⚠️ Пост не удалось опубликовать в Facebook. Настройки таргетинга сохранены.";
|
||||
default -> "Запуск таргетинга выполнен.";
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// STATELESS: Независимый режим для React-приложения
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
public MarketingAnalysisDto generateAnalysis(String topic) {
|
||||
logger.info("Generating marketing analysis for topic: {}", topic);
|
||||
|
||||
String systemPrompt = """
|
||||
Ты — профессиональный маркетинговый аналитик с 15-летним опытом работы
|
||||
на рынках СНГ (Казахстан, Россия, Узбекистан). Специализируешься на digital-рекламе.
|
||||
|
||||
Возвращай ТОЛЬКО валидный JSON без markdown, без пояснений, без блоков кода.
|
||||
Чистый JSON-объект и ничего более. Весь текст внутри JSON — на русском языке.
|
||||
""";
|
||||
|
||||
String instruction = """
|
||||
Проанализируй данный бизнес для создания рекламной кампании и верни строго следующий JSON:
|
||||
{
|
||||
"topic": "string",
|
||||
"marketOverview": "2-3 предложения о рынке на русском",
|
||||
"targetAudience": {
|
||||
"primary": "описание основной аудитории",
|
||||
"secondary": "описание вторичной аудитории",
|
||||
"ageRange": "например 25-45",
|
||||
"gender": "например Женщины 70%, Мужчины 30%",
|
||||
"income": "например Средний и выше среднего",
|
||||
"interests": ["интерес1", "интерес2", "интерес3", "интерес4", "интерес5"],
|
||||
"painPoints": ["боль1", "боль2", "боль3"],
|
||||
"motivations": ["мотивация1", "мотивация2", "мотивация3"]
|
||||
},
|
||||
"platforms": [
|
||||
{ "name": "Instagram", "priority": "Высокий", "reason": "почему на русском" },
|
||||
{ "name": "Facebook", "priority": "Средний", "reason": "почему" },
|
||||
{ "name": "TikTok", "priority": "Низкий", "reason": "почему" }
|
||||
],
|
||||
"bestAdFormats": ["Stories", "Reels", "Публикация", "Карусель"],
|
||||
"postingTimes": ["Пн-Пт 18:00-21:00", "Сб-Вс 10:00-13:00"],
|
||||
"budgetRecommendation": {
|
||||
"minDaily": "5 000 KZT",
|
||||
"optimalDaily": "15 000 KZT",
|
||||
"monthly": "300 000 - 450 000 KZT"
|
||||
},
|
||||
"keyMessages": ["сообщение1", "сообщение2", "сообщение3"],
|
||||
"competitorsInsight": "краткий анализ конкурентов на русском",
|
||||
"recommendedVisuals": "подробное описание лучших визуалов на русском"
|
||||
}
|
||||
|
||||
Бизнес для анализа:
|
||||
""";
|
||||
|
||||
String rawResponse = openAIAnalyticsService.generateWithInstructionWithModel(
|
||||
topic, instruction, "ru", analysisModel, systemPrompt, analysisMaxTokens, 120000L);
|
||||
|
||||
return parseResponse(rawResponse, MarketingAnalysisDto.class, "Marketing Analysis");
|
||||
}
|
||||
|
||||
public MarketingStrategyDto generateStrategy(MarketingAnalysisDto analysis) {
|
||||
logger.info("Generating marketing strategy for topic: {}", analysis.getTopic());
|
||||
|
||||
String systemPrompt = """
|
||||
Ты — старший маркетинговый стратег, специалист по performance-маркетингу
|
||||
на рынках СНГ (Казахстан). Создаёшь эффективные рекламные стратегии.
|
||||
|
||||
Возвращай ТОЛЬКО валидный JSON без markdown, без пояснений, без блоков кода.
|
||||
Чистый JSON-объект и ничего более. Весь текст внутри JSON — на русском языке.
|
||||
""";
|
||||
|
||||
String instruction = """
|
||||
На основе предоставленного маркетингового анализа создай полную 30-дневную
|
||||
рекламную стратегию и верни строго следующий JSON:
|
||||
{
|
||||
"strategyName": "название стратегии на русском",
|
||||
"goal": "главная цель кампании на русском",
|
||||
"duration": "30 дней",
|
||||
"phases": [
|
||||
{
|
||||
"phase": 1,
|
||||
"name": "Осведомлённость",
|
||||
"duration": "10 дней",
|
||||
"objective": "что достигаем",
|
||||
"tactics": ["тактика1", "тактика2", "тактика3"],
|
||||
"kpi": "ожидаемый KPI"
|
||||
},
|
||||
{
|
||||
"phase": 2,
|
||||
"name": "Вовлечённость",
|
||||
"duration": "10 дней",
|
||||
"objective": "...",
|
||||
"tactics": ["..."],
|
||||
"kpi": "..."
|
||||
},
|
||||
{
|
||||
"phase": 3,
|
||||
"name": "Конверсия",
|
||||
"duration": "10 дней",
|
||||
"objective": "...",
|
||||
"tactics": ["..."],
|
||||
"kpi": "..."
|
||||
}
|
||||
],
|
||||
"adCreativesNeeded": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "Публикация в ленте",
|
||||
"theme": "тема поста",
|
||||
"captionIdea": "идея подписи",
|
||||
"imageDescription": "детальное описание изображения",
|
||||
"cta": "Записаться →",
|
||||
"targetSegment": "сегмент аудитории"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "Сторис",
|
||||
"theme": "...",
|
||||
"captionIdea": "...",
|
||||
"imageDescription": "...",
|
||||
"cta": "...",
|
||||
"targetSegment": "..."
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "Обложка Reels",
|
||||
"theme": "...",
|
||||
"captionIdea": "...",
|
||||
"imageDescription": "...",
|
||||
"cta": "...",
|
||||
"targetSegment": "..."
|
||||
}
|
||||
],
|
||||
"totalBudget": "450 000 KZT",
|
||||
"expectedResults": {
|
||||
"reach": "50 000 - 100 000 человек",
|
||||
"clicks": "2 000 - 5 000",
|
||||
"conversions": "100 - 300 лидов",
|
||||
"cpl": "1 500 - 3 000 KZT"
|
||||
}
|
||||
}
|
||||
|
||||
Маркетинговый анализ:
|
||||
""";
|
||||
|
||||
String rawResponse = openAIAnalyticsService.generateWithInstructionWithModel(
|
||||
serializeToJson(analysis, "analysis"), instruction, "ru",
|
||||
strategyModel, systemPrompt, strategyMaxTokens, 120000L);
|
||||
|
||||
return parseResponse(rawResponse, MarketingStrategyDto.class, "Marketing Strategy");
|
||||
}
|
||||
|
||||
public AdCreativeDto generateCreative(
|
||||
String captionIdea,
|
||||
MarketingAnalysisDto analysis,
|
||||
MarketingStrategyDto strategy) {
|
||||
|
||||
logger.info("Generating ad creative for idea: {}", captionIdea);
|
||||
|
||||
String systemPrompt = """
|
||||
Ты — профессиональный копирайтер для рекламы в социальных сетях.
|
||||
Специализируешься на рынке Казахстана, пишешь эмоционально и убедительно.
|
||||
Твои тексты всегда включают хук, выгоды, социальное доказательство и призыв к действию.
|
||||
|
||||
Возвращай ТОЛЬКО валидный JSON без markdown, без пояснений, без блоков кода.
|
||||
Чистый JSON-объект и ничего более. Весь текст внутри JSON — на русском языке.
|
||||
""";
|
||||
|
||||
String instruction = """
|
||||
На основе предоставленных данных создай рекламный пост и верни строго следующий JSON:
|
||||
{
|
||||
"headline": "короткий цепляющий заголовок до 10 слов на русском",
|
||||
"caption": "полный текст поста 3-5 предложений с эмоджи на русском",
|
||||
"hashtags": ["#тег1", "#тег2", "#тег3", "#тег4", "#тег5", "#тег6"],
|
||||
"ctaButton": "Записаться →",
|
||||
"targetingSettings": {
|
||||
"audience": "однострочное описание аудитории на русском",
|
||||
"age": "возрастной диапазон из анализа",
|
||||
"interests": ["интерес1", "интерес2", "интерес3", "интерес4"],
|
||||
"placement": "Instagram Лента + Facebook Лента",
|
||||
"budgetPerDay": "бюджет из анализа в KZT",
|
||||
"estimatedReach": "например 5 000 - 15 000 человек/день"
|
||||
}
|
||||
}
|
||||
|
||||
Данные для создания поста:
|
||||
""";
|
||||
|
||||
String contextJson = buildCreativeContext(captionIdea, analysis, strategy);
|
||||
String rawResponse = openAIAnalyticsService.generateWithInstructionWithModel(
|
||||
contextJson, instruction, "ru", creativeModel, systemPrompt, creativeMaxTokens, 90000L);
|
||||
|
||||
return parseResponse(rawResponse, AdCreativeDto.class, "Ad Creative");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// HELPERS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
private String extractJson(String rawResponse) {
|
||||
if (rawResponse == null || rawResponse.isBlank()) {
|
||||
throw new IllegalStateException("Пустой ответ от AI");
|
||||
}
|
||||
String trimmed = rawResponse.trim();
|
||||
if (trimmed.startsWith("```")) {
|
||||
int firstNewline = trimmed.indexOf('\n');
|
||||
int lastBacktick = trimmed.lastIndexOf("```");
|
||||
if (firstNewline > 0 && lastBacktick > firstNewline) {
|
||||
trimmed = trimmed.substring(firstNewline + 1, lastBacktick).trim();
|
||||
}
|
||||
}
|
||||
int start = trimmed.indexOf('{');
|
||||
int end = trimmed.lastIndexOf('}');
|
||||
if (start == -1 || end == -1 || end <= start) {
|
||||
throw new IllegalStateException("JSON не найден в ответе AI: "
|
||||
+ trimmed.substring(0, Math.min(200, trimmed.length())));
|
||||
}
|
||||
return trimmed.substring(start, end + 1);
|
||||
}
|
||||
|
||||
private <T> T parseResponse(String rawResponse, Class<T> type, String context) {
|
||||
if (rawResponse == null) {
|
||||
throw new IllegalStateException("AI не вернул ответ для: " + context);
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(extractJson(rawResponse), type);
|
||||
} catch (IllegalStateException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to parse {} response. Raw: {}", context, rawResponse, e);
|
||||
throw new IllegalStateException("Ошибка парсинга " + context + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String serializeToJson(Object obj, String context) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(obj);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Ошибка сериализации " + context + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String buildCreativeContext(String captionIdea, MarketingAnalysisDto analysis, MarketingStrategyDto strategy) {
|
||||
try {
|
||||
return objectMapper.createObjectNode()
|
||||
.put("captionIdea", captionIdea)
|
||||
.set("analysis", objectMapper.valueToTree(analysis))
|
||||
.toString()
|
||||
+ "\nСтратегия: " + objectMapper.writeValueAsString(strategy);
|
||||
} catch (Exception e) {
|
||||
return "Идея: " + captionIdea + "\nАнализ: " + serializeToJson(analysis, "analysis");
|
||||
}
|
||||
}
|
||||
|
||||
private String safeGet(String value, String fallback) {
|
||||
return (value != null && !value.isBlank()) ? value : fallback;
|
||||
}
|
||||
|
||||
private String formatList(List<String> list) {
|
||||
if (list == null || list.isEmpty()) return "Не указано";
|
||||
return String.join(", ", list);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user