@
All checks were successful
deploy-wise / deploy (push) Successful in 1m6s

개인정보보호 기능개선 8차 — 관리자 화면 4종 신설

제안서·RFP 대비 화면이 없어 증빙을 낼 수 없던 항목을 채운다.

· 정기점검 리포트(SFR-004-7 #48)
  InspectionResultController 가 JSON 엔드포인트만 갖고 있어 점검결과를
  화면으로 입력·보관할 수 없었다. listView/saveResultView 를 추가하고
  기존 JSON 계약은 그대로 병존시킨다. 배치 집계값은 화면에서 수정하지
  않는다 — 감사 증빙이라 사후 변경되면 안 된다.

· 비정상접근 탐지(과업내용 다-① #49)
  탐지 5유형 로직은 있었으나 호출처가 배치뿐이었다. AbnormalAccessResultVO
  에 의심 대상 행(rows)을 실어 "누가 걸렸는지"를 요약 문자열 파싱 없이
  보여주고, 각 항목에서 접속기록 화면으로 조건을 넘겨 원본을 확인한다.
  탐지 결과를 적재하지 않는 이유는 컨트롤러 Javadoc 참조(임계치 변경 시
  과거 기준이 굳는 문제).

· 접속기록 파기이력(SER-002-2 / DBR-001-2)
  PDAL_PURGE_HISTORY 는 쌓이고 있었으나 볼 수 있는 곳이 없었다.
  "보관기간 경과분을 규정대로 파기했고 그 외 경로로는 손대지 않았다"를
  함께 입증하는 증빙이라 조회 전용으로만 연다.

· 회원정보 암호화 관리 CRUD(추가제안 Action4 / 제안서 슬라이드16)
  복호화 조회·수정·파기·신규등록 + 사유 입력 + 재인증(10분 TTL).
  목록은 항상 마스킹하고 평문은 사유를 남긴 상세 한 건에서만 보인다 —
  목록에서 평문이 뜨면 사유를 받는 의미가 없다.
  파기는 행을 지우지 않고 식별 항목만 비운다(신청·활동 기록이 참조 중).
  MBER_CRYPTO_ACCESS_HISTORY 에 처리자·일시·IP·사유·변경항목명을 남기되
  개인정보 본문은 담지 않는다.
  재인증에 로그인 2차인증을 재사용하지 않은 이유는 MberCryptoReauth
  Javadoc 참조(세션 내내 유지돼 재확인이 안 되고, OTP 미등록자가 잠긴다).

검증: JDK8 mvn compile EXIT=0, JSP 9종 Jasper 사전컴파일 오류 0건,
로컬 ROOT 반영 후 Tomcat 재기동 정상(신규 sqlMap 4건 기동시 파싱 통과),
신규 엔드포인트 6개 302→로그인 / 대조군 404 / frt/main.do 200.

DB: MBER_CRYPTO_ACCESS_HISTORY DDL + 메뉴 9100048~9100051
    (privacy.install_mysql.sql PART1·PART3 에도 병합, 재실행 안전)

후속 발견: 기존 탈퇴 구문 mberManageDAO.updateDeleteMber_S 는 개인정보를
비우면서도 이름(MBER_NM/ENC/HASH)은 지우지 않는다 — 파기 누락 소지.
신설 화면의 파기는 이름까지 파기한다. 기존 경로 보정은 별도 판단 필요.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@
This commit is contained in:
infraurp 2026-08-27 14:34:05 +09:00
parent db1999175a
commit 5c7d064fbd
34 changed files with 4207 additions and 9 deletions

View File

@ -0,0 +1,146 @@
/**
* Class Name : AbnormalAccessController.java
* Description : 비정상 접근 탐지 알림함 화면 — 개인정보보호 기능개선 사업(SFR-004 / SER-002 / #49)
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.27 개인정보보호 최초 생성 — 탐지 5유형(#35)의 화면 출력
*
* @since 2026. 08. 27
* @version 1.0
*/
package egovframework.com.cmm.privacy.auditlog;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
import javax.annotation.Resource;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import egovframework.com.cmm.LoginVO;
import egovframework.com.cmm.util.EgovUserDetailsHelper;
/**
* 비정상 접근 탐지 알림함 (과업내용 다-① "비정상 접근 탐지 및 기록", SER-002).
*
* <p>{@link AbnormalAccessDetectionService}(#35)의 5유형 탐지 결과를 기간 단위로 보여준다.
* 화면: {@code /WEB-INF/jsp/site/mnt/sec/abnormal/list.jsp}</p>
*
* <p><b>왜 저장 테이블이 아니라 즉시 집계인가:</b> 탐지 임계치는 정책값이라 언제든 바뀐다
* ({@link AbnormalAccessPolicy}). 탐지 결과를 별도 테이블에 적재해 두면 임계치를 조정한 뒤에도
* 과거 기준으로 굳은 행이 남아 "지금 기준으로 몇 건인가"에 답할 수 없다. 원천인 접속기록은
* 730일 보존되므로, 조회 시점 기준으로 다시 세는 편이 정확하다.
* 월 단위 스냅샷이 필요한 경우는 정기점검 리포트(#43)가 {@code ABNORMAL_SUMMARY} 로 남긴다.</p>
*
* <p><b>조회 부하 주의:</b> 5유형 각각이 접속기록 전체를 기간으로 훑는 집계라 기간이 넓으면
* 무거워진다. 그래서 기본 조회기간을 최근 30일로 두고, 최대 {@link #MAX_RANGE_DAYS}일로 제한한다.</p>
*
* <p><b>접근통제:</b> 인터셉터 ①인증판정이 비활성이라 화면 단에서 다시 판정한다
* ({@link PersonalDataAccessLogController} 와 동일 방식). 롤 판정은 {@code urlRoleMapping}
* 의 {@code /mnt/sec/*} = ROLE_ADMIN 이 담당한다.</p>
*/
@Controller
public class AbnormalAccessController {
private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do";
/** 기본 조회기간(일) — 최근 30일. */
private static final int DEFAULT_RANGE_DAYS = 30;
/** 최대 조회기간(일). 5유형 집계가 접속기록 전체를 훑으므로 상한을 둔다. */
private static final int MAX_RANGE_DAYS = 366;
private static final String DATE_PATTERN = "yyyy-MM-dd";
@Resource(name = "abnormalAccessDetectionService")
private AbnormalAccessDetectionService abnormalAccessDetectionService;
/**
* 비정상 접근 탐지 결과 화면.
*
* @param fromDt 시작일(yyyy-MM-dd). 미지정 시 오늘 - 30일
* @param toDt 종료일(yyyy-MM-dd). 미지정 시 오늘
*/
@RequestMapping(value = "/mnt/sec/abnormal/listView.do")
public String listView(@RequestParam(value = "fromDt", required = false) String fromDt,
@RequestParam(value = "toDt", required = false) String toDt,
ModelMap model) throws Exception {
if (handlerUser() == null) {
return LOGIN_REDIRECT;
}
SimpleDateFormat dateFmt = new SimpleDateFormat(DATE_PATTERN);
Calendar cal = Calendar.getInstance();
String toDate = isBlank(toDt) ? dateFmt.format(cal.getTime()) : toDt.trim();
String fromDate;
if (isBlank(fromDt)) {
cal.add(Calendar.DAY_OF_MONTH, -DEFAULT_RANGE_DAYS);
fromDate = dateFmt.format(cal.getTime());
} else {
fromDate = fromDt.trim();
}
String rangeMsg = null;
long days = diffDays(dateFmt, fromDate, toDate);
if (days < 0) {
// 시작 > 종료 — 조회하지 않고 되돌린다(뒤집힌 조건으로 "이상징후 0건"이 나오면 오독한다)
rangeMsg = "시작일이 종료일보다 뒤입니다. 기간을 다시 지정하세요.";
} else if (days > MAX_RANGE_DAYS) {
rangeMsg = "조회기간은 최대 " + MAX_RANGE_DAYS + "일까지입니다. 기간을 좁혀 주세요.";
}
if (rangeMsg == null) {
// 종료일 당일(23:59:59)까지 포함 — 접속기록 조회화면과 같은 규칙
List<AbnormalAccessResultVO> results =
abnormalAccessDetectionService.detect(fromDate + " 00:00:00", toDate + " 23:59:59");
model.addAttribute("resultList", results);
model.addAttribute("summary",
abnormalAccessDetectionService.summarize(fromDate + " 00:00:00", toDate + " 23:59:59"));
}
model.addAttribute("fromDt", fromDate);
model.addAttribute("toDt", toDate);
model.addAttribute("resultMsg", rangeMsg);
return "site/mnt/sec/abnormal/list";
}
/** 두 날짜(yyyy-MM-dd) 간 일수 차. 파싱 실패 시 0(=검증 통과)로 두고 조회를 진행한다. */
private long diffDays(SimpleDateFormat fmt, String from, String to) {
try {
Date f = fmt.parse(from);
Date t = fmt.parse(to);
return (t.getTime() - f.getTime()) / (24L * 60L * 60L * 1000L);
} catch (Exception e) {
return 0L;
}
}
private boolean isBlank(String v) {
return v == null || v.trim().length() == 0;
}
/**
* 인증된 업무사용자(USR)만 통과. 그 외에는 null.
* (일반회원 세션으로 관리자 화면에 들어오는 경로를 여기서 끊는다)
*/
private LoginVO handlerUser() {
if (!Boolean.TRUE.equals(EgovUserDetailsHelper.isAuthenticated())) {
return null;
}
Object o = EgovUserDetailsHelper.getAuthenticatedUser();
if (!(o instanceof LoginVO)) {
return null;
}
LoginVO loginVO = (LoginVO) o;
return "USR".equals(loginVO.getUserSe()) ? loginVO : null;
}
}

View File

@ -51,8 +51,8 @@ public class AbnormalAccessDetectionServiceImpl implements AbnormalAccessDetecti
// ② 대량조회(의심 접속자 목록)
List<Map<String, Object>> mass = abnormalAccessDetectionDAO.selectMassQuery(p);
results.add(new AbnormalAccessResultVO(AbnormalAccessType.MASS_QUERY, size(mass),
offenders(mass, "cnt", "건")));
results.add(withRows(new AbnormalAccessResultVO(AbnormalAccessType.MASS_QUERY, size(mass),
offenders(mass, "cnt", "건")), mass));
// ③ 비정상시간대접속
int off = abnormalAccessDetectionDAO.countOffHours(p);
@ -60,13 +60,13 @@ public class AbnormalAccessDetectionServiceImpl implements AbnormalAccessDetecti
// ④ 계정공유의심(의심 접속자 목록)
List<Map<String, Object>> sharing = abnormalAccessDetectionDAO.selectAccountSharing(p);
results.add(new AbnormalAccessResultVO(AbnormalAccessType.ACCOUNT_SHARING, size(sharing),
offenders(sharing, "ipCnt", "IP")));
results.add(withRows(new AbnormalAccessResultVO(AbnormalAccessType.ACCOUNT_SHARING, size(sharing),
offenders(sharing, "ipCnt", "IP")), sharing));
// ⑤ 인증실패반복(의심 접속자 목록)
List<Map<String, Object>> authFail = abnormalAccessDetectionDAO.selectRepeatedAuthFailure(p);
results.add(new AbnormalAccessResultVO(AbnormalAccessType.REPEATED_AUTH_FAILURE, size(authFail),
offenders(authFail, "cnt", "회")));
results.add(withRows(new AbnormalAccessResultVO(AbnormalAccessType.REPEATED_AUTH_FAILURE, size(authFail),
offenders(authFail, "cnt", "회")), authFail));
return results;
}
@ -91,6 +91,15 @@ public class AbnormalAccessDetectionServiceImpl implements AbnormalAccessDetecti
return sb.length() == 0 ? "(이상징후 없음)" : sb.toString();
}
/**
* 대상자형 유형에 의심 대상 상세 행을 실어 준다(알림함 화면 #49 용).
* 요약 문자열({@code detail})은 종전 그대로라 리포트(#43) 동작에는 영향이 없다.
*/
private static AbnormalAccessResultVO withRows(AbnormalAccessResultVO vo, List<Map<String, Object>> rows) {
vo.setRows(rows);
return vo;
}
private static int size(List<Map<String, Object>> list) {
return list == null ? 0 : list.size();
}

View File

@ -1,6 +1,8 @@
package egovframework.com.cmm.privacy.auditlog;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
/**
* 비정상 접근 탐지 결과 1유형 (#35).
@ -38,4 +40,16 @@ public class AbnormalAccessResultVO implements Serializable {
public String getDetail() { return detail; }
public void setDetail(String detail) { this.detail = detail; }
/**
* 의심 대상 상세 행(접속자ID + 건수/IP수). 대상자형 유형(대량조회·계정공유·인증실패반복)에만 있고,
* 건수형(비인가접근·비정상시간대)은 비어 있다 — 이 둘은 특정 대상자가 아니라 기간 전체의 건수다.
*
* <p>알림함 화면(#49)이 "누가 걸렸는지"를 요약 문자열에서 파싱하지 않도록 원본 행을 그대로 싣는다.
* 개인정보 본문은 담지 않는다(접속자ID·집계값뿐).</p>
*/
private List<Map<String, Object>> rows;
public List<Map<String, Object>> getRows() { return rows; }
public void setRows(List<Map<String, Object>> rows) { this.rows = rows; }
}

View File

@ -0,0 +1,102 @@
/**
* Class Name : AccessLogPurgeHistoryController.java
* Description : 개인정보 접속기록 파기이력 조회 화면 — 개인정보보호 기능개선 사업(SFR-004 / DBR-001 / SER-002)
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.27 개인정보보호 최초 생성 — 파기 배치(#37) 이력의 화면 출력
*
* @since 2026. 08. 27
* @version 1.0
*/
package egovframework.com.cmm.privacy.auditlog;
import java.util.List;
import javax.annotation.Resource;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import egovframework.com.cmm.LoginVO;
import egovframework.com.cmm.privacy.batch.AccessLogPartitionDAO;
import egovframework.com.cmm.util.EgovUserDetailsHelper;
import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo;
/**
* 접속기록 파기·증설 이력 조회 컨트롤러 (SER-002-2 / DBR-001-2).
*
* <p>보관기간(730일) 경과 파티션을 파기한 내역과, 미래 월 파티션을 미리 확보한 내역을 보여준다.
* 화면: {@code /WEB-INF/jsp/site/mnt/sec/purgeHistory/list.jsp}</p>
*
* <p><b>조회 전용이다.</b> 파기 이력 자체가 "접속기록을 규정대로 지웠고 그 외 경로로는 손대지
* 않았다"는 증빙이므로, 화면에서 이력을 고치거나 지울 수 있으면 증빙 가치가 사라진다.
* 등록·수정·삭제 엔드포인트를 두지 않는다.</p>
*
* <p><b>접근통제:</b> 인터셉터 3단 중 ①인증판정이 비활성이라 /mnt 하위라도 무인증 진입이
* 가능하므로 {@link #handlerUser()} 로 화면 단에서 다시 판정한다
* ({@link PersonalDataAccessLogController} 와 동일 방식). 롤 판정은 {@code urlRoleMapping}
* 의 {@code /mnt/sec/*} = ROLE_ADMIN 이 담당한다.</p>
*/
@Controller
public class AccessLogPurgeHistoryController {
private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do";
@Resource(name = "accessLogPartitionDAO")
private AccessLogPartitionDAO accessLogPartitionDAO;
/**
* 파기이력 목록 화면.
* 뷰: /WEB-INF/jsp/site/mnt/sec/purgeHistory/list.jsp
*/
@RequestMapping(value = "/mnt/sec/purgeHistory/listView.do")
public String listView(@ModelAttribute("accessLogPurgeHistorySearchVO") AccessLogPurgeHistorySearchVO searchVO,
ModelMap model) throws Exception {
if (handlerUser() == null) {
return LOGIN_REDIRECT;
}
if (searchVO.getPageIndex() < 1) {
searchVO.setPageIndex(1);
}
if (searchVO.getRecordCountPerPage() < 1) {
searchVO.setRecordCountPerPage(10);
}
searchVO.setFirstIndex((searchVO.getPageIndex() - 1) * searchVO.getRecordCountPerPage());
int totalCount = accessLogPartitionDAO.selectPurgeHistoryListCount(searchVO);
searchVO.setTotalRecordCount(totalCount);
List<AccessLogPurgeHistoryVO> list = accessLogPartitionDAO.selectPurgeHistoryList(searchVO);
PaginationInfo paginationInfo = new PaginationInfo();
paginationInfo.setCurrentPageNo(searchVO.getPageIndex());
paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage());
paginationInfo.setPageSize(10);
paginationInfo.setTotalRecordCount(totalCount);
model.addAttribute("resultList", list);
model.addAttribute("paginationInfo", paginationInfo);
return "site/mnt/sec/purgeHistory/list";
}
/**
* 인증된 업무사용자(USR)만 통과. 그 외에는 null.
* (일반회원 세션으로 관리자 화면에 들어오는 경로를 여기서 끊는다)
*/
private LoginVO handlerUser() {
if (!Boolean.TRUE.equals(EgovUserDetailsHelper.isAuthenticated())) {
return null;
}
Object o = EgovUserDetailsHelper.getAuthenticatedUser();
if (!(o instanceof LoginVO)) {
return null;
}
LoginVO loginVO = (LoginVO) o;
return "USR".equals(loginVO.getUserSe()) ? loginVO : null;
}
}

View File

@ -0,0 +1,77 @@
/**
* Class Name : AccessLogPurgeHistorySearchVO.java
* Description : 접속기록 파기이력 검색조건 VO — 개인정보보호 기능개선 사업(SFR-004 / SER-002)
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.27 개인정보보호 최초 생성
*
* @since 2026. 08. 27
* @version 1.0
*/
package egovframework.com.cmm.privacy.auditlog;
import java.io.Serializable;
/**
* 파기이력 조회 검색조건 + 페이징.
*
* <p><b>종료일 보정:</b> 사용자가 {@code 2026-08-27} 처럼 날짜만 넣으면 그날 00:00:00 이 되어
* 당일 파기분이 통째로 빠진다. {@link #getToDtParam()} 가 날짜만 들어온 경우
* {@code 23:59:59} 를 붙여 당일을 포함시킨다 — 접속기록 조회화면
* ({@link PersonalDataAccessLogSearchVO})과 같은 규칙이라 두 화면의 기간 검색이 어긋나지 않는다.</p>
*/
public class AccessLogPurgeHistorySearchVO implements Serializable {
private static final long serialVersionUID = 1L;
/** 유형 — DROP / ADD / 전체(빈값). */
private String purgeType;
/** 수행일시 시작(yyyy-MM-dd 또는 yyyy-MM-dd HH:mm:ss). */
private String fromDt;
/** 수행일시 종료. */
private String toDt;
private int pageIndex = 1;
private int recordCountPerPage = 10;
/** 조회 시작 offset(0-base) = (pageIndex-1) * recordCountPerPage */
private int firstIndex = 0;
private int totalRecordCount = 0;
public String getPurgeType() { return purgeType; }
public void setPurgeType(String purgeType) { this.purgeType = purgeType; }
public String getFromDt() { return fromDt; }
public void setFromDt(String fromDt) { this.fromDt = fromDt; }
public String getToDt() { return toDt; }
public void setToDt(String toDt) { this.toDt = toDt; }
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; }
/** 시작일시 바인딩값 — 날짜만 들어오면 00:00:00 을 붙인다. */
public String getFromDtParam() {
return isDateOnly(fromDt) ? fromDt + " 00:00:00" : fromDt;
}
/** 종료일시 바인딩값 — 날짜만 들어오면 당일 23:59:59 까지 포함시킨다. */
public String getToDtParam() {
return isDateOnly(toDt) ? toDt + " 23:59:59" : toDt;
}
private boolean isDateOnly(String v) {
return v != null && v.trim().length() == 10;
}
}

View File

@ -0,0 +1,60 @@
/**
* Class Name : AccessLogPurgeHistoryVO.java
* Description : 개인정보 접속기록 파기·증설 이력 VO — 개인정보보호 기능개선 사업(SFR-004 / DBR-001 / SER-002)
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.27 개인정보보호 최초 생성 — 파기이력 조회 화면(#37 후속)
*
* @since 2026. 08. 27
* @version 1.0
*/
package egovframework.com.cmm.privacy.auditlog;
import java.io.Serializable;
/**
* {@code PDAL_PURGE_HISTORY} 한 행 — 파기 배치(#37)가 수행한 파티션 DROP(파기)·ADD(선행증설) 내역.
*
* <p><b>이 이력이 증빙인 이유:</b> 접속기록 본문 테이블은 UPDATE/DELETE statement 자체를 두지 않고,
* 삭제는 오직 파티션 DROP 으로만 일어난다. 그래서 "언제 무엇이 지워졌는가"를 답할 수 있는 유일한
* 근거가 이 표다 — 보관기간 경과분을 실제로 파기했다는 사실(SER-002-2)과, 그 외 경로로는 지운 적이
* 없다는 사실을 함께 보인다.</p>
*/
public class AccessLogPurgeHistoryVO implements Serializable {
private static final long serialVersionUID = 1L;
/** 이력 일련번호. */
private long purgeId;
/** 대상 파티션명(예: p202607). */
private String partitionName;
/** 파티션 상한 경계값(LESS THAN). */
private String boundaryDt;
/** 유형 — DROP:파기 / ADD:선행증설. */
private String purgeType;
/** 수행 일시. */
private String purgeDt;
/** 비고(보관기간·사유). */
private String note;
public long getPurgeId() { return purgeId; }
public void setPurgeId(long purgeId) { this.purgeId = purgeId; }
public String getPartitionName() { return partitionName; }
public void setPartitionName(String partitionName) { this.partitionName = partitionName; }
public String getBoundaryDt() { return boundaryDt; }
public void setBoundaryDt(String boundaryDt) { this.boundaryDt = boundaryDt; }
public String getPurgeType() { return purgeType; }
public void setPurgeType(String purgeType) { this.purgeType = purgeType; }
public String getPurgeDt() { return purgeDt; }
public void setPurgeDt(String purgeDt) { this.purgeDt = purgeDt; }
public String getNote() { return note; }
public void setNote(String note) { this.note = note; }
}

View File

@ -5,6 +5,8 @@ import java.util.Map;
import org.springframework.stereotype.Repository;
import egovframework.com.cmm.privacy.auditlog.AccessLogPurgeHistorySearchVO;
import egovframework.com.cmm.privacy.auditlog.AccessLogPurgeHistoryVO;
import egovframework.com.cmm.service.impl.EgovComAbstractDAO;
/**
@ -54,4 +56,24 @@ public class AccessLogPartitionDAO extends EgovComAbstractDAO {
public void insertPurgeHistory(Map<String, String> param) throws Exception {
insert("PersonalDataAccessLogDAO.insertPurgeHistory", param);
}
/**
* 파기·증설 이력 목록(화면 조회용, 페이징).
*
* <p>배치가 쓰는 DAO 에 조회를 함께 두는 이유는 {@code PDAL_PURGE_HISTORY} 의 소유자가
* 이 클래스이기 때문이다. 이력을 읽는 statement 를 다른 sqlmap 으로 흩어 놓으면
* 적재 컬럼이 바뀔 때 조회 쪽이 조용히 어긋난다.</p>
*
* @param searchVO 유형·기간·페이징
*/
@SuppressWarnings("unchecked")
public List<AccessLogPurgeHistoryVO> selectPurgeHistoryList(AccessLogPurgeHistorySearchVO searchVO) {
return list("PersonalDataAccessLogDAO.selectPurgeHistoryList", searchVO);
}
/** 파기·증설 이력 총건수(페이징용). 목록과 <b>같은 검색조건</b>을 유지할 것. */
public int selectPurgeHistoryListCount(AccessLogPurgeHistorySearchVO searchVO) {
Object cnt = selectByPk("PersonalDataAccessLogDAO.selectPurgeHistoryListCount", searchVO);
return cnt == null ? 0 : ((Number) cnt).intValue();
}
}

View File

@ -1,5 +1,6 @@
package egovframework.com.cmm.privacy.batch;
import java.net.URLEncoder;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -12,13 +13,20 @@ import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.RequestMapping;
import egovframework.com.cmm.LoginVO;
import egovframework.com.cmm.util.EgovUserDetailsHelper;
/**
* 정기점검 결과 입력·보관 컨트롤러 (#44, SFR-004/SER-002).
*
* <p>취급자 영역(/mnt) 백엔드 엔드포인트. 응답은 jsonView(화면 JSP는 #48 대시보드 후속).
* <p>취급자 영역(/mnt) 엔드포인트. 기존 {@code list.do}/{@code detail.do}/{@code saveResult.do}
* 는 jsonView 로 응답하는 백엔드 계약이고, 2026.08.27 추가된 {@code listView.do}/
* {@code saveResultView.do} 가 관리자 화면(#48)이다 — 두 계약을 병존시켜 기존 호출부를 깨지 않는다.
* 점검자 식별자는 세션 loginVO 에서 취득한다.</p>
*
* <p><b>화면이 필요한 이유:</b> 월 1회 점검 자체는 배치(#43)가 집계하지만, "누가 언제 보고 무엇을
* 조치했는가"는 사람이 입력해야 남는다. 그 입력 경로가 JSON 엔드포인트뿐이면 점검 사실을
* 증빙으로 제출할 수 없다(SFR-004-7).</p>
*
* @author arko-privacy-auditlog (Fork C - 7차)
* @since 2026-07-21
*/
@ -27,6 +35,8 @@ public class InspectionResultController {
protected static final String JSON = "jsonView";
private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do";
@Resource(name = "inspectionResultService")
private InspectionResultService inspectionResultService;
@ -35,6 +45,82 @@ public class InspectionResultController {
return (o instanceof LoginVO) ? (LoginVO) o : null;
}
/**
* 정기점검 리포트 목록·상세 화면(서버 렌더링, #48).
* 뷰: /WEB-INF/jsp/site/mnt/sec/inspection/list.jsp
*
* <p>월 1행짜리 표라 페이징을 두지 않는다(연 12행). 연도 필터와 점검여부 필터만 제공한다.
* {@code rptId} 가 있으면 그 리포트 상세와 점검결과 입력 폼을 함께 편다 —
* 목록에서 상세로 화면을 옮기면 점검 중 다른 달과 대조하기가 불편하다.</p>
*/
@RequestMapping(value = "/mnt/sec/inspection/listView.do")
public String listView(@org.springframework.web.bind.annotation.RequestParam(value = "rptYmPrefix", required = false) String rptYmPrefix,
@org.springframework.web.bind.annotation.RequestParam(value = "inspectedYn", required = false) String inspectedYn,
@org.springframework.web.bind.annotation.RequestParam(value = "rptId", required = false) Long rptId,
@org.springframework.web.bind.annotation.RequestParam(value = "resultMsg", required = false) String resultMsg,
ModelMap model) throws Exception {
if (handlerUser() == null) {
return LOGIN_REDIRECT;
}
Map<String, Object> param = new HashMap<String, Object>();
param.put("rptYmPrefix", rptYmPrefix);
param.put("inspectedYn", inspectedYn);
List<InspectionReportVO> list = inspectionResultService.selectReportList(param);
model.addAttribute("resultList", list);
model.addAttribute("totalCount", list == null ? 0 : list.size());
model.addAttribute("rptYmPrefix", rptYmPrefix);
model.addAttribute("inspectedYn", inspectedYn);
model.addAttribute("resultMsg", resultMsg);
if (rptId != null) {
model.addAttribute("report", inspectionResultService.selectReport(rptId.longValue()));
}
return "site/mnt/sec/inspection/list";
}
/**
* 점검결과 입력·보관(화면용, #48). 저장 후 같은 리포트를 펼친 목록으로 되돌린다.
*
* <p>배치가 적재한 집계값은 손대지 않고 점검자 입력 필드만 갱신한다
* (감사 증빙이므로 집계값이 사후에 바뀌면 안 된다).</p>
*/
@RequestMapping(value = "/mnt/sec/inspection/saveResultView.do")
public String saveResultView(InspectionReportVO vo, HttpServletRequest request) throws Exception {
LoginVO user = handlerUser();
if (user == null) {
return LOGIN_REDIRECT;
}
vo.setInspectorId(user.getId());
String message;
try {
int cnt = inspectionResultService.saveInspectionResult(vo);
message = (cnt > 0) ? "점검결과를 저장했습니다." : "저장 대상 리포트가 없습니다.";
} catch (IllegalArgumentException e) {
// 점검결과 미입력 등 유효성 위반 — 화면에 사유를 그대로 보여준다
message = e.getMessage();
}
// Spring 3.0.5 에는 RedirectAttributes 가 없다(3.1 도입). 이 코드베이스의 관용대로
// 결과 메시지를 URL 인코딩해 쿼리스트링으로 넘긴다(AccountMngController 와 동일 방식).
return "redirect:/mnt/sec/inspection/listView.do?rptId=" + vo.getRptId()
+ "&resultMsg=" + URLEncoder.encode(message == null ? "" : message, "UTF-8");
}
/**
* 인증된 업무사용자(USR)만 통과. 그 외에는 null.
* (인터셉터 ①인증판정이 비활성이라 화면 단에서 다시 판정한다 — 다른 개인정보보호 화면과 동일)
*/
private LoginVO handlerUser() {
if (!Boolean.TRUE.equals(EgovUserDetailsHelper.isAuthenticated())) {
return null;
}
Object o = EgovUserDetailsHelper.getAuthenticatedUser();
if (!(o instanceof LoginVO)) {
return null;
}
LoginVO loginVO = (LoginVO) o;
return "USR".equals(loginVO.getUserSe()) ? loginVO : null;
}
/** 정기점검 리포트 월별 목록 (검색: rptYmPrefix 연도, inspectedYn Y/N) */
@RequestMapping(value = "/mnt/sec/inspection/list.do")
public String list(@org.springframework.web.bind.annotation.RequestParam(value = "rptYmPrefix", required = false) String rptYmPrefix,

View File

@ -0,0 +1,90 @@
/**
* Class Name : MberCryptoAccessHistoryVO.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;
/**
* {@code MBER_CRYPTO_ACCESS_HISTORY} 한 행 — 관리자가 평문 개인정보를 열람·변경한 행위의 소명 근거.
*
* <p><b>개인정보 본문은 담지 않는다.</b> 무엇을 바꿨는지는 {@link #changedItems}(항목명 목록)로만
* 남기고 값은 적재하지 않는다 — 이력이 또 하나의 평문 개인정보 저장소가 되면 안 된다.</p>
*/
public class MberCryptoAccessHistoryVO implements Serializable {
private static final long serialVersionUID = 1L;
/** 행위 — 복호화 조회. */
public static final String ACTION_SELECT = "S";
/** 행위 — 신규 등록. */
public static final String ACTION_INSERT = "I";
/** 행위 — 수정. */
public static final String ACTION_UPDATE = "U";
/** 행위 — 개인정보 파기. */
public static final String ACTION_DELETE = "D";
private long histId;
/** 처리자(취급자) 계정ID. */
private String handlrId;
/** 행위 S/I/U/D. */
private String actionTy;
/** 대상 회원ID. */
private String trgetMberId;
/** 사유(필수). */
private String accessReason;
/** 변경 항목명 목록(값 미적재). */
private String changedItems;
/** 처리자 IP. */
private String handlrIp;
/** 재인증 통과 여부 Y/N. */
private String reauthYn;
/** 처리 일시. */
private String accessDt;
public long getHistId() { return histId; }
public void setHistId(long histId) { this.histId = histId; }
public String getHandlrId() { return handlrId; }
public void setHandlrId(String handlrId) { this.handlrId = handlrId; }
public String getActionTy() { return actionTy; }
public void setActionTy(String actionTy) { this.actionTy = actionTy; }
public String getTrgetMberId() { return trgetMberId; }
public void setTrgetMberId(String trgetMberId) { this.trgetMberId = trgetMberId; }
public String getAccessReason() { return accessReason; }
public void setAccessReason(String accessReason) { this.accessReason = accessReason; }
public String getChangedItems() { return changedItems; }
public void setChangedItems(String changedItems) { this.changedItems = changedItems; }
public String getHandlrIp() { return handlrIp; }
public void setHandlrIp(String handlrIp) { this.handlrIp = handlrIp; }
public String getReauthYn() { return reauthYn; }
public void setReauthYn(String reauthYn) { this.reauthYn = reauthYn; }
public String getAccessDt() { return accessDt; }
public void setAccessDt(String accessDt) { this.accessDt = accessDt; }
/** 행위코드의 한글 표기(화면·리포트용). */
public String getActionNm() {
if (ACTION_SELECT.equals(actionTy)) { return "복호화조회"; }
if (ACTION_INSERT.equals(actionTy)) { return "등록"; }
if (ACTION_UPDATE.equals(actionTy)) { return "수정"; }
if (ACTION_DELETE.equals(actionTy)) { return "개인정보파기"; }
return actionTy;
}
}

View File

@ -0,0 +1,426 @@
/**
* Class Name : MberCryptoAdminController.java
* Description : 암호화 회원정보 관리자 전용 CRUD 화면 — 개인정보보호 기능개선 사업(추가제안 Action4)
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.27 개인정보보호 최초 생성 — 복호화 조회·수정·삭제·등록 + 사유·재인증
*
* @since 2026. 08. 27
* @version 1.0
*/
package egovframework.com.cmm.privacy.crypto;
import java.net.URLEncoder;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import egovframework.com.cmm.LoginVO;
import egovframework.com.cmm.privacy.auditlog.ClientIpResolver;
import egovframework.com.cmm.privacy.util.MaskingUtil;
import egovframework.com.cmm.util.EgovUserDetailsHelper;
import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo;
/**
* 관리자 전용 회원정보(암호화 항목) 화면 — 제안서 추가제안 Action4(슬라이드16).
*
* <p><b>화면 구성</b>
* <ul>
* <li>{@code listView.do} 목록 — <b>항상 마스킹</b>해서 보여준다(이름 홍*동, 휴대폰 010-****-1234).</li>
* <li>{@code view.do} 상세 — <b>사유 입력 + 재인증</b>을 통과해야 평문이 보인다.</li>
* <li>{@code registView.do}/{@code regist.do} 신규 등록, {@code update.do} 수정,
* {@code deletePii.do} 개인정보 파기 — 모두 사유·재인증 필수.</li>
* <li>{@code historyView.do} 이 화면에서 일어난 접근 이력(사유 포함) 조회.</li>
* </ul>
*
* <p><b>목록을 마스킹으로 고정한 이유:</b> 관리자가 목록만 열어도 평문 이름·연락처가 화면 한 장에
* 수십 건씩 뜨면, 사유를 받는 의미가 사라진다(정작 대량 노출은 목록에서 일어난다).
* 평문은 대상을 특정해 사유를 남긴 <u>상세 한 건</u>에서만 보이게 한다.</p>
*
* <p><b>접근통제 3중:</b> ① {@code urlRoleMapping} 의 {@code /mnt/sec/*} = ROLE_ADMIN(전체관리자
* 전용) ② {@link #handlerUser()} 로 업무사용자(USR) 세션 재확인 ③ 평문에 닿는 모든 경로에서
* {@link MberCryptoReauth} 재인증(10분). 여기에 접속기록 인터셉터가 {@code /mnt/sec/**} 를
* 자동 수집하므로 '언제 누가 열었는가'는 별도로도 남는다.</p>
*
* <p><b>상태 변경은 POST 만 받는다.</b> 등록·수정·파기가 GET 으로도 열리면 링크 한 번, 이미지
* 태그 하나로 실행될 수 있다.</p>
*/
@Controller
public class MberCryptoAdminController {
private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do";
private static final String LIST_REDIRECT = "redirect:/mnt/sec/mberCrypto/listView.do?page=9100051";
/** 재인증 화면으로 되돌아올 수 있는 경로의 접두사. 이 밖의 값은 받지 않는다(오픈 리다이렉트 차단). */
private static final String RETURN_URL_PREFIX = "/mnt/sec/mberCrypto/";
@Resource(name = "mberCryptoAdminService")
private MberCryptoAdminService mberCryptoAdminService;
/* ================================================================
* 목록 — 항상 마스킹
* ================================================================ */
/**
* 회원정보 목록(마스킹). 뷰: /WEB-INF/jsp/site/mnt/sec/mberCrypto/list.jsp
*
* <p>이름 검색은 완전일치만 된다(암호문은 부분검색이 원리적으로 불가). 그 사실을 화면 안내에
* 명시하고, 검색 결과가 0건일 때 "부분일치로는 찾을 수 없다"는 점을 함께 보여준다.</p>
*/
@RequestMapping(value = "/mnt/sec/mberCrypto/listView.do")
public String listView(@ModelAttribute("mberCryptoSearchVO") MberCryptoSearchVO searchVO,
@RequestParam(value = "resultMsg", required = false) String resultMsg,
HttpServletRequest request, ModelMap model) throws Exception {
if (handlerUser() == null) {
return LOGIN_REDIRECT;
}
if (searchVO.getPageIndex() < 1) {
searchVO.setPageIndex(1);
}
if (searchVO.getRecordCountPerPage() < 1) {
searchVO.setRecordCountPerPage(10);
}
searchVO.setFirstIndex((searchVO.getPageIndex() - 1) * searchVO.getRecordCountPerPage());
List<MberCryptoVO> list = mberCryptoAdminService.selectMberList(searchVO);
maskAll(list);
PaginationInfo paginationInfo = new PaginationInfo();
paginationInfo.setCurrentPageNo(searchVO.getPageIndex());
paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage());
paginationInfo.setPageSize(10);
paginationInfo.setTotalRecordCount(searchVO.getTotalRecordCount());
model.addAttribute("resultList", list);
model.addAttribute("paginationInfo", paginationInfo);
model.addAttribute("resultMsg", resultMsg);
model.addAttribute("reauthRemain", Long.valueOf(MberCryptoReauth.remainSeconds(request)));
return "site/mnt/sec/mberCrypto/list";
}
/* ================================================================
* 재인증
* ================================================================ */
/** 재인증 입력 화면. 뷰: /WEB-INF/jsp/site/mnt/sec/mberCrypto/reauth.jsp */
@RequestMapping(value = "/mnt/sec/mberCrypto/reauthView.do")
public String reauthView(@RequestParam(value = "returnUrl", required = false) String returnUrl,
@RequestParam(value = "resultMsg", required = false) String resultMsg,
ModelMap model) throws Exception {
if (handlerUser() == null) {
return LOGIN_REDIRECT;
}
model.addAttribute("returnUrl", safeReturnUrl(returnUrl));
model.addAttribute("resultMsg", resultMsg);
return "site/mnt/sec/mberCrypto/reauth";
}
/**
* 재인증 처리(본인 비밀번호 재확인). 성공하면 원래 가려던 화면으로 돌려보낸다.
*
* <p>실패 문구는 사유를 구분하지 않는다(계정 없음/비밀번호 불일치 동일) — 계정 열거를 막는
* 이 사업의 기존 처리(#23)와 같은 방침이다.</p>
*/
@RequestMapping(value = "/mnt/sec/mberCrypto/reauth.do", method = org.springframework.web.bind.annotation.RequestMethod.POST)
public String reauth(@RequestParam(value = "password", required = false) String password,
@RequestParam(value = "returnUrl", required = false) String returnUrl,
HttpServletRequest request) throws Exception {
LoginVO user = handlerUser();
if (user == null) {
return LOGIN_REDIRECT;
}
String target = safeReturnUrl(returnUrl);
if (mberCryptoAdminService.verifyHandlerPassword(user.getId(), password)) {
MberCryptoReauth.markVerified(request);
return "redirect:" + target;
}
MberCryptoReauth.clear(request);
return "redirect:/mnt/sec/mberCrypto/reauthView.do?returnUrl=" + URLEncoder.encode(target, "UTF-8")
+ "&resultMsg=" + URLEncoder.encode("본인 확인에 실패했습니다. 비밀번호를 다시 입력하세요.", "UTF-8");
}
/* ================================================================
* 상세(복호화) — 사유 + 재인증
* ================================================================ */
/**
* 회원 상세(평문). 사유 미입력이면 열지 않고, 재인증이 만료됐으면 재인증 화면으로 보낸다.
* 뷰: /WEB-INF/jsp/site/mnt/sec/mberCrypto/view.jsp
*/
@RequestMapping(value = "/mnt/sec/mberCrypto/view.do")
public String view(@RequestParam(value = "mberId", required = false) String mberId,
@RequestParam(value = "reason", required = false) String reason,
@RequestParam(value = "resultMsg", required = false) String resultMsg,
HttpServletRequest request, ModelMap model) throws Exception {
LoginVO user = handlerUser();
if (user == null) {
return LOGIN_REDIRECT;
}
if (isBlank(mberId)) {
return listRedirect("대상 회원이 지정되지 않았습니다.");
}
if (isBlank(reason)) {
return listRedirect("개인정보 조회 사유는 필수입니다. 목록에서 사유를 입력한 뒤 조회하세요.");
}
if (!MberCryptoReauth.isVerified(request)) {
return reauthRedirect(viewUrl(mberId, reason));
}
MberCryptoVO vo = mberCryptoAdminService.selectMber(mberId);
if (vo == null) {
return listRedirect("대상 회원을 찾을 수 없습니다.");
}
// 평문을 실제로 화면에 띄우는 시점에 사유와 함께 이력을 남긴다.
mberCryptoAdminService.recordDecryptView(mberId, user.getId(),
ClientIpResolver.getClientIp(request), reason);
model.addAttribute("mber", vo);
model.addAttribute("reason", reason);
model.addAttribute("resultMsg", resultMsg);
model.addAttribute("reauthRemain", Long.valueOf(MberCryptoReauth.remainSeconds(request)));
return "site/mnt/sec/mberCrypto/view";
}
/* ================================================================
* 등록 / 수정 / 파기 — 모두 POST + 사유 + 재인증
* ================================================================ */
/** 신규 등록 화면. 뷰: /WEB-INF/jsp/site/mnt/sec/mberCrypto/regist.jsp */
@RequestMapping(value = "/mnt/sec/mberCrypto/registView.do")
public String registView(@ModelAttribute("mberCryptoVO") MberCryptoVO vo,
@RequestParam(value = "resultMsg", required = false) String resultMsg,
HttpServletRequest request, ModelMap model) throws Exception {
if (handlerUser() == null) {
return LOGIN_REDIRECT;
}
if (!MberCryptoReauth.isVerified(request)) {
return reauthRedirect(RETURN_URL_PREFIX + "registView.do?page=9100051");
}
model.addAttribute("resultMsg", resultMsg);
model.addAttribute("reauthRemain", Long.valueOf(MberCryptoReauth.remainSeconds(request)));
return "site/mnt/sec/mberCrypto/regist";
}
/** 신규 등록 처리. */
@RequestMapping(value = "/mnt/sec/mberCrypto/regist.do", method = org.springframework.web.bind.annotation.RequestMethod.POST)
public String regist(@ModelAttribute("mberCryptoVO") MberCryptoVO vo,
@RequestParam(value = "reason", required = false) String reason,
HttpServletRequest request) throws Exception {
LoginVO user = handlerUser();
if (user == null) {
return LOGIN_REDIRECT;
}
if (!MberCryptoReauth.isVerified(request)) {
return reauthRedirect(RETURN_URL_PREFIX + "registView.do?page=9100051");
}
try {
String mberId = mberCryptoAdminService.registMber(vo, user.getId(),
ClientIpResolver.getClientIp(request), reason);
return listRedirect("회원을 등록했습니다: " + mberId);
} catch (IllegalArgumentException e) {
return registRedirect(e.getMessage());
}
}
/** 수정 처리. */
@RequestMapping(value = "/mnt/sec/mberCrypto/update.do", method = org.springframework.web.bind.annotation.RequestMethod.POST)
public String update(@ModelAttribute("mberCryptoVO") MberCryptoVO vo,
@RequestParam(value = "reason", required = false) String reason,
HttpServletRequest request) throws Exception {
LoginVO user = handlerUser();
if (user == null) {
return LOGIN_REDIRECT;
}
if (!MberCryptoReauth.isVerified(request)) {
return reauthRedirect(viewUrl(vo.getMberId(), reason));
}
try {
int cnt = mberCryptoAdminService.updateMber(vo, user.getId(),
ClientIpResolver.getClientIp(request), reason);
return listRedirect(cnt > 0 ? "회원정보를 수정했습니다." : "수정 대상이 없습니다.");
} catch (IllegalArgumentException e) {
return listRedirect(e.getMessage());
}
}
/**
* 개인정보 파기 처리.
*
* <p>되돌릴 수 없는 작업이라 화면에서 회원ID를 직접 입력해 확인시키고
* ({@code confirmId}), 서버에서도 대상과 일치하는지 다시 본다 — 확인 절차가 화면에만 있으면
* 요청을 직접 만드는 경로에서는 없는 것과 같다.</p>
*/
@RequestMapping(value = "/mnt/sec/mberCrypto/deletePii.do", method = org.springframework.web.bind.annotation.RequestMethod.POST)
public String deletePii(@RequestParam(value = "mberId", required = false) String mberId,
@RequestParam(value = "confirmId", required = false) String confirmId,
@RequestParam(value = "reason", required = false) String reason,
HttpServletRequest request) throws Exception {
LoginVO user = handlerUser();
if (user == null) {
return LOGIN_REDIRECT;
}
if (!MberCryptoReauth.isVerified(request)) {
return reauthRedirect(viewUrl(mberId, reason));
}
if (isBlank(mberId) || confirmId == null || !mberId.trim().equals(confirmId.trim())) {
return listRedirect("파기 확인을 위해 대상 회원ID를 정확히 입력해야 합니다.");
}
try {
int cnt = mberCryptoAdminService.deleteMberPii(mberId, user.getId(),
ClientIpResolver.getClientIp(request), reason);
return listRedirect(cnt > 0
? "개인정보를 파기하고 회원상태를 탈퇴로 전환했습니다: " + mberId.trim()
: "파기 대상이 없습니다.");
} catch (IllegalArgumentException e) {
return listRedirect(e.getMessage());
}
}
/* ================================================================
* 접근 이력
* ================================================================ */
/** 이 화면에서 일어난 접근 이력(사유 포함) 조회. 뷰: .../mberCrypto/history.jsp */
@RequestMapping(value = "/mnt/sec/mberCrypto/historyView.do")
public String historyView(@RequestParam(value = "trgetMberId", required = false) String trgetMberId,
@RequestParam(value = "handlrId", required = false) String handlrId,
@RequestParam(value = "actionTy", required = false) String actionTy,
@RequestParam(value = "pageIndex", required = false, defaultValue = "1") int pageIndex,
ModelMap model) throws Exception {
if (handlerUser() == null) {
return LOGIN_REDIRECT;
}
int page = pageIndex < 1 ? 1 : pageIndex;
int perPage = 10;
Map<String, Object> param = new HashMap<String, Object>();
param.put("trgetMberId", trim(trgetMberId));
param.put("handlrId", trim(handlrId));
param.put("actionTy", trim(actionTy));
param.put("firstIndex", Integer.valueOf((page - 1) * perPage));
param.put("recordCountPerPage", Integer.valueOf(perPage));
int total = mberCryptoAdminService.selectAccessHistoryListCount(param);
List<MberCryptoAccessHistoryVO> list = mberCryptoAdminService.selectAccessHistoryList(param);
PaginationInfo paginationInfo = new PaginationInfo();
paginationInfo.setCurrentPageNo(page);
paginationInfo.setRecordCountPerPage(perPage);
paginationInfo.setPageSize(10);
paginationInfo.setTotalRecordCount(total);
model.addAttribute("resultList", list);
model.addAttribute("paginationInfo", paginationInfo);
model.addAttribute("totalCount", Integer.valueOf(total));
model.addAttribute("trgetMberId", trgetMberId);
model.addAttribute("handlrId", handlrId);
model.addAttribute("actionTy", actionTy);
return "site/mnt/sec/mberCrypto/history";
}
/* ================================================================
* 내부 헬퍼
* ================================================================ */
/** 목록에 실릴 값을 전부 마스킹한다(평문은 상세에서만 보여준다). */
private void maskAll(List<MberCryptoVO> list) {
if (list == null) {
return;
}
for (int i = 0; i < list.size(); i++) {
MberCryptoVO vo = list.get(i);
vo.setMberNm(MaskingUtil.maskName(vo.getMberNm()));
vo.setMoblphonNo(MaskingUtil.maskPhone(vo.getMoblphonNo()));
vo.setMberEmailAdres(MaskingUtil.maskEmail(vo.getMberEmailAdres()));
// 암호문·해시는 화면으로 나갈 이유가 없다. 목록 모델에서 지운다.
vo.setMberNmEnc(null);
vo.setMberNmHash(null);
}
}
/**
* 돌아갈 URL 을 이 화면 대역으로 제한한다.
* 외부 주소나 다른 화면이 들어오면 목록으로 되돌린다(오픈 리다이렉트 차단).
*/
private String safeReturnUrl(String returnUrl) {
if (returnUrl == null) {
return RETURN_URL_PREFIX + "listView.do?page=9100051";
}
String u = returnUrl.trim();
// "//host" 형태는 프로토콜 상대 URL 이라 외부로 나간다 — 접두사 검사만으로는 막히지 않는다.
if (u.startsWith(RETURN_URL_PREFIX) && !u.startsWith("//")) {
return u;
}
return RETURN_URL_PREFIX + "listView.do?page=9100051";
}
private String viewUrl(String mberId, String reason) throws Exception {
StringBuilder sb = new StringBuilder(RETURN_URL_PREFIX).append("view.do?page=9100051");
if (!isBlank(mberId)) {
sb.append("&mberId=").append(URLEncoder.encode(mberId.trim(), "UTF-8"));
}
if (!isBlank(reason)) {
sb.append("&reason=").append(URLEncoder.encode(reason.trim(), "UTF-8"));
}
return sb.toString();
}
private String reauthRedirect(String returnUrl) throws Exception {
return "redirect:/mnt/sec/mberCrypto/reauthView.do?returnUrl="
+ URLEncoder.encode(returnUrl, "UTF-8");
}
private String listRedirect(String message) throws Exception {
if (message == null) {
return LIST_REDIRECT;
}
return LIST_REDIRECT + "&resultMsg=" + URLEncoder.encode(message, "UTF-8");
}
private String registRedirect(String message) throws Exception {
String base = "redirect:/mnt/sec/mberCrypto/registView.do?page=9100051";
if (message == null) {
return base;
}
return base + "&resultMsg=" + URLEncoder.encode(message, "UTF-8");
}
private String trim(String v) {
return (v == null || v.trim().length() == 0) ? null : v.trim();
}
private boolean isBlank(String v) {
return v == null || v.trim().length() == 0;
}
/**
* 인증된 업무사용자(USR)만 통과. 그 외에는 null.
* (일반회원 세션으로 관리자 화면에 들어오는 경로를 여기서 끊는다)
*/
private LoginVO handlerUser() {
if (!Boolean.TRUE.equals(EgovUserDetailsHelper.isAuthenticated())) {
return null;
}
Object o = EgovUserDetailsHelper.getAuthenticatedUser();
if (!(o instanceof LoginVO)) {
return null;
}
LoginVO loginVO = (LoginVO) o;
return "USR".equals(loginVO.getUserSe()) ? loginVO : null;
}
}

View File

@ -0,0 +1,107 @@
/**
* Class Name : MberCryptoAdminDAO.java
* Description : 암호화 회원정보 관리자 CRUD DAO — 개인정보보호 기능개선 사업(추가제안 Action4)
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.27 개인정보보호 최초 생성
*
* @since 2026. 08. 27
* @version 1.0
*/
package egovframework.com.cmm.privacy.crypto;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Repository;
import egovframework.com.cmm.service.impl.EgovComAbstractDAO;
/**
* 관리자 전용 회원정보 화면의 데이터 접근.
*
* <p><b>복호화 지점을 여기 한 곳으로 모은다.</b> 조회 statement 는 {@code MBER_NM_ENC} 를
* {@code mberNm} 별칭으로 가져오고, 이 DAO 가 반환 직전
* {@link MemberNmDecryptSupport} 로 같은 자리에 평문을 되돌려 쓴다. 서비스·화면은 복호화를
* 의식하지 않는다 — 읽기 전환 시 정착된 이 코드베이스의 관용을 그대로 따른다.</p>
*
* <p><b>기존 회원관리(mberManageDAO)와 왜 분리했나:</b> 표준 회원관리 VO 는 활동지역·재능 등
* 수십 개 필드를 함께 다뤄 이 화면이 필요로 하지 않는 값까지 오간다. 개인정보 항목만 좁게
* 취급하는 별도 계층을 두는 편이 "무엇을 읽고 무엇을 바꿨는가"를 이력에 정확히 남기기 쉽다.</p>
*/
@Repository("mberCryptoAdminDAO")
public class MberCryptoAdminDAO extends EgovComAbstractDAO {
/** 회원 목록(검색·페이징). 반환 직전 이름을 복호화해 {@code mberNm} 에 되돌려 쓴다. */
@SuppressWarnings("unchecked")
public List<MberCryptoVO> selectMberList(MberCryptoSearchVO searchVO) {
List<MberCryptoVO> rows = list("MberCryptoAdminDAO.selectMberList", searchVO);
MemberNmDecryptSupport.decryptListInPlace(rows, "mberNm");
return rows;
}
/** 회원 목록 총건수(페이징용). 목록과 <b>같은 검색조건</b>을 유지할 것. */
public int selectMberListCount(MberCryptoSearchVO searchVO) {
Object cnt = selectByPk("MberCryptoAdminDAO.selectMberListCount", searchVO);
return cnt == null ? 0 : ((Number) cnt).intValue();
}
/** 회원 단건. 없으면 null. 반환 직전 이름을 복호화한다. */
public MberCryptoVO selectMber(String mberId) {
MberCryptoVO row = (MberCryptoVO) selectByPk("MberCryptoAdminDAO.selectMber", mberId);
MemberNmDecryptSupport.decryptInPlace(row, "mberNm");
return row;
}
/** 회원ID 중복 여부(신규 등록 전 확인). */
public int selectMberIdCount(String mberId) {
Object cnt = selectByPk("MberCryptoAdminDAO.selectMberIdCount", mberId);
return cnt == null ? 0 : ((Number) cnt).intValue();
}
/**
* 신규 회원 등록. 이름은 암호문·해시로만 저장하며, 평문 컬럼에는 표식만 들어간다
* (컬럼이 NOT NULL 이라 비울 수 없다 — sqlmap 주석 참조).
*/
public void insertMber(MberCryptoVO vo) {
insert("MberCryptoAdminDAO.insertMber", vo);
}
/** 회원 개인정보 수정(이름은 암호문·해시 동시 갱신). */
public int updateMber(MberCryptoVO vo) {
return update("MberCryptoAdminDAO.updateMber", vo);
}
/**
* 개인정보 파기 — 행을 지우지 않고 개인정보 항목만 비우고 상태를 탈퇴('D')로 바꾼다.
* 물리 삭제를 하지 않는 이유는 서비스 Javadoc 참조(참조 무결성·통계 정합).
*/
public int deleteMberPii(MberCryptoVO vo) {
return update("MberCryptoAdminDAO.deleteMberPii", vo);
}
/** 취급자 비밀번호(재인증 대조용). 계정이 없으면 null. */
public String selectHandlerPassword(String emplyrId) {
return (String) selectByPk("MberCryptoAdminDAO.selectHandlerPassword", emplyrId);
}
/** 관리자 접근 이력 적재(사유·재인증 여부 포함). */
public void insertAccessHistory(MberCryptoAccessHistoryVO vo) {
insert("MberCryptoAdminDAO.insertAccessHistory", vo);
}
/** 관리자 접근 이력 목록. param: trgetMberId/handlrId/actionTy/fromDt/toDt/firstIndex/recordCountPerPage */
@SuppressWarnings("unchecked")
public List<MberCryptoAccessHistoryVO> selectAccessHistoryList(Map<String, Object> param) {
return list("MberCryptoAdminDAO.selectAccessHistoryList", param);
}
/** 관리자 접근 이력 총건수. */
public int selectAccessHistoryListCount(Map<String, Object> param) {
Object cnt = selectByPk("MberCryptoAdminDAO.selectAccessHistoryListCount", param);
return cnt == null ? 0 : ((Number) cnt).intValue();
}
}

View File

@ -0,0 +1,83 @@
/**
* Class Name : MberCryptoAdminService.java
* Description : 암호화 회원정보 관리자 CRUD 서비스 — 개인정보보호 기능개선 사업(추가제안 Action4)
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.27 개인정보보호 최초 생성
*
* @since 2026. 08. 27
* @version 1.0
*/
package egovframework.com.cmm.privacy.crypto;
import java.util.List;
import java.util.Map;
/**
* 관리자 전용 회원정보(암호화 항목) CRUD.
*
* <p>제안서 추가제안 Action4(슬라이드16)의 요건을 구현한다 —
* <b>관리자 전용 화면에서 복호화 조회·수정·삭제·신규 등록, 조회·수정·삭제 시 사유 입력 및 재인증 연계.</b></p>
*
* <p><b>사유는 서비스가 강제한다.</b> 화면에서만 막으면 URL 을 직접 부르는 경로가 뚫린다.
* 사유가 비어 있으면 {@link IllegalArgumentException} 으로 작업 자체를 거절한다
* (접속기록 다운로드 사유 필수 처리와 같은 방식).</p>
*
* <p><b>모든 변경은 이력과 한 트랜잭션이다.</b> {@code egovframework.com..*Impl.*} 이
* 트랜잭션 어드바이스 대상이라, 회원정보 변경만 커밋되고 이력이 빠지는 상태가 생기지 않는다.</p>
*/
public interface MberCryptoAdminService {
/** 회원 목록(검색·페이징). 이름 검색은 완전일치(해시)로 처리된다. */
List<MberCryptoVO> selectMberList(MberCryptoSearchVO searchVO) throws Exception;
/** 회원 단건 조회(복호화). 없으면 null. */
MberCryptoVO selectMber(String mberId) throws Exception;
/**
* 복호화 상세 조회 행위를 이력에 남긴다(SFR: 조회 시 사유 입력).
*
* @throws IllegalArgumentException 사유 미입력
*/
void recordDecryptView(String mberId, String handlerId, String handlerIp, String reason) throws Exception;
/**
* 신규 회원 등록. 이름은 암호문·해시로 저장한다.
*
* @return 등록된 회원ID
* @throws IllegalArgumentException 필수값 누락·사유 미입력·회원ID 중복
*/
String registMber(MberCryptoVO vo, String handlerId, String handlerIp, String reason) throws Exception;
/**
* 회원 개인정보 수정. 바뀐 항목명만 이력에 남긴다(값은 남기지 않는다).
*
* @return 갱신 건수(대상 없음 0)
* @throws IllegalArgumentException 사유 미입력·대상 회원 없음
*/
int updateMber(MberCryptoVO vo, String handlerId, String handlerIp, String reason) throws Exception;
/**
* 개인정보 파기 — 행은 남기고 개인정보 항목만 비운 뒤 상태를 탈퇴('D')로 바꾼다.
*
* @return 처리 건수
* @throws IllegalArgumentException 사유 미입력·대상 회원 없음
*/
int deleteMberPii(String mberId, String handlerId, String handlerIp, String reason) throws Exception;
/**
* 취급자 비밀번호 재확인(재인증).
*
* @return 일치하면 true
*/
boolean verifyHandlerPassword(String handlerId, String rawPassword) throws Exception;
/** 관리자 접근 이력 목록. */
List<MberCryptoAccessHistoryVO> selectAccessHistoryList(Map<String, Object> param) throws Exception;
/** 관리자 접근 이력 총건수. */
int selectAccessHistoryListCount(Map<String, Object> param) throws Exception;
}

View File

@ -0,0 +1,317 @@
/**
* Class Name : MberCryptoAdminServiceImpl.java
* Description : 암호화 회원정보 관리자 CRUD 서비스 구현 — 개인정보보호 기능개선 사업(추가제안 Action4)
*
* Modification Information
*
* 수정일 수정자 수정내용
* ------- -------- ---------------------------
* 2026.08.27 개인정보보호 최초 생성
*
* @since 2026. 08. 27
* @version 1.0
*/
package egovframework.com.cmm.privacy.crypto;
import java.util.List;
import java.util.Map;
import javax.annotation.Resource;
import org.springframework.stereotype.Service;
import egovframework.com.utl.sim.service.EgovFileScrty;
import egovframework.rte.fdl.idgnr.EgovIdGnrService;
/**
* {@link MberCryptoAdminService} 구현.
*
* <p><b>암호화 헬퍼를 필드로 들고 있는 이유:</b> {@link MemberDataCipher}·{@link MemberDataHasher}
* 는 생성 시점에 키·pepper 를 읽고 없으면 예외를 던진다. 매 호출마다 새로 만들면 키 미설정을
* 요청 시점에야 알게 되므로, 최초 사용 시 한 번 만들어 재사용한다(둘 다 불변·스레드 안전).</p>
*
* <p><b>왜 지연 생성인가:</b> 필드 초기화로 생성하면 키가 없는 개발 환경에서 이 빈 하나 때문에
* 컨텍스트 기동이 통째로 실패한다. 회원정보 암호화가 적용되지 않은 환경에서도 나머지 관리자
* 기능은 떠야 하므로, 이 화면을 실제로 쓸 때 실패하게 미룬다.</p>
*/
@Service("mberCryptoAdminService")
public class MberCryptoAdminServiceImpl implements MberCryptoAdminService {
/** 파기 후 평문 이름 컬럼(NOT NULL)에 남기는 표식. */
private static final String PII_PURGED_MARK = "(파기)";
/** 신규 등록 시 평문 이름 컬럼(NOT NULL)에 남기는 표식 — 평문은 저장하지 않는다. */
private static final String ENCRYPTED_MARK = "(암호화)";
@Resource(name = "mberCryptoAdminDAO")
private MberCryptoAdminDAO mberCryptoAdminDAO;
/** ESNTL_ID 채번 — 표준 회원관리(EgovMberManageServiceImpl)와 같은 채번기를 쓴다. */
@Resource(name = "egovUsrCnfrmIdGnrService")
private EgovIdGnrService idgenService;
private volatile MemberDataCipher cipher;
private volatile MemberDataHasher hasher;
private MemberDataCipher cipher() {
MemberDataCipher c = cipher;
if (c == null) {
synchronized (this) {
c = cipher;
if (c == null) {
c = new MemberDataCipher();
cipher = c;
}
}
}
return c;
}
private MemberDataHasher hasher() {
MemberDataHasher h = hasher;
if (h == null) {
synchronized (this) {
h = hasher;
if (h == null) {
h = new MemberDataHasher();
hasher = h;
}
}
}
return h;
}
@Override
public List<MberCryptoVO> selectMberList(MberCryptoSearchVO searchVO) throws Exception {
// 이름은 암호문이라 LIKE 가 불가능하다. 입력 이름을 해시로 바꿔 완전일치로만 찾는다.
// 평문 이름은 SQL 에 실리지 않는다(바인딩되는 값은 해시뿐).
if (isNotBlank(searchVO.getMberNm())) {
searchVO.setMberNmHash(hasher().hashName(searchVO.getMberNm()));
} else {
searchVO.setMberNmHash(null);
}
int total = mberCryptoAdminDAO.selectMberListCount(searchVO);
searchVO.setTotalRecordCount(total);
if (total == 0) {
return new java.util.ArrayList<MberCryptoVO>();
}
return mberCryptoAdminDAO.selectMberList(searchVO);
}
@Override
public MberCryptoVO selectMber(String mberId) throws Exception {
if (!isNotBlank(mberId)) {
return null;
}
return mberCryptoAdminDAO.selectMber(mberId.trim());
}
@Override
public void recordDecryptView(String mberId, String handlerId, String handlerIp, String reason)
throws Exception {
String r = requireReason(reason);
writeHistory(MberCryptoAccessHistoryVO.ACTION_SELECT, handlerId, handlerIp, mberId, r, null);
}
@Override
public String registMber(MberCryptoVO vo, String handlerId, String handlerIp, String reason)
throws Exception {
String r = requireReason(reason);
if (vo == null || !isNotBlank(vo.getMberId())) {
throw new IllegalArgumentException("회원ID는 필수입니다.");
}
if (!isNotBlank(vo.getMberNm())) {
throw new IllegalArgumentException("이름은 필수입니다.");
}
if (!isNotBlank(vo.getPassword())) {
throw new IllegalArgumentException("초기 비밀번호는 필수입니다.");
}
String mberId = vo.getMberId().trim();
if (mberCryptoAdminDAO.selectMberIdCount(mberId) > 0) {
throw new IllegalArgumentException("이미 사용 중인 회원ID입니다: " + mberId);
}
vo.setMberId(mberId);
vo.setUniqId(idgenService.getNextStringId());
// 이름은 암호문·해시로만 들어간다. 평문 컬럼(NOT NULL)에는 표식만 남긴다.
vo.setMberNmEnc(cipher().encrypt(vo.getMberNm().trim()));
vo.setMberNmHash(hasher().hashName(vo.getMberNm().trim()));
vo.setCryptoKeyVer(Integer.valueOf(MemberDataCipher.CURRENT_KEY_VER));
vo.setMberNm(ENCRYPTED_MARK);
// 초기 비밀번호는 솔트 해시로 저장한다(평문·무솔트 저장 금지 — SER-003).
vo.setPassword(EgovFileScrty.encryptPasswordWithSalt(vo.getPassword()));
if (!isNotBlank(vo.getMberSttus())) {
vo.setMberSttus("P");
}
normalizeBlanks(vo);
mberCryptoAdminDAO.insertMber(vo);
writeHistory(MberCryptoAccessHistoryVO.ACTION_INSERT, handlerId, handlerIp, mberId, r,
"이름,이메일,휴대폰,주소");
return mberId;
}
@Override
public int updateMber(MberCryptoVO vo, String handlerId, String handlerIp, String reason)
throws Exception {
String r = requireReason(reason);
if (vo == null || !isNotBlank(vo.getMberId())) {
throw new IllegalArgumentException("회원ID는 필수입니다.");
}
if (!isNotBlank(vo.getMberNm())) {
throw new IllegalArgumentException("이름은 필수입니다.");
}
String mberId = vo.getMberId().trim();
MberCryptoVO before = mberCryptoAdminDAO.selectMber(mberId);
if (before == null) {
throw new IllegalArgumentException("대상 회원을 찾을 수 없습니다: " + mberId);
}
// 바뀐 항목명만 뽑는다 — 값은 이력에 남기지 않는다(이력이 평문 저장소가 되면 안 된다).
String changed = diffItems(before, vo);
vo.setMberId(mberId);
vo.setMberNmEnc(cipher().encrypt(vo.getMberNm().trim()));
vo.setMberNmHash(hasher().hashName(vo.getMberNm().trim()));
vo.setCryptoKeyVer(Integer.valueOf(MemberDataCipher.CURRENT_KEY_VER));
if (!isNotBlank(vo.getMberSttus())) {
vo.setMberSttus(before.getMberSttus());
}
normalizeBlanks(vo);
int cnt = mberCryptoAdminDAO.updateMber(vo);
if (cnt > 0) {
writeHistory(MberCryptoAccessHistoryVO.ACTION_UPDATE, handlerId, handlerIp, mberId, r,
changed.length() == 0 ? "(변경 항목 없음)" : changed);
}
return cnt;
}
@Override
public int deleteMberPii(String mberId, String handlerId, String handlerIp, String reason)
throws Exception {
String r = requireReason(reason);
if (!isNotBlank(mberId)) {
throw new IllegalArgumentException("회원ID는 필수입니다.");
}
String id = mberId.trim();
MberCryptoVO before = mberCryptoAdminDAO.selectMber(id);
if (before == null) {
throw new IllegalArgumentException("대상 회원을 찾을 수 없습니다: " + id);
}
MberCryptoVO vo = new MberCryptoVO();
vo.setMberId(id);
vo.setMberNm(PII_PURGED_MARK);
int cnt = mberCryptoAdminDAO.deleteMberPii(vo);
if (cnt > 0) {
writeHistory(MberCryptoAccessHistoryVO.ACTION_DELETE, handlerId, handlerIp, id, r,
"이름,이메일,휴대폰,주소,생년월일,성별");
}
return cnt;
}
@Override
public boolean verifyHandlerPassword(String handlerId, String rawPassword) throws Exception {
if (!isNotBlank(handlerId) || !isNotBlank(rawPassword)) {
return false;
}
String stored = mberCryptoAdminDAO.selectHandlerPassword(handlerId.trim());
if (stored == null || stored.length() == 0) {
// 계정이 없거나 비밀번호가 비어 있으면 통과시키지 않는다.
// (존재 여부를 응답으로 구분해 주지 않기 위해 호출부는 실패 문구를 한 가지로 쓴다)
return false;
}
// 솔트 유무를 자동 판별해 대조한다(무솔트 레거시 해시도 그대로 검증된다).
return EgovFileScrty.matches(rawPassword, stored);
}
@Override
public List<MberCryptoAccessHistoryVO> selectAccessHistoryList(Map<String, Object> param) throws Exception {
return mberCryptoAdminDAO.selectAccessHistoryList(param);
}
@Override
public int selectAccessHistoryListCount(Map<String, Object> 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 을 빈 문자열로 바꾼다.
*
* <p>{@code COMTNGNRLMBER} 는 ZIP·ADRES·MBTLNUM 등이 NOT NULL 이라, 화면에서 비워 둔 값이
* 그대로 null 로 내려가면 INSERT/UPDATE 가 제약 위반으로 실패한다. SQL 쪽에서 {@code IFNULL}
* 로 감싸는 방법도 있으나, 그러면 "이 컬럼은 비어도 되는가"의 답이 SQL 에 흩어진다.
* 값의 형태를 맞추는 일은 서비스가 한 곳에서 한다.</p>
*/
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;
}
}

View File

@ -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;
/**
* 관리자 회원정보 화면의 <b>재인증</b> 상태를 세션에 표시하고 유효시간을 판정한다.
*
* <p>제안서 슬라이드16의 "조회·수정·삭제 시 사유 입력 및 <b>재인증</b> 연계" 요건이다.
* 로그인 자체가 아니라 <u>이 화면에 들어올 때 한 번 더</u> 본인을 확인한다 — 자리를 비운 사이
* 열려 있던 세션으로 평문 개인정보를 훑는 경로를 끊는 것이 목적이다.</p>
*
* <p><b>왜 로그인 2차 인증(SECOND_FACTOR_DONE)을 재사용하지 않는가:</b> 2차 인증은 로그인 시점에
* 한 번 통과하면 세션 내내 유지된다(30분 자동 로그아웃까지). 그 표식을 그대로 쓰면 "다시 확인"이
* 되지 못한다. 또 OTP 를 등록하지 않은 취급자는 2차 인증을 통과한 적이 없어 이 화면에 영영
* 들어오지 못한다. 그래서 <b>본인 비밀번호 재확인</b>이라는 독립 표식을 둔다.</p>
*
* <p><b>유효시간을 짧게 두는 이유:</b> 한 번 통과하면 세션이 끝날 때까지 열어 두는 방식은
* 사실상 재인증이 없는 것과 같다. {@link #TTL_MILLIS}(10분)마다 다시 묻는다. 작업 중 만료되면
* 재인증 화면을 거쳐 원래 위치로 돌아온다.</p>
*
* <p><b>연장하지 않는다.</b> 통과 시각을 갱신하지 않고 최초 통과 시각을 그대로 두므로, 화면을
* 계속 쓰더라도 10분마다 반드시 다시 확인한다(슬라이딩 만료가 아니다).</p>
*/
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;
}
}

View File

@ -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;
/**
* 관리자 회원정보 검색조건 + 페이징.
*
* <p><b>이름은 완전일치만 된다.</b> 이름은 랜덤 IV AES-GCM 으로 저장돼 같은 이름도 매번 다른
* 암호문이라 {@code LIKE '%홍%'} 이 원리적으로 불가능하다. 대신 검색 전용 HMAC 해시 컬럼
* ({@code MBER_NM_HASH})에 인덱스가 걸려 있어 <u>정확히 같은 이름</u>은 빠르게 찾는다.
* 서비스가 입력 이름을 {@link MemberDataHasher#hashName(String)} 로 바꿔 {@link #setMberNmHash}
* 에 실어 주면 SQL 은 해시만 비교한다 — 평문 이름은 SQL 에 등장하지 않는다.</p>
*
* <p>이메일·휴대폰·회원ID 는 아직 평문 컬럼이라 전방일치 검색이 가능하다
* (암호화 대상 확대는 후속 단계 — 확대되면 이 필드들도 해시 검색으로 옮겨야 한다).</p>
*/
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();
}
}

View File

@ -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} 한 행.
*
* <p><b>{@code mberNm} 에 무엇이 들어있는가는 단계에 따라 다르다.</b> DAO 가 조회하는 컬럼은
* {@code MBER_NM_ENC}(암호문)이고, DAO 반환 직전 {@link MemberNmDecryptSupport} 가 같은 자리에
* 평문을 되돌려 쓴다 — 서비스·화면은 항상 평문으로 본다. 저장(등록·수정) 방향은 서비스가
* 평문을 받아 {@link MemberDataCipher#encrypt(String)}·{@link MemberDataHasher#hashName(String)}
* 로 {@code mberNmEnc}/{@code mberNmHash} 를 채운 뒤 DAO 에 넘긴다.</p>
*
* <p><b>평문 컬럼 {@code MBER_NM} 은 이 화면이 건드리지 않는다.</b> 암호화 이관 당시 롤백 창구로
* 남겨둔 컬럼이라, 관리자 화면이 여기에 새 평문을 쓰기 시작하면 "이관이 끝나면 평문 컬럼을 지운다"는
* 계획이 조용히 무너진다. 신규 등록 시에도 평문 자리에는 마스킹 표식만 넣는다
* ({@code MBER_NM} 이 NOT NULL 이라 빈 값을 넣을 수 없기 때문이다).</p>
*/
public class MberCryptoVO implements Serializable {
private static final long serialVersionUID = 1L;
/** 회원ID(PK). */
private String mberId;
/** 내부 식별자 ESNTL_ID. */
private String uniqId;
/**
* 이름 — 조회 결과에서는 <b>복호화된 평문</b>, 등록·수정 입력에서도 평문.
* (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; }
}

View File

@ -152,4 +152,7 @@
<!-- 개인정보보호(SER-001 #24) - 로그인 실패 계정잠금 예외 화이트리스트(LOGIN_LOCK_EXEMPT) -->
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyLoginLockExempt_SQL_Mysql.xml"/>
<!-- 개인정보보호(추가제안 Action4) - 관리자 전용 회원정보 CRUD + 접근이력(MBER_CRYPTO_ACCESS_HISTORY) -->
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyMberCryptoAdmin_SQL_Mysql.xml"/>
</sqlMapConfig>

View File

@ -409,4 +409,45 @@
]]>
</select>
<!-- ================================================================
[SFR-004 / DBR-001 / SER-002-2] 접속기록 파기·증설 이력 조회 (화면 /mnt/sec/purgeHistory).
파기 배치(#37)가 남긴 PDAL_PURGE_HISTORY 를 유형·기간으로 조회한다.
이 표가 증빙인 이유: 접속기록 본문은 UPDATE/DELETE statement 자체가 없고 삭제는 오직
파티션 DROP 으로만 일어난다. 따라서 "보관기간 경과분을 실제로 파기했다"와 "그 외 경로로는
지운 적이 없다"를 동시에 보이는 유일한 근거가 이 이력이다.
조회 전용 — 화면에서도 수정·삭제를 제공하지 않는다.
================================================================ -->
<select id="PersonalDataAccessLogDAO.selectPurgeHistoryList"
parameterClass="egovframework.com.cmm.privacy.auditlog.AccessLogPurgeHistorySearchVO"
resultClass="egovframework.com.cmm.privacy.auditlog.AccessLogPurgeHistoryVO">
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
<dynamic prepend="WHERE">
<!-- 판정(isNotEmpty)은 원본 필드로, 바인딩은 종료일 보정된 파생 getter 로 하는 짝을 지킬 것. -->
<isNotEmpty prepend="AND" property="purgeType"> PURGE_TYPE = #purgeType# </isNotEmpty>
<isNotEmpty prepend="AND" property="fromDt"> PURGE_DT &gt;= #fromDtParam# </isNotEmpty>
<isNotEmpty prepend="AND" property="toDt"> PURGE_DT &lt;= #toDtParam# </isNotEmpty>
</dynamic>
ORDER BY PURGE_ID DESC
LIMIT #firstIndex#, #recordCountPerPage#
</select>
<select id="PersonalDataAccessLogDAO.selectPurgeHistoryListCount"
parameterClass="egovframework.com.cmm.privacy.auditlog.AccessLogPurgeHistorySearchVO"
resultClass="java.lang.Integer">
SELECT COUNT(1)
FROM PDAL_PURGE_HISTORY
<dynamic prepend="WHERE">
<isNotEmpty prepend="AND" property="purgeType"> PURGE_TYPE = #purgeType# </isNotEmpty>
<isNotEmpty prepend="AND" property="fromDt"> PURGE_DT &gt;= #fromDtParam# </isNotEmpty>
<isNotEmpty prepend="AND" property="toDt"> PURGE_DT &lt;= #toDtParam# </isNotEmpty>
</dynamic>
</select>
</sqlMap>

View File

@ -332,4 +332,47 @@
]]>
</select>
<!-- ================================================================
[SFR-004 / DBR-001 / SER-002-2] 접속기록 파기·증설 이력 조회 (화면 /mnt/sec/purgeHistory).
MySQL 쌍(PrivacyAccessLog_SQL_Mysql.xml)과 동일 계약. 페이징만 방언이 다르다
(MySQL LIMIT offset,count ↔ Oracle ROWNUM 이중 인라인뷰).
================================================================ -->
<select id="PersonalDataAccessLogDAO.selectPurgeHistoryList"
parameterClass="egovframework.com.cmm.privacy.auditlog.AccessLogPurgeHistorySearchVO"
resultClass="egovframework.com.cmm.privacy.auditlog.AccessLogPurgeHistoryVO">
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
<dynamic prepend="WHERE">
<isNotEmpty prepend="AND" property="purgeType"> PURGE_TYPE = #purgeType# </isNotEmpty>
<isNotEmpty prepend="AND" property="fromDt"> PURGE_DT &gt;= TO_DATE(#fromDtParam#, 'YYYY-MM-DD HH24:MI:SS') </isNotEmpty>
<isNotEmpty prepend="AND" property="toDt"> PURGE_DT &lt;= TO_DATE(#toDtParam#, 'YYYY-MM-DD HH24:MI:SS') </isNotEmpty>
</dynamic>
ORDER BY PURGE_ID DESC
) a
WHERE ROWNUM &lt;= #firstIndex# + #recordCountPerPage#
)
WHERE rnum &gt; #firstIndex#
</select>
<select id="PersonalDataAccessLogDAO.selectPurgeHistoryListCount"
parameterClass="egovframework.com.cmm.privacy.auditlog.AccessLogPurgeHistorySearchVO"
resultClass="java.lang.Integer">
SELECT COUNT(1)
FROM PDAL_PURGE_HISTORY
<dynamic prepend="WHERE">
<isNotEmpty prepend="AND" property="purgeType"> PURGE_TYPE = #purgeType# </isNotEmpty>
<isNotEmpty prepend="AND" property="fromDt"> PURGE_DT &gt;= TO_DATE(#fromDtParam#, 'YYYY-MM-DD HH24:MI:SS') </isNotEmpty>
<isNotEmpty prepend="AND" property="toDt"> PURGE_DT &lt;= TO_DATE(#toDtParam#, 'YYYY-MM-DD HH24:MI:SS') </isNotEmpty>
</dynamic>
</select>
</sqlMap>

View File

@ -0,0 +1,256 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
<!--
개인정보보호 기능개선 사업 (추가제안 Action4 / 제안서 슬라이드16)
관리자 전용 회원정보 CRUD + 접근 이력.
[이름 컬럼 취급 규칙 — 이 파일 전체에 적용]
· 조회: MBER_NM_ENC 를 mberNm 별칭으로 가져온다. 복호화는 DAO 가 반환 직전에 한다
(MberCryptoAdminDAO → MemberNmDecryptSupport). SQL 에는 평문이 없다.
· 저장: mberNmEnc(암호문) + mberNmHash(HMAC) 만 쓴다.
· 평문 컬럼 MBER_NM 은 이 화면이 새 평문을 쓰지 않는다. NOT NULL 이라 비울 수 없어
표식 문자열('(암호화)' / '(파기)')만 넣는다. 암호화 이관의 롤백 창구로 남겨둔
컬럼이라, 여기에 평문을 다시 쓰기 시작하면 평문 컬럼 제거 계획이 무너진다.
[검색]
· 이름: 랜덤 IV AES-GCM 이라 LIKE 불가 → MBER_NM_HASH 완전일치(IX_GNRLMBER_NM_HASH 인덱스).
· 회원ID/이메일/휴대폰: 아직 평문 컬럼이라 전방일치(LIKE ... ESCAPE '!').
판정(isNotEmpty)은 원본 필드로, 바인딩은 파생 getter 로 하는 짝을 지킬 것.
[Oracle 쌍] PrivacyMberCryptoAdmin_SQL_Oracle.xml 과 statement id·파라미터 계약이 같아야 한다.
-->
<sqlMap namespace="MberCryptoAdminDAO">
<!-- ================================================================
목록 조회 (마스킹은 화면 계층 담당 — SQL 은 원본을 그대로 준다)
================================================================ -->
<select id="MberCryptoAdminDAO.selectMberList"
parameterClass="egovframework.com.cmm.privacy.crypto.MberCryptoSearchVO"
resultClass="egovframework.com.cmm.privacy.crypto.MberCryptoVO">
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
<dynamic prepend="WHERE">
<isNotEmpty prepend="AND" property="mberId"> MBER_ID LIKE #mberIdLike# ESCAPE '!' </isNotEmpty>
<isNotEmpty prepend="AND" property="mberNmHash"> MBER_NM_HASH = #mberNmHash# </isNotEmpty>
<isNotEmpty prepend="AND" property="mberEmailAdres"> MBER_EMAIL_ADRES LIKE #mberEmailAdresLike# ESCAPE '!' </isNotEmpty>
<isNotEmpty prepend="AND" property="moblphonNo"> MBTLNUM LIKE #moblphonNoLike# ESCAPE '!' </isNotEmpty>
<isNotEmpty prepend="AND" property="mberSttus"> MBER_STTUS = #mberSttus# </isNotEmpty>
</dynamic>
ORDER BY SBSCRB_DE DESC, MBER_ID DESC
LIMIT #firstIndex#, #recordCountPerPage#
</select>
<select id="MberCryptoAdminDAO.selectMberListCount"
parameterClass="egovframework.com.cmm.privacy.crypto.MberCryptoSearchVO"
resultClass="java.lang.Integer">
SELECT COUNT(1)
FROM COMTNGNRLMBER
<dynamic prepend="WHERE">
<isNotEmpty prepend="AND" property="mberId"> MBER_ID LIKE #mberIdLike# ESCAPE '!' </isNotEmpty>
<isNotEmpty prepend="AND" property="mberNmHash"> MBER_NM_HASH = #mberNmHash# </isNotEmpty>
<isNotEmpty prepend="AND" property="mberEmailAdres"> MBER_EMAIL_ADRES LIKE #mberEmailAdresLike# ESCAPE '!' </isNotEmpty>
<isNotEmpty prepend="AND" property="moblphonNo"> MBTLNUM LIKE #moblphonNoLike# ESCAPE '!' </isNotEmpty>
<isNotEmpty prepend="AND" property="mberSttus"> MBER_STTUS = #mberSttus# </isNotEmpty>
</dynamic>
</select>
<!-- ================================================================
단건 조회 / 중복 확인
================================================================ -->
<select id="MberCryptoAdminDAO.selectMber"
parameterClass="java.lang.String"
resultClass="egovframework.com.cmm.privacy.crypto.MberCryptoVO">
<![CDATA[
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
WHERE MBER_ID = #mberId#
]]>
</select>
<select id="MberCryptoAdminDAO.selectMberIdCount"
parameterClass="java.lang.String" resultClass="java.lang.Integer">
<![CDATA[
SELECT COUNT(1) FROM COMTNGNRLMBER WHERE MBER_ID = #mberId#
]]>
</select>
<!-- ================================================================
신규 등록
NOT NULL 제약(ZIP/ADRES/AREA_NO/END_TELNO/MBTLNUM/MIDDLE_TELNO)이 있어
입력하지 않은 항목은 빈 문자열로 채운다. 이 화면은 개인정보 항목만 다루므로
활동지역·재능 등 부가 컬럼은 건드리지 않는다(기본값/빈값 유지).
================================================================ -->
<insert id="MberCryptoAdminDAO.insertMber"
parameterClass="egovframework.com.cmm.privacy.crypto.MberCryptoVO">
<![CDATA[
INSERT INTO COMTNGNRLMBER (
ESNTL_ID, MBER_ID, MBER_NM, MBER_NM_ENC, MBER_NM_HASH, CRYPTO_KEY_VER,
PASSWORD, SEXDSTN_CODE, ZIP, ADRES, AREA_NO, MBER_STTUS, DETAIL_ADRES,
END_TELNO, MBTLNUM, MBER_EMAIL_ADRES, MIDDLE_TELNO, SBSCRB_DE
) VALUES (
#uniqId#,
#mberId#,
#mberNm#,
#mberNmEnc#,
#mberNmHash#,
#cryptoKeyVer:TINYINT#,
#password#,
#sexdstnCode:VARCHAR#,
#zip:VARCHAR#,
#adres:VARCHAR#,
'',
#mberSttus#,
#detailAdres:VARCHAR#,
'',
#moblphonNo:VARCHAR#,
#mberEmailAdres:VARCHAR#,
'',
NOW()
)
]]>
</insert>
<!-- ================================================================
수정 — 개인정보 항목만. 비밀번호·가입일·식별자는 이 화면에서 바꾸지 않는다.
================================================================ -->
<update id="MberCryptoAdminDAO.updateMber"
parameterClass="egovframework.com.cmm.privacy.crypto.MberCryptoVO">
<![CDATA[
UPDATE COMTNGNRLMBER
SET MBER_NM_ENC = #mberNmEnc#,
MBER_NM_HASH = #mberNmHash#,
CRYPTO_KEY_VER = #cryptoKeyVer:TINYINT#,
MBER_EMAIL_ADRES = #mberEmailAdres:VARCHAR#,
MBTLNUM = #moblphonNo:VARCHAR#,
ZIP = #zip:VARCHAR#,
ADRES = #adres:VARCHAR#,
DETAIL_ADRES = #detailAdres:VARCHAR#,
SEXDSTN_CODE = #sexdstnCode:VARCHAR#,
MBER_STTUS = #mberSttus#
WHERE MBER_ID = #mberId#
]]>
</update>
<!-- ================================================================
개인정보 파기 — 행을 지우지 않고 개인정보 항목만 비운다.
[왜 DELETE 가 아닌가] MBER_ID·ESNTL_ID 는 신청·활동확인·마일리지 등 여러 표가
참조한다. 행을 지우면 그 기록들이 주인 없는 값을 가리키게 되고, 통계·정산이 어긋난다.
개인정보 보호의 목적은 "식별할 수 있는 정보를 없애는 것"이지 "행을 지우는 것"이 아니므로,
식별 항목만 비우고 상태를 탈퇴로 바꾼다.
[기존 탈퇴(mberManageDAO.updateDeleteMber_S)와의 차이] 기존 탈퇴 구문은 이름
(MBER_NM/MBER_NM_ENC/MBER_NM_HASH)을 지우지 않아 탈퇴 후에도 이름이 남는다.
이 구문은 이름과 암호문·해시까지 함께 파기한다.
================================================================ -->
<update id="MberCryptoAdminDAO.deleteMberPii"
parameterClass="egovframework.com.cmm.privacy.crypto.MberCryptoVO">
<![CDATA[
UPDATE COMTNGNRLMBER
SET MBER_NM = #mberNm#,
MBER_NM_ENC = NULL,
MBER_NM_HASH = NULL,
CRYPTO_KEY_VER = NULL,
IHIDNUM = NULL,
MBER_EMAIL_ADRES = NULL,
MBTLNUM = '',
MBER_FXNUM = '',
ZIP = '',
ADRES = '',
DETAIL_ADRES = '',
AREA_NO = '',
END_TELNO = '',
MIDDLE_TELNO = '',
SEXDSTN_CODE = '',
MBER_STTUS = 'D'
WHERE MBER_ID = #mberId#
]]>
</update>
<!-- ================================================================
재인증 — 취급자 본인 비밀번호 대조용 저장값 조회.
반환값은 해시 문자열이며 화면으로 나가지 않는다(서비스에서 대조만 한다).
================================================================ -->
<select id="MberCryptoAdminDAO.selectHandlerPassword"
parameterClass="java.lang.String" resultClass="java.lang.String">
<![CDATA[
SELECT PASSWORD
FROM COMTNEMPLYRINFO
WHERE EMPLYR_ID = #emplyrId#
]]>
</select>
<!-- ================================================================
관리자 접근 이력 (사유·재인증 여부). 개인정보 본문은 적재하지 않는다.
조회·적재만 두고 UPDATE/DELETE 는 정의하지 않는다 — 소명 근거라 사후 변경 경로를 만들지 않는다.
================================================================ -->
<insert id="MberCryptoAdminDAO.insertAccessHistory"
parameterClass="egovframework.com.cmm.privacy.crypto.MberCryptoAccessHistoryVO">
<![CDATA[
INSERT INTO MBER_CRYPTO_ACCESS_HISTORY (
HANDLR_ID, ACTION_TY, TRGET_MBER_ID, ACCESS_REASON,
CHANGED_ITEMS, HANDLR_IP, REAUTH_YN, ACCESS_DT
) VALUES (
#handlrId#, #actionTy#, #trgetMberId:VARCHAR#, #accessReason#,
#changedItems:VARCHAR#, #handlrIp:VARCHAR#, #reauthYn#, NOW()
)
]]>
</insert>
<select id="MberCryptoAdminDAO.selectAccessHistoryList"
parameterClass="java.util.HashMap"
resultClass="egovframework.com.cmm.privacy.crypto.MberCryptoAccessHistoryVO">
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
<dynamic prepend="WHERE">
<isNotEmpty prepend="AND" property="trgetMberId"> TRGET_MBER_ID = #trgetMberId# </isNotEmpty>
<isNotEmpty prepend="AND" property="handlrId"> HANDLR_ID = #handlrId# </isNotEmpty>
<isNotEmpty prepend="AND" property="actionTy"> ACTION_TY = #actionTy# </isNotEmpty>
</dynamic>
ORDER BY HIST_ID DESC
LIMIT #firstIndex#, #recordCountPerPage#
</select>
<select id="MberCryptoAdminDAO.selectAccessHistoryListCount"
parameterClass="java.util.HashMap" resultClass="java.lang.Integer">
SELECT COUNT(1)
FROM MBER_CRYPTO_ACCESS_HISTORY
<dynamic prepend="WHERE">
<isNotEmpty prepend="AND" property="trgetMberId"> TRGET_MBER_ID = #trgetMberId# </isNotEmpty>
<isNotEmpty prepend="AND" property="handlrId"> HANDLR_ID = #handlrId# </isNotEmpty>
<isNotEmpty prepend="AND" property="actionTy"> ACTION_TY = #actionTy# </isNotEmpty>
</dynamic>
</select>
</sqlMap>

View File

@ -0,0 +1,232 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
<!--
개인정보보호 기능개선 사업 (추가제안 Action4 / 제안서 슬라이드16)
관리자 전용 회원정보 CRUD + 접근 이력 — Oracle 쌍.
MySQL 쌍(PrivacyMberCryptoAdmin_SQL_Mysql.xml)과 statement id·파라미터 계약이 동일하다.
방언 차이만 다르다:
· 페이징 : LIMIT offset,count → ROWNUM 이중 인라인뷰
· 현재시각: NOW() → SYSDATE
· 날짜포맷: 그대로 반환 → TO_CHAR(..., 'YYYY-MM-DD HH24:MI:SS')
운영 DB 는 MySQL(csv)이며 이 파일은 방언 쌍 유지 관례에 따른 것이다.
이름 컬럼 취급 규칙·검색 제약은 MySQL 쌍 주석 참조.
-->
<sqlMap namespace="MberCryptoAdminDAO">
<select id="MberCryptoAdminDAO.selectMberList"
parameterClass="egovframework.com.cmm.privacy.crypto.MberCryptoSearchVO"
resultClass="egovframework.com.cmm.privacy.crypto.MberCryptoVO">
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
<dynamic prepend="WHERE">
<isNotEmpty prepend="AND" property="mberId"> MBER_ID LIKE #mberIdLike# ESCAPE '!' </isNotEmpty>
<isNotEmpty prepend="AND" property="mberNmHash"> MBER_NM_HASH = #mberNmHash# </isNotEmpty>
<isNotEmpty prepend="AND" property="mberEmailAdres"> MBER_EMAIL_ADRES LIKE #mberEmailAdresLike# ESCAPE '!' </isNotEmpty>
<isNotEmpty prepend="AND" property="moblphonNo"> MBTLNUM LIKE #moblphonNoLike# ESCAPE '!' </isNotEmpty>
<isNotEmpty prepend="AND" property="mberSttus"> MBER_STTUS = #mberSttus# </isNotEmpty>
</dynamic>
ORDER BY SBSCRB_DE DESC, MBER_ID DESC
) a
WHERE ROWNUM &lt;= #firstIndex# + #recordCountPerPage#
)
WHERE rnum &gt; #firstIndex#
</select>
<select id="MberCryptoAdminDAO.selectMberListCount"
parameterClass="egovframework.com.cmm.privacy.crypto.MberCryptoSearchVO"
resultClass="java.lang.Integer">
SELECT COUNT(1)
FROM COMTNGNRLMBER
<dynamic prepend="WHERE">
<isNotEmpty prepend="AND" property="mberId"> MBER_ID LIKE #mberIdLike# ESCAPE '!' </isNotEmpty>
<isNotEmpty prepend="AND" property="mberNmHash"> MBER_NM_HASH = #mberNmHash# </isNotEmpty>
<isNotEmpty prepend="AND" property="mberEmailAdres"> MBER_EMAIL_ADRES LIKE #mberEmailAdresLike# ESCAPE '!' </isNotEmpty>
<isNotEmpty prepend="AND" property="moblphonNo"> MBTLNUM LIKE #moblphonNoLike# ESCAPE '!' </isNotEmpty>
<isNotEmpty prepend="AND" property="mberSttus"> MBER_STTUS = #mberSttus# </isNotEmpty>
</dynamic>
</select>
<select id="MberCryptoAdminDAO.selectMber"
parameterClass="java.lang.String"
resultClass="egovframework.com.cmm.privacy.crypto.MberCryptoVO">
<![CDATA[
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
WHERE MBER_ID = #mberId#
]]>
</select>
<select id="MberCryptoAdminDAO.selectMberIdCount"
parameterClass="java.lang.String" resultClass="java.lang.Integer">
<![CDATA[
SELECT COUNT(1) FROM COMTNGNRLMBER WHERE MBER_ID = #mberId#
]]>
</select>
<insert id="MberCryptoAdminDAO.insertMber"
parameterClass="egovframework.com.cmm.privacy.crypto.MberCryptoVO">
<![CDATA[
INSERT INTO COMTNGNRLMBER (
ESNTL_ID, MBER_ID, MBER_NM, MBER_NM_ENC, MBER_NM_HASH, CRYPTO_KEY_VER,
PASSWORD, SEXDSTN_CODE, ZIP, ADRES, AREA_NO, MBER_STTUS, DETAIL_ADRES,
END_TELNO, MBTLNUM, MBER_EMAIL_ADRES, MIDDLE_TELNO, SBSCRB_DE
) VALUES (
#uniqId#,
#mberId#,
#mberNm#,
#mberNmEnc#,
#mberNmHash#,
#cryptoKeyVer:NUMERIC#,
#password#,
#sexdstnCode:VARCHAR#,
#zip:VARCHAR#,
#adres:VARCHAR#,
'',
#mberSttus#,
#detailAdres:VARCHAR#,
'',
#moblphonNo:VARCHAR#,
#mberEmailAdres:VARCHAR#,
'',
SYSDATE
)
]]>
</insert>
<update id="MberCryptoAdminDAO.updateMber"
parameterClass="egovframework.com.cmm.privacy.crypto.MberCryptoVO">
<![CDATA[
UPDATE COMTNGNRLMBER
SET MBER_NM_ENC = #mberNmEnc#,
MBER_NM_HASH = #mberNmHash#,
CRYPTO_KEY_VER = #cryptoKeyVer:NUMERIC#,
MBER_EMAIL_ADRES = #mberEmailAdres:VARCHAR#,
MBTLNUM = #moblphonNo:VARCHAR#,
ZIP = #zip:VARCHAR#,
ADRES = #adres:VARCHAR#,
DETAIL_ADRES = #detailAdres:VARCHAR#,
SEXDSTN_CODE = #sexdstnCode:VARCHAR#,
MBER_STTUS = #mberSttus#
WHERE MBER_ID = #mberId#
]]>
</update>
<update id="MberCryptoAdminDAO.deleteMberPii"
parameterClass="egovframework.com.cmm.privacy.crypto.MberCryptoVO">
<![CDATA[
UPDATE COMTNGNRLMBER
SET MBER_NM = #mberNm#,
MBER_NM_ENC = NULL,
MBER_NM_HASH = NULL,
CRYPTO_KEY_VER = NULL,
IHIDNUM = NULL,
MBER_EMAIL_ADRES = NULL,
MBTLNUM = '',
MBER_FXNUM = '',
ZIP = '',
ADRES = '',
DETAIL_ADRES = '',
AREA_NO = '',
END_TELNO = '',
MIDDLE_TELNO = '',
SEXDSTN_CODE = '',
MBER_STTUS = 'D'
WHERE MBER_ID = #mberId#
]]>
</update>
<select id="MberCryptoAdminDAO.selectHandlerPassword"
parameterClass="java.lang.String" resultClass="java.lang.String">
<![CDATA[
SELECT PASSWORD
FROM COMTNEMPLYRINFO
WHERE EMPLYR_ID = #emplyrId#
]]>
</select>
<insert id="MberCryptoAdminDAO.insertAccessHistory"
parameterClass="egovframework.com.cmm.privacy.crypto.MberCryptoAccessHistoryVO">
<![CDATA[
INSERT INTO MBER_CRYPTO_ACCESS_HISTORY (
HIST_ID, HANDLR_ID, ACTION_TY, TRGET_MBER_ID, ACCESS_REASON,
CHANGED_ITEMS, HANDLR_IP, REAUTH_YN, ACCESS_DT
) VALUES (
MBER_CRYPTO_ACCESS_HIST_SEQ.NEXTVAL,
#handlrId#, #actionTy#, #trgetMberId:VARCHAR#, #accessReason#,
#changedItems:VARCHAR#, #handlrIp:VARCHAR#, #reauthYn#, SYSDATE
)
]]>
</insert>
<select id="MberCryptoAdminDAO.selectAccessHistoryList"
parameterClass="java.util.HashMap"
resultClass="egovframework.com.cmm.privacy.crypto.MberCryptoAccessHistoryVO">
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
<dynamic prepend="WHERE">
<isNotEmpty prepend="AND" property="trgetMberId"> TRGET_MBER_ID = #trgetMberId# </isNotEmpty>
<isNotEmpty prepend="AND" property="handlrId"> HANDLR_ID = #handlrId# </isNotEmpty>
<isNotEmpty prepend="AND" property="actionTy"> ACTION_TY = #actionTy# </isNotEmpty>
</dynamic>
ORDER BY HIST_ID DESC
) a
WHERE ROWNUM &lt;= #firstIndex# + #recordCountPerPage#
)
WHERE rnum &gt; #firstIndex#
</select>
<select id="MberCryptoAdminDAO.selectAccessHistoryListCount"
parameterClass="java.util.HashMap" resultClass="java.lang.Integer">
SELECT COUNT(1)
FROM MBER_CRYPTO_ACCESS_HISTORY
<dynamic prepend="WHERE">
<isNotEmpty prepend="AND" property="trgetMberId"> TRGET_MBER_ID = #trgetMberId# </isNotEmpty>
<isNotEmpty prepend="AND" property="handlrId"> HANDLR_ID = #handlrId# </isNotEmpty>
<isNotEmpty prepend="AND" property="actionTy"> ACTION_TY = #actionTy# </isNotEmpty>
</dynamic>
</select>
</sqlMap>

View File

@ -94,10 +94,10 @@
</li>
</c:if>
<c:if test="${menuUpperVo2.menuNo eq '9100030'}">
<li <c:if test="${param.page eq '9100031' || param.page eq '9100034' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100047'}">class="on"</c:if>><a href="#none">${menuUpperVo2.menuNm}</a>
<li <c:if test="${param.page eq '9100031' || param.page eq '9100034' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100047' || param.page eq '9100048' || param.page eq '9100049' || param.page eq '9100050' || param.page eq '9100051'}">class="on"</c:if>><a href="#none">${menuUpperVo2.menuNm}</a>
<c:forEach items="${list_menulist}" var="menuUpperVo3">
<c:if test="${menuUpperVo3.menuNo eq '9100030'}">
<ul <c:if test="${param.page eq '9100031' || param.page eq '9100034' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100047'}">style="display: block;"</c:if>>
<ul <c:if test="${param.page eq '9100031' || param.page eq '9100034' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100047' || param.page eq '9100048' || param.page eq '9100049' || param.page eq '9100050' || param.page eq '9100051'}">style="display: block;"</c:if>>
<c:forEach items="${list_menulist}" var="menuUpperVo4">
<c:choose>
<c:when test="${menuUpperVo4.menuNo eq '9100031'}">
@ -131,6 +131,22 @@
<c:when test="${menuUpperVo4.menuNo eq '9100047'}">
<li <c:if test="${param.page eq '9100047'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
</c:when>
<%-- [개인정보/SFR-004-7 #48] 접속기록 정기점검 리포트(점검결과 입력·보관) --%>
<c:when test="${menuUpperVo4.menuNo eq '9100048'}">
<li <c:if test="${param.page eq '9100048'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
</c:when>
<%-- [개인정보/과업내용 다-① #49] 비정상접근 탐지 5유형 --%>
<c:when test="${menuUpperVo4.menuNo eq '9100049'}">
<li <c:if test="${param.page eq '9100049'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
</c:when>
<%-- [개인정보/SER-002-2] 접속기록 파기이력(파티션 DROP/ADD 증빙) --%>
<c:when test="${menuUpperVo4.menuNo eq '9100050'}">
<li <c:if test="${param.page eq '9100050'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
</c:when>
<%-- [개인정보/추가제안 Action4] 회원정보 암호화 관리(복호화 CRUD·사유·재인증) --%>
<c:when test="${menuUpperVo4.menuNo eq '9100051'}">
<li <c:if test="${param.page eq '9100051'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
</c:when>
</c:choose>
</c:forEach>
</ul>

View File

@ -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 --%>
<c:import url = "/mnt/include/head.do" /><%--// 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)으로 조건을 넘겨 바로 원본 기록을 확인할 수 있게 연결한다
--%>
<c:set var="PATH" value="/mnt" />
<script type="text/javascript">
<!--
function fn_search() {
document.frm.method = "get";
document.frm.action = "<c:url value='${PATH}/sec/abnormal/listView.do' />";
document.frm.submit();
}
/* 최근 N일로 기간을 다시 잡는다(자주 쓰는 구간을 손으로 입력하지 않게) */
function fn_range(days) {
var to = new Date();
var from = new Date();
from.setDate(from.getDate() - days);
document.getElementById("fromDt").value = fn_fmt(from);
document.getElementById("toDt").value = fn_fmt(to);
fn_search();
}
function fn_fmt(d) {
var m = d.getMonth() + 1;
var day = d.getDate();
return d.getFullYear() + "-" + (m < 10 ? "0" + m : m) + "-" + (day < 10 ? "0" + day : day);
}
//-->
</script>
<style type="text/css">
.guide { margin:6px 0 10px; padding:8px 10px; background:#f5f7fa; border:1px solid #dde3ea; font-size:12px; color:#555; }
.msg { margin:6px 0 10px; padding:8px 10px; background:#fff8e6; border:1px solid #e6d5a8; font-size:13px; color:#7a5b00; }
.mono { font-family:Consolas,monospace; }
.nodata { color:#aaa; }
.btn_area { margin:8px 0; text-align:right; }
.sum_line { margin:10px 0 14px; padding:10px 12px; background:#eef2f7; border:1px solid #dde3ea; font-size:13px; color:#334; }
.card_wrap { overflow:hidden; margin:0 0 6px; }
.card { float:left; width:18.4%; margin:0 1.9% 12px 0; box-sizing:border-box; border:1px solid #dde3ea; background:#fff; }
.card.last { margin-right:0; }
.card .hd { padding:7px 9px; background:#f7f9fb; border-bottom:1px solid #dde3ea; font-size:12px; color:#445; font-weight:bold; }
.card .bd { padding:12px 9px; text-align:center; }
.card .cnt { font-size:26px; font-weight:bold; color:#4a7ebb; line-height:1.1; }
.card .cnt.hit { color:#c25b5b; }
.card .unit { font-size:12px; color:#778; }
.card .dt { padding:0 9px 10px; font-size:11px; color:#889; text-align:center; word-break:break-all; }
.detail_box { margin:6px 0 18px; }
.detail_box h3 { margin:0 0 6px; font-size:14px; color:#334; }
.detail_box h3 .hint { font-size:12px; font-weight:normal; color:#889; margin-left:8px; }
.clear { clear:both; }
</style>
</head>
<body>
<!-- 타이틀 -->
<c:import url = "/mnt/include/navigation.do" />
<!-- //타이틀 -->
<!-- 컨텐츠 영역 -->
<c:if test="${not empty resultMsg}">
<div class="msg"><c:out value="${resultMsg}" /></div>
</c:if>
<form id="frm" name="frm" method="get" onsubmit="fn_search(); return false;">
<!-- 검색창 -->
<div id="search_wrap">
<fieldset class="date_search">
<span>탐지기간 :</span>
<input type="text" name="fromDt" id="fromDt" class="tbox" title="시작일"
value="<c:out value='${fromDt}'/>" placeholder="yyyy-MM-dd" maxlength="10" />
~
<input type="text" name="toDt" id="toDt" class="tbox" title="종료일"
value="<c:out value='${toDt}'/>" placeholder="yyyy-MM-dd" maxlength="10" />
<input type="submit" class="btn_search" value="탐지" />
<span>
<a href="#none" onclick="fn_range(7); return false;">최근 7일</a> ·
<a href="#none" onclick="fn_range(30); return false;">30일</a> ·
<a href="#none" onclick="fn_range(90); return false;">90일</a>
</span>
</fieldset>
</div>
<!-- //검색창 -->
</form>
<div class="guide">
· 개인정보 접속기록(730일 보존)을 <strong>조회 시점의 정책 임계치</strong>로 다시 집계한 결과입니다. 탐지 결과를 따로 저장하지 않으므로 임계치를 조정하면 과거 기간도 새 기준으로 다시 계산됩니다.<br />
· <strong>탐지되었다는 것이 곧 위반은 아닙니다.</strong> 업무상 정당한 대량조회·야간작업이 있을 수 있으므로, 각 항목의 <strong>접속기록 보기</strong>로 원본 기록을 확인한 뒤 판단하세요.<br />
· 월 단위 점검 기록으로 남겨야 하는 경우에는 <strong>정기점검 리포트</strong> 화면에서 점검결과·조치사항을 입력하세요(이 화면은 조회 전용입니다).<br />
· 5유형 각각이 기간 전체를 훑는 집계라 기간이 넓으면 조회가 느려집니다(최대 366일).
</div>
<c:if test="${not empty summary}">
<div class="sum_line">
<strong><c:out value="${fromDt}" /> ~ <c:out value="${toDt}" /></strong> 탐지 요약 &nbsp;:&nbsp; <c:out value="${summary}" />
</div>
</c:if>
<%-- 유형별 요약 카드 5장 --%>
<div class="card_wrap">
<c:forEach items="${resultList}" var="r" varStatus="st">
<div class="card <c:if test='${st.last}'>last</c:if>">
<div class="hd"><c:out value="${r.type.label}" /></div>
<div class="bd">
<span class="cnt <c:if test='${r.count > 0}'>hit</c:if>"><c:out value="${r.count}" /></span>
<span class="unit">
<c:choose>
<c:when test="${r.type eq 'UNAUTHORIZED_ACCESS' or r.type eq 'OFF_HOURS_ACCESS'}">건</c:when>
<c:otherwise>명</c:otherwise>
</c:choose>
</span>
</div>
<div class="dt"><c:out value="${r.detail}" /></div>
</div>
</c:forEach>
</div>
<div class="clear"></div>
<%-- 유형별 상세 --%>
<c:forEach items="${resultList}" var="r">
<div class="detail_box">
<h3>
<c:out value="${r.type.label}" />
<span class="hint">
<c:choose>
<c:when test="${r.type eq 'UNAUTHORIZED_ACCESS'}">처리결과가 실패·차단(F)으로 남은 기록입니다 — 권한 없는 화면 접근 시도가 여기에 잡힙니다.</c:when>
<c:when test="${r.type eq 'MASS_QUERY'}">단위기간 조회(S) 건수가 임계를 넘은 취급자입니다.</c:when>
<c:when test="${r.type eq 'OFF_HOURS_ACCESS'}">업무시간 밖(기본 09시 이전·18시 이후)에 처리된 기록입니다.</c:when>
<c:when test="${r.type eq 'ACCOUNT_SHARING'}">같은 계정이 서로 다른 IP에서 사용된 경우입니다 — 계정 공유·탈취 의심 신호입니다.</c:when>
<c:otherwise>같은 취급자에게서 실패(F) 기록이 반복된 경우입니다.</c:otherwise>
</c:choose>
</span>
</h3>
<c:choose>
<%-- 대상자형(대량조회·계정공유·인증실패반복) : 누가 걸렸는지 목록으로 --%>
<c:when test="${not empty r.rows}">
<table summary="<c:out value='${r.type.label}'/> 의심 대상 목록" class="list1">
<caption><c:out value="${r.type.label}" /> 의심 대상 목록</caption>
<colgroup><col width="8%" /><col width="30%" /><col width="20%" /><col width="*" /></colgroup>
<thead>
<tr>
<th>NO.</th>
<th>접속자ID</th>
<th>
<c:choose>
<c:when test="${r.type eq 'ACCOUNT_SHARING'}">사용 IP 수</c:when>
<c:when test="${r.type eq 'REPEATED_AUTH_FAILURE'}">실패 횟수</c:when>
<c:otherwise>조회 건수</c:otherwise>
</c:choose>
</th>
<th>원본 확인</th>
</tr>
</thead>
<tbody>
<c:forEach items="${r.rows}" var="row" varStatus="st">
<tr>
<td><c:out value="${st.index + 1}" /></td>
<td class="mono"><c:out value="${row.conectId}" /></td>
<td>
<c:choose>
<c:when test="${r.type eq 'ACCOUNT_SHARING'}"><c:out value="${row.ipCnt}" /></c:when>
<c:otherwise><c:out value="${row.cnt}" /></c:otherwise>
</c:choose>
</td>
<td>
<%-- 접속기록 조회화면(#47)으로 조건을 넘겨 원본을 바로 확인한다.
인증실패반복은 처리결과 F, 대량조회는 처리구분 S 로 좁혀 준다. --%>
<a href="<c:url value='${PATH}/sec/accessLog/listView.do'/>?conectId=<c:out value='${row.conectId}'/>&fromDt=<c:out value='${fromDt}'/>&toDt=<c:out value='${toDt}'/><c:if test="${r.type eq 'REPEATED_AUTH_FAILURE'}">&processResult=F</c:if><c:if test="${r.type eq 'MASS_QUERY'}">&dmlGubun=S</c:if>">접속기록 보기</a>
</td>
</tr>
</c:forEach>
</tbody>
</table>
</c:when>
<%-- 건수형(비인가접근·비정상시간대) : 대상자가 아니라 기간 전체 건수라 목록이 없다 --%>
<c:when test="${r.type eq 'UNAUTHORIZED_ACCESS' and r.count > 0}">
<div class="sum_line">
기간 내 <strong><c:out value="${r.count}" /></strong>건 —
<a href="<c:url value='${PATH}/sec/accessLog/listView.do'/>?processResult=F&fromDt=<c:out value='${fromDt}'/>&toDt=<c:out value='${toDt}'/>">접속기록에서 실패·차단 기록 보기</a>
</div>
</c:when>
<c:when test="${r.type eq 'OFF_HOURS_ACCESS' and r.count > 0}">
<div class="sum_line">
기간 내 <strong><c:out value="${r.count}" /></strong>건 —
<a href="<c:url value='${PATH}/sec/accessLog/listView.do'/>?fromDt=<c:out value='${fromDt}'/>&toDt=<c:out value='${toDt}'/>">접속기록에서 해당 기간 기록 보기</a>
<span class="nodata">(시간대 조건은 접속기록 화면의 접속일시로 확인하세요)</span>
</div>
</c:when>
<c:otherwise>
<div class="sum_line"><span class="nodata">해당 기간에 탐지된 항목이 없습니다.</span></div>
</c:otherwise>
</c:choose>
</div>
</c:forEach>
<c:if test="${empty resultList}">
<table summary="비정상 접근 탐지 결과" class="list1">
<caption>비정상 접근 탐지 결과</caption>
<tbody>
<tr><td><spring:message code="common.nodata.msg" /></td></tr>
</tbody>
</table>
</c:if>
</div>
<!--// 컨텐츠 영역 -->
</div>
<!--// 오른쪽 영역 -->
</div>
<!--// 전체 둘러싸기 -->
</body>
</html>

View File

@ -0,0 +1,243 @@
<%@ page language="java" contentType="text/html; charset=utf-8" pageEncoding="utf-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%><%-- Head --%>
<c:import url = "/mnt/include/head.do" /><%--// Head --%>
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"%>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%>
<%--
개인정보 접속기록 정기점검 리포트 (개인정보보호 SFR-004-7 / SER-002 / #48)
· 월 1회 배치(#43)가 전월분 접속기록을 집계해 1행씩 적재한 PDAL_INSPECTION_REPORT
· 점검자가 점검결과·조치사항을 입력하면 INSPECTOR_ID/INSPECT_RESULT/ACTN_MATTER/INSPECT_DT 가 채워진다(#44)
· 배치 집계값(총건수·무결성·이상징후 등)은 화면에서 수정하지 않는다 — 감사 증빙이라 사후 변경되면 안 된다
--%>
<c:set var="PATH" value="/mnt" />
<script type="text/javascript">
<!--
function fn_list() {
document.frm.method = "get";
document.frm.action = "<c:url value='${PATH}/sec/inspection/listView.do' />";
document.frm.submit();
}
/* 상세 펼치기 — 목록을 떠나지 않고 같은 화면에 상세·입력폼을 연다(다른 달과 대조하며 점검) */
function fn_detail(rptId) {
location.href = "<c:url value='${PATH}/sec/inspection/listView.do' />"
+ "?rptId=" + rptId
+ "&rptYmPrefix=" + encodeURIComponent(document.getElementById("rptYmPrefix").value)
+ "&inspectedYn=" + encodeURIComponent(document.getElementById("inspectedYn").value);
}
/* 점검결과 저장 — 점검결과는 필수. 서버도 다시 막지만(IllegalArgumentException)
빈 값으로 왕복하는 낭비를 줄인다. */
function fn_saveResult() {
var r = document.getElementById("inspectResult");
if (r.value.replace(/^\s+|\s+$/g, "") == "") {
alert("점검결과는 필수 입력입니다.");
r.focus();
return;
}
var f = document.saveFrm;
f.method = "post";
f.action = "<c:url value='${PATH}/sec/inspection/saveResultView.do' />";
f.submit();
}
//-->
</script>
<style type="text/css">
.badge { display:inline-block; padding:2px 8px; border-radius:3px; font-size:12px; color:#fff; }
.badge.ok { background:#4a7ebb; }
.badge.ng { background:#c25b5b; }
.badge.wait { background:#999; }
.mono { font-family:Consolas,monospace; }
.guide { margin:6px 0 10px; padding:8px 10px; background:#f5f7fa; border:1px solid #dde3ea; font-size:12px; color:#555; }
.msg { margin:6px 0 10px; padding:8px 10px; background:#fff8e6; border:1px solid #e6d5a8; font-size:13px; color:#7a5b00; }
.btn_area { margin:8px 0; text-align:right; }
.nodata { color:#aaa; }
.rpt_detail { margin:14px 0 20px; padding:0; border:1px solid #dde3ea; }
.rpt_detail h3 { margin:0; padding:8px 12px; background:#eef2f7; font-size:14px; color:#334; border-bottom:1px solid #dde3ea; }
.rpt_detail .inner { padding:12px; }
.rpt_detail dl { margin:0 0 10px; font-size:13px; }
.rpt_detail dt { float:left; width:120px; clear:both; color:#556; padding:3px 0; }
.rpt_detail dd { margin-left:130px; padding:3px 0; word-break:break-all; }
.rpt_detail textarea { width:98%; }
.summary_box { background:#fbfcfd; border:1px solid #e5eaf0; padding:8px 10px; font-size:12px; color:#445; white-space:pre-wrap; word-break:break-all; }
tr.on td { background:#f2f6fb; }
</style>
</head>
<body>
<!-- 타이틀 -->
<c:import url = "/mnt/include/navigation.do" />
<!-- //타이틀 -->
<!-- 컨텐츠 영역 -->
<c:if test="${not empty resultMsg}">
<div class="msg"><c:out value="${resultMsg}" /></div>
</c:if>
<form id="frm" name="frm" method="get" onsubmit="fn_list(); return false;">
<!-- 검색창 -->
<div id="search_wrap">
<fieldset class="date_search">
<span>대상 연도 :</span>
<input type="text" name="rptYmPrefix" id="rptYmPrefix" class="tbox" title="대상 연도(yyyy)"
value="<c:out value='${rptYmPrefix}'/>" placeholder="yyyy" maxlength="4" />
<span>점검여부 :</span>
<select name="inspectedYn" id="inspectedYn" title="점검여부">
<option value="" <c:if test="${empty inspectedYn}">selected="selected"</c:if>>전체</option>
<option value="Y" <c:if test="${inspectedYn eq 'Y'}">selected="selected"</c:if>>점검완료</option>
<option value="N" <c:if test="${inspectedYn eq 'N'}">selected="selected"</c:if>>미점검</option>
</select>
<input type="submit" class="btn_search" value="검색" />
</fieldset>
</div>
<!-- //검색창 -->
</form>
<div class="guide">
· 매월 1일 배치가 <strong>전월분 접속기록</strong>을 집계해 리포트 1건을 생성합니다(개인정보의 안전성 확보조치 기준 제8조 — 월 1회 이상 점검).<br />
· <strong>집계값(총 처리건수·실패건수·무결성 검증결과·이상징후·다운로드 건수)은 배치가 적재한 값이며 화면에서 수정할 수 없습니다</strong> — 사후 변경이 가능하면 점검 증빙이 되지 못합니다.<br />
· 점검자가 입력하는 항목은 <strong>점검결과·조치사항</strong>이며, 저장 시 <strong>점검자 계정과 점검일시가 자동 기록</strong>됩니다.<br />
· <strong>무결성 위반(VIOLATION)</strong>은 접속기록 해시체인이 끊긴 상태로, 위·변조 가능성을 뜻합니다. 최초 불일치 기록번호를 확인해 즉시 조치사항에 남기세요.
</div>
<div class="btn_area">
총 <strong><c:out value="${totalCount}" /></strong>건
</div>
<table summary="정기점검 리포트 목록" class="list1">
<caption>정기점검 리포트 목록</caption>
<colgroup>
<col width="9%" /><col width="15%" /><col width="9%" /><col width="9%" />
<col width="11%" /><col width="*" /><col width="10%" /><col width="12%" />
</colgroup>
<thead>
<tr>
<th>대상연월</th>
<th>집계기간</th>
<th>총 처리</th>
<th>실패/차단</th>
<th>무결성</th>
<th>이상징후 요약</th>
<th>점검여부</th>
<th>점검자/일시</th>
</tr>
</thead>
<tbody>
<c:forEach items="${resultList}" var="r">
<tr <c:if test="${not empty report and report.rptId eq r.rptId}">class="on"</c:if>>
<td><a href="#none" onclick="fn_detail('<c:out value="${r.rptId}"/>'); return false;"><strong><c:out value="${r.rptYm}" /></strong></a></td>
<td class="mono"><c:out value="${fn:substring(r.periodFrom, 0, 10)}" /> ~ <c:out value="${fn:substring(r.periodTo, 0, 10)}" /></td>
<td><c:out value="${r.totalLogCnt}" /></td>
<td><c:out value="${r.failCnt}" /></td>
<td>
<c:choose>
<c:when test="${r.integrityResult eq 'OK'}"><span class="badge ok">정상</span></c:when>
<c:otherwise><span class="badge ng">위반</span></c:otherwise>
</c:choose>
</td>
<td><c:out value="${r.abnormalSummary}" /></td>
<td>
<c:choose>
<c:when test="${not empty r.inspectDt}"><span class="badge ok">점검완료</span></c:when>
<c:otherwise><span class="badge wait">미점검</span></c:otherwise>
</c:choose>
</td>
<td>
<c:choose>
<c:when test="${not empty r.inspectDt}">
<c:out value="${r.inspectorId}" /><br /><span class="mono"><c:out value="${r.inspectDt}" /></span>
</c:when>
<c:otherwise><span class="nodata">-</span></c:otherwise>
</c:choose>
</td>
</tr>
</c:forEach>
<c:if test="${fn:length(resultList) == 0}">
<tr>
<td colspan="8"><spring:message code="common.nodata.msg" /></td>
</tr>
</c:if>
</tbody>
</table>
<%-- 상세 + 점검결과 입력 --%>
<c:if test="${not empty report}">
<div class="rpt_detail">
<h3><c:out value="${report.rptYm}" /> 정기점검 리포트 상세</h3>
<div class="inner">
<dl>
<dt>집계기간</dt>
<dd class="mono"><c:out value="${report.periodFrom}" /> ~ <c:out value="${report.periodTo}" /></dd>
<dt>총 처리건수</dt>
<dd><c:out value="${report.totalLogCnt}" />건 (실패/차단 <c:out value="${report.failCnt}" />건)</dd>
<dt>다운로드</dt>
<dd><c:out value="${report.downloadCnt}" />건</dd>
<dt>무결성 검증</dt>
<dd>
<c:choose>
<c:when test="${report.integrityResult eq 'OK'}">
<span class="badge ok">정상</span> 검증대상 <c:out value="${report.integrityTotalCnt}" />건 — 해시체인 연결 이상 없음
</c:when>
<c:otherwise>
<span class="badge ng">위반</span> 검증대상 <c:out value="${report.integrityTotalCnt}" />건 —
최초 불일치 기록번호 <strong class="mono"><c:out value="${report.integrityMismatchId}" /></strong>
</c:otherwise>
</c:choose>
</dd>
<dt>이상징후</dt>
<dd><div class="summary_box"><c:out value="${report.abnormalSummary}" /></div></dd>
<dt>취급자별 현황</dt>
<dd><div class="summary_box"><c:out value="${report.handlerSummary}" /></div></dd>
<dt>리포트 생성</dt>
<dd class="mono"><c:out value="${report.regDt}" /></dd>
</dl>
<form name="saveFrm" id="saveFrm" method="post">
<input type="hidden" name="rptId" value="<c:out value='${report.rptId}'/>" />
<table summary="점검결과 입력" class="list1">
<caption>점검결과 입력</caption>
<colgroup><col width="15%" /><col width="*" /></colgroup>
<tbody>
<tr>
<th scope="row">점검결과 <span style="color:#c25b5b;">*</span></th>
<td>
<textarea name="inspectResult" id="inspectResult" rows="3" title="점검결과"><c:out value="${report.inspectResult}" /></textarea>
</td>
</tr>
<tr>
<th scope="row">조치사항</th>
<td>
<textarea name="actnMatter" id="actnMatter" rows="4" title="조치사항"><c:out value="${report.actnMatter}" /></textarea>
</td>
</tr>
<tr>
<th scope="row">점검자 / 점검일시</th>
<td>
<c:choose>
<c:when test="${not empty report.inspectDt}">
<c:out value="${report.inspectorId}" /> / <span class="mono"><c:out value="${report.inspectDt}" /></span>
<span class="nodata">(저장 시 현재 로그인 계정·시각으로 갱신됩니다)</span>
</c:when>
<c:otherwise><span class="nodata">저장 시 현재 로그인 계정·시각이 자동 기록됩니다.</span></c:otherwise>
</c:choose>
</td>
</tr>
</tbody>
</table>
<div class="btn_area">
<input type="button" class="btn_search" value="점검결과 저장" onclick="fn_saveResult();" />
</div>
</form>
</div>
</div>
</c:if>
</div>
<!--// 컨텐츠 영역 -->
</div>
<!--// 오른쪽 영역 -->
</div>
<!--// 전체 둘러싸기 -->
</body>
</html>

View File

@ -0,0 +1,165 @@
<%@ page language="java" contentType="text/html; charset=utf-8" pageEncoding="utf-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%><%-- Head --%>
<c:import url = "/mnt/include/head.do" /><%--// Head --%>
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"%>
<%@ taglib prefix="ui" uri="http://egovframework.gov/ctl/ui"%>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%>
<%--
암호화 회원정보 관리 — 접근 이력 (사유·재인증 기록)
· 개인정보 접속기록(PDAL)이 '언제 어느 화면을 열었나'라면, 이 표는 '왜 열어봤나'를 담는다
· 개인정보 본문은 담기지 않는다 — 변경 항목명만 남고 값은 남지 않는다
--%>
<c:set var="PATH" value="/mnt" />
<script type="text/javascript">
<!--
function fn_list(pageIndex) {
document.frm.pageIndex.value = pageIndex;
document.frm.method = "get";
document.frm.action = "<c:url value='${PATH}/sec/mberCrypto/historyView.do' />";
document.frm.submit();
}
function fn_back() {
location.href = "<c:url value='${PATH}/sec/mberCrypto/listView.do' />?page=9100051";
}
//-->
</script>
<style type="text/css">
.badge { display:inline-block; padding:2px 8px; border-radius:3px; font-size:12px; color:#fff; }
.badge.s { background:#4a7ebb; }
.badge.i { background:#5a9367; }
.badge.u { background:#b08a3e; }
.badge.d { background:#c25b5b; }
.mono { font-family:Consolas,monospace; }
.guide { margin:6px 0 10px; padding:8px 10px; background:#f5f7fa; border:1px solid #dde3ea; font-size:12px; color:#555; }
.btn_area { margin:8px 0; text-align:right; }
.nodata { color:#aaa; }
.reason { word-break:break-all; text-align:left; }
#search_wrap .srow span { padding-left:12px; }
#search_wrap .srow > span:first-child { padding-left:0; }
</style>
</head>
<body>
<!-- 타이틀 -->
<c:import url = "/mnt/include/navigation.do" />
<!-- //타이틀 -->
<!-- 컨텐츠 영역 -->
<form id="frm" name="frm" method="get" onsubmit="fn_list('1'); return false;">
<input type="hidden" name="pageIndex" value="1" />
<input type="hidden" name="page" value="9100051" />
<!-- 검색창 -->
<div id="search_wrap">
<fieldset class="date_search">
<div class="srow">
<span>대상 회원ID :</span>
<input type="text" name="trgetMberId" class="tbox" title="대상 회원ID"
value="<c:out value='${trgetMberId}'/>" />
<span>처리자 :</span>
<input type="text" name="handlrId" class="tbox" title="처리자 계정ID"
value="<c:out value='${handlrId}'/>" />
<span>행위 :</span>
<select name="actionTy" title="행위">
<option value="" <c:if test="${empty actionTy}">selected="selected"</c:if>>전체</option>
<option value="S" <c:if test="${actionTy eq 'S'}">selected="selected"</c:if>>복호화조회</option>
<option value="I" <c:if test="${actionTy eq 'I'}">selected="selected"</c:if>>등록</option>
<option value="U" <c:if test="${actionTy eq 'U'}">selected="selected"</c:if>>수정</option>
<option value="D" <c:if test="${actionTy eq 'D'}">selected="selected"</c:if>>개인정보파기</option>
</select>
<input type="submit" class="btn_search" value="검색" />
</div>
</fieldset>
</div>
<!-- //검색창 -->
<div class="guide">
· 관리자 전용 회원정보 화면에서 <strong>평문 개인정보를 열람·변경한 행위</strong>의 소명 근거입니다.<br />
· 접속기록(개인정보 접속기록 화면)이 <u>언제 어느 화면을 열었는가</u>를 남긴다면, 이 표는 <u>왜 열어봤는가(사유)</u>를 남깁니다. 둘은 서로를 대조하는 이중 기록입니다.<br />
· <strong>개인정보 본문은 기록되지 않습니다</strong> — 무엇을 바꿨는지는 항목명만 남고 값은 남기지 않습니다(이력이 또 하나의 평문 저장소가 되면 안 됩니다).<br />
· 이 이력은 <strong>수정·삭제할 수 없습니다</strong>(해당 SQL 구문 자체를 두지 않았습니다).
</div>
<div class="btn_area">
총 <strong><c:out value="${totalCount}" /></strong>건
&nbsp;<input type="button" class="btn_search" value="회원정보 목록" onclick="fn_back();" />
</div>
<table summary="암호화 회원정보 접근 이력" class="list1">
<caption>암호화 회원정보 접근 이력</caption>
<colgroup>
<col width="15%" /><col width="11%" /><col width="10%" /><col width="11%" />
<col width="*" /><col width="14%" /><col width="11%" /><col width="6%" />
</colgroup>
<thead>
<tr>
<th>처리일시</th>
<th>처리자</th>
<th>행위</th>
<th>대상 회원</th>
<th>사유</th>
<th>변경 항목</th>
<th>처리자 IP</th>
<th>재인증</th>
</tr>
</thead>
<tbody>
<c:forEach items="${resultList}" var="h">
<tr>
<td class="mono"><c:out value="${h.accessDt}" /></td>
<td class="mono"><c:out value="${h.handlrId}" /></td>
<td>
<c:choose>
<c:when test="${h.actionTy eq 'S'}"><span class="badge s">복호화조회</span></c:when>
<c:when test="${h.actionTy eq 'I'}"><span class="badge i">등록</span></c:when>
<c:when test="${h.actionTy eq 'U'}"><span class="badge u">수정</span></c:when>
<c:when test="${h.actionTy eq 'D'}"><span class="badge d">파기</span></c:when>
<c:otherwise><c:out value="${h.actionTy}" /></c:otherwise>
</c:choose>
</td>
<td class="mono">
<c:choose>
<c:when test="${not empty h.trgetMberId}"><c:out value="${h.trgetMberId}" /></c:when>
<c:otherwise><span class="nodata">-</span></c:otherwise>
</c:choose>
</td>
<td class="reason"><c:out value="${h.accessReason}" /></td>
<td>
<c:choose>
<c:when test="${not empty h.changedItems}"><c:out value="${h.changedItems}" /></c:when>
<c:otherwise><span class="nodata">-</span></c:otherwise>
</c:choose>
</td>
<td class="mono"><c:out value="${h.handlrIp}" /></td>
<td>
<c:choose>
<c:when test="${h.reauthYn eq 'Y'}">통과</c:when>
<c:otherwise><span class="nodata">-</span></c:otherwise>
</c:choose>
</td>
</tr>
</c:forEach>
<c:if test="${fn:length(resultList) == 0}">
<tr>
<td colspan="8"><spring:message code="common.nodata.msg" /></td>
</tr>
</c:if>
</tbody>
</table>
<!-- paginate -->
<div class="paginate">
<ui:pagination paginationInfo="${paginationInfo}" type="image" jsFunction="fn_list" />
</div>
<!--// paginate -->
</form>
</div>
<!--// 컨텐츠 영역 -->
</div>
<!--// 오른쪽 영역 -->
</div>
<!--// 전체 둘러싸기 -->
</body>
</html>

View File

@ -0,0 +1,209 @@
<%@ page language="java" contentType="text/html; charset=utf-8" pageEncoding="utf-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%><%-- Head --%>
<c:import url = "/mnt/include/head.do" /><%--// Head --%>
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"%>
<%@ taglib prefix="ui" uri="http://egovframework.gov/ctl/ui"%>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form"%>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%>
<%--
암호화 회원정보 관리 — 목록 (개인정보보호 추가제안 Action4 / 제안서 슬라이드16)
· 목록은 항상 마스킹이다. 평문은 대상을 특정해 사유를 남긴 '상세 한 건'에서만 보인다
(목록에서 평문이 뜨면 사유를 받는 의미가 없다 — 대량 노출은 목록에서 일어난다)
· 이름 검색은 완전일치만 된다. 랜덤 IV AES-GCM 이라 부분검색이 원리적으로 불가능하다
--%>
<c:set var="PATH" value="/mnt" />
<script type="text/javascript">
<!--
function fn_list(pageIndex) {
document.frm.pageIndex.value = pageIndex;
document.frm.method = "get";
document.frm.action = "<c:url value='${PATH}/sec/mberCrypto/listView.do' />";
document.frm.submit();
}
/* 상세(평문) 보기 — 사유 입력 필수. 사유는 접근 이력에 그대로 기록된다.
재인증이 만료돼 있으면 서버가 재인증 화면으로 보냈다가 이 위치로 되돌린다. */
function fn_view(mberId) {
var reason = prompt("개인정보 조회 사유를 입력하세요.\n(필수 — 접근 이력에 기록됩니다)", "");
if (reason == null) {
return; /* 취소 */
}
if (reason.replace(/^\s+|\s+$/g, "") == "") {
alert("조회 사유는 필수입니다.");
return;
}
location.href = "<c:url value='${PATH}/sec/mberCrypto/view.do' />?page=9100051"
+ "&mberId=" + encodeURIComponent(mberId)
+ "&reason=" + encodeURIComponent(reason);
}
function fn_regist() {
location.href = "<c:url value='${PATH}/sec/mberCrypto/registView.do' />?page=9100051";
}
function fn_history() {
location.href = "<c:url value='${PATH}/sec/mberCrypto/historyView.do' />?page=9100051";
}
//-->
</script>
<style type="text/css">
.badge { display:inline-block; padding:2px 8px; border-radius:3px; font-size:12px; color:#fff; }
.badge.p { background:#4a7ebb; }
.badge.d { background:#999; }
.badge.enc { background:#5a9367; }
.badge.plain { background:#c25b5b; }
.mono { font-family:Consolas,monospace; }
.guide { margin:6px 0 10px; padding:8px 10px; background:#f5f7fa; border:1px solid #dde3ea; font-size:12px; color:#555; }
.msg { margin:6px 0 10px; padding:8px 10px; background:#fff8e6; border:1px solid #e6d5a8; font-size:13px; color:#7a5b00; }
.btn_area { margin:8px 0; text-align:right; }
.nodata { color:#aaa; }
.reauth { float:left; font-size:12px; color:#556; padding-top:4px; }
.reauth strong { color:#4a7ebb; }
#search_wrap .srow { white-space:nowrap; }
#search_wrap .srow + .srow { margin-top:4px; }
#search_wrap .srow input.tbox { width:120px; }
#search_wrap .srow span { padding-left:12px; }
#search_wrap .srow > span:first-child { padding-left:0; }
</style>
</head>
<body>
<!-- 타이틀 -->
<c:import url = "/mnt/include/navigation.do" />
<!-- //타이틀 -->
<!-- 컨텐츠 영역 -->
<c:if test="${not empty resultMsg}">
<div class="msg"><c:out value="${resultMsg}" /></div>
</c:if>
<form:form commandName="mberCryptoSearchVO" onsubmit="fn_list('1'); return false;" id="frm" name="frm" method="get" >
<form:hidden path="pageIndex" />
<input type="hidden" name="page" value="9100051" />
<!-- 검색창 -->
<div id="search_wrap">
<fieldset class="date_search">
<div class="srow">
<span>회원ID :</span>
<form:input path="mberId" title="회원ID" cssClass="tbox" />
<span>이름(완전일치) :</span>
<form:input path="mberNm" title="이름 완전일치" cssClass="tbox" />
<span>상태 :</span>
<select name="mberSttus" id="mberSttus" title="회원상태">
<option value="" <c:if test="${empty mberCryptoSearchVO.mberSttus}">selected="selected"</c:if>>전체</option>
<option value="P" <c:if test="${mberCryptoSearchVO.mberSttus eq 'P'}">selected="selected"</c:if>>정상</option>
<option value="D" <c:if test="${mberCryptoSearchVO.mberSttus eq 'D'}">selected="selected"</c:if>>탈퇴</option>
</select>
</div>
<div class="srow">
<span>이메일 :</span>
<form:input path="mberEmailAdres" title="이메일" cssClass="tbox" />
<span>휴대폰 :</span>
<form:input path="moblphonNo" title="휴대폰" cssClass="tbox" />
<input type="submit" class="btn_search" value="검색" />
</div>
</fieldset>
</div>
<!-- //검색창 -->
<div class="guide">
· 이 목록의 이름·휴대폰·이메일은 <strong>항상 마스킹</strong>되어 표시됩니다. 평문은 <strong>사유를 입력하고 상세를 연 한 건</strong>에서만 보입니다.<br />
· <strong>이름 검색은 완전일치만 가능합니다.</strong> 이름은 암호화(AES-GCM, 랜덤 IV) 저장되어 같은 이름도 저장된 값이 매번 달라 부분검색(예: '홍')이 원리적으로 불가능합니다 — 검색 전용 해시로 <u>정확히 같은 이름</u>만 찾습니다.<br />
· 조회·수정·파기에는 <strong>사유 입력과 재인증(본인 비밀번호 재확인, 10분 유효)</strong>이 필요하며, 처리자·일시·IP·사유가 접근 이력에 기록됩니다.<br />
· 이 화면의 모든 요청은 <strong>개인정보 접속기록</strong>에도 자동으로 남습니다.
</div>
<div class="btn_area">
<span class="reauth">
<c:choose>
<c:when test="${reauthRemain > 0}">재인증 유효 — 남은 시간 <strong><c:out value="${reauthRemain}" />초</strong></c:when>
<c:otherwise>재인증 필요 <span class="nodata">(상세·등록·수정·파기 시 본인 확인을 요청합니다)</span></c:otherwise>
</c:choose>
</span>
총 <strong><c:out value="${mberCryptoSearchVO.totalRecordCount}" /></strong>건
&nbsp;<input type="button" class="btn_search" value="신규 등록" onclick="fn_regist();" />
<input type="button" class="btn_search" value="접근 이력" onclick="fn_history();" />
</div>
<table summary="암호화 회원정보 목록" class="list1">
<caption>암호화 회원정보 목록</caption>
<colgroup>
<col width="5%" /><col width="14%" /><col width="10%" /><col width="18%" />
<col width="13%" /><col width="*" /><col width="8%" /><col width="10%" /><col width="8%" />
</colgroup>
<thead>
<tr>
<th>NO.</th>
<th>회원ID</th>
<th>이름</th>
<th>이메일</th>
<th>휴대폰</th>
<th>주소</th>
<th>상태</th>
<th>가입일</th>
<th>상세</th>
</tr>
</thead>
<tbody>
<c:forEach items="${resultList}" var="result" varStatus="status">
<tr>
<td><c:out value="${mberCryptoSearchVO.totalRecordCount - (status.index + (mberCryptoSearchVO.pageIndex-1)*mberCryptoSearchVO.recordCountPerPage)}" /></td>
<td class="mono"><c:out value="${result.mberId}" /></td>
<td>
<c:out value="${result.mberNm}" />
<%-- 암호화 여부 표시 — 이관 후 남은 미암호화 행을 눈으로 찾을 수 있게 한다 --%>
<c:choose>
<c:when test="${not empty result.cryptoKeyVer}"><span class="badge enc" title="암호화 저장(키 버전 ${result.cryptoKeyVer})">암호화</span></c:when>
<c:otherwise><span class="badge plain" title="암호문이 없는 행 — 백필 대상이거나 파기된 회원입니다">미암호화</span></c:otherwise>
</c:choose>
</td>
<td><c:out value="${result.mberEmailAdres}" /></td>
<td class="mono"><c:out value="${result.moblphonNo}" /></td>
<td>
<c:choose>
<c:when test="${not empty result.adres}"><c:out value="${result.adres}" /></c:when>
<c:otherwise><span class="nodata">-</span></c:otherwise>
</c:choose>
</td>
<td>
<c:choose>
<c:when test="${result.mberSttus eq 'P'}"><span class="badge p">정상</span></c:when>
<c:when test="${result.mberSttus eq 'D'}"><span class="badge d">탈퇴</span></c:when>
<c:otherwise><c:out value="${result.mberSttus}" /></c:otherwise>
</c:choose>
</td>
<td><c:out value="${fn:substring(result.sbscrbDe, 0, 10)}" /></td>
<td>
<a href="#none" onclick="fn_view('<c:out value="${result.mberId}"/>'); return false;">사유입력·조회</a>
</td>
</tr>
</c:forEach>
<c:if test="${fn:length(resultList) == 0}">
<tr>
<td colspan="9">
<spring:message code="common.nodata.msg" />
<c:if test="${not empty mberCryptoSearchVO.mberNm}">
<br /><span class="nodata">이름은 <strong>완전일치</strong>로만 검색됩니다. 성만 입력하거나 일부만 입력하면 찾을 수 없습니다.</span>
</c:if>
</td>
</tr>
</c:if>
</tbody>
</table>
<!-- paginate -->
<div class="paginate">
<ui:pagination paginationInfo="${paginationInfo}" type="image" jsFunction="fn_list" />
</div>
<!--// paginate -->
</form:form>
</div>
<!--// 컨텐츠 영역 -->
</div>
<!--// 오른쪽 영역 -->
</div>
<!--// 전체 둘러싸기 -->
</body>
</html>

View File

@ -0,0 +1,82 @@
<%@ page language="java" contentType="text/html; charset=utf-8" pageEncoding="utf-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%><%-- Head --%>
<c:import url = "/mnt/include/head.do" /><%--// Head --%>
<%--
암호화 회원정보 관리 — 재인증(본인 비밀번호 재확인)
· 제안서 슬라이드16 "조회·수정·삭제 시 사유 입력 및 재인증 연계" 요건
· 로그인 2차 인증을 재사용하지 않는 이유는 MberCryptoReauth Javadoc 참조
(2차 인증은 로그인 시 한 번이라 '다시 확인'이 되지 못하고, 미등록 취급자는 영영 못 들어온다)
--%>
<c:set var="PATH" value="/mnt" />
<script type="text/javascript">
<!--
function fn_list() {
location.href = "<c:url value='${PATH}/sec/mberCrypto/listView.do' />?page=9100051";
}
function fn_confirm() {
var f = document.reauthFrm;
if (f.password.value == "") {
alert("비밀번호를 입력하세요.");
f.password.focus();
return;
}
f.method = "post";
f.action = "<c:url value='${PATH}/sec/mberCrypto/reauth.do' />";
f.submit();
}
//-->
</script>
<style type="text/css">
.guide { margin:6px 0 10px; padding:8px 10px; background:#f5f7fa; border:1px solid #dde3ea; font-size:12px; color:#555; }
.msg { margin:6px 0 10px; padding:8px 10px; background:#fdf3f3; border:1px solid #e6bcbc; font-size:13px; color:#8a3b3b; }
.btn_area { margin:10px 0; text-align:center; }
.reauth_box { max-width:520px; margin:20px auto; border:1px solid #dde3ea; }
.reauth_box h3 { margin:0; padding:10px 12px; background:#eef2f7; font-size:14px; color:#334; border-bottom:1px solid #dde3ea; }
.reauth_box .inner { padding:16px 18px; }
.reauth_box label { display:block; margin-bottom:6px; font-size:13px; color:#445; }
.reauth_box input.tbox { width:100%; box-sizing:border-box; }
</style>
</head>
<body>
<!-- 타이틀 -->
<c:import url = "/mnt/include/navigation.do" />
<!-- //타이틀 -->
<!-- 컨텐츠 영역 -->
<div class="reauth_box">
<h3>본인 확인</h3>
<div class="inner">
<c:if test="${not empty resultMsg}">
<div class="msg"><c:out value="${resultMsg}" /></div>
</c:if>
<div class="guide">
개인정보 평문을 다루는 화면입니다. <strong>본인 비밀번호</strong>를 한 번 더 입력해 주세요.<br />
확인 후 <strong>10분 동안</strong> 유효하며, 시간이 지나면 다시 확인합니다.
</div>
<form name="reauthFrm" id="reauthFrm" method="post" onsubmit="fn_confirm(); return false;">
<input type="hidden" name="page" value="9100051" />
<input type="hidden" name="returnUrl" value="<c:out value='${returnUrl}'/>" />
<label for="password">비밀번호</label>
<input type="password" name="password" id="password" class="tbox" maxlength="30"
title="비밀번호" autocomplete="current-password" />
<div class="btn_area">
<input type="button" class="btn_search" value="목록으로" onclick="fn_list();" />
<input type="button" class="btn_search" value="확인" onclick="fn_confirm();" />
</div>
</form>
</div>
</div>
</div>
<!--// 컨텐츠 영역 -->
</div>
<!--// 오른쪽 영역 -->
</div>
<!--// 전체 둘러싸기 -->
</body>
</html>

View File

@ -0,0 +1,141 @@
<%@ page language="java" contentType="text/html; charset=utf-8" pageEncoding="utf-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%><%-- Head --%>
<c:import url = "/mnt/include/head.do" /><%--// Head --%>
<%--
암호화 회원정보 관리 — 신규 등록
· 이름은 입력 즉시 암호화되어 저장된다(평문 컬럼에는 '(암호화)' 표식만 들어간다)
· 초기 비밀번호는 솔트 해시로 저장된다
--%>
<c:set var="PATH" value="/mnt" />
<script type="text/javascript">
<!--
function fn_list() {
location.href = "<c:url value='${PATH}/sec/mberCrypto/listView.do' />?page=9100051";
}
function fn_regist() {
var f = document.regFrm;
if (f.mberId.value.replace(/^\s+|\s+$/g, "") == "") {
alert("회원ID는 필수입니다.");
f.mberId.focus();
return;
}
if (f.mberNm.value.replace(/^\s+|\s+$/g, "") == "") {
alert("이름은 필수입니다.");
f.mberNm.focus();
return;
}
if (f.password.value.replace(/^\s+|\s+$/g, "") == "") {
alert("초기 비밀번호는 필수입니다.");
f.password.focus();
return;
}
var reason = prompt("등록 사유를 입력하세요.\n(필수 — 접근 이력에 기록됩니다)", "");
if (reason == null) {
return;
}
if (reason.replace(/^\s+|\s+$/g, "") == "") {
alert("등록 사유는 필수입니다.");
return;
}
f.reason.value = reason;
f.method = "post";
f.action = "<c:url value='${PATH}/sec/mberCrypto/regist.do' />";
f.submit();
}
//-->
</script>
<style type="text/css">
.guide { margin:6px 0 10px; padding:8px 10px; background:#f5f7fa; border:1px solid #dde3ea; font-size:12px; color:#555; }
.msg { margin:6px 0 10px; padding:8px 10px; background:#fff8e6; border:1px solid #e6d5a8; font-size:13px; color:#7a5b00; }
.btn_area { margin:10px 0; text-align:right; }
.req { color:#c25b5b; }
</style>
</head>
<body>
<!-- 타이틀 -->
<c:import url = "/mnt/include/navigation.do" />
<!-- //타이틀 -->
<!-- 컨텐츠 영역 -->
<c:if test="${not empty resultMsg}">
<div class="msg"><c:out value="${resultMsg}" /></div>
</c:if>
<div class="guide">
· 이름은 <strong>저장 시점에 암호화</strong>되며 평문으로는 보관되지 않습니다(검색용 해시가 함께 만들어집니다).<br />
· <strong>초기 비밀번호</strong>는 솔트를 붙인 해시로 저장됩니다. 등록 후 회원에게 변경을 안내하세요.<br />
· 이 화면은 개인정보 항목만 다룹니다. 활동지역·재능 등 부가 정보는 기존 회원관리 화면에서 입력합니다.<br />
· 등록에도 <strong>사유 입력</strong>이 필요하며 처리자·일시·IP·사유가 접근 이력에 기록됩니다.
<c:if test="${reauthRemain > 0}"><br />· 재인증 남은 시간 <strong><c:out value="${reauthRemain}" />초</strong></c:if>
</div>
<form name="regFrm" id="regFrm" method="post">
<input type="hidden" name="page" value="9100051" />
<input type="hidden" name="reason" value="" />
<table summary="회원 신규 등록" class="list1">
<caption>회원 신규 등록</caption>
<colgroup><col width="16%" /><col width="34%" /><col width="16%" /><col width="*" /></colgroup>
<tbody>
<tr>
<th scope="row">회원ID <span class="req">*</span></th>
<td><input type="text" name="mberId" id="mberId" class="tbox" maxlength="20" title="회원ID" /></td>
<th scope="row">초기 비밀번호 <span class="req">*</span></th>
<td><input type="password" name="password" id="password" class="tbox" maxlength="30" title="초기 비밀번호" autocomplete="new-password" /></td>
</tr>
<tr>
<th scope="row">이름 <span class="req">*</span></th>
<td><input type="text" name="mberNm" id="mberNm" class="tbox" maxlength="50" title="이름" /></td>
<th scope="row">성별</th>
<td>
<select name="sexdstnCode" title="성별">
<option value="">선택안함</option>
<option value="M">남</option>
<option value="F">여</option>
</select>
</td>
</tr>
<tr>
<th scope="row">이메일</th>
<td><input type="text" name="mberEmailAdres" class="tbox" maxlength="50" title="이메일" /></td>
<th scope="row">휴대폰</th>
<td><input type="text" name="moblphonNo" class="tbox" maxlength="20" title="휴대폰" /></td>
</tr>
<tr>
<th scope="row">우편번호</th>
<td><input type="text" name="zip" class="tbox" maxlength="6" title="우편번호" /></td>
<th scope="row">회원상태</th>
<td>
<select name="mberSttus" title="회원상태">
<option value="P" selected="selected">정상</option>
<option value="D">탈퇴</option>
</select>
</td>
</tr>
<tr>
<th scope="row">주소</th>
<td colspan="3">
<input type="text" name="adres" class="tbox" style="width:45%;" maxlength="100" title="주소" />
<input type="text" name="detailAdres" class="tbox" style="width:45%;" maxlength="100" title="상세주소" />
</td>
</tr>
</tbody>
</table>
<div class="btn_area">
<input type="button" class="btn_search" value="목록" onclick="fn_list();" />
<input type="button" class="btn_search" value="등록(사유 입력)" onclick="fn_regist();" />
</div>
</form>
</div>
<!--// 컨텐츠 영역 -->
</div>
<!--// 오른쪽 영역 -->
</div>
<!--// 전체 둘러싸기 -->
</body>
</html>

View File

@ -0,0 +1,222 @@
<%@ page language="java" contentType="text/html; charset=utf-8" pageEncoding="utf-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%><%-- Head --%>
<c:import url = "/mnt/include/head.do" /><%--// Head --%>
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"%>
<%--
암호화 회원정보 관리 — 상세(평문) / 수정 / 파기
· 이 화면에 도달했다는 것은 사유 입력과 재인증을 모두 통과했다는 뜻이다(컨트롤러가 판정)
· 화면을 연 사실 자체가 이미 접근 이력에 기록되어 있다(복호화조회 S)
--%>
<c:set var="PATH" value="/mnt" />
<script type="text/javascript">
<!--
function fn_list() {
location.href = "<c:url value='${PATH}/sec/mberCrypto/listView.do' />?page=9100051";
}
/* 수정 — 사유를 다시 받는다. 조회 사유와 변경 사유는 다를 수 있고, 이력에도 각각 남는다. */
function fn_update() {
var f = document.updFrm;
if (f.mberNm.value.replace(/^\s+|\s+$/g, "") == "") {
alert("이름은 필수입니다.");
f.mberNm.focus();
return;
}
var reason = prompt("수정 사유를 입력하세요.\n(필수 — 접근 이력에 기록됩니다)", "");
if (reason == null) {
return;
}
if (reason.replace(/^\s+|\s+$/g, "") == "") {
alert("수정 사유는 필수입니다.");
return;
}
f.reason.value = reason;
f.method = "post";
f.action = "<c:url value='${PATH}/sec/mberCrypto/update.do' />";
f.submit();
}
/* 개인정보 파기 — 되돌릴 수 없다.
회원ID를 직접 입력하게 해 오조작을 막고, 서버에서도 같은 값인지 다시 확인한다. */
function fn_deletePii() {
var target = document.getElementById("mberIdRef").value;
var typed = prompt("이 회원의 개인정보를 파기합니다.\n\n"
+ "· 이름·이메일·휴대폰·주소·성별이 삭제되고 회원상태가 '탈퇴'로 바뀝니다.\n"
+ "· 되돌릴 수 없습니다(암호문·해시까지 함께 지워집니다).\n"
+ "· 활동·신청 기록은 남습니다(식별정보만 지웁니다).\n\n"
+ "확인을 위해 회원ID를 그대로 입력하세요: " + target, "");
if (typed == null) {
return;
}
if (typed.replace(/^\s+|\s+$/g, "") != target) {
alert("회원ID가 일치하지 않습니다. 파기를 취소합니다.");
return;
}
var reason = prompt("파기 사유를 입력하세요.\n(필수 — 접근 이력에 기록됩니다)", "");
if (reason == null) {
return;
}
if (reason.replace(/^\s+|\s+$/g, "") == "") {
alert("파기 사유는 필수입니다.");
return;
}
var f = document.delFrm;
f.confirmId.value = typed.replace(/^\s+|\s+$/g, "");
f.reason.value = reason;
f.method = "post";
f.action = "<c:url value='${PATH}/sec/mberCrypto/deletePii.do' />";
f.submit();
}
//-->
</script>
<style type="text/css">
.badge { display:inline-block; padding:2px 8px; border-radius:3px; font-size:12px; color:#fff; }
.badge.p { background:#4a7ebb; }
.badge.d { background:#999; }
.badge.enc { background:#5a9367; }
.mono { font-family:Consolas,monospace; }
.guide { margin:6px 0 10px; padding:8px 10px; background:#f5f7fa; border:1px solid #dde3ea; font-size:12px; color:#555; }
.warn { margin:6px 0 10px; padding:8px 10px; background:#fdf3f3; border:1px solid #e6bcbc; font-size:12px; color:#8a3b3b; }
.msg { margin:6px 0 10px; padding:8px 10px; background:#fff8e6; border:1px solid #e6d5a8; font-size:13px; color:#7a5b00; }
.btn_area { margin:10px 0; text-align:right; }
.btn_del { padding:5px 14px; border:1px solid #c25b5b; background:#c25b5b; color:#fff; cursor:pointer; font-size:12px; }
.reason_box { margin:6px 0 12px; padding:8px 10px; background:#eef2f7; border:1px solid #dde3ea; font-size:12px; color:#334; }
.nodata { color:#aaa; }
</style>
</head>
<body>
<!-- 타이틀 -->
<c:import url = "/mnt/include/navigation.do" />
<!-- //타이틀 -->
<!-- 컨텐츠 영역 -->
<c:if test="${not empty resultMsg}">
<div class="msg"><c:out value="${resultMsg}" /></div>
</c:if>
<div class="reason_box">
<strong>조회 사유</strong> : <c:out value="${reason}" />
&nbsp;|&nbsp; 이 조회는 접근 이력에 기록되었습니다.
<c:if test="${reauthRemain > 0}">&nbsp;|&nbsp; 재인증 남은 시간 <strong><c:out value="${reauthRemain}" />초</strong></c:if>
</div>
<div class="guide">
· 아래 값은 <strong>복호화된 평문</strong>입니다. 화면을 열어 둔 채 자리를 비우지 마세요.<br />
· 수정 시 이름은 <strong>다시 암호화</strong>되고 검색용 해시도 함께 갱신됩니다(평문 이름은 저장되지 않습니다).<br />
· 비밀번호·가입일·내부 식별자는 이 화면에서 변경하지 않습니다.
</div>
<form name="updFrm" id="updFrm" method="post">
<input type="hidden" name="page" value="9100051" />
<input type="hidden" name="reason" value="" />
<input type="hidden" name="mberId" value="<c:out value='${mber.mberId}'/>" />
<input type="hidden" id="mberIdRef" value="<c:out value='${mber.mberId}'/>" />
<table summary="회원 개인정보 상세" class="list1">
<caption>회원 개인정보 상세</caption>
<colgroup><col width="16%" /><col width="34%" /><col width="16%" /><col width="*" /></colgroup>
<tbody>
<tr>
<th scope="row">회원ID</th>
<td class="mono">
<c:out value="${mber.mberId}" />
<c:choose>
<c:when test="${mber.mberSttus eq 'P'}"><span class="badge p">정상</span></c:when>
<c:when test="${mber.mberSttus eq 'D'}"><span class="badge d">탈퇴</span></c:when>
</c:choose>
</td>
<th scope="row">내부 식별자</th>
<td class="mono"><c:out value="${mber.uniqId}" /></td>
</tr>
<tr>
<th scope="row">이름 <span style="color:#c25b5b;">*</span></th>
<td>
<input type="text" name="mberNm" id="mberNm" class="tbox" maxlength="50"
value="<c:out value='${mber.mberNm}'/>" title="이름" />
<c:if test="${not empty mber.cryptoKeyVer}">
<span class="badge enc" title="암호화 저장">암호화 v<c:out value="${mber.cryptoKeyVer}" /></span>
</c:if>
</td>
<th scope="row">성별</th>
<td>
<select name="sexdstnCode" title="성별">
<option value="" <c:if test="${empty mber.sexdstnCode}">selected="selected"</c:if>>선택안함</option>
<option value="M" <c:if test="${mber.sexdstnCode eq 'M'}">selected="selected"</c:if>>남</option>
<option value="F" <c:if test="${mber.sexdstnCode eq 'F'}">selected="selected"</c:if>>여</option>
</select>
</td>
</tr>
<tr>
<th scope="row">이메일</th>
<td>
<input type="text" name="mberEmailAdres" class="tbox" maxlength="50"
value="<c:out value='${mber.mberEmailAdres}'/>" title="이메일" />
</td>
<th scope="row">휴대폰</th>
<td>
<input type="text" name="moblphonNo" class="tbox" maxlength="20"
value="<c:out value='${mber.moblphonNo}'/>" title="휴대폰" />
</td>
</tr>
<tr>
<th scope="row">우편번호</th>
<td>
<input type="text" name="zip" class="tbox" maxlength="6"
value="<c:out value='${mber.zip}'/>" title="우편번호" />
</td>
<th scope="row">회원상태</th>
<td>
<select name="mberSttus" title="회원상태">
<option value="P" <c:if test="${mber.mberSttus eq 'P'}">selected="selected"</c:if>>정상</option>
<option value="D" <c:if test="${mber.mberSttus eq 'D'}">selected="selected"</c:if>>탈퇴</option>
</select>
</td>
</tr>
<tr>
<th scope="row">주소</th>
<td colspan="3">
<input type="text" name="adres" class="tbox" style="width:45%;" maxlength="100"
value="<c:out value='${mber.adres}'/>" title="주소" />
<input type="text" name="detailAdres" class="tbox" style="width:45%;" maxlength="100"
value="<c:out value='${mber.detailAdres}'/>" title="상세주소" />
</td>
</tr>
<tr>
<th scope="row">가입일</th>
<td colspan="3"><c:out value="${mber.sbscrbDe}" /></td>
</tr>
</tbody>
</table>
<div class="btn_area">
<input type="button" class="btn_search" value="목록" onclick="fn_list();" />
<input type="button" class="btn_search" value="수정(사유 입력)" onclick="fn_update();" />
</div>
</form>
<div class="warn">
<strong>개인정보 파기</strong> — 이름·이메일·휴대폰·주소·성별을 삭제하고 회원상태를 '탈퇴'로 바꿉니다.
암호문과 검색용 해시도 함께 지워지며 <strong>되돌릴 수 없습니다</strong>.
회원 행 자체는 남깁니다(신청·활동 기록이 참조하고 있어 행을 지우면 그 기록들이 주인 없는 값을 가리키게 됩니다).
</div>
<form name="delFrm" id="delFrm" method="post">
<input type="hidden" name="page" value="9100051" />
<input type="hidden" name="mberId" value="<c:out value='${mber.mberId}'/>" />
<input type="hidden" name="confirmId" value="" />
<input type="hidden" name="reason" value="" />
<div class="btn_area">
<input type="button" class="btn_del" value="개인정보 파기" onclick="fn_deletePii();" />
</div>
</form>
</div>
<!--// 컨텐츠 영역 -->
</div>
<!--// 오른쪽 영역 -->
</div>
<!--// 전체 둘러싸기 -->
</body>
</html>

View File

@ -0,0 +1,151 @@
<%@ page language="java" contentType="text/html; charset=utf-8" pageEncoding="utf-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%><%-- Head --%>
<c:import url = "/mnt/include/head.do" /><%--// Head --%>
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"%>
<%@ taglib prefix="ui" uri="http://egovframework.gov/ctl/ui"%>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form"%>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%>
<%--
개인정보 접속기록 파기이력 (개인정보보호 SFR-004 / DBR-001 / SER-002-2)
· 보관기간(730일) 경과 파티션 DROP(파기) + 미래 월 파티션 ADD(선행증설) 내역
· 조회 전용 — 이력을 화면에서 고치거나 지울 수 있으면 증빙 가치가 사라지므로
등록·수정·삭제 엔드포인트를 두지 않는다(컨트롤러에도 없다)
--%>
<c:set var="PATH" value="/mnt" />
<script type="text/javascript">
<!--
function fn_list(pageIndex) {
document.frm.pageIndex.value = pageIndex;
document.frm.method = "get";
document.frm.action = "<c:url value='${PATH}/sec/purgeHistory/listView.do' />";
document.frm.submit();
}
//-->
</script>
<style type="text/css">
.badge { display:inline-block; padding:2px 8px; border-radius:3px; font-size:12px; color:#fff; }
.badge.drop { background:#c25b5b; }
.badge.add { background:#4a7ebb; }
.mono { font-family:Consolas,monospace; }
.guide { margin:6px 0 10px; padding:8px 10px; background:#f5f7fa; border:1px solid #dde3ea; font-size:12px; color:#555; }
.btn_area { margin:8px 0; text-align:right; }
.nodata { color:#aaa; }
#search_wrap .srow { white-space:nowrap; }
#search_wrap .srow input.tbox.dt { width:140px; }
#search_wrap .srow select { max-width:170px; }
#search_wrap .srow span { padding-left:12px; }
#search_wrap .srow > span:first-child { padding-left:0; }
</style>
</head>
<body>
<!-- 타이틀 -->
<c:import url = "/mnt/include/navigation.do" />
<!-- //타이틀 -->
<!-- 컨텐츠 영역 -->
<form:form commandName="accessLogPurgeHistorySearchVO" onsubmit="fn_list('1'); return false;" id="frm" name="frm" method="get" >
<form:hidden path="pageIndex" />
<!-- 검색창 -->
<div id="search_wrap">
<fieldset class="date_search">
<div class="srow">
<span>유형 :</span>
<select name="purgeType" id="purgeType" title="유형">
<option value="" <c:if test="${empty accessLogPurgeHistorySearchVO.purgeType}">selected="selected"</c:if>>전체</option>
<option value="DROP" <c:if test="${accessLogPurgeHistorySearchVO.purgeType eq 'DROP'}">selected="selected"</c:if>>파기(DROP)</option>
<option value="ADD" <c:if test="${accessLogPurgeHistorySearchVO.purgeType eq 'ADD'}">selected="selected"</c:if>>선행증설(ADD)</option>
</select>
<span>수행일시 :</span>
<form:input path="fromDt" title="시작일시" cssClass="tbox dt" placeholder="yyyy-MM-dd" />
~
<form:input path="toDt" title="종료일시" cssClass="tbox dt" placeholder="yyyy-MM-dd" />
<input type="submit" class="btn_search" value="검색" />
</div>
</fieldset>
</div>
<!-- //검색창 -->
<div class="guide">
· 개인정보 접속기록(<strong>PERSONAL_DATA_ACCESS_LOG</strong>)의 <strong>파기·증설 이력</strong>입니다. 기간은 <strong>yyyy-MM-dd</strong> 또는 <strong>yyyy-MM-dd HH:mm:ss</strong> 형식으로 입력하세요(날짜만 입력 시 종료일 당일 포함).<br />
· 접속기록의 삭제 경로는 <strong>월 단위 파티션 삭제(DROP)</strong> 하나뿐입니다. 개별 레코드 DELETE 는 사용하지 않으며, 접속기록 테이블에는 UPDATE·DELETE 구문 자체가 정의되어 있지 않습니다.<br />
· 따라서 이 목록은 <strong>“보관기간(730일) 경과분을 규정대로 파기했고, 그 외 경로로는 손대지 않았다”</strong>는 사실을 함께 입증하는 증빙 자료입니다(안전성 확보조치 기준 제8조).<br />
· <strong>선행증설(ADD)</strong>은 앞으로 쌓일 기록을 담을 월 파티션을 미리 만들어 두는 작업입니다 — 파기가 아니라 <u>수집이 끊기지 않게 하는</u> 조치입니다.<br />
· 이 화면은 <strong>조회 전용</strong>입니다. 이력의 수정·삭제 기능은 제공하지 않습니다.
</div>
<div class="btn_area">
총 <strong><c:out value="${accessLogPurgeHistorySearchVO.totalRecordCount}" /></strong>건
</div>
<table summary="개인정보 접속기록 파기·증설 이력 목록" class="list1">
<caption>개인정보 접속기록 파기·증설 이력 목록</caption>
<colgroup>
<col width="6%" />
<col width="17%" />
<col width="14%" />
<col width="14%" />
<col width="12%" />
<col width="*" />
</colgroup>
<thead>
<tr>
<th>NO.</th>
<th>수행일시</th>
<th>대상 파티션</th>
<th>파티션 경계</th>
<th>유형</th>
<th>비고</th>
</tr>
</thead>
<tbody>
<c:forEach items="${resultList}" var="result" varStatus="status">
<tr>
<td><c:out value="${accessLogPurgeHistorySearchVO.totalRecordCount - (status.index + (accessLogPurgeHistorySearchVO.pageIndex-1)*accessLogPurgeHistorySearchVO.recordCountPerPage)}" /></td>
<td><c:out value="${result.purgeDt}" /></td>
<td class="mono"><c:out value="${result.partitionName}" /></td>
<td class="mono">
<c:choose>
<c:when test="${not empty result.boundaryDt}"><c:out value="${result.boundaryDt}" /></c:when>
<c:otherwise><span class="nodata">-</span></c:otherwise>
</c:choose>
</td>
<td>
<c:choose>
<c:when test="${result.purgeType eq 'DROP'}"><span class="badge drop">파기</span></c:when>
<c:when test="${result.purgeType eq 'ADD'}"><span class="badge add">선행증설</span></c:when>
<c:otherwise><c:out value="${result.purgeType}" /></c:otherwise>
</c:choose>
</td>
<td>
<c:choose>
<c:when test="${not empty result.note}"><c:out value="${result.note}" /></c:when>
<c:otherwise><span class="nodata">-</span></c:otherwise>
</c:choose>
</td>
</tr>
</c:forEach>
<c:if test="${fn:length(resultList) == 0}">
<tr>
<td colspan="6"><spring:message code="common.nodata.msg" /></td>
</tr>
</c:if>
</tbody>
</table>
<!-- paginate -->
<div class="paginate">
<ui:pagination paginationInfo="${paginationInfo}" type="image" jsFunction="fn_list" />
</div>
<!--// paginate -->
</form:form>
</div>
<!--// 컨텐츠 영역 -->
</div>
<!--// 오른쪽 영역 -->
</div>
<!--// 전체 둘러싸기 -->
</body>
</html>

View File

@ -0,0 +1,55 @@
-- =====================================================================
-- 암호화 회원정보 관리자 접근 이력 : MBER_CRYPTO_ACCESS_HISTORY
-- 문화품앗이 개인정보보호 기능개선 사업 (추가제안 Action4 / 제안서 Ⅲ 슬라이드16)
-- 작성일: 2026-08-27 DB: MySQL 8 / MariaDB (스키마 csv)
-- ---------------------------------------------------------------------
-- [목적] 관리자 전용 회원정보 화면에서 일어난 '복호화 조회·수정·삭제·등록' 행위를
-- 사유와 함께 남긴다. 제안서 슬라이드16의 접근통제 요건:
-- "관리자 페이지 별도 권한 부여, 조회·수정·삭제 시 사유 입력 및 재인증 연계"
--
-- [PERSONAL_DATA_ACCESS_LOG(#30)와 무엇이 다른가]
-- · 접속기록(PDAL)은 URL 기준으로 '언제 어느 화면을 열었는가'를 자동 수집한다.
-- 이 화면도 /mnt/sec/** 라 자동 수집 대상이지만, 거기에는 사유가 없다.
-- · 이 표는 '왜 열어봤는가(사유)'와 '재인증을 거쳤는가'를 담는다. 평문 개인정보를
-- 실제로 눈으로 본 행위의 소명 근거라 접속기록과 목적이 다르다.
-- · 둘 다 남기는 것은 중복이 아니라 이중 기록이다(하나가 비면 다른 하나로 대조).
--
-- [담지 않는 것] 개인정보 본문은 적재하지 않는다. 대상 회원ID와 '어떤 항목을 바꿨는가'의
-- 항목명만 남긴다 — 이력 테이블이 또 하나의 평문 개인정보 저장소가 되면 안 된다.
--
-- [보관] 접속기록과 같은 2년(730일) 이상 보관을 전제로 한다. 별도 파기 배치는 두지 않고
-- 행 수가 적어(관리자 수동 조회 기록) 운영 중 증가량을 관찰한 뒤 결정한다.
--
-- [재실행 안전] CREATE TABLE IF NOT EXISTS — 반복 실행 무해.
-- =====================================================================
CREATE TABLE IF NOT EXISTS MBER_CRYPTO_ACCESS_HISTORY
(
HIST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '이력 일련번호',
HANDLR_ID VARCHAR(20) NOT NULL COMMENT '처리자(취급자) 계정ID',
ACTION_TY CHAR(1) NOT NULL COMMENT '행위 S:복호화조회 I:등록 U:수정 D:개인정보파기',
TRGET_MBER_ID VARCHAR(20) NULL COMMENT '대상 회원ID(목록 검색은 NULL)',
ACCESS_REASON VARCHAR(500) NOT NULL COMMENT '사유(필수 입력)',
CHANGED_ITEMS VARCHAR(500) NULL COMMENT '변경 항목명 목록(값은 미적재). 예: 이름,휴대폰',
HANDLR_IP VARCHAR(45) NULL COMMENT '처리자 IP(IPv6 대비 45)',
REAUTH_YN CHAR(1) NOT NULL DEFAULT 'N' COMMENT '재인증 통과 여부',
ACCESS_DT DATETIME NOT NULL COMMENT '처리 일시',
PRIMARY KEY (HIST_ID),
KEY IDX_MCAH_01 (ACCESS_DT),
KEY IDX_MCAH_02 (HANDLR_ID),
KEY IDX_MCAH_03 (TRGET_MBER_ID)
)
ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='암호화 회원정보 관리자 접근 이력(사유·재인증, 추가제안 Action4)';
-- ---------------------------------------------------------------------
-- [적용 확인]
-- ---------------------------------------------------------------------
-- SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE FROM information_schema.COLUMNS
-- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'MBER_CRYPTO_ACCESS_HISTORY'
-- ORDER BY ORDINAL_POSITION;
-- ---------------------------------------------------------------------
-- [롤백]
-- ---------------------------------------------------------------------
-- DROP TABLE IF EXISTS MBER_CRYPTO_ACCESS_HISTORY;

View File

@ -0,0 +1,127 @@
-- =====================================================================
-- 관리자 메뉴 등록 : 개인정보보호 화면 4종 (MENU_NO 9100048 ~ 9100051)
-- 문화품앗이 개인정보보호 기능개선 사업
-- 작성일: 2026-08-27 DB: MySQL 8 / MariaDB (스키마 csv)
-- ---------------------------------------------------------------------
-- [등록 대상]
-- 9100048 / ORDR 14 정기점검 리포트 /mnt/sec/inspection/listView.do (SFR-004-7, #48)
-- 9100049 / ORDR 15 비정상접근 탐지 /mnt/sec/abnormal/listView.do (과업내용 다-①, #49)
-- 9100050 / ORDR 16 접속기록 파기이력 /mnt/sec/purgeHistory/listView.do (SER-002-2 / DBR-001-2)
-- 9100051 / ORDR 17 회원정보 암호화관리 /mnt/sec/mberCrypto/listView.do (추가제안 Action4)
--
-- [배치] 시스템관리(9100000) > 권한관리(9100030) 하위. 직전 항목은
-- 접근통제 정책(9100047, ORDR 13)이라 그 뒤 순번을 이어 붙인다.
--
-- [메뉴번호 근거] 9100044(접속기록)·9100047(접근통제 정책)까지 사용 중이고,
-- 9100045·9100046 은 2026-08-11 화면 폐기와 함께 삭제됐다(재사용하지 않는다 —
-- 폐기 이력이 있는 번호를 되쓰면 과거 권한매핑 잔재와 섞인다).
-- 9100048~9100051 은 레포 전체 미사용을 확인했다(9100044~9100070 구간에 9100070 만 존재).
-- 노출 순서는 MENU_NO 가 아니라 MENU_ORDR 을 따른다.
--
-- ★★ 이 DML만으로는 좌측메뉴에 안 뜬다 — left.jsp 수정 동반 배포 필수 ★★
-- src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp 는 메뉴번호 하드코딩 JSP라
-- ① 권한관리(9100030) 블록에 9100048~9100051 <c:when> 분기 4개
-- ② 시스템관리/권한관리 상위 하이라이트 조건(param.page eq '910004X') 2곳
-- 이 함께 배포돼야 렌더된다. (소스에는 이미 반영 완료)
--
-- [재실행 안전] 전 구간 NOT EXISTS 가드 → 반복 실행 무해.
-- [노출권한] 기존 권한관리 메뉴와 동일하게 IS_AUTHENTICATED_FULLY, ROLE_ADMIN 2종.
-- 실제 접근 차단은 urlRoleMapping(/mnt/sec/* = ROLE_ADMIN)이 담당한다.
-- [한글 리터럴 주의] git-bash + mysql.exe 로 이 파일을 stdin 파이프 실행하면 한글이
-- 이중 인코딩으로 손상된다(기존 실측). 적용 후 반드시 HEX(MENU_NM) 로 검증하고,
-- 손상 시 CONVERT(UNHEX('<UTF-8 hex>') USING utf8mb4) UPDATE 로 교정할 것.
-- [Oracle] 대상 운영 DB가 MySQL(csv)이라 Oracle 쌍 미작성(기존 privacy 메뉴 DML 과 동일).
-- =====================================================================
-- ── [1] 프로그램(URL) 등록 (COMTNMENUINFO.PROGRM_FILE_NM → COMTNPROGRMLIST FK, 메뉴보다 먼저) ──
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
SELECT * FROM (
SELECT 'InspectionReportList' AS PROGRM_FILE_NM, '/mnt/sec/inspection/' AS PROGRM_STRE_PATH,
'정기점검 리포트' AS PROGRM_KOREAN_NM,
'접속기록 정기점검 리포트 조회·점검결과 입력(SFR-004-7)' AS PROGRM_DC,
'/mnt/sec/inspection/listView.do?page=9100048' AS URL
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM);
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
SELECT * FROM (
SELECT 'AbnormalAccessList' AS PROGRM_FILE_NM, '/mnt/sec/abnormal/' AS PROGRM_STRE_PATH,
'비정상접근 탐지' AS PROGRM_KOREAN_NM,
'비정상 접근 탐지 5유형 조회(과업내용 다-①)' AS PROGRM_DC,
'/mnt/sec/abnormal/listView.do?page=9100049' AS URL
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM);
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
SELECT * FROM (
SELECT 'AccessLogPurgeHistory' AS PROGRM_FILE_NM, '/mnt/sec/purgeHistory/' AS PROGRM_STRE_PATH,
'접속기록 파기이력' AS PROGRM_KOREAN_NM,
'접속기록 파티션 파기·증설 이력 조회(SER-002-2)' AS PROGRM_DC,
'/mnt/sec/purgeHistory/listView.do?page=9100050' AS URL
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM);
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
SELECT * FROM (
SELECT 'MberCryptoAdmin' AS PROGRM_FILE_NM, '/mnt/sec/mberCrypto/' AS PROGRM_STRE_PATH,
'회원정보 암호화 관리' AS PROGRM_KOREAN_NM,
'암호화 회원정보 복호화 조회·수정·파기·등록(추가제안 Action4)' AS PROGRM_DC,
'/mnt/sec/mberCrypto/listView.do?page=9100051' AS URL
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM);
-- ── [2] 메뉴 등록 : 권한관리(9100030) 하위 ──
INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM)
SELECT * FROM (
SELECT '정기점검 리포트' AS MENU_NM, 'InspectionReportList' AS PROGRM_FILE_NM, 9100048 AS MENU_NO,
9100030 AS UPPER_MENU_NO, 14 AS MENU_ORDR, '접속기록 정기점검 리포트 조회·점검결과 입력' AS MENU_DC,
'/' AS RELATE_IMAGE_PATH, '/' AS RELATE_IMAGE_NM
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO m WHERE m.MENU_NO = seed.MENU_NO);
INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM)
SELECT * FROM (
SELECT '비정상접근 탐지' AS MENU_NM, 'AbnormalAccessList' AS PROGRM_FILE_NM, 9100049 AS MENU_NO,
9100030 AS UPPER_MENU_NO, 15 AS MENU_ORDR, '비정상 접근 탐지 5유형 조회' AS MENU_DC,
'/' AS RELATE_IMAGE_PATH, '/' AS RELATE_IMAGE_NM
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO m WHERE m.MENU_NO = seed.MENU_NO);
INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM)
SELECT * FROM (
SELECT '접속기록 파기이력' AS MENU_NM, 'AccessLogPurgeHistory' AS PROGRM_FILE_NM, 9100050 AS MENU_NO,
9100030 AS UPPER_MENU_NO, 16 AS MENU_ORDR, '접속기록 파티션 파기·증설 이력' AS MENU_DC,
'/' AS RELATE_IMAGE_PATH, '/' AS RELATE_IMAGE_NM
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO m WHERE m.MENU_NO = seed.MENU_NO);
INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM)
SELECT * FROM (
SELECT '회원정보 암호화 관리' AS MENU_NM, 'MberCryptoAdmin' AS PROGRM_FILE_NM, 9100051 AS MENU_NO,
9100030 AS UPPER_MENU_NO, 17 AS MENU_ORDR, '암호화 회원정보 복호화 조회·수정·파기·등록' AS MENU_DC,
'/' AS RELATE_IMAGE_PATH, '/' AS RELATE_IMAGE_NM
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO m WHERE m.MENU_NO = seed.MENU_NO);
-- ── [3] 권한↔메뉴 노출 매핑 (4메뉴 × 2권한, FK 안전) ──
INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE)
SELECT n.mno, g.ac
FROM (SELECT 9100048 AS mno UNION ALL SELECT 9100049 UNION ALL
SELECT 9100050 UNION ALL SELECT 9100051) n
CROSS JOIN (SELECT 'IS_AUTHENTICATED_FULLY' AS ac UNION ALL SELECT 'ROLE_ADMIN') g
WHERE g.ac IN (SELECT AUTHOR_CODE FROM COMTNAUTHORINFO) -- FK 안전
AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x
WHERE x.MENU_NO = n.mno AND x.AUTHOR_CODE = g.ac);
-- ── [검증] 등록결과 + 한글 손상 여부(HEX 가 EA/EB/EC 대로 시작해야 정상) ──
-- SELECT m.MENU_NO, m.UPPER_MENU_NO, m.MENU_ORDR, m.MENU_NM, HEX(m.MENU_NM), p.URL,
-- (SELECT COUNT(*) FROM COMTNMENUCREATDTLS d WHERE d.MENU_NO = m.MENU_NO) grants
-- FROM COMTNMENUINFO m JOIN COMTNPROGRMLIST p ON m.PROGRM_FILE_NM = p.PROGRM_FILE_NM
-- WHERE m.MENU_NO BETWEEN 9100048 AND 9100051
-- ORDER BY m.MENU_ORDR;
-- ── [롤백] (역순) ──
-- DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO BETWEEN 9100048 AND 9100051;
-- DELETE FROM COMTNMENUINFO WHERE MENU_NO BETWEEN 9100048 AND 9100051;
-- DELETE FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM IN
-- ('InspectionReportList','AbnormalAccessList','AccessLogPurgeHistory','MberCryptoAdmin');

View File

@ -1054,6 +1054,33 @@ DROP VIEW IF EXISTS selectboardarticlelist;
DROP VIEW IF EXISTS selectmiddleappconfirmlist;
-- ---------------------------------------------------------------------
-- [privacy.mber_crypto_access_history_create_mysql.sql] 2026-08-27 추가
-- 암호화 회원정보 관리자 접근 이력(추가제안 Action4 / 제안서 슬라이드16)
-- · 관리자 전용 회원정보 화면의 복호화 조회·등록·수정·파기 행위를 '사유'와 함께 남긴다.
-- · 접속기록(PERSONAL_DATA_ACCESS_LOG)이 '언제 어느 화면을 열었나'라면 이 표는 '왜 열어봤나'다.
-- 둘 다 남기는 것은 중복이 아니라 이중 기록이다(하나가 비면 다른 하나로 대조).
-- · 개인정보 본문은 적재하지 않는다 — 변경 항목명만 남기고 값은 남기지 않는다.
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS MBER_CRYPTO_ACCESS_HISTORY
(
HIST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '이력 일련번호',
HANDLR_ID VARCHAR(20) NOT NULL COMMENT '처리자(취급자) 계정ID',
ACTION_TY CHAR(1) NOT NULL COMMENT '행위 S:복호화조회 I:등록 U:수정 D:개인정보파기',
TRGET_MBER_ID VARCHAR(20) NULL COMMENT '대상 회원ID(목록 검색은 NULL)',
ACCESS_REASON VARCHAR(500) NOT NULL COMMENT '사유(필수 입력)',
CHANGED_ITEMS VARCHAR(500) NULL COMMENT '변경 항목명 목록(값은 미적재). 예: 이름,휴대폰',
HANDLR_IP VARCHAR(45) NULL COMMENT '처리자 IP(IPv6 대비 45)',
REAUTH_YN CHAR(1) NOT NULL DEFAULT 'N' COMMENT '재인증 통과 여부',
ACCESS_DT DATETIME NOT NULL COMMENT '처리 일시',
PRIMARY KEY (HIST_ID),
KEY IDX_MCAH_01 (ACCESS_DT),
KEY IDX_MCAH_02 (HANDLR_ID),
KEY IDX_MCAH_03 (TRGET_MBER_ID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='암호화 회원정보 관리자 접근 이력(사유·재인증, 추가제안 Action4)';
-- =====================================================================
-- PART 2. DML (기준/시드 데이터) ※ 멱등: NOT EXISTS 가드로 중복 적재 방지
-- 수록: ACCOUNT_MNG_POLICY(기본정책 1행)
@ -1249,6 +1276,91 @@ SELECT 9100047, g.ac
-- ('AuthorRequestList','AuthorHistoryList','DormantStatusList','AccountPolicySet');
-- ── [7] 개인정보보호 화면 4종 메뉴(9100048~9100051) — 2026-08-27 추가 ──
-- 9100048 / ORDR 14 정기점검 리포트 /mnt/sec/inspection/listView.do (SFR-004-7)
-- 9100049 / ORDR 15 비정상접근 탐지 /mnt/sec/abnormal/listView.do (과업내용 다-①)
-- 9100050 / ORDR 16 접속기록 파기이력 /mnt/sec/purgeHistory/listView.do (SER-002-2)
-- 9100051 / ORDR 17 회원정보 암호화관리 /mnt/sec/mberCrypto/listView.do (추가제안 Action4)
-- ★ left.jsp 9100048~9100051 <c:when> 분기 4개 + 상위 하이라이트 조건 2곳 동반 배포 필수
-- ※ 9100045·9100046 은 2026-08-11 화면 폐기와 함께 삭제된 번호라 재사용하지 않는다.
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
SELECT * FROM (
SELECT 'InspectionReportList' AS PROGRM_FILE_NM, '/mnt/sec/inspection/' AS PROGRM_STRE_PATH,
'정기점검 리포트' AS PROGRM_KOREAN_NM,
'접속기록 정기점검 리포트 조회·점검결과 입력(SFR-004-7)' AS PROGRM_DC,
'/mnt/sec/inspection/listView.do?page=9100048' AS URL
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM);
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
SELECT * FROM (
SELECT 'AbnormalAccessList' AS PROGRM_FILE_NM, '/mnt/sec/abnormal/' AS PROGRM_STRE_PATH,
'비정상접근 탐지' AS PROGRM_KOREAN_NM,
'비정상 접근 탐지 5유형 조회(과업내용 다-①)' AS PROGRM_DC,
'/mnt/sec/abnormal/listView.do?page=9100049' AS URL
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM);
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
SELECT * FROM (
SELECT 'AccessLogPurgeHistory' AS PROGRM_FILE_NM, '/mnt/sec/purgeHistory/' AS PROGRM_STRE_PATH,
'접속기록 파기이력' AS PROGRM_KOREAN_NM,
'접속기록 파티션 파기·증설 이력 조회(SER-002-2)' AS PROGRM_DC,
'/mnt/sec/purgeHistory/listView.do?page=9100050' AS URL
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM);
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
SELECT * FROM (
SELECT 'MberCryptoAdmin' AS PROGRM_FILE_NM, '/mnt/sec/mberCrypto/' AS PROGRM_STRE_PATH,
'회원정보 암호화 관리' AS PROGRM_KOREAN_NM,
'암호화 회원정보 복호화 조회·수정·파기·등록(추가제안 Action4)' AS PROGRM_DC,
'/mnt/sec/mberCrypto/listView.do?page=9100051' AS URL
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM);
INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM)
SELECT * FROM (
SELECT '정기점검 리포트' AS MENU_NM, 'InspectionReportList' AS PROGRM_FILE_NM, 9100048 AS MENU_NO,
9100030 AS UPPER_MENU_NO, 14 AS MENU_ORDR, '접속기록 정기점검 리포트 조회·점검결과 입력' AS MENU_DC,
'/' AS RELATE_IMAGE_PATH, '/' AS RELATE_IMAGE_NM
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO m WHERE m.MENU_NO = seed.MENU_NO);
INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM)
SELECT * FROM (
SELECT '비정상접근 탐지' AS MENU_NM, 'AbnormalAccessList' AS PROGRM_FILE_NM, 9100049 AS MENU_NO,
9100030 AS UPPER_MENU_NO, 15 AS MENU_ORDR, '비정상 접근 탐지 5유형 조회' AS MENU_DC,
'/' AS RELATE_IMAGE_PATH, '/' AS RELATE_IMAGE_NM
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO m WHERE m.MENU_NO = seed.MENU_NO);
INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM)
SELECT * FROM (
SELECT '접속기록 파기이력' AS MENU_NM, 'AccessLogPurgeHistory' AS PROGRM_FILE_NM, 9100050 AS MENU_NO,
9100030 AS UPPER_MENU_NO, 16 AS MENU_ORDR, '접속기록 파티션 파기·증설 이력' AS MENU_DC,
'/' AS RELATE_IMAGE_PATH, '/' AS RELATE_IMAGE_NM
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO m WHERE m.MENU_NO = seed.MENU_NO);
INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM)
SELECT * FROM (
SELECT '회원정보 암호화 관리' AS MENU_NM, 'MberCryptoAdmin' AS PROGRM_FILE_NM, 9100051 AS MENU_NO,
9100030 AS UPPER_MENU_NO, 17 AS MENU_ORDR, '암호화 회원정보 복호화 조회·수정·파기·등록' AS MENU_DC,
'/' AS RELATE_IMAGE_PATH, '/' AS RELATE_IMAGE_NM
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO m WHERE m.MENU_NO = seed.MENU_NO);
INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE)
SELECT n.mno, g.ac
FROM (SELECT 9100048 AS mno UNION ALL SELECT 9100049 UNION ALL
SELECT 9100050 UNION ALL SELECT 9100051) n
CROSS JOIN (SELECT 'IS_AUTHENTICATED_FULLY' AS ac UNION ALL SELECT 'ROLE_ADMIN') g
WHERE g.ac IN (SELECT AUTHOR_CODE FROM COMTNAUTHORINFO)
AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x
WHERE x.MENU_NO = n.mno AND x.AUTHOR_CODE = g.ac);
-- =====================================================================
-- PART 4. 뷰 GNR 컷오버 — comvnusermaster / comvnuserinfo (2026-08-11 통합)
-- 원본: src/script/mysql/ddl/privacy.mber_nm_view_hash_cutover_mysql.sql

View File

@ -25,6 +25,29 @@
---
## 1-0. 2026-08-27 작업 — 관리자 화면 4종 신설
> ⚠ 아래 §1-1 이후의 기록은 **2026-07-22 기준**이라 현재 소스와 어긋난다(그 뒤 커밋에서
> 인터셉터 `enforce=true`·`urlRoleMapping` 활성, 관리자 화면 8종, OTP DB영속, 회원정보 암호화
> 컷오버가 모두 반영됨). 갱신은 별도 작업으로 남긴다.
| 대상 | 요구사항 | 산출물 | 상태 |
|------|----------|--------|:----:|
| #48 정기점검 리포트 화면 | SFR-004-7 | `InspectionResultController.listView/saveResultView` + `site/mnt/sec/inspection/list.jsp` + 메뉴 9100048 | ✅ 완료 |
| #49 비정상접근 탐지 화면 | 과업내용 다-①, SER-002 | `AbnormalAccessController`(신규) + `AbnormalAccessResultVO.rows` 확장 + `abnormal/list.jsp` + 메뉴 9100049 | ✅ 완료 |
| 접속기록 파기이력 화면(신규 항목) | SER-002-2 / DBR-001-2 | `AccessLogPurgeHistoryController`+VO 2종, `AccessLogPartitionDAO` 조회 2메서드, sqlmap 2 statement, `purgeHistory/list.jsp`, 메뉴 9100050 | ✅ 완료 |
| 회원정보 암호화 관리 CRUD(추가제안 Action4) | 제안서 슬라이드16 | `MberCryptoAdmin*` 8클래스 + `PrivacyMberCryptoAdmin_SQL_Mysql/Oracle` + `MBER_CRYPTO_ACCESS_HISTORY` DDL + JSP 5종 + 메뉴 9100051 | ✅ 완료 |
- 검증: JDK8 `mvn -o compile` **EXIT=0**, JSP 9종 Jasper 사전컴파일 **오류 0건**.
- 배포 델타: `_workspace_privacy/deploy/20260827_1410/DELTA.md` (SQL 2건 적용순서 포함).
- **미완**: Tomcat 기동 상태 실화면 검증(관리자 계정 필요), 개발/운영 서버 반영.
- **후속 발견**: 기존 탈퇴 구문 `mberManageDAO.updateDeleteMber_S` 는 개인정보를 비우면서도
**이름(MBER_NM/MBER_NM_ENC/MBER_NM_HASH)은 지우지 않는다** — 탈퇴 후에도 이름이 남는 파기 누락
소지. 신설 화면의 파기(`MberCryptoAdminDAO.deleteMberPii`)는 이름까지 파기한다. 기존 탈퇴 경로
보정은 별도 판단 필요(영향 범위: 프론트 탈퇴 전체).
---
## 1-1. 오늘의 작업 (2026-07-21, ~17:00 목표)
fork 3개를 병렬로 투입해 **자체 완결적·파일 충돌 없는 기반 항목 6개**를 당일 완료 목표로 착수한다.