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:
zio 2026-07-14 06:26:32 +09:00
parent 0922e5660b
commit cf431a8809
10 changed files with 392 additions and 56 deletions

View File

@ -2,6 +2,9 @@ package com.zioinfo.kintex.mail;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.ArrayList;
import java.util.List;
/** /**
* 메일 발송 채널 설정 자체 Postfix SMTP(mail.zioinfo.co.kr, GUARDiA 운영). * 메일 발송 채널 설정 자체 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_ENABLED}(기본 false) 발송 스위치. false면 인앱/로깅만 하고 실제 전송은 건너뛴다(정직 로그).</li>
* <li>{@code MAIL_FROM} From 헤더 표기 주소.</li> * <li>{@code MAIL_FROM} From 헤더 표기 주소.</li>
* </ul> * </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") @ConfigurationProperties(prefix = "kintex.mail")
public class MailProperties { public class MailProperties {
@ -22,9 +34,21 @@ public class MailProperties {
/** 발신자 주소(From). */ /** 발신자 주소(From). */
private String from = "no-reply@kintex.zioinfo.co.kr"; private String from = "no-reply@kintex.zioinfo.co.kr";
/** 프론트 베이스 URL — 메일 내 링크(옥션 상세·재설정 화면) 생성용. */ /** 프론트 베이스 URL — 메일 내 링크(옥션 상세·재설정 화면·수신거부) 생성용. */
private String webBaseUrl = "https://kintex.zioinfo.co.kr"; 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() { public boolean isEnabled() {
return enabled; return enabled;
} }
@ -48,4 +72,36 @@ public class MailProperties {
public void setWebBaseUrl(String webBaseUrl) { public void setWebBaseUrl(String webBaseUrl) {
this.webBaseUrl = 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;
}
} }

View File

@ -43,8 +43,19 @@ public final class MailTemplates {
+ WRAP_FOOT; + WRAP_FOOT;
} }
/** EDM 광고 본문 — 캠페인 명 + 안내 + 정보통신망법 수신거부 문구. */ /** EDM 광고 본문(수신거부 링크 없음 — 하위호환). 원클릭 수신거부는 {@link #edm(String, String, String)} 사용. */
public static String edm(String campaignName, String eventName) { 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())
? "수신을 원치 않으시면 마이페이지 &gt; 수신설정에서 수신거부하실 수 있습니다."
: "수신을 원치 않으시면 <a href=\"" + esc(unsubUrl) + "\" style=\"color:#6b7280\">수신거부</a> 하실 수 있습니다.";
return WRAP_HEAD return WRAP_HEAD
+ "<p style=\"font-size:12px;color:#9ca3af\">(광고) " + esc(eventName) + "</p>" + "<p style=\"font-size:12px;color:#9ca3af\">(광고) " + esc(eventName) + "</p>"
+ "<h2 style=\"font-size:18px\">" + esc(campaignName) + "</h2>" + "<h2 style=\"font-size:18px\">" + esc(campaignName) + "</h2>"
@ -52,8 +63,7 @@ public final class MailTemplates {
+ "자세한 내용과 사전등록은 아래에서 확인하실 수 있습니다.</p>" + "자세한 내용과 사전등록은 아래에서 확인하실 수 있습니다.</p>"
+ "<hr style=\"border:none;border-top:1px solid #e5e7eb;margin:24px 0 12px\">" + "<hr style=\"border:none;border-top:1px solid #e5e7eb;margin:24px 0 12px\">"
+ "<p style=\"font-size:12px;color:#9ca3af\">본 메일은 수신동의(마케팅 정보 수신)를 하신 분께 발송되었습니다. " + "<p style=\"font-size:12px;color:#9ca3af\">본 메일은 수신동의(마케팅 정보 수신)를 하신 분께 발송되었습니다. "
+ "수신을 원치 않으시면 마이페이지 &gt; 수신설정에서 수신거부하실 수 있습니다. " + unsubLine + " (정보통신망 이용촉진 및 정보보호 등에 관한 법률 준수)</p>"
+ "(정보통신망 이용촉진 및 정보보호 등에 관한 법률 준수)</p>"
+ "<p style=\"font-size:12px;color:#9ca3af\">KINTEX 전시운영 시스템 · 발신 전용</p></div>"; + "<p style=\"font-size:12px;color:#9ca3af\">KINTEX 전시운영 시스템 · 발신 전용</p></div>";
} }

View File

@ -46,14 +46,19 @@ public class MarketingController {
return ApiResponse.ok(service.createCampaign(eventId, req)); return ApiResponse.ok(service.createCampaign(eventId, req));
} }
/** 캠페인 발송(수신동의자 한정) — 매니저 이상. 정보통신망법 준수·회당 상한·배치 분할은 서비스가 강제. */ /**
* 캠페인 발송(수신동의자 한정) 매니저 이상. 정보통신망법 준수·회당 상한·rate cap·수신거부 링크는 서비스가 강제.
* <p>기본은 테스트 모드( 수신자 미발송) 운영 발송은 요청 본문 {@code live=true} 명시 + 서버 설정(EDM_TEST_MODE=false) 필요.
*/
@Audited(action = "CAMPAIGN_SEND", targetType = "edm_campaign") @Audited(action = "CAMPAIGN_SEND", targetType = "edm_campaign")
@PostMapping("/campaigns/{id}/send") @PostMapping("/campaigns/{id}/send")
public ApiResponse<CampaignSendResult> sendCampaign(@AuthenticationPrincipal KintexPrincipal principal, public ApiResponse<CampaignSendResult> sendCampaign(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String eventId, @PathVariable String eventId,
@PathVariable String id) { @PathVariable String id,
@RequestBody(required = false) CampaignSendRequest req) {
guard.requireRole(principal, eventId, EventRole.ORGANIZER, EventRole.HALL_MANAGER); 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));
} }
/** 스폰서십 패키지·판매 현황. */ /** 스폰서십 패키지·판매 현황. */

View File

@ -53,29 +53,64 @@ public interface MarketingMapper {
Map<String, Object> findCampaign(@Param("eventId") String eventId, @Param("id") String id); Map<String, Object> findCampaign(@Param("eventId") String eventId, @Param("id") String id);
/** /**
* 발송 대상 이메일(수신동의 = agree_marketing=true, 이메일 보유). 원문 이메일은 발송 경로 한정 * 발송 대상(수신동의 = agree_marketing=true, 미수신거부 = marketing_unsubscribed=false, 이메일 보유).
* 응답/로그에 노출하지 않는다. 회당 상한(limit) 가드는 서비스가 적용. * 원문 이메일은 발송 경로 한정 응답/로그에 노출하지 않는다(§0-3). 행에 불투명 수신거부 토큰(unsubToken) 동봉.
* 회당 상한(limit) 가드는 서비스가 적용. 행사 단위 이메일 유니크(uq_visitor_reg_event_email) 중복 없음.
*/ */
@Select(""" @Select("""
SELECT DISTINCT lower(email) AS "email" SELECT lower(email) AS "email", unsub_token AS "unsubToken"
FROM visitor_registration FROM visitor_registration
WHERE event_id = #{eventId} WHERE event_id = #{eventId}
AND agree_marketing = true AND agree_marketing = true
AND marketing_unsubscribed = false
AND email IS NOT NULL AND email <> '' AND email IS NOT NULL AND email <> ''
ORDER BY lower(email) ORDER BY lower(email)
LIMIT #{limit} LIMIT #{limit}
""") """)
List<Map<String, Object>> listMarketingRecipients(@Param("eventId") String eventId, @Param("limit") int 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(""" @org.apache.ibatis.annotations.Update("""
UPDATE edm_campaign 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} WHERE id = #{id} AND event_id = #{eventId}
""") """)
int markCampaignSent(@Param("eventId") String eventId, @Param("id") String id, int markCampaignResult(@Param("eventId") String eventId, @Param("id") String id,
@Param("status") String status, @Param("sentCount") int sentCount, @Param("status") String status, @Param("sentOk") int sentOk,
@Param("meta") String meta); @Param("skipped") int skipped, @Param("failed") int failed,
@Param("testMode") boolean testMode, @Param("meta") String meta);
// 스폰서십 // 스폰서십
@Select(""" @Select("""

View File

@ -13,15 +13,15 @@ import java.util.UUID;
/** /**
* M12 EDM·캠페인 · 스폰서십 서비스. * M12 EDM·캠페인 · 스폰서십 서비스.
* <p> 캠페인 발송은 미구현 생성 status 전이만 결정한다(즉시=draft, 예약=scheduled). 발송 게이트웨이 연동은 후속 . * <p>캠페인 발송(G-04): 수신동의 필터 발송 상한/rate cap 테스트 모드(기본 ON) 자체 SMTP 개별 발송 집계.
* 수신자 원문/PII 응답·로그에 노출하지 않는다(건수만). 원클릭 수신거부는 {@link #unsubscribe(String)}.
*/ */
@Service @Service
public class MarketingService { public class MarketingService {
private static final ObjectMapper JSON = new ObjectMapper(); private static final ObjectMapper JSON = new ObjectMapper();
/** EDM 회당 발송 상한(가드) 및 배치 크기. */ /** EDM 배치 크기(디스패치 청크). 회당 상한/rate cap 은 MailProperties(설정값)로 강제. */
private static final int SEND_CAP = 500;
private static final int BATCH_SIZE = 50; private static final int BATCH_SIZE = 50;
private final MarketingMapper mapper; private final MarketingMapper mapper;
@ -71,11 +71,19 @@ public class MarketingService {
} }
/** /**
* 캠페인 발송(수신동의자 한정). 정보통신망법 준수: agree_marketing=true + 이메일 보유자만, * 캠페인 발송(수신동의자 한정). 정보통신망법 준수 대상: agree_marketing=true + 미수신거부 + 이메일 보유.
* 회당 상한 {@value #SEND_CAP} , {@value #BATCH_SIZE} 배치. 상태를 done 으로 전이하고 발송 건수를 기록한다. * <p>안전장치:
* <ol>
* <li><b>수신동의 필터</b> 매퍼가 동의·미거부·이메일 보유만 반환.</li>
* <li><b>발송 상한</b> 캠페인당 상한(edmCampaignCap) + 1회 rate cap(edmRatePerMinute). 초과분은 skipped.</li>
* <li><b>테스트 모드(기본 ON)</b> livetrue 수신자 미발송. 지정 테스트 수신함으로만 보내거나(없으면) dry-run 집계만.</li>
* <li><b>수신거부 링크</b> 본문에 불투명 토큰 원클릭 수신거부 링크 삽입.</li>
* </ol>
* 수신자 원문 이메일은 발송 경로 한정 응답/로그에는 건수만 노출한다(§0-3). * 수신자 원문 이메일은 발송 경로 한정 응답/로그에는 건수만 노출한다(§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); Map<String, Object> c = mapper.findCampaign(eventId, campaignId);
if (c == null) { if (c == null) {
throw new ApiException(ErrorCode.NOT_FOUND, "존재하지 않는 캠페인입니다."); throw new ApiException(ErrorCode.NOT_FOUND, "존재하지 않는 캠페인입니다.");
@ -85,37 +93,108 @@ public class MarketingService {
throw new ApiException(ErrorCode.CONFLICT, "이미 발송되었거나 발송 중인 캠페인입니다."); throw new ApiException(ErrorCode.CONFLICT, "이미 발송되었거나 발송 중인 캠페인입니다.");
} }
String name = str(c.get("name")); String name = str(c.get("name"));
String eventName = null;
List<Map<String, Object>> recipients = mapper.listMarketingRecipients(eventId, SEND_CAP); // 발송 상한: 캠페인당 상한으로 대상 조회, rate cap 으로 1회 디스패치 제한
List<String> emails = new ArrayList<>(); int cap = Math.max(mailProperties.getEdmCampaignCap(), 0);
if (recipients != null) { int rate = Math.max(mailProperties.getEdmRatePerMinute(), 0);
for (Map<String, Object> r : recipients) { long eligibleTotal = mapper.countMarketingRecipients(eventId);
String e = str(r.get("email")); List<Map<String, Object>> recipients = mapper.listMarketingRecipients(eventId, cap);
if (e != null && !e.isBlank()) { if (recipients == null) recipients = List.of();
emails.add(e);
} 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 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; int eligible = recipients.size();
// 50건 배치로 분할 발송( 건은 MailService @Async·실패 격리). MAIL_ENABLED=false면 내부에서 스킵. int overCap = (int) Math.max(eligibleTotal - eligible, 0); // 캠페인 상한 초과 스킵
for (List<String> batch : com.zioinfo.kintex.mail.MailBatch.partition(emails, BATCH_SIZE)) { int sentOk = 0;
for (String email : batch) { int skipped = overCap;
mailService.send("EDM", campaignId, email, subject, html); int failed = 0;
sent++;
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 String meta = mailEnabled
? "발송 완료: " + sent + "건 (수신동의자)" ? "발송 " + sentOk + "건 (대상 " + eligibleTotal + ""
: "발송 대상 " + sent + "건 (MAIL 비활성 — 실 전송 없음)"; + (remaining > 0 ? " · 잔여 " + remaining + "" : "")
mapper.markCampaignSent(eventId, campaignId, "done", sent, meta); + (overCap > 0 ? " · 상한초과 " + overCap + "" : "") + ")"
return new CampaignSendResult(campaignId, "done", sent, mailEnabled, meta); : "발송 대상 " + 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; 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) { private static String trimOrNull(String s) {
if (s == null) return null; if (s == null) return null;
String t = s.trim(); String t = s.trim();

View File

@ -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
? "앞으로 마케팅 정보 메일을 보내지 않습니다. 다시 수신을 원하시면 마이페이지 &gt; 수신설정에서 동의하실 수 있습니다."
: "링크가 만료되었거나 올바르지 않습니다. 마이페이지 &gt; 수신설정에서 직접 변경해 주세요.";
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
}

View File

@ -21,8 +21,22 @@ public final class MarketingDtos {
String scheduleType, String scheduledAt) { String scheduleType, String scheduledAt) {
} }
/** 캠페인 발송 결과 — 발송 건수·상태. 수신자 원문/PII 는 포함하지 않는다. */ /** 캠페인 발송 요청 — live=true 면 운영 실발송, 그 외/미지정은 테스트 모드(안전 기본). */
public record CampaignSendResult(String id, String status, int sentCount, boolean mailEnabled, 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) { String meta) {
} }

View File

@ -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;

View File

@ -52,14 +52,32 @@ export function EdmCampaignPage() {
const [sendMsg, setSendMsg] = useState<string | null>(null); const [sendMsg, setSendMsg] = useState<string | null>(null);
const sendMut = useMutation({ const sendMut = useMutation({
// 안전 기본: 테스트 모드(live 미지정). 운영 실발송은 서버 설정(EDM_TEST_MODE=false)에서만 개방.
mutationFn: (id: string) => marketingApi.sendCampaign(eventId as string, id), mutationFn: (id: string) => marketingApi.sendCampaign(eventId as string, id),
onSuccess: (res) => { onSuccess: (res) => {
const n = res.sentCount.toLocaleString(); const n = res.sentCount.toLocaleString();
setSendMsg( let base: string;
res.mailEnabled if (res.testMode) {
? t('marketing.sentDone', { n }) // 신규 키(g04_i18n_todo) — 미번역 시 defaultValue 로 우아하게 폴백.
: t('marketing.sentDisabled', { n }), 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] }); queryClient.invalidateQueries({ queryKey: ['campaigns', eventId] });
}, },
onError: () => setSendMsg(t('marketing.sendFail')), onError: () => setSendMsg(t('marketing.sendFail')),
@ -301,7 +319,7 @@ export function EdmCampaignPage() {
{createMut.isPending ? '생성 중…' : schedule === 'later' ? '예약 캠페인 등록' : '캠페인 초안 저장'} {createMut.isPending ? '생성 중…' : schedule === 'later' ? '예약 캠페인 등록' : '캠페인 초안 저장'}
</Button> </Button>
<p className="kx-vis__ai-note" style={{ marginTop: 8 }}> <p className="kx-vis__ai-note" style={{ marginTop: 8 }}>
( ) · . ( · ). .
</p> </p>
</div> </div>
</aside> </aside>

View File

@ -65,12 +65,16 @@ export interface SponsorshipViewDto {
sponsors: SponsorDto[]; sponsors: SponsorDto[];
} }
// ── 캠페인 발송 결과 ── // ── 캠페인 발송 결과(집계) ──
export interface CampaignSendResultDto { export interface CampaignSendResultDto {
id: string; id: string;
status: string; // done status: string; // draft|scheduled|sending|done
sentCount: number; testMode: boolean; // 실 수신자 미발송(테스트/드라이런)
mailEnabled: boolean; mailEnabled: boolean;
eligible: number; // 수신동의·미거부 대상 총수
sentCount: number; // 실제 디스패치 건수
skipped: number; // 상한/rate cap/무효 스킵
failed: number; // 동기 검증 실패(SMTP 비동기 실패는 이력)
meta: string; meta: string;
} }
@ -81,10 +85,11 @@ export const marketingApi = {
}, },
createCampaign: (eventId: string, body: CampaignCreateBody) => createCampaign: (eventId: string, body: CampaignCreateBody) =>
api.post<CampaignDto>(`/api/events/${encodeURIComponent(eventId)}/campaigns`, body), 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.post<CampaignSendResultDto>(
`/api/events/${encodeURIComponent(eventId)}/campaigns/${encodeURIComponent(id)}/send`, `/api/events/${encodeURIComponent(eventId)}/campaigns/${encodeURIComponent(id)}/send`,
{}, { live: live ?? false },
), ),
sponsorship: (eventId: string) => sponsorship: (eventId: string) =>
api.get<SponsorshipViewDto>(`/api/events/${encodeURIComponent(eventId)}/sponsorship`), api.get<SponsorshipViewDto>(`/api/events/${encodeURIComponent(eventId)}/sponsorship`),