target fix

This commit is contained in:
arys
2026-03-29 16:58:51 +05:00
parent 5a66742aae
commit b72e3e505e
42 changed files with 2444 additions and 1 deletions
@@ -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";
};
}
}
+23 -1
View File
@@ -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