diff --git a/src/main/java/kz/konturai/KonturaiApplication.java b/src/main/java/kz/konturai/KonturaiApplication.java index 85f7008..56f52b9 100644 --- a/src/main/java/kz/konturai/KonturaiApplication.java +++ b/src/main/java/kz/konturai/KonturaiApplication.java @@ -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) { diff --git a/src/main/java/kz/konturai/configuration/GlobalExceptionHandler.java b/src/main/java/kz/konturai/configuration/GlobalExceptionHandler.java index 7310702..a4d98b7 100644 --- a/src/main/java/kz/konturai/configuration/GlobalExceptionHandler.java +++ b/src/main/java/kz/konturai/configuration/GlobalExceptionHandler.java @@ -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 handleSocialApi(SocialApiException ex) { + return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(new ErrorResponse(ex.getMessage())); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleGeneric(Exception ex) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new ErrorResponse("Internal server error")); diff --git a/src/main/java/kz/konturai/controller/AnalyticsController.java b/src/main/java/kz/konturai/controller/AnalyticsController.java new file mode 100644 index 0000000..e3badbe --- /dev/null +++ b/src/main/java/kz/konturai/controller/AnalyticsController.java @@ -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 collectNow() { + analyticsService.collectAndSaveAnalytics(); + return Map.of( + "success", true, + "message", "Сбор аналитики запущен."); + } +} diff --git a/src/main/java/kz/konturai/controller/CampaignController.java b/src/main/java/kz/konturai/controller/CampaignController.java new file mode 100644 index 0000000..eda6157 --- /dev/null +++ b/src/main/java/kz/konturai/controller/CampaignController.java @@ -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 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); + } +} diff --git a/src/main/java/kz/konturai/controller/ChannelController.java b/src/main/java/kz/konturai/controller/ChannelController.java new file mode 100644 index 0000000..b196d15 --- /dev/null +++ b/src/main/java/kz/konturai/controller/ChannelController.java @@ -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 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); + } +} diff --git a/src/main/java/kz/konturai/controller/ContentController.java b/src/main/java/kz/konturai/controller/ContentController.java new file mode 100644 index 0000000..12cce7c --- /dev/null +++ b/src/main/java/kz/konturai/controller/ContentController.java @@ -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 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 listMessages(@PathVariable UUID id) { + return contentService.listMessages(id); + } +} diff --git a/src/main/java/kz/konturai/controller/PublishingController.java b/src/main/java/kz/konturai/controller/PublishingController.java new file mode 100644 index 0000000..043bcf5 --- /dev/null +++ b/src/main/java/kz/konturai/controller/PublishingController.java @@ -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 publish(@PathVariable UUID contentId) { + Message m = publishingService.publishContentById(contentId); + return Map.of( + "success", true, + "message", "Пост успешно опубликован", + "data", Map.of( + "messageId", m.getId(), + "externalUrl", m.getUrl())); + } +} diff --git a/src/main/java/kz/konturai/domain/Campaign.java b/src/main/java/kz/konturai/domain/Campaign.java new file mode 100644 index 0000000..a8ca320 --- /dev/null +++ b/src/main/java/kz/konturai/domain/Campaign.java @@ -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; + } +} diff --git a/src/main/java/kz/konturai/domain/Channel.java b/src/main/java/kz/konturai/domain/Channel.java new file mode 100644 index 0000000..7b4f0f6 --- /dev/null +++ b/src/main/java/kz/konturai/domain/Channel.java @@ -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; + } +} diff --git a/src/main/java/kz/konturai/domain/ContentQueue.java b/src/main/java/kz/konturai/domain/ContentQueue.java new file mode 100644 index 0000000..13225f1 --- /dev/null +++ b/src/main/java/kz/konturai/domain/ContentQueue.java @@ -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; + } +} diff --git a/src/main/java/kz/konturai/domain/KpiSnapshot.java b/src/main/java/kz/konturai/domain/KpiSnapshot.java new file mode 100644 index 0000000..a1b0e61 --- /dev/null +++ b/src/main/java/kz/konturai/domain/KpiSnapshot.java @@ -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; + } +} diff --git a/src/main/java/kz/konturai/domain/Message.java b/src/main/java/kz/konturai/domain/Message.java new file mode 100644 index 0000000..4c29c2c --- /dev/null +++ b/src/main/java/kz/konturai/domain/Message.java @@ -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; + } +} diff --git a/src/main/java/kz/konturai/domain/enums/CampaignStatus.java b/src/main/java/kz/konturai/domain/enums/CampaignStatus.java new file mode 100644 index 0000000..1ca9a4e --- /dev/null +++ b/src/main/java/kz/konturai/domain/enums/CampaignStatus.java @@ -0,0 +1,7 @@ +package kz.konturai.domain.enums; + +public enum CampaignStatus { + PLANNED, + ACTIVE, + COMPLETED +} diff --git a/src/main/java/kz/konturai/domain/enums/ChannelType.java b/src/main/java/kz/konturai/domain/enums/ChannelType.java new file mode 100644 index 0000000..ad38cd5 --- /dev/null +++ b/src/main/java/kz/konturai/domain/enums/ChannelType.java @@ -0,0 +1,7 @@ +package kz.konturai.domain.enums; + +public enum ChannelType { + TELEGRAM, + VK, + INSTAGRAM +} diff --git a/src/main/java/kz/konturai/domain/enums/ContentStatus.java b/src/main/java/kz/konturai/domain/enums/ContentStatus.java new file mode 100644 index 0000000..061d4a6 --- /dev/null +++ b/src/main/java/kz/konturai/domain/enums/ContentStatus.java @@ -0,0 +1,9 @@ +package kz.konturai.domain.enums; + +public enum ContentStatus { + DRAFT, + PENDING_APPROVAL, + APPROVED, + PUBLISHED, + FAILED +} diff --git a/src/main/java/kz/konturai/dto/CampaignDto.java b/src/main/java/kz/konturai/dto/CampaignDto.java new file mode 100644 index 0000000..e6a5e6c --- /dev/null +++ b/src/main/java/kz/konturai/dto/CampaignDto.java @@ -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; +} diff --git a/src/main/java/kz/konturai/dto/ChannelDto.java b/src/main/java/kz/konturai/dto/ChannelDto.java new file mode 100644 index 0000000..12e26ef --- /dev/null +++ b/src/main/java/kz/konturai/dto/ChannelDto.java @@ -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; +} diff --git a/src/main/java/kz/konturai/dto/ContentQueueDto.java b/src/main/java/kz/konturai/dto/ContentQueueDto.java new file mode 100644 index 0000000..2433f41 --- /dev/null +++ b/src/main/java/kz/konturai/dto/ContentQueueDto.java @@ -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; +} diff --git a/src/main/java/kz/konturai/dto/CreateCampaignRequest.java b/src/main/java/kz/konturai/dto/CreateCampaignRequest.java new file mode 100644 index 0000000..c620251 --- /dev/null +++ b/src/main/java/kz/konturai/dto/CreateCampaignRequest.java @@ -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; +} diff --git a/src/main/java/kz/konturai/dto/CreateChannelRequest.java b/src/main/java/kz/konturai/dto/CreateChannelRequest.java new file mode 100644 index 0000000..aa02e0a --- /dev/null +++ b/src/main/java/kz/konturai/dto/CreateChannelRequest.java @@ -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; +} diff --git a/src/main/java/kz/konturai/dto/CreateContentRequest.java b/src/main/java/kz/konturai/dto/CreateContentRequest.java new file mode 100644 index 0000000..f0cbb52 --- /dev/null +++ b/src/main/java/kz/konturai/dto/CreateContentRequest.java @@ -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; +} diff --git a/src/main/java/kz/konturai/dto/MessageDto.java b/src/main/java/kz/konturai/dto/MessageDto.java new file mode 100644 index 0000000..beeef57 --- /dev/null +++ b/src/main/java/kz/konturai/dto/MessageDto.java @@ -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; +} diff --git a/src/main/java/kz/konturai/dto/PostStatsDto.java b/src/main/java/kz/konturai/dto/PostStatsDto.java new file mode 100644 index 0000000..19516a9 --- /dev/null +++ b/src/main/java/kz/konturai/dto/PostStatsDto.java @@ -0,0 +1,7 @@ +package kz.konturai.dto; + +public class PostStatsDto { + public long views; + public long reactions; + public long shares; +} diff --git a/src/main/java/kz/konturai/exception/SocialApiException.java b/src/main/java/kz/konturai/exception/SocialApiException.java new file mode 100644 index 0000000..6564d28 --- /dev/null +++ b/src/main/java/kz/konturai/exception/SocialApiException.java @@ -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); + } +} diff --git a/src/main/java/kz/konturai/repository/CampaignRepository.java b/src/main/java/kz/konturai/repository/CampaignRepository.java new file mode 100644 index 0000000..02cf7fd --- /dev/null +++ b/src/main/java/kz/konturai/repository/CampaignRepository.java @@ -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 { +} diff --git a/src/main/java/kz/konturai/repository/ChannelRepository.java b/src/main/java/kz/konturai/repository/ChannelRepository.java new file mode 100644 index 0000000..a635c2f --- /dev/null +++ b/src/main/java/kz/konturai/repository/ChannelRepository.java @@ -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 { +} diff --git a/src/main/java/kz/konturai/repository/ContentQueueRepository.java b/src/main/java/kz/konturai/repository/ContentQueueRepository.java new file mode 100644 index 0000000..77788c2 --- /dev/null +++ b/src/main/java/kz/konturai/repository/ContentQueueRepository.java @@ -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 { +} diff --git a/src/main/java/kz/konturai/repository/KpiSnapshotRepository.java b/src/main/java/kz/konturai/repository/KpiSnapshotRepository.java new file mode 100644 index 0000000..7ab128a --- /dev/null +++ b/src/main/java/kz/konturai/repository/KpiSnapshotRepository.java @@ -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 { +} diff --git a/src/main/java/kz/konturai/repository/MessageRepository.java b/src/main/java/kz/konturai/repository/MessageRepository.java new file mode 100644 index 0000000..79ae285 --- /dev/null +++ b/src/main/java/kz/konturai/repository/MessageRepository.java @@ -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 { +} diff --git a/src/main/java/kz/konturai/service/impl/TelegramApiClient.java b/src/main/java/kz/konturai/service/impl/TelegramApiClient.java new file mode 100644 index 0000000..eac9d51 --- /dev/null +++ b/src/main/java/kz/konturai/service/impl/TelegramApiClient.java @@ -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 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 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); + } + } +} diff --git a/src/main/java/kz/konturai/service/spec/AnalyticsService.java b/src/main/java/kz/konturai/service/spec/AnalyticsService.java new file mode 100644 index 0000000..4a9203e --- /dev/null +++ b/src/main/java/kz/konturai/service/spec/AnalyticsService.java @@ -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 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); + } +} diff --git a/src/main/java/kz/konturai/service/spec/CampaignService.java b/src/main/java/kz/konturai/service/spec/CampaignService.java new file mode 100644 index 0000000..8c0261a --- /dev/null +++ b/src/main/java/kz/konturai/service/spec/CampaignService.java @@ -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 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; + } +} diff --git a/src/main/java/kz/konturai/service/spec/ChannelService.java b/src/main/java/kz/konturai/service/spec/ChannelService.java new file mode 100644 index 0000000..b1d236b --- /dev/null +++ b/src/main/java/kz/konturai/service/spec/ChannelService.java @@ -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 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; + } +} diff --git a/src/main/java/kz/konturai/service/spec/ContentService.java b/src/main/java/kz/konturai/service/spec/ContentService.java new file mode 100644 index 0000000..48bb44f --- /dev/null +++ b/src/main/java/kz/konturai/service/spec/ContentService.java @@ -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 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 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; + } +} diff --git a/src/main/java/kz/konturai/service/spec/PublishingService.java b/src/main/java/kz/konturai/service/spec/PublishingService.java new file mode 100644 index 0000000..5abdd3d --- /dev/null +++ b/src/main/java/kz/konturai/service/spec/PublishingService.java @@ -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; + } +} diff --git a/src/main/resources/db/migration/V4__create_smm_tables.sql b/src/main/resources/db/migration/V4__create_smm_tables.sql new file mode 100644 index 0000000..eddb6cd --- /dev/null +++ b/src/main/resources/db/migration/V4__create_smm_tables.sql @@ -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); + + diff --git a/ТЗ №5: Полный API для управления контентом.md b/ТЗ №5: Полный API для управления контентом.md new file mode 100644 index 0000000..e69de29 diff --git a/Техническое задание №1: Основа проекта, модели данных и репозитории.md b/Техническое задание №1: Основа проекта, модели данных и репозитории.md new file mode 100644 index 0000000..f7a6a64 --- /dev/null +++ b/Техническое задание №1: Основа проекта, модели данных и репозитории.md @@ -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 { + // Здесь в будущем можно будет добавлять кастомные методы для запросов +} +``` + +--- + +## 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 автоматически создает или обновляет таблицы в базе данных в соответствии с описанными моделями. diff --git a/Техническое задание №2: Базовое управление кампаниями и каналами (CRUD).md b/Техническое задание №2: Базовое управление кампаниями и каналами (CRUD).md new file mode 100644 index 0000000..e14b381 --- /dev/null +++ b/Техническое задание №2: Базовое управление кампаниями и каналами (CRUD).md @@ -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 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 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` +- **`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` +- **`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-статусы и тела ответов. +- Реализована обработка ошибок для случаев, когда сущность не найдена. +- Код покрыт базовыми юнит-тестами для сервисного слоя. diff --git a/Техническое задание №3: Модуль AI-генерации контента.md b/Техническое задание №3: Модуль AI-генерации контента.md new file mode 100644 index 0000000..20e0389 --- /dev/null +++ b/Техническое задание №3: Модуль AI-генерации контента.md @@ -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 assetsRefs; + private ContentStatus status; +} + +// Вспомогательный DTO для JSON-поля postDraft +public class PostDraftDto { + private String title; + private String body; + private List hashtags; +} +``` + +--- + +## 2\. Интеграция с внешними AI-сервисами + +Необходимо создать два новых сервиса-клиента для взаимодействия с внешними AI API. + +### `LanguageModelClient.java` (Агент "Writer/Researcher") + +- **Задача:** Отправлять запросы к LLM (например, Ollama, OpenAI) для генерации текста. +- **Методы:** + - `String generatePostText(String topic, String locale)`: Генерирует основной текст поста. + - `String generateTitle(String postText)`: Генерирует заголовок на основе текста. + - `List 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-сервисов. diff --git a/Техническое задание №4: Модуль публикации по расписанию.md b/Техническое задание №4: Модуль публикации по расписанию.md new file mode 100644 index 0000000..c039d1e --- /dev/null +++ b/Техническое задание №4: Модуль публикации по расписанию.md @@ -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 соцсетей. diff --git a/Техническое задание №6: Модуль сбора аналитики.md b/Техническое задание №6: Модуль сбора аналитики.md new file mode 100644 index 0000000..f491c5f --- /dev/null +++ b/Техническое задание №6: Модуль сбора аналитики.md @@ -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). diff --git a/Техническое задание: Интеграция AI-аналитики с помощью Ollama.md b/Техническое задание: Интеграция AI-аналитики с помощью Ollama.md new file mode 100644 index 0000000..cdc34e1 --- /dev/null +++ b/Техническое задание: Интеграция AI-аналитики с помощью Ollama.md @@ -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 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` остается пустым, но парсинг не прерывается).