analytics

This commit is contained in:
root
2025-09-22 17:42:15 +05:00
parent 005cd0d906
commit 66956c3769
43 changed files with 2321 additions and 0 deletions
@@ -2,8 +2,10 @@ package kz.konturai;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
public class KonturaiApplication {
public static void main(String[] args) {
@@ -1,6 +1,7 @@
package kz.konturai.configuration;
import java.util.NoSuchElementException;
import kz.konturai.exception.SocialApiException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
@@ -55,6 +56,11 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(new ErrorResponse("Forbidden"));
}
@ExceptionHandler(SocialApiException.class)
public ResponseEntity<ErrorResponse> handleSocialApi(SocialApiException ex) {
return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(new ErrorResponse(ex.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new ErrorResponse("Internal server error"));
@@ -0,0 +1,27 @@
package kz.konturai.controller;
import java.util.Map;
import kz.konturai.service.spec.AnalyticsService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
// Optional admin/debug controller
@RestController
@RequestMapping("/api/smm/analytics")
public class AnalyticsController {
private final AnalyticsService analyticsService;
public AnalyticsController(AnalyticsService analyticsService) {
this.analyticsService = analyticsService;
}
@PostMapping("/collect-now")
public Map<String, Object> collectNow() {
analyticsService.collectAndSaveAnalytics();
return Map.of(
"success", true,
"message", "Сбор аналитики запущен.");
}
}
@@ -0,0 +1,56 @@
package kz.konturai.controller;
import jakarta.validation.Valid;
import java.util.List;
import java.util.UUID;
import kz.konturai.dto.CampaignDto;
import kz.konturai.dto.CreateCampaignRequest;
import kz.konturai.service.spec.CampaignService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/smm/campaigns")
public class CampaignController {
private final CampaignService campaignService;
public CampaignController(CampaignService campaignService) {
this.campaignService = campaignService;
}
@GetMapping
public List<CampaignDto> getAll() {
return campaignService.getAllCampaigns();
}
@GetMapping("/{id}")
public CampaignDto getById(@PathVariable UUID id) {
return campaignService.getCampaignById(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public CampaignDto create(@Valid @RequestBody CreateCampaignRequest request) {
return campaignService.createCampaign(request);
}
@PutMapping("/{id}")
public CampaignDto update(@PathVariable UUID id, @Valid @RequestBody CreateCampaignRequest request) {
return campaignService.updateCampaign(id, request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable UUID id) {
campaignService.deleteCampaign(id);
}
}
@@ -0,0 +1,56 @@
package kz.konturai.controller;
import jakarta.validation.Valid;
import java.util.List;
import java.util.UUID;
import kz.konturai.dto.ChannelDto;
import kz.konturai.dto.CreateChannelRequest;
import kz.konturai.service.spec.ChannelService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/smm/channels")
public class ChannelController {
private final ChannelService channelService;
public ChannelController(ChannelService channelService) {
this.channelService = channelService;
}
@GetMapping
public List<ChannelDto> getAll() {
return channelService.getAllChannels();
}
@GetMapping("/{id}")
public ChannelDto getById(@PathVariable UUID id) {
return channelService.getChannelById(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ChannelDto create(@Valid @RequestBody CreateChannelRequest request) {
return channelService.createChannel(request);
}
@PutMapping("/{id}")
public ChannelDto update(@PathVariable UUID id, @Valid @RequestBody CreateChannelRequest request) {
return channelService.updateChannel(id, request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable UUID id) {
channelService.deleteChannel(id);
}
}
@@ -0,0 +1,67 @@
package kz.konturai.controller;
import jakarta.validation.Valid;
import java.util.List;
import java.util.UUID;
import kz.konturai.dto.ContentQueueDto;
import kz.konturai.dto.CreateContentRequest;
import kz.konturai.dto.MessageDto;
import kz.konturai.service.spec.ContentService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/smm/content")
public class ContentController {
private final ContentService contentService;
public ContentController(ContentService contentService) {
this.contentService = contentService;
}
@GetMapping
public List<ContentQueueDto> list() {
return contentService.list();
}
@GetMapping("/{id}")
public ContentQueueDto get(@PathVariable UUID id) {
return contentService.get(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ContentQueueDto create(@Valid @RequestBody CreateContentRequest request) {
return contentService.create(request);
}
@PutMapping("/{id}")
public ContentQueueDto update(@PathVariable UUID id, @Valid @RequestBody CreateContentRequest request) {
return contentService.update(id, request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable UUID id) {
contentService.delete(id);
}
@PostMapping("/{id}/approve")
public ContentQueueDto approve(@PathVariable UUID id) {
return contentService.approve(id);
}
@GetMapping("/{id}/messages")
public List<MessageDto> listMessages(@PathVariable UUID id) {
return contentService.listMessages(id);
}
}
@@ -0,0 +1,32 @@
package kz.konturai.controller;
import java.util.Map;
import java.util.UUID;
import kz.konturai.domain.Message;
import kz.konturai.service.spec.PublishingService;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/smm/publishing")
public class PublishingController {
private final PublishingService publishingService;
public PublishingController(PublishingService publishingService) {
this.publishingService = publishingService;
}
@PostMapping("/post/{contentId}")
public Map<String, Object> publish(@PathVariable UUID contentId) {
Message m = publishingService.publishContentById(contentId);
return Map.of(
"success", true,
"message", "Пост успешно опубликован",
"data", Map.of(
"messageId", m.getId(),
"externalUrl", m.getUrl()));
}
}
@@ -0,0 +1,98 @@
package kz.konturai.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.ZonedDateTime;
import java.util.UUID;
import kz.konturai.domain.enums.CampaignStatus;
@Entity
@Table(name = "campaigns")
public class Campaign {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id;
@Column(nullable = false)
private String name;
@Column
private String goal;
@Column
private BigDecimal budget;
@Column
private ZonedDateTime startAt;
@Column
private ZonedDateTime endAt;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private CampaignStatus status;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getGoal() {
return goal;
}
public void setGoal(String goal) {
this.goal = goal;
}
public BigDecimal getBudget() {
return budget;
}
public void setBudget(BigDecimal budget) {
this.budget = budget;
}
public ZonedDateTime getStartAt() {
return startAt;
}
public void setStartAt(ZonedDateTime startAt) {
this.startAt = startAt;
}
public ZonedDateTime getEndAt() {
return endAt;
}
public void setEndAt(ZonedDateTime endAt) {
this.endAt = endAt;
}
public CampaignStatus getStatus() {
return status;
}
public void setStatus(CampaignStatus status) {
this.status = status;
}
}
@@ -0,0 +1,74 @@
package kz.konturai.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.util.UUID;
import kz.konturai.domain.enums.ChannelType;
@Entity
@Table(name = "channels")
public class Channel {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id;
@Column(nullable = false)
private String name;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ChannelType type;
@Column
private String apiKeyRef;
@Column(nullable = false)
private boolean isActive;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public ChannelType getType() {
return type;
}
public void setType(ChannelType type) {
this.type = type;
}
public String getApiKeyRef() {
return apiKeyRef;
}
public void setApiKeyRef(String apiKeyRef) {
this.apiKeyRef = apiKeyRef;
}
public boolean isActive() {
return isActive;
}
public void setActive(boolean active) {
isActive = active;
}
}
@@ -0,0 +1,127 @@
package kz.konturai.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.ZonedDateTime;
import java.util.UUID;
import kz.konturai.domain.enums.ContentStatus;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
@Entity
@Table(name = "content_queue")
public class ContentQueue {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "campaign_id")
private Campaign campaign;
@Column
private String locale;
@Column
private String topic;
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb")
private String postDraft;
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb")
private String assetsRefs;
@Column
private ZonedDateTime scheduledAt;
@Column
private int priority;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ContentStatus status;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public Campaign getCampaign() {
return campaign;
}
public void setCampaign(Campaign campaign) {
this.campaign = campaign;
}
public String getLocale() {
return locale;
}
public void setLocale(String locale) {
this.locale = locale;
}
public String getTopic() {
return topic;
}
public void setTopic(String topic) {
this.topic = topic;
}
public String getPostDraft() {
return postDraft;
}
public void setPostDraft(String postDraft) {
this.postDraft = postDraft;
}
public String getAssetsRefs() {
return assetsRefs;
}
public void setAssetsRefs(String assetsRefs) {
this.assetsRefs = assetsRefs;
}
public ZonedDateTime getScheduledAt() {
return scheduledAt;
}
public void setScheduledAt(ZonedDateTime scheduledAt) {
this.scheduledAt = scheduledAt;
}
public int getPriority() {
return priority;
}
public void setPriority(int priority) {
this.priority = priority;
}
public ContentStatus getStatus() {
return status;
}
public void setStatus(ContentStatus status) {
this.status = status;
}
}
@@ -0,0 +1,76 @@
package kz.konturai.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
@Entity
@Table(name = "kpi_snapshots")
public class KpiSnapshot {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id;
@Column(nullable = false)
private LocalDate date;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "channel_id")
private Channel channel;
@Column(nullable = false)
private String metric;
@Column(nullable = false)
private BigDecimal value;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public LocalDate getDate() {
return date;
}
public void setDate(LocalDate date) {
this.date = date;
}
public Channel getChannel() {
return channel;
}
public void setChannel(Channel channel) {
this.channel = channel;
}
public String getMetric() {
return metric;
}
public void setMetric(String metric) {
this.metric = metric;
}
public BigDecimal getValue() {
return value;
}
public void setValue(BigDecimal value) {
this.value = value;
}
}
@@ -0,0 +1,88 @@
package kz.konturai.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import java.time.ZonedDateTime;
import java.util.UUID;
@Entity
@Table(name = "messages")
public class Message {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "channel_id")
private Channel channel;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "content_id")
private ContentQueue content;
@Column
private String externalId;
@Column
private String url;
@Column
private ZonedDateTime postedAt;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public Channel getChannel() {
return channel;
}
public void setChannel(Channel channel) {
this.channel = channel;
}
public ContentQueue getContent() {
return content;
}
public void setContent(ContentQueue content) {
this.content = content;
}
public String getExternalId() {
return externalId;
}
public void setExternalId(String externalId) {
this.externalId = externalId;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public ZonedDateTime getPostedAt() {
return postedAt;
}
public void setPostedAt(ZonedDateTime postedAt) {
this.postedAt = postedAt;
}
}
@@ -0,0 +1,7 @@
package kz.konturai.domain.enums;
public enum CampaignStatus {
PLANNED,
ACTIVE,
COMPLETED
}
@@ -0,0 +1,7 @@
package kz.konturai.domain.enums;
public enum ChannelType {
TELEGRAM,
VK,
INSTAGRAM
}
@@ -0,0 +1,9 @@
package kz.konturai.domain.enums;
public enum ContentStatus {
DRAFT,
PENDING_APPROVAL,
APPROVED,
PUBLISHED,
FAILED
}
@@ -0,0 +1,17 @@
package kz.konturai.dto;
import java.math.BigDecimal;
import java.time.ZonedDateTime;
import java.util.UUID;
import kz.konturai.domain.enums.CampaignStatus;
public class CampaignDto {
public UUID id;
public String name;
public String goal;
public BigDecimal budget;
public ZonedDateTime startAt;
public ZonedDateTime endAt;
public CampaignStatus status;
}
@@ -0,0 +1,12 @@
package kz.konturai.dto;
import java.util.UUID;
import kz.konturai.domain.enums.ChannelType;
public class ChannelDto {
public UUID id;
public String name;
public ChannelType type;
public boolean isActive;
}
@@ -0,0 +1,17 @@
package kz.konturai.dto;
import java.time.ZonedDateTime;
import java.util.UUID;
import kz.konturai.domain.enums.ContentStatus;
public class ContentQueueDto {
public UUID id;
public UUID campaignId;
public String locale;
public String topic;
public String postDraft;
public String assetsRefs;
public ZonedDateTime scheduledAt;
public int priority;
public ContentStatus status;
}
@@ -0,0 +1,23 @@
package kz.konturai.dto;
import jakarta.validation.constraints.Future;
import jakarta.validation.constraints.FutureOrPresent;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.PositiveOrZero;
import java.math.BigDecimal;
import java.time.ZonedDateTime;
import kz.konturai.domain.enums.CampaignStatus;
public class CreateCampaignRequest {
@NotBlank
public String name;
public String goal;
@PositiveOrZero
public BigDecimal budget;
@FutureOrPresent
public ZonedDateTime startAt;
@Future
public ZonedDateTime endAt;
public CampaignStatus status = CampaignStatus.PLANNED;
}
@@ -0,0 +1,16 @@
package kz.konturai.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import kz.konturai.domain.enums.ChannelType;
public class CreateChannelRequest {
@NotBlank
public String name;
@NotNull
public ChannelType type;
@NotBlank
public String apiKeyRef;
public boolean isActive = true;
}
@@ -0,0 +1,19 @@
package kz.konturai.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.ZonedDateTime;
import java.util.UUID;
public class CreateContentRequest {
@NotNull
public UUID campaignId;
@NotBlank
public String locale;
@NotBlank
public String topic;
public String postDraft;
public String assetsRefs;
public ZonedDateTime scheduledAt;
public Integer priority;
}
@@ -0,0 +1,13 @@
package kz.konturai.dto;
import java.time.ZonedDateTime;
import java.util.UUID;
public class MessageDto {
public UUID id;
public UUID channelId;
public UUID contentId;
public String externalId;
public String url;
public ZonedDateTime postedAt;
}
@@ -0,0 +1,7 @@
package kz.konturai.dto;
public class PostStatsDto {
public long views;
public long reactions;
public long shares;
}
@@ -0,0 +1,11 @@
package kz.konturai.exception;
public class SocialApiException extends RuntimeException {
public SocialApiException(String message) {
super(message);
}
public SocialApiException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,10 @@
package kz.konturai.repository;
import java.util.UUID;
import kz.konturai.domain.Campaign;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CampaignRepository extends JpaRepository<Campaign, UUID> {
}
@@ -0,0 +1,10 @@
package kz.konturai.repository;
import java.util.UUID;
import kz.konturai.domain.Channel;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface ChannelRepository extends JpaRepository<Channel, UUID> {
}
@@ -0,0 +1,10 @@
package kz.konturai.repository;
import java.util.UUID;
import kz.konturai.domain.ContentQueue;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface ContentQueueRepository extends JpaRepository<ContentQueue, UUID> {
}
@@ -0,0 +1,10 @@
package kz.konturai.repository;
import java.util.UUID;
import kz.konturai.domain.KpiSnapshot;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface KpiSnapshotRepository extends JpaRepository<KpiSnapshot, UUID> {
}
@@ -0,0 +1,10 @@
package kz.konturai.repository;
import java.util.UUID;
import kz.konturai.domain.Message;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface MessageRepository extends JpaRepository<Message, UUID> {
}
@@ -0,0 +1,62 @@
package kz.konturai.service.impl;
import kz.konturai.dto.PostStatsDto;
import kz.konturai.exception.SocialApiException;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
@Component
public class TelegramApiClient {
public record TelegramPostResponse(String externalId, String url) {
}
private final RestTemplate restTemplate = new RestTemplate();
public TelegramPostResponse postMessage(String apiToken, String chatId, String text, String imagePath) {
try {
String url = "https://api.telegram.org/bot" + apiToken + "/sendMessage";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
String body = "{" +
"\"chat_id\":\"" + chatId + "\"," +
"\"text\":\"" + text.replace("\"", "\\\"") + "\"" +
"}";
HttpEntity<String> request = new HttpEntity<>(body, headers);
var response = restTemplate.postForEntity(url, request, java.util.Map.class);
Object msg = ((java.util.Map<?, ?>) response.getBody()).get("result");
String messageId = String.valueOf(((java.util.Map<?, ?>) msg).get("message_id"));
String externalUrl = "https://t.me/c/" + chatId + "/" + messageId;
return new TelegramPostResponse(messageId, externalUrl);
} catch (RestClientException e) {
throw new SocialApiException("Telegram API error: " + e.getMessage(), e);
}
}
public PostStatsDto getPostStatistics(String apiToken, String chatId, String externalId) {
try {
// Placeholder implementation; Telegram views require specific APIs and bot
// settings.
// Here we just call a harmless endpoint to validate token and return zeros.
String url = "https://api.telegram.org/bot" + apiToken + "/getChat";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
String body = "{" +
"\"chat_id\":\"" + chatId + "\"" +
"}";
HttpEntity<String> request = new HttpEntity<>(body, headers);
restTemplate.postForEntity(url, request, java.util.Map.class);
PostStatsDto stats = new PostStatsDto();
stats.views = 0;
stats.reactions = 0;
stats.shares = 0;
return stats;
} catch (RestClientException e) {
throw new SocialApiException("Telegram API error: " + e.getMessage(), e);
}
}
}
@@ -0,0 +1,65 @@
package kz.konturai.service.spec;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import kz.konturai.domain.Channel;
import kz.konturai.domain.Message;
import kz.konturai.domain.KpiSnapshot;
import kz.konturai.dto.PostStatsDto;
import kz.konturai.repository.KpiSnapshotRepository;
import kz.konturai.repository.MessageRepository;
import kz.konturai.service.impl.TelegramApiClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AnalyticsService {
private static final Logger log = LoggerFactory.getLogger(AnalyticsService.class);
private final MessageRepository messageRepository;
private final KpiSnapshotRepository kpiSnapshotRepository;
private final TelegramApiClient telegramApiClient;
public AnalyticsService(MessageRepository messageRepository, KpiSnapshotRepository kpiSnapshotRepository,
TelegramApiClient telegramApiClient) {
this.messageRepository = messageRepository;
this.kpiSnapshotRepository = kpiSnapshotRepository;
this.telegramApiClient = telegramApiClient;
}
@Scheduled(cron = "0 0 * * * *")
@Transactional
public void collectAndSaveAnalytics() {
List<Message> messages = messageRepository.findAll();
for (Message m : messages) {
try {
Channel ch = m.getChannel();
// For demo we assume Telegram for all
PostStatsDto stats = telegramApiClient.getPostStatistics(ch.getApiKeyRef(), "" + 0, m.getExternalId());
saveSnapshot(m, ch, "VIEWS", BigDecimal.valueOf(stats.views));
saveSnapshot(m, ch, "REACTIONS", BigDecimal.valueOf(stats.reactions));
saveSnapshot(m, ch, "SHARES", BigDecimal.valueOf(stats.shares));
// Example ER calc: (reactions + shares) / max(views,1)
long denom = Math.max(stats.views, 1);
BigDecimal er = BigDecimal.valueOf((double) (stats.reactions + stats.shares) / denom);
saveSnapshot(m, ch, "ENGAGEMENT_RATE", er);
} catch (Exception ex) {
log.warn("Analytics collection failed for message {}: {}", m.getId(), ex.getMessage());
}
}
}
private void saveSnapshot(Message m, Channel ch, String metric, BigDecimal value) {
KpiSnapshot snap = new KpiSnapshot();
snap.setDate(LocalDate.now());
snap.setChannel(ch);
snap.setMetric(metric);
snap.setValue(value);
kpiSnapshotRepository.save(snap);
}
}
@@ -0,0 +1,78 @@
package kz.konturai.service.spec;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.UUID;
import kz.konturai.domain.Campaign;
import kz.konturai.dto.CampaignDto;
import kz.konturai.dto.CreateCampaignRequest;
import kz.konturai.repository.CampaignRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CampaignService {
private final CampaignRepository campaignRepository;
public CampaignService(CampaignRepository campaignRepository) {
this.campaignRepository = campaignRepository;
}
public List<CampaignDto> getAllCampaigns() {
return campaignRepository.findAll().stream().map(CampaignService::toDto).toList();
}
public CampaignDto getCampaignById(UUID id) {
Campaign c = campaignRepository.findById(id)
.orElseThrow(() -> new NoSuchElementException("Campaign not found"));
return toDto(c);
}
@Transactional
public CampaignDto createCampaign(CreateCampaignRequest request) {
Campaign c = new Campaign();
c.setName(request.name);
c.setGoal(request.goal);
c.setBudget(request.budget);
c.setStartAt(request.startAt);
c.setEndAt(request.endAt);
c.setStatus(request.status);
Campaign saved = campaignRepository.save(c);
return toDto(saved);
}
@Transactional
public CampaignDto updateCampaign(UUID id, CreateCampaignRequest request) {
Campaign c = campaignRepository.findById(id)
.orElseThrow(() -> new NoSuchElementException("Campaign not found"));
c.setName(request.name);
c.setGoal(request.goal);
c.setBudget(request.budget);
c.setStartAt(request.startAt);
c.setEndAt(request.endAt);
c.setStatus(request.status);
Campaign saved = campaignRepository.save(c);
return toDto(saved);
}
@Transactional
public void deleteCampaign(UUID id) {
if (!campaignRepository.existsById(id)) {
throw new NoSuchElementException("Campaign not found");
}
campaignRepository.deleteById(id);
}
private static CampaignDto toDto(Campaign c) {
CampaignDto dto = new CampaignDto();
dto.id = c.getId();
dto.name = c.getName();
dto.goal = c.getGoal();
dto.budget = c.getBudget();
dto.startAt = c.getStartAt();
dto.endAt = c.getEndAt();
dto.status = c.getStatus();
return dto;
}
}
@@ -0,0 +1,69 @@
package kz.konturai.service.spec;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.UUID;
import kz.konturai.domain.Channel;
import kz.konturai.dto.ChannelDto;
import kz.konturai.dto.CreateChannelRequest;
import kz.konturai.repository.ChannelRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ChannelService {
private final ChannelRepository channelRepository;
public ChannelService(ChannelRepository channelRepository) {
this.channelRepository = channelRepository;
}
public List<ChannelDto> getAllChannels() {
return channelRepository.findAll().stream().map(ChannelService::toDto).toList();
}
public ChannelDto getChannelById(UUID id) {
Channel ch = channelRepository.findById(id).orElseThrow(() -> new NoSuchElementException("Channel not found"));
return toDto(ch);
}
@Transactional
public ChannelDto createChannel(CreateChannelRequest request) {
Channel ch = new Channel();
ch.setName(request.name);
ch.setType(request.type);
ch.setApiKeyRef(request.apiKeyRef);
ch.setActive(request.isActive);
Channel saved = channelRepository.save(ch);
return toDto(saved);
}
@Transactional
public ChannelDto updateChannel(UUID id, CreateChannelRequest request) {
Channel ch = channelRepository.findById(id).orElseThrow(() -> new NoSuchElementException("Channel not found"));
ch.setName(request.name);
ch.setType(request.type);
ch.setApiKeyRef(request.apiKeyRef);
ch.setActive(request.isActive);
Channel saved = channelRepository.save(ch);
return toDto(saved);
}
@Transactional
public void deleteChannel(UUID id) {
if (!channelRepository.existsById(id)) {
throw new NoSuchElementException("Channel not found");
}
channelRepository.deleteById(id);
}
private static ChannelDto toDto(Channel ch) {
ChannelDto dto = new ChannelDto();
dto.id = ch.getId();
dto.name = ch.getName();
dto.type = ch.getType();
dto.isActive = ch.isActive();
return dto;
}
}
@@ -0,0 +1,131 @@
package kz.konturai.service.spec;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.UUID;
import kz.konturai.domain.Campaign;
import kz.konturai.domain.ContentQueue;
import kz.konturai.domain.Message;
import kz.konturai.domain.enums.ContentStatus;
import kz.konturai.dto.ContentQueueDto;
import kz.konturai.dto.CreateContentRequest;
import kz.konturai.dto.MessageDto;
import kz.konturai.repository.CampaignRepository;
import kz.konturai.repository.ContentQueueRepository;
import kz.konturai.repository.MessageRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ContentService {
private final ContentQueueRepository contentQueueRepository;
private final CampaignRepository campaignRepository;
private final MessageRepository messageRepository;
public ContentService(ContentQueueRepository contentQueueRepository, CampaignRepository campaignRepository,
MessageRepository messageRepository) {
this.contentQueueRepository = contentQueueRepository;
this.campaignRepository = campaignRepository;
this.messageRepository = messageRepository;
}
public List<ContentQueueDto> list() {
return contentQueueRepository.findAll().stream().map(ContentService::toDto).toList();
}
public ContentQueueDto get(UUID id) {
return contentQueueRepository.findById(id).map(ContentService::toDto)
.orElseThrow(() -> new NoSuchElementException("Content not found"));
}
@Transactional
public ContentQueueDto create(CreateContentRequest r) {
Campaign campaign = campaignRepository.findById(r.campaignId)
.orElseThrow(() -> new NoSuchElementException("Campaign not found"));
ContentQueue c = new ContentQueue();
c.setCampaign(campaign);
c.setLocale(r.locale);
c.setTopic(r.topic);
c.setPostDraft(r.postDraft);
c.setAssetsRefs(r.assetsRefs);
c.setScheduledAt(r.scheduledAt);
c.setPriority(r.priority != null ? r.priority : 0);
c.setStatus(ContentStatus.DRAFT);
return toDto(contentQueueRepository.save(c));
}
@Transactional
public ContentQueueDto update(UUID id, CreateContentRequest r) {
ContentQueue c = contentQueueRepository.findById(id)
.orElseThrow(() -> new NoSuchElementException("Content not found"));
if (r.campaignId != null) {
Campaign campaign = campaignRepository.findById(r.campaignId)
.orElseThrow(() -> new NoSuchElementException("Campaign not found"));
c.setCampaign(campaign);
}
if (r.locale != null)
c.setLocale(r.locale);
if (r.topic != null)
c.setTopic(r.topic);
if (r.postDraft != null)
c.setPostDraft(r.postDraft);
if (r.assetsRefs != null)
c.setAssetsRefs(r.assetsRefs);
if (r.scheduledAt != null)
c.setScheduledAt(r.scheduledAt);
if (r.priority != null)
c.setPriority(r.priority);
return toDto(contentQueueRepository.save(c));
}
@Transactional
public void delete(UUID id) {
if (!contentQueueRepository.existsById(id)) {
throw new NoSuchElementException("Content not found");
}
contentQueueRepository.deleteById(id);
}
@Transactional
public ContentQueueDto approve(UUID id) {
ContentQueue c = contentQueueRepository.findById(id)
.orElseThrow(() -> new NoSuchElementException("Content not found"));
c.setStatus(ContentStatus.APPROVED);
return toDto(contentQueueRepository.save(c));
}
public List<MessageDto> listMessages(UUID contentId) {
ContentQueue c = contentQueueRepository.findById(contentId)
.orElseThrow(() -> new NoSuchElementException("Content not found"));
return messageRepository.findAll().stream()
.filter(m -> m.getContent() != null && m.getContent().getId().equals(c.getId()))
.map(ContentService::toDto)
.toList();
}
private static ContentQueueDto toDto(ContentQueue c) {
ContentQueueDto dto = new ContentQueueDto();
dto.id = c.getId();
dto.campaignId = c.getCampaign() != null ? c.getCampaign().getId() : null;
dto.locale = c.getLocale();
dto.topic = c.getTopic();
dto.postDraft = c.getPostDraft();
dto.assetsRefs = c.getAssetsRefs();
dto.scheduledAt = c.getScheduledAt();
dto.priority = c.getPriority();
dto.status = c.getStatus();
return dto;
}
private static MessageDto toDto(Message m) {
MessageDto dto = new MessageDto();
dto.id = m.getId();
dto.channelId = m.getChannel() != null ? m.getChannel().getId() : null;
dto.contentId = m.getContent() != null ? m.getContent().getId() : null;
dto.externalId = m.getExternalId();
dto.url = m.getUrl();
dto.postedAt = m.getPostedAt();
return dto;
}
}
@@ -0,0 +1,85 @@
package kz.konturai.service.spec;
import java.time.ZonedDateTime;
import java.util.NoSuchElementException;
import java.util.UUID;
import kz.konturai.domain.Channel;
import kz.konturai.domain.ContentQueue;
import kz.konturai.domain.Message;
import kz.konturai.domain.enums.ContentStatus;
import kz.konturai.repository.ChannelRepository;
import kz.konturai.repository.ContentQueueRepository;
import kz.konturai.repository.MessageRepository;
import kz.konturai.service.impl.TelegramApiClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class PublishingService {
private static final Logger log = LoggerFactory.getLogger(PublishingService.class);
private final ContentQueueRepository contentQueueRepository;
private final MessageRepository messageRepository;
private final ChannelRepository channelRepository;
private final TelegramApiClient telegramApiClient;
public PublishingService(ContentQueueRepository contentQueueRepository, MessageRepository messageRepository,
ChannelRepository channelRepository, TelegramApiClient telegramApiClient) {
this.contentQueueRepository = contentQueueRepository;
this.messageRepository = messageRepository;
this.channelRepository = channelRepository;
this.telegramApiClient = telegramApiClient;
}
@Scheduled(cron = "0 * * * * *")
@Transactional
public void findAndPublishScheduledContent() {
// naive scan (should use a query in repo for production)
contentQueueRepository.findAll().stream()
.filter(c -> c.getStatus() == ContentStatus.APPROVED)
.filter(c -> c.getScheduledAt() != null && !c.getScheduledAt().isAfter(ZonedDateTime.now()))
.forEach(c -> {
try {
publishSingle(c);
} catch (Exception ex) {
log.error("Failed to publish content {}: {}", c.getId(), ex.getMessage());
c.setStatus(ContentStatus.FAILED);
}
});
}
@Transactional
public Message publishContentById(UUID contentId) {
ContentQueue c = contentQueueRepository.findById(contentId)
.orElseThrow(() -> new NoSuchElementException("Content not found"));
return publishSingle(c);
}
private Message publishSingle(ContentQueue c) {
// For demo, assume Telegram channel and use campaign id as channel id
// placeholder
// In real design, ContentQueue should reference a Channel. Here we pick by
// priority mod.
Channel channel = channelRepository.findAll().stream().findFirst()
.orElseThrow(() -> new NoSuchElementException("No channels configured"));
String text = c.getPostDraft() != null ? c.getPostDraft() : "";
TelegramApiClient.TelegramPostResponse resp = telegramApiClient.postMessage(channel.getApiKeyRef(), "" + 0,
text,
null);
Message m = new Message();
m.setChannel(channel);
m.setContent(c);
m.setExternalId(resp.externalId());
m.setUrl(resp.url());
m.setPostedAt(ZonedDateTime.now());
Message saved = messageRepository.save(m);
c.setStatus(ContentStatus.PUBLISHED);
return saved;
}
}
@@ -0,0 +1,60 @@
-- Create SMM core tables
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- channels
CREATE TABLE IF NOT EXISTS channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
type VARCHAR(32) NOT NULL,
api_key_ref VARCHAR(512),
is_active BOOLEAN NOT NULL
);
-- campaigns
CREATE TABLE IF NOT EXISTS campaigns (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
goal TEXT,
budget NUMERIC(19,2),
start_at TIMESTAMPTZ,
end_at TIMESTAMPTZ,
status VARCHAR(32) NOT NULL
);
-- content_queue
CREATE TABLE IF NOT EXISTS content_queue (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
campaign_id UUID REFERENCES campaigns(id) ON DELETE SET NULL,
locale VARCHAR(16),
topic TEXT,
post_draft JSONB,
assets_refs JSONB,
scheduled_at TIMESTAMPTZ,
priority INT,
status VARCHAR(32) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_content_queue_campaign ON content_queue(campaign_id);
-- messages
CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID REFERENCES channels(id) ON DELETE SET NULL,
content_id UUID REFERENCES content_queue(id) ON DELETE SET NULL,
external_id VARCHAR(255),
url TEXT,
posted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id);
CREATE INDEX IF NOT EXISTS idx_messages_content ON messages(content_id);
-- kpi_snapshots
CREATE TABLE IF NOT EXISTS kpi_snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
date DATE NOT NULL,
channel_id UUID REFERENCES channels(id) ON DELETE SET NULL,
metric VARCHAR(64) NOT NULL,
value NUMERIC(19,4) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_kpi_snapshots_channel_date ON kpi_snapshots(channel_id, date);
@@ -0,0 +1,268 @@
### **Техническое задание №1: Основа проекта, модели данных и репозитории**
**Цель:** Создать фундамент backend-приложения для модуля SMM-автоматизации. На этом этапе необходимо инициализировать проект, определить структуру базы данных с помощью JPA-сущностей и создать репозитории для доступа к данным.
---
## 1\. Инициализация проекта
- **Задача:** Создать новый проект на базе **Spring Boot**.
- **Технологический стек:**
- **Фреймворк:** Spring Boot 3.x
- **Язык:** Java 17 (или выше)
- **Сборщик:** Maven
- **Артефакт:** `smm-automation-service`
---
## 2\. Зависимости
В `pom.xml` необходимо добавить следующие ключевые зависимости:
- **Spring Web:** для создания REST-контроллеров.
- **Spring Data JPA:** для работы с базой данных через сущности.
- **PostgreSQL Driver:** для подключения к базе данных PostgreSQL.
- **Lombok:** для сокращения шаблонного кода в моделях (геттеры, сеттеры, конструкторы).
- **Hibernate Types:** для поддержки типа данных JSON в PostgreSQL.
---
## 3\. Модели данных (JPA Entities)
Необходимо создать Java-классы, описывающие таблицы в базе данных, со всеми указанными полями, типами и связями.
### `Channel.java`
Канал для публикации (Telegram, VK и т.д.).
```java
import jakarta.persistence.*;
import java.util.UUID;
@Entity
@Table(name = "channels")
public class Channel {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id;
private String name; // e.g., "Telegram Official"
@Enumerated(EnumType.STRING)
private ChannelType type; // ENUM: TELEGRAM, VK, INSTAGRAM
private String apiKeyRef; // Ссылка на secret в Vault или аналоге
private boolean isActive;
}
// Необходимо также создать Enum ChannelType
public enum ChannelType {
TELEGRAM, VK, INSTAGRAM
}
```
### `Campaign.java`
Маркетинговая кампания.
```java
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.ZonedDateTime;
import java.util.UUID;
@Entity
@Table(name = "campaigns")
public class Campaign {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id;
private String name;
private String goal;
private BigDecimal budget;
private ZonedDateTime startAt;
private ZonedDateTime endAt;
@Enumerated(EnumType.STRING)
private CampaignStatus status; // ENUM: PLANNED, ACTIVE, COMPLETED
}
// Enum CampaignStatus
public enum CampaignStatus {
PLANNED, ACTIVE, COMPLETED
}
```
### `ContentQueue.java`
Очередь контента для публикации.
```java
import jakarta.persistence.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.ZonedDateTime;
import java.util.UUID;
@Entity
@Table(name = "content_queue")
public class ContentQueue {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "campaign_id")
private Campaign campaign;
private String locale; // e.g., "ru", "kk"
private String topic;
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb")
private String postDraft; // JSON: { "title": "...", "body": "...", "hashtags": [...] }
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb")
private String assetsRefs; // JSON: ["path/to/image1.jpg"]
private ZonedDateTime scheduledAt;
private int priority;
@Enumerated(EnumType.STRING)
private ContentStatus status; // ENUM: DRAFT, PENDING_APPROVAL, APPROVED, PUBLISHED, FAILED
}
// Enum ContentStatus
public enum ContentStatus {
DRAFT, PENDING_APPROVAL, APPROVED, PUBLISHED, FAILED
}
```
### `Message.java`
Факт публикации сообщения.
```java
import jakarta.persistence.*;
import java.time.ZonedDateTime;
import java.util.UUID;
@Entity
@Table(name = "messages")
public class Message {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "channel_id")
private Channel channel;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "content_id")
private ContentQueue content;
private String externalId; // ID поста в соцсети
private String url;
private ZonedDateTime postedAt;
}
```
### `KpiSnapshot.java`
Срезы по ключевым метрикам.
```java
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
@Entity
@Table(name = "kpi_snapshots")
public class KpiSnapshot {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id;
private LocalDate date;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "channel_id")
private Channel channel;
private String metric; // e.g., "CTR", "ER", "LEADS"
private BigDecimal value;
}
```
---
## 4\. Репозитории (Spring Data JPA)
Для каждой созданной сущности необходимо создать соответствующий интерфейс-репозиторий для взаимодействия с базой данных.
- `ChannelRepository.java`
- `CampaignRepository.java`
- `ContentQueueRepository.java`
- `MessageRepository.java`
- `KpiSnapshotRepository.java`
**Пример для `CampaignRepository`:**
```java
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.UUID;
@Repository
public interface CampaignRepository extends JpaRepository<Campaign, UUID> {
// Здесь в будущем можно будет добавлять кастомные методы для запросов
}
```
---
## 5\. Конфигурация базы данных
В файле `application.properties` или `application.yml` необходимо указать настройки для подключения к локальной или dev-базе данных PostgreSQL.
**Пример для `application.yml`:**
```yaml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/smm_automation_db
username: your_username
password: your_password
jpa:
hibernate:
ddl-auto: update # На время разработки, для prod использовать 'validate'
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true
show-sql: true
```
---
## Критерии выполнения
- Проект Spring Boot успешно создан и запускается без ошибок.
- Все 5 JPA-сущностей и 3 Enum'а созданы в проекте.
- Для каждой сущности создан соответствующий Spring Data JPA репозиторий.
- Приложение успешно подключается к базе данных PostgreSQL.
- При запуске приложения Hibernate автоматически создает или обновляет таблицы в базе данных в соответствии с описанными моделями.
@@ -0,0 +1,157 @@
### **Техническое задание №2: Базовое управление кампаниями и каналами (CRUD)**
**Цель:** Реализовать полную бизнес-логику и API для управления основными сущностями системы: **маркетинговыми кампаниями (`Campaign`)** и **каналами публикации (`Channel`)**. На этом этапе будет создана базовая административная функциональность, позволяющая создавать, просматривать, редактировать и удалять эти сущности через REST API.
---
## 1\. Data Transfer Objects (DTOs)
Для обеспечения чистоты и безопасности API необходимо создать DTO для всех входящих и исходящих данных.
### `CampaignDto.java`
Используется для возврата информации о кампании.
```java
public class CampaignDto {
private UUID id;
private String name;
private String goal;
private BigDecimal budget;
private ZonedDateTime startAt;
private ZonedDateTime endAt;
private CampaignStatus status;
}
```
### `CreateCampaignRequest.java`
Используется для создания новой кампании.
```java
public class CreateCampaignRequest {
@NotBlank
private String name;
private String goal;
@PositiveOrZero
private BigDecimal budget;
@FutureOrPresent
private ZonedDateTime startAt;
@Future
private ZonedDateTime endAt;
private CampaignStatus status = CampaignStatus.PLANNED;
}
```
### `ChannelDto.java`
Используется для возврата информации о канале.
```java
public class ChannelDto {
private UUID id;
private String name;
private ChannelType type;
private boolean isActive;
}
```
### `CreateChannelRequest.java`
Используется для создания нового канала.
```java
public class CreateChannelRequest {
@NotBlank
private String name;
@NotNull
private ChannelType type;
@NotBlank
private String apiKeyRef;
private boolean isActive = true;
}
```
---
## 2\. Сервисный слой (Business Logic)
### `CampaignService.java`
Сервис для управления кампаниями.
- **Методы:**
- `List<CampaignDto> getAllCampaigns()`: Возвращает список всех кампаний.
- `CampaignDto getCampaignById(UUID id)`: Находит кампанию по ID. В случае отсутствия выбрасывает исключение `ResourceNotFoundException`.
- `CampaignDto createCampaign(CreateCampaignRequest request)`: Создает новую кампанию на основе DTO, сохраняет в БД и возвращает `CampaignDto`.
- `CampaignDto updateCampaign(UUID id, CreateCampaignRequest request)`: Обновляет существующую кампанию.
- `void deleteCampaign(UUID id)`: Удаляет кампанию по ID.
### `ChannelService.java`
Сервис для управления каналами.
- **Методы:**
- `List<ChannelDto> getAllChannels()`: Возвращает список всех каналов.
- `ChannelDto getChannelById(UUID id)`: Находит канал по ID. В случае отсутствия выбрасывает исключение `ResourceNotFoundException`.
- `ChannelDto createChannel(CreateChannelRequest request)`: Создает новый канал.
- `ChannelDto updateChannel(UUID id, CreateChannelRequest request)`: Обновляет существующий канал.
- `void deleteChannel(UUID id)`: Удаляет канал по ID.
---
## 3\. API Endpoints (REST Controllers)
### `CampaignController.java`
Контроллер для управления кампаниями.
- **`GET /api/smm/campaigns`**
- **Описание:** Получить список всех кампаний.
- **Ответ (200 OK):** `List<CampaignDto>`
- **`GET /api/smm/campaigns/{id}`**
- **Описание:** Получить кампанию по ID.
- **Ответ (200 OK):** `CampaignDto`
- **Ответ (404 Not Found):** Если кампания не найдена.
- **`POST /api/smm/campaigns`**
- **Описание:** Создать новую кампанию.
- **Тело запроса:** `CreateCampaignRequest`
- **Ответ (201 Created):** `CampaignDto`
- **`PUT /api/smm/campaigns/{id}`**
- **Описание:** Обновить существующую кампанию.
- **Тело запроса:** `CreateCampaignRequest`
- **Ответ (200 OK):** `CampaignDto`
- **`DELETE /api/smm/campaigns/{id}`**
- **Описание:** Удалить кампанию.
- **Ответ (204 No Content):** Успешное удаление.
### `ChannelController.java`
Контроллер для управления каналами.
- **`GET /api/smm/channels`**
- **Описание:** Получить список всех каналов.
- **Ответ (200 OK):** `List<ChannelDto>`
- **`POST /api/smm/channels`**
- **Описание:** Создать новый канал.
- **Тело запроса:** `CreateChannelRequest`
- **Ответ (201 Created):** `ChannelDto`
- **Прочие эндпоинты (`GET /id`, `PUT /id`, `DELETE /id`)** реализуются по аналогии с `CampaignController`.
---
## 4\. Обработка ошибок
- Необходимо создать глобальный обработчик исключений (`@ControllerAdvice`) для перехвата `ResourceNotFoundException` и возврата корректного HTTP-статуса `404 Not Found` с информативным сообщением в теле ответа.
- Валидация DTO (`@NotBlank`, `@NotNull` и т.д.) должна быть включена с помощью аннотации `@Valid` в методах контроллера. При ошибке валидации Spring автоматически вернет статус `400 Bad Request`.
---
## Критерии выполнения
- Созданы все указанные DTO с аннотациями для валидации.
- Реализованы `CampaignService` и `ChannelService` со всей CRUD-логикой.
- Реализованы `CampaignController` и `ChannelController` со всеми указанными REST-эндпоинтами.
- Все эндпоинты корректно работают и возвращают ожидаемые HTTP-статусы и тела ответов.
- Реализована обработка ошибок для случаев, когда сущность не найдена.
- Код покрыт базовыми юнит-тестами для сервисного слоя.
@@ -0,0 +1,123 @@
### **Техническое задание №3: Модуль AI-генерации контента**
**Цель:** Реализовать ключевую функцию SMM-модуля — автоматическую генерацию черновиков контента (текст и изображения) с помощью AI. Этот модуль будет ядром "умного" контент-плана, позволяя пользователям быстро создавать посты на заданную тему.
---
## 1\. Data Transfer Objects (DTOs)
### `GenerateContentRequest.java`
Используется для запроса на генерацию нового поста.
```java
public class GenerateContentRequest {
@NotNull
private UUID campaignId; // К какой кампании относится пост
@NotBlank
private String topic; // Тема для генерации
@NotBlank
private String locale; // Локаль ("ru" или "kk")
}
```
### `GeneratedContentDto.java`
Используется в ответе и представляет собой DTO для `ContentQueue`.
```java
public class GeneratedContentDto {
private UUID id;
private UUID campaignId;
private String locale;
private String topic;
private PostDraftDto postDraft;
private List<String> assetsRefs;
private ContentStatus status;
}
// Вспомогательный DTO для JSON-поля postDraft
public class PostDraftDto {
private String title;
private String body;
private List<String> hashtags;
}
```
---
## 2\. Интеграция с внешними AI-сервисами
Необходимо создать два новых сервиса-клиента для взаимодействия с внешними AI API.
### `LanguageModelClient.java` (Агент "Writer/Researcher")
- **Задача:** Отправлять запросы к LLM (например, Ollama, OpenAI) для генерации текста.
- **Методы:**
- `String generatePostText(String topic, String locale)`: Генерирует основной текст поста.
- `String generateTitle(String postText)`: Генерирует заголовок на основе текста.
- `List<String> generateHashtags(String postText)`: Генерирует хештеги.
- **Промпты (примерные):**
- **Для текста:** `"Напиши экспертный пост для [соцсеть] на тему '[topic]' на [locale] языке. Стиль: [стиль]. Целевая аудитория: [аудитория]."`
- **Для хештегов:** `"Подбери 5-7 релевантных хештегов для этого текста. В ответе дай только список через запятую."`
### `ImageGenerationClient.java` (Агент "Designer")
- **Задача:** Отправлять запросы к API для генерации изображений (например, Stable Diffusion, Midjourney API).
- **Методы:**
- `String generateImage(String textPrompt)`: Принимает текстовое описание и возвращает URL или путь к сгенерированному изображению.
- **Логика:** Сервис должен уметь формировать промпт для картинки на основе темы и основного текста поста.
---
## 3\. Сервисный слой (Business Logic)
### `ContentGenerationService.java`
Основной сервис, который оркестрирует процесс генерации.
- **Зависимости:** `LanguageModelClient`, `ImageGenerationClient`, `ContentQueueRepository`, `CampaignRepository`.
- **Основной метод:** `GeneratedContentDto generateContent(GenerateContentRequest request)`
- **Алгоритм работы метода:**
1. Проверить существование кампании по `campaignId` из запроса. Если не найдена — ошибка.
2. Вызвать `languageModelClient.generatePostText()` для создания основного текста.
3. На основе полученного текста вызвать `languageModelClient.generateTitle()` и `languageModelClient.generateHashtags()`.
4. Сформировать объект `PostDraftDto` из полученных текста, заголовка и хештегов.
5. Вызвать `imageGenerationClient.generateImage()`, передав промпт, основанный на теме и тексте.
6. Создать новую сущность `ContentQueue`.
7. Заполнить ее данными: `campaign`, `topic`, `locale`, `postDraft` (в виде JSON), `assetsRefs` (массив с путем к картинке).
8. Установить `status = ContentStatus.DRAFT`.
9. Сохранить сущность в базу данных через `ContentQueueRepository`.
10. Вернуть `GeneratedContentDto` созданного черновика.
---
## 4\. API Endpoint (REST Controller)
### `ContentController.java`
Необходимо добавить новый эндпоинт в существующий `ContentController`.
- **`POST /api/smm/content/generate`**
- **Описание:** Запускает процесс генерации нового черновика поста.
- **Тело запроса:** `GenerateContentRequest`
- **Успешный ответ (201 Created):** `GeneratedContentDto` (созданный черновик).
- **Ответ при ошибке (400 Bad Request):** Если тело запроса невалидно.
- **Ответ при ошибке (503 Service Unavailable):** Если внешние AI-сервисы недоступны.
---
## 5\. Обработка ошибок
- Сервис должен корректно обрабатывать ошибки от внешних API (LLM, Image API). Если один из сервисов недоступен, вся операция должна завершиться ошибкой, и в лог должно быть записано информативное сообщение.
- Необходимо реализовать глобальный обработчик исключений (`@ControllerAdvice`) для перехвата ошибок от AI-сервисов и возврата клиенту статуса `503 Service Unavailable`.
---
## Критерии выполнения
- Созданы и реализованы `LanguageModelClient` и `ImageGenerationClient`.
- Реализован `ContentGenerationService` с полной бизнес-логикой по генерации и сохранению черновика.
- В `ContentController` добавлен эндпоинт `POST /api/smm/content/generate`, который корректно принимает запрос и возвращает DTO созданного черновика.
- При вызове эндпоинта в таблице `content_queue` появляется новая запись со статусом `DRAFT`, заполненными полями `postDraft` и `assetsRefs`.
- Реализована корректная обработка ошибок от внешних AI-сервисов.
@@ -0,0 +1,116 @@
### **Техническое задание №4: Модуль публикации по расписанию**
**Цель:** Реализовать автоматизированный механизм, который будет публиковать согласованный контент в социальные сети в точно запланированное время. Также необходимо предоставить возможность ручной, принудительной публикации для экстренных случаев.
---
## 1\. Интеграция с API социальных сетей
Необходимо создать сервисы-клиенты для взаимодействия с API каждого поддерживаемого канала.
### `TelegramApiClient.java`
- **Задача:** Инкапсулировать логику отправки сообщений в Telegram.
- **Методы:**
- `TelegramPostResponse postMessage(String apiToken, String chatId, String text, String imagePath)`: Принимает токен, ID чата и контент, отправляет пост в Telegram. Возвращает объект с `externalId` и `url` опубликованного поста.
- **Реализация:** Использовать библиотеку, например, `java-telegram-bot-api`, или прямые HTTP-запросы к Telegram Bot API.
_(По аналогии создаются клиенты для других соцсетей, например, `VkApiClient.java`)_
---
## 2\. Сервисный слой (Business Logic)
### `PublishingService.java`
Основной сервис, управляющий процессом публикации.
- **Зависимости:** `ContentQueueRepository`, `MessageRepository`, `ChannelRepository`, а также все созданные API-клиенты (`TelegramApiClient` и др.).
- **Ключевые методы:**
- `void findAndPublishScheduledContent()`: Основной метод, запускаемый по расписанию.
- `void publishContentById(UUID contentId)`: Метод для принудительной публикации по ID.
#### 2.1. Логика автоматической публикации (`findAndPublishScheduledContent`)
Этот метод должен быть аннотирован `@Scheduled` для регулярного запуска (например, каждую минуту).
- **Алгоритм работы:**
1. Найти в `ContentQueueRepository` все записи, у которых:
- `status` равен `ContentStatus.APPROVED`.
- `scheduledAt` меньше или равен текущему времени (`ZonedDateTime.now()`).
2. Для каждой найденной записи (`ContentQueue`):
- Получить связанный с ней канал (`Channel`) и его токен (`apiKeyRef`).
- Получить текст и ассеты из полей `postDraft` и `assetsRefs`.
- Вызвать соответствующий API-клиент (например, `telegramApiClient.postMessage(...)`).
- **В случае успеха:**
- Создать новую запись в таблице `Message`, сохранив `externalId` и `url` из ответа API.
- Обновить статус записи в `ContentQueue` на `PUBLISHED`.
- **В случае ошибки:**
- Обновить статус записи в `ContentQueue` на `FAILED`.
- Записать детальную информацию об ошибке в логи.
#### 2.2. Логика ручной публикации (`publishContentById`)
- **Алгоритм работы:**
1. Найти запись в `ContentQueue` по `contentId`. Если не найдена — ошибка `ResourceNotFoundException`.
2. Выполнить ту же логику публикации, что и в шаге 2.1, но для одной конкретной записи.
3. Этот метод не зависит от статуса и времени, он должен публиковать пост немедленно.
---
## 3\. API Endpoint (REST Controller)
### `PublishingController.java`
Новый контроллер для управления процессом публикации вручную.
- **`POST /api/smm/publishing/post/{contentId}`**
- **Описание:** Принудительно опубликовать пост из очереди контента по его ID.
- **Параметры:** `contentId` (UUID) - ID записи из `ContentQueue`.
- **Успешный ответ (200 OK):**
```json
{
"success": true,
"message": "Пост успешно опубликован",
"data": {
"messageId": "...", // UUID из таблицы Message
"externalUrl": "https://t.me/channel/12345"
}
}
```
- **Ответ при ошибке (404 Not Found):** Если контент с таким `contentId` не найден.
- **Ответ при ошибке (502 Bad Gateway):** Если API соцсети вернуло ошибку при публикации.
---
## 4\. Конфигурация планировщика
В главном классе приложения или в отдельном конфигурационном классе необходимо включить поддержку планировщика с помощью аннотации `@EnableScheduling`.
Метод `findAndPublishScheduledContent` в `PublishingService` должен быть аннотирован:
```java
@Scheduled(cron = "0 * * * * *") // Запускать каждую минуту
public void findAndPublishScheduledContent() {
// ... логика ...
}
```
---
## 5\. Обработка ошибок и повторные попытки
- **Ошибки API:** `PublishingService` должен корректно обрабатывать исключения от API-клиентов.
- **Повторные попытки (Retry):** Рекомендуется добавить механизм повторных попыток для постов со статусом `FAILED`. Например, можно добавить аннотацию `@Retryable` (из Spring Retry) на метод отправки в API-клиенте или создать отдельный `Scheduled` метод, который будет пытаться повторно опубликовать "упавшие" посты несколько раз с интервалом.
---
## Критерии выполнения
- Созданы API-клиенты для взаимодействия как минимум с одной соцсетью (Telegram).
- Реализован `PublishingService` с логикой автоматической и ручной публикации.
- Метод автоматической публикации успешно запускается по расписанию, находит и публикует согласованный контент.
- После успешной публикации в таблице `Message` создается запись, а статус в `ContentQueue` меняется на `PUBLISHED`.
- В случае ошибки публикации статус в `ContentQueue` меняется на `FAILED`.
- Новый эндпоинт `POST /api/smm/publishing/post/{contentId}` корректно работает и позволяет публиковать посты вручную.
- Реализована базовая обработка ошибок от API соцсетей.
@@ -0,0 +1,97 @@
### **Техническое задание №6: Модуль сбора аналитики**
**Цель:** Реализовать автоматизированный процесс сбора ключевых показателей эффективности (KPI) по всем опубликованным постам. Эти данные необходимы для заполнения дашборда аналитики и оценки эффективности SMM-кампаний.
---
## 1\. Расширение API-клиентов для соцсетей
Необходимо дополнить существующие API-клиенты (например, `TelegramApiClient`) методами для получения статистики.
### `TelegramApiClient.java`
- **Новый метод:** `PostStatsDto getPostStatistics(String apiToken, String chatId, String externalId)`
- **Задача:** Получить статистику по конкретному посту. В Telegram это может быть количество просмотров или реакции (если применимо).
- **Возвращаемое значение:** DTO `PostStatsDto`, содержащий поля `views`, `reactions`, `shares` и т.д., в зависимости от возможностей API.
_(По аналогии расширяются клиенты для других соцсетей, например, `VkApiClient.java`, который может возвращать просмотры, лайки, репосты, комментарии.)_
---
## 2\. Сервисный слой (Business Logic)
### `AnalyticsService.java`
Основной сервис, управляющий процессом сбора и сохранения аналитики.
- **Зависимости:** `MessageRepository`, `KpiSnapshotRepository`, а также все API-клиенты (`TelegramApiClient` и др.).
- **Ключевой метод:** `void collectAndSaveAnalytics()`
#### Логика сбора и сохранения аналитики (`collectAndSaveAnalytics`)
Этот метод должен быть аннотирован `@Scheduled` для регулярного запуска (например, раз в час или раз в сутки).
- **Алгоритм работы:**
1. Найти в `MessageRepository` все записи об опубликованных постах, для которых статистика еще не собиралась или собиралась давно (например, старше 24 часов).
2. Для каждой найденной записи (`Message`):
- Получить `externalId` поста и информацию о канале (`channel`).
- Вызвать соответствующий API-клиент (например, `telegramApiClient.getPostStatistics(...)`) для получения актуальной статистики.
- **Обработка ошибки:** Если API соцсети возвращает ошибку "пост не найден" (например, его удалили), пометить этот `Message` в нашей БД как удаленный, чтобы больше не пытаться его опрашивать.
3. **Агрегация и сохранение:**
- На основе полученных данных от API (просмотры, лайки, репосты) рассчитать ключевые метрики (KPI), например, **ER (Engagement Rate)**.
- Создать новые записи в `KpiSnapshot` для каждой рассчитанной метрики.
- Каждая запись `KpiSnapshot` должна содержать:
- `date`: Текущая дата.
- `channel`: Ссылка на канал.
- `metric`: Название метрики (например, "VIEWS", "LIKES", "ENGAGEMENT_RATE").
- `value`: Значение метрики.
---
## 3\. API Endpoint (REST Controller)
Для этого модуля **не требуется** создавать новые эндпоинты для фронтенда, так как он работает полностью в фоновом режиме. Однако, для удобства отладки можно добавить административный эндпоинт.
### `AnalyticsController.java` (Опционально, для отладки)
- **`POST /api/smm/analytics/collect-now`**
- **Описание:** Принудительно запускает один цикл сбора аналитики.
- **Успешный ответ (200 OK):**
```json
{
"success": true,
"message": "Сбор аналитики запущен. Обработано N постов."
}
```
---
## 4\. Конфигурация планировщика
Метод `collectAndSaveAnalytics` в `AnalyticsService` должен быть аннотирован для периодического запуска.
```java
// Запускать каждый час в 0 минут
@Scheduled(cron = "0 0 * * * *")
public void collectAndSaveAnalytics() {
// ... логика ...
}
```
---
## 5\. Обработка ошибок
- **Ошибки API:** Сервис должен быть устойчив к временным сбоям API соцсетей. Если API недоступно, сбор аналитики для этого поста можно отложить до следующего запуска.
- **Удаленные посты:** Важно корректно обрабатывать ситуацию, когда пост был удален из соцсети, чтобы избежать бесконечных попыток получить по нему статистику.
---
## Критерии выполнения
- Существующие API-клиенты для соцсетей расширены методами для получения статистики.
- Реализован `AnalyticsService` с методом, запускаемым по расписанию.
- Процесс сбора статистики успешно находит опубликованные посты и запрашивает по ним данные у API соцсетей.
- Рассчитанные KPI-метрики корректно сохраняются в таблицу `KpiSnapshot`.
- Реализована корректная обработка ошибок, включая ситуацию с удаленными постами.
- Данные из `KpiSnapshot` готовы для использования на дашборде аналитики (который будет запрашивать их через `GET` эндпоинт, реализованный в рамках ТЗ №2 или №5).
@@ -0,0 +1,93 @@
### Техническое задание (Версия 2.0): Интеграция AI-аналитики в `parser-service`
**Задача:** Модифицировать существующий `parser-service` для обогащения новостей аналитическими данными (саммари, теги, тональность) **в момент парсинга**, перед сохранением в базу данных.
**Контекст:** Мы отказываемся от создания отдельного `analytics-service` в пользу более простой, монолитной архитектуры. Аналитика должна стать неотъемлемой частью процесса парсинга. Каждая новость, попадающая в базу данных, должна уже содержать сгенерированные AI-данные.
---
## 1\. Архитектурные изменения
- **Никаких новых сервисов.** Вся логика реализуется внутри существующего `parser-service`.
- **Синхронный процесс:** Новый воркфлоу для каждой новости: **Парсинг -\> Аналитика -\> Сохранение в MongoDB**.
- **Новый компонент:** Внутри `parser-service` необходимо создать новый сервис/компонент (например, `OllamaAnalyticsService`), который будет отвечать за все взаимодействия с API Ollama.
---
## 2\. Основные требования
### 2.1. Создание `OllamaAnalyticsService`
В проекте `parser-service` создайте новый сервис, который будет инкапсулировать логику общения с Ollama.
- **Взаимодействие с API Ollama:**
- **Хост:** `http://185.35.223.45:11434`
- **Модель:** `gemma3:1b`
- **Клиент:** Использовать `WebClient` для HTTP-запросов к эндпоинту `/api/generate`.
- **Основной метод:** У сервиса должен быть публичный метод, например `Analytics analyzeText(String text)`, который принимает сырой текст статьи и возвращает готовый объект `Analytics` со всеми заполненными полями.
### 2.2. Модификация существующих парсеров
Необходимо изменить логику **каждого** существующего парсера (`KursivParserService`, `KapitalParserService` и т.д.).
**Новый алгоритм работы для метода `parseAndSaveRssFeed()`:**
1. Получить и разобрать данные из RSS-ленты.
2. Для каждой новости, после извлечения `raw_text`, **вызвать** метод `analyzeText` из нового `OllamaAnalyticsService`.
3. Получить в ответ заполненный объект `Analytics`.
4. Установить этот объект в поле `analytics` у сущности `MarketItem`.
5. **Только после этого** сохранить полностью обогащенный `MarketItem` в MongoDB.
**Примерный псевдокод для `KursivParserService`:**
```java
@Service
public class KursivParserService implements ParserService {
@Autowired
private OllamaAnalyticsService analyticsService; // Новый сервис
@Autowired
private MarketItemRepository repository;
@Override
public List<MarketItem> parseAndSaveRssFeed() {
// ... логика получения данных из RSS ...
for (RssItem rssItem : feedItems) {
MarketItem marketItem = new MarketItem();
marketItem.setTitle(rssItem.getTitle());
marketItem.setRawText(rssItem.getText());
// ... установить остальные поля ...
// === НОВЫЙ ШАГ ===
// Вызываем аналитику ПЕРЕД сохранением
Analytics analyticsData = analyticsService.analyzeText(rssItem.getText());
marketItem.setAnalytics(analyticsData);
// ==================
// Сохраняем уже обогащенный объект
repository.save(marketItem);
}
// ... вернуть результат ...
}
}
```
### 2.3. Промпты для Ollama
Используйте следующие промпты для каждой аналитической задачи внутри `OllamaAnalyticsService`:
- **Саммари:** `Напиши краткое саммари следующей новостной статьи на русском языке. Ответ должен содержать только саммари из 3-4 предложений, без лишних вступлений. Статья: [Текст статьи]`
- **Теги:** `Извлеки 5-7 ключевых слов или тегов из текста новостной статьи. В ответе дай только список тегов через запятую, без нумерации и заголовков. Статья: [Текст статьи]`
- **Тональность:** `Определи тональность текста новостной статьи. В ответе дай только одно слово латиницей: positive, negative или neutral. Статья: [Текст статьи]`
- **Сущности (JSON):** `Извлеки из текста имена людей, названия компаний и географические локации. В ответе дай только JSON объект следующей структуры: {"persons": [], "companies": [], "locations": []}. Статья: [Текст статьи]`
---
## Критерии выполнения
- Новый сервис `OllamaAnalyticsService` создан внутри проекта `parser-service`.
- Существующие парсеры (`KursivParserService` и др.) модифицированы для вызова `OllamaAnalyticsService` перед сохранением данных.
- Все новые записи, сохраняемые в MongoDB, **сразу содержат** заполненное поле `analytics`.
- Процесс парсинга теперь может занимать больше времени, это ожидаемое поведение.
- Реализована базовая обработка ошибок (например, если Ollama недоступен, поле `analytics` остается пустым, но парсинг не прерывается).