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-*}): 대량 마케팅 발송의 오발송·과발송을 막는다. + *

    */ @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> 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(""" diff --git a/src/backend/src/main/java/com/zioinfo/kintex/marketing/MarketingService.java b/src/backend/src/main/java/com/zioinfo/kintex/marketing/MarketingService.java index 63a91b5..5840af2 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/marketing/MarketingService.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/marketing/MarketingService.java @@ -13,15 +13,15 @@ import java.util.UUID; /** * M12 EDM·캠페인 · 스폰서십 서비스. - *

    ★ 캠페인 실 발송은 미구현 — 생성 시 status 전이만 결정한다(즉시=draft, 예약=scheduled). 실 발송 게이트웨이 연동은 후속 갭. + *

    캠페인 발송(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 + 미수신거부 + 이메일 보유. + *

    안전장치: + *

      + *
    1. 수신동의 필터 — 매퍼가 동의·미거부·이메일 보유만 반환.
    2. + *
    3. 발송 상한 — 캠페인당 상한(edmCampaignCap) + 1회 rate cap(edmRatePerMinute). 초과분은 skipped.
    4. + *
    5. 테스트 모드(기본 ON) — live≠true 면 실 수신자 미발송. 지정 테스트 수신함으로만 보내거나(없으면) dry-run 집계만.
    6. + *
    7. 수신거부 링크 — 각 본문에 불투명 토큰 원클릭 수신거부 링크 삽입.
    8. + *
    * 수신자 원문 이메일은 발송 경로 한정 — 응답/로그에는 건수만 노출한다(§0-3). + * + * @param live 운영 실발송 여부(true 만 실 수신자 발송). null/false 는 테스트 모드. */ - public CampaignSendResult sendCampaign(String eventId, String campaignId) { + public CampaignSendResult sendCampaign(String eventId, String campaignId, Boolean live) { Map 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> recipients = mapper.listMarketingRecipients(eventId, SEND_CAP); - List emails = new ArrayList<>(); - if (recipients != null) { - for (Map 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> 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 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 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 toDispatch = new ArrayList<>(dispatchLimit); // [email, unsubToken] + for (int i = 0; i < recipients.size(); i++) { + Map 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 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 sanitize(List in) { + List 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(); diff --git a/src/backend/src/main/java/com/zioinfo/kintex/marketing/UnsubscribeController.java b/src/backend/src/main/java/com/zioinfo/kintex/marketing/UnsubscribeController.java new file mode 100644 index 0000000..19c0912 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/marketing/UnsubscribeController.java @@ -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 본문의 불투명 토큰 링크가 이 경로를 연다. + *

    브라우저에서 열리는 링크이므로 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 "" + + "" + + "" + esc(title) + "" + + "" + + "

    " + + "

    " + esc(title) + "

    " + + "

    " + body + "

    " + + "

    KINTEX 전시운영 시스템

    " + + "
    "; + } + + private static String esc(String s) { + if (s == null) return ""; + return s.replace("&", "&").replace("<", "<").replace(">", ">"); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/marketing/dto/MarketingDtos.java b/src/backend/src/main/java/com/zioinfo/kintex/marketing/dto/MarketingDtos.java index 328bd5f..7244873 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/marketing/dto/MarketingDtos.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/marketing/dto/MarketingDtos.java @@ -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 는 포함하지 않는다(건수만). + *
      + *
    • {@code testMode} — 테스트 모드 여부(실 수신자 미발송).
    • + *
    • {@code eligible} — 수신동의·미거부 대상 총수(상한 적용 전).
    • + *
    • {@code sentCount} — 실제 디스패치된 건수(테스트 모드면 테스트 수신함 건수).
    • + *
    • {@code skipped} — 상한/rate cap/무효로 발송하지 않은 건수.
    • + *
    • {@code failed} — 동기 검증 실패 건수(SMTP 비동기 실패는 mail_log 이력).
    • + *
    + */ + public record CampaignSendResult(String id, String status, boolean testMode, boolean mailEnabled, + int eligible, int sentCount, int skipped, int failed, String meta) { } diff --git a/src/backend/src/main/resources/db/migration/V53__marketing_consent_send_history.sql b/src/backend/src/main/resources/db/migration/V53__marketing_consent_send_history.sql new file mode 100644 index 0000000..bf1aef7 --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V53__marketing_consent_send_history.sql @@ -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; diff --git a/src/frontend/src/screens/marketing/EdmCampaignPage.tsx b/src/frontend/src/screens/marketing/EdmCampaignPage.tsx index 0136d80..1b59ee3 100644 --- a/src/frontend/src/screens/marketing/EdmCampaignPage.tsx +++ b/src/frontend/src/screens/marketing/EdmCampaignPage.tsx @@ -52,14 +52,32 @@ export function EdmCampaignPage() { const [sendMsg, setSendMsg] = useState(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' ? '예약 캠페인 등록' : '캠페인 초안 저장'}

    - 실제 발송(게이트웨이 연동)은 후속 단계에서 제공됩니다 — 현재는 캠페인 등록·상태 관리만 수행합니다. + 발송 버튼은 기본 테스트 모드로 동작합니다(실 수신자 미발송·수신동의자만 집계). 운영 실발송은 서버 정책에 따라 개방됩니다.

    diff --git a/src/frontend/src/screens/marketing/marketingApi.ts b/src/frontend/src/screens/marketing/marketingApi.ts index f46e17e..cec99ab 100644 --- a/src/frontend/src/screens/marketing/marketingApi.ts +++ b/src/frontend/src/screens/marketing/marketingApi.ts @@ -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(`/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( `/api/events/${encodeURIComponent(eventId)}/campaigns/${encodeURIComponent(id)}/send`, - {}, + { live: live ?? false }, ), sponsorship: (eventId: string) => api.get(`/api/events/${encodeURIComponent(eventId)}/sponsorship`),