analytics
This commit is contained in:
@@ -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` остается пустым, но парсинг не прерывается).
|
||||
Reference in New Issue
Block a user