param) throws Exception {
+ return mberCryptoAdminDAO.selectAccessHistoryListCount(param);
+ }
+
+ /**
+ * 이력 1행 적재. 재인증은 컨트롤러가 이미 통과시킨 뒤에만 이 서비스에 닿으므로 'Y'로 기록한다.
+ * (재인증 없이 도달할 수 있는 경로가 생기면 이 값도 인자로 받도록 바꿀 것)
+ */
+ private void writeHistory(String actionTy, String handlerId, String handlerIp,
+ String trgetMberId, String reason, String changedItems) {
+ MberCryptoAccessHistoryVO h = new MberCryptoAccessHistoryVO();
+ h.setActionTy(actionTy);
+ h.setHandlrId(handlerId);
+ h.setHandlrIp(handlerIp);
+ h.setTrgetMberId(trgetMberId);
+ h.setAccessReason(reason);
+ h.setChangedItems(changedItems);
+ h.setReauthYn("Y");
+ mberCryptoAdminDAO.insertAccessHistory(h);
+ }
+
+ /**
+ * NOT NULL 컬럼에 대응하는 필드의 null 을 빈 문자열로 바꾼다.
+ *
+ * {@code COMTNGNRLMBER} 는 ZIP·ADRES·MBTLNUM 등이 NOT NULL 이라, 화면에서 비워 둔 값이
+ * 그대로 null 로 내려가면 INSERT/UPDATE 가 제약 위반으로 실패한다. SQL 쪽에서 {@code IFNULL}
+ * 로 감싸는 방법도 있으나, 그러면 "이 컬럼은 비어도 되는가"의 답이 SQL 에 흩어진다.
+ * 값의 형태를 맞추는 일은 서비스가 한 곳에서 한다.
+ */
+ private void normalizeBlanks(MberCryptoVO vo) {
+ vo.setZip(blankIfNull(vo.getZip()));
+ vo.setAdres(blankIfNull(vo.getAdres()));
+ vo.setDetailAdres(blankIfNull(vo.getDetailAdres()));
+ vo.setSexdstnCode(blankIfNull(vo.getSexdstnCode()));
+ vo.setMoblphonNo(blankIfNull(vo.getMoblphonNo()));
+ // MBER_EMAIL_ADRES 는 NULL 허용 컬럼이라 그대로 둔다(빈 문자열과 미입력을 구분).
+ }
+
+ private String blankIfNull(String v) {
+ return v == null ? "" : v.trim();
+ }
+
+ /** 사유 필수 검증. 통과하면 앞뒤 공백을 다듬은 값을 돌려준다. */
+ private String requireReason(String reason) {
+ if (!isNotBlank(reason)) {
+ throw new IllegalArgumentException("개인정보 처리 사유는 필수 입력입니다. (접근 이력에 기록됩니다)");
+ }
+ String r = reason.trim();
+ return r.length() > 500 ? r.substring(0, 500) : r;
+ }
+
+ /** 변경된 항목명만 콤마로 잇는다(값은 담지 않는다). */
+ private String diffItems(MberCryptoVO before, MberCryptoVO after) {
+ StringBuilder sb = new StringBuilder();
+ appendIfChanged(sb, "이름", before.getMberNm(), after.getMberNm());
+ appendIfChanged(sb, "이메일", before.getMberEmailAdres(), after.getMberEmailAdres());
+ appendIfChanged(sb, "휴대폰", before.getMoblphonNo(), after.getMoblphonNo());
+ appendIfChanged(sb, "우편번호", before.getZip(), after.getZip());
+ appendIfChanged(sb, "주소", before.getAdres(), after.getAdres());
+ appendIfChanged(sb, "상세주소", before.getDetailAdres(), after.getDetailAdres());
+ appendIfChanged(sb, "성별", before.getSexdstnCode(), after.getSexdstnCode());
+ appendIfChanged(sb, "회원상태", before.getMberSttus(), after.getMberSttus());
+ return sb.toString();
+ }
+
+ private void appendIfChanged(StringBuilder sb, String label, String before, String after) {
+ String b = before == null ? "" : before;
+ String a = after == null ? "" : after;
+ if (b.equals(a)) {
+ return;
+ }
+ if (sb.length() > 0) {
+ sb.append(',');
+ }
+ sb.append(label);
+ }
+
+ private boolean isNotBlank(String v) {
+ return v != null && v.trim().length() > 0;
+ }
+}
diff --git a/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoReauth.java b/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoReauth.java
new file mode 100644
index 0000000..377e369
--- /dev/null
+++ b/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoReauth.java
@@ -0,0 +1,90 @@
+/**
+ * Class Name : MberCryptoReauth.java
+ * Description : 암호화 회원정보 화면 재인증 세션 표식 — 개인정보보호 기능개선 사업(추가제안 Action4)
+ *
+ * Modification Information
+ *
+ * 수정일 수정자 수정내용
+ * ------- -------- ---------------------------
+ * 2026.08.27 개인정보보호 최초 생성
+ *
+ * @since 2026. 08. 27
+ * @version 1.0
+ */
+package egovframework.com.cmm.privacy.crypto;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpSession;
+
+/**
+ * 관리자 회원정보 화면의 재인증 상태를 세션에 표시하고 유효시간을 판정한다.
+ *
+ * 제안서 슬라이드16의 "조회·수정·삭제 시 사유 입력 및 재인증 연계" 요건이다.
+ * 로그인 자체가 아니라 이 화면에 들어올 때 한 번 더 본인을 확인한다 — 자리를 비운 사이
+ * 열려 있던 세션으로 평문 개인정보를 훑는 경로를 끊는 것이 목적이다.
+ *
+ * 왜 로그인 2차 인증(SECOND_FACTOR_DONE)을 재사용하지 않는가: 2차 인증은 로그인 시점에
+ * 한 번 통과하면 세션 내내 유지된다(30분 자동 로그아웃까지). 그 표식을 그대로 쓰면 "다시 확인"이
+ * 되지 못한다. 또 OTP 를 등록하지 않은 취급자는 2차 인증을 통과한 적이 없어 이 화면에 영영
+ * 들어오지 못한다. 그래서 본인 비밀번호 재확인 이라는 독립 표식을 둔다.
+ *
+ * 유효시간을 짧게 두는 이유: 한 번 통과하면 세션이 끝날 때까지 열어 두는 방식은
+ * 사실상 재인증이 없는 것과 같다. {@link #TTL_MILLIS}(10분)마다 다시 묻는다. 작업 중 만료되면
+ * 재인증 화면을 거쳐 원래 위치로 돌아온다.
+ *
+ * 연장하지 않는다. 통과 시각을 갱신하지 않고 최초 통과 시각을 그대로 두므로, 화면을
+ * 계속 쓰더라도 10분마다 반드시 다시 확인한다(슬라이딩 만료가 아니다).
+ */
+public final class MberCryptoReauth {
+
+ /** 재인증 통과 시각(밀리초)을 담는 세션 속성명. */
+ public static final String SESSION_KEY = "MBER_CRYPTO_REAUTH_AT";
+
+ /** 재인증 유효시간 — 10분. */
+ public static final long TTL_MILLIS = 10L * 60L * 1000L;
+
+ private MberCryptoReauth() {
+ }
+
+ /** 재인증이 유효한 상태인지 판정한다. 세션이 없거나 만료면 false. */
+ public static boolean isVerified(HttpServletRequest request) {
+ HttpSession session = request.getSession(false);
+ if (session == null) {
+ return false;
+ }
+ Object v = session.getAttribute(SESSION_KEY);
+ if (!(v instanceof Long)) {
+ return false;
+ }
+ long at = ((Long) v).longValue();
+ return (System.currentTimeMillis() - at) < TTL_MILLIS;
+ }
+
+ /** 재인증 통과를 표시한다(현재 시각 기록). */
+ public static void markVerified(HttpServletRequest request) {
+ request.getSession(true).setAttribute(SESSION_KEY, Long.valueOf(System.currentTimeMillis()));
+ }
+
+ /** 재인증 표식을 지운다(화면을 벗어나거나 실패했을 때). */
+ public static void clear(HttpServletRequest request) {
+ HttpSession session = request.getSession(false);
+ if (session != null) {
+ session.removeAttribute(SESSION_KEY);
+ }
+ }
+
+ /** 남은 유효시간(초). 만료·미인증이면 0. 화면에 남은 시간을 알려 주기 위한 값이다. */
+ public static long remainSeconds(HttpServletRequest request) {
+ HttpSession session = request.getSession(false);
+ if (session == null) {
+ return 0L;
+ }
+ Object v = session.getAttribute(SESSION_KEY);
+ if (!(v instanceof Long)) {
+ return 0L;
+ }
+ long elapsed = System.currentTimeMillis() - ((Long) v).longValue();
+ long remain = TTL_MILLIS - elapsed;
+ return remain <= 0 ? 0L : remain / 1000L;
+ }
+}
diff --git a/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoSearchVO.java b/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoSearchVO.java
new file mode 100644
index 0000000..a376736
--- /dev/null
+++ b/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoSearchVO.java
@@ -0,0 +1,117 @@
+/**
+ * Class Name : MberCryptoSearchVO.java
+ * Description : 암호화 회원정보 관리자 화면 검색조건 VO — 개인정보보호 기능개선 사업(추가제안 Action4)
+ *
+ * Modification Information
+ *
+ * 수정일 수정자 수정내용
+ * ------- -------- ---------------------------
+ * 2026.08.27 개인정보보호 최초 생성
+ *
+ * @since 2026. 08. 27
+ * @version 1.0
+ */
+package egovframework.com.cmm.privacy.crypto;
+
+import java.io.Serializable;
+
+/**
+ * 관리자 회원정보 검색조건 + 페이징.
+ *
+ * 이름은 완전일치만 된다. 이름은 랜덤 IV AES-GCM 으로 저장돼 같은 이름도 매번 다른
+ * 암호문이라 {@code LIKE '%홍%'} 이 원리적으로 불가능하다. 대신 검색 전용 HMAC 해시 컬럼
+ * ({@code MBER_NM_HASH})에 인덱스가 걸려 있어 정확히 같은 이름 은 빠르게 찾는다.
+ * 서비스가 입력 이름을 {@link MemberDataHasher#hashName(String)} 로 바꿔 {@link #setMberNmHash}
+ * 에 실어 주면 SQL 은 해시만 비교한다 — 평문 이름은 SQL 에 등장하지 않는다.
+ *
+ * 이메일·휴대폰·회원ID 는 아직 평문 컬럼이라 전방일치 검색이 가능하다
+ * (암호화 대상 확대는 후속 단계 — 확대되면 이 필드들도 해시 검색으로 옮겨야 한다).
+ */
+public class MberCryptoSearchVO implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ /** 회원ID(전방일치). */
+ private String mberId;
+ /** 이름(완전일치 — 해시 검색). 화면 입력값이며 SQL 에는 해시만 나간다. */
+ private String mberNm;
+ /** 이메일(전방일치). */
+ private String mberEmailAdres;
+ /** 휴대폰(전방일치). */
+ private String moblphonNo;
+ /** 회원상태 P/D. 빈값이면 전체. */
+ private String mberSttus;
+
+ /** 서비스가 채우는 이름 해시(파생값). 화면에서 직접 입력받지 않는다. */
+ private String mberNmHash;
+
+ private int pageIndex = 1;
+ private int recordCountPerPage = 10;
+ /** 조회 시작 offset(0-base) = (pageIndex-1) * recordCountPerPage */
+ private int firstIndex = 0;
+ private int totalRecordCount = 0;
+
+ private static final String LIKE_ESCAPE = "!";
+
+ public String getMberId() { return mberId; }
+ public void setMberId(String mberId) { this.mberId = mberId; }
+
+ public String getMberNm() { return mberNm; }
+ public void setMberNm(String mberNm) { this.mberNm = mberNm; }
+
+ public String getMberEmailAdres() { return mberEmailAdres; }
+ public void setMberEmailAdres(String mberEmailAdres) { this.mberEmailAdres = mberEmailAdres; }
+
+ public String getMoblphonNo() { return moblphonNo; }
+ public void setMoblphonNo(String moblphonNo) { this.moblphonNo = moblphonNo; }
+
+ public String getMberSttus() { return mberSttus; }
+ public void setMberSttus(String mberSttus) { this.mberSttus = mberSttus; }
+
+ public String getMberNmHash() { return mberNmHash; }
+ public void setMberNmHash(String mberNmHash) { this.mberNmHash = mberNmHash; }
+
+ public int getPageIndex() { return pageIndex; }
+ public void setPageIndex(int pageIndex) { this.pageIndex = pageIndex; }
+
+ public int getRecordCountPerPage() { return recordCountPerPage; }
+ public void setRecordCountPerPage(int recordCountPerPage) { this.recordCountPerPage = recordCountPerPage; }
+
+ public int getFirstIndex() { return firstIndex; }
+ public void setFirstIndex(int firstIndex) { this.firstIndex = firstIndex; }
+
+ public int getTotalRecordCount() { return totalRecordCount; }
+ public void setTotalRecordCount(int totalRecordCount) { this.totalRecordCount = totalRecordCount; }
+
+ /** 회원ID 전방일치 바인딩값. */
+ public String getMberIdLike() { return toPrefixLike(mberId); }
+
+ /** 이메일 전방일치 바인딩값. */
+ public String getMberEmailAdresLike() { return toPrefixLike(mberEmailAdres); }
+
+ /** 휴대폰 전방일치 바인딩값. */
+ public String getMoblphonNoLike() { return toPrefixLike(moblphonNo); }
+
+ /**
+ * 전방일치 LIKE 값으로 변환한다. 사용자가 넣은 {@code %} {@code _} 는 와일드카드가 아니라
+ * 글자 그대로 찾게 이스케이프한다(SQL 은 {@code ESCAPE '!'} 로 받는다).
+ */
+ private String toPrefixLike(String v) {
+ if (v == null) {
+ return null;
+ }
+ String t = v.trim();
+ if (t.length() == 0) {
+ return null;
+ }
+ StringBuilder sb = new StringBuilder(t.length() + 8);
+ for (int i = 0; i < t.length(); i++) {
+ char c = t.charAt(i);
+ if (c == '%' || c == '_' || c == '!') {
+ sb.append(LIKE_ESCAPE);
+ }
+ sb.append(c);
+ }
+ return sb.append('%').toString();
+ }
+}
diff --git a/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoVO.java b/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoVO.java
new file mode 100644
index 0000000..3f7be27
--- /dev/null
+++ b/src/main/java/egovframework/com/cmm/privacy/crypto/MberCryptoVO.java
@@ -0,0 +1,110 @@
+/**
+ * Class Name : MberCryptoVO.java
+ * Description : 암호화 회원정보 관리자 화면 VO — 개인정보보호 기능개선 사업(추가제안 Action4)
+ *
+ * Modification Information
+ *
+ * 수정일 수정자 수정내용
+ * ------- -------- ---------------------------
+ * 2026.08.27 개인정보보호 최초 생성 — 관리자 전용 복호화 CRUD 화면
+ *
+ * @since 2026. 08. 27
+ * @version 1.0
+ */
+package egovframework.com.cmm.privacy.crypto;
+
+import java.io.Serializable;
+
+/**
+ * 관리자 전용 회원정보 화면이 다루는 {@code COMTNGNRLMBER} 한 행.
+ *
+ * {@code mberNm} 에 무엇이 들어있는가는 단계에 따라 다르다. DAO 가 조회하는 컬럼은
+ * {@code MBER_NM_ENC}(암호문)이고, DAO 반환 직전 {@link MemberNmDecryptSupport} 가 같은 자리에
+ * 평문을 되돌려 쓴다 — 서비스·화면은 항상 평문으로 본다. 저장(등록·수정) 방향은 서비스가
+ * 평문을 받아 {@link MemberDataCipher#encrypt(String)}·{@link MemberDataHasher#hashName(String)}
+ * 로 {@code mberNmEnc}/{@code mberNmHash} 를 채운 뒤 DAO 에 넘긴다.
+ *
+ * 평문 컬럼 {@code MBER_NM} 은 이 화면이 건드리지 않는다. 암호화 이관 당시 롤백 창구로
+ * 남겨둔 컬럼이라, 관리자 화면이 여기에 새 평문을 쓰기 시작하면 "이관이 끝나면 평문 컬럼을 지운다"는
+ * 계획이 조용히 무너진다. 신규 등록 시에도 평문 자리에는 마스킹 표식만 넣는다
+ * ({@code MBER_NM} 이 NOT NULL 이라 빈 값을 넣을 수 없기 때문이다).
+ */
+public class MberCryptoVO implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ /** 회원ID(PK). */
+ private String mberId;
+ /** 내부 식별자 ESNTL_ID. */
+ private String uniqId;
+
+ /**
+ * 이름 — 조회 결과에서는 복호화된 평문 , 등록·수정 입력에서도 평문.
+ * (DB 컬럼 MBER_NM_ENC 와 이 필드를 잇는 변환은 서비스·DAO 가 담당한다)
+ */
+ private String mberNm;
+ /** 이름 암호문(내부 전달용). 화면에 노출하지 않는다. */
+ private String mberNmEnc;
+ /** 이름 검색용 HMAC 해시(내부 전달용). */
+ private String mberNmHash;
+ /** 암호화 키 버전. 미암호화 행은 null. */
+ private Integer cryptoKeyVer;
+
+ private String mberEmailAdres;
+ private String moblphonNo;
+ private String zip;
+ private String adres;
+ private String detailAdres;
+ private String sexdstnCode;
+ /** 회원상태 — P:정상 D:탈퇴(기존 코드계 그대로). */
+ private String mberSttus;
+ private String sbscrbDe;
+
+ /** 신규 등록 시 초기 비밀번호(평문 입력). 저장 시 솔트 해시로 변환되며 조회에는 실리지 않는다. */
+ private String password;
+
+ public String getMberId() { return mberId; }
+ public void setMberId(String mberId) { this.mberId = mberId; }
+
+ public String getUniqId() { return uniqId; }
+ public void setUniqId(String uniqId) { this.uniqId = uniqId; }
+
+ public String getMberNm() { return mberNm; }
+ public void setMberNm(String mberNm) { this.mberNm = mberNm; }
+
+ public String getMberNmEnc() { return mberNmEnc; }
+ public void setMberNmEnc(String mberNmEnc) { this.mberNmEnc = mberNmEnc; }
+
+ public String getMberNmHash() { return mberNmHash; }
+ public void setMberNmHash(String mberNmHash) { this.mberNmHash = mberNmHash; }
+
+ public Integer getCryptoKeyVer() { return cryptoKeyVer; }
+ public void setCryptoKeyVer(Integer cryptoKeyVer) { this.cryptoKeyVer = cryptoKeyVer; }
+
+ public String getMberEmailAdres() { return mberEmailAdres; }
+ public void setMberEmailAdres(String mberEmailAdres) { this.mberEmailAdres = mberEmailAdres; }
+
+ public String getMoblphonNo() { return moblphonNo; }
+ public void setMoblphonNo(String moblphonNo) { this.moblphonNo = moblphonNo; }
+
+ public String getZip() { return zip; }
+ public void setZip(String zip) { this.zip = zip; }
+
+ public String getAdres() { return adres; }
+ public void setAdres(String adres) { this.adres = adres; }
+
+ public String getDetailAdres() { return detailAdres; }
+ public void setDetailAdres(String detailAdres) { this.detailAdres = detailAdres; }
+
+ public String getSexdstnCode() { return sexdstnCode; }
+ public void setSexdstnCode(String sexdstnCode) { this.sexdstnCode = sexdstnCode; }
+
+ public String getMberSttus() { return mberSttus; }
+ public void setMberSttus(String mberSttus) { this.mberSttus = mberSttus; }
+
+ public String getSbscrbDe() { return sbscrbDe; }
+ public void setSbscrbDe(String sbscrbDe) { this.sbscrbDe = sbscrbDe; }
+
+ public String getPassword() { return password; }
+ public void setPassword(String password) { this.password = password; }
+}
diff --git a/src/main/resources/egovframework/sqlmap/config/mysql/sql-map-config-mysql-0-site.xml b/src/main/resources/egovframework/sqlmap/config/mysql/sql-map-config-mysql-0-site.xml
index 7ed2e38..ccbccf3 100644
--- a/src/main/resources/egovframework/sqlmap/config/mysql/sql-map-config-mysql-0-site.xml
+++ b/src/main/resources/egovframework/sqlmap/config/mysql/sql-map-config-mysql-0-site.xml
@@ -152,4 +152,7 @@
+
+
+
diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLog_SQL_Mysql.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLog_SQL_Mysql.xml
index f8d78c1..77bcec1 100644
--- a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLog_SQL_Mysql.xml
+++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLog_SQL_Mysql.xml
@@ -409,4 +409,45 @@
]]>
+
+
+ SELECT PURGE_ID AS purgeId,
+ PARTITION_NAME AS partitionName,
+ BOUNDARY_DT AS boundaryDt,
+ PURGE_TYPE AS purgeType,
+ PURGE_DT AS purgeDt,
+ NOTE AS note
+ FROM PDAL_PURGE_HISTORY
+
+
+ PURGE_TYPE = #purgeType#
+ PURGE_DT >= #fromDtParam#
+ PURGE_DT <= #toDtParam#
+
+ ORDER BY PURGE_ID DESC
+ LIMIT #firstIndex#, #recordCountPerPage#
+
+
+
+ SELECT COUNT(1)
+ FROM PDAL_PURGE_HISTORY
+
+ PURGE_TYPE = #purgeType#
+ PURGE_DT >= #fromDtParam#
+ PURGE_DT <= #toDtParam#
+
+
+
diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLog_SQL_Oracle.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLog_SQL_Oracle.xml
index fffead4..b7d7d7d 100644
--- a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLog_SQL_Oracle.xml
+++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLog_SQL_Oracle.xml
@@ -332,4 +332,47 @@
]]>
+
+
+ SELECT purgeId, partitionName, boundaryDt, purgeType, purgeDt, note
+ FROM (
+ SELECT ROWNUM AS rnum, a.*
+ FROM (
+ SELECT PURGE_ID AS purgeId,
+ PARTITION_NAME AS partitionName,
+ BOUNDARY_DT AS boundaryDt,
+ PURGE_TYPE AS purgeType,
+ TO_CHAR(PURGE_DT, 'YYYY-MM-DD HH24:MI:SS') AS purgeDt,
+ NOTE AS note
+ FROM PDAL_PURGE_HISTORY
+
+ PURGE_TYPE = #purgeType#
+ PURGE_DT >= TO_DATE(#fromDtParam#, 'YYYY-MM-DD HH24:MI:SS')
+ PURGE_DT <= TO_DATE(#toDtParam#, 'YYYY-MM-DD HH24:MI:SS')
+
+ ORDER BY PURGE_ID DESC
+ ) a
+ WHERE ROWNUM <= #firstIndex# + #recordCountPerPage#
+ )
+ WHERE rnum > #firstIndex#
+
+
+
+ SELECT COUNT(1)
+ FROM PDAL_PURGE_HISTORY
+
+ PURGE_TYPE = #purgeType#
+ PURGE_DT >= TO_DATE(#fromDtParam#, 'YYYY-MM-DD HH24:MI:SS')
+ PURGE_DT <= TO_DATE(#toDtParam#, 'YYYY-MM-DD HH24:MI:SS')
+
+
+
diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyMberCryptoAdmin_SQL_Mysql.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyMberCryptoAdmin_SQL_Mysql.xml
new file mode 100644
index 0000000..6264a32
--- /dev/null
+++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyMberCryptoAdmin_SQL_Mysql.xml
@@ -0,0 +1,256 @@
+
+
+
+
+
+
+
+
+ SELECT MBER_ID AS mberId,
+ ESNTL_ID AS uniqId,
+ MBER_NM_ENC AS mberNm,
+ CRYPTO_KEY_VER AS cryptoKeyVer,
+ MBER_EMAIL_ADRES AS mberEmailAdres,
+ MBTLNUM AS moblphonNo,
+ ZIP AS zip,
+ ADRES AS adres,
+ DETAIL_ADRES AS detailAdres,
+ SEXDSTN_CODE AS sexdstnCode,
+ MBER_STTUS AS mberSttus,
+ SBSCRB_DE AS sbscrbDe
+ FROM COMTNGNRLMBER
+
+ MBER_ID LIKE #mberIdLike# ESCAPE '!'
+ MBER_NM_HASH = #mberNmHash#
+ MBER_EMAIL_ADRES LIKE #mberEmailAdresLike# ESCAPE '!'
+ MBTLNUM LIKE #moblphonNoLike# ESCAPE '!'
+ MBER_STTUS = #mberSttus#
+
+ ORDER BY SBSCRB_DE DESC, MBER_ID DESC
+ LIMIT #firstIndex#, #recordCountPerPage#
+
+
+
+ SELECT COUNT(1)
+ FROM COMTNGNRLMBER
+
+ MBER_ID LIKE #mberIdLike# ESCAPE '!'
+ MBER_NM_HASH = #mberNmHash#
+ MBER_EMAIL_ADRES LIKE #mberEmailAdresLike# ESCAPE '!'
+ MBTLNUM LIKE #moblphonNoLike# ESCAPE '!'
+ MBER_STTUS = #mberSttus#
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SELECT HIST_ID AS histId,
+ HANDLR_ID AS handlrId,
+ ACTION_TY AS actionTy,
+ TRGET_MBER_ID AS trgetMberId,
+ ACCESS_REASON AS accessReason,
+ CHANGED_ITEMS AS changedItems,
+ HANDLR_IP AS handlrIp,
+ REAUTH_YN AS reauthYn,
+ ACCESS_DT AS accessDt
+ FROM MBER_CRYPTO_ACCESS_HISTORY
+
+ TRGET_MBER_ID = #trgetMberId#
+ HANDLR_ID = #handlrId#
+ ACTION_TY = #actionTy#
+
+ ORDER BY HIST_ID DESC
+ LIMIT #firstIndex#, #recordCountPerPage#
+
+
+
+ SELECT COUNT(1)
+ FROM MBER_CRYPTO_ACCESS_HISTORY
+
+ TRGET_MBER_ID = #trgetMberId#
+ HANDLR_ID = #handlrId#
+ ACTION_TY = #actionTy#
+
+
+
+
diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyMberCryptoAdmin_SQL_Oracle.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyMberCryptoAdmin_SQL_Oracle.xml
new file mode 100644
index 0000000..d076a72
--- /dev/null
+++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyMberCryptoAdmin_SQL_Oracle.xml
@@ -0,0 +1,232 @@
+
+
+
+
+
+
+
+ SELECT mberId, uniqId, mberNm, cryptoKeyVer, mberEmailAdres, moblphonNo,
+ zip, adres, detailAdres, sexdstnCode, mberSttus, sbscrbDe
+ FROM (
+ SELECT ROWNUM AS rnum, a.*
+ FROM (
+ SELECT MBER_ID AS mberId,
+ ESNTL_ID AS uniqId,
+ MBER_NM_ENC AS mberNm,
+ CRYPTO_KEY_VER AS cryptoKeyVer,
+ MBER_EMAIL_ADRES AS mberEmailAdres,
+ MBTLNUM AS moblphonNo,
+ ZIP AS zip,
+ ADRES AS adres,
+ DETAIL_ADRES AS detailAdres,
+ SEXDSTN_CODE AS sexdstnCode,
+ MBER_STTUS AS mberSttus,
+ TO_CHAR(SBSCRB_DE, 'YYYY-MM-DD HH24:MI:SS') AS sbscrbDe
+ FROM COMTNGNRLMBER
+
+ MBER_ID LIKE #mberIdLike# ESCAPE '!'
+ MBER_NM_HASH = #mberNmHash#
+ MBER_EMAIL_ADRES LIKE #mberEmailAdresLike# ESCAPE '!'
+ MBTLNUM LIKE #moblphonNoLike# ESCAPE '!'
+ MBER_STTUS = #mberSttus#
+
+ ORDER BY SBSCRB_DE DESC, MBER_ID DESC
+ ) a
+ WHERE ROWNUM <= #firstIndex# + #recordCountPerPage#
+ )
+ WHERE rnum > #firstIndex#
+
+
+
+ SELECT COUNT(1)
+ FROM COMTNGNRLMBER
+
+ MBER_ID LIKE #mberIdLike# ESCAPE '!'
+ MBER_NM_HASH = #mberNmHash#
+ MBER_EMAIL_ADRES LIKE #mberEmailAdresLike# ESCAPE '!'
+ MBTLNUM LIKE #moblphonNoLike# ESCAPE '!'
+ MBER_STTUS = #mberSttus#
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SELECT histId, handlrId, actionTy, trgetMberId, accessReason,
+ changedItems, handlrIp, reauthYn, accessDt
+ FROM (
+ SELECT ROWNUM AS rnum, a.*
+ FROM (
+ SELECT HIST_ID AS histId,
+ HANDLR_ID AS handlrId,
+ ACTION_TY AS actionTy,
+ TRGET_MBER_ID AS trgetMberId,
+ ACCESS_REASON AS accessReason,
+ CHANGED_ITEMS AS changedItems,
+ HANDLR_IP AS handlrIp,
+ REAUTH_YN AS reauthYn,
+ TO_CHAR(ACCESS_DT, 'YYYY-MM-DD HH24:MI:SS') AS accessDt
+ FROM MBER_CRYPTO_ACCESS_HISTORY
+
+ TRGET_MBER_ID = #trgetMberId#
+ HANDLR_ID = #handlrId#
+ ACTION_TY = #actionTy#
+
+ ORDER BY HIST_ID DESC
+ ) a
+ WHERE ROWNUM <= #firstIndex# + #recordCountPerPage#
+ )
+ WHERE rnum > #firstIndex#
+
+
+
+ SELECT COUNT(1)
+ FROM MBER_CRYPTO_ACCESS_HISTORY
+
+ TRGET_MBER_ID = #trgetMberId#
+ HANDLR_ID = #handlrId#
+ ACTION_TY = #actionTy#
+
+
+
+
diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp
index 5675110..3705a72 100644
--- a/src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp
+++ b/src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp
@@ -94,10 +94,10 @@
- class="on" >${menuUpperVo2.menuNm}
+ class="on">${menuUpperVo2.menuNm}
- >
+
diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/abnormal/list.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/abnormal/list.jsp
new file mode 100644
index 0000000..c724db4
--- /dev/null
+++ b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/abnormal/list.jsp
@@ -0,0 +1,221 @@
+<%@ page language="java" contentType="text/html; charset=utf-8" pageEncoding="utf-8"%>
+<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%><%-- Head --%>
+ <%--// Head --%>
+<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"%>
+<%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%>
+
+<%--
+ 비정상 접근 탐지 알림함 (개인정보보호 과업내용 다-① / SER-002 / #49)
+ · AbnormalAccessDetectionService(#35)의 5유형을 기간 단위로 즉시 집계해 보여준다
+ · 탐지 결과를 별도 테이블에 적재하지 않는 이유는 컨트롤러 Javadoc 참조(임계치 변경 시 과거 기준이 굳는 문제)
+ · 각 항목은 접속기록 조회화면(#47)으로 조건을 넘겨 바로 원본 기록을 확인할 수 있게 연결한다
+--%>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ · 개인정보 접속기록(730일 보존)을 조회 시점의 정책 임계치 로 다시 집계한 결과입니다. 탐지 결과를 따로 저장하지 않으므로 임계치를 조정하면 과거 기간도 새 기준으로 다시 계산됩니다.
+ · 탐지되었다는 것이 곧 위반은 아닙니다. 업무상 정당한 대량조회·야간작업이 있을 수 있으므로, 각 항목의 접속기록 보기 로 원본 기록을 확인한 뒤 판단하세요.
+ · 월 단위 점검 기록으로 남겨야 하는 경우에는 정기점검 리포트 화면에서 점검결과·조치사항을 입력하세요(이 화면은 조회 전용입니다).
+ · 5유형 각각이 기간 전체를 훑는 집계라 기간이 넓으면 조회가 느려집니다(최대 366일).
+
+
+
+
+ ~ 탐지 요약 :
+
+
+
+ <%-- 유형별 요약 카드 5장 --%>
+
+
+
+
+
+
+
+
+ 건
+ 명
+
+
+
+
+
+
+
+
+
+ <%-- 유형별 상세 --%>
+
+
+
+
+
+
+ 처리결과가 실패·차단(F)으로 남은 기록입니다 — 권한 없는 화면 접근 시도가 여기에 잡힙니다.
+ 단위기간 조회(S) 건수가 임계를 넘은 취급자입니다.
+ 업무시간 밖(기본 09시 이전·18시 이후)에 처리된 기록입니다.
+ 같은 계정이 서로 다른 IP에서 사용된 경우입니다 — 계정 공유·탈취 의심 신호입니다.
+ 같은 취급자에게서 실패(F) 기록이 반복된 경우입니다.
+
+
+
+
+
+ <%-- 대상자형(대량조회·계정공유·인증실패반복) : 누가 걸렸는지 목록으로 --%>
+
+
+ 의심 대상 목록
+
+
+
+ NO.
+ 접속자ID
+
+
+ 사용 IP 수
+ 실패 횟수
+ 조회 건수
+
+
+ 원본 확인
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <%-- 접속기록 조회화면(#47)으로 조건을 넘겨 원본을 바로 확인한다.
+ 인증실패반복은 처리결과 F, 대량조회는 처리구분 S 로 좁혀 준다. --%>
+ &processResult=F&dmlGubun=S ">접속기록 보기
+
+
+
+
+
+
+
+ <%-- 건수형(비인가접근·비정상시간대) : 대상자가 아니라 기간 전체 건수라 목록이 없다 --%>
+
+
+
+
+
+
+
+ 해당 기간에 탐지된 항목이 없습니다.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+