target fix
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
package kz.konturai.parser.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "targeting")
|
||||
@Data
|
||||
public class TargetingApiConfig {
|
||||
private FacebookConfig facebook = new FacebookConfig();
|
||||
private TiktokConfig tiktok = new TiktokConfig();
|
||||
private Double kztUsdRate = 460.0;
|
||||
private int insightsSyncIntervalHours = 6;
|
||||
private AiConfig ai = new AiConfig();
|
||||
|
||||
@Data
|
||||
public static class FacebookConfig {
|
||||
private String appId;
|
||||
private String appSecret;
|
||||
private String graphApiVersion = "v19.0";
|
||||
private String oauthRedirectUri;
|
||||
private long rateLimitRetryMs = 60000;
|
||||
private int maxRetries = 3;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class TiktokConfig {
|
||||
private String appId;
|
||||
private String appSecret;
|
||||
private String oauthRedirectUri;
|
||||
private String baseUrl = "https://business-api.tiktok.com/open_api/v1.3";
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class AiConfig {
|
||||
private String audienceModel = "gpt-4o";
|
||||
private String creativesModel = "gpt-4o";
|
||||
private String budgetModel = "gpt-4o-mini";
|
||||
private int maxAudienceTokens = 3000;
|
||||
private int maxCreativesTokens = 8000;
|
||||
private int maxBudgetTokens = 2000;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package kz.konturai.parser.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 TargetingAsyncConfig {
|
||||
|
||||
@Bean(name = "targetingExecutor")
|
||||
public Executor targetingExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(3);
|
||||
executor.setMaxPoolSize(6);
|
||||
executor.setQueueCapacity(50);
|
||||
executor.setThreadNamePrefix("targeting-async-");
|
||||
executor.initialize();
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package kz.konturai.parser.controller;
|
||||
|
||||
import kz.konturai.parser.dto.*;
|
||||
import kz.konturai.parser.model.MarketingAnalysisV3Document;
|
||||
import kz.konturai.parser.model.TargetingAudienceProfile;
|
||||
import kz.konturai.parser.model.TargetingCampaign;
|
||||
import kz.konturai.parser.repository.MarketingAnalysisV3Repository;
|
||||
import kz.konturai.parser.service.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/targeting")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class TargetingCampaignController {
|
||||
|
||||
private final TargetingCampaignService campaignService;
|
||||
private final AudienceSegmentationService audienceService;
|
||||
private final BudgetOptimizerService budgetOptimizerService;
|
||||
private final FacebookAdsService facebookAdsService;
|
||||
private final TikTokAdsService tikTokAdsService;
|
||||
private final ABTestingService abTestingService;
|
||||
private final MarketingAnalysisV3Repository analysisRepository;
|
||||
private final SocialMediaCredentialsService credentialsService;
|
||||
|
||||
@PostMapping("/campaigns")
|
||||
public ResponseEntity<TargetingCampaign> createCampaign(
|
||||
@RequestHeader("X-User-Id") String userId,
|
||||
@RequestBody TargetingCampaignRequest request) {
|
||||
log.info("[Targeting] Create campaign requested by {}", userId);
|
||||
return ResponseEntity.ok(campaignService.createCampaign(request, userId));
|
||||
}
|
||||
|
||||
@GetMapping("/campaigns")
|
||||
public ResponseEntity<Page<TargetingCampaign>> getUserCampaigns(
|
||||
@RequestHeader("X-User-Id") String userId,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size) {
|
||||
log.info("[Targeting] Get campaigns for {}", userId);
|
||||
return ResponseEntity.ok(campaignService.getUserCampaigns(userId, page, size));
|
||||
}
|
||||
|
||||
@GetMapping("/campaigns/{id}")
|
||||
public ResponseEntity<TargetingCampaign> getCampaign(
|
||||
@RequestHeader("X-User-Id") String userId,
|
||||
@PathVariable String id) {
|
||||
log.info("[Targeting] Get campaign {} for {}", id, userId);
|
||||
return ResponseEntity.ok(campaignService.getCampaignById(id, userId));
|
||||
}
|
||||
|
||||
@PostMapping("/campaigns/{id}/pause")
|
||||
public ResponseEntity<TargetingCampaign> pauseCampaign(
|
||||
@RequestHeader("X-User-Id") String userId,
|
||||
@PathVariable String id) {
|
||||
log.info("[Targeting] Pause campaign {}", id);
|
||||
return ResponseEntity.ok(campaignService.pauseCampaign(id, userId));
|
||||
}
|
||||
|
||||
@PostMapping("/campaigns/{id}/resume")
|
||||
public ResponseEntity<TargetingCampaign> resumeCampaign(
|
||||
@RequestHeader("X-User-Id") String userId,
|
||||
@PathVariable String id) {
|
||||
log.info("[Targeting] Resume campaign {}", id);
|
||||
return ResponseEntity.ok(campaignService.resumeCampaign(id, userId));
|
||||
}
|
||||
|
||||
@GetMapping("/campaigns/{id}/insights")
|
||||
public ResponseEntity<TargetingPerformanceDto> getInsights(
|
||||
@RequestHeader("X-User-Id") String userId,
|
||||
@PathVariable String id,
|
||||
@RequestParam(defaultValue = "last_7d") String datePreset) {
|
||||
log.info("[Targeting] Get insights for campaign {}", id);
|
||||
return ResponseEntity.ok(campaignService.getCampaignInsights(id, userId, datePreset));
|
||||
}
|
||||
|
||||
@PostMapping("/campaigns/{id}/sync-insights")
|
||||
public ResponseEntity<Void> syncInsights(
|
||||
@RequestHeader("X-User-Id") String userId,
|
||||
@PathVariable String id) {
|
||||
log.info("[Targeting] Force sync insights for campaign {}", id);
|
||||
// Authorize
|
||||
campaignService.getCampaignById(id, userId);
|
||||
campaignService.syncInsights(id);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@GetMapping("/campaigns/{id}/ab-test/evaluate")
|
||||
public ResponseEntity<String> evaluateAbTest(
|
||||
@RequestHeader("X-User-Id") String userId,
|
||||
@PathVariable String id) {
|
||||
log.info("[Targeting] Evaluate AB Test for {}", id);
|
||||
TargetingCampaign campaign = campaignService.getCampaignById(id, userId);
|
||||
return ResponseEntity.ok(abTestingService.evaluateABTest(campaign));
|
||||
}
|
||||
|
||||
// --- Audience endpoints ---
|
||||
|
||||
@PostMapping("/audience/segment")
|
||||
public ResponseEntity<List<AudienceSegmentDto>> segmentAudience(
|
||||
@RequestHeader("X-User-Id") String userId,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String analysisId = body.get("analysisId");
|
||||
log.info("[Targeting] Segment audience for analysis {}", analysisId);
|
||||
MarketingAnalysisV3Document doc = analysisRepository.findById(analysisId).orElseThrow();
|
||||
return ResponseEntity.ok(audienceService.segmentAudience(doc));
|
||||
}
|
||||
|
||||
@PostMapping("/audience/estimate")
|
||||
public ResponseEntity<Map<String, Long>> estimateAudience(
|
||||
@RequestHeader("X-User-Id") String userId,
|
||||
@RequestParam String platform,
|
||||
@RequestBody TargetingAudienceProfile profile) {
|
||||
log.info("[Targeting] Estimate audience for platform {}", platform);
|
||||
return ResponseEntity.ok(audienceService.estimateAudienceSize(profile, platform));
|
||||
}
|
||||
|
||||
// --- Budget endpoint ---
|
||||
|
||||
@PostMapping("/budget/optimize")
|
||||
public ResponseEntity<BudgetOptimizationDto> optimizeBudget(
|
||||
@RequestHeader("X-User-Id") String userId,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String campaignId = body.get("campaignId");
|
||||
log.info("[Targeting] Optimize budget for campaign {}", campaignId);
|
||||
TargetingCampaign campaign = campaignService.getCampaignById(campaignId, userId);
|
||||
MarketingAnalysisV3Document doc = analysisRepository.findById(campaign.getAnalysisId()).orElseThrow();
|
||||
return ResponseEntity.ok(budgetOptimizerService.optimizeBudget(campaign, doc));
|
||||
}
|
||||
|
||||
// --- Account Selection endpoint ---
|
||||
|
||||
@PostMapping("/account/select")
|
||||
public ResponseEntity<Void> selectAdAccount(
|
||||
@RequestHeader("X-User-Id") String userId,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String platform = body.get("platform");
|
||||
String adAccountId = body.get("adAccountId");
|
||||
|
||||
log.info("[Targeting] Select ad account {} for platform {} by user {}", adAccountId, platform, userId);
|
||||
credentialsService.updateAdAccountId(userId, platform, adAccountId);
|
||||
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
// --- Facebook OAuth ---
|
||||
|
||||
@GetMapping("/facebook/oauth-url")
|
||||
public ResponseEntity<Map<String, String>> getFbOauthUrl(@RequestHeader("X-User-Id") String userId) {
|
||||
log.info("[Targeting] FB OAuth URL requested by {}", userId);
|
||||
return ResponseEntity.ok(Map.of("url", facebookAdsService.getOAuthUrl(userId)));
|
||||
}
|
||||
|
||||
@GetMapping("/facebook/callback")
|
||||
public ResponseEntity<String> fbCallback(
|
||||
@RequestParam String code,
|
||||
@RequestParam String state) {
|
||||
log.info("[Targeting] FB Callback received for state/userId: {}", state);
|
||||
try {
|
||||
facebookAdsService.exchangeCodeForToken(code, state);
|
||||
return ResponseEntity.ok("Facebook Ads account successfully linked!");
|
||||
} catch (Exception e) {
|
||||
log.error("[Targeting] FB Callback error", e);
|
||||
return ResponseEntity.badRequest().body("Facebook integration failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/facebook/ad-accounts")
|
||||
public ResponseEntity<List<FacebookAdAccountDto>> getFbAdAccounts(
|
||||
@RequestHeader("X-User-Id") String userId,
|
||||
@RequestHeader("Authorization") String token) {
|
||||
log.info("[Targeting] Get FB Ad Accounts");
|
||||
// token passed via header or db; assuming passed directly here for dynamic use or fetched via service inside
|
||||
return ResponseEntity.ok(facebookAdsService.getAdAccounts(token.replace("Bearer ", "")));
|
||||
}
|
||||
|
||||
// --- TikTok OAuth ---
|
||||
|
||||
@GetMapping("/tiktok/oauth-url")
|
||||
public ResponseEntity<Map<String, String>> getTtOauthUrl(@RequestHeader("X-User-Id") String userId) {
|
||||
log.info("[Targeting] TT OAuth URL requested by {}", userId);
|
||||
return ResponseEntity.ok(Map.of("url", tikTokAdsService.getOAuthUrl(userId)));
|
||||
}
|
||||
|
||||
@GetMapping("/tiktok/callback")
|
||||
public ResponseEntity<String> ttCallback(
|
||||
@RequestParam String auth_code,
|
||||
@RequestParam String state) {
|
||||
log.info("[Targeting] TT Callback received for state/userId: {}", state);
|
||||
try {
|
||||
tikTokAdsService.exchangeCodeForToken(auth_code, state);
|
||||
return ResponseEntity.ok("TikTok Ads account successfully linked!");
|
||||
} catch (Exception e) {
|
||||
log.error("[Targeting] TT Callback error", e);
|
||||
return ResponseEntity.badRequest().body("TikTok integration failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/tiktok/advertisers")
|
||||
public ResponseEntity<List<Map<String, Object>>> getTtAdvertisers(
|
||||
@RequestHeader("X-User-Id") String userId,
|
||||
@RequestHeader("Authorization") String token) {
|
||||
log.info("[Targeting] Get TT Advertisers");
|
||||
return ResponseEntity.ok(tikTokAdsService.getAdvertisers(token.replace("Bearer ", "")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package kz.konturai.parser.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AudienceOverrideDto {
|
||||
private int ageMin;
|
||||
private int ageMax;
|
||||
private List<String> genders;
|
||||
private List<String> cities;
|
||||
private List<String> additionalInterests;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package kz.konturai.parser.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AudienceSegmentDto {
|
||||
private String segmentName;
|
||||
private String description;
|
||||
private int ageMin;
|
||||
private int ageMax;
|
||||
private List<String> genders;
|
||||
private String incomeLevelKzt;
|
||||
private String platform;
|
||||
private List<String> facebookInterests;
|
||||
private List<String> tiktokInterestCategories;
|
||||
private List<String> behaviors;
|
||||
private Long estimatedReachAlmaty;
|
||||
private Long estimatedReachKazakhstan;
|
||||
private String recommendedMessage;
|
||||
private String bestTimeToShow;
|
||||
private String contentFormat;
|
||||
private Double estimatedCpmKzt;
|
||||
private int segmentScore;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package kz.konturai.parser.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class BudgetOptimizationDto {
|
||||
private Map<String, Double> platformAllocations;
|
||||
private Map<String, Double> dailyBudgetPerPlatform;
|
||||
private Long estimatedImpressions;
|
||||
private Long estimatedClicks;
|
||||
private Long estimatedLeads;
|
||||
private Double estimatedCostPerLead;
|
||||
private Double expectedRoas;
|
||||
private String riskLevel;
|
||||
private String riskRationale;
|
||||
private List<String> schedulingWindows;
|
||||
private String recommendedBidStrategy;
|
||||
private List<String> optimizationTips;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package kz.konturai.parser.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class FacebookAdAccountDto {
|
||||
private String id;
|
||||
private String name;
|
||||
private int accountStatus;
|
||||
private String currency;
|
||||
private String timezoneName;
|
||||
private String balance;
|
||||
private String amountSpent;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package kz.konturai.parser.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class InstagramInsightDto {
|
||||
private Long impressions;
|
||||
private Long reach;
|
||||
private Long clicks;
|
||||
private Double spend;
|
||||
private Double ctr;
|
||||
private Double cpm;
|
||||
private Double cpc;
|
||||
private Long actions;
|
||||
private Map<String, Double> costPerActionType;
|
||||
|
||||
// Additional fields for Instagram profile insights
|
||||
private Long profileViews;
|
||||
private Long websiteClicks;
|
||||
private Long followerCount;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package kz.konturai.parser.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TargetingAIAnalysisResult {
|
||||
private List<AudienceSegmentDto> audienceSegments;
|
||||
private List<AudienceSegmentDto> competitorInsights;
|
||||
private BudgetOptimizationDto budgetOptimization;
|
||||
private String aiRecommendationsRationale;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package kz.konturai.parser.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TargetingCampaignRequest {
|
||||
private String analysisId;
|
||||
private String strategyId;
|
||||
private String campaignName;
|
||||
private String objective;
|
||||
private List<String> platforms;
|
||||
private Double totalBudgetKzt;
|
||||
private Double dailyBudgetKzt;
|
||||
private LocalDateTime startDate;
|
||||
private LocalDateTime endDate;
|
||||
|
||||
private AudienceOverrideDto audienceOverride;
|
||||
|
||||
private boolean generateAiAudience;
|
||||
private boolean generateAdCreatives;
|
||||
private boolean enableAbTesting;
|
||||
|
||||
private List<String> referenceMediaFilenames;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package kz.konturai.parser.dto;
|
||||
|
||||
import kz.konturai.parser.enums.CampaignObjective;
|
||||
import kz.konturai.parser.model.BudgetConfig;
|
||||
import kz.konturai.parser.model.PerformanceMetrics;
|
||||
import kz.konturai.parser.model.TargetingAudienceProfile;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TargetingCampaignResponse {
|
||||
private String id;
|
||||
private String name;
|
||||
private String status;
|
||||
private CampaignObjective objective;
|
||||
private BudgetConfig budget;
|
||||
private TargetingAudienceProfile audience;
|
||||
private PerformanceMetrics performanceMetrics;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package kz.konturai.parser.dto;
|
||||
|
||||
import kz.konturai.parser.model.PerformanceMetrics;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TargetingPerformanceDto {
|
||||
private Double totalSpend;
|
||||
private Long totalImpressions;
|
||||
private Long totalClicks;
|
||||
private Long totalLeads;
|
||||
private Double avgCtr;
|
||||
private Double avgCpm;
|
||||
private Double avgCpc;
|
||||
private Double avgCpa;
|
||||
private Double roas;
|
||||
|
||||
private Map<String, PerformanceMetrics> platformBreakdown;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package kz.konturai.parser.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TikTokCampaignDto {
|
||||
private Double spend;
|
||||
private Long impressions;
|
||||
private Double ctr;
|
||||
private Double cpm;
|
||||
private Long videoViews;
|
||||
private Long conversions;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package kz.konturai.parser.enums;
|
||||
|
||||
public enum CampaignObjective {
|
||||
AWARENESS,
|
||||
TRAFFIC,
|
||||
ENGAGEMENT,
|
||||
LEADS,
|
||||
SALES,
|
||||
APP_INSTALLS
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package kz.konturai.parser.enums;
|
||||
|
||||
public enum TargetingPlatform {
|
||||
INSTAGRAM,
|
||||
FACEBOOK,
|
||||
TIKTOK
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package kz.konturai.parser.exception;
|
||||
|
||||
public class ABTestInsufficientDataException extends RuntimeException {
|
||||
public ABTestInsufficientDataException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ABTestInsufficientDataException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package kz.konturai.parser.exception;
|
||||
|
||||
public class AudienceGenerationException extends RuntimeException {
|
||||
public AudienceGenerationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public AudienceGenerationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package kz.konturai.parser.exception;
|
||||
|
||||
public class BudgetOptimizationException extends RuntimeException {
|
||||
public BudgetOptimizationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public BudgetOptimizationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package kz.konturai.parser.exception;
|
||||
|
||||
public class FacebookPermissionException extends RuntimeException {
|
||||
public FacebookPermissionException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public FacebookPermissionException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package kz.konturai.parser.exception;
|
||||
|
||||
public class TargetingCampaignNotFoundException extends RuntimeException {
|
||||
public TargetingCampaignNotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public TargetingCampaignNotFoundException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package kz.konturai.parser.exception;
|
||||
|
||||
public class TikTokApiException extends RuntimeException {
|
||||
public TikTokApiException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public TikTokApiException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package kz.konturai.parser.model;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.mongodb.core.mapping.Field;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A/B testing configuration for a targeting campaign.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ABTestConfig {
|
||||
|
||||
@Field("is_enabled")
|
||||
private boolean isEnabled;
|
||||
|
||||
@Field("variants")
|
||||
private List<String> variants;
|
||||
|
||||
@Field("winner_variant_id")
|
||||
private String winnerVariantId;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package kz.konturai.parser.model;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.mongodb.core.mapping.Field;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Budget configuration for a targeting campaign.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class BudgetConfig {
|
||||
|
||||
@Field("total_budget")
|
||||
private Double totalBudget;
|
||||
|
||||
@Field("daily_budget")
|
||||
private Double dailyBudget;
|
||||
|
||||
@Field("currency")
|
||||
@Builder.Default
|
||||
private String currency = "KZT";
|
||||
|
||||
@Field("start_date")
|
||||
private LocalDateTime startDate;
|
||||
|
||||
@Field("end_date")
|
||||
private LocalDateTime endDate;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package kz.konturai.parser.model;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.mongodb.core.mapping.Field;
|
||||
|
||||
/**
|
||||
* Location target for a specific audience.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class LocationTarget {
|
||||
|
||||
@Field("city_name")
|
||||
private String cityName;
|
||||
|
||||
@Field("country")
|
||||
private String country;
|
||||
|
||||
@Field("radius")
|
||||
private Double radius;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package kz.konturai.parser.model;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.mongodb.core.mapping.Field;
|
||||
|
||||
/**
|
||||
* Performance metrics for a targeting campaign.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PerformanceMetrics {
|
||||
|
||||
@Field("impressions")
|
||||
private Long impressions;
|
||||
|
||||
@Field("clicks")
|
||||
private Long clicks;
|
||||
|
||||
@Field("spend")
|
||||
private Double spend;
|
||||
|
||||
@Field("leads")
|
||||
private Long leads;
|
||||
|
||||
@Field("ctr")
|
||||
private Double ctr;
|
||||
|
||||
@Field("cpm")
|
||||
private Double cpm;
|
||||
|
||||
@Field("cpc")
|
||||
private Double cpc;
|
||||
|
||||
@Field("roas")
|
||||
private Double roas;
|
||||
}
|
||||
@@ -21,6 +21,9 @@ public class SocialMediaCredentials {
|
||||
@Field("encrypted_credentials")
|
||||
private Object encryptedCredentials;
|
||||
|
||||
@Field("ad_account_id")
|
||||
private String adAccountId;
|
||||
|
||||
@Field("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@@ -72,6 +75,15 @@ public class SocialMediaCredentials {
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public String getAdAccountId() {
|
||||
return adAccountId;
|
||||
}
|
||||
|
||||
public void setAdAccountId(String adAccountId) {
|
||||
this.adAccountId = adAccountId;
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package kz.konturai.parser.model;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.mongodb.core.mapping.Field;
|
||||
|
||||
/**
|
||||
* Specific ad definition within an ad set.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TargetingAd {
|
||||
|
||||
@Field("ad_id")
|
||||
private String adId; // UUID
|
||||
|
||||
@Field("name")
|
||||
private String name;
|
||||
|
||||
@Field("ad_format")
|
||||
private String adFormat; // "SINGLE_IMAGE", "VIDEO", "CAROUSEL", "COLLECTION"
|
||||
|
||||
@Field("media_url")
|
||||
private String mediaUrl; // MinIO filename
|
||||
|
||||
@Field("headline")
|
||||
private String headline;
|
||||
|
||||
@Field("primary_text")
|
||||
private String primaryText; // Capitalized text
|
||||
|
||||
@Field("call_to_action")
|
||||
private String callToAction; // "LEARN_MORE", "SHOP_NOW", "SIGN_UP", "CONTACT_US", "BOOK_NOW"
|
||||
|
||||
@Field("destination_url")
|
||||
private String destinationUrl;
|
||||
|
||||
@Field("ai_generated_prompt")
|
||||
private String aiGeneratedPrompt;
|
||||
|
||||
@Field("ab_variant")
|
||||
private String abVariant; // "A", "B", "C"
|
||||
|
||||
@Field("performance_score")
|
||||
private int performanceScore; // 0-100
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package kz.konturai.parser.model;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.mongodb.core.mapping.Field;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Ad set definition within a targeting campaign.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TargetingAdSet {
|
||||
|
||||
@Field("ad_set_id")
|
||||
private String adSetId; // UUID
|
||||
|
||||
@Field("name")
|
||||
private String name;
|
||||
|
||||
@Field("platform")
|
||||
private String platform;
|
||||
|
||||
@Field("budget")
|
||||
private Double budget;
|
||||
|
||||
@Field("bid_strategy")
|
||||
private String bidStrategy; // "LOWEST_COST", "TARGET_COST", "BID_CAP"
|
||||
|
||||
@Field("optimization_goal")
|
||||
private String optimizationGoal;
|
||||
|
||||
@Field("placements")
|
||||
private List<String> placements; // "FEED", "STORIES", "REELS", "EXPLORE"
|
||||
|
||||
@Field("ads")
|
||||
private List<TargetingAd> ads;
|
||||
|
||||
@Field("status")
|
||||
private String status;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package kz.konturai.parser.model;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.mongodb.core.mapping.Field;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Target audience profile for an ad campaign.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TargetingAudienceProfile {
|
||||
|
||||
@Field("age_min")
|
||||
private int ageMin;
|
||||
|
||||
@Field("age_max")
|
||||
private int ageMax;
|
||||
|
||||
@Field("genders")
|
||||
private List<String> genders; // "MALE", "FEMALE", "ALL"
|
||||
|
||||
@Field("locations")
|
||||
private List<LocationTarget> locations;
|
||||
|
||||
@Field("interests")
|
||||
private List<String> interests;
|
||||
|
||||
@Field("behaviors")
|
||||
private List<String> behaviors;
|
||||
|
||||
@Field("languages")
|
||||
private List<String> languages;
|
||||
|
||||
@Field("custom_audiences")
|
||||
private List<String> customAudiences; // IDs of custom audiences
|
||||
|
||||
@Field("excluded_audiences")
|
||||
private List<String> excludedAudiences; // IDs of excluded audiences
|
||||
|
||||
@Field("device_types")
|
||||
private List<String> deviceTypes; // "mobile", "desktop", "tablet"
|
||||
|
||||
@Field("connection_type")
|
||||
private String connectionType; // "ALL", "WIFI", "CELLULAR"
|
||||
|
||||
@Field("estimated_reach_min")
|
||||
private Long estimatedReachMin;
|
||||
|
||||
@Field("estimated_reach_max")
|
||||
private Long estimatedReachMax;
|
||||
|
||||
@Field("audience_score")
|
||||
private int audienceScore; // 0-100, AI rating
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package kz.konturai.parser.model;
|
||||
|
||||
import kz.konturai.parser.dto.StatusHistoryEntry;
|
||||
import kz.konturai.parser.dto.TargetingAIAnalysisResult;
|
||||
import kz.konturai.parser.enums.CampaignObjective;
|
||||
import kz.konturai.parser.enums.TargetingPlatform;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
import org.springframework.data.mongodb.core.mapping.Field;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Main Targeting Campaign entity.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Document(collection = "targeting_campaigns")
|
||||
public class TargetingCampaign {
|
||||
|
||||
@Id
|
||||
private String id;
|
||||
|
||||
@Field("user_id")
|
||||
private String userId;
|
||||
|
||||
@Field("analysis_id")
|
||||
private String analysisId;
|
||||
|
||||
@Field("strategy_id")
|
||||
private String strategyId;
|
||||
|
||||
@Field("name")
|
||||
private String name;
|
||||
|
||||
@Field("objective")
|
||||
private CampaignObjective objective;
|
||||
|
||||
@Field("status")
|
||||
private String status; // draft, active, paused, completed, failed
|
||||
|
||||
@Field("platforms")
|
||||
private List<TargetingPlatform> platforms;
|
||||
|
||||
@Field("budget")
|
||||
private BudgetConfig budget;
|
||||
|
||||
@Field("audience")
|
||||
private TargetingAudienceProfile audience;
|
||||
|
||||
@Field("ad_sets")
|
||||
private List<TargetingAdSet> adSets;
|
||||
|
||||
@Field("ai_recommendations")
|
||||
private TargetingAIAnalysisResult aiRecommendations;
|
||||
|
||||
@Field("insights")
|
||||
private List<TargetingInsight> insights;
|
||||
|
||||
@Field("status_history")
|
||||
private List<StatusHistoryEntry> statusHistory;
|
||||
|
||||
@Field("performance_metrics")
|
||||
private PerformanceMetrics performanceMetrics;
|
||||
|
||||
@Field("ab_test_config")
|
||||
private ABTestConfig abTestConfig;
|
||||
|
||||
@Field("external_ids")
|
||||
private Map<String, String> externalIds; // platform -> external id
|
||||
|
||||
@Field("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Field("updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@Field("completed_at")
|
||||
private LocalDateTime completedAt;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package kz.konturai.parser.model;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.mongodb.core.mapping.Field;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Analytical insights for a targeting campaign.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TargetingInsight {
|
||||
|
||||
@Field("recorded_at")
|
||||
private LocalDateTime recordedAt;
|
||||
|
||||
@Field("platform")
|
||||
private String platform;
|
||||
|
||||
@Field("metric")
|
||||
private String metric;
|
||||
|
||||
@Field("value")
|
||||
private Double value;
|
||||
|
||||
@Field("change_percent")
|
||||
private Double changePercent;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package kz.konturai.parser.repository;
|
||||
|
||||
import kz.konturai.parser.model.TargetingCampaign;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.mongodb.repository.MongoRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface TargetingCampaignRepository extends MongoRepository<TargetingCampaign, String> {
|
||||
|
||||
List<TargetingCampaign> findByUserId(String userId);
|
||||
|
||||
Page<TargetingCampaign> findByUserIdOrderByCreatedAtDesc(String userId, Pageable pageable);
|
||||
|
||||
List<TargetingCampaign> findByUserIdAndStatus(String userId, String status);
|
||||
|
||||
List<TargetingCampaign> findByAnalysisId(String analysisId);
|
||||
|
||||
List<TargetingCampaign> findByStrategyId(String strategyId);
|
||||
|
||||
List<TargetingCampaign> findAllByStatusAndUpdatedAtBefore(String status, LocalDateTime date);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package kz.konturai.parser.service;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import kz.konturai.parser.config.TargetingApiConfig;
|
||||
import kz.konturai.parser.exception.ABTestInsufficientDataException;
|
||||
import kz.konturai.parser.model.ABTestConfig;
|
||||
import kz.konturai.parser.model.TargetingAd;
|
||||
import kz.konturai.parser.model.TargetingCampaign;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ABTestingService {
|
||||
|
||||
private final OpenAIAnalyticsService openAiService;
|
||||
private final TargetingApiConfig apiConfig;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public void createABTest(TargetingCampaign campaign, String adSetId) {
|
||||
log.info("Creating AB Test for campaign {} adSet {}", campaign.getId(), adSetId);
|
||||
ABTestConfig config = ABTestConfig.builder()
|
||||
.isEnabled(true)
|
||||
.variants(List.of("A", "B", "C"))
|
||||
.build();
|
||||
campaign.setAbTestConfig(config);
|
||||
}
|
||||
|
||||
public String evaluateABTest(TargetingCampaign campaign) {
|
||||
log.info("Evaluating AB Test for campaign {}", campaign.getId());
|
||||
|
||||
if (campaign.getPerformanceMetrics() == null || campaign.getPerformanceMetrics().getClicks() < 100) {
|
||||
throw new ABTestInsufficientDataException("Not enough clicks to statistically evaluate the A/B test");
|
||||
}
|
||||
|
||||
// Placeholder logic to determine winner
|
||||
String winnerVariant = "A";
|
||||
campaign.getAbTestConfig().setWinnerVariantId(winnerVariant);
|
||||
return winnerVariant;
|
||||
}
|
||||
|
||||
public List<TargetingAd> generateABVariants(String analysisContext, String objective) {
|
||||
log.info("Generating AB Variants for objective {}", objective);
|
||||
|
||||
try {
|
||||
String systemPrompt = "Ты — AI копирайтер для рынка Казахстана. Создай 3 варианта одного рекламного объявления с разными подходами: " +
|
||||
"A: Рациональный (факты, выгоды), B: Эмоциональный (боль клиента), C: Социальное доказательство (кейсы). " +
|
||||
"Для каждого объявления верни объект с: abVariant (A,B,C), headline (макс 40 симв), primaryText (макс 125 для превью), callToAction (LEARN_MORE, SHOP_NOW, SIGN_UP, CONTACT_US, BOOK_NOW). " +
|
||||
"Верни JSON массив объектов. Без маркдауна.";
|
||||
|
||||
String instruction = "Сгенерируй A/B/C варианты креативов для кампании с целью " + objective + ". Контекст: " + analysisContext;
|
||||
|
||||
String response = openAiService.generateWithInstructionWithModel(
|
||||
"Генерация креативов",
|
||||
instruction,
|
||||
"ru",
|
||||
apiConfig.getAi().getCreativesModel(),
|
||||
systemPrompt,
|
||||
apiConfig.getAi().getMaxCreativesTokens()
|
||||
);
|
||||
|
||||
if (response != null && response.startsWith("```json")) {
|
||||
response = response.substring(7, response.lastIndexOf("```")).trim();
|
||||
}
|
||||
|
||||
return objectMapper.readValue(response, new TypeReference<List<TargetingAd>>() {});
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to generate AB variants", e);
|
||||
|
||||
// Fallback definitions in case of failure
|
||||
List<TargetingAd> fallback = new ArrayList<>();
|
||||
fallback.add(createFallbackAd("A", "Рациональный заголовок", "Узнайте о наших выгодах. Жмите подробнее."));
|
||||
fallback.add(createFallbackAd("B", "Хватит переплачивать!", "Решите проблему сегодня. Жмите подробнее."));
|
||||
fallback.add(createFallbackAd("C", "Выбор 1000 клиентов", "Присоединяйтесь к нам. Жмите подробнее."));
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private TargetingAd createFallbackAd(String variant, String headline, String text) {
|
||||
return TargetingAd.builder()
|
||||
.adId(UUID.randomUUID().toString())
|
||||
.abVariant(variant)
|
||||
.headline(headline)
|
||||
.primaryText(text)
|
||||
.callToAction("LEARN_MORE")
|
||||
.name("Option " + variant)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package kz.konturai.parser.service;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import kz.konturai.parser.dto.AudienceSegmentDto;
|
||||
import kz.konturai.parser.dto.BudgetOptimizationDto;
|
||||
import kz.konturai.parser.dto.TargetingCampaignRequest;
|
||||
import kz.konturai.parser.model.MarketingAnalysisV3Document;
|
||||
import kz.konturai.parser.model.TargetingAd;
|
||||
import kz.konturai.parser.model.TargetingAdSet;
|
||||
import kz.konturai.parser.model.TargetingAudienceProfile;
|
||||
import kz.konturai.parser.model.TargetingCampaign;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AiTargetologistService {
|
||||
|
||||
private final AudienceSegmentationService audienceService;
|
||||
private final BudgetOptimizerService budgetOptimizerService;
|
||||
private final ABTestingService abTestingService;
|
||||
private final OpenAIAnalyticsService openAiService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final SerperSearchService serperSearchService;
|
||||
|
||||
public TargetingAudienceProfile generateAudienceProfile(String analysisId, TargetingCampaignRequest req, MarketingAnalysisV3Document analysis) {
|
||||
log.info("Generating Audience Profile for analysis {}", analysisId);
|
||||
|
||||
List<AudienceSegmentDto> aiSegments = audienceService.segmentAudience(analysis);
|
||||
|
||||
TargetingAudienceProfile overrides = req.getAudienceOverride() != null ? TargetingAudienceProfile.builder()
|
||||
.ageMin(req.getAudienceOverride().getAgeMin())
|
||||
.ageMax(req.getAudienceOverride().getAgeMax())
|
||||
.genders(req.getAudienceOverride().getGenders())
|
||||
.build() : new TargetingAudienceProfile();
|
||||
|
||||
return audienceService.mergeSegmentsWithExistingAudience(aiSegments, overrides);
|
||||
}
|
||||
|
||||
public List<TargetingAdSet> generateAdCreatives(TargetingCampaign campaign, MarketingAnalysisV3Document analysis) {
|
||||
log.info("Generating Ad Creatives for campaign {}", campaign.getId());
|
||||
List<TargetingAdSet> adSets = new ArrayList<>();
|
||||
|
||||
try {
|
||||
String context = objectMapper.writeValueAsString(analysis.getResultData());
|
||||
String objective = campaign.getObjective() != null ? campaign.getObjective().name() : "TRAFFIC";
|
||||
|
||||
campaign.getPlatforms().forEach(platform -> {
|
||||
TargetingAdSet adSet = TargetingAdSet.builder()
|
||||
.adSetId(UUID.randomUUID().toString())
|
||||
.name(platform.name() + " Main Catch")
|
||||
.platform(platform.name())
|
||||
.budget(campaign.getBudget().getDailyBudget() / campaign.getPlatforms().size())
|
||||
.bidStrategy("LOWEST_COST")
|
||||
.optimizationGoal(objective)
|
||||
.status("DRAFT")
|
||||
.build();
|
||||
|
||||
List<TargetingAd> ads = new ArrayList<>();
|
||||
if (campaign.getAbTestConfig() != null && campaign.getAbTestConfig().isEnabled()) {
|
||||
ads.addAll(abTestingService.generateABVariants(context, objective));
|
||||
} else {
|
||||
List<TargetingAd> defaultAds = abTestingService.generateABVariants(context, objective);
|
||||
if (!defaultAds.isEmpty()) ads.add(defaultAds.get(0));
|
||||
}
|
||||
|
||||
adSet.setAds(ads);
|
||||
adSets.add(adSet);
|
||||
});
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Error generating Ad creatives", e);
|
||||
}
|
||||
|
||||
return adSets;
|
||||
}
|
||||
|
||||
public BudgetOptimizationDto optimizeBudgetDistribution(TargetingCampaign campaign, MarketingAnalysisV3Document analysis) {
|
||||
return budgetOptimizerService.optimizeBudget(campaign, analysis);
|
||||
}
|
||||
|
||||
public List<AudienceSegmentDto> analyzeCompetitorAds(String businessNiche, List<String> cities) {
|
||||
log.info("Analyzing Competitor Ads for niche {}", businessNiche);
|
||||
try {
|
||||
String query = businessNiche + " реклама кейсы " + String.join(" ", cities);
|
||||
String searchResults = serperSearchService.search(query).toString();
|
||||
|
||||
// In a real scenario, feed searchResults into openAiService to parse out actionable segments
|
||||
// For now, returning a static mapped list or empty list.
|
||||
return new ArrayList<>();
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to analyze competitor ads", e);
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package kz.konturai.parser.service;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import kz.konturai.parser.config.TargetingApiConfig;
|
||||
import kz.konturai.parser.dto.AudienceSegmentDto;
|
||||
import kz.konturai.parser.exception.AudienceGenerationException;
|
||||
import kz.konturai.parser.model.MarketingAnalysisV3Document;
|
||||
import kz.konturai.parser.model.TargetingAudienceProfile;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AudienceSegmentationService {
|
||||
|
||||
private final OpenAIAnalyticsService openAiService;
|
||||
private final TargetingApiConfig apiConfig;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
// In-memory cache instead of Redis to avoid external dependencies
|
||||
private final ConcurrentMap<String, String> cache = new ConcurrentHashMap<>();
|
||||
|
||||
public List<AudienceSegmentDto> segmentAudience(MarketingAnalysisV3Document analysis) {
|
||||
String analysisId = analysis.getId();
|
||||
String cacheKey = "segments:" + analysisId;
|
||||
|
||||
String cached = cache.get(cacheKey);
|
||||
if (cached != null) {
|
||||
try {
|
||||
return objectMapper.readValue(cached, new TypeReference<List<AudienceSegmentDto>>() {});
|
||||
} catch (JsonProcessingException e) {
|
||||
log.warn("Failed to parse cached segments, generating new ones...");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
String analysisJson = objectMapper.writeValueAsString(analysis.getResultData());
|
||||
String systemPrompt = "Ты — эксперт по аудиторному таргетингу с 15-летним опытом в digital-маркетинге на рынке Казахстана. " +
|
||||
"На основе данных анализа создай МИНИМУМ 5 чётких сегментов аудитории. Верни только JSON-массив объектов без маркдауна. " +
|
||||
"Для каждого сегмента верни:\n" +
|
||||
"- segmentName: яркое название\n" +
|
||||
"- description: детальное описание\n" +
|
||||
"- ageMin, ageMax: числа\n" +
|
||||
"- genders: массив строк (MALE, FEMALE)\n" +
|
||||
"- incomeLevelKzt: оценка дохода\n" +
|
||||
"- platform: основная платформа (INSTAGRAM, TIKTOK, FACEBOOK)\n" +
|
||||
"- facebookInterests: массив строк\n" +
|
||||
"- tiktokInterestCategories: массив строк\n" +
|
||||
"- behaviors: массив строк\n" +
|
||||
"- estimatedReachAlmaty: число\n" +
|
||||
"- estimatedReachKazakhstan: число\n" +
|
||||
"- recommendedMessage: ключевое сообщение\n" +
|
||||
"- bestTimeToShow: строка времени\n" +
|
||||
"- contentFormat: формат\n" +
|
||||
"- estimatedCpmKzt: число\n" +
|
||||
"- segmentScore: число (0-100)\n" +
|
||||
"КОНТЕКСТ: " + analysisJson;
|
||||
|
||||
String response = openAiService.generateWithInstructionWithModel(
|
||||
"Создай сегменты аудитории.",
|
||||
systemPrompt,
|
||||
"ru",
|
||||
apiConfig.getAi().getAudienceModel(),
|
||||
systemPrompt,
|
||||
apiConfig.getAi().getMaxAudienceTokens()
|
||||
);
|
||||
|
||||
if (response == null || response.isBlank()) {
|
||||
throw new AudienceGenerationException("OpenAI returned null/empty response for segmentation");
|
||||
}
|
||||
|
||||
// Clean markdown blocks if AI returned them
|
||||
if (response.startsWith("```json")) {
|
||||
response = response.substring(7, response.lastIndexOf("```")).trim();
|
||||
}
|
||||
|
||||
List<AudienceSegmentDto> segments = objectMapper.readValue(response, new TypeReference<List<AudienceSegmentDto>>() {});
|
||||
segments.sort(Comparator.comparingInt(AudienceSegmentDto::getSegmentScore).reversed());
|
||||
|
||||
cache.put(cacheKey, objectMapper.writeValueAsString(segments));
|
||||
return segments;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Error generating audience segments", e);
|
||||
throw new AudienceGenerationException("Failed to segment audience", e);
|
||||
}
|
||||
}
|
||||
|
||||
public TargetingAudienceProfile mergeSegmentsWithExistingAudience(List<AudienceSegmentDto> aiSegments, TargetingAudienceProfile manual) {
|
||||
if (manual == null) {
|
||||
manual = new TargetingAudienceProfile();
|
||||
}
|
||||
|
||||
Set<String> mergedInterests = new HashSet<>(manual.getInterests() != null ? manual.getInterests() : Collections.emptyList());
|
||||
Set<String> mergedBehaviors = new HashSet<>(manual.getBehaviors() != null ? manual.getBehaviors() : Collections.emptyList());
|
||||
|
||||
int minAge = manual.getAgeMin() > 0 ? manual.getAgeMin() : 18;
|
||||
int maxAge = manual.getAgeMax() > 0 ? manual.getAgeMax() : 65;
|
||||
|
||||
for (AudienceSegmentDto seg : aiSegments) {
|
||||
if (seg.getFacebookInterests() != null) mergedInterests.addAll(seg.getFacebookInterests());
|
||||
if (seg.getBehaviors() != null) mergedBehaviors.addAll(seg.getBehaviors());
|
||||
if (seg.getAgeMin() < minAge && manual.getAgeMin() == 0) minAge = seg.getAgeMin();
|
||||
if (seg.getAgeMax() > maxAge && manual.getAgeMax() == 0) maxAge = seg.getAgeMax();
|
||||
}
|
||||
|
||||
TargetingAudienceProfile result = TargetingAudienceProfile.builder()
|
||||
.ageMin(minAge)
|
||||
.ageMax(maxAge)
|
||||
.genders(manual.getGenders() != null ? manual.getGenders() : List.of("ALL"))
|
||||
.locations(manual.getLocations() != null ? manual.getLocations() : new ArrayList<>())
|
||||
.interests(new ArrayList<>(mergedInterests))
|
||||
.behaviors(new ArrayList<>(mergedBehaviors))
|
||||
.languages(manual.getLanguages() != null ? manual.getLanguages() : List.of("RU", "KK"))
|
||||
.customAudiences(manual.getCustomAudiences())
|
||||
.excludedAudiences(manual.getExcludedAudiences())
|
||||
.deviceTypes(manual.getDeviceTypes() != null ? manual.getDeviceTypes() : List.of("mobile"))
|
||||
.connectionType(manual.getConnectionType() != null ? manual.getConnectionType() : "ALL")
|
||||
.audienceScore(85) // Placeholder
|
||||
.build();
|
||||
return result;
|
||||
}
|
||||
|
||||
public Map<String, Long> estimateAudienceSize(TargetingAudienceProfile profile, String platform) {
|
||||
long baseSize = switch (platform.toUpperCase()) {
|
||||
case "INSTAGRAM" -> 6500000L;
|
||||
case "TIKTOK" -> 8000000L;
|
||||
case "FACEBOOK" -> 4000000L;
|
||||
default -> 5000000L;
|
||||
};
|
||||
|
||||
// Naive heuristic adjustment based on profile
|
||||
long minEstimated = (long) (baseSize * 0.1);
|
||||
long maxEstimated = (long) (baseSize * 0.4);
|
||||
|
||||
profile.setEstimatedReachMin(minEstimated);
|
||||
profile.setEstimatedReachMax(maxEstimated);
|
||||
|
||||
return Map.of(
|
||||
"estimated_min", minEstimated,
|
||||
"estimated_max", maxEstimated
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package kz.konturai.parser.service;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import kz.konturai.parser.config.TargetingApiConfig;
|
||||
import kz.konturai.parser.dto.BudgetOptimizationDto;
|
||||
import kz.konturai.parser.exception.BudgetOptimizationException;
|
||||
import kz.konturai.parser.model.MarketingAnalysisV3Document;
|
||||
import kz.konturai.parser.model.TargetingCampaign;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class BudgetOptimizerService {
|
||||
|
||||
private final OpenAIAnalyticsService openAiService;
|
||||
private final TargetingApiConfig apiConfig;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public BudgetOptimizationDto optimizeBudget(TargetingCampaign campaign, MarketingAnalysisV3Document analysis) {
|
||||
log.info("[BudgetOptimizerService] Optimizing budget for campaign {}", campaign.getId());
|
||||
|
||||
try {
|
||||
String systemPrompt = "Ты — медиапланер с опытом работы на рынке Казахстана. Верни только JSON ответ без маркдауна. \n" +
|
||||
"Дано:\n" +
|
||||
" - Общий бюджет: " + campaign.getBudget().getTotalBudget() + " KZT\n" +
|
||||
" - Цель: " + campaign.getObjective() + "\n" +
|
||||
"Рекомендации по рынку Казахстана (используй как базу):\n" +
|
||||
" - Instagram Feed CPM: 1200-2500 KZT\n" +
|
||||
" - TikTok CPM: 400-900 KZT\n" +
|
||||
"Верни JSON со следующей структурой: platformAllocations, dailyBudgetPerPlatform, estimatedImpressions, estimatedClicks, estimatedLeads, estimatedCostPerLead, expectedRoas, riskLevel, riskRationale, schedulingWindows, recommendedBidStrategy, optimizationTips";
|
||||
|
||||
String instruction = "Рассчитай медиаплан на основе бюджета " + campaign.getBudget().getTotalBudget() + " тнг";
|
||||
|
||||
String response = openAiService.generateWithInstructionWithModel(
|
||||
"Анализ ниши",
|
||||
instruction,
|
||||
"ru",
|
||||
apiConfig.getAi().getBudgetModel(),
|
||||
systemPrompt,
|
||||
apiConfig.getAi().getMaxBudgetTokens()
|
||||
);
|
||||
|
||||
if (response == null || response.isBlank()) {
|
||||
throw new BudgetOptimizationException("AI returned empty for budget optimization");
|
||||
}
|
||||
if (response.startsWith("```json")) {
|
||||
response = response.substring(7, response.lastIndexOf("```")).trim();
|
||||
}
|
||||
|
||||
return objectMapper.readValue(response, BudgetOptimizationDto.class);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to optimize budget", e);
|
||||
throw new BudgetOptimizationException("Failed to optimize budget", e);
|
||||
}
|
||||
}
|
||||
|
||||
public BudgetOptimizationDto rebalanceBudget(String campaignId, BudgetOptimizationDto existingPlan) {
|
||||
log.info("[BudgetOptimizerService] Rebalancing budget for campaign {}", campaignId);
|
||||
// In a real scenario, this fetches actual insights comparing to expected plan.
|
||||
// For now, returning existing plan setup smoothly.
|
||||
return existingPlan;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package kz.konturai.parser.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import kz.konturai.parser.config.TargetingApiConfig;
|
||||
import kz.konturai.parser.dto.FacebookAdAccountDto;
|
||||
import kz.konturai.parser.dto.InstagramInsightDto;
|
||||
import kz.konturai.parser.exception.FacebookPermissionException;
|
||||
import kz.konturai.parser.exception.FacebookTokenExpiredException;
|
||||
import kz.konturai.parser.model.BudgetConfig;
|
||||
import kz.konturai.parser.model.TargetingAd;
|
||||
import kz.konturai.parser.model.TargetingAdSet;
|
||||
import kz.konturai.parser.model.TargetingAudienceProfile;
|
||||
import kz.konturai.parser.model.TargetingCampaign;
|
||||
import kz.konturai.parser.enums.CampaignObjective;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class FacebookAdsService {
|
||||
|
||||
private final TargetingApiConfig apiConfig;
|
||||
private final WebClient.Builder webClientBuilder;
|
||||
private final SocialMediaCredentialsService credentialsService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private WebClient webClient;
|
||||
private final Map<String, List<Map<String, Object>>> interestCache = new ConcurrentHashMap<>();
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
String baseUrl = "https://graph.facebook.com/" + apiConfig.getFacebook().getGraphApiVersion();
|
||||
this.webClient = webClientBuilder.baseUrl(baseUrl).build();
|
||||
}
|
||||
|
||||
public List<FacebookAdAccountDto> getAdAccounts(String accessToken) {
|
||||
return executeWithRetry(() -> {
|
||||
JsonNode response = webClient.get()
|
||||
.uri("/me/adaccounts?fields=id,name,account_status,currency,timezone_name,balance,amount_spent")
|
||||
.header("Authorization", "Bearer " + accessToken)
|
||||
.retrieve()
|
||||
.bodyToMono(JsonNode.class)
|
||||
.block();
|
||||
List<FacebookAdAccountDto> accounts = new ArrayList<>();
|
||||
if (response != null && response.has("data")) {
|
||||
for (JsonNode node : response.get("data")) {
|
||||
accounts.add(objectMapper.convertValue(node, FacebookAdAccountDto.class));
|
||||
}
|
||||
}
|
||||
return accounts;
|
||||
});
|
||||
}
|
||||
|
||||
public String createCampaign(String accessToken, String adAccountId, TargetingCampaign campaign) {
|
||||
return executeWithRetry(() -> {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("name", campaign.getName());
|
||||
body.put("objective", mapObjective(campaign.getObjective()));
|
||||
body.put("status", "PAUSED");
|
||||
body.put("special_ad_categories", new ArrayList<>());
|
||||
|
||||
JsonNode response = webClient.post()
|
||||
.uri("/{ad-account-id}/campaigns", adAccountId)
|
||||
.header("Authorization", "Bearer " + accessToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(body)
|
||||
.retrieve()
|
||||
.bodyToMono(JsonNode.class)
|
||||
.block();
|
||||
|
||||
return response != null && response.has("id") ? response.get("id").asText() : null;
|
||||
});
|
||||
}
|
||||
|
||||
public String createAdSet(String accessToken, String adAccountId, String facebookCampaignId,
|
||||
TargetingAdSet adSet, TargetingAudienceProfile audience, BudgetConfig budget) {
|
||||
return executeWithRetry(() -> {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("name", adSet.getName());
|
||||
body.put("campaign_id", facebookCampaignId);
|
||||
body.put("status", "PAUSED");
|
||||
|
||||
Map<String, Object> targeting = new HashMap<>();
|
||||
targeting.put("geo_locations", Map.of("countries", List.of("KZ")));
|
||||
if (audience.getAgeMin() > 0) targeting.put("age_min", audience.getAgeMin());
|
||||
if (audience.getAgeMax() > 0) targeting.put("age_max", audience.getAgeMax());
|
||||
body.put("targeting", targeting);
|
||||
|
||||
body.put("optimization_goal", adSet.getOptimizationGoal());
|
||||
body.put("billing_event", "IMPRESSIONS");
|
||||
|
||||
double budgetInCents = budget.getDailyBudget() / apiConfig.getKztUsdRate() * 100;
|
||||
body.put("daily_budget", (long) budgetInCents);
|
||||
|
||||
JsonNode response = webClient.post()
|
||||
.uri("/{ad-account-id}/adsets", adAccountId)
|
||||
.header("Authorization", "Bearer " + accessToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(body)
|
||||
.retrieve()
|
||||
.bodyToMono(JsonNode.class)
|
||||
.block();
|
||||
|
||||
return response != null && response.has("id") ? response.get("id").asText() : null;
|
||||
});
|
||||
}
|
||||
|
||||
public String createAd(String accessToken, String adAccountId, String facebookAdSetId, TargetingAd ad, String pageId) {
|
||||
return executeWithRetry(() -> {
|
||||
// Placeholder for AdCreative creation logic
|
||||
String creativeId = "creative_placeholder";
|
||||
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("name", ad.getName());
|
||||
body.put("adset_id", facebookAdSetId);
|
||||
body.put("creative", Map.of("creative_id", creativeId));
|
||||
body.put("status", "PAUSED");
|
||||
|
||||
JsonNode response = webClient.post()
|
||||
.uri("/{ad-account-id}/ads", adAccountId)
|
||||
.header("Authorization", "Bearer " + accessToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(body)
|
||||
.retrieve()
|
||||
.bodyToMono(JsonNode.class)
|
||||
.block();
|
||||
|
||||
return response != null && response.has("id") ? response.get("id").asText() : null;
|
||||
});
|
||||
}
|
||||
|
||||
public InstagramInsightDto getInsights(String accessToken, String campaignId, String datePreset) {
|
||||
return executeWithRetry(() -> {
|
||||
JsonNode response = webClient.get()
|
||||
.uri("/{campaign-id}/insights?fields=impressions,reach,clicks,spend,ctr,cpm,cpc,actions,cost_per_action_type&date_preset={date_preset}", campaignId, datePreset)
|
||||
.header("Authorization", "Bearer " + accessToken)
|
||||
.retrieve()
|
||||
.bodyToMono(JsonNode.class)
|
||||
.block();
|
||||
|
||||
if (response != null && response.has("data") && response.get("data").isArray() && !response.get("data").isEmpty()) {
|
||||
JsonNode data = response.get("data").get(0);
|
||||
return InstagramInsightDto.builder()
|
||||
.impressions(data.has("impressions") ? data.get("impressions").asLong() : 0L)
|
||||
.reach(data.has("reach") ? data.get("reach").asLong() : 0L)
|
||||
.clicks(data.has("clicks") ? data.get("clicks").asLong() : 0L)
|
||||
.spend(data.has("spend") ? data.get("spend").asDouble() : 0.0)
|
||||
.ctr(data.has("ctr") ? data.get("ctr").asDouble() : 0.0)
|
||||
.cpm(data.has("cpm") ? data.get("cpm").asDouble() : 0.0)
|
||||
.cpc(data.has("cpc") ? data.get("cpc").asDouble() : 0.0)
|
||||
.build();
|
||||
}
|
||||
return new InstagramInsightDto();
|
||||
});
|
||||
}
|
||||
|
||||
public InstagramInsightDto getInstagramInsights(String accessToken, String instagramAccountId) {
|
||||
return executeWithRetry(() -> {
|
||||
JsonNode response = webClient.get()
|
||||
.uri("/{ig-user-id}/insights?metric=impressions,reach,profile_views,website_clicks,follower_count&period=week", instagramAccountId)
|
||||
.header("Authorization", "Bearer " + accessToken)
|
||||
.retrieve()
|
||||
.bodyToMono(JsonNode.class)
|
||||
.block();
|
||||
return new InstagramInsightDto();
|
||||
});
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> searchTargetingInterests(String accessToken, String query) {
|
||||
if (interestCache.containsKey(query)) {
|
||||
return interestCache.get(query);
|
||||
}
|
||||
return executeWithRetry(() -> {
|
||||
JsonNode response = webClient.get()
|
||||
.uri("/search?type=adinterest&q={query}&locale=ru_RU", query)
|
||||
.header("Authorization", "Bearer " + accessToken)
|
||||
.retrieve()
|
||||
.bodyToMono(JsonNode.class)
|
||||
.block();
|
||||
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
if (response != null && response.has("data")) {
|
||||
for (JsonNode node : response.get("data")) {
|
||||
results.add(objectMapper.convertValue(node, Map.class));
|
||||
}
|
||||
}
|
||||
interestCache.put(query, results);
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
public void pauseCampaign(String accessToken, String campaignId) {
|
||||
updateCampaignStatus(accessToken, campaignId, "PAUSED");
|
||||
}
|
||||
|
||||
public void resumeCampaign(String accessToken, String campaignId) {
|
||||
updateCampaignStatus(accessToken, campaignId, "ACTIVE");
|
||||
}
|
||||
|
||||
private void updateCampaignStatus(String accessToken, String campaignId, String status) {
|
||||
executeWithRetry(() -> {
|
||||
webClient.post()
|
||||
.uri("/{campaign-id}?status={status}", campaignId, status)
|
||||
.header("Authorization", "Bearer " + accessToken)
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block();
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
public String getOAuthUrl(String userId) {
|
||||
return "https://www.facebook.com/" + apiConfig.getFacebook().getGraphApiVersion() + "/dialog/oauth?" +
|
||||
"client_id=" + apiConfig.getFacebook().getAppId() +
|
||||
"&redirect_uri=" + apiConfig.getFacebook().getOauthRedirectUri() +
|
||||
"&scope=ads_management,ads_read,instagram_basic,instagram_manage_insights,pages_read_engagement" +
|
||||
"&state=" + userId +
|
||||
"&response_type=code";
|
||||
}
|
||||
|
||||
public String exchangeCodeForToken(String code, String userId) {
|
||||
return executeWithRetry(() -> {
|
||||
JsonNode response = webClient.get()
|
||||
.uri("/oauth/access_token?client_id={app_id}&client_secret={app_secret}&code={code}&redirect_uri={redirect_uri}",
|
||||
apiConfig.getFacebook().getAppId(),
|
||||
apiConfig.getFacebook().getAppSecret(),
|
||||
code,
|
||||
apiConfig.getFacebook().getOauthRedirectUri())
|
||||
.retrieve()
|
||||
.bodyToMono(JsonNode.class)
|
||||
.block();
|
||||
|
||||
if (response != null && response.has("access_token")) {
|
||||
String accessToken = response.get("access_token").asText();
|
||||
credentialsService.saveCredentials(userId, "FACEBOOK", accessToken);
|
||||
return accessToken;
|
||||
}
|
||||
throw new RuntimeException("Failed to exchange code for token");
|
||||
});
|
||||
}
|
||||
|
||||
private String mapObjective(CampaignObjective objective) {
|
||||
if (objective == null) return "OUTCOME_TRAFFIC";
|
||||
return switch (objective) {
|
||||
case LEADS -> "OUTCOME_LEADS";
|
||||
case SALES -> "OUTCOME_SALES";
|
||||
case TRAFFIC -> "OUTCOME_TRAFFIC";
|
||||
case AWARENESS -> "OUTCOME_AWARENESS";
|
||||
case ENGAGEMENT -> "OUTCOME_ENGAGEMENT";
|
||||
case APP_INSTALLS -> "OUTCOME_APP_PROMOTION";
|
||||
};
|
||||
}
|
||||
|
||||
private <T> T executeWithRetry(java.util.function.Supplier<T> action) {
|
||||
int maxRetries = apiConfig.getFacebook().getMaxRetries();
|
||||
long retryDelay = apiConfig.getFacebook().getRateLimitRetryMs();
|
||||
int attempt = 0;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
return action.get();
|
||||
} catch (WebClientResponseException e) {
|
||||
handleGraphApiError(e);
|
||||
attempt++;
|
||||
if (attempt > maxRetries) {
|
||||
throw e;
|
||||
}
|
||||
log.warn("[Facebook API] Rate limit hit. Retrying in {} ms. Attempt {}/{}", retryDelay, attempt, maxRetries);
|
||||
try {
|
||||
Thread.sleep(retryDelay);
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new RuntimeException("Thread interrupted during retry delay", ie);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[Facebook API] Unexpected error", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleGraphApiError(WebClientResponseException e) {
|
||||
String responseBody = e.getResponseBodyAsString();
|
||||
log.error("[Facebook API] Error: Status={}, Response={}", e.getStatusCode(), responseBody);
|
||||
|
||||
try {
|
||||
JsonNode errorNode = objectMapper.readTree(responseBody).path("error");
|
||||
if (!errorNode.isMissingNode()) {
|
||||
int code = errorNode.path("code").asInt();
|
||||
if (code == 190) {
|
||||
throw new FacebookTokenExpiredException("Facebook token expired or invalid", "Facebook token expired or invalid", 190, 190);
|
||||
} else if (code == 200 || code == 270) {
|
||||
throw new FacebookPermissionException("Permission denied for this action");
|
||||
} else if (code == 17 || code == 80000 || code == 32 || code == 4) {
|
||||
// Rate limit, let the caller handle retry
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (Exception parseException) {
|
||||
log.warn("Failed to parse Facebook API error response");
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -163,4 +163,27 @@ public class SocialMediaCredentialsService {
|
||||
|
||||
return repository.findByUserId(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет выбранный рекламный аккаунт для платформы
|
||||
*
|
||||
* @param userId ID пользователя
|
||||
* @param platform Платформа
|
||||
* @param adAccountId ID выбранного рекламного кабинета
|
||||
*/
|
||||
public void updateAdAccountId(String userId, String platform, String adAccountId) {
|
||||
if (userId == null || platform == null || adAccountId == null) {
|
||||
throw new IllegalArgumentException("UserId, platform and adAccountId are required");
|
||||
}
|
||||
|
||||
Optional<SocialMediaCredentials> existing = repository.findByUserIdAndPlatform(userId, platform.toLowerCase());
|
||||
if (existing.isPresent()) {
|
||||
SocialMediaCredentials credentials = existing.get();
|
||||
credentials.setAdAccountId(adAccountId);
|
||||
repository.save(credentials);
|
||||
logger.info("Updated adAccountId for user {} and platform {}", userId, platform);
|
||||
} else {
|
||||
throw new IllegalArgumentException("Credentials not found for user " + userId + " and platform " + platform);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
package kz.konturai.parser.service;
|
||||
|
||||
import kz.konturai.parser.dto.StatusHistoryEntry;
|
||||
import kz.konturai.parser.dto.TargetingAIAnalysisResult;
|
||||
import kz.konturai.parser.dto.TargetingCampaignRequest;
|
||||
import kz.konturai.parser.dto.TargetingPerformanceDto;
|
||||
import kz.konturai.parser.exception.TargetingCampaignNotFoundException;
|
||||
import kz.konturai.parser.model.*;
|
||||
import kz.konturai.parser.repository.MarketingAnalysisV3Repository;
|
||||
import kz.konturai.parser.repository.TargetingCampaignRepository;
|
||||
import kz.konturai.parser.enums.CampaignObjective;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class TargetingCampaignService {
|
||||
|
||||
private final TargetingCampaignRepository repository;
|
||||
private final MarketingAnalysisV3Repository analysisRepository;
|
||||
private final AiTargetologistService aiService;
|
||||
private final ABTestingService abTestingService;
|
||||
private final FacebookAdsService facebookAdsService;
|
||||
private final TikTokAdsService tikTokAdsService;
|
||||
private final SocialMediaCredentialsService credentialsService;
|
||||
private final TargetingInsightService insightService;
|
||||
|
||||
public TargetingCampaign createCampaign(TargetingCampaignRequest req, String userId) {
|
||||
if (req.getAnalysisId() == null || req.getAnalysisId().isBlank()) {
|
||||
throw new IllegalArgumentException("analysisId is required");
|
||||
}
|
||||
if (req.getTotalBudgetKzt() == null || req.getTotalBudgetKzt() <= 0) {
|
||||
throw new IllegalArgumentException("Valid budget is required");
|
||||
}
|
||||
|
||||
BudgetConfig budget = BudgetConfig.builder()
|
||||
.totalBudget(req.getTotalBudgetKzt())
|
||||
.dailyBudget(req.getDailyBudgetKzt())
|
||||
.startDate(req.getStartDate() != null ? req.getStartDate() : LocalDateTime.now())
|
||||
.endDate(req.getEndDate() != null ? req.getEndDate() : LocalDateTime.now().plusDays(30))
|
||||
.build();
|
||||
|
||||
TargetingCampaign campaign = TargetingCampaign.builder()
|
||||
.userId(userId)
|
||||
.analysisId(req.getAnalysisId())
|
||||
.strategyId(req.getStrategyId())
|
||||
.name(req.getCampaignName())
|
||||
.objective(CampaignObjective.valueOf(req.getObjective()))
|
||||
.status("draft")
|
||||
.budget(budget)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.statusHistory(new ArrayList<>())
|
||||
.insights(new ArrayList<>())
|
||||
.externalIds(new HashMap<>())
|
||||
.build();
|
||||
|
||||
// Map platforms
|
||||
if (req.getPlatforms() != null) {
|
||||
List<kz.konturai.parser.enums.TargetingPlatform> platforms = new ArrayList<>();
|
||||
req.getPlatforms().forEach(p -> platforms.add(kz.konturai.parser.enums.TargetingPlatform.valueOf(p)));
|
||||
campaign.setPlatforms(platforms);
|
||||
}
|
||||
|
||||
addStatusHistory(campaign, "CREATED", "Campaign draft created, initiating AI pipeline");
|
||||
TargetingCampaign saved = repository.save(campaign);
|
||||
|
||||
// Process Async
|
||||
processAsync(saved.getId(), req, userId);
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Async("targetingExecutor")
|
||||
public void processAsync(String campaignId, TargetingCampaignRequest req, String userId) {
|
||||
log.info("Starting async processing for campaign {}", campaignId);
|
||||
TargetingCampaign campaign = repository.findById(campaignId).orElseThrow(() ->
|
||||
new TargetingCampaignNotFoundException("Campaign not found async: " + campaignId));
|
||||
|
||||
try {
|
||||
MarketingAnalysisV3Document analysis = analysisRepository.findById(campaign.getAnalysisId()).orElse(null);
|
||||
if (analysis == null) {
|
||||
failCampaign(campaign, "Analysis Document not found: " + campaign.getAnalysisId());
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 1: AI Recommendations & Audience
|
||||
addStatusHistory(campaign, "GENERATING_AUDIENCE", "Analyzing market data & segmenting audience");
|
||||
TargetingAudienceProfile profile = aiService.generateAudienceProfile(campaign.getAnalysisId(), req, analysis);
|
||||
campaign.setAudience(profile);
|
||||
|
||||
TargetingAIAnalysisResult aiResult = TargetingAIAnalysisResult.builder().build();
|
||||
campaign.setAiRecommendations(aiResult);
|
||||
|
||||
// Step 2: Budget Optimization
|
||||
addStatusHistory(campaign, "OPTIMIZING_BUDGET", "AI allocating budget efficiently");
|
||||
aiResult.setBudgetOptimization(aiService.optimizeBudgetDistribution(campaign, analysis));
|
||||
repository.save(campaign);
|
||||
|
||||
// Step 3: Competitor Ads
|
||||
addStatusHistory(campaign, "ANALYZING_COMPETITORS", "Reviewing competitor creatives");
|
||||
List<String> cities = profile.getLocations() != null ?
|
||||
profile.getLocations().stream().map(LocationTarget::getCityName).toList() : List.of("Алматы");
|
||||
aiResult.setCompetitorInsights(aiService.analyzeCompetitorAds("Ниша клиента", cities));
|
||||
|
||||
// Step 4: Ad Creatives
|
||||
if (req.isGenerateAdCreatives()) {
|
||||
addStatusHistory(campaign, "GENERATING_CREATIVES", "Composing Ad Headlines & Texts");
|
||||
campaign.setAdSets(aiService.generateAdCreatives(campaign, analysis));
|
||||
}
|
||||
|
||||
// Step 5: AB Test
|
||||
if (req.isEnableAbTesting() && campaign.getAdSets() != null && !campaign.getAdSets().isEmpty()) {
|
||||
addStatusHistory(campaign, "CONFIGURING_AB_TEST", "Setting up A/B Test environment");
|
||||
abTestingService.createABTest(campaign, campaign.getAdSets().get(0).getAdSetId());
|
||||
}
|
||||
|
||||
// Step 6: External Platform Publishing
|
||||
addStatusHistory(campaign, "PUBLISHING", "Pushing to Social Media Ads APIs");
|
||||
publishToPlatforms(campaign, userId);
|
||||
|
||||
// Step 7: Done
|
||||
if ("failed".equals(campaign.getStatus())) {
|
||||
addStatusHistory(campaign, "FAILED", "Some integrations failed or required credentials missing");
|
||||
} else {
|
||||
campaign.setStatus("active");
|
||||
addStatusHistory(campaign, "ACTIVE", "Campaign successfully processed and active");
|
||||
}
|
||||
|
||||
campaign.setUpdatedAt(LocalDateTime.now());
|
||||
repository.save(campaign);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Error during async targeting pipeline", e);
|
||||
failCampaign(campaign, "Pipeline error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void publishToPlatforms(TargetingCampaign campaign, String userId) {
|
||||
boolean allSuccess = true;
|
||||
|
||||
if (campaign.getPlatforms() != null) {
|
||||
// FB / IG
|
||||
if (campaign.getPlatforms().contains(kz.konturai.parser.enums.TargetingPlatform.FACEBOOK) ||
|
||||
campaign.getPlatforms().contains(kz.konturai.parser.enums.TargetingPlatform.INSTAGRAM)) {
|
||||
|
||||
String fbToken = credentialsService.getCredentials(userId, "FACEBOOK");
|
||||
if (fbToken != null) {
|
||||
try {
|
||||
SocialMediaCredentials creds = credentialsService.getUserCredentials(userId).stream()
|
||||
.filter(c -> "facebook".equalsIgnoreCase(c.getPlatform()))
|
||||
.findFirst().orElse(null);
|
||||
String adAccountId = (creds != null && creds.getAdAccountId() != null)
|
||||
? creds.getAdAccountId() : "placeholder_ad_account";
|
||||
|
||||
String fbCampId = facebookAdsService.createCampaign(fbToken, adAccountId, campaign);
|
||||
if (fbCampId != null) {
|
||||
campaign.getExternalIds().put("FACEBOOK", fbCampId);
|
||||
log.info("Created FB Campaign {}", fbCampId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to create FB campaign", e);
|
||||
allSuccess = false;
|
||||
}
|
||||
} else {
|
||||
log.warn("Missing FB credentials for user {}", userId);
|
||||
allSuccess = false;
|
||||
}
|
||||
}
|
||||
|
||||
// TikTok
|
||||
if (campaign.getPlatforms().contains(kz.konturai.parser.enums.TargetingPlatform.TIKTOK)) {
|
||||
String ttToken = credentialsService.getCredentials(userId, "TIKTOK");
|
||||
if (ttToken != null) {
|
||||
try {
|
||||
SocialMediaCredentials creds = credentialsService.getUserCredentials(userId).stream()
|
||||
.filter(c -> "tiktok".equalsIgnoreCase(c.getPlatform()))
|
||||
.findFirst().orElse(null);
|
||||
String ttAdvId = (creds != null && creds.getAdAccountId() != null)
|
||||
? creds.getAdAccountId() : "placeholder_tt_adv";
|
||||
|
||||
String ttCampId = tikTokAdsService.createCampaign(ttToken, ttAdvId, campaign);
|
||||
if (ttCampId != null) {
|
||||
campaign.getExternalIds().put("TIKTOK", ttCampId);
|
||||
log.info("Created TT Campaign {}", ttCampId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to create TT campaign", e);
|
||||
allSuccess = false;
|
||||
}
|
||||
} else {
|
||||
log.warn("Missing TT credentials for user {}", userId);
|
||||
allSuccess = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!allSuccess) {
|
||||
campaign.setStatus("failed");
|
||||
}
|
||||
}
|
||||
|
||||
public TargetingCampaign getCampaignById(String id, String userId) {
|
||||
TargetingCampaign campaign = repository.findById(id)
|
||||
.orElseThrow(() -> new TargetingCampaignNotFoundException("Campaign not found: " + id));
|
||||
if (!campaign.getUserId().equals(userId)) {
|
||||
throw new TargetingCampaignNotFoundException("Not authorized for this campaign");
|
||||
}
|
||||
return campaign;
|
||||
}
|
||||
|
||||
public Page<TargetingCampaign> getUserCampaigns(String userId, int page, int size) {
|
||||
return repository.findByUserIdOrderByCreatedAtDesc(userId, PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")));
|
||||
}
|
||||
|
||||
public TargetingCampaign pauseCampaign(String campaignId, String userId) {
|
||||
TargetingCampaign campaign = getCampaignById(campaignId, userId);
|
||||
campaign.setStatus("paused");
|
||||
|
||||
Map<String, String> ids = campaign.getExternalIds();
|
||||
if (ids != null) {
|
||||
if (ids.containsKey("FACEBOOK")) {
|
||||
facebookAdsService.pauseCampaign(credentialsService.getCredentials(userId, "FACEBOOK"), ids.get("FACEBOOK"));
|
||||
}
|
||||
// Add TikTok pause when needed
|
||||
}
|
||||
|
||||
addStatusHistory(campaign, "PAUSED", "Campaign manually paused");
|
||||
return repository.save(campaign);
|
||||
}
|
||||
|
||||
public TargetingCampaign resumeCampaign(String campaignId, String userId) {
|
||||
TargetingCampaign campaign = getCampaignById(campaignId, userId);
|
||||
campaign.setStatus("active");
|
||||
|
||||
Map<String, String> ids = campaign.getExternalIds();
|
||||
if (ids != null) {
|
||||
if (ids.containsKey("FACEBOOK")) {
|
||||
facebookAdsService.resumeCampaign(credentialsService.getCredentials(userId, "FACEBOOK"), ids.get("FACEBOOK"));
|
||||
}
|
||||
// Add TikTok resume when needed
|
||||
}
|
||||
|
||||
addStatusHistory(campaign, "RESUMED", "Campaign manually resumed");
|
||||
return repository.save(campaign);
|
||||
}
|
||||
|
||||
public TargetingPerformanceDto getCampaignInsights(String campaignId, String userId, String datePreset) {
|
||||
TargetingCampaign campaign = getCampaignById(campaignId, userId);
|
||||
// Simple aggregate for the controller
|
||||
TargetingPerformanceDto dto = new TargetingPerformanceDto();
|
||||
dto.setTotalSpend(campaign.getPerformanceMetrics() != null ? campaign.getPerformanceMetrics().getSpend() : 0.0);
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Scheduled(cron = "0 0 */6 * * *") // Every 6 hours
|
||||
public void syncInsightsScheduler() {
|
||||
log.info("Running scheduled insights sync");
|
||||
List<TargetingCampaign> activeCampaigns = repository.findAllByStatusAndUpdatedAtBefore("active", LocalDateTime.now());
|
||||
for (TargetingCampaign campaign : activeCampaigns) {
|
||||
try {
|
||||
syncInsights(campaign.getId());
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to sync insights for {}", campaign.getId(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void syncInsights(String campaignId) {
|
||||
TargetingCampaign campaign = repository.findById(campaignId).orElseThrow();
|
||||
List<TargetingInsight> newInsights = insightService.generateTargetingInsights(campaign);
|
||||
if (campaign.getInsights() == null) campaign.setInsights(new ArrayList<>());
|
||||
campaign.getInsights().addAll(newInsights);
|
||||
|
||||
// Accumulate metrics
|
||||
PerformanceMetrics metrics = campaign.getPerformanceMetrics() != null ? campaign.getPerformanceMetrics() : new PerformanceMetrics();
|
||||
for (TargetingInsight ti : newInsights) {
|
||||
if ("SPEND".equals(ti.getMetric())) metrics.setSpend((metrics.getSpend() != null ? metrics.getSpend() : 0) + ti.getValue());
|
||||
}
|
||||
campaign.setPerformanceMetrics(metrics);
|
||||
|
||||
campaign.setUpdatedAt(LocalDateTime.now());
|
||||
repository.save(campaign);
|
||||
}
|
||||
|
||||
private void failCampaign(TargetingCampaign campaign, String error) {
|
||||
campaign.setStatus("failed");
|
||||
addStatusHistory(campaign, "FAILED", error);
|
||||
campaign.setUpdatedAt(LocalDateTime.now());
|
||||
repository.save(campaign);
|
||||
}
|
||||
|
||||
private void addStatusHistory(TargetingCampaign campaign, String status, String message) {
|
||||
if (campaign.getStatusHistory() == null) {
|
||||
campaign.setStatusHistory(new ArrayList<>());
|
||||
}
|
||||
campaign.getStatusHistory().add(StatusHistoryEntry.builder()
|
||||
.timestamp(LocalDateTime.now())
|
||||
.status(status)
|
||||
.message(message)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package kz.konturai.parser.service;
|
||||
|
||||
import kz.konturai.parser.dto.FacebookAdAccountDto;
|
||||
import kz.konturai.parser.dto.InstagramInsightDto;
|
||||
import kz.konturai.parser.dto.TikTokCampaignDto;
|
||||
import kz.konturai.parser.model.TargetingCampaign;
|
||||
import kz.konturai.parser.model.TargetingInsight;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class TargetingInsightService {
|
||||
|
||||
private final FacebookAdsService facebookAdsService;
|
||||
private final TikTokAdsService tikTokAdsService;
|
||||
private final SocialMediaCredentialsService credentialsService;
|
||||
|
||||
public List<TargetingInsight> generateTargetingInsights(TargetingCampaign campaign) {
|
||||
log.info("Generating insights for campaign {}", campaign.getId());
|
||||
List<TargetingInsight> insights = new ArrayList<>();
|
||||
Map<String, String> externalIds = campaign.getExternalIds();
|
||||
|
||||
if (externalIds == null || externalIds.isEmpty()) {
|
||||
log.warn("Campaign {} has no external IDs to fetch insights", campaign.getId());
|
||||
return insights;
|
||||
}
|
||||
|
||||
String userId = campaign.getUserId();
|
||||
|
||||
try {
|
||||
if (externalIds.containsKey("FACEBOOK") || externalIds.containsKey("INSTAGRAM")) {
|
||||
String fbToken = credentialsService.getCredentials(userId, "FACEBOOK");
|
||||
if (fbToken != null) {
|
||||
String fbCampaignId = externalIds.getOrDefault("FACEBOOK", externalIds.get("INSTAGRAM"));
|
||||
InstagramInsightDto fbInsights = facebookAdsService.getInsights(fbToken, fbCampaignId, "last_3d");
|
||||
insights.add(buildInsight("FACEBOOK", "CTR", fbInsights.getCtr()));
|
||||
insights.add(buildInsight("FACEBOOK", "CPM", fbInsights.getCpm()));
|
||||
insights.add(buildInsight("FACEBOOK", "SPEND", fbInsights.getSpend()));
|
||||
}
|
||||
}
|
||||
|
||||
if (externalIds.containsKey("TIKTOK")) {
|
||||
String ttToken = credentialsService.getCredentials(userId, "TIKTOK");
|
||||
if (ttToken != null) {
|
||||
String ttCampaignId = externalIds.get("TIKTOK");
|
||||
String today = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
|
||||
// Let's assume advertiser_id is known or saved in credentials, use a placeholder here
|
||||
String advertiserId = "placeholder_adv_id";
|
||||
TikTokCampaignDto ttInsights = tikTokAdsService.getCampaignInsights(ttToken, advertiserId, ttCampaignId, today, today);
|
||||
insights.add(buildInsight("TIKTOK", "CTR", ttInsights.getCtr()));
|
||||
insights.add(buildInsight("TIKTOK", "CPM", ttInsights.getCpm()));
|
||||
insights.add(buildInsight("TIKTOK", "SPEND", ttInsights.getSpend()));
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to generate targeting insights for campaign {}", campaign.getId(), e);
|
||||
}
|
||||
|
||||
return insights;
|
||||
}
|
||||
|
||||
private TargetingInsight buildInsight(String platform, String metric, Double value) {
|
||||
return TargetingInsight.builder()
|
||||
.platform(platform)
|
||||
.metric(metric)
|
||||
.value(value != null ? value : 0.0)
|
||||
.recordedAt(LocalDateTime.now())
|
||||
.changePercent(0.0) // Requires historical comparison
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package kz.konturai.parser.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import kz.konturai.parser.config.TargetingApiConfig;
|
||||
import kz.konturai.parser.dto.TikTokCampaignDto;
|
||||
import kz.konturai.parser.exception.TikTokApiException;
|
||||
import kz.konturai.parser.model.TargetingAd;
|
||||
import kz.konturai.parser.model.TargetingAdSet;
|
||||
import kz.konturai.parser.model.TargetingAudienceProfile;
|
||||
import kz.konturai.parser.model.TargetingCampaign;
|
||||
import kz.konturai.parser.enums.CampaignObjective;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class TikTokAdsService {
|
||||
|
||||
private final TargetingApiConfig apiConfig;
|
||||
private final WebClient.Builder webClientBuilder;
|
||||
private final SocialMediaCredentialsService credentialsService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private WebClient webClient;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
this.webClient = webClientBuilder.baseUrl(apiConfig.getTiktok().getBaseUrl()).build();
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getAdvertisers(String accessToken) {
|
||||
return executeApiCall(accessToken, "/oauth2/advertiser/get/", Map.of(
|
||||
"app_id", apiConfig.getTiktok().getAppId(),
|
||||
"secret", apiConfig.getTiktok().getAppSecret()
|
||||
), List.class);
|
||||
}
|
||||
|
||||
public String createCampaign(String accessToken, String advertiserId, TargetingCampaign campaign) {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("advertiser_id", advertiserId);
|
||||
body.put("campaign_name", campaign.getName());
|
||||
body.put("objective_type", mapObjective(campaign.getObjective()));
|
||||
body.put("budget_mode", "BUDGET_MODE_DAY");
|
||||
body.put("budget", campaign.getBudget().getDailyBudget() / apiConfig.getKztUsdRate());
|
||||
|
||||
JsonNode response = executeApiCall(accessToken, "/campaign/create/", body, JsonNode.class);
|
||||
return getResponseId(response, "campaign_id");
|
||||
}
|
||||
|
||||
public String createAdGroup(String accessToken, String advertiserId, String tiktokCampaignId,
|
||||
TargetingAdSet adSet, TargetingAudienceProfile audience) {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("advertiser_id", advertiserId);
|
||||
body.put("campaign_id", tiktokCampaignId);
|
||||
body.put("adgroup_name", adSet.getName());
|
||||
body.put("placement_type", "PLACEMENT_TYPE_NORMAL");
|
||||
body.put("placements", List.of("PLACEMENT_TIKTOK"));
|
||||
|
||||
// Audience
|
||||
body.put("location_ids", List.of("6252001")); // Placeholder KZ/Almaty mapping
|
||||
|
||||
body.put("optimization_goal", adSet.getOptimizationGoal());
|
||||
body.put("budget_mode", "BUDGET_MODE_DAY");
|
||||
body.put("budget", adSet.getBudget() / apiConfig.getKztUsdRate());
|
||||
|
||||
JsonNode response = executeApiCall(accessToken, "/adgroup/create/", body, JsonNode.class);
|
||||
return getResponseId(response, "adgroup_id");
|
||||
}
|
||||
|
||||
public String createAd(String accessToken, String advertiserId, String tiktokAdGroupId, TargetingAd ad) {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("advertiser_id", advertiserId);
|
||||
body.put("adgroup_id", tiktokAdGroupId);
|
||||
body.put("ad_name", ad.getName());
|
||||
body.put("ad_text", ad.getPrimaryText() != null ? ad.getPrimaryText() : "");
|
||||
body.put("call_to_action_id", "DOWNLOAD"); // Placeholder CTA code
|
||||
// Skipping media logic to simplify
|
||||
|
||||
JsonNode response = executeApiCall(accessToken, "/ad/create/", body, JsonNode.class);
|
||||
return getResponseId(response, "ad_id");
|
||||
}
|
||||
|
||||
public TikTokCampaignDto getCampaignInsights(String accessToken, String advertiserId, String campaignId,
|
||||
String startDate, String endDate) {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("advertiser_id", advertiserId);
|
||||
body.put("report_type", "BASIC");
|
||||
body.put("data_level", "AUCTION_CAMPAIGN");
|
||||
body.put("dimensions", List.of("campaign_id", "stat_time_day"));
|
||||
body.put("metrics", List.of("spend", "impressions", "reach", "clicks", "ctr", "cpm", "conversions"));
|
||||
body.put("start_date", startDate);
|
||||
body.put("end_date", endDate);
|
||||
body.put("filtering", List.of(Map.of("field_name", "campaign_ids", "filter_type", "IN", "filter_value", List.of(campaignId))));
|
||||
|
||||
JsonNode response = executeApiCall(accessToken, "/report/integrated/get/", body, JsonNode.class);
|
||||
|
||||
return TikTokCampaignDto.builder()
|
||||
.spend(0.0) // placeholder parsing
|
||||
.impressions(0L)
|
||||
.build();
|
||||
}
|
||||
|
||||
public String getOAuthUrl(String userId) {
|
||||
return "https://business-api.tiktok.com/portal/auth?" +
|
||||
"app_id=" + apiConfig.getTiktok().getAppId() +
|
||||
"&state=" + userId +
|
||||
"&redirect_uri=" + apiConfig.getTiktok().getOauthRedirectUri();
|
||||
}
|
||||
|
||||
public String exchangeCodeForToken(String authCode, String userId) {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("app_id", apiConfig.getTiktok().getAppId());
|
||||
body.put("secret", apiConfig.getTiktok().getAppSecret());
|
||||
body.put("auth_code", authCode);
|
||||
body.put("grant_type", "authorization_code");
|
||||
|
||||
// The token endpoint doesn't require an access token in the header
|
||||
try {
|
||||
JsonNode response = webClient.post()
|
||||
.uri("/oauth2/access_token/")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(body)
|
||||
.retrieve()
|
||||
.bodyToMono(JsonNode.class)
|
||||
.block();
|
||||
|
||||
if (response != null && response.has("data") && response.get("data").has("access_token")) {
|
||||
String accessToken = response.get("data").get("access_token").asText();
|
||||
credentialsService.saveCredentials(userId, "TIKTOK", accessToken);
|
||||
return accessToken;
|
||||
}
|
||||
throw new TikTokApiException("Failed to extract access_token from TikTok response");
|
||||
} catch (WebClientResponseException e) {
|
||||
log.error("[TikTok API] Token exchange error: {}", e.getResponseBodyAsString());
|
||||
throw new TikTokApiException("Failed to exchange code", e);
|
||||
}
|
||||
}
|
||||
|
||||
private <T> T executeApiCall(String accessToken, String uri, Object body, Class<T> responseType) {
|
||||
try {
|
||||
return webClient.post()
|
||||
.uri(uri)
|
||||
.header("Access-Token", accessToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(body)
|
||||
.retrieve()
|
||||
.bodyToMono(responseType)
|
||||
.block();
|
||||
} catch (WebClientResponseException e) {
|
||||
log.error("[TikTok API] HTTP error calling {}: {}", uri, e.getResponseBodyAsString());
|
||||
throw new TikTokApiException("TikTok API Call Failed", e);
|
||||
} catch (Exception e) {
|
||||
log.error("[TikTok API] Unexpected error calling {}", uri, e);
|
||||
throw new TikTokApiException("Unexpected TikTok API Error", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String getResponseId(JsonNode response, String fieldName) {
|
||||
if (response != null && response.has("data") && response.get("data").has(fieldName)) {
|
||||
return response.get("data").get(fieldName).asText();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String mapObjective(CampaignObjective objective) {
|
||||
if (objective == null) return "TRAFFIC";
|
||||
return switch (objective) {
|
||||
case LEADS -> "LEAD_GENERATION";
|
||||
case SALES -> "CONVERSIONS";
|
||||
case TRAFFIC -> "TRAFFIC";
|
||||
case AWARENESS -> "REACH";
|
||||
case ENGAGEMENT -> "VIDEO_VIEWS";
|
||||
case APP_INSTALLS -> "APP_PROMOTION";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -112,4 +112,26 @@ telegram.api.timeout=30000
|
||||
|
||||
facebook.api.timeout=30000
|
||||
facebook.verify.token=konturAI
|
||||
facebook.page.access.token=EAAaocqgT3JoBQtNeU4uqBrywSRwGmFZBQDZBnd1ynWjX070wAa09QBKebjrd9vyjAZCiJuZAZCU8266VlJYTAfwue20QNvENgM3wpjWNmCrTO0gTMAkmPZBKZBXKvZA8eCA4aZCUhmU2jsBRluYqKUyqpYV3ZCK928OVqLqCNZCUhqU6nUU0xP0DOzk90x58tmxpS2y1i7hjQZDZD
|
||||
facebook.page.access.token=EAAaocqgT3JoBQtNeU4uqBrywSRwGmFZBQDZBnd1ynWjX070wAa09QBKebjrd9vyjAZCiJuZAZCU8266VlJYTAfwue20QNvENgM3wpjWNmCrTO0gTMAkmPZBKZBXKvZA8eCA4aZCUhmU2jsBRluYqKUyqpYV3ZCK928OVqLqCNZCUhqU6nUU0xP0DOzk90x58tmxpS2y1i7hjQZDZD
|
||||
|
||||
# Targeting Module Configuration
|
||||
targeting.kzt-usd-rate=460.0
|
||||
|
||||
targeting.facebook.app-id=${FACEBOOK_APP_ID:dummy-app-id}
|
||||
targeting.facebook.app-secret=${FACEBOOK_APP_SECRET:dummy-app-secret}
|
||||
targeting.facebook.oauth-redirect-uri=${FACEBOOK_REDIRECT_URI:http://localhost:8080/api/v1/targeting/facebook/callback}
|
||||
targeting.facebook.graph-api-version=v19.0
|
||||
targeting.facebook.rate-limit-retry-ms=5000
|
||||
targeting.facebook.max-retries=3
|
||||
|
||||
targeting.tiktok.app-id=${TIKTOK_APP_ID:dummy-app-id}
|
||||
targeting.tiktok.app-secret=${TIKTOK_APP_SECRET:dummy-app-secret}
|
||||
targeting.tiktok.oauth-redirect-uri=${TIKTOK_REDIRECT_URI:http://localhost:8080/api/v1/targeting/tiktok/callback}
|
||||
targeting.tiktok.base-url=https://business-api.tiktok.com/open_api/v1.3
|
||||
|
||||
targeting.ai.audience-model=gpt-4o
|
||||
targeting.ai.max-audience-tokens=2500
|
||||
targeting.ai.budget-model=gpt-4o-mini
|
||||
targeting.ai.max-budget-tokens=1000
|
||||
targeting.ai.creatives-model=gpt-4o
|
||||
targeting.ai.max-creatives-tokens=2000
|
||||
Reference in New Issue
Block a user