diff --git a/src/main/java/egovframework/com/cmm/privacy/batch/PrivacyAdminAlertService.java b/src/main/java/egovframework/com/cmm/privacy/batch/PrivacyAdminAlertService.java new file mode 100644 index 0000000..0992ad6 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/batch/PrivacyAdminAlertService.java @@ -0,0 +1,57 @@ +/** + * Class Name : PrivacyAdminAlertService.java + * Description : 개인정보보호 관리자 알림 — 개인정보보호 기능개선 사업(제안서 slide15 "관리자 알림") + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.08.27 개인정보보호 최초 생성 + * + * @since 2026. 08. 27 + * @version 1.0 + */ +package egovframework.com.cmm.privacy.batch; + +import egovframework.com.cmm.privacy.batch.HashChainVerifyResultVO; + +/** + * 개인정보 접속기록 관련 이상 상황을 관리자에게 통보한다. + * + *

제안요약서 slide15 "로그 조회 및 감사 기능"의 구성요소 중 관리자 알림이다. + * 종전에는 무결성 위반을 에러 로그로만 남겨서, 로그를 들여다보지 않으면 아무도 모르는 + * 상태였다 — 탐지해 놓고 알리지 않으면 탐지의 의미가 없다.

+ * + *

수신자는 {@code ACCOUNT_MNG_POLICY.ADMIN_EMAIL}(계정 관리 정책 화면에서 설정)이며, + * 발송은 휴면 사전알림({@code DormantAlertService})이 쓰는 표준 메일 모듈을 그대로 재사용한다. + * 알림 채널을 새로 만들지 않는 이유는, 운영에서 메일 발송 경로가 하나여야 장애를 한 곳에서 + * 확인할 수 있기 때문이다.

+ * + *

메일에 개인정보 본문을 담지 않는다. 알림에는 건수·취급자 계정ID·기간처럼 조치에 + * 필요한 최소 정보만 넣는다. 상세는 관리자가 접속기록 화면에서 확인한다 — 메일함이 + * 개인정보 저장소가 되면 안 된다.

+ * + *

배치를 막지 않는다. 모든 메서드는 실패해도 예외를 올리지 않는다(발송 실패로 + * 파기·검증 배치가 중단되면 본말이 전도된다). 발송 여부와 무관하게 로그는 항상 남긴다.

+ */ +public interface PrivacyAdminAlertService { + + /** + * 접속기록 해시체인 무결성 위반 통보. + * + * @param result 검증 결과(위반이 아니면 아무것도 보내지 않는다) + * @return 발송 건수(0 = 미발송) + */ + int alertIntegrityViolation(HashChainVerifyResultVO result); + + /** + * 이상행위 탐지 통보(정기점검 배치가 월 1회 호출). + * + * @param periodFrom 집계 시작일시 + * @param periodTo 집계 종료일시 + * @param summary 탐지 요약 문자열({@code AbnormalAccessDetectionService.summarize}) + * @param detected 탐지 합계(0이면 보내지 않는다 — 조용한 달에는 메일도 조용해야 한다) + * @return 발송 건수(0 = 미발송) + */ + int alertAbnormalAccess(String periodFrom, String periodTo, String summary, int detected); +} diff --git a/src/main/java/egovframework/com/cmm/privacy/batch/PrivacyAdminAlertServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/batch/PrivacyAdminAlertServiceImpl.java new file mode 100644 index 0000000..af42808 --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/batch/PrivacyAdminAlertServiceImpl.java @@ -0,0 +1,132 @@ +/** + * Class Name : PrivacyAdminAlertServiceImpl.java + * Description : 개인정보보호 관리자 알림 구현 — 개인정보보호 기능개선 사업(제안서 slide15) + * + * Modification Information + * + * 수정일 수정자 수정내용 + * ------- -------- --------------------------- + * 2026.08.27 개인정보보호 최초 생성 + * + * @since 2026. 08. 27 + * @version 1.0 + */ +package egovframework.com.cmm.privacy.batch; + +import javax.annotation.Resource; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import egovframework.com.cmm.privacy.account.AccountMngDAO; +import egovframework.com.cmm.privacy.account.AccountMngPolicyVO; +import egovframework.com.cop.ems.service.EgovSndngMailRegistService; +import egovframework.com.cop.ems.service.SndngMailVO; + +/** + * {@link PrivacyAdminAlertService} 구현. + * + *

발송 경로는 휴면 사전알림({@code DormantAlertServiceImpl.sendMail})과 동일하다 — + * {@code SndngMailVO} 를 만들어 표준 메일 모듈에 적재하면 발송 배치가 실제 전송을 맡는다.

+ */ +@Service("privacyAdminAlertService") +public class PrivacyAdminAlertServiceImpl implements PrivacyAdminAlertService { + + private static final Logger LOG = LoggerFactory.getLogger(PrivacyAdminAlertServiceImpl.class); + + /** 메일 제목 접두 — 관리자 메일함에서 걸러 보기 쉽게 고정한다. */ + private static final String SUBJECT_PREFIX = "[문화품앗이/개인정보] "; + + /** 발신자 계정 — 기존 알림과 동일. */ + private static final String SENDER = "webmaster"; + + @Resource(name = "accountMngDAO") + private AccountMngDAO accountMngDAO; + + @Resource(name = "sndngMailRegistService") + private EgovSndngMailRegistService sndngMailRegistService; + + @Override + public int alertIntegrityViolation(HashChainVerifyResultVO result) { + if (result == null || result.isOk()) { + return 0; + } + StringBuilder body = new StringBuilder(); + body.append("개인정보 접속기록의 해시체인 무결성 검증에서 불일치가 발견되었습니다.\n\n"); + body.append("- 검증 일시 : ").append(nvl(result.getVerifiedAt())).append('\n'); + body.append("- 검증 대상 : ").append(result.getTotalCount()).append("건\n"); + body.append("- 최초 불일치 : 기록번호 ").append(result.getMismatchLogId()).append('\n'); + body.append('\n'); + body.append("접속기록은 각 행이 이전 행의 해시를 물고 있어, 중간의 어떤 행이 바뀌면\n"); + body.append("그 뒤가 전부 어긋납니다. 위 기록번호부터가 검증에 실패한 지점입니다.\n\n"); + body.append("확인 경로: 시스템관리 > 권한관리 > 접속기록 조회 및 감사\n"); + body.append("※ 이 메일에는 개인정보 본문이 포함되어 있지 않습니다. 상세는 화면에서 확인하십시오.\n"); + + return send("접속기록 무결성 위반 탐지", body.toString(), "무결성 위반"); + } + + @Override + public int alertAbnormalAccess(String periodFrom, String periodTo, String summary, int detected) { + if (detected <= 0) { + // 조용한 달에는 메일도 조용해야 한다. 매달 "0건" 메일이 오면 곧 아무도 열지 않는다. + return 0; + } + StringBuilder body = new StringBuilder(); + body.append("개인정보 접속기록 정기점검에서 이상행위가 탐지되었습니다.\n\n"); + body.append("- 집계 기간 : ").append(nvl(periodFrom)).append(" ~ ").append(nvl(periodTo)).append('\n'); + body.append("- 탐지 합계 : ").append(detected).append("건\n"); + body.append("- 탐지 요약 : ").append(nvl(summary)).append('\n'); + body.append('\n'); + body.append("탐지되었다는 것이 곧 위반은 아닙니다. 업무상 정당한 대량조회나 야간작업이\n"); + body.append("있을 수 있으므로, 아래 화면에서 원본 기록을 확인한 뒤 판단하십시오.\n\n"); + body.append("확인 경로: 시스템관리 > 권한관리 > 접속기록 조회 및 감사\n"); + body.append(" (목록 오른쪽 '감사' 칸의 이상/점검 표시로 해당 기록을 찾을 수 있습니다)\n"); + body.append("※ 이 메일에는 정보주체의 개인정보가 포함되어 있지 않습니다.\n"); + + return send("접속기록 이상행위 탐지", body.toString(), "이상행위 탐지"); + } + + /** + * 관리자 메일 적재. 수신자 미설정이면 보내지 않되 로그는 남긴다 — + * "알림이 안 왔다"와 "보낼 주소가 없다"를 구분할 수 있어야 한다. + */ + private int send(String subject, String body, String kind) { + String adminEmail = null; + try { + AccountMngPolicyVO policy = accountMngDAO.selectPolicy(); + if (policy != null) { + adminEmail = policy.getAdminEmail(); + } + } catch (Exception e) { + LOG.warn("[개인정보/알림] 정책 조회 실패 — {} 알림을 보내지 못했습니다: {}", kind, e.toString()); + return 0; + } + + if (adminEmail == null || adminEmail.trim().length() == 0) { + LOG.warn("[개인정보/알림] 관리자 수신 이메일 미설정(ACCOUNT_MNG_POLICY.ADMIN_EMAIL) —" + + " {} 알림을 보내지 못했습니다. 계정 관리 정책 화면에서 주소를 등록하십시오.", kind); + return 0; + } + + try { + SndngMailVO vo = new SndngMailVO(); + vo.setDsptchPerson(SENDER); + vo.setRecptnPerson(adminEmail.trim()); + vo.setSj(SUBJECT_PREFIX + subject); + vo.setEmailCn(body); + vo.setAtchFileId(""); + sndngMailRegistService.insertSndngMail(vo); + LOG.info("[개인정보/알림] {} 알림 발송 적재 완료 → {}", kind, adminEmail.trim()); + return 1; + } catch (Exception e) { + // 발송 실패로 파기·검증 배치가 멈추면 본말이 전도된다. + LOG.error("[개인정보/알림] {} 알림 발송 실패 — 배치는 계속합니다: {}", kind, e.toString()); + return 0; + } + } + + private String nvl(Object v) { + return v == null ? "-" : String.valueOf(v); + } +} diff --git a/src/main/java/egovframework/com/cmm/privacy/batch/PrivacyBatchServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/batch/PrivacyBatchServiceImpl.java index 734b0db..38132e9 100644 --- a/src/main/java/egovframework/com/cmm/privacy/batch/PrivacyBatchServiceImpl.java +++ b/src/main/java/egovframework/com/cmm/privacy/batch/PrivacyBatchServiceImpl.java @@ -105,6 +105,10 @@ public class PrivacyBatchServiceImpl implements PrivacyBatchService { @Resource(name = "accessLogDownloadHistoryDAO") private AccessLogDownloadHistoryDAO accessLogDownloadHistoryDAO; + /** 제안서 slide15 "관리자 알림" — 무결성 위반·이상행위 탐지 통보. */ + @Resource(name = "privacyAdminAlertService") + private PrivacyAdminAlertService privacyAdminAlertService; + /** 정기점검 리포트 취급자별 처리현황 요약 최대 표기 행 수. */ private static final int HANDLER_SUMMARY_TOP = 20; @@ -145,12 +149,36 @@ public class PrivacyBatchServiceImpl implements PrivacyBatchService { } /** - * 무결성 위반 즉시 관리자 통보. - * TODO: 실제 통보 채널(관리자 메일/SMS/알림함 #49) 연동. 현재는 에러 로그로 남긴다. + * 무결성 위반 즉시 관리자 통보(제안서 slide15 "관리자 알림"). + * + *

에러 로그는 그대로 남긴다 — 메일이 막혀 있어도 흔적은 서버에 있어야 한다. + * 실제 통보는 {@link PrivacyAdminAlertService} 가 관리자 메일로 보낸다.

*/ private void notifyAdmin(HashChainVerifyResultVO result) { - LOG.error("[개인정보/#40] 관리자 통보 필요 — 무결성 위반 LOG_ID=" + result.getMismatchLogId() - + " (검증일시 " + result.getVerifiedAt() + "). TODO: 메일/SMS/알림함 연동."); + LOG.error("[개인정보/#40] 관리자 통보 — 무결성 위반 LOG_ID=" + result.getMismatchLogId() + + " (검증일시 " + result.getVerifiedAt() + ")."); + privacyAdminAlertService.alertIntegrityViolation(result); + } + + /** + * 기간 내 이상행위 탐지 합계. 알림 발송 여부를 정하는 값이라 요약 문자열을 파싱하지 않고 + * 탐지 결과의 건수를 그대로 더한다(문자열 파싱은 문구가 바뀌면 조용히 어긋난다). + * 집계에 실패하면 0 을 돌려 알림을 보내지 않는다 — 근거 없이 알리지 않는다. + */ + private int countDetected(Map param) { + try { + List results = + abnormalAccessDetectionService.detect(param.get("fromDt").toString(), + param.get("toDt").toString()); + int sum = 0; + for (int i = 0; results != null && i < results.size(); i++) { + sum += results.get(i).getCount(); + } + return sum; + } catch (Exception e) { + LOG.error("[개인정보/알림] 이상행위 건수 집계 실패 - 알림 생략: " + e.getMessage()); + return 0; + } } @Override @@ -234,6 +262,18 @@ public class PrivacyBatchServiceImpl implements PrivacyBatchService { report.setDownloadCnt(downloadCnt); inspectionReportDAO.insertInspectionReport(report); + + // [제안서 slide15 "관리자 알림"] 이상행위가 탐지된 달에만 관리자에게 통보한다. + // 리포트를 적재한 뒤에 보내는 이유는, 메일을 받은 관리자가 화면에서 바로 근거를 + // 확인할 수 있어야 하기 때문이다(먼저 보내면 리포트가 아직 없을 수 있다). + try { + int detected = countDetected(param); + privacyAdminAlertService.alertAbnormalAccess( + report.getPeriodFrom(), report.getPeriodTo(), abnormalSummary, detected); + } catch (Exception e) { + LOG.error("[개인정보/알림] 이상행위 알림 처리 오류 - 리포트는 정상 생성됨: " + e.getMessage()); + } + LOG.info("[개인정보/#43] 정기점검 리포트 생성 — " + rptYm + " 총 " + totalCnt + "건(실패 " + failCnt + "), 무결성=" + report.getIntegrityResult() + ", 취급자 " + (handlers == null ? 0 : handlers.size()) + "종.");