Kampanie w panelu admina naprawde sie wysylaja
Deploy Polska Lokalnie / deploy (push) Successful in 10s
Deploy Polska Lokalnie / deploy (push) Successful in 10s
Outbox byl budowany, ale nic go nie przetwarzalo - kampania wchodzila w status SENDING i tak zostawala. CampaignSender co minute bierze kampanie gotowe do wyslania i wysyla je porcjami po 25, pilnujac dziennego limitu kampanii. Tuz przed wyslaniem kazdy odbiorca jest sprawdzany ponownie (zgoda mogla zostac cofnieta po zbudowaniu outboxu) - taki dostaje OPTED_OUT. Kazda wiadomosc ma link rezygnacji. Bez skonfigurowanej skrzynki kolejka czeka, zamiast spalic sie na bledach. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
package pl.polskalokalnie.campaign;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.Limit;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.util.HtmlUtils;
|
||||
import pl.polskalokalnie.lead.Lead;
|
||||
import pl.polskalokalnie.lead.LeadRepository;
|
||||
import pl.polskalokalnie.mailconfig.MailConfigService;
|
||||
import pl.polskalokalnie.mailconfig.SmsGatewayConfig;
|
||||
import pl.polskalokalnie.mailconfig.SmtpConfig;
|
||||
import pl.polskalokalnie.send.EmailSender;
|
||||
import pl.polskalokalnie.send.SendResult;
|
||||
import pl.polskalokalnie.send.SmsSender;
|
||||
import pl.polskalokalnie.unsubscribe.UnsubscribeService;
|
||||
|
||||
/**
|
||||
* Wysylka kampanii z outboxu. Nic nie rusza sama z siebie - wysyla wylacznie to, co administrator
|
||||
* zaplanowal w panelu, porcjami i w granicach dziennego limitu kampanii.
|
||||
*
|
||||
* <p>Tuz przed wyslaniem kazdy odbiorca jest sprawdzany ponownie: zgoda mogla zostac cofnieta,
|
||||
* a rezygnacja przyjeta juz po zbudowaniu outboxu. Taki odbiorca dostaje status OPTED_OUT.</p>
|
||||
*/
|
||||
@Component
|
||||
public class CampaignSender {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CampaignSender.class);
|
||||
|
||||
/** Ile pozycji outboxu przetwarzamy na jeden przebieg - reszta poczeka minute. */
|
||||
private static final int BATCH_SIZE = 25;
|
||||
|
||||
private final CampaignRepository campaignRepository;
|
||||
private final CampaignRecipientRepository recipientRepository;
|
||||
private final LeadRepository leadRepository;
|
||||
private final MailConfigService mailConfigService;
|
||||
private final EmailSender emailSender;
|
||||
private final SmsSender smsSender;
|
||||
private final UnsubscribeService unsubscribeService;
|
||||
private final String baseUrl;
|
||||
|
||||
public CampaignSender(CampaignRepository campaignRepository,
|
||||
CampaignRecipientRepository recipientRepository,
|
||||
LeadRepository leadRepository,
|
||||
MailConfigService mailConfigService,
|
||||
EmailSender emailSender,
|
||||
SmsSender smsSender,
|
||||
UnsubscribeService unsubscribeService,
|
||||
@Value("${app.public-base-url:http://localhost}") String baseUrl) {
|
||||
this.campaignRepository = campaignRepository;
|
||||
this.recipientRepository = recipientRepository;
|
||||
this.leadRepository = leadRepository;
|
||||
this.mailConfigService = mailConfigService;
|
||||
this.emailSender = emailSender;
|
||||
this.smsSender = smsSender;
|
||||
this.unsubscribeService = unsubscribeService;
|
||||
this.baseUrl = baseUrl == null ? "http://localhost" : baseUrl.replaceAll("/+$", "");
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = 60_000, initialDelay = 30_000)
|
||||
@Transactional
|
||||
public void processOutbox() {
|
||||
Instant now = Instant.now();
|
||||
campaignRepository.findByStatus(CampaignStatus.SCHEDULED).stream()
|
||||
.filter(campaign -> campaign.getScheduledAt() == null || !campaign.getScheduledAt().isAfter(now))
|
||||
.forEach(campaign -> {
|
||||
campaign.setStatus(CampaignStatus.SENDING);
|
||||
campaignRepository.save(campaign);
|
||||
});
|
||||
|
||||
for (Campaign campaign : campaignRepository.findByStatus(CampaignStatus.SENDING)) {
|
||||
try {
|
||||
sendBatch(campaign);
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("Kampania {}: przebieg wysylki nie powiodl sie: {}", campaign.getId(), ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void sendBatch(Campaign campaign) {
|
||||
int remainingToday = remainingToday(campaign);
|
||||
if (remainingToday <= 0) {
|
||||
return;
|
||||
}
|
||||
List<CampaignRecipient> batch = recipientRepository.findByCampaignIdAndStatusOrderByIdAsc(
|
||||
campaign.getId(), RecipientStatus.QUEUED, Limit.of(Math.min(BATCH_SIZE, remainingToday)));
|
||||
if (batch.isEmpty()) {
|
||||
campaign.setStatus(CampaignStatus.COMPLETED);
|
||||
campaignRepository.save(campaign);
|
||||
return;
|
||||
}
|
||||
|
||||
SmtpConfig smtp = campaign.getChannel() == Channel.EMAIL ? mailConfigService.getSmtp() : null;
|
||||
SmsGatewayConfig sms = campaign.getChannel() == Channel.SMS ? mailConfigService.getSms() : null;
|
||||
// Bez skonfigurowanej skrzynki (albo bramki SMS) kolejka czeka - inaczej caly outbox
|
||||
// spalilby sie na bledach, a kampanii nie da sie wyslac drugi raz.
|
||||
if (!senderConfigured(smtp, sms)) {
|
||||
log.warn("Kampania {}: brak konfiguracji kanalu {} - wysylka czeka.", campaign.getId(), campaign.getChannel());
|
||||
return;
|
||||
}
|
||||
int sent = 0;
|
||||
|
||||
for (CampaignRecipient recipient : batch) {
|
||||
if (stillOptedOut(recipient)) {
|
||||
recipient.setStatus(RecipientStatus.OPTED_OUT);
|
||||
recipientRepository.save(recipient);
|
||||
continue;
|
||||
}
|
||||
SendResult result = campaign.getChannel() == Channel.EMAIL
|
||||
? emailSender.send(smtp, recipient.getAddress(), campaign.getSubject(), emailBody(campaign, recipient))
|
||||
: smsSender.send(sms, recipient.getAddress(), smsBody(campaign, recipient));
|
||||
if (result.ok()) {
|
||||
recipient.setStatus(RecipientStatus.SENT);
|
||||
recipient.setSentAt(Instant.now());
|
||||
recipient.setProviderMessageId(result.providerMessageId());
|
||||
recipient.setError(null);
|
||||
sent++;
|
||||
} else {
|
||||
recipient.setStatus(RecipientStatus.FAILED);
|
||||
recipient.setError(trim(result.error()));
|
||||
}
|
||||
recipientRepository.save(recipient);
|
||||
}
|
||||
|
||||
campaign.setSentToday(todaysCount(campaign) + sent);
|
||||
campaign.setSentTodayDate(LocalDate.now());
|
||||
if (!recipientRepository.findByCampaignIdAndStatusOrderByIdAsc(campaign.getId(), RecipientStatus.QUEUED, Limit.of(1)).isEmpty()) {
|
||||
campaignRepository.save(campaign);
|
||||
return;
|
||||
}
|
||||
campaign.setStatus(CampaignStatus.COMPLETED);
|
||||
campaignRepository.save(campaign);
|
||||
}
|
||||
|
||||
static boolean senderConfigured(SmtpConfig smtp, SmsGatewayConfig sms) {
|
||||
if (smtp != null) {
|
||||
return smtp.isEnabled() && smtp.getHost() != null && !smtp.getHost().isBlank();
|
||||
}
|
||||
return sms != null && sms.isEnabled() && sms.getEndpointUrl() != null && !sms.getEndpointUrl().isBlank();
|
||||
}
|
||||
|
||||
/** Ile jeszcze mozna dzis wyslac w tej kampanii - licznik zeruje sie z nowym dniem. */
|
||||
private int remainingToday(Campaign campaign) {
|
||||
int limit = campaign.getDailyLimit() == null || campaign.getDailyLimit() <= 0 ? Integer.MAX_VALUE : campaign.getDailyLimit();
|
||||
return limit - todaysCount(campaign);
|
||||
}
|
||||
|
||||
private int todaysCount(Campaign campaign) {
|
||||
boolean isToday = LocalDate.now().equals(campaign.getSentTodayDate());
|
||||
return isToday && campaign.getSentToday() != null ? campaign.getSentToday() : 0;
|
||||
}
|
||||
|
||||
/** Zgoda mogla zostac cofnieta po zbudowaniu outboxu - sprawdzamy ja tuz przed wyslaniem. */
|
||||
private boolean stillOptedOut(CampaignRecipient recipient) {
|
||||
Lead lead = recipient.getLeadId() == null ? null : leadRepository.findById(recipient.getLeadId()).orElse(null);
|
||||
if (lead == null) {
|
||||
return false;
|
||||
}
|
||||
return recipient.getChannel() == Channel.EMAIL
|
||||
? !lead.isEmailConsent() || lead.isEmailOptOut()
|
||||
: !lead.isSmsConsent() || lead.isSmsOptOut();
|
||||
}
|
||||
|
||||
/** Kazda wiadomosc marketingowa musi dac sie wypisac jednym kliknieciem. */
|
||||
private String unsubscribeLink(CampaignRecipient recipient) {
|
||||
String channel = recipient.getChannel() == Channel.EMAIL ? "email" : "sms";
|
||||
return baseUrl + "/api/unsubscribe?token=" + unsubscribeService.generateToken(recipient.getLeadId(), channel);
|
||||
}
|
||||
|
||||
private String emailBody(Campaign campaign, CampaignRecipient recipient) {
|
||||
String body = campaign.getBody() == null ? "" : campaign.getBody();
|
||||
String greeting = recipient.getName() == null || recipient.getName().isBlank() ? ""
|
||||
: "<p>Cześć " + HtmlUtils.htmlEscape(recipient.getName().trim().split("\\s+")[0]) + ",</p>";
|
||||
String link = recipient.getLeadId() == null ? null : unsubscribeLink(recipient);
|
||||
String footer = link == null ? ""
|
||||
: "<hr style=\"border:0;border-top:1px solid #dde5ee;margin:20px 0\">"
|
||||
+ "<p style=\"color:#8793a3;font-size:12px\">Otrzymujesz tę wiadomość, bo zgodziłeś(-aś) się na"
|
||||
+ " informacje marketingowe od serwisu Polska Lokalnie. "
|
||||
+ "<a href=\"" + link + "\" style=\"color:#12a764\">Zrezygnuj z tych wiadomości</a>.</p>";
|
||||
return "<!doctype html><html lang=\"pl\"><body style=\"margin:0;background:#f5f7fa\">"
|
||||
+ "<div style=\"max-width:560px;margin:0 auto;padding:24px 12px;font-family:Arial,Helvetica,sans-serif;color:#24344b\">"
|
||||
+ greeting + body + footer + "</div></body></html>";
|
||||
}
|
||||
|
||||
private String smsBody(Campaign campaign, CampaignRecipient recipient) {
|
||||
String body = campaign.getBody() == null ? "" : campaign.getBody().trim();
|
||||
String link = recipient.getLeadId() == null ? null : unsubscribeLink(recipient);
|
||||
return link == null ? body : body + " Rezygnacja: " + link;
|
||||
}
|
||||
|
||||
private static String trim(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return value.length() > 240 ? value.substring(0, 240) : value;
|
||||
}
|
||||
}
|
||||
@@ -85,8 +85,8 @@ public class CampaignService {
|
||||
|
||||
/**
|
||||
* Uruchamia kampanie: buduje outbox (jesli jeszcze nie istnieje) i ustawia status. Kampania
|
||||
* z data w przyszlosci przechodzi w SCHEDULED, w przeciwnym razie od razu w SENDING - workera
|
||||
* wysylajacego dodajemy w kolejnej fazie.
|
||||
* z data w przyszlosci przechodzi w SCHEDULED, w przeciwnym razie od razu w SENDING.
|
||||
* Wysylke z outboxu obsluguje {@link CampaignSender}.
|
||||
*/
|
||||
@Transactional
|
||||
public CampaignResponse schedule(Long id) {
|
||||
|
||||
Reference in New Issue
Block a user