From cf431a880944df8664697e20ad4cc57ad141582d Mon Sep 17 00:00:00 2001
From: zio
Date: Tue, 14 Jul 2026 06:26:32 +0900
Subject: [PATCH] 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
---
.../zioinfo/kintex/mail/MailProperties.java | 58 ++++++-
.../zioinfo/kintex/mail/MailTemplates.java | 16 +-
.../kintex/marketing/MarketingController.java | 11 +-
.../kintex/marketing/MarketingMapper.java | 51 +++++-
.../kintex/marketing/MarketingService.java | 158 ++++++++++++++----
.../marketing/UnsubscribeController.java | 52 ++++++
.../kintex/marketing/dto/MarketingDtos.java | 18 +-
.../V53__marketing_consent_send_history.sql | 39 +++++
.../src/screens/marketing/EdmCampaignPage.tsx | 30 +++-
.../src/screens/marketing/marketingApi.ts | 15 +-
10 files changed, 392 insertions(+), 56 deletions(-)
create mode 100644 src/backend/src/main/java/com/zioinfo/kintex/marketing/UnsubscribeController.java
create mode 100644 src/backend/src/main/resources/db/migration/V53__marketing_consent_send_history.sql
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/mail/MailProperties.java b/src/backend/src/main/java/com/zioinfo/kintex/mail/MailProperties.java
index 9c97a2e..597f4ba 100644
--- a/src/backend/src/main/java/com/zioinfo/kintex/mail/MailProperties.java
+++ b/src/backend/src/main/java/com/zioinfo/kintex/mail/MailProperties.java
@@ -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;
* {@code MAIL_ENABLED}(기본 false) — 실 발송 스위치. false면 인앱/로깅만 하고 실제 전송은 건너뛴다(정직 로그).
* {@code MAIL_FROM} — From 헤더 표기 주소.
*
+ *
+ * EDM 발송 안전장치({@code edm-*}): 대량 마케팅 발송의 오발송·과발송을 막는다.
+ *
+ * - {@code edm-test-mode}(기본 true) — 테스트 모드. 실제 수신자에게 보내지 않고 지정 테스트 수신함으로만 보내거나
+ * dry-run 집계만 수행한다. 운영 발송은 발송 요청에 명시 플래그(live=true)가 있어야 한다.
+ * - {@code edm-test-recipients} — 테스트 모드 발송 대상(콤마 구분). 비어 있으면 dry-run 카운트만.
+ * - {@code edm-campaign-cap} — 캠페인 1회당 발송 상한(하드 실링).
+ * - {@code edm-rate-per-minute} — 1회 발송 호출(≈분당)에서 실제 디스패치할 최대 건수. 초과분은 rate_capped 스킵.
+ *
*/
@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 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 getEdmTestRecipients() {
+ return edmTestRecipients;
+ }
+
+ public void setEdmTestRecipients(List 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;
+ }
}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/mail/MailTemplates.java b/src/backend/src/main/java/com/zioinfo/kintex/mail/MailTemplates.java
index 427f3cb..7474bf2 100644
--- a/src/backend/src/main/java/com/zioinfo/kintex/mail/MailTemplates.java
+++ b/src/backend/src/main/java/com/zioinfo/kintex/mail/MailTemplates.java
@@ -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())
+ ? "수신을 원치 않으시면 마이페이지 > 수신설정에서 수신거부하실 수 있습니다."
+ : "수신을 원치 않으시면 수신거부 하실 수 있습니다.";
return WRAP_HEAD
+ "(광고) " + esc(eventName) + "
"
+ "" + esc(campaignName) + "
"
@@ -52,8 +63,7 @@ public final class MailTemplates {
+ "자세한 내용과 사전등록은 아래에서 확인하실 수 있습니다.
"
+ "
"
+ "본 메일은 수신동의(마케팅 정보 수신)를 하신 분께 발송되었습니다. "
- + "수신을 원치 않으시면 마이페이지 > 수신설정에서 수신거부하실 수 있습니다. "
- + "(정보통신망 이용촉진 및 정보보호 등에 관한 법률 준수)
"
+ + unsubLine + " (정보통신망 이용촉진 및 정보보호 등에 관한 법률 준수)"
+ "KINTEX 전시운영 시스템 · 발신 전용
";
}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/marketing/MarketingController.java b/src/backend/src/main/java/com/zioinfo/kintex/marketing/MarketingController.java
index 89b65d7..9efa469 100644
--- a/src/backend/src/main/java/com/zioinfo/kintex/marketing/MarketingController.java
+++ b/src/backend/src/main/java/com/zioinfo/kintex/marketing/MarketingController.java
@@ -46,14 +46,19 @@ public class MarketingController {
return ApiResponse.ok(service.createCampaign(eventId, req));
}
- /** 캠페인 발송(수신동의자 한정) — 매니저 이상. 정보통신망법 준수·회당 상한·배치 분할은 서비스가 강제. */
+ /**
+ * 캠페인 발송(수신동의자 한정) — 매니저 이상. 정보통신망법 준수·회당 상한·rate cap·수신거부 링크는 서비스가 강제.
+ * 기본은 테스트 모드(실 수신자 미발송) — 운영 발송은 요청 본문 {@code live=true} 명시 + 서버 설정(EDM_TEST_MODE=false) 필요.
+ */
@Audited(action = "CAMPAIGN_SEND", targetType = "edm_campaign")
@PostMapping("/campaigns/{id}/send")
public ApiResponse 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));
}
/** 스폰서십 패키지·판매 현황. */
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/marketing/MarketingMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/marketing/MarketingMapper.java
index e166d57..1e19f40 100644
--- a/src/backend/src/main/java/com/zioinfo/kintex/marketing/MarketingMapper.java
+++ b/src/backend/src/main/java/com/zioinfo/kintex/marketing/MarketingMapper.java
@@ -53,29 +53,64 @@ public interface MarketingMapper {
Map 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