feat(marketing): EDM real send with consent, unsubscribe and history (G-04)
Consent-filtered recipient resolution, SMTP send with test-mode ON by default (EDM_TEST_MODE, campaign cap, per-minute rate), opaque unsubscribe tokens with a public unsubscribe endpoint (no PII in URLs/logs), and per-campaign send history via V53. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
0922e5660b
commit
cf431a8809
@ -2,6 +2,9 @@ package com.zioinfo.kintex.mail;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 메일 발송 채널 설정 — 자체 Postfix SMTP(mail.zioinfo.co.kr, GUARDiA 운영).
|
||||
*
|
||||
@ -12,6 +15,15 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
* <li>{@code MAIL_ENABLED}(기본 false) — 실 발송 스위치. false면 인앱/로깅만 하고 실제 전송은 건너뛴다(정직 로그).</li>
|
||||
* <li>{@code MAIL_FROM} — From 헤더 표기 주소.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>EDM 발송 안전장치({@code edm-*})</b>: 대량 마케팅 발송의 오발송·과발송을 막는다.
|
||||
* <ul>
|
||||
* <li>{@code edm-test-mode}(기본 true) — 테스트 모드. 실제 수신자에게 보내지 않고 지정 테스트 수신함으로만 보내거나
|
||||
* dry-run 집계만 수행한다. 운영 발송은 발송 요청에 명시 플래그(live=true)가 있어야 한다.</li>
|
||||
* <li>{@code edm-test-recipients} — 테스트 모드 발송 대상(콤마 구분). 비어 있으면 dry-run 카운트만.</li>
|
||||
* <li>{@code edm-campaign-cap} — 캠페인 1회당 발송 상한(하드 실링).</li>
|
||||
* <li>{@code edm-rate-per-minute} — 1회 발송 호출(≈분당)에서 실제 디스패치할 최대 건수. 초과분은 rate_capped 스킵.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "kintex.mail")
|
||||
public class MailProperties {
|
||||
@ -22,9 +34,21 @@ public class MailProperties {
|
||||
/** 발신자 주소(From). */
|
||||
private String from = "no-reply@kintex.zioinfo.co.kr";
|
||||
|
||||
/** 프론트 베이스 URL — 메일 내 링크(옥션 상세·재설정 화면) 생성용. */
|
||||
/** 프론트 베이스 URL — 메일 내 링크(옥션 상세·재설정 화면·수신거부) 생성용. */
|
||||
private String webBaseUrl = "https://kintex.zioinfo.co.kr";
|
||||
|
||||
/** EDM 테스트 모드(기본 true) — 실 수신자 미발송. 운영 발송은 요청 live=true 명시 필요. */
|
||||
private boolean edmTestMode = true;
|
||||
|
||||
/** 테스트 모드 발송 대상(콤마 구분, env EDM_TEST_RECIPIENTS). 비면 dry-run 카운트만. */
|
||||
private List<String> edmTestRecipients = new ArrayList<>();
|
||||
|
||||
/** 캠페인 1회당 발송 상한(하드 실링). */
|
||||
private int edmCampaignCap = 5000;
|
||||
|
||||
/** 1회 발송 호출에서 실제 디스패치할 최대 건수(분당 rate cap). 초과분은 스킵. */
|
||||
private int edmRatePerMinute = 300;
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
@ -48,4 +72,36 @@ public class MailProperties {
|
||||
public void setWebBaseUrl(String webBaseUrl) {
|
||||
this.webBaseUrl = webBaseUrl;
|
||||
}
|
||||
|
||||
public boolean isEdmTestMode() {
|
||||
return edmTestMode;
|
||||
}
|
||||
|
||||
public void setEdmTestMode(boolean edmTestMode) {
|
||||
this.edmTestMode = edmTestMode;
|
||||
}
|
||||
|
||||
public List<String> getEdmTestRecipients() {
|
||||
return edmTestRecipients;
|
||||
}
|
||||
|
||||
public void setEdmTestRecipients(List<String> edmTestRecipients) {
|
||||
this.edmTestRecipients = edmTestRecipients == null ? new ArrayList<>() : edmTestRecipients;
|
||||
}
|
||||
|
||||
public int getEdmCampaignCap() {
|
||||
return edmCampaignCap;
|
||||
}
|
||||
|
||||
public void setEdmCampaignCap(int edmCampaignCap) {
|
||||
this.edmCampaignCap = edmCampaignCap;
|
||||
}
|
||||
|
||||
public int getEdmRatePerMinute() {
|
||||
return edmRatePerMinute;
|
||||
}
|
||||
|
||||
public void setEdmRatePerMinute(int edmRatePerMinute) {
|
||||
this.edmRatePerMinute = edmRatePerMinute;
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,8 +43,19 @@ public final class MailTemplates {
|
||||
+ WRAP_FOOT;
|
||||
}
|
||||
|
||||
/** EDM 광고 본문 — 캠페인 명 + 안내 + 정보통신망법 수신거부 문구. */
|
||||
/** EDM 광고 본문(수신거부 링크 없음 — 하위호환). 원클릭 수신거부는 {@link #edm(String, String, String)} 사용. */
|
||||
public static String edm(String campaignName, String eventName) {
|
||||
return edm(campaignName, eventName, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* EDM 광고 본문 — 캠페인 명 + 안내 + 정보통신망법 수신거부 문구 + 원클릭 수신거부 링크.
|
||||
* {@code unsubUrl} 이 있으면 하단에 수신거부 링크를 삽입한다(불투명 토큰 링크 — PII 미포함).
|
||||
*/
|
||||
public static String edm(String campaignName, String eventName, String unsubUrl) {
|
||||
String unsubLine = (unsubUrl == null || unsubUrl.isBlank())
|
||||
? "수신을 원치 않으시면 마이페이지 > 수신설정에서 수신거부하실 수 있습니다."
|
||||
: "수신을 원치 않으시면 <a href=\"" + esc(unsubUrl) + "\" style=\"color:#6b7280\">수신거부</a> 하실 수 있습니다.";
|
||||
return WRAP_HEAD
|
||||
+ "<p style=\"font-size:12px;color:#9ca3af\">(광고) " + esc(eventName) + "</p>"
|
||||
+ "<h2 style=\"font-size:18px\">" + esc(campaignName) + "</h2>"
|
||||
@ -52,8 +63,7 @@ public final class MailTemplates {
|
||||
+ "자세한 내용과 사전등록은 아래에서 확인하실 수 있습니다.</p>"
|
||||
+ "<hr style=\"border:none;border-top:1px solid #e5e7eb;margin:24px 0 12px\">"
|
||||
+ "<p style=\"font-size:12px;color:#9ca3af\">본 메일은 수신동의(마케팅 정보 수신)를 하신 분께 발송되었습니다. "
|
||||
+ "수신을 원치 않으시면 마이페이지 > 수신설정에서 수신거부하실 수 있습니다. "
|
||||
+ "(정보통신망 이용촉진 및 정보보호 등에 관한 법률 준수)</p>"
|
||||
+ unsubLine + " (정보통신망 이용촉진 및 정보보호 등에 관한 법률 준수)</p>"
|
||||
+ "<p style=\"font-size:12px;color:#9ca3af\">KINTEX 전시운영 시스템 · 발신 전용</p></div>";
|
||||
}
|
||||
|
||||
|
||||
@ -46,14 +46,19 @@ public class MarketingController {
|
||||
return ApiResponse.ok(service.createCampaign(eventId, req));
|
||||
}
|
||||
|
||||
/** 캠페인 발송(수신동의자 한정) — 매니저 이상. 정보통신망법 준수·회당 상한·배치 분할은 서비스가 강제. */
|
||||
/**
|
||||
* 캠페인 발송(수신동의자 한정) — 매니저 이상. 정보통신망법 준수·회당 상한·rate cap·수신거부 링크는 서비스가 강제.
|
||||
* <p>기본은 테스트 모드(실 수신자 미발송) — 운영 발송은 요청 본문 {@code live=true} 명시 + 서버 설정(EDM_TEST_MODE=false) 필요.
|
||||
*/
|
||||
@Audited(action = "CAMPAIGN_SEND", targetType = "edm_campaign")
|
||||
@PostMapping("/campaigns/{id}/send")
|
||||
public ApiResponse<CampaignSendResult> sendCampaign(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String eventId,
|
||||
@PathVariable String id) {
|
||||
@PathVariable String id,
|
||||
@RequestBody(required = false) CampaignSendRequest req) {
|
||||
guard.requireRole(principal, eventId, EventRole.ORGANIZER, EventRole.HALL_MANAGER);
|
||||
return ApiResponse.ok(service.sendCampaign(eventId, id));
|
||||
Boolean live = req == null ? null : req.live();
|
||||
return ApiResponse.ok(service.sendCampaign(eventId, id, live));
|
||||
}
|
||||
|
||||
/** 스폰서십 패키지·판매 현황. */
|
||||
|
||||
@ -53,29 +53,64 @@ public interface MarketingMapper {
|
||||
Map<String, Object> findCampaign(@Param("eventId") String eventId, @Param("id") String id);
|
||||
|
||||
/**
|
||||
* 발송 대상 이메일(수신동의 = agree_marketing=true, 이메일 보유). 원문 이메일은 발송 경로 한정 —
|
||||
* 응답/로그에 노출하지 않는다. 회당 상한(limit) 가드는 서비스가 적용.
|
||||
* 발송 대상(수신동의 = agree_marketing=true, 미수신거부 = marketing_unsubscribed=false, 이메일 보유).
|
||||
* 원문 이메일은 발송 경로 한정 — 응답/로그에 노출하지 않는다(§0-3). 각 행에 불투명 수신거부 토큰(unsubToken) 동봉.
|
||||
* 회당 상한(limit) 가드는 서비스가 적용. 행사 단위 이메일 유니크(uq_visitor_reg_event_email)로 중복 없음.
|
||||
*/
|
||||
@Select("""
|
||||
SELECT DISTINCT lower(email) AS "email"
|
||||
SELECT lower(email) AS "email", unsub_token AS "unsubToken"
|
||||
FROM visitor_registration
|
||||
WHERE event_id = #{eventId}
|
||||
AND agree_marketing = true
|
||||
AND marketing_unsubscribed = false
|
||||
AND email IS NOT NULL AND email <> ''
|
||||
ORDER BY lower(email)
|
||||
LIMIT #{limit}
|
||||
""")
|
||||
List<Map<String, Object>> listMarketingRecipients(@Param("eventId") String eventId, @Param("limit") int limit);
|
||||
|
||||
/** 발송 결과 반영 — 상태·발송 건수(audience)·표시 메타 갱신. */
|
||||
/** 수신동의 대상 총수(상한 초과분 집계용 — cap 적용 전 전체 규모). */
|
||||
@Select("""
|
||||
SELECT count(*)
|
||||
FROM visitor_registration
|
||||
WHERE event_id = #{eventId}
|
||||
AND agree_marketing = true
|
||||
AND marketing_unsubscribed = false
|
||||
AND email IS NOT NULL AND email <> ''
|
||||
""")
|
||||
long countMarketingRecipients(@Param("eventId") String eventId);
|
||||
|
||||
/**
|
||||
* 원클릭 수신거부 — 불투명 토큰으로 대상 등록의 수신거부 플래그 설정(멱등). 이메일/PII 노출 없음.
|
||||
* @return 갱신 행수(1=성공, 0=토큰 불일치 또는 이미 처리).
|
||||
*/
|
||||
@org.apache.ibatis.annotations.Update("""
|
||||
UPDATE visitor_registration
|
||||
SET marketing_unsubscribed = true
|
||||
WHERE unsub_token = #{token} AND marketing_unsubscribed = false
|
||||
""")
|
||||
int markUnsubscribed(@Param("token") String token);
|
||||
|
||||
/** 토큰 존재 여부(이미 거부된 건도 성공 안내하기 위한 확인). */
|
||||
@Select("SELECT count(*) FROM visitor_registration WHERE unsub_token = #{token}")
|
||||
long unsubTokenExists(@Param("token") String token);
|
||||
|
||||
/**
|
||||
* 발송 결과 반영 — 상태·성공 발송 건수(audience/sent_ok)·스킵·실패·테스트여부·표시 메타 갱신.
|
||||
* 테스트 모드는 상태를 done 으로 전이하지 않는다(서비스가 status 결정).
|
||||
*/
|
||||
@org.apache.ibatis.annotations.Update("""
|
||||
UPDATE edm_campaign
|
||||
SET status = #{status}, audience = #{sentCount}, meta = #{meta}, updated_at = now()
|
||||
SET status = #{status}, audience = #{sentOk},
|
||||
sent_ok = #{sentOk}, sent_skipped = #{skipped}, sent_failed = #{failed},
|
||||
last_test_mode = #{testMode}, last_sent_at = now(),
|
||||
meta = #{meta}, updated_at = now()
|
||||
WHERE id = #{id} AND event_id = #{eventId}
|
||||
""")
|
||||
int markCampaignSent(@Param("eventId") String eventId, @Param("id") String id,
|
||||
@Param("status") String status, @Param("sentCount") int sentCount,
|
||||
@Param("meta") String meta);
|
||||
int markCampaignResult(@Param("eventId") String eventId, @Param("id") String id,
|
||||
@Param("status") String status, @Param("sentOk") int sentOk,
|
||||
@Param("skipped") int skipped, @Param("failed") int failed,
|
||||
@Param("testMode") boolean testMode, @Param("meta") String meta);
|
||||
|
||||
// ── 스폰서십 ──
|
||||
@Select("""
|
||||
|
||||
@ -13,15 +13,15 @@ import java.util.UUID;
|
||||
|
||||
/**
|
||||
* M12 EDM·캠페인 · 스폰서십 서비스.
|
||||
* <p>★ 캠페인 실 발송은 미구현 — 생성 시 status 전이만 결정한다(즉시=draft, 예약=scheduled). 실 발송 게이트웨이 연동은 후속 갭.
|
||||
* <p>캠페인 발송(G-04): 수신동의 필터 → 발송 상한/rate cap → 테스트 모드(기본 ON) → 자체 SMTP 개별 발송 → 집계.
|
||||
* 수신자 원문/PII 는 응답·로그에 노출하지 않는다(건수만). 원클릭 수신거부는 {@link #unsubscribe(String)}.
|
||||
*/
|
||||
@Service
|
||||
public class MarketingService {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
/** EDM 회당 발송 상한(가드) 및 배치 크기. */
|
||||
private static final int SEND_CAP = 500;
|
||||
/** EDM 배치 크기(디스패치 청크). 회당 상한/rate cap 은 MailProperties(설정값)로 강제. */
|
||||
private static final int BATCH_SIZE = 50;
|
||||
|
||||
private final MarketingMapper mapper;
|
||||
@ -71,11 +71,19 @@ public class MarketingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 캠페인 실 발송(수신동의자 한정). 정보통신망법 준수: agree_marketing=true + 이메일 보유자만,
|
||||
* 회당 상한 {@value #SEND_CAP} 건, {@value #BATCH_SIZE} 건 배치. 상태를 done 으로 전이하고 발송 건수를 기록한다.
|
||||
* 캠페인 발송(수신동의자 한정). 정보통신망법 준수 — 대상: agree_marketing=true + 미수신거부 + 이메일 보유.
|
||||
* <p>안전장치:
|
||||
* <ol>
|
||||
* <li><b>수신동의 필터</b> — 매퍼가 동의·미거부·이메일 보유만 반환.</li>
|
||||
* <li><b>발송 상한</b> — 캠페인당 상한(edmCampaignCap) + 1회 rate cap(edmRatePerMinute). 초과분은 skipped.</li>
|
||||
* <li><b>테스트 모드(기본 ON)</b> — live≠true 면 실 수신자 미발송. 지정 테스트 수신함으로만 보내거나(없으면) dry-run 집계만.</li>
|
||||
* <li><b>수신거부 링크</b> — 각 본문에 불투명 토큰 원클릭 수신거부 링크 삽입.</li>
|
||||
* </ol>
|
||||
* 수신자 원문 이메일은 발송 경로 한정 — 응답/로그에는 건수만 노출한다(§0-3).
|
||||
*
|
||||
* @param live 운영 실발송 여부(true 만 실 수신자 발송). null/false 는 테스트 모드.
|
||||
*/
|
||||
public CampaignSendResult sendCampaign(String eventId, String campaignId) {
|
||||
public CampaignSendResult sendCampaign(String eventId, String campaignId, Boolean live) {
|
||||
Map<String, Object> c = mapper.findCampaign(eventId, campaignId);
|
||||
if (c == null) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND, "존재하지 않는 캠페인입니다.");
|
||||
@ -85,37 +93,108 @@ public class MarketingService {
|
||||
throw new ApiException(ErrorCode.CONFLICT, "이미 발송되었거나 발송 중인 캠페인입니다.");
|
||||
}
|
||||
String name = str(c.get("name"));
|
||||
String eventName = null;
|
||||
|
||||
List<Map<String, Object>> recipients = mapper.listMarketingRecipients(eventId, SEND_CAP);
|
||||
List<String> emails = new ArrayList<>();
|
||||
if (recipients != null) {
|
||||
for (Map<String, Object> r : recipients) {
|
||||
String e = str(r.get("email"));
|
||||
if (e != null && !e.isBlank()) {
|
||||
emails.add(e);
|
||||
}
|
||||
}
|
||||
// ── 발송 상한: 캠페인당 상한으로 대상 조회, rate cap 으로 1회 디스패치 제한 ──
|
||||
int cap = Math.max(mailProperties.getEdmCampaignCap(), 0);
|
||||
int rate = Math.max(mailProperties.getEdmRatePerMinute(), 0);
|
||||
long eligibleTotal = mapper.countMarketingRecipients(eventId);
|
||||
List<Map<String, Object>> recipients = mapper.listMarketingRecipients(eventId, cap);
|
||||
if (recipients == null) recipients = List.of();
|
||||
|
||||
boolean mailEnabled = mailProperties.isEnabled();
|
||||
// 테스트 모드 판정: 운영 발송은 live=true 명시 필수(기본 안전).
|
||||
boolean testMode = !(Boolean.TRUE.equals(live)) || mailProperties.isEdmTestMode();
|
||||
// 운영 발송을 명시해도 기본 설정이 테스트 모드이면 테스트 유지(2중 안전 — env 로만 운영 개방).
|
||||
if (Boolean.TRUE.equals(live) && !mailProperties.isEdmTestMode()) {
|
||||
testMode = false;
|
||||
}
|
||||
|
||||
String subject = "[KINTEX] " + (name == null ? "행사 소식" : name);
|
||||
String html = com.zioinfo.kintex.mail.MailTemplates.edm(name, eventName == null ? "KINTEX" : eventName);
|
||||
String baseUrl = trimTrailingSlash(mailProperties.getWebBaseUrl());
|
||||
|
||||
int sent = 0;
|
||||
// 50건 배치로 분할 발송(각 건은 MailService @Async·실패 격리). MAIL_ENABLED=false면 내부에서 스킵.
|
||||
for (List<String> batch : com.zioinfo.kintex.mail.MailBatch.partition(emails, BATCH_SIZE)) {
|
||||
for (String email : batch) {
|
||||
mailService.send("EDM", campaignId, email, subject, html);
|
||||
sent++;
|
||||
int eligible = recipients.size();
|
||||
int overCap = (int) Math.max(eligibleTotal - eligible, 0); // 캠페인 상한 초과 스킵
|
||||
int sentOk = 0;
|
||||
int skipped = overCap;
|
||||
int failed = 0;
|
||||
|
||||
if (testMode) {
|
||||
// 테스트 모드 — 실 수신자 미발송. 지정 테스트 수신함으로만 발송(없으면 dry-run 카운트만).
|
||||
List<String> testTo = sanitize(mailProperties.getEdmTestRecipients());
|
||||
String html = com.zioinfo.kintex.mail.MailTemplates.edm(name, "KINTEX",
|
||||
baseUrl + "/api/public/marketing/unsubscribe?token=SAMPLE");
|
||||
for (String to : testTo) {
|
||||
mailService.send("EDM_TEST", campaignId, to, "[TEST] " + subject, html);
|
||||
sentOk++;
|
||||
}
|
||||
skipped += eligible; // 실 대상 전원 이번엔 미발송(테스트)
|
||||
String meta = testTo.isEmpty()
|
||||
? "테스트(dry-run): 대상 " + eligible + "명 · 실발송 없음"
|
||||
: "테스트 발송: 테스트 수신함 " + sentOk + "건 · 실대상 " + eligible + "명 미발송";
|
||||
// 상태 미전이(draft/scheduled 유지) — 테스트는 캠페인을 소진하지 않는다.
|
||||
mapper.markCampaignResult(eventId, campaignId, status, sentOk, skipped, failed, true, meta);
|
||||
return new CampaignSendResult(campaignId, status, true, mailEnabled,
|
||||
eligible, sentOk, skipped, failed, meta);
|
||||
}
|
||||
|
||||
// ── 운영 실발송 — rate cap 만큼만 이번 호출에서 디스패치, 초과는 rate_capped 스킵 ──
|
||||
int dispatchLimit = rate > 0 ? Math.min(eligible, rate) : eligible;
|
||||
List<String[]> toDispatch = new ArrayList<>(dispatchLimit); // [email, unsubToken]
|
||||
for (int i = 0; i < recipients.size(); i++) {
|
||||
Map<String, Object> r = recipients.get(i);
|
||||
String email = str(r.get("email"));
|
||||
String token = str(r.get("unsubToken"));
|
||||
if (email == null || email.isBlank()) {
|
||||
failed++; // 방어(매퍼 필터로 사실상 발생 안 함)
|
||||
continue;
|
||||
}
|
||||
if (i < dispatchLimit) {
|
||||
toDispatch.add(new String[]{email, token});
|
||||
} else {
|
||||
skipped++; // rate cap 초과 — 후속 호출에서 재발송 가능
|
||||
}
|
||||
}
|
||||
|
||||
boolean mailEnabled = mailProperties.isEnabled();
|
||||
for (List<String[]> batch : com.zioinfo.kintex.mail.MailBatch.partition(toDispatch, BATCH_SIZE)) {
|
||||
for (String[] pair : batch) {
|
||||
String unsubUrl = baseUrl + "/api/public/marketing/unsubscribe?token="
|
||||
+ urlEncode(pair[1] == null ? "" : pair[1]);
|
||||
String html = com.zioinfo.kintex.mail.MailTemplates.edm(name, "KINTEX", unsubUrl);
|
||||
mailService.send("EDM", campaignId, pair[0], subject, html);
|
||||
sentOk++;
|
||||
}
|
||||
}
|
||||
|
||||
// 이번 호출로 대상 전부 소진했으면 done, 아니면 sending(잔여는 재호출 발송).
|
||||
int remaining = skipped - overCap - failed; // rate cap 잔여
|
||||
String newStatus = remaining > 0 ? "sending" : "done";
|
||||
String meta = mailEnabled
|
||||
? "발송 완료: " + sent + "건 (수신동의자)"
|
||||
: "발송 대상 " + sent + "건 (MAIL 비활성 — 실 전송 없음)";
|
||||
mapper.markCampaignSent(eventId, campaignId, "done", sent, meta);
|
||||
return new CampaignSendResult(campaignId, "done", sent, mailEnabled, meta);
|
||||
? "발송 " + sentOk + "건 (대상 " + eligibleTotal + "명"
|
||||
+ (remaining > 0 ? " · 잔여 " + remaining + "명" : "")
|
||||
+ (overCap > 0 ? " · 상한초과 " + overCap + "명" : "") + ")"
|
||||
: "발송 대상 " + sentOk + "건 (MAIL 비활성 — 실 전송 없음)";
|
||||
mapper.markCampaignResult(eventId, campaignId, newStatus, sentOk, skipped, failed, false, meta);
|
||||
return new CampaignSendResult(campaignId, newStatus, false, mailEnabled,
|
||||
(int) Math.min(eligibleTotal, Integer.MAX_VALUE), sentOk, skipped, failed, meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* 원클릭 수신거부 처리(공개·비인증). 불투명 토큰으로 대상 등록의 수신거부 플래그를 멱등 설정한다.
|
||||
* 토큰이 존재하면(이미 처리된 건 포함) 성공 안내한다. 잘못된 토큰은 실패로 구분.
|
||||
*
|
||||
* @return true=처리/이미처리(토큰 유효), false=토큰 불일치.
|
||||
*/
|
||||
public boolean unsubscribe(String token) {
|
||||
if (token == null || token.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
String t = token.trim();
|
||||
int updated = mapper.markUnsubscribed(t);
|
||||
if (updated > 0) {
|
||||
return true;
|
||||
}
|
||||
// 이미 거부되었거나(멱등) 토큰 존재 여부로 성공/실패 구분.
|
||||
return mapper.unsubTokenExists(t) > 0;
|
||||
}
|
||||
|
||||
// ── 스폰서십 ──
|
||||
@ -210,6 +289,29 @@ public class MarketingService {
|
||||
return s == null || s.isBlank() ? null : s;
|
||||
}
|
||||
|
||||
private static String trimTrailingSlash(String s) {
|
||||
if (s == null || s.isBlank()) return "";
|
||||
String t = s.trim();
|
||||
return t.endsWith("/") ? t.substring(0, t.length() - 1) : t;
|
||||
}
|
||||
|
||||
/** 콤마/공백 정리 — 빈 값 제거, 트림. */
|
||||
private static List<String> sanitize(List<String> in) {
|
||||
List<String> out = new ArrayList<>();
|
||||
if (in != null) {
|
||||
for (String s : in) {
|
||||
if (s != null && !s.isBlank()) {
|
||||
out.add(s.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String urlEncode(String s) {
|
||||
return java.net.URLEncoder.encode(s, java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static String trimOrNull(String s) {
|
||||
if (s == null) return null;
|
||||
String t = s.trim();
|
||||
|
||||
@ -0,0 +1,52 @@
|
||||
package com.zioinfo.kintex.marketing;
|
||||
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* EDM 원클릭 수신거부 — 공개·비인증({@code /api/public/**} permitAll). EDM 본문의 불투명 토큰 링크가 이 경로를 연다.
|
||||
* <p>브라우저에서 열리는 링크이므로 JSON 봉투 대신 간단한 HTML 안내 페이지를 반환한다(외부 리소스·PII 없음).
|
||||
* 토큰만으로 대상 등록의 수신거부 플래그를 멱등 설정한다(정보통신망법 — 수신거부 즉시 반영).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/public/marketing")
|
||||
public class UnsubscribeController {
|
||||
|
||||
private final MarketingService service;
|
||||
|
||||
public UnsubscribeController(MarketingService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
/** GET /api/public/marketing/unsubscribe?token= — 수신거부 처리 후 안내 HTML. */
|
||||
@GetMapping(value = "/unsubscribe", produces = MediaType.TEXT_HTML_VALUE + ";charset=UTF-8")
|
||||
public String unsubscribe(@RequestParam(required = false) String token) {
|
||||
boolean ok = service.unsubscribe(token);
|
||||
String title = ok ? "수신거부가 완료되었습니다" : "처리할 수 없는 요청입니다";
|
||||
String body = ok
|
||||
? "앞으로 마케팅 정보 메일을 보내지 않습니다. 다시 수신을 원하시면 마이페이지 > 수신설정에서 동의하실 수 있습니다."
|
||||
: "링크가 만료되었거나 올바르지 않습니다. 마이페이지 > 수신설정에서 직접 변경해 주세요.";
|
||||
return page(title, body);
|
||||
}
|
||||
|
||||
private static String page(String title, String body) {
|
||||
return "<!doctype html><html lang=\"ko\"><head><meta charset=\"utf-8\">"
|
||||
+ "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
|
||||
+ "<title>" + esc(title) + "</title></head>"
|
||||
+ "<body style=\"font-family:'Pretendard',Arial,sans-serif;background:#f6f7f9;margin:0\">"
|
||||
+ "<div style=\"max-width:480px;margin:64px auto;padding:32px;background:#fff;"
|
||||
+ "border-radius:16px;border:1px solid #e5e7eb;text-align:center;color:#1f2937\">"
|
||||
+ "<h1 style=\"font-size:20px;margin:0 0 12px\">" + esc(title) + "</h1>"
|
||||
+ "<p style=\"font-size:14px;line-height:1.6;color:#6b7280;margin:0\">" + body + "</p>"
|
||||
+ "<p style=\"font-size:12px;color:#9ca3af;margin-top:24px\">KINTEX 전시운영 시스템</p>"
|
||||
+ "</div></body></html>";
|
||||
}
|
||||
|
||||
private static String esc(String s) {
|
||||
if (s == null) return "";
|
||||
return s.replace("&", "&").replace("<", "<").replace(">", ">");
|
||||
}
|
||||
}
|
||||
@ -21,8 +21,22 @@ public final class MarketingDtos {
|
||||
String scheduleType, String scheduledAt) {
|
||||
}
|
||||
|
||||
/** 캠페인 발송 결과 — 발송 건수·상태. 수신자 원문/PII 는 포함하지 않는다. */
|
||||
public record CampaignSendResult(String id, String status, int sentCount, boolean mailEnabled,
|
||||
/** 캠페인 발송 요청 — live=true 면 운영 실발송, 그 외/미지정은 테스트 모드(안전 기본). */
|
||||
public record CampaignSendRequest(Boolean live) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 캠페인 발송 결과 집계 — 수신자 원문/PII 는 포함하지 않는다(건수만).
|
||||
* <ul>
|
||||
* <li>{@code testMode} — 테스트 모드 여부(실 수신자 미발송).</li>
|
||||
* <li>{@code eligible} — 수신동의·미거부 대상 총수(상한 적용 전).</li>
|
||||
* <li>{@code sentCount} — 실제 디스패치된 건수(테스트 모드면 테스트 수신함 건수).</li>
|
||||
* <li>{@code skipped} — 상한/rate cap/무효로 발송하지 않은 건수.</li>
|
||||
* <li>{@code failed} — 동기 검증 실패 건수(SMTP 비동기 실패는 mail_log 이력).</li>
|
||||
* </ul>
|
||||
*/
|
||||
public record CampaignSendResult(String id, String status, boolean testMode, boolean mailEnabled,
|
||||
int eligible, int sentCount, int skipped, int failed,
|
||||
String meta) {
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
-- V53: 마케팅(EDM) 수신동의·수신거부 + 발송 이력 집계 (GAP G-04 실발송).
|
||||
-- 멱등(IF NOT EXISTS / 조건부 UPDATE). 기존 마이그레이션(V1~V51) 불변 · 순증(additive)만.
|
||||
-- 보안 불변(§0-3): 원문 이메일은 응답/로그 미노출. unsub_token 은 무작위 불투명 토큰(PII 미포함).
|
||||
-- ★ V52 는 타 에이전트 예약 — 본 파일은 V53 만 사용한다.
|
||||
|
||||
-- ── 1) 관람객 사전등록: 수신거부 상태 + 불투명 수신거부 토큰 ──────────────────
|
||||
-- marketing_unsubscribed: 수신거부(true 시 발송 대상에서 영구 제외 — agree_marketing 과 별개 축).
|
||||
ALTER TABLE visitor_registration
|
||||
ADD COLUMN IF NOT EXISTS marketing_unsubscribed boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- unsub_token: EDM 하단 수신거부 링크의 불투명 토큰(이메일·PII 미포함, 재현 불가 난수).
|
||||
ALTER TABLE visitor_registration
|
||||
ADD COLUMN IF NOT EXISTS unsub_token varchar(64);
|
||||
|
||||
-- 신규 등록은 DB DEFAULT 로 토큰 자동 부여(애플리케이션 INSERT 수정 불필요, 확장 무의존).
|
||||
ALTER TABLE visitor_registration
|
||||
ALTER COLUMN unsub_token SET DEFAULT md5(random()::text || clock_timestamp()::text || random()::text);
|
||||
|
||||
-- 기존 행 백필(멱등 — NULL 만 채움).
|
||||
UPDATE visitor_registration
|
||||
SET unsub_token = md5(random()::text || clock_timestamp()::text || id)
|
||||
WHERE unsub_token IS NULL;
|
||||
|
||||
-- 토큰 조회(수신거부 처리)용 유니크 인덱스.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_visitor_reg_unsub_token
|
||||
ON visitor_registration (unsub_token) WHERE unsub_token IS NOT NULL;
|
||||
|
||||
-- 발송 대상 조회 가속(동의·미거부·이메일 보유).
|
||||
CREATE INDEX IF NOT EXISTS idx_visitor_reg_marketing_target
|
||||
ON visitor_registration (event_id)
|
||||
WHERE agree_marketing = true AND marketing_unsubscribed = false AND email IS NOT NULL;
|
||||
|
||||
-- ── 2) 캠페인 발송 결과 집계 컬럼 ────────────────────────────────────────────
|
||||
-- audience(=성공 발송건)와 별도로 스킵/실패/테스트여부를 표시용으로 보존.
|
||||
ALTER TABLE edm_campaign ADD COLUMN IF NOT EXISTS sent_ok integer NOT NULL DEFAULT 0;
|
||||
ALTER TABLE edm_campaign ADD COLUMN IF NOT EXISTS sent_skipped integer NOT NULL DEFAULT 0;
|
||||
ALTER TABLE edm_campaign ADD COLUMN IF NOT EXISTS sent_failed integer NOT NULL DEFAULT 0;
|
||||
ALTER TABLE edm_campaign ADD COLUMN IF NOT EXISTS last_test_mode boolean;
|
||||
ALTER TABLE edm_campaign ADD COLUMN IF NOT EXISTS last_sent_at timestamptz;
|
||||
@ -52,14 +52,32 @@ export function EdmCampaignPage() {
|
||||
|
||||
const [sendMsg, setSendMsg] = useState<string | null>(null);
|
||||
const sendMut = useMutation({
|
||||
// 안전 기본: 테스트 모드(live 미지정). 운영 실발송은 서버 설정(EDM_TEST_MODE=false)에서만 개방.
|
||||
mutationFn: (id: string) => marketingApi.sendCampaign(eventId as string, id),
|
||||
onSuccess: (res) => {
|
||||
const n = res.sentCount.toLocaleString();
|
||||
setSendMsg(
|
||||
res.mailEnabled
|
||||
? t('marketing.sentDone', { n })
|
||||
: t('marketing.sentDisabled', { n }),
|
||||
);
|
||||
let base: string;
|
||||
if (res.testMode) {
|
||||
// 신규 키(g04_i18n_todo) — 미번역 시 defaultValue 로 우아하게 폴백.
|
||||
base = t('marketing.sentTest', {
|
||||
defaultValue: '테스트 발송: 대상 {{eligible}}명 · 실발송 {{n}}건 (실 수신자 미발송)',
|
||||
n,
|
||||
eligible: res.eligible.toLocaleString(),
|
||||
});
|
||||
} else if (res.mailEnabled) {
|
||||
base = t('marketing.sentDone', { n });
|
||||
} else {
|
||||
base = t('marketing.sentDisabled', { n });
|
||||
}
|
||||
const extra =
|
||||
res.skipped > 0
|
||||
? ' · ' +
|
||||
t('marketing.skipped', {
|
||||
defaultValue: '보류 {{n}}건',
|
||||
n: res.skipped.toLocaleString(),
|
||||
})
|
||||
: '';
|
||||
setSendMsg(base + extra);
|
||||
queryClient.invalidateQueries({ queryKey: ['campaigns', eventId] });
|
||||
},
|
||||
onError: () => setSendMsg(t('marketing.sendFail')),
|
||||
@ -301,7 +319,7 @@ export function EdmCampaignPage() {
|
||||
{createMut.isPending ? '생성 중…' : schedule === 'later' ? '예약 캠페인 등록' : '캠페인 초안 저장'}
|
||||
</Button>
|
||||
<p className="kx-vis__ai-note" style={{ marginTop: 8 }}>
|
||||
실제 발송(게이트웨이 연동)은 후속 단계에서 제공됩니다 — 현재는 캠페인 등록·상태 관리만 수행합니다.
|
||||
발송 버튼은 기본 테스트 모드로 동작합니다(실 수신자 미발송·수신동의자만 집계). 운영 실발송은 서버 정책에 따라 개방됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@ -65,12 +65,16 @@ export interface SponsorshipViewDto {
|
||||
sponsors: SponsorDto[];
|
||||
}
|
||||
|
||||
// ── 캠페인 발송 결과 ──
|
||||
// ── 캠페인 발송 결과(집계) ──
|
||||
export interface CampaignSendResultDto {
|
||||
id: string;
|
||||
status: string; // done
|
||||
sentCount: number;
|
||||
status: string; // draft|scheduled|sending|done
|
||||
testMode: boolean; // 실 수신자 미발송(테스트/드라이런)
|
||||
mailEnabled: boolean;
|
||||
eligible: number; // 수신동의·미거부 대상 총수
|
||||
sentCount: number; // 실제 디스패치 건수
|
||||
skipped: number; // 상한/rate cap/무효 스킵
|
||||
failed: number; // 동기 검증 실패(SMTP 비동기 실패는 이력)
|
||||
meta: string;
|
||||
}
|
||||
|
||||
@ -81,10 +85,11 @@ export const marketingApi = {
|
||||
},
|
||||
createCampaign: (eventId: string, body: CampaignCreateBody) =>
|
||||
api.post<CampaignDto>(`/api/events/${encodeURIComponent(eventId)}/campaigns`, body),
|
||||
sendCampaign: (eventId: string, id: string) =>
|
||||
// live=true 는 운영 실발송(서버 EDM_TEST_MODE=false 필요). 미지정/false 는 안전 기본(테스트 모드).
|
||||
sendCampaign: (eventId: string, id: string, live?: boolean) =>
|
||||
api.post<CampaignSendResultDto>(
|
||||
`/api/events/${encodeURIComponent(eventId)}/campaigns/${encodeURIComponent(id)}/send`,
|
||||
{},
|
||||
{ live: live ?? false },
|
||||
),
|
||||
sponsorship: (eventId: string) =>
|
||||
api.get<SponsorshipViewDto>(`/api/events/${encodeURIComponent(eventId)}/sponsorship`),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user