+ */
+public class AccountActionHistoryVO implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ /** 조치 유형 — 잠금(휴면 확정). */
+ public static final String ACTION_LOCK = "L";
+ /** 조치 유형 — 말소. */
+ public static final String ACTION_EXPIRE = "D";
+
+ private long actionHistId;
+ /** 조치 대상 계정 ID. */
+ private String trgetId;
+ /** 대상 회원구분 — 취급자는 'EMP'. */
+ private String trgetSe = "EMP";
+ /** 조치 유형 L:잠금 D:말소. */
+ private String actionTy;
+ /** 조치 처리자(관리자) ID. */
+ private String actionUserId;
+ /** 조치 사유(필수). */
+ private String actionResn;
+ /** 조치자 접속 IP(프록시 보정값). */
+ private String actionIp;
+ /** 조치 시점의 미접속 일수(사후 검증용 스냅샷). */
+ private int noConectDay;
+ private String actionDt;
+
+ public long getActionHistId() { return actionHistId; }
+ public void setActionHistId(long actionHistId) { this.actionHistId = actionHistId; }
+ public String getTrgetId() { return trgetId; }
+ public void setTrgetId(String trgetId) { this.trgetId = trgetId; }
+ public String getTrgetSe() { return trgetSe; }
+ public void setTrgetSe(String trgetSe) { this.trgetSe = trgetSe; }
+ public String getActionTy() { return actionTy; }
+ public void setActionTy(String actionTy) { this.actionTy = actionTy; }
+ public String getActionUserId() { return actionUserId; }
+ public void setActionUserId(String actionUserId) { this.actionUserId = actionUserId; }
+ public String getActionResn() { return actionResn; }
+ public void setActionResn(String actionResn) { this.actionResn = actionResn; }
+ public String getActionIp() { return actionIp; }
+ public void setActionIp(String actionIp) { this.actionIp = actionIp; }
+ public int getNoConectDay() { return noConectDay; }
+ public void setNoConectDay(int noConectDay) { this.noConectDay = noConectDay; }
+ public String getActionDt() { return actionDt; }
+ public void setActionDt(String actionDt) { this.actionDt = actionDt; }
+}
diff --git a/src/main/java/egovframework/com/cmm/privacy/account/AccountMngController.java b/src/main/java/egovframework/com/cmm/privacy/account/AccountMngController.java
index f558e36..1284949 100644
--- a/src/main/java/egovframework/com/cmm/privacy/account/AccountMngController.java
+++ b/src/main/java/egovframework/com/cmm/privacy/account/AccountMngController.java
@@ -1,5 +1,6 @@
package egovframework.com.cmm.privacy.account;
+import java.net.URLEncoder;
import java.util.List;
import javax.annotation.Resource;
@@ -9,8 +10,10 @@ 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.util.EgovUserDetailsHelper;
import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo;
@@ -23,6 +26,8 @@ import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo;
public class AccountMngController {
private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do";
+ /** 현황 목록(#11). page 파라미터는 좌측메뉴 하이라이트(left.jsp 9100037 분기)용이라 유지한다. */
+ private static final String DORMANT_LIST_REDIRECT = "redirect:/mnt/sec/dormantStatus/listView.do?page=9100037";
@Resource(name = "accountMngPolicyService")
private AccountMngPolicyService accountMngPolicyService;
@@ -200,10 +205,20 @@ public class AccountMngController {
*/
@RequestMapping(value = "/mnt/sec/dormantStatus/listView.do")
public String dormantStatusListView(@ModelAttribute("dormantAccountStatusVO") DormantAccountStatusVO searchVO,
+ @RequestParam(value = "resultMsg", required = false) String resultMsg,
+ @RequestParam(value = "searched", required = false) String searched,
ModelMap model) throws Exception {
if (handlerUser() == null) {
return LOGIN_REDIRECT;
}
+
+ DormantAccountSummaryVO summary = dormantAccountStatusService.selectDormantSummary();
+ // 최초 진입(검색 미수행)은 관리 대상만 보이도록 '휴면예정 기준일수 이상'을 기본 조건으로 건다.
+ // 사용자가 한 번이라도 검색하면(searched=Y) 선택한 조건을 그대로 존중한다('전체' 포함).
+ if (!"Y".equals(searched) && searchVO.getMinNoConnectDays() <= 0) {
+ searchVO.setMinNoConnectDays(summary.getWarnDay());
+ }
+
PaginationInfo paginationInfo = new PaginationInfo();
paginationInfo.setCurrentPageNo(searchVO.getPageIndex());
paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage() < 1 ? 10 : searchVO.getRecordCountPerPage());
@@ -215,6 +230,70 @@ public class AccountMngController {
model.addAttribute("resultList", list);
model.addAttribute("paginationInfo", paginationInfo);
+ model.addAttribute("summary", summary);
+ model.addAttribute("resultMsg", resultMsg);
return "site/mnt/sec/dormantStatus/list";
}
+
+ /** #11 선택 계정 잠금(휴면 확정). 상태를 바꾸므로 POST 한정. */
+ @RequestMapping(value = "/mnt/sec/dormantStatus/lock.do")
+ public String dormantAccountLock(@RequestParam(value = "checkedIds", required = false) String[] checkedIds,
+ @RequestParam(value = "actionResn", required = false) String actionResn,
+ HttpServletRequest request) throws Exception {
+ return handleAction(checkedIds, actionResn, request, true);
+ }
+
+ /** #11 선택 계정 말소(잠금 상태 계정 대상). 상태를 바꾸므로 POST 한정. */
+ @RequestMapping(value = "/mnt/sec/dormantStatus/expire.do")
+ public String dormantAccountExpire(@RequestParam(value = "checkedIds", required = false) String[] checkedIds,
+ @RequestParam(value = "actionResn", required = false) String actionResn,
+ HttpServletRequest request) throws Exception {
+ return handleAction(checkedIds, actionResn, request, false);
+ }
+
+ /** 잠금·말소 공통 전처리(인증·요청경로·사유 검증) 후 서비스 호출. */
+ private String handleAction(String[] checkedIds, String actionResn, HttpServletRequest request, boolean lock)
+ throws Exception {
+ LoginVO loginVO = handlerUser();
+ if (loginVO == null) {
+ return LOGIN_REDIRECT;
+ }
+ if (!"POST".equalsIgnoreCase(request.getMethod())) {
+ return dormantListRedirect("잘못된 요청 방식입니다.");
+ }
+ if (!isSameOrigin(request)) {
+ return dormantListRedirect("잘못된 요청 경로입니다.");
+ }
+ if (checkedIds == null || checkedIds.length == 0) {
+ return dormantListRedirect("대상 계정을 선택하세요.");
+ }
+ String resn = actionResn == null ? "" : actionResn.trim();
+ if (resn.length() == 0) {
+ return dormantListRedirect("조치 사유는 필수입니다.");
+ }
+
+ String ip = ClientIpResolver.getClientIp(request);
+ int done = lock
+ ? dormantAccountStatusService.lockAccounts(checkedIds, resn, loginVO.getId(), ip)
+ : dormantAccountStatusService.expireAccounts(checkedIds, resn, loginVO.getId(), ip);
+
+ int skipped = checkedIds.length - done;
+ String label = lock ? "잠금" : "말소";
+ StringBuilder msg = new StringBuilder();
+ msg.append(done).append("건 ").append(label).append(" 처리했습니다.");
+ if (skipped > 0) {
+ // 본인 계정·이미 처리된 계정·(말소의 경우) 잠기지 않은 계정은 서버가 걸러낸다.
+ msg.append(" ").append(skipped).append("건은 조치 대상이 아니어서 제외했습니다");
+ msg.append(lock ? "(본인 계정·이미 잠긴 계정)." : "(본인 계정·잠금되지 않은 계정·이미 말소된 계정).");
+ }
+ return dormantListRedirect(msg.toString());
+ }
+
+ /** 처리결과 메시지를 붙여 현황 목록으로 리다이렉트. */
+ private String dormantListRedirect(String message) throws Exception {
+ if (message == null) {
+ return DORMANT_LIST_REDIRECT;
+ }
+ return DORMANT_LIST_REDIRECT + "&resultMsg=" + URLEncoder.encode(message, "UTF-8");
+ }
}
diff --git a/src/main/java/egovframework/com/cmm/privacy/account/AccountMngDAO.java b/src/main/java/egovframework/com/cmm/privacy/account/AccountMngDAO.java
index f8cbd2a..7cb4173 100644
--- a/src/main/java/egovframework/com/cmm/privacy/account/AccountMngDAO.java
+++ b/src/main/java/egovframework/com/cmm/privacy/account/AccountMngDAO.java
@@ -46,4 +46,45 @@ public class AccountMngDAO extends EgovComAbstractDAO {
param.put("alertBefore", Integer.valueOf(alertBefore));
return list("AccountMngDAO.selectDormantAlertTargets", param);
}
+
+ /** #11 현황 화면 상단 요약(전체·휴면예정·휴면잠금·말소 건수). 검색조건 무관 전체 집계. */
+ public DormantAccountSummaryVO selectDormantSummary() {
+ return (DormantAccountSummaryVO) selectByPk("AccountMngDAO.selectDormantSummary", null);
+ }
+
+ /** 단건 파생 계정상태(N/W/L/D). 없는 계정이면 null. */
+ public String selectDormantAccountState(String accountId) {
+ return (String) selectByPk("AccountMngDAO.selectDormantAccountState", accountId);
+ }
+
+ /** 단건 미접속 일수. 없는 계정이면 0. */
+ public int selectDormantAccountDays(String accountId) {
+ Object days = selectByPk("AccountMngDAO.selectDormantAccountDays", accountId);
+ return days == null ? 0 : ((Integer) days).intValue();
+ }
+
+ /** 휴면행 상태(DIAPAUSE_STAT). 행이 없으면 null, 값이 비어있으면 빈 문자열. */
+ public String selectDiapauseStatEmp(String accountId) {
+ return (String) selectByPk("AccountMngDAO.selectDiapauseStatEmp", accountId);
+ }
+
+ /** #11-2 잠금(휴면 확정) — 기존 휴면행 승격. */
+ public int updateDiapauseConfirmEmp(String accountId) {
+ return update("AccountMngDAO.updateDiapauseConfirmEmp", accountId);
+ }
+
+ /** #11-2 잠금(휴면 확정) — 휴면행이 없는 계정의 신규 적재. */
+ public void insertDiapauseConfirmEmp(String accountId) {
+ insert("AccountMngDAO.insertDiapauseConfirmEmp", accountId);
+ }
+
+ /** #11-2 말소 — EMPLYR_STTUS_CODE='D'. 이미 말소면 0건. */
+ public int updateEmplyrExpire(String accountId) {
+ return update("AccountMngDAO.updateEmplyrExpire", accountId);
+ }
+
+ /** #11-2 조치 이력 적재. */
+ public void insertAccountActionHistory(AccountActionHistoryVO vo) {
+ insert("AccountMngDAO.insertAccountActionHistory", vo);
+ }
}
diff --git a/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusService.java b/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusService.java
index 68727b8..da5b3b9 100644
--- a/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusService.java
+++ b/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusService.java
@@ -12,4 +12,26 @@ public interface DormantAccountStatusService {
/** 현황 전체 건수. */
int selectDormantStatusTotCnt(DormantAccountStatusVO searchVO) throws Exception;
+
+ /** 화면 상단 요약(전체·휴면예정·휴면잠금·말소 건수 + 정책 기준일수). */
+ DormantAccountSummaryVO selectDormantSummary() throws Exception;
+
+ /**
+ * 선택 계정 잠금(휴면 확정). 자동 배치가 하는 확정을 관리자가 앞당겨 수행한다.
+ *
+ * @param accountIds 대상 계정ID 배열
+ * @param resn 조치 사유(필수, 이력 적재)
+ * @param actorId 조치자(관리자) ID — 본인 계정은 대상에서 제외한다
+ * @param actorIp 조치자 IP
+ * @return 실제 처리 건수
+ */
+ int lockAccounts(String[] accountIds, String resn, String actorId, String actorIp) throws Exception;
+
+ /**
+ * 선택 계정 말소. 잠금(휴면·잠금) 상태인 계정만 대상으로 한다
+ * ("잠금 후에도 미사용 시 관리자 승인으로 말소" 정책).
+ *
+ * @return 실제 처리 건수
+ */
+ int expireAccounts(String[] accountIds, String resn, String actorId, String actorIp) throws Exception;
}
diff --git a/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusServiceImpl.java
index 02594b0..1b07119 100644
--- a/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusServiceImpl.java
+++ b/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusServiceImpl.java
@@ -7,14 +7,31 @@ import javax.annotation.Resource;
import org.springframework.stereotype.Service;
/**
- * 장기 미사용 계정 현황 조회 서비스 구현 (#11).
+ * 장기 미사용 계정 현황 조회·조치 서비스 구현 (#11).
+ *
+ *
조회(목록·요약)와 현황 화면의 조치(잠금=휴면 확정 / 말소)를 담당한다.
+ * 메서드 전체가 context-transaction.xml 의 {@code egovframework.com..*Impl.*} 포인트컷에
+ * 걸려 트랜잭션 경계 안에서 실행되므로, 다건 조치 중 예외가 나면 그 요청 전체가 롤백된다.
*/
@Service("dormantAccountStatusService")
public class DormantAccountStatusServiceImpl implements DormantAccountStatusService {
+ /** 조치 사유 최대 길이 — ACCOUNT_ACTION_HISTORY.ACTION_RESN VARCHAR(500). */
+ private static final int MAX_RESN_LEN = 500;
+
@Resource(name = "accountMngDAO")
private AccountMngDAO accountMngDAO;
+ @Resource(name = "accountMngPolicyService")
+ private AccountMngPolicyService accountMngPolicyService;
+
@Override
public List selectDormantStatusList(DormantAccountStatusVO searchVO) throws Exception {
int pageIndex = searchVO.getPageIndex() < 1 ? 1 : searchVO.getPageIndex();
@@ -30,4 +47,106 @@ public class DormantAccountStatusServiceImpl implements DormantAccountStatusServ
public int selectDormantStatusTotCnt(DormantAccountStatusVO searchVO) throws Exception {
return accountMngDAO.selectDormantStatusListCount(searchVO);
}
+
+ @Override
+ public DormantAccountSummaryVO selectDormantSummary() throws Exception {
+ DormantAccountSummaryVO summary = accountMngDAO.selectDormantSummary();
+ if (summary == null) {
+ summary = new DormantAccountSummaryVO();
+ }
+ // 카드 라벨(60일↑/90일↑)에 쓰이는 기준일수는 정책값에서 가져온다(하드코딩 없음, #9/#13).
+ AccountMngPolicyVO policy = accountMngPolicyService.selectPolicy();
+ int dormantDay = policy == null ? AccountMngPolicyVO.DEFAULT_DORMANT_DAY : policy.getDormantDay();
+ int alertD1 = policy == null ? 0 : policy.getAlertD1();
+ summary.setDormantDay(dormantDay);
+ // 사전알림 1차 시점부터 '휴면예정'으로 본다. 정책이 뒤집혀도 음수가 되지 않게 하한 0.
+ summary.setWarnDay(Math.max(dormantDay - alertD1, 0));
+ summary.setBatchCycle(policy == null ? null : policy.getBatchCycle());
+ summary.setPurgeUseYn(policy == null ? null : policy.getPurgeUseYn());
+ return summary;
+ }
+
+ @Override
+ public int lockAccounts(String[] accountIds, String resn, String actorId, String actorIp) throws Exception {
+ return processAction(accountIds, resn, actorId, actorIp, AccountActionHistoryVO.ACTION_LOCK);
+ }
+
+ @Override
+ public int expireAccounts(String[] accountIds, String resn, String actorId, String actorIp) throws Exception {
+ return processAction(accountIds, resn, actorId, actorIp, AccountActionHistoryVO.ACTION_EXPIRE);
+ }
+
+ /**
+ * 잠금·말소 공통 처리. 대상 계정별로 상태를 다시 읽어 조치 가능 여부를 서버에서 판정한다
+ * (화면 목록이 오래됐거나 요청이 위조된 경우를 걸러낸다).
+ */
+ private int processAction(String[] accountIds, String resn, String actorId, String actorIp, String actionTy)
+ throws Exception {
+ if (accountIds == null || accountIds.length == 0) {
+ return 0;
+ }
+ String reason = resn == null ? "" : resn.trim();
+ if (reason.length() > MAX_RESN_LEN) {
+ reason = reason.substring(0, MAX_RESN_LEN);
+ }
+
+ int done = 0;
+ for (int i = 0; i < accountIds.length; i++) {
+ String accountId = accountIds[i] == null ? "" : accountIds[i].trim();
+ if (accountId.length() == 0) {
+ continue;
+ }
+ // 자기 계정은 조치할 수 없다(관리자 자신을 잠가 운영이 막히는 사고 방지).
+ if (accountId.equals(actorId)) {
+ continue;
+ }
+
+ String state = accountMngDAO.selectDormantAccountState(accountId);
+ if (state == null || "D".equals(state)) {
+ continue; // 없는 계정이거나 이미 말소됨
+ }
+
+ boolean changed;
+ if (AccountActionHistoryVO.ACTION_LOCK.equals(actionTy)) {
+ changed = lockOne(accountId);
+ } else {
+ // 말소는 '잠금 후에도 미사용'인 계정만 — 잠기지 않은 계정은 건너뛴다.
+ if (!"L".equals(state)) {
+ continue;
+ }
+ changed = accountMngDAO.updateEmplyrExpire(accountId) > 0;
+ }
+ if (!changed) {
+ continue;
+ }
+
+ AccountActionHistoryVO hist = new AccountActionHistoryVO();
+ hist.setTrgetId(accountId);
+ hist.setTrgetSe("EMP");
+ hist.setActionTy(actionTy);
+ hist.setActionUserId(actorId);
+ hist.setActionResn(reason);
+ hist.setActionIp(actorIp);
+ hist.setNoConectDay(accountMngDAO.selectDormantAccountDays(accountId));
+ accountMngDAO.insertAccountActionHistory(hist);
+ done++;
+ }
+ return done;
+ }
+
+ /**
+ * 휴면행이 있으면 'P'로 승격, 없으면 신규 적재.
+ * 이미 확정('P')이면 아무것도 하지 않고 false — 같은 계정을 다시 눌러도 이력이 중복되지 않는다.
+ */
+ private boolean lockOne(String accountId) {
+ String stat = accountMngDAO.selectDiapauseStatEmp(accountId);
+ if (stat == null) {
+ accountMngDAO.insertDiapauseConfirmEmp(accountId);
+ return true;
+ }
+ if ("P".equals(stat.trim())) {
+ return false;
+ }
+ return accountMngDAO.updateDiapauseConfirmEmp(accountId) > 0;
+ }
}
diff --git a/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusVO.java b/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusVO.java
index c8f1b2b..d9a4e08 100644
--- a/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusVO.java
+++ b/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountStatusVO.java
@@ -23,9 +23,18 @@ public class DormantAccountStatusVO implements Serializable {
private String sttusCode; // 상태코드(EMPLYR_STTUS_CODE)
private String dormantExpectDt; // 휴면예정일(yyyy-MM-dd)
private String mberTyCode; // 회원유형(EMP/GNR)
+ private String roleNm; // 역할명(COMTNAUTHORINFO.AUTHOR_NM, 복수는 쉼표 연결)
+ /**
+ * 파생 계정상태 — 정책값 기준으로 SQL이 계산한다.
+ * N:사용 W:휴면예정(기준일수-1차알림일 경과) L:휴면·잠금(기준일수 경과 또는 휴면확정) D:말소
+ * EMPLYR_STTUS_CODE(sttusCode) 원본과 달리 '미접속 일수'를 함께 반영한 화면 표시용 상태다.
+ */
+ private String acntState;
// ---- 검색조건(#11) ----
private int minNoConnectDays = 0; // 미접속일수 하한 필터
+ private String searchKeyword; // 취급자명 또는 계정ID 통합검색
+ private String searchState; // 계정상태 필터(N/W/L/D) — 파생 acntState 대상
// ---- 페이징 ----
private int pageIndex = 1;
@@ -53,8 +62,16 @@ public class DormantAccountStatusVO implements Serializable {
public void setDormantExpectDt(String dormantExpectDt) { this.dormantExpectDt = dormantExpectDt; }
public String getMberTyCode() { return mberTyCode; }
public void setMberTyCode(String mberTyCode) { this.mberTyCode = mberTyCode; }
+ public String getRoleNm() { return roleNm; }
+ public void setRoleNm(String roleNm) { this.roleNm = roleNm; }
+ public String getAcntState() { return acntState; }
+ public void setAcntState(String acntState) { this.acntState = acntState; }
public int getMinNoConnectDays() { return minNoConnectDays; }
public void setMinNoConnectDays(int minNoConnectDays) { this.minNoConnectDays = minNoConnectDays; }
+ public String getSearchKeyword() { return searchKeyword; }
+ public void setSearchKeyword(String searchKeyword) { this.searchKeyword = searchKeyword; }
+ public String getSearchState() { return searchState; }
+ public void setSearchState(String searchState) { this.searchState = searchState; }
public int getPageIndex() { return pageIndex; }
public void setPageIndex(int pageIndex) { this.pageIndex = pageIndex; }
public int getRecordCountPerPage() { return recordCountPerPage; }
diff --git a/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountSummaryVO.java b/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountSummaryVO.java
new file mode 100644
index 0000000..ced8dee
--- /dev/null
+++ b/src/main/java/egovframework/com/cmm/privacy/account/DormantAccountSummaryVO.java
@@ -0,0 +1,56 @@
+package egovframework.com.cmm.privacy.account;
+
+import java.io.Serializable;
+
+/**
+ * 장기 미사용 계정 현황 요약 VO (#11 화면 상단 요약 카드).
+ *
+ *
취급자 전체 계정수와 휴면예정·휴면/잠금·말소 건수를 한 번의 조회로 담는다.
+ * 카드 라벨에 쓰이는 기준일수(dormantDay/warnDay)와 정책 적용상태는
+ * ACCOUNT_MNG_POLICY 값을 서비스 계층에서 채운다(하드코딩 없음, #9/#13 연계).
+ */
+public class DormantAccountSummaryVO implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ /** 취급자 전체 계정수. */
+ private int totCnt;
+ /** 휴면예정 건수(미접속 warnDay 이상). */
+ private int warnCnt;
+ /** 휴면·잠금 건수(미접속 dormantDay 이상 또는 휴면확정). */
+ private int lockCnt;
+ /** 말소 건수(EMPLYR_STTUS_CODE='D'). */
+ private int expiredCnt;
+
+ /** 휴면 전환(자동 잠금) 기준 미접속 일수 — ACCOUNT_MNG_POLICY.DORMANT_DAY. */
+ private int dormantDay;
+ /** 휴면예정 진입 기준 미접속 일수 — DORMANT_DAY - ALERT_D1. */
+ private int warnDay;
+ /** 배치 주기(DAILY/WEEKLY/MONTHLY). */
+ private String batchCycle;
+ /** 파기 배치 사용여부(Y/N). */
+ private String purgeUseYn;
+
+ public int getTotCnt() { return totCnt; }
+ public void setTotCnt(int totCnt) { this.totCnt = totCnt; }
+ public int getWarnCnt() { return warnCnt; }
+ public void setWarnCnt(int warnCnt) { this.warnCnt = warnCnt; }
+ public int getLockCnt() { return lockCnt; }
+ public void setLockCnt(int lockCnt) { this.lockCnt = lockCnt; }
+ public int getExpiredCnt() { return expiredCnt; }
+ public void setExpiredCnt(int expiredCnt) { this.expiredCnt = expiredCnt; }
+ public int getDormantDay() { return dormantDay; }
+ public void setDormantDay(int dormantDay) { this.dormantDay = dormantDay; }
+ public int getWarnDay() { return warnDay; }
+ public void setWarnDay(int warnDay) { this.warnDay = warnDay; }
+ public String getBatchCycle() { return batchCycle; }
+ public void setBatchCycle(String batchCycle) { this.batchCycle = batchCycle; }
+ public String getPurgeUseYn() { return purgeUseYn; }
+ public void setPurgeUseYn(String purgeUseYn) { this.purgeUseYn = purgeUseYn; }
+}
diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobDAO.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobDAO.java
new file mode 100644
index 0000000..f7a1eef
--- /dev/null
+++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobDAO.java
@@ -0,0 +1,84 @@
+package egovframework.com.cmm.privacy.auditlog;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.stereotype.Repository;
+
+import egovframework.com.cmm.service.impl.EgovComAbstractDAO;
+
+/**
+ * 접속기록 CSV 비동기 다운로드 작업(PDAL_DOWNLOAD_JOB) DAO.
+ *
+ *
개인정보보호 기능개선 사업(SFR-004-6 / SER-002). statement 는
+ * {@code PrivacyDownloadJob_SQL_Mysql.xml}(namespace {@code AccessLogDownloadJobDAO}).
+ * 신규 sqlMap 이므로 {@code sql-map-config-mysql-0-site.xml} 등록이 필요하다.
+ *
+ * @since 2026-08-07
+ */
+@Repository("accessLogDownloadJobDAO")
+public class AccessLogDownloadJobDAO extends EgovComAbstractDAO {
+
+ /** 요청 등록. 채번된 JOB_ID 는 selectKey 로 vo.jobId 에 채워진다. */
+ public long insertJob(AccessLogDownloadJobVO vo) {
+ insert("AccessLogDownloadJobDAO.insertJob", vo);
+ return vo.getJobId();
+ }
+
+ public AccessLogDownloadJobVO selectJob(long jobId) {
+ return (AccessLogDownloadJobVO) selectByPk("AccessLogDownloadJobDAO.selectJob", Long.valueOf(jobId));
+ }
+
+ /** 요청자 본인의 최근 작업 목록. */
+ @SuppressWarnings("unchecked")
+ public List selectJobListByRequester(String requesterId, int limitCnt) {
+ Map param = new HashMap();
+ param.put("requesterId", requesterId);
+ param.put("limitCnt", Integer.valueOf(limitCnt));
+ return list("AccessLogDownloadJobDAO.selectJobListByRequester", param);
+ }
+
+ /** 대기 상태의 가장 오래된 작업 ID. 없으면 null. */
+ public Long selectNextWaitingJobId() {
+ return (Long) selectByPk("AccessLogDownloadJobDAO.selectNextWaitingJobId", null);
+ }
+
+ /**
+ * 대기건 선점. 영향행수 1이면 이 워커가 소유권을 얻은 것이다.
+ * (WHERE 에 STATUS='W' 가 걸려 있어 동시 선점 시 한쪽만 성공한다.)
+ */
+ public boolean claimJob(long jobId) {
+ return update("AccessLogDownloadJobDAO.claimJob", Long.valueOf(jobId)) == 1;
+ }
+
+ public void updateProgress(AccessLogDownloadJobVO vo) {
+ update("AccessLogDownloadJobDAO.updateProgress", vo);
+ }
+
+ public void updateSuccess(AccessLogDownloadJobVO vo) {
+ update("AccessLogDownloadJobDAO.updateSuccess", vo);
+ }
+
+ public void updateFail(AccessLogDownloadJobVO vo) {
+ update("AccessLogDownloadJobDAO.updateFail", vo);
+ }
+
+ public void updateDownloaded(long jobId) {
+ update("AccessLogDownloadJobDAO.updateDownloaded", Long.valueOf(jobId));
+ }
+
+ @SuppressWarnings("unchecked")
+ public List selectExpiredJobs() {
+ return list("AccessLogDownloadJobDAO.selectExpiredJobs", null);
+ }
+
+ public void updatePurged(long jobId) {
+ update("AccessLogDownloadJobDAO.updatePurged", Long.valueOf(jobId));
+ }
+
+ /** WAS 비정상 종료로 'R' 에 멈춘 작업을 대기로 되돌린다(기동 시 1회). */
+ public int resetRunningJobs() {
+ return update("AccessLogDownloadJobDAO.resetRunningJobs", null);
+ }
+}
diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobService.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobService.java
new file mode 100644
index 0000000..c22ca7c
--- /dev/null
+++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobService.java
@@ -0,0 +1,40 @@
+package egovframework.com.cmm.privacy.auditlog;
+
+import java.util.List;
+
+/**
+ * 접속기록 CSV 비동기 다운로드 작업 서비스 (SFR-004-6 / SER-002).
+ *
+ *
다운로드를 '요청 → 백그라운드 생성 → 완료 후 내려받기'로 나눈다. 대용량 조건에서
+ * 전체를 메모리에 올린 뒤 응답에 싣던 방식의 메모리 압박·요청 타임아웃을 없애기 위함이다.
+ *
+ * @since 2026-08-07
+ */
+public interface AccessLogDownloadJobService {
+
+ /**
+ * 다운로드 작업을 등록한다(즉시 반환). 사유는 필수다.
+ *
+ * @return 등록된 작업 ID
+ * @throws IllegalArgumentException 사유 미입력
+ */
+ long requestJob(String requesterId, String requesterIp, String reason,
+ PersonalDataAccessLogSearchVO searchVO) throws Exception;
+
+ /** 요청자 본인의 최근 작업 목록(화면 표시·폴링용). */
+ List selectMyJobs(String requesterId, int limitCnt) throws Exception;
+
+ /**
+ * 내려받기용 작업 조회 — 요청자 본인인지 재검증한다.
+ * 작업ID를 바꿔치기해 남의 다운로드 파일을 가져가는 것을 막는다.
+ *
+ * @return 본인의 내려받기 가능한 작업. 아니면 null
+ */
+ AccessLogDownloadJobVO selectDownloadableJob(long jobId, String requesterId) throws Exception;
+
+ /** 내려받기 성공 시 횟수·일시를 기록한다(다운로드 이력은 별도 적재). */
+ void markDownloaded(long jobId) throws Exception;
+
+ /** 만료된 작업 파일을 삭제하고 상태를 파기(X)로 정리한다. 파기 건수 반환. */
+ int purgeExpiredJobs() throws Exception;
+}
diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobServiceImpl.java
new file mode 100644
index 0000000..a7a4bde
--- /dev/null
+++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobServiceImpl.java
@@ -0,0 +1,170 @@
+package egovframework.com.cmm.privacy.auditlog;
+
+import java.io.File;
+import java.util.List;
+
+import javax.annotation.Resource;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+/**
+ * 접속기록 CSV 비동기 다운로드 작업 서비스 구현 (SFR-004-6 / SER-002).
+ *
+ *
보관 최소화: 생성된 파일은 대량 개인정보 파일이라 오래 두지 않는다.
+ * {@link #expireDays}(기본 3일) 경과분은 파기 배치가 파일을 지우고 상태를 X 로 바꾼다.
+ *
+ *
권한 재검증: 내려받기는 요청자 본인만 가능하다. 작업ID는 연속 정수라
+ * 추측이 쉬우므로, 조회 단계에서 요청자ID 일치를 확인하지 않으면 남의 파일이 새어나간다.
+ *
+ * @since 2026-08-07
+ */
+@Service("accessLogDownloadJobService")
+public class AccessLogDownloadJobServiceImpl implements AccessLogDownloadJobService {
+
+ private static final Log LOG = LogFactory.getLog(AccessLogDownloadJobServiceImpl.class);
+
+ @Resource(name = "accessLogDownloadJobDAO")
+ private AccessLogDownloadJobDAO jobDAO;
+
+ /**
+ * 생성 파일 보관일수. 대량 개인정보 파일이므로 짧게 잡는다(기본 3일).
+ * 접속기록 본체의 보관기간(730일)과는 무관한, '내려받기용 사본'의 수명이다.
+ */
+ @Value("${Globals.privacyDownloadExpireDays:3}")
+ private int expireDays = 3;
+
+ @Override
+ public long requestJob(String requesterId, String requesterIp, String reason,
+ PersonalDataAccessLogSearchVO searchVO) throws Exception {
+
+ // 사유 필수 — 종전 동기 다운로드와 동일한 차단 기준을 유지한다.
+ if (reason == null || reason.trim().length() == 0) {
+ throw new IllegalArgumentException("다운로드 사유는 필수입니다.");
+ }
+ if (requesterId == null || requesterId.trim().length() == 0) {
+ throw new IllegalArgumentException("요청자를 확인할 수 없습니다.");
+ }
+ if (searchVO == null) {
+ searchVO = new PersonalDataAccessLogSearchVO();
+ }
+
+ AccessLogDownloadJobVO job = new AccessLogDownloadJobVO();
+ job.setRequesterId(requesterId);
+ job.setRequesterIp(requesterIp);
+ job.setDlReason(cut(reason.trim(), 500));
+ job.setSearchCond(cut(summarizeCond(searchVO), 1000));
+ job.setCondConectId(searchVO.getConectId());
+ job.setCondJobSeCode(searchVO.getJobSeCode());
+ job.setCondTrgetId(searchVO.getTrgetId());
+ job.setCondConectIp(searchVO.getConectIp());
+ job.setCondDmlGubun(searchVO.getDmlGubun());
+ job.setCondProcessResult(searchVO.getProcessResult());
+ job.setCondFromDt(searchVO.getFromDt());
+ job.setCondToDt(searchVO.getToDt());
+ job.setExpireDays(expireDays < 1 ? 3 : expireDays);
+
+ long jobId = jobDAO.insertJob(job);
+ LOG.info("[개인정보/SFR-004-6] 다운로드 작업 등록 — jobId=" + jobId + ", 요청자=" + requesterId
+ + ", 조건=" + job.getSearchCond());
+ return jobId;
+ }
+
+ @Override
+ public List selectMyJobs(String requesterId, int limitCnt) throws Exception {
+ if (requesterId == null || requesterId.trim().length() == 0) {
+ return java.util.Collections.emptyList();
+ }
+ return jobDAO.selectJobListByRequester(requesterId, limitCnt < 1 ? 10 : limitCnt);
+ }
+
+ @Override
+ public AccessLogDownloadJobVO selectDownloadableJob(long jobId, String requesterId) throws Exception {
+ AccessLogDownloadJobVO job = jobDAO.selectJob(jobId);
+ if (job == null) {
+ return null;
+ }
+ // 본인 확인 — 작업ID 추측으로 남의 파일을 가져가는 것을 막는다.
+ if (requesterId == null || !requesterId.equals(job.getRequesterId())) {
+ LOG.warn("[개인정보/SFR-004-6] 타인 다운로드 시도 차단 — jobId=" + jobId
+ + ", 소유자=" + job.getRequesterId() + ", 요청자=" + requesterId);
+ return null;
+ }
+ if (!job.isDownloadable()) {
+ return null;
+ }
+ File f = new File(job.getFilePath());
+ if (!f.exists() || !f.isFile()) {
+ LOG.warn("[개인정보/SFR-004-6] 파일 없음(파기됐거나 유실) — jobId=" + jobId);
+ return null;
+ }
+ return job;
+ }
+
+ @Override
+ public void markDownloaded(long jobId) throws Exception {
+ jobDAO.updateDownloaded(jobId);
+ }
+
+ @Override
+ public int purgeExpiredJobs() throws Exception {
+ List expired = jobDAO.selectExpiredJobs();
+ int purged = 0;
+ if (expired != null) {
+ for (int i = 0; i < expired.size(); i++) {
+ AccessLogDownloadJobVO job = expired.get(i);
+ String path = job.getFilePath();
+ if (path != null && path.length() > 0) {
+ try {
+ File f = new File(path);
+ if (f.exists() && !f.delete()) {
+ // 삭제 실패는 개인정보 파일이 남는다는 뜻이라 조용히 넘기지 않는다.
+ LOG.error("[개인정보/SFR-004-6] 다운로드 파일 삭제 실패 — jobId=" + job.getJobId()
+ + ", path=" + path + " (수동 삭제 필요)");
+ continue;
+ }
+ } catch (Exception e) {
+ LOG.error("[개인정보/SFR-004-6] 다운로드 파일 삭제 중 예외 — jobId=" + job.getJobId(), e);
+ continue;
+ }
+ }
+ jobDAO.updatePurged(job.getJobId());
+ purged++;
+ }
+ }
+ LOG.info("[개인정보/SFR-004-6] 만료 다운로드 파일 파기 완료 — " + purged + "건(보관 " + expireDays + "일)");
+ return purged;
+ }
+
+ /** 검색조건을 개인정보 본문 없이 요약. */
+ private String summarizeCond(PersonalDataAccessLogSearchVO vo) {
+ StringBuilder sb = new StringBuilder();
+ appendCond(sb, "conectId", vo.getConectId());
+ appendCond(sb, "jobSeCode", vo.getJobSeCode());
+ appendCond(sb, "trgetId", vo.getTrgetId());
+ appendCond(sb, "conectIp", vo.getConectIp());
+ appendCond(sb, "dmlGubun", vo.getDmlGubun());
+ appendCond(sb, "processResult", vo.getProcessResult());
+ appendCond(sb, "fromDt", vo.getFromDt());
+ appendCond(sb, "toDt", vo.getToDt());
+ return sb.length() == 0 ? "(전체)" : sb.toString();
+ }
+
+ private void appendCond(StringBuilder sb, String key, String val) {
+ if (val != null && val.trim().length() > 0) {
+ if (sb.length() > 0) {
+ sb.append(", ");
+ }
+ sb.append(key).append("=").append(val.trim());
+ }
+ }
+
+ private static String cut(String s, int max) {
+ if (s == null) {
+ return null;
+ }
+ return (s.length() > max) ? s.substring(0, max) : s;
+ }
+}
diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobVO.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobVO.java
new file mode 100644
index 0000000..03b5e17
--- /dev/null
+++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobVO.java
@@ -0,0 +1,161 @@
+package egovframework.com.cmm.privacy.auditlog;
+
+import java.io.Serializable;
+
+/**
+ * 개인정보 접속기록 CSV 비동기 다운로드 작업(PDAL_DOWNLOAD_JOB) VO.
+ *
+ *
개인정보보호 기능개선 사업(SFR-004-6 / SER-002) — 대용량 다운로드를 요청 즉시 응답하고
+ * 백그라운드에서 파일을 만들기 위한 작업 한 건.
+ *
+ *
검색조건을 컬럼으로 보존하는 이유: 백그라운드 워커는 요청 세션이 끝난 뒤 실행되므로
+ * 조건을 스스로 재현해야 한다. 담는 값은 식별자·코드·기간뿐이라 접속기록 본문 미적재 원칙에
+ * 어긋나지 않는다.
+ *
+ * @since 2026-08-07
+ */
+public class AccessLogDownloadJobVO implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ /** 상태: 대기 */
+ public static final String ST_WAIT = "W";
+ /** 상태: 생성중 */
+ public static final String ST_RUNNING = "R";
+ /** 상태: 완료(내려받기 가능) */
+ public static final String ST_SUCCESS = "S";
+ /** 상태: 실패 */
+ public static final String ST_FAIL = "F";
+ /** 상태: 파기(파일 삭제됨) */
+ public static final String ST_PURGED = "X";
+
+ private long jobId;
+ private String requesterId;
+ private String requesterIp;
+ private String dlReason;
+ private String searchCond;
+
+ // 조건 재현용 — 프로퍼티명을 cond* 로 둔다.
+ // getFConectId 처럼 '소문자1자+대문자' 형태면 JavaBeans 인트로스펙션이 프로퍼티를
+ // 'FConectId' 로 해석해 iBatis 바인딩(#fConectId#)이 조용히 실패한다.
+ private String condConectId;
+ private String condJobSeCode;
+ private String condTrgetId;
+ private String condConectIp;
+ private String condDmlGubun;
+ private String condProcessResult;
+ private String condFromDt;
+ private String condToDt;
+
+ private String status;
+ private int totalCnt;
+ private int doneCnt;
+ private String fileNm;
+ private String filePath;
+ private long fileSize;
+ private String errMsg;
+ private int downloadCnt;
+ private String lastDlDt;
+ private String regDt;
+ private String startDt;
+ private String endDt;
+ private String expireDt;
+
+ /** 만료일(일). 서비스가 EXPIRE_DT 계산에 쓰는 값을 화면에 알리기 위한 비영속 필드. */
+ private int expireDays;
+
+ public long getJobId() { return jobId; }
+ public void setJobId(long jobId) { this.jobId = jobId; }
+
+ public String getRequesterId() { return requesterId; }
+ public void setRequesterId(String requesterId) { this.requesterId = requesterId; }
+
+ public String getRequesterIp() { return requesterIp; }
+ public void setRequesterIp(String requesterIp) { this.requesterIp = requesterIp; }
+
+ public String getDlReason() { return dlReason; }
+ public void setDlReason(String dlReason) { this.dlReason = dlReason; }
+
+ public String getSearchCond() { return searchCond; }
+ public void setSearchCond(String searchCond) { this.searchCond = searchCond; }
+
+ public String getCondConectId() { return condConectId; }
+ public void setCondConectId(String condConectId) { this.condConectId = condConectId; }
+
+ public String getCondJobSeCode() { return condJobSeCode; }
+ public void setCondJobSeCode(String condJobSeCode) { this.condJobSeCode = condJobSeCode; }
+
+ public String getCondTrgetId() { return condTrgetId; }
+ public void setCondTrgetId(String condTrgetId) { this.condTrgetId = condTrgetId; }
+
+ public String getCondConectIp() { return condConectIp; }
+ public void setCondConectIp(String condConectIp) { this.condConectIp = condConectIp; }
+
+ public String getCondDmlGubun() { return condDmlGubun; }
+ public void setCondDmlGubun(String condDmlGubun) { this.condDmlGubun = condDmlGubun; }
+
+ public String getCondProcessResult() { return condProcessResult; }
+ public void setCondProcessResult(String condProcessResult) { this.condProcessResult = condProcessResult; }
+
+ public String getCondFromDt() { return condFromDt; }
+ public void setCondFromDt(String condFromDt) { this.condFromDt = condFromDt; }
+
+ public String getCondToDt() { return condToDt; }
+ public void setCondToDt(String condToDt) { this.condToDt = condToDt; }
+
+ public String getStatus() { return status; }
+ public void setStatus(String status) { this.status = status; }
+
+ public int getTotalCnt() { return totalCnt; }
+ public void setTotalCnt(int totalCnt) { this.totalCnt = totalCnt; }
+
+ public int getDoneCnt() { return doneCnt; }
+ public void setDoneCnt(int doneCnt) { this.doneCnt = doneCnt; }
+
+ public String getFileNm() { return fileNm; }
+ public void setFileNm(String fileNm) { this.fileNm = fileNm; }
+
+ public String getFilePath() { return filePath; }
+ public void setFilePath(String filePath) { this.filePath = filePath; }
+
+ public long getFileSize() { return fileSize; }
+ public void setFileSize(long fileSize) { this.fileSize = fileSize; }
+
+ public String getErrMsg() { return errMsg; }
+ public void setErrMsg(String errMsg) { this.errMsg = errMsg; }
+
+ public int getDownloadCnt() { return downloadCnt; }
+ public void setDownloadCnt(int downloadCnt) { this.downloadCnt = downloadCnt; }
+
+ public String getLastDlDt() { return lastDlDt; }
+ public void setLastDlDt(String lastDlDt) { this.lastDlDt = lastDlDt; }
+
+ public String getRegDt() { return regDt; }
+ public void setRegDt(String regDt) { this.regDt = regDt; }
+
+ public String getStartDt() { return startDt; }
+ public void setStartDt(String startDt) { this.startDt = startDt; }
+
+ public String getEndDt() { return endDt; }
+ public void setEndDt(String endDt) { this.endDt = endDt; }
+
+ public String getExpireDt() { return expireDt; }
+ public void setExpireDt(String expireDt) { this.expireDt = expireDt; }
+
+ public int getExpireDays() { return expireDays; }
+ public void setExpireDays(int expireDays) { this.expireDays = expireDays; }
+
+ /** 진행률(%). 총건수 미확정이면 0. */
+ public int getProgressPct() {
+ if (totalCnt <= 0) {
+ return ST_SUCCESS.equals(status) ? 100 : 0;
+ }
+ long pct = (long) doneCnt * 100L / totalCnt;
+ return (pct > 100L) ? 100 : (int) pct;
+ }
+
+ /** 내려받기 가능 여부(완료 + 파일 보유). */
+ public boolean isDownloadable() {
+ return ST_SUCCESS.equals(status) && filePath != null && filePath.length() > 0;
+ }
+}
diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobWorker.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobWorker.java
new file mode 100644
index 0000000..216769c
--- /dev/null
+++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadJobWorker.java
@@ -0,0 +1,357 @@
+package egovframework.com.cmm.privacy.auditlog;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.OutputStreamWriter;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.List;
+
+import javax.annotation.PostConstruct;
+import javax.annotation.PreDestroy;
+import javax.annotation.Resource;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import egovframework.com.utl.sim.service.EgovFileScrty;
+
+/**
+ * 접속기록 CSV 비동기 다운로드 워커 (SFR-004-6 / SER-002).
+ *
+ *
대기(W) 작업을 하나씩 집어 CSV 파일을 만든다. 단일 워커 스레드다 —
+ * 대량 조회가 동시에 여러 건 돌면 DB 부하가 그대로 서비스 지연이 되기 때문이다.
+ *
+ *
메모리를 쓰지 않는 방식: 전체를 List 로 올리지 않고 {@link #CHUNK_ROWS}건씩
+ * 끊어 조회하며 곧바로 파일에 쓴다. 100만 건이어도 상주 메모리는 한 청크뿐이다.
+ * 이것이 이 작업의 핵심이며, 종전 동기 다운로드의 OOM 위험을 없앤다.
+ *
+ *
파일은 암호화해 둔다: 생성 결과는 '대량 개인정보 파일'이다. 평문 CSV 를 디스크에
+ * 남기지 않기 위해 {@link EgovFileScrty#encryptFile}로 암호화한 뒤 평문 임시파일을 지운다.
+ * 내려받을 때 복호화해 스트리밍한다. 저장 위치는 반드시 웹 루트 밖이어야 한다 —
+ * webapps 하위에 두면 URL 직접 접근으로 무인증 유출이 된다.
+ *
+ *
기동 시 고아 복구: 파일 생성 중 WAS 가 죽으면 작업이 R 로 굳는다. 기동 시
+ * R → W 로 되돌려 재시도한다(화면에 '생성중'이 영원히 멈춰 보이는 것 방지).
+ *
+ * @since 2026-08-07
+ */
+@Component("accessLogDownloadJobWorker")
+public class AccessLogDownloadJobWorker {
+
+ private static final Log LOG = LogFactory.getLog(AccessLogDownloadJobWorker.class);
+
+ /** 한 번에 조회·기록할 행 수. 메모리 상주량을 결정한다. */
+ private static final int CHUNK_ROWS = 2000;
+ /** 대기 작업이 없을 때 다음 확인까지 쉬는 시간(ms). */
+ private static final long IDLE_SLEEP_MS = 3000L;
+ /** 예외 발생 후 폭주 방지 대기(ms). */
+ private static final long ERROR_SLEEP_MS = 10000L;
+
+ private static final String CSV_HEADER =
+ "로그번호,접속일시,접속자ID,처리자구분,접속IP,수행업무코드,수행업무명,처리구분,처리대상ID,처리대상범위,처리결과,무결성해시";
+
+ @Resource(name = "accessLogDownloadJobDAO")
+ private AccessLogDownloadJobDAO jobDAO;
+
+ @Resource(name = "personalDataAccessLogSearchService")
+ private PersonalDataAccessLogSearchService searchService;
+
+ /**
+ * 생성 파일 저장 디렉터리. 반드시 웹 루트(webapps) 밖이어야 한다.
+ * 미설정 시 기동 로그에 경고를 남기고 워커를 띄우지 않는다 — 임의 경로에
+ * 개인정보 파일을 만드는 것보다 기능을 멈추는 편이 안전하다.
+ */
+ @Value("${Globals.privacyDownloadPath:}")
+ private String downloadPath;
+
+ private volatile boolean running = false;
+ private volatile Thread worker;
+
+ @PostConstruct
+ public void start() {
+ // 저장경로가 없거나 만들 수 없어도 워커는 띄운다.
+ // 종전에는 여기서 return 해 워커를 아예 기동하지 않았고, 그 결과 요청은 W 로 정상 등록되지만
+ // 집어갈 주체가 없어 화면이 '대기'에서 영원히 멈췄다(사용자에겐 아무 단서도 보이지 않았다).
+ // 게다가 화면 폴링이 그 상태를 5초마다 새로고침하며 접속기록을 자가증식시켰다.
+ // 지금은 작업마다 경로를 확인해 확보 불가면 그 작업을 '실패'로 확정한다 —
+ // 사유가 화면에 뜨고, 경로가 나중에 생기면 재기동 없이 다음 작업부터 정상 처리된다.
+ try {
+ ensureDir();
+ LOG.info("[개인정보/SFR-004-6] 저장경로 확인 완료 — " + downloadPath);
+ } catch (Exception e) {
+ LOG.error("[개인정보/SFR-004-6] 저장경로를 확보하지 못했다 — " + e.getMessage()
+ + " 워커는 기동하되, 경로가 확보될 때까지 접수된 작업은 '실패'로 처리한다. "
+ + "웹 루트 밖 경로를 지정·생성할 것(예: /app/egovframework/privacy-download/).");
+ }
+ try {
+ int reset = jobDAO.resetRunningJobs();
+ if (reset > 0) {
+ LOG.warn("[개인정보/SFR-004-6] 이전 기동에서 생성 중이던 작업 " + reset + "건을 대기로 되돌려 재시도한다.");
+ }
+ } catch (Exception e) {
+ LOG.error("[개인정보/SFR-004-6] 고아 작업 복구 실패(계속 진행)", e);
+ }
+
+ running = true;
+ Thread t = new Thread(new Runnable() {
+ public void run() {
+ loop();
+ }
+ }, "pdal-download-worker");
+ t.setDaemon(true);
+ worker = t;
+ t.start();
+ LOG.info("[개인정보/SFR-004-6] 비동기 다운로드 워커 기동 — 저장경로=" + downloadPath
+ + ", 청크=" + CHUNK_ROWS + "행");
+ }
+
+ @PreDestroy
+ public void stop() {
+ running = false;
+ Thread t = worker;
+ if (t != null) {
+ t.interrupt();
+ }
+ }
+
+ /** 워커 루프 — 대기건을 하나씩 선점해 처리한다. Throwable 까지 흡수해 스스로 죽지 않는다. */
+ private void loop() {
+ while (running) {
+ try {
+ Long jobId = jobDAO.selectNextWaitingJobId();
+ if (jobId == null) {
+ Thread.sleep(IDLE_SLEEP_MS);
+ continue;
+ }
+ if (!jobDAO.claimJob(jobId.longValue())) {
+ // 다른 워커/재시도와 경합해 선점 실패 — 즉시 다음 것을 본다.
+ continue;
+ }
+ process(jobId.longValue());
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ break;
+ } catch (Throwable t) {
+ LOG.error("[개인정보/SFR-004-6] 워커 루프 예외(계속)", t);
+ try {
+ Thread.sleep(ERROR_SLEEP_MS);
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ }
+ }
+ LOG.info("[개인정보/SFR-004-6] 비동기 다운로드 워커 종료.");
+ }
+
+ /** 작업 1건 처리 — 청크 조회 → CSV 기록 → 암호화 → 완료 확정. */
+ private void process(long jobId) {
+ AccessLogDownloadJobVO job = null;
+ File plain = null;
+ File enc = null;
+ try {
+ job = jobDAO.selectJob(jobId);
+ if (job == null) {
+ return;
+ }
+
+ PersonalDataAccessLogSearchVO searchVO = toSearchVO(job);
+ int total = searchService.selectAccessLogListTotCnt(searchVO);
+ job.setTotalCnt(total);
+ job.setDoneCnt(0);
+ jobDAO.updateProgress(job);
+
+ // 작업마다 저장경로를 확인한다 — 기동 후 경로가 생겼다면 재기동 없이 여기서 살아난다.
+ // 확보 불가면 예외가 나 아래 catch 에서 이 작업이 '실패'로 확정된다(무한 대기 방지).
+ File dir = ensureDir();
+
+ String stamp = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
+ String baseNm = "개인정보접속기록_" + stamp + "_" + jobId;
+ plain = new File(dir, baseNm + ".csv.tmp");
+ enc = new File(dir, baseNm + ".csv.enc");
+
+ int written = writeCsv(plain, searchVO, total, job);
+
+ // 평문 CSV 를 디스크에 남기지 않는다 — 암호화 후 즉시 삭제.
+ if (!EgovFileScrty.encryptFile(plain.getAbsolutePath(), enc.getAbsolutePath())) {
+ throw new IllegalStateException("파일 암호화 실패");
+ }
+ deleteQuietly(plain);
+ plain = null;
+
+ job.setDoneCnt(written);
+ job.setTotalCnt(total);
+ job.setFileNm(baseNm + ".csv");
+ job.setFilePath(enc.getAbsolutePath());
+ job.setFileSize(enc.length());
+ jobDAO.updateSuccess(job);
+ LOG.info("[개인정보/SFR-004-6] 다운로드 파일 생성 완료 — jobId=" + jobId + ", " + written + "행, "
+ + enc.length() + "byte");
+
+ } catch (Throwable t) {
+ LOG.error("[개인정보/SFR-004-6] 다운로드 파일 생성 실패 — jobId=" + jobId, t);
+ deleteQuietly(plain);
+ deleteQuietly(enc);
+ try {
+ AccessLogDownloadJobVO fail = (job != null) ? job : new AccessLogDownloadJobVO();
+ fail.setJobId(jobId);
+ fail.setErrMsg(cut(String.valueOf(t.getMessage()), 500));
+ jobDAO.updateFail(fail);
+ } catch (Exception e) {
+ LOG.error("[개인정보/SFR-004-6] 실패 상태 기록마저 실패 — jobId=" + jobId, e);
+ }
+ }
+ }
+
+ /**
+ * 청크 단위로 조회하며 곧바로 파일에 기록한다(전체를 메모리에 올리지 않는다).
+ *
+ * @return 기록한 행 수
+ */
+ private int writeCsv(File target, PersonalDataAccessLogSearchVO searchVO, int total,
+ AccessLogDownloadJobVO job) throws Exception {
+ BufferedWriter w = null;
+ int written = 0;
+ try {
+ FileOutputStream fos = new FileOutputStream(target);
+ // UTF-8 BOM — 엑셀에서 한글이 깨지지 않게 한다(기존 동기 다운로드와 동일).
+ fos.write(0xEF); fos.write(0xBB); fos.write(0xBF);
+ w = new BufferedWriter(new OutputStreamWriter(fos, "UTF-8"));
+ w.write(CSV_HEADER);
+ w.newLine();
+
+ int page = 1;
+ while (written < total) {
+ searchVO.setPageIndex(page);
+ searchVO.setRecordCountPerPage(CHUNK_ROWS);
+ List chunk = searchService.selectAccessLogList(searchVO);
+ if (chunk == null || chunk.isEmpty()) {
+ break;
+ }
+ for (int i = 0; i < chunk.size(); i++) {
+ w.write(toCsvLine(chunk.get(i)));
+ w.newLine();
+ written++;
+ }
+ w.flush();
+
+ // 진행률 갱신 — 화면 폴링이 실제로 움직이는 값을 보게 한다.
+ job.setDoneCnt(written);
+ job.setTotalCnt(total);
+ try {
+ jobDAO.updateProgress(job);
+ } catch (Exception e) {
+ // 진행률은 부가정보다 — 실패해도 생성은 계속한다.
+ LOG.warn("[개인정보/SFR-004-6] 진행률 갱신 실패(무시) — jobId=" + job.getJobId());
+ }
+ page++;
+ }
+ w.flush();
+ } finally {
+ if (w != null) {
+ try { w.close(); } catch (Exception ignore) { /* 닫기 실패는 무시 */ }
+ }
+ }
+ return written;
+ }
+
+ private String toCsvLine(PersonalDataAccessLogVO v) {
+ StringBuilder sb = new StringBuilder(256);
+ sb.append(v.getLogId()).append(',')
+ .append(csv(v.getConectDt())).append(',')
+ .append(csv(v.getConectId())).append(',')
+ .append(csv(v.getUserSe())).append(',')
+ .append(csv(v.getConectIp())).append(',')
+ .append(csv(v.getJobSeCode())).append(',')
+ .append(csv(v.getJobSeNm())).append(',')
+ .append(csv(dmlLabel(v.getDmlGubun()))).append(',')
+ .append(csv(v.getTrgetId())).append(',')
+ .append(csv(v.getTrgetCn())).append(',')
+ .append(csv(resultLabel(v.getProcessResult()))).append(',')
+ .append(csv(v.getHashVal()));
+ return sb.toString();
+ }
+
+ private PersonalDataAccessLogSearchVO toSearchVO(AccessLogDownloadJobVO job) {
+ PersonalDataAccessLogSearchVO vo = new PersonalDataAccessLogSearchVO();
+ vo.setConectId(job.getCondConectId());
+ vo.setJobSeCode(job.getCondJobSeCode());
+ vo.setTrgetId(job.getCondTrgetId());
+ vo.setConectIp(job.getCondConectIp());
+ vo.setDmlGubun(job.getCondDmlGubun());
+ vo.setProcessResult(job.getCondProcessResult());
+ vo.setFromDt(job.getCondFromDt());
+ vo.setToDt(job.getCondToDt());
+ return vo;
+ }
+
+ /**
+ * 저장 디렉터리를 확보한다(없으면 생성).
+ *
+ *
실패는 예외로 알린다. 조용히 null 을 돌려주면 호출측이 그냥 넘어가고,
+ * 그 결과가 '영원한 대기'였다. 메시지는 그대로 작업의 실패사유(ERR_MSG)로 화면에 노출되므로
+ * 운영자가 원인을 바로 알 수 있게 경로를 포함해 적는다.
+ */
+ private File ensureDir() {
+ if (downloadPath == null || downloadPath.trim().length() == 0) {
+ throw new IllegalStateException("다운로드 저장경로 미설정(Globals.privacyDownloadPath)");
+ }
+ File dir = new File(downloadPath.trim());
+ if (!dir.exists() && !dir.mkdirs()) {
+ throw new IllegalStateException("다운로드 저장경로 생성 실패 — " + dir.getAbsolutePath());
+ }
+ if (!dir.isDirectory()) {
+ throw new IllegalStateException("다운로드 저장경로가 디렉터리가 아님 — " + dir.getAbsolutePath());
+ }
+ if (!dir.canWrite()) {
+ throw new IllegalStateException("다운로드 저장경로에 쓸 수 없음(WAS 계정 권한 확인) — " + dir.getAbsolutePath());
+ }
+ return dir;
+ }
+
+ private void deleteQuietly(File f) {
+ if (f != null && f.exists()) {
+ try {
+ if (!f.delete()) {
+ LOG.warn("[개인정보/SFR-004-6] 임시파일 삭제 실패(수동 확인 필요) — " + f.getAbsolutePath());
+ }
+ } catch (Exception ignore) {
+ LOG.warn("[개인정보/SFR-004-6] 임시파일 삭제 중 예외 — " + f.getAbsolutePath());
+ }
+ }
+ }
+
+ /** CSV 셀 이스케이프(구분자·따옴표·개행 방어). */
+ private String csv(String v) {
+ if (v == null) {
+ return "";
+ }
+ String s = v.replace("\"", "\"\"");
+ return "\"" + s.replace("\r", " ").replace("\n", " ") + "\"";
+ }
+
+ private String dmlLabel(String code) {
+ if ("S".equals(code)) return "조회";
+ if ("I".equals(code)) return "등록";
+ if ("U".equals(code)) return "수정";
+ if ("D".equals(code)) return "삭제";
+ return code;
+ }
+
+ private String resultLabel(String code) {
+ if ("S".equals(code)) return "성공";
+ if ("F".equals(code)) return "실패/차단";
+ return code;
+ }
+
+ private static String cut(String s, int max) {
+ if (s == null) {
+ return null;
+ }
+ return (s.length() > max) ? s.substring(0, max) : s;
+ }
+}
diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadService.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadService.java
index ec0013f..7bc5e4c 100644
--- a/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadService.java
+++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadService.java
@@ -25,4 +25,21 @@ public interface AccessLogDownloadService {
*/
List downloadAccessLog(String downloaderId, String downloaderIp,
String reason, PersonalDataAccessLogSearchVO searchVO) throws Exception;
+
+ /**
+ * 다운로드 이력만 적재한다(조회 없이).
+ *
+ *
비동기 다운로드(SFR-004-6)는 파일을 백그라운드에서 미리 만들어 두므로, 실제로
+ * 내려받은 시점에 이력을 남겨야 증빙이 맞다. 조회는 이미 끝나 있어 건수·조건을
+ * 작업 정보에서 그대로 받는다.
+ *
+ * @param downloaderId 다운로드자 계정ID
+ * @param downloaderIp 다운로드자 접속 IP
+ * @param reason 다운로드 사유(요청 시 입력한 값)
+ * @param searchCond 검색조건 요약(개인정보 본문 미포함)
+ * @param resultCnt 내려받은 건수
+ * @throws Exception 적재 오류
+ */
+ void recordDownloadHistory(String downloaderId, String downloaderIp, String reason,
+ String searchCond, int resultCnt) throws Exception;
}
diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadServiceImpl.java
index e94e080..e431591 100644
--- a/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadServiceImpl.java
+++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/AccessLogDownloadServiceImpl.java
@@ -58,6 +58,18 @@ public class AccessLogDownloadServiceImpl implements AccessLogDownloadService {
return result;
}
+ @Override
+ public void recordDownloadHistory(String downloaderId, String downloaderIp, String reason,
+ String searchCond, int resultCnt) throws Exception {
+ AccessLogDownloadHistoryVO hist = new AccessLogDownloadHistoryVO();
+ hist.setDownloaderId(downloaderId);
+ hist.setDownloaderIp(downloaderIp);
+ hist.setDlReason(reason == null ? "" : reason.trim());
+ hist.setSearchCond(searchCond);
+ hist.setResultCnt(resultCnt);
+ downloadHistoryDAO.insertDownloadHistory(hist);
+ }
+
/** 검색조건을 개인정보 본문 없이 요약 문자열로. */
private String summarizeCond(PersonalDataAccessLogSearchVO vo) {
StringBuilder sb = new StringBuilder();
diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogController.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogController.java
index ab8dfe6..2b4de76 100644
--- a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogController.java
+++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogController.java
@@ -1,5 +1,7 @@
package egovframework.com.cmm.privacy.auditlog;
+import java.io.File;
+import java.io.FileInputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
@@ -7,7 +9,9 @@ import java.net.URLEncoder;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
@@ -21,6 +25,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import egovframework.com.cmm.LoginVO;
import egovframework.com.cmm.util.EgovUserDetailsHelper;
+import egovframework.com.utl.sim.service.EgovFileScrty;
import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo;
/**
@@ -59,6 +64,9 @@ public class PersonalDataAccessLogController {
@Resource(name = "personalDataAccessLogSearchDAO")
private PersonalDataAccessLogSearchDAO personalDataAccessLogSearchDAO;
+ @Resource(name = "accessLogDownloadJobService")
+ private AccessLogDownloadJobService accessLogDownloadJobService;
+
/**
* 개인정보 접속기록 조회 화면(서버 렌더링).
* 뷰: /WEB-INF/jsp/site/mnt/sec/accessLog/list.jsp
@@ -82,12 +90,178 @@ public class PersonalDataAccessLogController {
model.addAttribute("resultList", list);
model.addAttribute("paginationInfo", paginationInfo);
model.addAttribute("jobSeList", selectJobSeCodeList());
+ // 현재 검색조건 기준 조회/등록/수정/삭제 건수 — 목록을 넘겨보지 않고 처리행위를 판별하기 위함
+ model.addAttribute("dmlSummary", selectDmlSummary(searchVO));
+ // 내 다운로드 작업 목록(비동기) — 상태·진행률 확인 및 내려받기
+ model.addAttribute("dlJobList", selectMyDownloadJobs());
return "site/mnt/sec/accessLog/list";
}
/**
- * 접속기록 다운로드(CSV) — 사유 필수 + 다운로드 이력 적재(#42).
- * 사유 미입력·차단 시 파일을 열지 않고 400 으로 응답한다.
+ * 다운로드 요청(비동기) — 작업만 등록하고 즉시 목록으로 돌아온다.
+ *
+ *
대용량 조건에서 전체를 메모리에 올려 응답에 싣던 방식(아래 {@link #download})은
+ * OOM·요청 타임아웃 위험이 있어, 파일 생성은 백그라운드 워커가 맡는다.
+ * 사유는 여기서도 필수이며, 미입력이면 작업을 만들지 않는다.
+ */
+ @RequestMapping(value = "/mnt/sec/accessLog/downloadRequest.do")
+ public String downloadRequest(@ModelAttribute("personalDataAccessLogSearchVO") PersonalDataAccessLogSearchVO searchVO,
+ @RequestParam(value = "dlReason", required = false) String dlReason,
+ HttpServletRequest request, ModelMap model) throws Exception {
+ LoginVO user = handlerUser();
+ if (user == null) {
+ return LOGIN_REDIRECT;
+ }
+ try {
+ accessLogDownloadJobService.requestJob(user.getId(),
+ ClientIpResolver.getClientIp(request), dlReason, searchVO);
+ } catch (IllegalArgumentException e) {
+ model.addAttribute("dlMessage", e.getMessage());
+ } catch (Exception e) {
+ model.addAttribute("dlMessage", "다운로드 요청 중 오류가 발생했습니다.");
+ }
+ // 검색조건을 유지한 채 목록으로 — 요청 직후 작업 목록에서 진행상태를 볼 수 있다.
+ return listView(searchVO, model);
+ }
+
+ /**
+ * 완료된 다운로드 파일 내려받기 — 요청자 본인만 가능하다.
+ *
+ *
저장 파일은 암호화돼 있어 복호화 후 스트리밍한다. 임시 복호화본은 전송 직후 지운다.
+ * 실제 내려받은 시점의 사유·건수 이력은 기존 {@code PDAL_DOWNLOAD_HISTORY} 에 적재한다.
+ */
+ @RequestMapping(value = "/mnt/sec/accessLog/downloadFile.do")
+ public void downloadFile(@RequestParam("jobId") long jobId,
+ HttpServletRequest request, HttpServletResponse response) throws Exception {
+ LoginVO user = handlerUser();
+ if (user == null) {
+ response.sendError(HttpServletResponse.SC_FORBIDDEN, "접근 권한이 없습니다.");
+ return;
+ }
+ AccessLogDownloadJobVO job = accessLogDownloadJobService.selectDownloadableJob(jobId, user.getId());
+ if (job == null) {
+ // 존재/소유/완료 여부를 구분해 알리지 않는다(작업ID 탐색 방지).
+ response.sendError(HttpServletResponse.SC_NOT_FOUND, "내려받을 수 있는 파일이 없습니다.");
+ return;
+ }
+
+ File plain = File.createTempFile("pdal-dl-", ".csv");
+ try {
+ if (!EgovFileScrty.decryptFile(job.getFilePath(), plain.getAbsolutePath())) {
+ response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "파일을 열 수 없습니다.");
+ return;
+ }
+
+ String fileName = URLEncoder.encode(job.getFileNm(), "UTF-8");
+ response.setContentType("text/csv; charset=UTF-8");
+ response.setHeader("Content-Disposition",
+ "attachment; filename=\"" + fileName + "\"; filename*=UTF-8''" + fileName);
+ response.setHeader("Content-Transfer-Encoding", "binary");
+ response.setHeader("Content-Length", String.valueOf(plain.length()));
+
+ FileInputStream in = null;
+ OutputStream os = response.getOutputStream();
+ try {
+ in = new FileInputStream(plain);
+ byte[] buf = new byte[8192];
+ int len;
+ while ((len = in.read(buf)) > 0) {
+ os.write(buf, 0, len);
+ }
+ os.flush();
+ } finally {
+ if (in != null) {
+ try { in.close(); } catch (Exception ignore) { /* 무시 */ }
+ }
+ }
+
+ accessLogDownloadJobService.markDownloaded(jobId);
+ // 내려받은 사실을 기존 다운로드 이력에도 남긴다(사유·건수 증빙 일원화).
+ accessLogDownloadService.recordDownloadHistory(user.getId(), ClientIpResolver.getClientIp(request),
+ job.getDlReason(), job.getSearchCond(), job.getDoneCnt());
+ } finally {
+ // 복호화 임시본을 남기지 않는다.
+ if (plain.exists() && !plain.delete()) {
+ plain.deleteOnExit();
+ }
+ }
+ }
+
+ /**
+ * 내 다운로드 작업의 상태만 돌려주는 폴링 전용 엔드포인트(#47 후속).
+ *
+ *
왜 따로 뒀나: 종전 화면은 진행률을 보려고 목록 화면 전체를 5초마다 다시 열었다.
+ * 그런데 이 화면은 접속기록 수집 화이트리스트({@code /mnt/sec/**}) 안이라 새로고침 한 번이
+ * 접속기록 1행이 된다. 즉 폴링이 도는 동안 감사로그가 분당 12행씩 자기 자신을 기록했고,
+ * 이는 정기점검·비정상접근 탐지(SER-002)의 신호를 그대로 오염시킨다. 상태 조회를 별도 경로로
+ * 빼고 그 경로를 {@code excludePatterns} 에 넣어 수집 대상에서 제외한다.
+ *
+ *
본문 원칙: 개인정보는커녕 검색조건도 싣지 않는다. 작업ID·상태·진행률·건수와
+ * 실패사유뿐이며, 모두 본인 작업에 한정된다({@code selectMyJobs(본인ID)}).
+ *
+ *
형식은 한 줄 1작업의 {@code |} 구분 텍스트다 — 이 스택(Spring 3.0.5)에 JSON 메시지
+ * 컨버터를 새로 물리지 않고도 확실히 동작하며, 파싱이 단순해 화면 쪽 실패 여지가 적다.
+ */
+ @RequestMapping(value = "/mnt/sec/accessLog/jobStatus.do")
+ public void jobStatus(HttpServletResponse response) throws Exception {
+ response.setContentType("text/plain; charset=UTF-8");
+ response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
+ response.setHeader("Pragma", "no-cache");
+
+ LoginVO user = handlerUser();
+ if (user == null) {
+ // 화면이 재로그인으로 유도하도록 상태코드만 알린다(본문 없음).
+ response.setStatus(HttpServletResponse.SC_FORBIDDEN);
+ return;
+ }
+
+ StringBuilder sb = new StringBuilder(256);
+ List list = accessLogDownloadJobService.selectMyJobs(user.getId(), 10);
+ if (list != null) {
+ for (AccessLogDownloadJobVO j : list) {
+ sb.append(j.getJobId()).append('|')
+ .append(nvl(j.getStatus())).append('|')
+ .append(j.getProgressPct()).append('|')
+ .append(j.getDoneCnt()).append('|')
+ .append(j.getTotalCnt()).append('|')
+ .append(oneLine(j.getErrMsg())).append('\n');
+ }
+ }
+ response.getWriter().write(sb.toString());
+ response.getWriter().flush();
+ }
+
+ /** 구분자·개행이 섞이면 화면 파싱이 깨지므로 한 줄로 눌러 담는다. */
+ private String oneLine(String v) {
+ if (v == null) {
+ return "";
+ }
+ return v.replace('|', '/').replace('\r', ' ').replace('\n', ' ');
+ }
+
+ private String nvl(String v) {
+ return (v == null) ? "" : v;
+ }
+
+ /** 내 다운로드 작업 목록(최근 10건). 조회 실패해도 화면은 떠야 하므로 빈 목록으로 fail-safe. */
+ private List selectMyDownloadJobs() {
+ try {
+ LoginVO user = handlerUser();
+ if (user == null) {
+ return new ArrayList();
+ }
+ List list = accessLogDownloadJobService.selectMyJobs(user.getId(), 10);
+ return (list == null) ? new ArrayList() : list;
+ } catch (Exception e) {
+ return new ArrayList();
+ }
+ }
+
+ /**
+ * 접속기록 다운로드(CSV) — 동기 방식(기존 #42). 사유 필수 + 다운로드 이력 적재.
+ *
+ *
주의: 조건 전체를 메모리에 올린다. 대용량은 위 {@link #downloadRequest} 비동기 경로를
+ * 쓴다. 이 엔드포인트는 소량 즉시 다운로드와 기존 링크 호환을 위해 남겨둔다.
*/
@RequestMapping(value = "/mnt/sec/accessLog/download.do")
public void download(@ModelAttribute("personalDataAccessLogSearchVO") PersonalDataAccessLogSearchVO searchVO,
@@ -153,6 +327,43 @@ public class PersonalDataAccessLogController {
}
}
+ /**
+ * 현재 검색조건 기준 처리구분별 건수 요약.
+ *
+ *
"이 취급자가 개인정보를 조회만 했는지, 수정·삭제까지 했는지"를 목록을 넘겨보지 않고
+ * 판별하기 위한 값이다. 화면이 {@code ${dmlSummary.S}} 처럼 바로 꺼내 쓰도록
+ * S/I/U/D 네 키를 항상 채워 반환한다(해당 기록이 없으면 0).
검색조건 정규화(2026-08-07). 원본 입력값은 화면 재표시를 위해 그대로 두고,
+ * SQL 바인딩에는 아래 파생 getter를 쓴다. 이렇게 하면 MySQL/Oracle 두 sqlMap에
+ * 방언 차이({@code CONCAT} vs {@code ||})를 새로 만들지 않아도 된다.
{@link #getConectIdLike()} 등 — 전방일치 검색. 종전 완전일치는 ID 전체를 정확히 알아야 해서
+ * SFR-004-6(조회·검색)의 실효가 없었다. 전방일치는 {@code IDX_PDAL_01(CONECT_ID, CONECT_DT)}
+ * 인덱스를 그대로 탄다(중간일치 {@code %x%}는 인덱스를 못 써서 채택하지 않았다).
+ *
*
* @author arko-privacy-auditlog (Fork B - 5차)
* @since 2026-07-21
@@ -81,4 +93,74 @@ public class PersonalDataAccessLogSearchVO implements Serializable {
public int getTotalRecordCount() { return totalRecordCount; }
public void setTotalRecordCount(int totalRecordCount) { this.totalRecordCount = totalRecordCount; }
+
+ // =====================================================================
+ // SQL 바인딩용 파생값 (2026-08-07) — setter 없음(읽기 전용).
+ // 원본 필드는 화면 재표시용으로 건드리지 않는다.
+ // =====================================================================
+
+ /** 날짜만 입력된 값인지(yyyy-MM-dd). */
+ private boolean isDateOnly(String v) {
+ return v != null && v.trim().length() == 10;
+ }
+
+ /**
+ * 조회 시작일시 바인딩값. 날짜만 입력하면 그날 {@code 00:00:00}부터.
+ * (시작 경계는 문자열 비교로도 동일하지만, 종료와 대칭을 맞춰 의도를 드러낸다.)
+ */
+ public String getFromDtParam() {
+ if (fromDt == null) {
+ return null;
+ }
+ String v = fromDt.trim();
+ return isDateOnly(v) ? v + " 00:00:00" : v;
+ }
+
+ /**
+ * 조회 종료일시 바인딩값. 날짜만 입력하면 그날 {@code 23:59:59}까지 포함한다.
+ *
+ *
이 보정이 없으면 종료일 당일 기록이 전부 빠진다 — 화면 안내가 {@code yyyy-MM-dd} 입력을
+ * 권장하므로 반드시 밟게 되는 함정이었다.
+ */
+ public String getToDtParam() {
+ if (toDt == null) {
+ return null;
+ }
+ String v = toDt.trim();
+ return isDateOnly(v) ? v + " 23:59:59" : v;
+ }
+
+ /** LIKE 이스케이프 문자. SQL 의 {@code ESCAPE '!'} 와 반드시 짝을 이룬다. */
+ private static final String LIKE_ESCAPE = "!";
+
+ /**
+ * LIKE 전방일치 패턴으로 변환한다. 사용자가 입력한 {@code %}/{@code _}는 와일드카드가 아니라
+ * 문자 그대로 찾도록 이스케이프한다 — 안 하면 {@code %} 한 글자로 전건이 조회된다.
+ *
+ *
이스케이프 문자로 백슬래시가 아닌 {@code !}를 쓴다. MySQL 문자열 리터럴에서 {@code '\'}는
+ * 뒤따르는 따옴표를 이스케이프해 SQL 자체가 깨지므로 {@code ESCAPE '\'}를 쓸 수 없고,
+ * MySQL만 {@code '\\'}로 적으면 Oracle 쌍과 표현이 갈린다. {@code !}는 두 방언 모두 동일하다.
+ */
+ private String toPrefixLike(String v) {
+ if (v == null) {
+ return null;
+ }
+ String s = v.trim();
+ if (s.length() == 0) {
+ return null;
+ }
+ s = s.replace(LIKE_ESCAPE, LIKE_ESCAPE + LIKE_ESCAPE)
+ .replace("%", LIKE_ESCAPE + "%")
+ .replace("_", LIKE_ESCAPE + "_");
+ return s + "%";
+ }
+
+ /** 접속자ID 전방일치 패턴. */
+ public String getConectIdLike() { return toPrefixLike(conectId); }
+
+ /** 처리대상 식별자 전방일치 패턴. */
+ public String getTrgetIdLike() { return toPrefixLike(trgetId); }
+
+ /** 접속IP 전방일치 패턴 — 대역 검색용(예: {@code 192.168.} 입력 시 해당 대역 전체). */
+ public String getConectIpLike() { return toPrefixLike(conectIp); }
}
diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorChangeHistoryVO.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorChangeHistoryVO.java
index fd7c1d4..9352cf4 100644
--- a/src/main/java/egovframework/com/cmm/privacy/author/AuthorChangeHistoryVO.java
+++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorChangeHistoryVO.java
@@ -20,6 +20,8 @@ public class AuthorChangeHistoryVO implements Serializable {
private String trgetUniqId;
/** 변경 대상자 계정ID */
private String trgetId;
+ /** 변경 대상자 성명 — COMTNEMPLYRINFO 조인 표기용(비영속) */
+ private String trgetNm;
/** 변경구분 G:부여 R:회수 M:변경 */
private String chgSeCode;
private String beforeAuthor;
@@ -54,6 +56,8 @@ public class AuthorChangeHistoryVO implements Serializable {
public void setTrgetUniqId(String trgetUniqId) { this.trgetUniqId = trgetUniqId; }
public String getTrgetId() { return trgetId; }
public void setTrgetId(String trgetId) { this.trgetId = trgetId; }
+ public String getTrgetNm() { return trgetNm; }
+ public void setTrgetNm(String trgetNm) { this.trgetNm = trgetNm; }
public String getChgSeCode() { return chgSeCode; }
public void setChgSeCode(String chgSeCode) { this.chgSeCode = chgSeCode; }
public String getBeforeAuthor() { return beforeAuthor; }
diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobDAO.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobDAO.java
new file mode 100644
index 0000000..649dd71
--- /dev/null
+++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobDAO.java
@@ -0,0 +1,85 @@
+package egovframework.com.cmm.privacy.author;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.stereotype.Repository;
+
+import egovframework.com.cmm.service.impl.EgovComAbstractDAO;
+
+/**
+ * 권한변경 이력 CSV 비동기 다운로드 작업(AUTHOR_HIST_DOWNLOAD_JOB) DAO.
+ *
+ *
개인정보보호 기능개선 사업(SFR-001 / 개발항목 #6). statement 는
+ * {@code PrivacyAuthorHistDownloadJob_SQL_Mysql.xml}
+ * (namespace {@code AuthorHistDownloadJobDAO}). 신규 sqlMap 이므로
+ * {@code sql-map-config-mysql-0-site.xml} 등록이 필요하다.
+ *
+ * @since 2026-08-10
+ */
+@Repository("authorHistDownloadJobDAO")
+public class AuthorHistDownloadJobDAO extends EgovComAbstractDAO {
+
+ /** 요청 등록. 채번된 JOB_ID 는 selectKey 로 vo.jobId 에 채워진다. */
+ public long insertJob(AuthorHistDownloadJobVO vo) {
+ insert("AuthorHistDownloadJobDAO.insertJob", vo);
+ return vo.getJobId();
+ }
+
+ public AuthorHistDownloadJobVO selectJob(long jobId) {
+ return (AuthorHistDownloadJobVO) selectByPk("AuthorHistDownloadJobDAO.selectJob", Long.valueOf(jobId));
+ }
+
+ /** 요청자 본인의 최근 작업 목록. */
+ @SuppressWarnings("unchecked")
+ public List selectJobListByRequester(String requesterId, int limitCnt) {
+ Map param = new HashMap();
+ param.put("requesterId", requesterId);
+ param.put("limitCnt", Integer.valueOf(limitCnt));
+ return list("AuthorHistDownloadJobDAO.selectJobListByRequester", param);
+ }
+
+ /** 대기 상태의 가장 오래된 작업 ID. 없으면 null. */
+ public Long selectNextWaitingJobId() {
+ return (Long) selectByPk("AuthorHistDownloadJobDAO.selectNextWaitingJobId", null);
+ }
+
+ /**
+ * 대기건 선점. 영향행수 1이면 이 워커가 소유권을 얻은 것이다.
+ * (WHERE 에 STATUS='W' 가 걸려 있어 동시 선점 시 한쪽만 성공한다.)
+ */
+ public boolean claimJob(long jobId) {
+ return update("AuthorHistDownloadJobDAO.claimJob", Long.valueOf(jobId)) == 1;
+ }
+
+ public void updateProgress(AuthorHistDownloadJobVO vo) {
+ update("AuthorHistDownloadJobDAO.updateProgress", vo);
+ }
+
+ public void updateSuccess(AuthorHistDownloadJobVO vo) {
+ update("AuthorHistDownloadJobDAO.updateSuccess", vo);
+ }
+
+ public void updateFail(AuthorHistDownloadJobVO vo) {
+ update("AuthorHistDownloadJobDAO.updateFail", vo);
+ }
+
+ public void updateDownloaded(long jobId) {
+ update("AuthorHistDownloadJobDAO.updateDownloaded", Long.valueOf(jobId));
+ }
+
+ @SuppressWarnings("unchecked")
+ public List selectExpiredJobs() {
+ return list("AuthorHistDownloadJobDAO.selectExpiredJobs", null);
+ }
+
+ public void updatePurged(long jobId) {
+ update("AuthorHistDownloadJobDAO.updatePurged", Long.valueOf(jobId));
+ }
+
+ /** WAS 비정상 종료로 'R' 에 멈춘 작업을 대기로 되돌린다(기동 시 1회). */
+ public int resetRunningJobs() {
+ return update("AuthorHistDownloadJobDAO.resetRunningJobs", null);
+ }
+}
diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobService.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobService.java
new file mode 100644
index 0000000..045d8a2
--- /dev/null
+++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobService.java
@@ -0,0 +1,38 @@
+package egovframework.com.cmm.privacy.author;
+
+import java.util.List;
+
+/**
+ * 권한변경 이력 CSV 비동기 다운로드 작업 서비스 (SFR-001 / 개발항목 #6).
+ *
+ *
요청은 작업만 등록하고 즉시 반환한다. 파일 생성은
+ * {@link AuthorHistDownloadJobWorker} 가 백그라운드에서 맡는다.
+ *
+ * @since 2026-08-10
+ */
+public interface AuthorHistDownloadJobService {
+
+ /**
+ * 다운로드 요청 등록.
+ *
+ * @param requesterId 요청자 계정ID
+ * @param requesterIp 요청 IP
+ * @param reason 다운로드 사유(필수 — 없으면 IllegalArgumentException)
+ * @param searchVO 다운로드 대상 검색조건
+ * @return 채번된 작업 ID
+ */
+ long requestJob(String requesterId, String requesterIp, String reason,
+ AuthorChangeHistoryVO searchVO) throws Exception;
+
+ /** 요청자 본인의 최근 작업 목록. */
+ List selectMyJobs(String requesterId, int limitCnt) throws Exception;
+
+ /** 내려받기 가능한 작업 조회. 본인 것이 아니거나 미완료/파일 없음이면 null. */
+ AuthorHistDownloadJobVO selectDownloadableJob(long jobId, String requesterId) throws Exception;
+
+ /** 내려받은 횟수·일시 기록. */
+ void markDownloaded(long jobId) throws Exception;
+
+ /** 만료 파일 파기(배치). 파일 삭제 + 상태 X. */
+ int purgeExpiredJobs() throws Exception;
+}
diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobServiceImpl.java
new file mode 100644
index 0000000..0f2dc71
--- /dev/null
+++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobServiceImpl.java
@@ -0,0 +1,169 @@
+package egovframework.com.cmm.privacy.author;
+
+import java.io.File;
+import java.util.List;
+
+import javax.annotation.Resource;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+/**
+ * 권한변경 이력 CSV 비동기 다운로드 작업 서비스 구현 (SFR-001 / 개발항목 #6).
+ *
+ *
보관 최소화: 생성 파일은 취급자 권한 변동 내역이 담긴 개인정보 파일이라 오래 두지 않는다.
+ * {@link #expireDays}(기본 3일) 경과분은 파기 배치가 파일을 지우고 상태를 X 로 바꾼다.
+ * 권한이력 본체의 보관기간(기본 1,095일)과는 무관한 '내려받기용 사본'의 수명이다.
+ *
+ *
권한 재검증: 내려받기는 요청자 본인만 가능하다. 작업ID는 연속 정수라
+ * 추측이 쉬우므로, 조회 단계에서 요청자ID 일치를 확인하지 않으면 남의 파일이 새어나간다.
+ *
+ * @since 2026-08-10
+ */
+@Service("authorHistDownloadJobService")
+public class AuthorHistDownloadJobServiceImpl implements AuthorHistDownloadJobService {
+
+ private static final Log LOG = LogFactory.getLog(AuthorHistDownloadJobServiceImpl.class);
+
+ @Resource(name = "authorHistDownloadJobDAO")
+ private AuthorHistDownloadJobDAO jobDAO;
+
+ /** 생성 파일 보관일수. 접속기록 비동기 다운로드와 같은 설정값을 공유한다(기본 3일). */
+ @Value("${Globals.privacyDownloadExpireDays:3}")
+ private int expireDays = 3;
+
+ @Override
+ public long requestJob(String requesterId, String requesterIp, String reason,
+ AuthorChangeHistoryVO searchVO) throws Exception {
+
+ // 사유 필수 — 종전 동기 다운로드와 동일한 차단 기준을 유지한다.
+ if (reason == null || reason.trim().length() == 0) {
+ throw new IllegalArgumentException("다운로드 사유는 필수입니다.");
+ }
+ if (requesterId == null || requesterId.trim().length() == 0) {
+ throw new IllegalArgumentException("요청자를 확인할 수 없습니다.");
+ }
+ if (searchVO == null) {
+ searchVO = new AuthorChangeHistoryVO();
+ }
+
+ AuthorHistDownloadJobVO job = new AuthorHistDownloadJobVO();
+ job.setRequesterId(requesterId);
+ job.setRequesterIp(requesterIp);
+ job.setDlReason(cut(reason.trim(), 500));
+ job.setSearchCond(cut(summarizeCond(searchVO), 1000));
+ job.setCondTrgetId(searchVO.getSearchTrgetId());
+ job.setCondChangerId(searchVO.getSearchChangerId());
+ job.setCondChgSeCode(searchVO.getSearchChgSeCode());
+ job.setCondFromDt(searchVO.getFromDt());
+ job.setCondToDt(searchVO.getToDt());
+ job.setExpireDays(expireDays < 1 ? 3 : expireDays);
+
+ long jobId = jobDAO.insertJob(job);
+ LOG.info("[개인정보/SFR-001-6] 권한이력 다운로드 작업 등록 — jobId=" + jobId + ", 요청자=" + requesterId
+ + ", 조건=" + job.getSearchCond());
+ return jobId;
+ }
+
+ @Override
+ public List selectMyJobs(String requesterId, int limitCnt) throws Exception {
+ if (requesterId == null || requesterId.trim().length() == 0) {
+ return java.util.Collections.emptyList();
+ }
+ return jobDAO.selectJobListByRequester(requesterId, limitCnt < 1 ? 10 : limitCnt);
+ }
+
+ @Override
+ public AuthorHistDownloadJobVO selectDownloadableJob(long jobId, String requesterId) throws Exception {
+ AuthorHistDownloadJobVO job = jobDAO.selectJob(jobId);
+ if (job == null) {
+ return null;
+ }
+ // 본인 확인 — 작업ID 추측으로 남의 파일을 가져가는 것을 막는다.
+ if (requesterId == null || !requesterId.equals(job.getRequesterId())) {
+ LOG.warn("[개인정보/SFR-001-6] 타인 다운로드 시도 차단 — jobId=" + jobId
+ + ", 소유자=" + job.getRequesterId() + ", 요청자=" + requesterId);
+ return null;
+ }
+ if (!job.isDownloadable()) {
+ return null;
+ }
+ File f = new File(job.getFilePath());
+ if (!f.exists() || !f.isFile()) {
+ LOG.warn("[개인정보/SFR-001-6] 파일 없음(파기됐거나 유실) — jobId=" + jobId);
+ return null;
+ }
+ return job;
+ }
+
+ @Override
+ public void markDownloaded(long jobId) throws Exception {
+ jobDAO.updateDownloaded(jobId);
+ }
+
+ @Override
+ public int purgeExpiredJobs() throws Exception {
+ List expired = jobDAO.selectExpiredJobs();
+ int purged = 0;
+ if (expired != null) {
+ for (int i = 0; i < expired.size(); i++) {
+ AuthorHistDownloadJobVO job = expired.get(i);
+ String path = job.getFilePath();
+ if (path != null && path.length() > 0) {
+ try {
+ File f = new File(path);
+ if (f.exists() && !f.delete()) {
+ // 삭제 실패는 개인정보 파일이 남는다는 뜻이라 조용히 넘기지 않는다.
+ LOG.error("[개인정보/SFR-001-6] 다운로드 파일 삭제 실패 — jobId=" + job.getJobId()
+ + ", path=" + path + " (수동 삭제 필요)");
+ continue;
+ }
+ } catch (Exception e) {
+ LOG.error("[개인정보/SFR-001-6] 다운로드 파일 삭제 중 예외 — jobId=" + job.getJobId(), e);
+ continue;
+ }
+ }
+ jobDAO.updatePurged(job.getJobId());
+ purged++;
+ }
+ }
+ LOG.info("[개인정보/SFR-001-6] 만료 권한이력 다운로드 파일 파기 완료 — " + purged + "건(보관 " + expireDays + "일)");
+ return purged;
+ }
+
+ /** 검색조건을 요약(표시용). */
+ private String summarizeCond(AuthorChangeHistoryVO vo) {
+ StringBuilder sb = new StringBuilder();
+ appendCond(sb, "대상자", vo.getSearchTrgetId());
+ appendCond(sb, "변경자", vo.getSearchChangerId());
+ appendCond(sb, "변경구분", chgSeLabel(vo.getSearchChgSeCode()));
+ appendCond(sb, "시작일", vo.getFromDt());
+ appendCond(sb, "종료일", vo.getToDt());
+ return sb.length() == 0 ? "(전체)" : sb.toString();
+ }
+
+ private void appendCond(StringBuilder sb, String key, String val) {
+ if (val != null && val.trim().length() > 0) {
+ if (sb.length() > 0) {
+ sb.append(", ");
+ }
+ sb.append(key).append("=").append(val.trim());
+ }
+ }
+
+ private String chgSeLabel(String code) {
+ if ("G".equals(code)) return "부여";
+ if ("R".equals(code)) return "회수";
+ if ("M".equals(code)) return "변경";
+ return code;
+ }
+
+ private static String cut(String s, int max) {
+ if (s == null) {
+ return null;
+ }
+ return (s.length() > max) ? s.substring(0, max) : s;
+ }
+}
diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobVO.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobVO.java
new file mode 100644
index 0000000..11f56e1
--- /dev/null
+++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobVO.java
@@ -0,0 +1,148 @@
+package egovframework.com.cmm.privacy.author;
+
+import java.io.Serializable;
+
+/**
+ * 권한변경 이력 CSV 비동기 다운로드 작업(AUTHOR_HIST_DOWNLOAD_JOB) VO.
+ *
+ *
개인정보보호 기능개선 사업(SFR-001 / 개발항목 #6) — 대용량 다운로드를 요청 즉시 응답하고
+ * 백그라운드에서 파일을 만들기 위한 작업 한 건. 접속기록 쪽
+ * {@code AccessLogDownloadJobVO} 와 같은 패턴이며 검색조건 필드만 다르다.
+ *
+ *
검색조건을 컬럼으로 보존하는 이유: 백그라운드 워커는 요청 세션이 끝난 뒤 실행되므로
+ * 조건을 스스로 재현해야 한다. 담는 값은 식별자·코드·기간뿐이다.
+ *
+ * @since 2026-08-10
+ */
+public class AuthorHistDownloadJobVO implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ /** 상태: 대기 */
+ public static final String ST_WAIT = "W";
+ /** 상태: 생성중 */
+ public static final String ST_RUNNING = "R";
+ /** 상태: 완료(내려받기 가능) */
+ public static final String ST_SUCCESS = "S";
+ /** 상태: 실패 */
+ public static final String ST_FAIL = "F";
+ /** 상태: 파기(파일 삭제됨) */
+ public static final String ST_PURGED = "X";
+
+ private long jobId;
+ private String requesterId;
+ private String requesterIp;
+ private String dlReason;
+ private String searchCond;
+
+ // 조건 재현용. 프로퍼티명을 cond* 로 둔다 — getFTrgetId 처럼 '대문자1자+대문자' 형태면
+ // JavaBeans 인트로스펙션이 프로퍼티를 다르게 해석해 iBatis 바인딩이 조용히 실패한다.
+ private String condTrgetId;
+ private String condChangerId;
+ private String condChgSeCode;
+ private String condFromDt;
+ private String condToDt;
+
+ private String status;
+ private int totalCnt;
+ private int doneCnt;
+ private String fileNm;
+ private String filePath;
+ private long fileSize;
+ private String errMsg;
+ private int downloadCnt;
+ private String lastDlDt;
+ private String regDt;
+ private String startDt;
+ private String endDt;
+ private String expireDt;
+
+ /** 만료일(일). 서비스가 EXPIRE_DT 계산에 쓰는 값을 전달하기 위한 비영속 필드. */
+ private int expireDays;
+
+ public long getJobId() { return jobId; }
+ public void setJobId(long jobId) { this.jobId = jobId; }
+
+ public String getRequesterId() { return requesterId; }
+ public void setRequesterId(String requesterId) { this.requesterId = requesterId; }
+
+ public String getRequesterIp() { return requesterIp; }
+ public void setRequesterIp(String requesterIp) { this.requesterIp = requesterIp; }
+
+ public String getDlReason() { return dlReason; }
+ public void setDlReason(String dlReason) { this.dlReason = dlReason; }
+
+ public String getSearchCond() { return searchCond; }
+ public void setSearchCond(String searchCond) { this.searchCond = searchCond; }
+
+ public String getCondTrgetId() { return condTrgetId; }
+ public void setCondTrgetId(String condTrgetId) { this.condTrgetId = condTrgetId; }
+
+ public String getCondChangerId() { return condChangerId; }
+ public void setCondChangerId(String condChangerId) { this.condChangerId = condChangerId; }
+
+ public String getCondChgSeCode() { return condChgSeCode; }
+ public void setCondChgSeCode(String condChgSeCode) { this.condChgSeCode = condChgSeCode; }
+
+ public String getCondFromDt() { return condFromDt; }
+ public void setCondFromDt(String condFromDt) { this.condFromDt = condFromDt; }
+
+ public String getCondToDt() { return condToDt; }
+ public void setCondToDt(String condToDt) { this.condToDt = condToDt; }
+
+ public String getStatus() { return status; }
+ public void setStatus(String status) { this.status = status; }
+
+ public int getTotalCnt() { return totalCnt; }
+ public void setTotalCnt(int totalCnt) { this.totalCnt = totalCnt; }
+
+ public int getDoneCnt() { return doneCnt; }
+ public void setDoneCnt(int doneCnt) { this.doneCnt = doneCnt; }
+
+ public String getFileNm() { return fileNm; }
+ public void setFileNm(String fileNm) { this.fileNm = fileNm; }
+
+ public String getFilePath() { return filePath; }
+ public void setFilePath(String filePath) { this.filePath = filePath; }
+
+ public long getFileSize() { return fileSize; }
+ public void setFileSize(long fileSize) { this.fileSize = fileSize; }
+
+ public String getErrMsg() { return errMsg; }
+ public void setErrMsg(String errMsg) { this.errMsg = errMsg; }
+
+ public int getDownloadCnt() { return downloadCnt; }
+ public void setDownloadCnt(int downloadCnt) { this.downloadCnt = downloadCnt; }
+
+ public String getLastDlDt() { return lastDlDt; }
+ public void setLastDlDt(String lastDlDt) { this.lastDlDt = lastDlDt; }
+
+ public String getRegDt() { return regDt; }
+ public void setRegDt(String regDt) { this.regDt = regDt; }
+
+ public String getStartDt() { return startDt; }
+ public void setStartDt(String startDt) { this.startDt = startDt; }
+
+ public String getEndDt() { return endDt; }
+ public void setEndDt(String endDt) { this.endDt = endDt; }
+
+ public String getExpireDt() { return expireDt; }
+ public void setExpireDt(String expireDt) { this.expireDt = expireDt; }
+
+ public int getExpireDays() { return expireDays; }
+ public void setExpireDays(int expireDays) { this.expireDays = expireDays; }
+
+ /** 진행률(%). 총건수 미확정이면 0. */
+ public int getProgressPct() {
+ if (totalCnt <= 0) {
+ return ST_SUCCESS.equals(status) ? 100 : 0;
+ }
+ long pct = (long) doneCnt * 100L / totalCnt;
+ return (pct > 100L) ? 100 : (int) pct;
+ }
+
+ /** 내려받기 가능 여부(완료 + 파일 보유). */
+ public boolean isDownloadable() {
+ return ST_SUCCESS.equals(status) && filePath != null && filePath.length() > 0;
+ }
+}
diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobWorker.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobWorker.java
new file mode 100644
index 0000000..087c85a
--- /dev/null
+++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistDownloadJobWorker.java
@@ -0,0 +1,338 @@
+package egovframework.com.cmm.privacy.author;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.OutputStreamWriter;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.List;
+
+import javax.annotation.PostConstruct;
+import javax.annotation.PreDestroy;
+import javax.annotation.Resource;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import egovframework.com.utl.sim.service.EgovFileScrty;
+
+/**
+ * 권한변경 이력 CSV 비동기 다운로드 워커 (SFR-001 / 개발항목 #6).
+ *
+ *
대기(W) 작업을 하나씩 집어 CSV 파일을 만든다. 단일 워커 스레드다 —
+ * 대량 조회가 동시에 여러 건 돌면 DB 부하가 그대로 서비스 지연이 되기 때문이다.
+ * 접속기록 워커({@code AccessLogDownloadJobWorker})와 같은 구조이며 조회 대상만 다르다.
+ *
+ *
메모리를 쓰지 않는 방식: 전체를 List 로 올리지 않고 {@link #CHUNK_ROWS}건씩
+ * 끊어 조회하며 곧바로 파일에 쓴다. 종전 동기 다운로드(전량 추출)의 OOM 위험을 없앤다.
+ *
+ *
파일은 암호화해 둔다: 취급자 권한 변동 내역이 담긴 개인정보 파일이라 평문 CSV 를
+ * 디스크에 남기지 않는다. {@link EgovFileScrty#encryptFile}로 암호화한 뒤 평문 임시파일을 지우고,
+ * 내려받을 때 복호화해 스트리밍한다. 저장 위치는 반드시 웹 루트 밖이어야 한다.
+ *
+ *
기동 시 고아 복구: 파일 생성 중 WAS 가 죽으면 작업이 R 로 굳는다. 기동 시
+ * R → W 로 되돌려 재시도한다(화면에 '생성중'이 영원히 멈춰 보이는 것 방지).
+ *
+ * @since 2026-08-10
+ */
+@Component("authorHistDownloadJobWorker")
+public class AuthorHistDownloadJobWorker {
+
+ private static final Log LOG = LogFactory.getLog(AuthorHistDownloadJobWorker.class);
+
+ /** 한 번에 조회·기록할 행 수. 메모리 상주량을 결정한다. */
+ private static final int CHUNK_ROWS = 2000;
+ /** 대기 작업이 없을 때 다음 확인까지 쉬는 시간(ms). */
+ private static final long IDLE_SLEEP_MS = 3000L;
+ /** 예외 발생 후 폭주 방지 대기(ms). */
+ private static final long ERROR_SLEEP_MS = 10000L;
+
+ /** 화면 열 구성과 맞춘다(대상자명·변경자IP 포함). */
+ private static final String CSV_HEADER =
+ "변경일시,대상자ID,대상자명,변경구분,변경전권한,변경후권한,변경사유,승인자,변경자ID,변경자IP";
+
+ @Resource(name = "authorHistDownloadJobDAO")
+ private AuthorHistDownloadJobDAO jobDAO;
+
+ @Resource(name = "authorHistoryService")
+ private AuthorHistoryService authorHistoryService;
+
+ /**
+ * 생성 파일 저장 디렉터리. 반드시 웹 루트(webapps) 밖이어야 한다.
+ * 접속기록 비동기 다운로드와 같은 설정값을 공유한다. 미설정 시 기동 로그에 경고를 남기고
+ * 워커를 띄우지 않는다 — 임의 경로에 개인정보 파일을 만드는 것보다 기능을 멈추는 편이 안전하다.
+ */
+ @Value("${Globals.privacyDownloadPath:}")
+ private String downloadPath;
+
+ private volatile boolean running = false;
+ private volatile Thread worker;
+
+ @PostConstruct
+ public void start() {
+ // 저장경로가 없거나 만들 수 없어도 워커는 띄운다(접속기록 워커와 동일한 이유).
+ // 종전에는 여기서 return 해 워커가 아예 안 돌았고, 작업이 '대기'로 영원히 굳었다.
+ // 지금은 작업마다 경로를 확인해 확보 불가면 그 작업을 '실패'로 확정한다.
+ try {
+ ensureDir();
+ LOG.info("[개인정보/SFR-001-6] 저장경로 확인 완료 — " + downloadPath);
+ } catch (Exception e) {
+ LOG.error("[개인정보/SFR-001-6] 저장경로를 확보하지 못했다 — " + e.getMessage()
+ + " 워커는 기동하되, 경로가 확보될 때까지 접수된 작업은 '실패'로 처리한다. "
+ + "웹 루트 밖 경로를 지정·생성할 것(예: /app/egovframework/privacy-download/).");
+ }
+ try {
+ int reset = jobDAO.resetRunningJobs();
+ if (reset > 0) {
+ LOG.warn("[개인정보/SFR-001-6] 이전 기동에서 생성 중이던 작업 " + reset + "건을 대기로 되돌려 재시도한다.");
+ }
+ } catch (Exception e) {
+ LOG.error("[개인정보/SFR-001-6] 고아 작업 복구 실패(계속 진행)", e);
+ }
+
+ running = true;
+ Thread t = new Thread(new Runnable() {
+ public void run() {
+ loop();
+ }
+ }, "author-hist-download-worker");
+ t.setDaemon(true);
+ worker = t;
+ t.start();
+ LOG.info("[개인정보/SFR-001-6] 권한이력 비동기 다운로드 워커 기동 — 저장경로=" + downloadPath
+ + ", 청크=" + CHUNK_ROWS + "행");
+ }
+
+ @PreDestroy
+ public void stop() {
+ running = false;
+ Thread t = worker;
+ if (t != null) {
+ t.interrupt();
+ }
+ }
+
+ /** 워커 루프 — 대기건을 하나씩 선점해 처리한다. Throwable 까지 흡수해 스스로 죽지 않는다. */
+ private void loop() {
+ while (running) {
+ try {
+ Long jobId = jobDAO.selectNextWaitingJobId();
+ if (jobId == null) {
+ Thread.sleep(IDLE_SLEEP_MS);
+ continue;
+ }
+ if (!jobDAO.claimJob(jobId.longValue())) {
+ // 다른 워커/재시도와 경합해 선점 실패 — 즉시 다음 것을 본다.
+ continue;
+ }
+ process(jobId.longValue());
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ break;
+ } catch (Throwable t) {
+ LOG.error("[개인정보/SFR-001-6] 워커 루프 예외(계속)", t);
+ try {
+ Thread.sleep(ERROR_SLEEP_MS);
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ }
+ }
+ LOG.info("[개인정보/SFR-001-6] 권한이력 비동기 다운로드 워커 종료.");
+ }
+
+ /** 작업 1건 처리 — 청크 조회 → CSV 기록 → 암호화 → 완료 확정. */
+ private void process(long jobId) {
+ AuthorHistDownloadJobVO job = null;
+ File plain = null;
+ File enc = null;
+ try {
+ job = jobDAO.selectJob(jobId);
+ if (job == null) {
+ return;
+ }
+
+ AuthorChangeHistoryVO searchVO = toSearchVO(job);
+ int total = authorHistoryService.selectHistoryListCount(searchVO);
+ job.setTotalCnt(total);
+ job.setDoneCnt(0);
+ jobDAO.updateProgress(job);
+
+ // 작업마다 저장경로를 확인한다 — 기동 후 경로가 생겼다면 재기동 없이 여기서 살아난다.
+ File dir = ensureDir();
+
+ String stamp = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
+ String baseNm = "권한변경이력_" + stamp + "_" + jobId;
+ plain = new File(dir, baseNm + ".csv.tmp");
+ enc = new File(dir, baseNm + ".csv.enc");
+
+ int written = writeCsv(plain, searchVO, total, job);
+
+ // 평문 CSV 를 디스크에 남기지 않는다 — 암호화 후 즉시 삭제.
+ if (!EgovFileScrty.encryptFile(plain.getAbsolutePath(), enc.getAbsolutePath())) {
+ throw new IllegalStateException("파일 암호화 실패");
+ }
+ deleteQuietly(plain);
+ plain = null;
+
+ job.setDoneCnt(written);
+ job.setTotalCnt(total);
+ job.setFileNm(baseNm + ".csv");
+ job.setFilePath(enc.getAbsolutePath());
+ job.setFileSize(enc.length());
+ jobDAO.updateSuccess(job);
+ LOG.info("[개인정보/SFR-001-6] 권한이력 다운로드 파일 생성 완료 — jobId=" + jobId + ", " + written + "행, "
+ + enc.length() + "byte");
+
+ } catch (Throwable t) {
+ LOG.error("[개인정보/SFR-001-6] 권한이력 다운로드 파일 생성 실패 — jobId=" + jobId, t);
+ deleteQuietly(plain);
+ deleteQuietly(enc);
+ try {
+ AuthorHistDownloadJobVO fail = (job != null) ? job : new AuthorHistDownloadJobVO();
+ fail.setJobId(jobId);
+ fail.setErrMsg(cut(String.valueOf(t.getMessage()), 500));
+ jobDAO.updateFail(fail);
+ } catch (Exception e) {
+ LOG.error("[개인정보/SFR-001-6] 실패 상태 기록마저 실패 — jobId=" + jobId, e);
+ }
+ }
+ }
+
+ /**
+ * 청크 단위로 조회하며 곧바로 파일에 기록한다(전체를 메모리에 올리지 않는다).
+ *
+ * @return 기록한 행 수
+ */
+ private int writeCsv(File target, AuthorChangeHistoryVO searchVO, int total,
+ AuthorHistDownloadJobVO job) throws Exception {
+ BufferedWriter w = null;
+ int written = 0;
+ try {
+ FileOutputStream fos = new FileOutputStream(target);
+ // UTF-8 BOM — 엑셀에서 한글이 깨지지 않게 한다(기존 동기 다운로드와 동일).
+ fos.write(0xEF); fos.write(0xBB); fos.write(0xBF);
+ w = new BufferedWriter(new OutputStreamWriter(fos, "UTF-8"));
+ w.write(CSV_HEADER);
+ w.newLine();
+
+ int page = 1;
+ while (written < total) {
+ searchVO.setPageIndex(page);
+ searchVO.setRecordCountPerPage(CHUNK_ROWS);
+ List chunk = authorHistoryService.selectHistoryList(searchVO);
+ if (chunk == null || chunk.isEmpty()) {
+ break;
+ }
+ for (int i = 0; i < chunk.size(); i++) {
+ w.write(toCsvLine(chunk.get(i)));
+ w.newLine();
+ written++;
+ }
+ w.flush();
+
+ // 진행률 갱신 — 화면 폴링이 실제로 움직이는 값을 보게 한다.
+ job.setDoneCnt(written);
+ job.setTotalCnt(total);
+ try {
+ jobDAO.updateProgress(job);
+ } catch (Exception e) {
+ // 진행률은 부가정보다 — 실패해도 생성은 계속한다.
+ LOG.warn("[개인정보/SFR-001-6] 진행률 갱신 실패(무시) — jobId=" + job.getJobId());
+ }
+ page++;
+ }
+ w.flush();
+ } finally {
+ if (w != null) {
+ try { w.close(); } catch (Exception ignore) { /* 닫기 실패는 무시 */ }
+ }
+ }
+ return written;
+ }
+
+ private String toCsvLine(AuthorChangeHistoryVO v) {
+ StringBuilder sb = new StringBuilder(256);
+ sb.append(csv(v.getChgDt())).append(',')
+ .append(csv(v.getTrgetId())).append(',')
+ .append(csv(v.getTrgetNm())).append(',')
+ .append(csv(chgSeLabel(v.getChgSeCode()))).append(',')
+ .append(csv(v.getBeforeAuthor())).append(',')
+ .append(csv(v.getAfterAuthor())).append(',')
+ .append(csv(v.getChgResn())).append(',')
+ .append(csv(v.getAprovlId())).append(',')
+ .append(csv(v.getChangerId())).append(',')
+ .append(csv(v.getChangerIp()));
+ return sb.toString();
+ }
+
+ private AuthorChangeHistoryVO toSearchVO(AuthorHistDownloadJobVO job) {
+ AuthorChangeHistoryVO vo = new AuthorChangeHistoryVO();
+ vo.setSearchTrgetId(job.getCondTrgetId());
+ vo.setSearchChangerId(job.getCondChangerId());
+ vo.setSearchChgSeCode(job.getCondChgSeCode());
+ vo.setFromDt(job.getCondFromDt());
+ vo.setToDt(job.getCondToDt());
+ return vo;
+ }
+
+ /**
+ * 저장 디렉터리를 확보한다(없으면 생성). 실패는 예외로 알린다 —
+ * 메시지가 그대로 작업 실패사유(ERR_MSG)로 화면에 노출된다.
+ */
+ private File ensureDir() {
+ if (downloadPath == null || downloadPath.trim().length() == 0) {
+ throw new IllegalStateException("다운로드 저장경로 미설정(Globals.privacyDownloadPath)");
+ }
+ File dir = new File(downloadPath.trim());
+ if (!dir.exists() && !dir.mkdirs()) {
+ throw new IllegalStateException("다운로드 저장경로 생성 실패 — " + dir.getAbsolutePath());
+ }
+ if (!dir.isDirectory()) {
+ throw new IllegalStateException("다운로드 저장경로가 디렉터리가 아님 — " + dir.getAbsolutePath());
+ }
+ if (!dir.canWrite()) {
+ throw new IllegalStateException("다운로드 저장경로에 쓸 수 없음(WAS 계정 권한 확인) — " + dir.getAbsolutePath());
+ }
+ return dir;
+ }
+
+ private void deleteQuietly(File f) {
+ if (f != null && f.exists()) {
+ try {
+ if (!f.delete()) {
+ LOG.warn("[개인정보/SFR-001-6] 임시파일 삭제 실패(수동 확인 필요) — " + f.getAbsolutePath());
+ }
+ } catch (Exception ignore) {
+ LOG.warn("[개인정보/SFR-001-6] 임시파일 삭제 중 예외 — " + f.getAbsolutePath());
+ }
+ }
+ }
+
+ /** CSV 셀 이스케이프(구분자·따옴표·개행 방어). */
+ private String csv(String v) {
+ if (v == null) {
+ return "";
+ }
+ String s = v.replace("\"", "\"\"");
+ return "\"" + s.replace("\r", " ").replace("\n", " ") + "\"";
+ }
+
+ private String chgSeLabel(String code) {
+ if ("G".equals(code)) return "부여";
+ if ("R".equals(code)) return "회수";
+ if ("M".equals(code)) return "변경";
+ return code;
+ }
+
+ private static String cut(String s, int max) {
+ if (s == null) {
+ return null;
+ }
+ return (s.length() > max) ? s.substring(0, max) : s;
+ }
+}
diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistoryService.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistoryService.java
index e4e1083..4993746 100644
--- a/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistoryService.java
+++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistoryService.java
@@ -21,4 +21,18 @@ public interface AuthorHistoryService {
* @return 추출된 이력 목록(파일 생성은 화면/컨트롤러 계층에서)
*/
List downloadHistory(AuthorChangeHistoryVO searchVo, AuthorHistDownloadVO download) throws Exception;
+
+ /**
+ * 다운로드 증빙 이력만 적재. 비동기 다운로드는 파일 생성과 실제 내려받기 시점이 달라
+ * '내려받은 시점'에 이 메서드로 증빙을 남긴다(사유·건수 증빙을 동기 경로와 일원화).
+ *
+ * @param downloaderId 다운로드자 계정ID
+ * @param downloaderUniqId 다운로드자 고유식별자
+ * @param downloaderIp 다운로드자 IP
+ * @param reason 다운로드 사유(요청 시 입력값)
+ * @param searchCond 검색조건 요약
+ * @param cnt 내려받은 건수
+ */
+ void recordDownloadHistory(String downloaderId, String downloaderUniqId, String downloaderIp,
+ String reason, String searchCond, int cnt) throws Exception;
}
diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistoryServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistoryServiceImpl.java
index a74f68b..3ac3618 100644
--- a/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistoryServiceImpl.java
+++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorHistoryServiceImpl.java
@@ -48,4 +48,17 @@ public class AuthorHistoryServiceImpl implements AuthorHistoryService {
return list;
}
+
+ @Override
+ public void recordDownloadHistory(String downloaderId, String downloaderUniqId, String downloaderIp,
+ String reason, String searchCond, int cnt) throws Exception {
+ AuthorHistDownloadVO dl = new AuthorHistDownloadVO();
+ dl.setDownloaderId(downloaderId);
+ dl.setDownloaderUniqId(downloaderUniqId);
+ dl.setDownloaderIp(downloaderIp);
+ dl.setDwldResn(reason);
+ dl.setSearchCond(searchCond);
+ dl.setDwldCnt(cnt);
+ authorHistoryDAO.insertDownloadHistory(dl);
+ }
}
diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowController.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowController.java
index bfacdfc..a9b6e99 100644
--- a/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowController.java
+++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowController.java
@@ -38,38 +38,38 @@ public class AuthorWorkflowController {
@Resource(name = "authorHistoryService")
private AuthorHistoryService authorHistoryService;
+ /** 권한이력 CSV 비동기 다운로드 작업(#6) */
+ @Resource(name = "authorHistDownloadJobService")
+ private AuthorHistDownloadJobService authorHistDownloadJobService;
+
private LoginVO loginUser(HttpServletRequest request) {
Object o = request.getSession().getAttribute("loginVO");
return (o instanceof LoginVO) ? (LoginVO) o : null;
}
- /** 권한 변경 신청 목록 (AJAX/JSON) */
- @RequestMapping(value = "/mnt/sec/authorRequest/list.do")
- public String requestList(AuthorChangeRequestVO searchVO, ModelMap model) throws Exception {
- List list = authorWorkflowService.selectRequestList(searchVO);
- model.addAttribute("resultList", list);
- model.addAttribute("totalCount", searchVO.getTotalRecordCount());
- return JSON;
- }
-
/**
- * 권한 변경 신청 목록·승인·반려 화면(서버 렌더링).
+ * 개인정보취급자 권한 관리 목록 화면(서버 렌더링) — 메뉴 진입점.
+ * 취급자 1인 = 1행. 소속기관/변경 전 역할/변경 후 역할/접근범위/권한상태/최근변경일/관리 열을 제공하고,
+ * 승인대기 행에서는 이 화면에서 바로 승인·반려한다.
* 뷰: /WEB-INF/jsp/site/mnt/sec/authorRequest/list.jsp
*/
@RequestMapping(value = "/mnt/sec/authorRequest/listView.do")
- public String requestListView(@ModelAttribute("authorChangeRequestVO") AuthorChangeRequestVO searchVO,
- ModelMap model) throws Exception {
+ public String userAuthorListView(@ModelAttribute("privacyUserAuthorVO") PrivacyUserAuthorVO searchVO,
+ ModelMap model) throws Exception {
PaginationInfo paginationInfo = new PaginationInfo();
paginationInfo.setCurrentPageNo(searchVO.getPageIndex());
paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage() < 1 ? 10 : searchVO.getRecordCountPerPage());
paginationInfo.setPageSize(10);
- // selectRequestList 내부에서 firstIndex/totalRecordCount 를 세팅한다.
- List list = authorWorkflowService.selectRequestList(searchVO);
+ // selectUserAuthorList 내부에서 firstIndex/totalRecordCount 를 세팅한다.
+ List list = authorWorkflowService.selectUserAuthorList(searchVO);
paginationInfo.setTotalRecordCount(searchVO.getTotalRecordCount());
model.addAttribute("resultList", list);
model.addAttribute("paginationInfo", paginationInfo);
+ model.addAttribute("orgnztList", authorWorkflowService.selectOrgnztList());
+ model.addAttribute("authorityList", authorWorkflowService.selectAuthorityList());
+ model.addAttribute("pendingCount", Integer.valueOf(authorWorkflowService.selectPendingRequestCount()));
return "site/mnt/sec/authorRequest/list";
}
@@ -163,7 +163,7 @@ public class AuthorWorkflowController {
*/
@RequestMapping(value = "/mnt/sec/authorHistory/listView.do")
public String historyListView(@ModelAttribute("authorChangeHistoryVO") AuthorChangeHistoryVO searchVO,
- ModelMap model) throws Exception {
+ HttpServletRequest request, ModelMap model) throws Exception {
PaginationInfo paginationInfo = new PaginationInfo();
paginationInfo.setCurrentPageNo(searchVO.getPageIndex());
paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage() < 1 ? 10 : searchVO.getRecordCountPerPage());
@@ -175,10 +175,166 @@ public class AuthorWorkflowController {
model.addAttribute("resultList", list);
model.addAttribute("paginationInfo", paginationInfo);
+ // 내 다운로드 작업 목록(비동기) — 상태·진행률 확인 및 내려받기
+ model.addAttribute("dlJobList", selectMyDownloadJobs(request));
return "site/mnt/sec/authorHistory/list";
}
- /** 권한 변경 이력 다운로드 — 사유 필수 + 다운로드 이력 적재 + CSV 파일 스트리밍 */
+ /**
+ * 내 권한이력 다운로드 작업의 상태만 돌려주는 폴링 전용 엔드포인트.
+ *
+ *
접속기록 화면과 같은 이유다 — 진행률을 보려고 목록 화면을 5초마다 다시 열면
+ * 이 화면이 접속기록 수집 화이트리스트({@code /mnt/sec/**}) 안이라 새로고침마다 감사로그 1행이
+ * 쌓인다. 상태 조회를 별도 경로로 빼고 {@code excludePatterns} 로 수집에서 제외한다.
+ * 본문에는 작업ID·상태·진행률·건수·실패사유만 담으며 본인 작업으로 한정한다.
종전 동기 방식({@link #historyDownload})은 조건 전체를 List 로 메모리에 올려
+ * OOM·요청 타임아웃 위험이 있었다. 파일 생성은 백그라운드 워커가 맡는다.
+ * 사유는 여기서도 필수이며, 미입력이면 작업을 만들지 않는다.
+ */
+ @RequestMapping(value = "/mnt/sec/authorHistory/downloadRequest.do")
+ public String historyDownloadRequest(@ModelAttribute("authorChangeHistoryVO") AuthorChangeHistoryVO searchVO,
+ @RequestParam(value = "dwldResn", required = false) String dwldResn,
+ HttpServletRequest request, ModelMap model) throws Exception {
+ LoginVO user = loginUser(request);
+ try {
+ if (user == null) {
+ throw new IllegalArgumentException("로그인 정보를 확인할 수 없습니다.");
+ }
+ authorHistDownloadJobService.requestJob(user.getId(),
+ ClientIpResolver.getClientIp(request), dwldResn, searchVO);
+ } catch (IllegalArgumentException e) {
+ model.addAttribute("dlMessage", e.getMessage());
+ } catch (Exception e) {
+ model.addAttribute("dlMessage", "다운로드 요청 중 오류가 발생했습니다.");
+ }
+ // 검색조건을 유지한 채 목록으로 — 요청 직후 작업 목록에서 진행상태를 볼 수 있다.
+ return historyListView(searchVO, request, model);
+ }
+
+ /**
+ * 완료된 권한이력 다운로드 파일 내려받기 — 요청자 본인만 가능하다.
+ *
+ *
저장 파일은 암호화돼 있어 복호화 후 스트리밍한다. 임시 복호화본은 전송 직후 지운다.
+ * 실제 내려받은 시점의 사유·건수 이력은 기존 {@code AUTHOR_HIST_DOWNLOAD_HISTORY} 에 적재한다.
+ */
+ @RequestMapping(value = "/mnt/sec/authorHistory/downloadFile.do")
+ public void historyDownloadFile(@RequestParam("jobId") long jobId,
+ HttpServletRequest request, HttpServletResponse response) throws Exception {
+ LoginVO user = loginUser(request);
+ if (user == null) {
+ response.sendError(HttpServletResponse.SC_FORBIDDEN, "접근 권한이 없습니다.");
+ return;
+ }
+ AuthorHistDownloadJobVO job = authorHistDownloadJobService.selectDownloadableJob(jobId, user.getId());
+ if (job == null) {
+ // 존재/소유/완료 여부를 구분해 알리지 않는다(작업ID 탐색 방지).
+ response.sendError(HttpServletResponse.SC_NOT_FOUND, "내려받을 수 있는 파일이 없습니다.");
+ return;
+ }
+
+ java.io.File plain = java.io.File.createTempFile("author-hist-dl-", ".csv");
+ try {
+ if (!egovframework.com.utl.sim.service.EgovFileScrty.decryptFile(
+ job.getFilePath(), plain.getAbsolutePath())) {
+ response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "파일을 열 수 없습니다.");
+ return;
+ }
+
+ String fileName = URLEncoder.encode(job.getFileNm(), "UTF-8");
+ response.setContentType("text/csv; charset=UTF-8");
+ response.setHeader("Content-Disposition",
+ "attachment; filename=\"" + fileName + "\"; filename*=UTF-8''" + fileName);
+ response.setHeader("Content-Transfer-Encoding", "binary");
+ response.setHeader("Content-Length", String.valueOf(plain.length()));
+
+ java.io.FileInputStream in = null;
+ OutputStream os = response.getOutputStream();
+ try {
+ in = new java.io.FileInputStream(plain);
+ byte[] buf = new byte[8192];
+ int len;
+ while ((len = in.read(buf)) > 0) {
+ os.write(buf, 0, len);
+ }
+ os.flush();
+ } finally {
+ if (in != null) {
+ try { in.close(); } catch (Exception ignore) { /* 무시 */ }
+ }
+ }
+
+ authorHistDownloadJobService.markDownloaded(jobId);
+ // 내려받은 사실을 기존 다운로드 이력에도 남긴다(사유·건수 증빙 일원화).
+ authorHistoryService.recordDownloadHistory(user.getId(), user.getUniqId(),
+ ClientIpResolver.getClientIp(request), job.getDlReason(), job.getSearchCond(), job.getDoneCnt());
+ } finally {
+ // 복호화 임시본을 남기지 않는다.
+ if (plain.exists() && !plain.delete()) {
+ plain.deleteOnExit();
+ }
+ }
+ }
+
+ /** 내 다운로드 작업 목록(최근 10건). 조회 실패해도 화면은 떠야 하므로 빈 목록으로 fail-safe. */
+ private List selectMyDownloadJobs(HttpServletRequest request) {
+ try {
+ LoginVO user = loginUser(request);
+ if (user == null) {
+ return new java.util.ArrayList();
+ }
+ List list = authorHistDownloadJobService.selectMyJobs(user.getId(), 10);
+ return (list == null) ? new java.util.ArrayList() : list;
+ } catch (Exception e) {
+ return new java.util.ArrayList();
+ }
+ }
+
+ /**
+ * 권한 변경 이력 다운로드 — 동기 방식(기존). 사유 필수 + 다운로드 이력 적재 + CSV 파일 스트리밍.
+ *
+ *
주의: 조건 전체를 메모리에 올린다. 화면은 위 {@link #historyDownloadRequest}
+ * 비동기 경로를 쓴다. 이 엔드포인트는 소량 즉시 다운로드와 기존 링크 호환을 위해 남겨둔다.
+ */
@RequestMapping(value = "/mnt/sec/authorHistory/download.do")
public void historyDownload(AuthorChangeHistoryVO searchVO,
@RequestParam(value = "dwldResn", required = false) String dwldResn,
@@ -211,10 +367,12 @@ public class AuthorWorkflowController {
OutputStream os = response.getOutputStream();
os.write(0xEF); os.write(0xBB); os.write(0xBF); // UTF-8 BOM (엑셀 한글 호환)
PrintWriter pw = new PrintWriter(new OutputStreamWriter(os, "UTF-8"));
- pw.println("변경일시,대상자ID,변경구분,변경전권한,변경후권한,변경사유,승인자,변경자ID,변경자IP");
+ // 열 구성은 비동기 워커(AuthorHistDownloadJobWorker.CSV_HEADER)와 동일하게 유지한다.
+ pw.println("변경일시,대상자ID,대상자명,변경구분,변경전권한,변경후권한,변경사유,승인자,변경자ID,변경자IP");
if (list != null) {
for (AuthorChangeHistoryVO h : list) {
- pw.println(csv(h.getChgDt()) + "," + csv(h.getTrgetId()) + "," + csv(chgSeLabel(h.getChgSeCode()))
+ pw.println(csv(h.getChgDt()) + "," + csv(h.getTrgetId()) + "," + csv(h.getTrgetNm())
+ + "," + csv(chgSeLabel(h.getChgSeCode()))
+ "," + csv(h.getBeforeAuthor()) + "," + csv(h.getAfterAuthor()) + "," + csv(h.getChgResn())
+ "," + csv(h.getAprovlId()) + "," + csv(h.getChangerId()) + "," + csv(h.getChangerIp()));
}
diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowDAO.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowDAO.java
index 0d93db3..06aa1ff 100644
--- a/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowDAO.java
+++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowDAO.java
@@ -30,18 +30,6 @@ public class AuthorWorkflowDAO extends EgovComAbstractDAO {
return (AuthorChangeRequestVO) selectByPk("AuthorWorkflowDAO.selectRequest", Long.valueOf(requestId));
}
- /** 신청 목록 조회(상태/대상 검색 + 페이징) */
- @SuppressWarnings("unchecked")
- public List selectRequestList(AuthorChangeRequestVO vo) {
- return list("AuthorWorkflowDAO.selectRequestList", vo);
- }
-
- /** 신청 목록 전체 건수 */
- public int selectRequestListCount(AuthorChangeRequestVO vo) {
- Object cnt = selectByPk("AuthorWorkflowDAO.selectRequestListCount", vo);
- return cnt == null ? 0 : ((Integer) cnt).intValue();
- }
-
/** 신청 상태 갱신(승인 A / 반려 J) */
public void updateRequestStatus(AuthorChangeRequestVO vo) {
update("AuthorWorkflowDAO.updateRequestStatus", vo);
@@ -77,4 +65,28 @@ public class AuthorWorkflowDAO extends EgovComAbstractDAO {
public egovframework.rte.psl.dataaccess.util.EgovMap selectTargetByLoginId(String trgetId) {
return (egovframework.rte.psl.dataaccess.util.EgovMap) selectByPk("AuthorWorkflowDAO.selectTargetByLoginId", trgetId);
}
+
+ /** 개인정보취급자 권한 목록(소속기관·역할·접근범위·권한상태·최근변경일 + 페이징) */
+ @SuppressWarnings("unchecked")
+ public List selectUserAuthorList(PrivacyUserAuthorVO vo) {
+ return list("AuthorWorkflowDAO.selectUserAuthorList", vo);
+ }
+
+ /** 개인정보취급자 권한 목록 전체 건수 */
+ public int selectUserAuthorListCount(PrivacyUserAuthorVO vo) {
+ Object cnt = selectByPk("AuthorWorkflowDAO.selectUserAuthorListCount", vo);
+ return cnt == null ? 0 : ((Integer) cnt).intValue();
+ }
+
+ /** 목록 상단 요약용 전체 승인대기 건수(검색조건 무관) */
+ public int selectPendingRequestCount() {
+ Object cnt = selectByPk("AuthorWorkflowDAO.selectPendingRequestCount", null);
+ return cnt == null ? 0 : ((Integer) cnt).intValue();
+ }
+
+ /** 검색조건 드롭다운용 소속기관 목록 */
+ @SuppressWarnings("unchecked")
+ public List selectOrgnztList() {
+ return list("AuthorWorkflowDAO.selectOrgnztList", null);
+ }
}
diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowService.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowService.java
index b7952fd..8103906 100644
--- a/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowService.java
+++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowService.java
@@ -10,12 +10,6 @@ public interface AuthorWorkflowService {
/** 권한 변경 신청 등록(상태 R) */
void submitRequest(AuthorChangeRequestVO vo) throws Exception;
- /** 신청 목록 조회 */
- List selectRequestList(AuthorChangeRequestVO vo) throws Exception;
-
- /** 신청 목록 건수 */
- int selectRequestListCount(AuthorChangeRequestVO vo) throws Exception;
-
/**
* 승인(A) — 권한 반영(COMTNEMPLYRSCRTYESTBS)과 이력 적재(AUTHOR_CHANGE_HISTORY)를
* 단일 트랜잭션으로 처리한다.
@@ -34,4 +28,13 @@ public interface AuthorWorkflowService {
/** 대상자 계정ID → 고유식별자·성명 자동조회 (없으면 null) */
egovframework.rte.psl.dataaccess.util.EgovMap selectTargetByLoginId(String trgetId) throws Exception;
+
+ /** 개인정보취급자 권한 목록 조회 — firstIndex/totalRecordCount 를 vo 에 세팅한다 */
+ List selectUserAuthorList(PrivacyUserAuthorVO vo) throws Exception;
+
+ /** 목록 상단 요약용 전체 승인대기 건수 */
+ int selectPendingRequestCount() throws Exception;
+
+ /** 검색조건 드롭다운용 소속기관 목록 */
+ List selectOrgnztList() throws Exception;
}
diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowServiceImpl.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowServiceImpl.java
index 1b8bc40..cc8ef0b 100644
--- a/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowServiceImpl.java
+++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowServiceImpl.java
@@ -31,21 +31,6 @@ public class AuthorWorkflowServiceImpl implements AuthorWorkflowService {
authorWorkflowDAO.insertRequest(vo);
}
- @Override
- public List selectRequestList(AuthorChangeRequestVO vo) throws Exception {
- int pageIndex = vo.getPageIndex() < 1 ? 1 : vo.getPageIndex();
- int perPage = vo.getRecordCountPerPage() < 1 ? 10 : vo.getRecordCountPerPage();
- vo.setFirstIndex((pageIndex - 1) * perPage);
- vo.setRecordCountPerPage(perPage);
- vo.setTotalRecordCount(authorWorkflowDAO.selectRequestListCount(vo));
- return authorWorkflowDAO.selectRequestList(vo);
- }
-
- @Override
- public int selectRequestListCount(AuthorChangeRequestVO vo) throws Exception {
- return authorWorkflowDAO.selectRequestListCount(vo);
- }
-
@Override
public void approveRequest(long requestId, String confmId, String confmUniqId, String confmIp) throws Exception {
AuthorChangeRequestVO req = authorWorkflowDAO.selectRequest(requestId);
@@ -119,6 +104,26 @@ public class AuthorWorkflowServiceImpl implements AuthorWorkflowService {
return authorWorkflowDAO.selectTargetByLoginId(trgetId.trim());
}
+ @Override
+ public List selectUserAuthorList(PrivacyUserAuthorVO vo) throws Exception {
+ int pageIndex = vo.getPageIndex() < 1 ? 1 : vo.getPageIndex();
+ int perPage = vo.getRecordCountPerPage() < 1 ? 10 : vo.getRecordCountPerPage();
+ vo.setFirstIndex((pageIndex - 1) * perPage);
+ vo.setRecordCountPerPage(perPage);
+ vo.setTotalRecordCount(authorWorkflowDAO.selectUserAuthorListCount(vo));
+ return authorWorkflowDAO.selectUserAuthorList(vo);
+ }
+
+ @Override
+ public int selectPendingRequestCount() throws Exception {
+ return authorWorkflowDAO.selectPendingRequestCount();
+ }
+
+ @Override
+ public List selectOrgnztList() throws Exception {
+ return authorWorkflowDAO.selectOrgnztList();
+ }
+
/** 신청구분(N/C/R) → 이력 변경구분(G:부여/M:변경/R:회수) */
private String mapChgSeCode(String requestSe) {
if ("R".equals(requestSe)) {
diff --git a/src/main/java/egovframework/com/cmm/privacy/author/PrivacyUserAuthorVO.java b/src/main/java/egovframework/com/cmm/privacy/author/PrivacyUserAuthorVO.java
new file mode 100644
index 0000000..7e3be63
--- /dev/null
+++ b/src/main/java/egovframework/com/cmm/privacy/author/PrivacyUserAuthorVO.java
@@ -0,0 +1,116 @@
+package egovframework.com.cmm.privacy.author;
+
+import java.io.Serializable;
+
+/**
+ * 개인정보취급자 권한 관리 목록 VO (개인정보보호 SFR-001 / #3 화면 #46).
+ *
+ *
취급자(COMTNEMPLYRINFO) 1행 = 목록 1행. 소속기관(COMTNORGNZTINFO),
+ * 역할·접근범위(COMTNEMPLYRSCRTYESTBS + COMTNAUTHORINFO), 권한상태(대기신청/권한유무),
+ * 최근변경일(AUTHOR_CHANGE_HISTORY)을 조인해 한 화면에 제공한다.
+ *
+ *
+ * 개정이력
+ * 2026.08.07 URP 최초 생성 — 신청목록 화면을 취급자 권한현황 화면으로 재구성
+ *
+ */
+public class PrivacyUserAuthorVO implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ /** 취급자 계정ID (COMTNEMPLYRINFO.EMPLYR_ID) */
+ private String emplyrId;
+ /** 취급자 고유식별자 (ESNTL_ID) */
+ private String uniqId;
+ /** 취급자명 */
+ private String userNm;
+ /** 소속기관 ID */
+ private String orgnztId;
+ /** 소속기관명 */
+ private String orgnztNm;
+ /** 역할 권한코드 */
+ private String authorCode;
+ /** 역할명(Role) */
+ private String authorNm;
+ /** 변경 전 역할명 — 승인대기 신청이 있으면 그 신청의 변경전권한, 없으면 최근 이력의 변경전권한 */
+ private String beforeAuthorNm;
+ /** 변경 후 역할명 — 승인대기 신청이 있으면 신청 목표권한, 없으면 현재 역할(=최근 이력의 변경후권한) */
+ private String afterAuthorNm;
+ /** 처리대기 신청구분 N:신설 C:변경 R:회수 (대기 신청 없으면 null) */
+ private String pendingRequestSe;
+ /** 접근범위 — 권한설명 없으면 허용 메뉴 수로 대체 */
+ private String accessScope;
+ /** 권한상태 N:정상 W:승인대기 R:말소 */
+ private String authorSttus;
+ /** 최근변경일 (yyyy-MM-dd) */
+ private String lastChgDt;
+ /** 처리대기 신청 일련번호 — 승인/반려 버튼용(없으면 0) */
+ private long pendingRequestId;
+ /** 계정상태 코드 P:사용 A:승인대기 S:사용중지 D:말소 */
+ private String emplyrSttusCode;
+
+ // --- 검색조건 ---
+ /** 소속기관 */
+ private String searchOrgnztId;
+ /** 역할(권한코드) */
+ private String searchAuthorCode;
+ /** 권한상태 N/W/R */
+ private String searchAuthorSttus;
+ /** 취급자명 또는 계정ID */
+ private String searchKeyword;
+
+ // --- 페이징 ---
+ private int pageIndex = 1;
+ private int recordCountPerPage = 10;
+ private int firstIndex = 0;
+ private int totalRecordCount = 0;
+
+ public String getEmplyrId() { return emplyrId; }
+ public void setEmplyrId(String emplyrId) { this.emplyrId = emplyrId; }
+ public String getUniqId() { return uniqId; }
+ public void setUniqId(String uniqId) { this.uniqId = uniqId; }
+ public String getUserNm() { return userNm; }
+ public void setUserNm(String userNm) { this.userNm = userNm; }
+ public String getOrgnztId() { return orgnztId; }
+ public void setOrgnztId(String orgnztId) { this.orgnztId = orgnztId; }
+ public String getOrgnztNm() { return orgnztNm; }
+ public void setOrgnztNm(String orgnztNm) { this.orgnztNm = orgnztNm; }
+ public String getAuthorCode() { return authorCode; }
+ public void setAuthorCode(String authorCode) { this.authorCode = authorCode; }
+ public String getAuthorNm() { return authorNm; }
+ public void setAuthorNm(String authorNm) { this.authorNm = authorNm; }
+ public String getBeforeAuthorNm() { return beforeAuthorNm; }
+ public void setBeforeAuthorNm(String beforeAuthorNm) { this.beforeAuthorNm = beforeAuthorNm; }
+ public String getAfterAuthorNm() { return afterAuthorNm; }
+ public void setAfterAuthorNm(String afterAuthorNm) { this.afterAuthorNm = afterAuthorNm; }
+ public String getPendingRequestSe() { return pendingRequestSe; }
+ public void setPendingRequestSe(String pendingRequestSe) { this.pendingRequestSe = pendingRequestSe; }
+ public String getAccessScope() { return accessScope; }
+ public void setAccessScope(String accessScope) { this.accessScope = accessScope; }
+ public String getAuthorSttus() { return authorSttus; }
+ public void setAuthorSttus(String authorSttus) { this.authorSttus = authorSttus; }
+ public String getLastChgDt() { return lastChgDt; }
+ public void setLastChgDt(String lastChgDt) { this.lastChgDt = lastChgDt; }
+ public long getPendingRequestId() { return pendingRequestId; }
+ public void setPendingRequestId(long pendingRequestId) { this.pendingRequestId = pendingRequestId; }
+ public String getEmplyrSttusCode() { return emplyrSttusCode; }
+ public void setEmplyrSttusCode(String emplyrSttusCode) { this.emplyrSttusCode = emplyrSttusCode; }
+
+ public String getSearchOrgnztId() { return searchOrgnztId; }
+ public void setSearchOrgnztId(String searchOrgnztId) { this.searchOrgnztId = searchOrgnztId; }
+ public String getSearchAuthorCode() { return searchAuthorCode; }
+ public void setSearchAuthorCode(String searchAuthorCode) { this.searchAuthorCode = searchAuthorCode; }
+ public String getSearchAuthorSttus() { return searchAuthorSttus; }
+ public void setSearchAuthorSttus(String searchAuthorSttus) { this.searchAuthorSttus = searchAuthorSttus; }
+ public String getSearchKeyword() { return searchKeyword; }
+ public void setSearchKeyword(String searchKeyword) { this.searchKeyword = searchKeyword; }
+
+ 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; }
+}
diff --git a/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogController.java b/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogController.java
new file mode 100644
index 0000000..5e63bed
--- /dev/null
+++ b/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogController.java
@@ -0,0 +1,194 @@
+package egovframework.com.cmm.privacy.conect;
+
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.net.URLEncoder;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+import javax.annotation.Resource;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+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.util.EgovUserDetailsHelper;
+import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo;
+
+/**
+ * 로그인·로그아웃 접속기록 조회·다운로드 컨트롤러 — 개인정보보호 기능개선 사업(#62).
+ *
+ *
설정 대역(CONECT_IP_LOCATION)과 실제 적재된 값(COMTNLOGINLOG DISTINCT)을
+ * 합친다. #32-1 에서 겪은 함정 — 매핑 테이블만 보면 실제 적재값과 어긋난 선택지가 떠
+ * 검색 결과가 항상 0건이 된다 — 을 피하기 위한 것이다.
접속ID 주의: {@code COMTNLOGINLOG.CONECT_ID}({@link #conectId})는 고유식별자
+ * 자리인데 실제 적재값이 16자리 hex 라 회원 테이블 어디와도 매칭되지 않는다(2026-08-07
+ * 373,959행 실측). 그래서 화면의 '접속ID'({@link #userId})는 조인 결과가 아니라 적재 시점에
+ * 세션 {@code LoginVO.getId()} 를 직접 남긴 {@code CONECT_ACNT_ID} 값이다.
+ * 2026-08-07 이전 기록은 이 값이 없어 화면이 {@link #conectId} 를 대신 보여준다.
{@code CONECT_IP_LOCATION}(IP 대역 → 위치명)을 캐시해 두고, 접속 IP 가 속하는
+ * 대역 중 가장 긴 프리픽스 1건의 위치명을 돌려준다. 매칭이 없으면
+ * 사설/루프백 대역 판정에 따른 내장 폴백을 쓴다 — 테이블이 비어 있어도 동작한다.
+ *
+ *
왜 스냅샷인가: 판정 결과는 적재 시점에
+ * {@code COMTNLOGINLOG.CONECT_LC} 로 기록된다. 나중에 대역 매핑을 바꿔도 과거
+ * 기록의 접속위치는 변하지 않아야 감사증빙으로 쓸 수 있다.
+ *
+ *
fail-safe: 이 판정은 로그인/로그아웃 경로에서 호출된다. 어떤 예외도
+ * 밖으로 던지지 않고 폴백 값을 돌려준다 — 접속위치 때문에 로그인이 막히면 안 된다.
+ */
+@Component("ipLocationResolver")
+public class IpLocationResolver {
+
+ /** 매칭 실패 시 기본 위치명. */
+ public static final String LOC_EXTERNAL = "외부(인터넷)";
+ /** 사설 대역 폴백. */
+ public static final String LOC_INTERNAL = "내부망";
+ /** 루프백 폴백. */
+ public static final String LOC_LOCAL = "서버 로컬";
+ /** IP 자체를 못 얻은 경우. */
+ public static final String LOC_UNKNOWN = "확인불가";
+
+ /** 캐시 재적재 주기(ms). 운영 중 대역 행을 추가해도 이 주기 안에 반영된다. */
+ private static final long RELOAD_INTERVAL_MS = 5L * 60L * 1000L;
+
+ @Resource(name = "ipLocationDAO")
+ private IpLocationDAO ipLocationDAO;
+
+ /** 프리픽스 길이 내림차순으로 정렬된 매핑 캐시(가장 긴 것이 먼저 매칭된다). */
+ private volatile List cache = new ArrayList();
+ /** 마지막 적재 시각(0 = 미적재). */
+ private volatile long loadedAt = 0L;
+
+ /**
+ * 접속 IP 의 위치명을 판정한다.
+ *
+ * @param ip 점 표기 IPv4(IPv6/불명이면 폴백)
+ * @return 위치명(항상 비어 있지 않은 문자열)
+ */
+ public String resolve(String ip) {
+ if (ip == null || ip.trim().length() == 0 || "unknown".equalsIgnoreCase(ip.trim())) {
+ return LOC_UNKNOWN;
+ }
+ String target = ip.trim();
+
+ long addr = ipv4ToLong(target);
+ if (addr < 0L) {
+ // IPv6 등 — 루프백만 별도로 알아보고 나머지는 외부로 본다.
+ return ("::1".equals(target) || "0:0:0:0:0:0:0:1".equals(target)) ? LOC_LOCAL : LOC_EXTERNAL;
+ }
+
+ for (IpLocationVO loc : currentCache()) {
+ if (loc.getPrefixLen() < 0) {
+ continue; // 파싱 불가 행은 무시(운영자가 잘못 입력해도 판정은 계속된다)
+ }
+ long mask = (loc.getPrefixLen() == 0) ? 0L : (0xFFFFFFFFL << (32 - loc.getPrefixLen())) & 0xFFFFFFFFL;
+ if ((addr & mask) == (loc.getNetwork() & mask)) {
+ return (loc.getLocNm() == null || loc.getLocNm().length() == 0) ? LOC_EXTERNAL : loc.getLocNm();
+ }
+ }
+ return fallback(addr);
+ }
+
+ /** 매핑 캐시를 즉시 비운다(대역 행을 고친 직후 반영용). */
+ public void refresh() {
+ loadedAt = 0L;
+ }
+
+ /** 유효기간이 지났으면 다시 읽고, 실패하면 직전 캐시를 그대로 쓴다. */
+ private List currentCache() {
+ long now = System.currentTimeMillis();
+ if (loadedAt != 0L && (now - loadedAt) < RELOAD_INTERVAL_MS) {
+ return cache;
+ }
+ try {
+ List loaded = ipLocationDAO.selectUsedLocationList();
+ if (loaded == null) {
+ loaded = new ArrayList();
+ }
+ for (IpLocationVO loc : loaded) {
+ parseCidr(loc);
+ }
+ Collections.sort(loaded, new Comparator() {
+ public int compare(IpLocationVO a, IpLocationVO b) {
+ // 프리픽스가 긴(=더 구체적인) 대역이 먼저 매칭되어야 한다.
+ return b.getPrefixLen() - a.getPrefixLen();
+ }
+ });
+ cache = loaded;
+ loadedAt = now;
+ } catch (Exception e) {
+ // 테이블 미적용·DB 일시 오류 — 직전 캐시(초기엔 빈 목록)로 계속 판정한다.
+ loadedAt = now; // 매 요청마다 실패 조회를 반복하지 않도록 주기는 넘긴다
+ }
+ return cache;
+ }
+
+ /** "10.0.0.0/8" → network/prefixLen. 형식 오류면 prefixLen = -1(무시 대상). */
+ private void parseCidr(IpLocationVO loc) {
+ loc.setNetwork(-1L);
+ loc.setPrefixLen(-1);
+ String cidr = loc.getIpCidr();
+ if (cidr == null) {
+ return;
+ }
+ String trimmed = cidr.trim();
+ int slash = trimmed.indexOf('/');
+ String network = (slash < 0) ? trimmed : trimmed.substring(0, slash);
+ int prefix;
+ try {
+ prefix = (slash < 0) ? 32 : Integer.parseInt(trimmed.substring(slash + 1).trim());
+ } catch (NumberFormatException e) {
+ return;
+ }
+ if (prefix < 0 || prefix > 32) {
+ return;
+ }
+ long net = ipv4ToLong(network);
+ if (net < 0L) {
+ return;
+ }
+ loc.setNetwork(net);
+ loc.setPrefixLen(prefix);
+ }
+
+ /** 테이블 매칭이 없을 때의 내장 판정(루프백/사설/그 외). */
+ private String fallback(long addr) {
+ if (inCidr(addr, "127.0.0.0", 8)) {
+ return LOC_LOCAL;
+ }
+ if (inCidr(addr, "10.0.0.0", 8) || inCidr(addr, "172.16.0.0", 12) || inCidr(addr, "192.168.0.0", 16)) {
+ return LOC_INTERNAL;
+ }
+ return LOC_EXTERNAL;
+ }
+
+ private boolean inCidr(long addr, String network, int prefix) {
+ long net = ipv4ToLong(network);
+ long mask = (0xFFFFFFFFL << (32 - prefix)) & 0xFFFFFFFFL;
+ return (addr & mask) == (net & mask);
+ }
+
+ /** 점 표기 IPv4 → 부호없는 long. 형식 오류(IPv6 포함) 시 -1. */
+ private static long ipv4ToLong(String ip) {
+ if (ip == null) {
+ return -1L;
+ }
+ String[] octets = ip.trim().split("\\.");
+ if (octets.length != 4) {
+ return -1L;
+ }
+ long result = 0L;
+ for (String octet : octets) {
+ int v;
+ try {
+ v = Integer.parseInt(octet.trim());
+ } catch (NumberFormatException e) {
+ return -1L;
+ }
+ if (v < 0 || v > 255) {
+ return -1L;
+ }
+ result = (result << 8) | v;
+ }
+ return result & 0xFFFFFFFFL;
+ }
+}
diff --git a/src/main/java/egovframework/com/cmm/privacy/conect/IpLocationVO.java b/src/main/java/egovframework/com/cmm/privacy/conect/IpLocationVO.java
new file mode 100644
index 0000000..bb88aaf
--- /dev/null
+++ b/src/main/java/egovframework/com/cmm/privacy/conect/IpLocationVO.java
@@ -0,0 +1,64 @@
+package egovframework.com.cmm.privacy.conect;
+
+import java.io.Serializable;
+
+/**
+ * 접속위치 매핑 VO — 개인정보보호 기능개선 사업(#62, 로그인·로그아웃 접속기록).
+ *
+ *
{@code CONECT_IP_LOCATION} 1행. IP 대역(CIDR)과 그 대역에 붙일 위치명이다.
+ * 접속기록 적재 시점에 {@link IpLocationResolver}가 가장 긴 프리픽스 1건을 골라
+ * {@code COMTNLOGINLOG.CONECT_LC}에 스냅샷으로 남긴다 — 나중에 매핑을 바꿔도
+ * 과거 기록의 위치는 변하지 않는다(감사증빙).
+ */
+public class IpLocationVO implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ /** 일련번호(PK). */
+ private long locId;
+ /** IP 대역(CIDR 표기. 예 "10.0.0.0/8", "203.0.113.7/32"). */
+ private String ipCidr;
+ /** 접속위치명. */
+ private String locNm;
+ /** 내외부 구분 I:내부망 E:외부. */
+ private String locSe;
+ /** 정렬순서. */
+ private int sortOrdr;
+ /** 사용여부 Y/N. */
+ private String useAt;
+ /** 비고. */
+ private String remark;
+
+ // ---- CIDR 파싱 결과(캐시 적재 시 1회 계산, DB 컬럼 아님) ----
+ /** 네트워크 주소(부호없는 IPv4). 파싱 실패 시 -1. */
+ private transient long network = -1L;
+ /** 프리픽스 길이(0~32). 파싱 실패 시 -1. */
+ private transient int prefixLen = -1;
+
+ public long getLocId() { return locId; }
+ public void setLocId(long locId) { this.locId = locId; }
+ public String getIpCidr() { return ipCidr; }
+ public void setIpCidr(String ipCidr) { this.ipCidr = ipCidr; }
+ public String getLocNm() { return locNm; }
+ public void setLocNm(String locNm) { this.locNm = locNm; }
+ public String getLocSe() { return locSe; }
+ public void setLocSe(String locSe) { this.locSe = locSe; }
+ public int getSortOrdr() { return sortOrdr; }
+ public void setSortOrdr(int sortOrdr) { this.sortOrdr = sortOrdr; }
+ public String getUseAt() { return useAt; }
+ public void setUseAt(String useAt) { this.useAt = useAt; }
+ public String getRemark() { return remark; }
+ public void setRemark(String remark) { this.remark = remark; }
+
+ public long getNetwork() { return network; }
+ public void setNetwork(long network) { this.network = network; }
+ public int getPrefixLen() { return prefixLen; }
+ public void setPrefixLen(int prefixLen) { this.prefixLen = prefixLen; }
+}
diff --git a/src/main/java/egovframework/com/cmm/privacy/crypto/MemberDataCipher.java b/src/main/java/egovframework/com/cmm/privacy/crypto/MemberDataCipher.java
new file mode 100644
index 0000000..320989e
--- /dev/null
+++ b/src/main/java/egovframework/com/cmm/privacy/crypto/MemberDataCipher.java
@@ -0,0 +1,181 @@
+/**
+ * Class Name : MemberDataCipher.java
+ * Description : 회원 개인정보 항목 대칭암호(AES-GCM) 헬퍼 — 개인정보보호 기능개선 사업(추가제안 Action4)
+ *
+ * Modification Information
+ *
+ * 수정일 수정자 수정내용
+ * ------- -------- ---------------------------
+ * 2026.08.10 개인정보보호 최초 생성 — 회원 이름(MBER_NM) 암호화 저장
+ *
+ * @since 2026. 08. 10
+ * @version 1.0
+ */
+package egovframework.com.cmm.privacy.crypto;
+
+import java.security.SecureRandom;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+
+import org.apache.commons.codec.binary.Base64;
+
+/**
+ * 회원 개인정보 항목을 가역 암호화(AES-128/GCM)하여 저장하고, 표시 시점에만 복호화한다.
+ *
+ *
대상: 1단계는 일반회원 이름({@code COMTNGNRLMBER.MBER_NM}, 실측 77,053건).
+ * 이메일·휴대폰·주소는 동일 클래스를 재사용해 후속 단계에서 확장한다.
+ *
+ *
랜덤 IV를 쓰는 이유: 같은 이름이라도 매번 다른 암호문이 나온다. 결정적 암호(고정 IV)를
+ * 쓰면 암호문만 보고 "이 두 회원은 동명이인"이라는 사실이 새어나가고, 빈도분석으로 흔한 이름부터
+ * 역추적된다. 대신 검색이 불가능해지므로 완전일치 검색은
+ * {@link MemberDataHasher}의 해시 컬럼이 전담한다.
키 관리 — 이 클래스는 개발용 폴백을 두지 않는다.
+ * 기존 {@code OtpSecretCipher}는 키 미설정 시 소스에 박힌 개발용 키로 폴백하는데, 회원정보에
+ * 그 방식을 승계하면 "암호화했으나 키가 소스에 있는 상태"가 되어 관리실태 점검 지적이 재발한다.
+ * 따라서 키가 없으면 {@link IllegalStateException}으로 기동을 실패시킨다. 조용히 약한
+ * 암호로 돌아가는 것보다 뜨는 편이 안전하다.
+ *
+ *
키 설정: 시스템 프로퍼티 {@code arko.mber.master.key} 또는 환경변수
+ * {@code ARKO_MBER_MASTER_KEY}에 Base64 인코딩된 16바이트 키를 넣는다.
+ * WAR 안에 두지 않는다(서버 {@code setenv.sh} 또는 퍼미션 600 키파일).
+ *
+ *
+ * # 키 생성 예시
+ * openssl rand -base64 16
+ *
+ *
+ *
키 길이가 AES-128인 이유: AES-256은 구형 JDK에서 JCE 무제한 정책 파일을 요구한다.
+ * 운영 JDK가 8u161 이상이면 무제한이 기본이므로 상향 가능하나, 운영 JDK 확인 전까지는
+ * {@code OtpSecretCipher}와 동일하게 128로 맞춰 배포 리스크를 없앤다.
+ *
+ *
스레드 안전: {@link Cipher}는 매 호출마다 새로 얻는다(재사용 불가). 이 클래스 자체는
+ * 불변이므로 싱글턴 빈으로 공유해도 된다.
+ */
+public class MemberDataCipher {
+
+ private static final String TRANSFORMATION = "AES/GCM/NoPadding";
+ private static final int GCM_TAG_BITS = 128;
+ private static final int GCM_IV_BYTES = 12;
+ private static final int KEY_BYTES = 16; // AES-128
+
+ /** 마스터 키 시스템 프로퍼티명. */
+ public static final String MASTER_KEY_PROP = "arko.mber.master.key";
+ /** 마스터 키 환경변수명. */
+ public static final String MASTER_KEY_ENV = "ARKO_MBER_MASTER_KEY";
+
+ /**
+ * 현재 키 버전. 암호화한 행의 {@code CRYPTO_KEY_VER}에 함께 적재한다.
+ * 키를 교체할 때 이 값을 올리고 복호화 측에서 버전별 키를 선택하게 확장한다.
+ */
+ public static final int CURRENT_KEY_VER = 1;
+
+ private final byte[] masterKey;
+ private final SecureRandom random = new SecureRandom();
+
+ /** 설정된 마스터 키로 생성한다. 키가 없으면 즉시 실패한다. */
+ public MemberDataCipher() {
+ this.masterKey = loadMasterKey();
+ }
+
+ /** 명시적 키 주입(테스트/키 교체용). */
+ public MemberDataCipher(byte[] key16) {
+ if (key16 == null || key16.length != KEY_BYTES) {
+ throw new IllegalArgumentException("AES-128 master key must be " + KEY_BYTES + " bytes");
+ }
+ this.masterKey = key16.clone();
+ }
+
+ /**
+ * 마스터 키를 로드한다.
+ * 폴백 없음 — 미설정·형식오류는 예외다.
+ */
+ private static byte[] loadMasterKey() {
+ String b64 = System.getProperty(MASTER_KEY_PROP);
+ if (isBlank(b64)) {
+ b64 = System.getenv(MASTER_KEY_ENV);
+ }
+ if (isBlank(b64)) {
+ throw new IllegalStateException(
+ "[개인정보/암호화] 회원정보 마스터 키가 설정되지 않았습니다. "
+ + "시스템 프로퍼티 " + MASTER_KEY_PROP + " 또는 환경변수 " + MASTER_KEY_ENV
+ + " 에 Base64 인코딩된 16바이트 키를 설정하십시오. (생성: openssl rand -base64 16)");
+ }
+ byte[] key = Base64.decodeBase64(b64.trim().getBytes());
+ if (key.length != KEY_BYTES) {
+ throw new IllegalStateException(
+ "[개인정보/암호화] 마스터 키 길이가 올바르지 않습니다. 기대 " + KEY_BYTES
+ + "바이트, 실제 " + key.length + "바이트. Base64 인코딩된 16바이트 키여야 합니다.");
+ }
+ return key;
+ }
+
+ /**
+ * 평문을 AES-GCM 암호화하여 Base64 문자열로 반환한다.
+ *
+ *
{@code null}은 {@code null}로, 빈 문자열은 빈 문자열로 그대로 돌려준다.
+ * arko는 회원 탈퇴 시 개인정보 컬럼을 빈 문자열로 채우는 로직이 있어
+ * ({@code EgovMberManage_SQL_Mysql.xml}) NULL과 ''를 구분해 보존해야 파기 상태가 뭉개지지 않는다.
암호문이 변조됐거나 다른 키로 만든 것이면 GCM 태그 검증에 실패해 예외가 난다.
+ * 호출측은 이를 "복호화 실패"로 처리하고 평문을 화면에 대체 출력하지 않는다.
+ *
+ * @param encryptedBase64 암호문 Base64(null/빈문자 허용)
+ * @return 평문. 입력이 null이면 null, 빈문자면 빈문자
+ */
+ public String decrypt(String encryptedBase64) throws Exception {
+ if (encryptedBase64 == null) {
+ return null;
+ }
+ if (encryptedBase64.length() == 0) {
+ return "";
+ }
+ byte[] all = Base64.decodeBase64(encryptedBase64.getBytes("US-ASCII"));
+ if (all.length <= GCM_IV_BYTES) {
+ throw new IllegalArgumentException("암호문 길이가 비정상입니다: " + all.length + "바이트");
+ }
+ byte[] iv = new byte[GCM_IV_BYTES];
+ System.arraycopy(all, 0, iv, 0, GCM_IV_BYTES);
+ byte[] ct = new byte[all.length - GCM_IV_BYTES];
+ System.arraycopy(all, GCM_IV_BYTES, ct, 0, ct.length);
+ Cipher cipher = Cipher.getInstance(TRANSFORMATION);
+ cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(masterKey, "AES"),
+ new GCMParameterSpec(GCM_TAG_BITS, iv));
+ return new String(cipher.doFinal(ct), "UTF-8");
+ }
+
+ private static boolean isBlank(String s) {
+ return s == null || s.trim().length() == 0;
+ }
+}
diff --git a/src/main/java/egovframework/com/cmm/privacy/crypto/MemberDataHasher.java b/src/main/java/egovframework/com/cmm/privacy/crypto/MemberDataHasher.java
new file mode 100644
index 0000000..3e6b228
--- /dev/null
+++ b/src/main/java/egovframework/com/cmm/privacy/crypto/MemberDataHasher.java
@@ -0,0 +1,162 @@
+/**
+ * Class Name : MemberDataHasher.java
+ * Description : 회원 개인정보 검색용 키드 해시(HMAC-SHA256) — 개인정보보호 기능개선 사업(추가제안 Action4)
+ *
+ * Modification Information
+ *
+ * 수정일 수정자 수정내용
+ * ------- -------- ---------------------------
+ * 2026.08.10 개인정보보호 최초 생성 — 이름 완전일치 검색용 해시
+ *
+ * @since 2026. 08. 10
+ * @version 1.0
+ */
+package egovframework.com.cmm.privacy.crypto;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+/**
+ * 암호화된 개인정보 항목의 완전일치 검색을 위한 키드 해시를 만든다.
+ *
+ *
왜 필요한가: {@link MemberDataCipher}는 랜덤 IV라 같은 이름도 매번 다른 암호문이 된다.
+ * 따라서 암호문으로는 검색할 수 없다. 검색 전용으로 결정적인 값을 별도 컬럼
+ * ({@code MBER_NM_HASH})에 두고 인덱스를 건다.
+ *
+ *
왜 순수 SHA-256이 아니라 HMAC인가: 제안서 문구는 "SHA-256"이지만, 이름·휴대폰번호는
+ * 엔트로피가 극히 낮다. 한국인 이름은 사실상 수십만 가지, 휴대폰번호는 1억 미만이라 순수 해시는
+ * 전수대입 한 번으로 원문이 전부 복원된다 — 해시를 걸어도 평문이나 마찬가지가 된다.
+ * 비밀 pepper를 섞는 HMAC이면 pepper를 모르는 공격자는 사전을 만들 수 없다.
+ * 제안 범위보다 강화되는 방향이므로 사업상 문제가 없고 점검 대응력이 올라간다.
+ *
+ *
정규화가 해시 일치의 전제다. 같은 값이 다른 해시로 갈리면 검색이 조용히 실패한다.
+ * 항목별 정규화 규칙을 이 클래스 한 곳에만 두고, 저장 시점과 검색 시점이 반드시 같은
+ * 메서드를 쓰게 한다.
+ *
+ *
pepper 설정: 시스템 프로퍼티 {@code arko.mber.hash.pepper} 또는 환경변수
+ * {@code ARKO_MBER_HASH_PEPPER}. 마스터 키와 다른 값이어야 한다(하나가 유출돼도
+ * 다른 하나는 살아 있게). 미설정 시 폴백 없이 실패한다.
+ *
+ *
pepper는 교체할 수 없다. 바꾸면 저장된 해시 전체를 재계산해야 한다
+ * (원본 복호화 → 재해시 배치). 처음에 충분히 긴 값으로 정하고 고정한다.
+ */
+public class MemberDataHasher {
+
+ private static final String ALGORITHM = "HmacSHA256";
+
+ /** pepper 시스템 프로퍼티명. */
+ public static final String PEPPER_PROP = "arko.mber.hash.pepper";
+ /** pepper 환경변수명. */
+ public static final String PEPPER_ENV = "ARKO_MBER_HASH_PEPPER";
+
+ private static final char[] HEX = "0123456789abcdef".toCharArray();
+
+ private final byte[] pepper;
+
+ public MemberDataHasher() {
+ this.pepper = loadPepper();
+ }
+
+ /** 명시적 pepper 주입(테스트용). */
+ public MemberDataHasher(byte[] pepper) {
+ if (pepper == null || pepper.length == 0) {
+ throw new IllegalArgumentException("pepper must not be empty");
+ }
+ this.pepper = pepper.clone();
+ }
+
+ private static byte[] loadPepper() {
+ String p = System.getProperty(PEPPER_PROP);
+ if (p == null || p.trim().length() == 0) {
+ p = System.getenv(PEPPER_ENV);
+ }
+ if (p == null || p.trim().length() == 0) {
+ throw new IllegalStateException(
+ "[개인정보/암호화] 검색용 해시 pepper가 설정되지 않았습니다. "
+ + "시스템 프로퍼티 " + PEPPER_PROP + " 또는 환경변수 " + PEPPER_ENV + " 를 설정하십시오. "
+ + "(마스터 키와 다른 값이어야 합니다. 생성: openssl rand -base64 32)");
+ }
+ try {
+ return p.trim().getBytes("UTF-8");
+ } catch (Exception e) {
+ throw new IllegalStateException("pepper 인코딩 실패", e);
+ }
+ }
+
+ /**
+ * 이름 검색용 해시. 정규화는 앞뒤 공백 제거만 한다.
+ *
+ *
가운데 공백은 지우지 않는다 — "남궁 민수"와 "남궁민수"는 실제로 다르게 등록된
+ * 별개 표기이며, 임의로 합치면 저장 시점과 관리자 검색 시점의 정규화가 어긋날 여지가 커진다.
+ * 소문자화도 하지 않는다(한글에 무의미하고 영문 이름의 대소문자는 유의미할 수 있다).
+ *
+ * @param name 평문 이름(null/빈문자 허용)
+ * @return 소문자 hex 64자. 입력이 null이면 null, 빈문자면 빈문자
+ */
+ public String hashName(String name) {
+ if (name == null) {
+ return null;
+ }
+ String norm = name.trim();
+ if (norm.length() == 0) {
+ return "";
+ }
+ return hmacHex(norm);
+ }
+
+ /**
+ * 이메일 검색용 해시. 앞뒤 공백 제거 + 소문자화.
+ * (후속 단계용 — 이름 단계에서는 사용하지 않는다.)
+ */
+ public String hashEmail(String email) {
+ if (email == null) {
+ return null;
+ }
+ String norm = email.trim().toLowerCase();
+ if (norm.length() == 0) {
+ return "";
+ }
+ return hmacHex(norm);
+ }
+
+ /**
+ * 휴대폰번호 검색용 해시. 숫자 이외 문자를 모두 제거한다.
+ * ({@code 010-1234-5678}, {@code 010 1234 5678} → {@code 01012345678})
+ * (후속 단계용 — 이름 단계에서는 사용하지 않는다.)
+ */
+ public String hashMobile(String mobile) {
+ if (mobile == null) {
+ return null;
+ }
+ StringBuilder sb = new StringBuilder(mobile.length());
+ for (int i = 0; i < mobile.length(); i++) {
+ char c = mobile.charAt(i);
+ if (c >= '0' && c <= '9') {
+ sb.append(c);
+ }
+ }
+ if (sb.length() == 0) {
+ return "";
+ }
+ return hmacHex(sb.toString());
+ }
+
+ /** 정규화가 끝난 문자열의 HMAC-SHA256 소문자 hex(64자). */
+ private String hmacHex(String normalized) {
+ try {
+ Mac mac = Mac.getInstance(ALGORITHM);
+ mac.init(new SecretKeySpec(pepper, ALGORITHM));
+ byte[] out = mac.doFinal(normalized.getBytes("UTF-8"));
+ char[] hex = new char[out.length * 2];
+ for (int i = 0; i < out.length; i++) {
+ int v = out[i] & 0xff;
+ hex[i * 2] = HEX[v >>> 4];
+ hex[i * 2 + 1] = HEX[v & 0x0f];
+ }
+ return new String(hex);
+ } catch (Exception e) {
+ // 해시 실패는 검색 불가로 이어지므로 삼키지 않는다.
+ throw new IllegalStateException("[개인정보/암호화] 검색용 해시 생성 실패", e);
+ }
+ }
+}
diff --git a/src/main/java/egovframework/com/cmm/privacy/crypto/MemberNmCryptoDAO.java b/src/main/java/egovframework/com/cmm/privacy/crypto/MemberNmCryptoDAO.java
new file mode 100644
index 0000000..bc703cc
--- /dev/null
+++ b/src/main/java/egovframework/com/cmm/privacy/crypto/MemberNmCryptoDAO.java
@@ -0,0 +1,139 @@
+/**
+ * Class Name : MemberNmCryptoDAO.java
+ * Description : 회원 이름 암호화 이관 DAO — 개인정보보호 기능개선 사업(추가제안 Action4)
+ *
+ * Modification Information
+ *
+ * 수정일 수정자 수정내용
+ * ------- -------- ---------------------------
+ * 2026.08.10 개인정보보호 최초 생성
+ *
+ * @since 2026. 08. 10
+ * @version 1.0
+ */
+package egovframework.com.cmm.privacy.crypto;
+
+import java.sql.SQLException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.orm.ibatis.SqlMapClientCallback;
+import org.springframework.stereotype.Repository;
+
+import com.ibatis.sqlmap.client.SqlMapExecutor;
+
+import egovframework.com.cmm.service.impl.EgovComAbstractDAO;
+
+/**
+ * 회원 이름 암호화 백필 배치의 데이터 접근. 청크 조회 → 배치 UPDATE → 진행상태 기록.
+ *
+ *
평문 컬럼은 절대 UPDATE하지 않는다. 이 DAO의 update문은 {@code MBER_NM_ENC} /
+ * {@code MBER_NM_HASH} / {@code CRYPTO_KEY_VER}만 건드린다. 평문이 그대로 남아 있어야
+ * ① 재실행 시 미처리 판정이 가능하고 ② 읽기 전환 전까지 롤백 창구가 유지된다.
+ *
+ * @author 개인정보보호
+ * @since 2026-08-10
+ */
+@Repository("memberNmCryptoDAO")
+public class MemberNmCryptoDAO extends EgovComAbstractDAO {
+
+ /** 진행상태 테이블의 작업 식별자. */
+ public static final String JOB_ID = "COMTNGNRLMBER.MBER_NM";
+
+ /**
+ * 커서 이후의 미암호화 행을 청크 크기만큼 조회한다.
+ *
+ * @param lastKey 직전 청크의 마지막 MBER_ID. 최초 실행이면 null
+ * @param chunkSize 청크 크기
+ */
+ @SuppressWarnings("unchecked")
+ public List selectChunk(String lastKey, int chunkSize) {
+ Map param = new HashMap();
+ param.put("lastKey", lastKey);
+ param.put("chunkSize", Integer.valueOf(chunkSize));
+ return list("MemberNmCryptoDAO.selectChunk", param);
+ }
+
+ /**
+ * 청크 한 덩어리를 단일 배치로 UPDATE한다.
+ *
+ *
건별 executeUpdate를 77,053번 왕복하면 네트워크 라운드트립이 지배적이 된다.
+ * iBatis2 배치로 묶으면 한 번의 왕복으로 처리되어 수 분이 수십 초로 줄어든다.
+ *
+ * @return 실제 반영 건수
+ */
+ public int updateEncryptedBatch(final List rows) {
+ if (rows == null || rows.isEmpty()) {
+ return 0;
+ }
+ Object result = getSqlMapClientTemplate().execute(new SqlMapClientCallback() {
+ public Object doInSqlMapClient(SqlMapExecutor executor) throws SQLException {
+ executor.startBatch();
+ for (int i = 0; i < rows.size(); i++) {
+ executor.update("MemberNmCryptoDAO.updateEncrypted", rows.get(i));
+ }
+ return Integer.valueOf(executor.executeBatch());
+ }
+ });
+ return (result instanceof Integer) ? ((Integer) result).intValue() : 0;
+ }
+
+ /** 아직 암호화되지 않은 잔여 건수. */
+ public int selectRemainingCount() {
+ Object v = selectByPk("MemberNmCryptoDAO.selectRemainingCount", null);
+ return (v instanceof Integer) ? ((Integer) v).intValue() : 0;
+ }
+
+ /** 진행상태 행을 없으면 만든다(재실행 안전). */
+ public void insertProgressIfAbsent() {
+ Map param = new HashMap();
+ param.put("jobId", JOB_ID);
+ insert("MemberNmCryptoDAO.insertProgressIfAbsent", param);
+ }
+
+ /** 청크 완료 시 커서·누적건수를 갱신한다. */
+ public void updateProgress(String lastKey, int doneDelta, int errDelta) {
+ Map param = new HashMap();
+ param.put("jobId", JOB_ID);
+ param.put("lastKey", lastKey);
+ param.put("doneDelta", Integer.valueOf(doneDelta));
+ param.put("errDelta", Integer.valueOf(errDelta));
+ update("MemberNmCryptoDAO.updateProgress", param);
+ }
+
+ /** 작업 상태(R/S/C/E)와 비고를 갱신한다. */
+ public void updateStatus(String status, String remark) {
+ Map param = new HashMap();
+ param.put("jobId", JOB_ID);
+ param.put("jobSttus", status);
+ param.put("rm", remark);
+ update("MemberNmCryptoDAO.updateStatus", param);
+ }
+
+ /** 진행상태 1건 조회(화면 표시·중지요청 확인용). */
+ @SuppressWarnings("unchecked")
+ public Map selectProgress() {
+ Map param = new HashMap();
+ param.put("jobId", JOB_ID);
+ return (Map) selectByPk("MemberNmCryptoDAO.selectProgress", param);
+ }
+
+ /**
+ * 검증용 집계 — 평문 보유 건수와 암호문 보유 건수, 그리고 누락 건수.
+ * 누락(평문 있는데 암호문 없음)이 0이어야 이관 완료다.
+ */
+ @SuppressWarnings("unchecked")
+ public Map selectVerifyCounts() {
+ return (Map) selectByPk("MemberNmCryptoDAO.selectVerifyCounts", null);
+ }
+
+ /** 검증용 — 암호화된 행을 커서 기준으로 청크 조회(복호화 대사에 사용). */
+ @SuppressWarnings("unchecked")
+ public List selectVerifyChunk(String lastKey, int chunkSize) {
+ Map param = new HashMap();
+ param.put("lastKey", lastKey);
+ param.put("chunkSize", Integer.valueOf(chunkSize));
+ return list("MemberNmCryptoDAO.selectVerifyChunk", param);
+ }
+}
diff --git a/src/main/java/egovframework/com/cmm/privacy/crypto/MemberNmCryptoVO.java b/src/main/java/egovframework/com/cmm/privacy/crypto/MemberNmCryptoVO.java
new file mode 100644
index 0000000..14d4b6a
--- /dev/null
+++ b/src/main/java/egovframework/com/cmm/privacy/crypto/MemberNmCryptoVO.java
@@ -0,0 +1,105 @@
+/**
+ * Class Name : MemberNmCryptoVO.java
+ * Description : 회원 이름 암호화 이관 대상 행 VO — 개인정보보호 기능개선 사업(추가제안 Action4)
+ *
+ * Modification Information
+ *
+ * 수정일 수정자 수정내용
+ * ------- -------- ---------------------------
+ * 2026.08.10 개인정보보호 최초 생성
+ *
+ * @since 2026. 08. 10
+ * @version 1.0
+ */
+package egovframework.com.cmm.privacy.crypto;
+
+import java.io.Serializable;
+
+/**
+ * 백필 배치가 읽고 쓰는 한 행. {@code COMTNGNRLMBER}의 PK와 이름 관련 컬럼만 담는다.
+ *
+ *
회원 전체 컬럼(40여 개)을 끌고 오지 않는 이유는 두 가지다. 이관 배치가 다루는 개인정보를
+ * 최소화해야 하고(필요 최소 수집), 77,053건을 청크로 훑을 때 불필요한 I/O를 줄여야 한다.
+ */
+public class MemberNmCryptoVO implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 회원ID — COMTNGNRLMBER의 PK이자 청크 커서 키.
+ *
+ *
실측 주의: 이 컬럼의 DB 타입은 {@code VARCHAR}가 아니라 {@code VARBINARY(32)}이며,
+ * 77,053건 중 39건(LVMS 연계 계정 {@code ...@lvms})에 비인쇄 바이트가 들어 있다.
+ * 다만 전건 감사 결과 UTF-8 문자열 왕복이 무손실임을 확인했으므로(왕복 실패 0건)
+ * String 매핑을 유지한다.
+ *
+ *
혹시 변환이 어긋나더라도 실패 양상은 안전하다 — UPDATE가 0건이 되어 해당 행의
+ * {@code MBER_NM_ENC}가 NULL로 남고, 검증 단계의 누락 건수에 그대로 잡힌다.
+ * 데이터가 잘못 덮어써지는 경우는 발생하지 않는다.
대상 규모(2026-08-10 실측): {@code COMTNGNRLMBER} 77,053건 / 39.6MB /
+ * PK {@code MBER_ID} 단일. 1,000건 청크 기준 78회 순회이며 실측 소요는 수십 초~수 분이다.
+ * 야간 점검창구가 필요한 규모가 아니다.
+ *
+ *
중단·재개 설계: 청크마다 {@code MIG_CRYPTO_PROGRESS}에 커서를 남긴다. 그러나
+ * 재개의 진짜 근거는 커서가 아니라 {@code MBER_NM_ENC IS NULL} 조건이다 —
+ * 진행상태 테이블이 통째로 날아가도 처음부터 다시 돌리면 이미 처리된 행은 건너뛴다.
+ * 커서는 "다시 훑는 비용"을 줄이는 최적화일 뿐 정합성의 근거가 아니다.
+ *
+ *
왜 상주 스케줄러에 넣지 않는가: 1회성 이관 작업이다. {@code BatchScheduler}에
+ * 등록하면 이관이 끝난 뒤에도 매 주기 깨어나 빈 조회를 반복하고, 실수로 재실행될 여지가 남는다.
+ * 관리자가 명시적으로 시작·중지하는 형태가 맞다.
+ *
+ *
평문을 로그에 남기지 않는다. 이 클래스의 모든 로그는 건수·회원ID까지만 남긴다.
+ * 이관 로그에 이름이 찍히면 암호화를 하고도 평문이 파일로 새어 나간다.
+ *
+ * @author 개인정보보호
+ * @since 2026-08-10
+ */
+@Component("memberNmCryptoWorker")
+public class MemberNmCryptoWorker {
+
+ private static final Logger LOG = LoggerFactory.getLogger(MemberNmCryptoWorker.class);
+
+ /** 기본 청크 크기. */
+ public static final int DEFAULT_CHUNK_SIZE = 1000;
+
+ /** 청크 간 기본 휴지(ms) — 온라인 트래픽·복제 지연 보호. */
+ public static final long DEFAULT_SLEEP_MS = 50L;
+
+ /** 진행상태 코드. */
+ private static final String STTUS_RUNNING = "R";
+ private static final String STTUS_STOP = "S";
+ private static final String STTUS_DONE = "C";
+ private static final String STTUS_ERROR = "E";
+
+ @Resource(name = "memberNmCryptoDAO")
+ private MemberNmCryptoDAO dao;
+
+ /** 암호·해시 헬퍼는 키를 들고 있으므로 생성 실패 시 기동을 막는다(폴백 없음). */
+ private MemberDataCipher cipher;
+ private MemberDataHasher hasher;
+
+ /** 실행 중 스레드. null 이면 미실행. */
+ private volatile Thread runner;
+
+ /** 중지 요청 플래그. */
+ private volatile boolean stopRequested = false;
+
+ /** 지연 초기화 — 키 미설정 서버에서 컨텍스트 전체가 죽지 않도록 실행 시점에 만든다. */
+ private synchronized void ensureCrypto() {
+ if (cipher == null) {
+ cipher = new MemberDataCipher();
+ }
+ if (hasher == null) {
+ hasher = new MemberDataHasher();
+ }
+ }
+
+ /** 현재 실행 중인가. */
+ public boolean isRunning() {
+ Thread t = runner;
+ return t != null && t.isAlive();
+ }
+
+ /** 진행상태 조회(관리자 화면용). */
+ public Map getProgress() {
+ return dao.selectProgress();
+ }
+
+ /** 중지 요청. 진행 중인 청크는 마치고 멈춘다(커서는 보존). */
+ public void requestStop() {
+ this.stopRequested = true;
+ LOG.info("[개인정보/암호화] 이름 백필 중지 요청 접수");
+ }
+
+ /**
+ * 백필을 백그라운드로 시작한다.
+ *
+ * @param chunkSize 청크 크기(1 미만이면 기본값)
+ * @param sleepMs 청크 간 휴지(음수면 기본값)
+ * @return 시작했으면 true, 이미 실행 중이면 false
+ */
+ public synchronized boolean start(int chunkSize, long sleepMs) {
+ if (isRunning()) {
+ LOG.warn("[개인정보/암호화] 이름 백필이 이미 실행 중입니다 — 중복 시작 무시");
+ return false;
+ }
+ final int chunk = (chunkSize < 1) ? DEFAULT_CHUNK_SIZE : chunkSize;
+ final long sleep = (sleepMs < 0) ? DEFAULT_SLEEP_MS : sleepMs;
+
+ this.stopRequested = false;
+ Thread t = new Thread(new Runnable() {
+ public void run() {
+ runBackfill(chunk, sleep);
+ }
+ }, "mber-nm-crypto-backfill");
+ t.setDaemon(true);
+ this.runner = t;
+ t.start();
+ return true;
+ }
+
+ /**
+ * 백필 본체. 커서 페이징으로 미암호화 행을 청크 단위로 암호화한다.
+ *
+ *
한 건이 실패해도 청크 전체를 버리지 않는다 — 실패 행만 건너뛰고 계속한다.
+ * 실패 건은 {@code MBER_NM_ENC}가 NULL로 남으므로 다음 실행에서 자연히 재시도된다.
77,053건 규모에서 샘플링할 이유가 없다. 전건을 복호화해 평문과 비교하고, 해시를
+ * 재계산해 저장값과 대조한다. 하나라도 어긋나면 읽기 전환을 해서는 안 된다.
+ *
+ *
제안서가 요구하는 "이관 전후 건수·값 검증"의 값 검증 부분이며,
+ * 반환 문자열을 그대로 조치결과보고서 첨부에 쓸 수 있게 구성했다.
+ *
+ * @param chunkSize 청크 크기
+ * @return 검증 결과 요약
+ */
+ public String verify(int chunkSize) {
+ int chunk = (chunkSize < 1) ? DEFAULT_CHUNK_SIZE : chunkSize;
+ ensureCrypto();
+
+ Map counts = dao.selectVerifyCounts();
+ long checked = 0;
+ long decMismatch = 0;
+ long hashMismatch = 0;
+ long decError = 0;
+
+ String lastKey = null;
+ while (true) {
+ List rows = dao.selectVerifyChunk(lastKey, chunk);
+ if (rows == null || rows.isEmpty()) {
+ break;
+ }
+ for (int i = 0; i < rows.size(); i++) {
+ MemberNmCryptoVO row = rows.get(i);
+ lastKey = row.getMberId();
+ checked++;
+ try {
+ String decrypted = cipher.decrypt(row.getMberNmEnc());
+ if (!equalsNullable(decrypted, row.getMberNm())) {
+ decMismatch++;
+ LOG.error("[개인정보/암호화] 복호화 불일치 mberId={}", row.getMberId());
+ }
+ String expected = hasher.hashName(row.getMberNm());
+ if (!equalsNullable(expected, row.getMberNmHash())) {
+ hashMismatch++;
+ LOG.error("[개인정보/암호화] 해시 불일치 mberId={}", row.getMberId());
+ }
+ } catch (Exception e) {
+ decError++;
+ LOG.error("[개인정보/암호화] 복호화 오류 mberId=" + row.getMberId(), e);
+ }
+ }
+ }
+
+ StringBuilder sb = new StringBuilder(400);
+ sb.append("[회원 이름 암호화 이관 검증]\n");
+ if (counts != null) {
+ sb.append(" 총 행수 : ").append(counts.get("TOTAL_CNT")).append('\n');
+ sb.append(" 평문 보유 : ").append(counts.get("PLAIN_CNT")).append('\n');
+ sb.append(" 암호문 보유 : ").append(counts.get("ENC_CNT")).append('\n');
+ sb.append(" 해시 보유 : ").append(counts.get("HASH_CNT")).append('\n');
+ sb.append(" 누락(미암호) : ").append(counts.get("MISSING_CNT")).append(" <- 0 이어야 함\n");
+ }
+ sb.append(" 대사 건수 : ").append(checked).append('\n');
+ sb.append(" 복호화 불일치: ").append(decMismatch).append(" <- 0 이어야 함\n");
+ sb.append(" 해시 불일치 : ").append(hashMismatch).append(" <- 0 이어야 함\n");
+ sb.append(" 복호화 오류 : ").append(decError).append(" <- 0 이어야 함\n");
+ boolean ok = decMismatch == 0 && hashMismatch == 0 && decError == 0
+ && counts != null && isZero(counts.get("MISSING_CNT"));
+ sb.append(" 판정 : ").append(ok ? "합격 — 읽기 전환 가능" : "불합격 — 읽기 전환 금지");
+
+ String report = sb.toString();
+ LOG.info("[개인정보/암호화] 검증 결과\n{}", report);
+ return report;
+ }
+
+ private static boolean equalsNullable(String a, String b) {
+ return (a == null) ? (b == null) : a.equals(b);
+ }
+
+ private static boolean isZero(Object v) {
+ if (v == null) {
+ return true;
+ }
+ if (v instanceof Number) {
+ return ((Number) v).longValue() == 0L;
+ }
+ return "0".equals(String.valueOf(v));
+ }
+}
diff --git a/src/main/java/egovframework/com/cmm/privacy/interceptor/MenuAccessLogInterceptor.java b/src/main/java/egovframework/com/cmm/privacy/interceptor/MenuAccessLogInterceptor.java
index a796ed1..3a0154b 100644
--- a/src/main/java/egovframework/com/cmm/privacy/interceptor/MenuAccessLogInterceptor.java
+++ b/src/main/java/egovframework/com/cmm/privacy/interceptor/MenuAccessLogInterceptor.java
@@ -3,6 +3,7 @@ package egovframework.com.cmm.privacy.interceptor;
import java.lang.reflect.Method;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
+import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
@@ -15,8 +16,11 @@ import javax.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
+import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo;
+
import egovframework.com.cmm.LoginVO;
import egovframework.com.cmm.privacy.auditlog.AccessLogQueue;
import egovframework.com.cmm.privacy.auditlog.ClientIpResolver;
@@ -114,14 +118,40 @@ public class MenuAccessLogInterceptor extends HandlerInterceptorAdapter {
private static final int TRGET_ID_MAX = 50;
/**
- * 처리대상 식별자 파라미터 우선순위. 식별자로 확정 가능한 이름만 둔다
- * (searchKeyword 등 검색어는 개인정보 본문일 수 있어 절대 넣지 않는다).
+ * 처리대상 식별자 파라미터 우선순위 — 화이트리스트 대상 컨트롤러의 {@code @RequestParam}
+ * 실측치다(추정 아님). 개인 식별자를 앞에, 단체·일련번호를 뒤에 둬서 한 요청에 여러 개가
+ * 실려도 "누구의 개인정보인가"가 먼저 잡히게 한다.
+ *
+ *
넣지 않는 것: {@code tmpNm}(이름)·{@code searchKeyword}(검색어)처럼 값 자체가
+ * 개인정보 본문이거나 본문일 수 있는 파라미터. 접속기록에 본문을 담지 않는다는 원칙 때문이며,
+ * 식별자만으로 정보주체를 특정할 수 있으므로 추적에 부족하지 않다.
+ *
+ *
{@code tmpId}(해당 회원ID)·{@code tmpGId}(그룹ID)·{@code tmpAppId}(신청ID)·
+ * {@code checkedIdForUp}(일괄 수정 대상)은 arko 관용 파라미터명이라 표준 이름만으로는 못 잡는다.
+ * 이들이 빠지면 신청·활동확인·일괄승인 화면의 처리대상이 통째로 비어버린다.
*/
private static final String[] TRGET_ID_PARAMS = {
- "esntlId", "uniqId", "selectedId", "mberId", "emplyrId", "entrprsId",
- "groupId", "userId", "checkedIdForDel", "checkedIdForUpdt", "sn"
+ // 개인 식별자
+ "esntlId", "uniqId", "selectedId", "mberId", "tmpId", "user_id", "userId",
+ "emplyrId", "entrprsId", "toId",
+ // 다건 선택(체크박스)
+ "checkedIdForDel", "checkedIdForUp",
+ // 단체·신청·일련 식별자
+ "groupId", "tmpGId", "tmpAppId", "tmpSeqId", "sn"
};
+ /**
+ * 처리 건수 취득에 쓸 모델 속성 후보(arko 목록·엑셀 화면 관용명).
+ * 코드목록 같은 부수 리스트를 세지 않도록 명시 후보만 본다.
+ */
+ private static final String[] RESULT_LIST_ATTRS = { "resultList", "excel_data", "mberList" };
+
+ /** postHandle → afterCompletion 사이에 처리 건수를 넘기는 request attribute 키. */
+ private static final String ATTR_TRGET_CN = MenuAccessLogInterceptor.class.getName() + ".TRGET_CN";
+
+ /** TRGET_CN 컬럼 길이(VARCHAR(200)) 보호. */
+ private static final int TRGET_CN_MAX = 200;
+
// ===================== 설정(주입) =====================
/**
@@ -163,6 +193,30 @@ public class MenuAccessLogInterceptor extends HandlerInterceptorAdapter {
public void setMenuAuthDAO(PrivacyMenuAuthDAO menuAuthDAO) { this.menuAuthDAO = menuAuthDAO; }
public void setAccessLogQueue(AccessLogQueue accessLogQueue) { this.accessLogQueue = accessLogQueue; }
+ /**
+ * 처리 건수를 확보한다 — "몇 건의 개인정보를 열람·반출했는가".
+ *
+ *
서비스 AOP 시절에는 반환값이 {@code Collection}이면 크기를 셌다. 요청 인터셉터는 서비스
+ * 반환값을 볼 수 없으므로, 컨트롤러가 모델에 담은 목록에서 대신 얻는다. 이 값이 없으면
+ * 명단 조회·엑셀 다운로드가 "처리대상 미상"으로 남아 무엇을 반출했는지 확인할 수 없다.
+ *
+ *
{@code postHandle}은 앞선 인터셉터가 요청을 차단하면 호출되지 않는다. 그때는 건수가
+ * 없는 채로 {@code afterCompletion}이 실패(F) 기록을 남긴다 — 차단된 요청에 건수가 없는 것이 옳다.
+ */
+ @Override
+ public void postHandle(HttpServletRequest request, HttpServletResponse response,
+ Object handler, ModelAndView modelAndView) {
+ try {
+ String cn = resolveResultCount(modelAndView);
+ if (cn != null) {
+ request.setAttribute(ATTR_TRGET_CN, cn);
+ }
+ } catch (Throwable t) {
+ // 건수는 부가정보다 — 취득 실패가 접속기록 자체를 막지 않는다.
+ LOG.error("[개인정보/SFR-004] 처리 건수 취득 중 예외(무시)", t);
+ }
+ }
+
/**
* 요청 종료 시점에 접속기록 1건을 남긴다.
* 처리결과(성공/실패)를 판정해야 하므로 preHandle 이 아닌 afterCompletion 이다.
@@ -558,8 +612,30 @@ public class MenuAccessLogInterceptor extends HandlerInterceptorAdapter {
return null;
}
- /** 다건 선택(체크박스 등)일 때만 "N건"을 남긴다. 단건은 TRGET_ID 로 충분하다. */
+ /**
+ * 처리대상 범위(TRGET_CN)를 만든다 — "몇 건을 다뤘는가".
+ *
+ *
두 출처를 합친다.
+ * ① 다건 선택(체크박스 파라미터가 여러 값) → "선택 N건"
+ * ② 모델 목록 건수({@link #postHandle}이 담아둔 값) → "조회 N건" / "조회 N건(전체 M건)"
+ * 둘 다 있으면 함께 남긴다. 단건 상세는 TRGET_ID 로 특정되므로 비어도 무방하다.
+ */
private String extractTrgetCn(HttpServletRequest request) {
+ String selected = selectedCount(request);
+ Object fromModel = request.getAttribute(ATTR_TRGET_CN);
+ String listCn = (fromModel instanceof String) ? (String) fromModel : null;
+
+ if (selected != null && listCn != null) {
+ return cut(selected + ", " + listCn, TRGET_CN_MAX);
+ }
+ if (selected != null) {
+ return cut(selected, TRGET_CN_MAX);
+ }
+ return (listCn == null) ? null : cut(listCn, TRGET_CN_MAX);
+ }
+
+ /** 체크박스 등으로 2건 이상을 한 번에 지정했을 때의 건수. 단건이면 null(TRGET_ID 로 충분). */
+ private String selectedCount(HttpServletRequest request) {
for (int i = 0; i < TRGET_ID_PARAMS.length; i++) {
String[] values = request.getParameterValues(TRGET_ID_PARAMS[i]);
if (values == null) {
@@ -572,15 +648,77 @@ public class MenuAccessLogInterceptor extends HandlerInterceptorAdapter {
}
}
if (count > 1) {
- return count + "건";
+ return "선택 " + count + "건";
}
if (count == 1) {
+ // 단일 값이지만 콤마로 여러 ID를 이어 보내는 화면이 있다(checkedIdForDel 관용).
+ int commas = countChar(values[0], ',');
+ if (commas > 0) {
+ return "선택 " + (commas + 1) + "건";
+ }
return null;
}
}
return null;
}
+ private static int countChar(String s, char c) {
+ if (s == null) {
+ return 0;
+ }
+ int n = 0;
+ for (int i = 0; i < s.length(); i++) {
+ if (s.charAt(i) == c) {
+ n++;
+ }
+ }
+ return n;
+ }
+
+ /**
+ * 모델에서 처리 건수를 뽑는다.
+ * 목록 크기(실제 열람·반출 건수)와 전체 검색 건수가 다르면 둘 다 남긴다 —
+ * "이 관리자가 10건을 봤고 검색 범위는 3,412건이었다"가 감사에서 의미가 다르기 때문이다.
+ */
+ private String resolveResultCount(ModelAndView modelAndView) {
+ if (modelAndView == null) {
+ return null;
+ }
+ Map model = modelAndView.getModel();
+ if (model == null || model.isEmpty()) {
+ return null;
+ }
+
+ Integer listSize = null;
+ for (int i = 0; i < RESULT_LIST_ATTRS.length; i++) {
+ Object v = model.get(RESULT_LIST_ATTRS[i]);
+ if (v instanceof Collection) {
+ listSize = Integer.valueOf(((Collection>) v).size());
+ break;
+ }
+ }
+
+ Integer total = null;
+ Object pi = model.get("paginationInfo");
+ if (pi instanceof PaginationInfo) {
+ int t = ((PaginationInfo) pi).getTotalRecordCount();
+ if (t > 0) {
+ total = Integer.valueOf(t);
+ }
+ }
+
+ if (listSize == null && total == null) {
+ return null;
+ }
+ if (listSize == null) {
+ return "전체 " + total + "건";
+ }
+ if (total == null || total.intValue() == listSize.intValue()) {
+ return "조회 " + listSize + "건";
+ }
+ return "조회 " + listSize + "건(전체 " + total + "건)";
+ }
+
// ===================== 유틸 =====================
private static boolean contains(String s, String token) {
diff --git a/src/main/java/egovframework/com/cmm/privacy/interceptor/SessionPolicyInterceptor.java b/src/main/java/egovframework/com/cmm/privacy/interceptor/SessionPolicyInterceptor.java
new file mode 100644
index 0000000..5923a1e
--- /dev/null
+++ b/src/main/java/egovframework/com/cmm/privacy/interceptor/SessionPolicyInterceptor.java
@@ -0,0 +1,164 @@
+package egovframework.com.cmm.privacy.interceptor;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpSession;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
+
+import egovframework.com.cmm.privacy.policy.AccessControlPolicyService;
+import egovframework.com.cmm.privacy.policy.AccessControlPolicyVO;
+
+/**
+ * 접근통제 세션 정책 인터셉터 — 개인정보보호 기능개선 사업(SER-001 요건 5, 자동 접속차단).
+ *
+ *
「개인정보보호강화 파일럿 개발항목」 접근통제 정책 적용(Action2) 시안의
+ * '미사용 자동 로그아웃'을 web.xml 수정·재배포 없이 관리자 설정값으로 즉시 반영하는 지점이다.
+ * 2025.9 자료요청 3번 "시간 설정 기능 없음" 지적에 대응한다.
+ *
+ *
하는 일 두 가지
+ *
+ *
관리자 화면({@code /mnt/}) 요청마다 로그인 세션의
+ * {@code maxInactiveInterval} 을 정책값(ACCESS_CONTROL_POLICY.AUTO_LOGOUT_MIN)으로 맞춘다.
+ * web.xml 의 {@code } 은 세션 생성 시 기본값일 뿐이며, 여기서 덮어쓴 값이
+ * 해당 세션의 실제 타임아웃이 된다.
+ *
로그인 시각({@value #ATTR_LOGIN_TIME})을 세션에 도장 찍는다. 관리자 상단(top.jsp)의
+ * "로그인 후 경과시간" 표시가 이 값을 쓴다.
+ *
로그인 경로가 여럿(actionLogin / EgovSpringSecurityLoginFilter / SSO 필터)이라
+ * 각 경로를 고쳐 도장을 찍는 대신, 세션에 loginVO 가 생긴 뒤 첫 관리자 요청에서
+ * 한 번만 찍는다. 로그인 직후 곧바로 관리자 화면으로 이동하므로 실제 오차는 1초 미만이고,
+ * 인증 경로를 건드리지 않아 회귀 위험이 없다.
+ *
+ *
+ *
적용 범위 — {@code applyPathPrefix}(기본 {@code /mnt/}) 이면서 세션에 loginVO 가 있을 때만
+ * 동작한다. 무인증 프론트(frt) 세션은 건드리지 않아 일반회원 이용에 영향이 없다.
+ *
+ *
fail-safe — 정책 조회 실패는 화면 접근을 막지 않는다. 서비스가 기본값(30분)을 돌려주며,
+ * 서비스 미주입 시에는 아무 것도 하지 않고 통과한다(web.xml 기본값이 그대로 유효).
+ *
+ *
+ * << 개정이력(Modification Information) >>
+ * 수정일 수정자 수정내용
+ * ---------- ------ ---------------------------
+ * 2026.08.10 URP 최초 생성 (접근통제 정책 적용 — 자동 로그아웃 정책값화)
+ *
+ */
+public class SessionPolicyInterceptor extends HandlerInterceptorAdapter {
+
+ private static final Logger LOG = LoggerFactory.getLogger(SessionPolicyInterceptor.class);
+
+ /** 로그인 시각(ms) 세션 키. top.jsp 경과시간 표시가 참조한다. */
+ public static final String ATTR_LOGIN_TIME = "PRIVACY_LOGIN_TIME";
+
+ /** 적용 중인 자동 로그아웃 시간(초) 세션 키. top.jsp 카운트다운이 참조한다. */
+ public static final String ATTR_AUTO_LOGOUT_SEC = "PRIVACY_AUTO_LOGOUT_SEC";
+
+ /** 만료 임박 안내 시점(초 전) 세션 키. 0이면 안내 미사용. */
+ public static final String ATTR_EXPIRE_NOTICE_SEC = "PRIVACY_EXPIRE_NOTICE_SEC";
+
+ /** 로그인 정보 세션 키. */
+ private static final String ATTR_LOGIN_VO = "loginVO";
+
+ /** 적용 경로 접두사. 기본 관리자 영역. */
+ private String applyPathPrefix = "/mnt/";
+
+ private AccessControlPolicyService accessControlPolicyService;
+
+ public void setApplyPathPrefix(String applyPathPrefix) {
+ this.applyPathPrefix = applyPathPrefix;
+ }
+
+ public void setAccessControlPolicyService(AccessControlPolicyService accessControlPolicyService) {
+ this.accessControlPolicyService = accessControlPolicyService;
+ }
+
+ @Override
+ public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
+ try {
+ String uri = request.getRequestURI();
+ String ctx = request.getContextPath();
+ if (ctx != null && ctx.length() > 0 && uri.startsWith(ctx)) {
+ uri = uri.substring(ctx.length());
+ }
+ if (applyPathPrefix != null && !uri.startsWith(applyPathPrefix)) {
+ return true;
+ }
+
+ // 세션을 새로 만들지 않는다 — 무인증 요청까지 세션을 생성하면 불필요한 부하가 된다.
+ HttpSession session = request.getSession(false);
+ if (session == null || session.getAttribute(ATTR_LOGIN_VO) == null) {
+ return true;
+ }
+
+ // 로그인 시각 도장 — 로그인 후 최초 1회만.
+ if (session.getAttribute(ATTR_LOGIN_TIME) == null) {
+ session.setAttribute(ATTR_LOGIN_TIME, Long.valueOf(System.currentTimeMillis()));
+ }
+
+ // 자동 로그아웃 시간 적용(정책값). 미주입 시 web.xml 기본값 유지.
+ if (accessControlPolicyService != null) {
+ AccessControlPolicyVO policy = accessControlPolicyService.getCachedPolicy();
+ int timeoutSec = policy.getAutoLogoutSec();
+ if (timeoutSec > 0 && session.getMaxInactiveInterval() != timeoutSec) {
+ session.setMaxInactiveInterval(timeoutSec);
+ }
+ // 화면(top.jsp)이 스크립틀릿·별도 조회 없이 JSTL 로 읽어 쓰도록 세션에 실어둔다.
+ session.setAttribute(ATTR_AUTO_LOGOUT_SEC, Integer.valueOf(session.getMaxInactiveInterval()));
+ session.setAttribute(ATTR_EXPIRE_NOTICE_SEC,
+ Integer.valueOf(policy.isExpireNoticeUse() ? policy.getExpireNoticeMin() * 60 : 0));
+ } else {
+ session.setAttribute(ATTR_AUTO_LOGOUT_SEC, Integer.valueOf(session.getMaxInactiveInterval()));
+ session.setAttribute(ATTR_EXPIRE_NOTICE_SEC, Integer.valueOf(0));
+ }
+ } catch (Exception e) {
+ // 세션 정책 적용 실패가 화면 진입을 막아서는 안 된다.
+ LOG.error("[개인정보/접근통제] 세션 정책 적용 오류 - 요청은 계속 진행: {}", e.toString());
+ }
+ return true;
+ }
+
+ /**
+ * 로그인 후 경과 시간(초). 도장이 없으면 세션 생성시각을 대신 쓴다.
+ *
+ * @param session 현재 세션(널이면 0)
+ * @return 경과 초(음수가 나오지 않도록 0 하한)
+ */
+ public static long elapsedLoginSec(HttpSession session) {
+ if (session == null) {
+ return 0L;
+ }
+ long base;
+ Object stamped = session.getAttribute(ATTR_LOGIN_TIME);
+ if (stamped instanceof Long) {
+ base = ((Long) stamped).longValue();
+ } else {
+ base = session.getCreationTime();
+ }
+ long elapsed = (System.currentTimeMillis() - base) / 1000L;
+ return elapsed < 0L ? 0L : elapsed;
+ }
+
+ /**
+ * 자동 로그아웃까지 남은 시간(초).
+ *
+ *
세션 타임아웃은 마지막 접근시각부터 흐른다. 이 메서드는 화면 렌더 시점 기준의
+ * 잔여시간을 돌려주며, 화면 타이머는 이 값에서 카운트다운한다.
{@code /mnt/session/status.do} — 로그인 경과/잔여시간 조회(상단 표시 갱신용)
+ *
+ *
+ *
경로 선정 근거 — 세션 연장·상태 조회는 전체관리자 전용이 아니라
+ * 로그인한 모든 취급자가 써야 한다. {@code /mnt/privacy/*}·{@code /mnt/sec/*} 는
+ * AuthorityInterceptor 에서 ROLE_ADMIN 전용으로 매핑돼 있어 시도·시군구 관리자가 403 을
+ * 맞으므로, 별도 대역 {@code /mnt/session/} 을 쓰고 두 판정 인터셉터의 예외에 등록한다.
+ *
+ *
+ * 수정일 수정자 수정내용
+ * ---------- ------ ---------------------------
+ * 2026.08.10 URP 최초 생성 (접근통제 정책 적용 화면)
+ *
+ */
+@Controller
+public class AccessControlPolicyController {
+
+ private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do";
+
+ @Resource(name = "accessControlPolicyService")
+ private AccessControlPolicyService accessControlPolicyService;
+
+ // ────────────────────────────────────────────────────────── 정책 화면
+
+ /**
+ * 접근통제 정책 설정 화면.
+ * 뷰: /WEB-INF/jsp/site/mnt/sec/accessPolicy/policy.jsp
+ */
+ @RequestMapping(value = "/mnt/sec/accessPolicy/policyView.do")
+ public String accessPolicyView(ModelMap model) throws Exception {
+ if (handlerUser() == null) {
+ return LOGIN_REDIRECT;
+ }
+ model.addAttribute("policy", accessControlPolicyService.selectPolicy());
+ return "site/mnt/sec/accessPolicy/policy";
+ }
+
+ /** 접근통제 정책 저장(AJAX). */
+ @RequestMapping(value = "/mnt/privacy/accessPolicyUpdate.do")
+ public String updateAccessPolicy(@ModelAttribute("policy") AccessControlPolicyVO policy,
+ HttpServletRequest request, ModelMap model) throws Exception {
+ LoginVO loginVO = handlerUser();
+ if (loginVO == null) {
+ return denyJson(model);
+ }
+ if (!isSameOrigin(request)) {
+ return failJson(model, "잘못된 요청 경로입니다.");
+ }
+
+ String invalid = validate(policy);
+ if (invalid != null) {
+ return failJson(model, invalid);
+ }
+
+ policy.setUpdId(loginVO.getId());
+ accessControlPolicyService.updatePolicy(policy);
+
+ // 저장한 관리자 본인의 현재 세션에도 즉시 반영(다음 요청을 기다리지 않는다).
+ HttpSession session = request.getSession(false);
+ if (session != null) {
+ session.setMaxInactiveInterval(policy.getAutoLogoutSec());
+ }
+
+ model.addAttribute("result", "success");
+ return "jsonView";
+ }
+
+ /**
+ * 화면 우회 입력(직접 POST)에 대비한 서버측 범위 검증. 위반 시 사용자 메시지 반환.
+ *
+ *
상한을 두는 이유: 자동 로그아웃을 사실상 무한대로 늘리거나 잠금 임계값을 크게 잡아
+ * 안전성 확보조치 기준 제6조를 우회하는 설정을 막는다.
+ */
+ private String validate(AccessControlPolicyVO p) {
+ if (p.getLockThreshold() < 1 || p.getLockThreshold() > 10) {
+ return "인증 실패 계정 잠금 횟수는 1~10회 범위로 입력하세요. (기준 권고 5회)";
+ }
+ if (!AccessControlPolicyVO.UNLOCK_MODE_HIST.equals(p.getUnlockMode())
+ && !AccessControlPolicyVO.UNLOCK_MODE_HIST_RESN.equals(p.getUnlockMode())) {
+ return "잠금 해제 방식 값이 올바르지 않습니다.";
+ }
+ if (p.getAutoLogoutMin() < 5 || p.getAutoLogoutMin() > 120) {
+ return "미사용 자동 로그아웃 시간은 5~120분 범위로 입력하세요. (기준 권고 30분)";
+ }
+ // 체크박스 미전송 시 null → 'N' 보정
+ p.setExpireNoticeYn(normalizeYn(p.getExpireNoticeYn()));
+ p.setSessionRegenYn(normalizeYn(p.getSessionRegenYn()));
+ if (p.getExpireNoticeYn() == null) {
+ return "만료 임박 안내 사용여부 값이 올바르지 않습니다.";
+ }
+ if (p.getSessionRegenYn() == null) {
+ return "세션 재발급 사용여부 값이 올바르지 않습니다.";
+ }
+ // 안내가 꺼져 있으면 시점값을 검증하지 않는다(화면에서도 비활성 입력이라 과거값이 남아 있을 수 있다).
+ if ("Y".equals(p.getExpireNoticeYn())
+ && (p.getExpireNoticeMin() < 1 || p.getExpireNoticeMin() >= p.getAutoLogoutMin())) {
+ return "만료 임박 안내 시점은 1분 이상이며 자동 로그아웃 시간보다 작아야 합니다.";
+ }
+ return null;
+ }
+
+ /** 'Y'/'N' 이외(널·공백 포함)는 'N'. 값이 있으나 Y/N 이 아니면 null(=오류). */
+ private String normalizeYn(String value) {
+ if (value == null || value.trim().length() == 0) {
+ return "N";
+ }
+ String v = value.trim();
+ if ("Y".equals(v) || "N".equals(v)) {
+ return v;
+ }
+ return null;
+ }
+
+ // ────────────────────────────────────────────────────────── 세션 상태·연장
+
+ /**
+ * 세션 상태 조회(JSON). 관리자 상단의 로그인 경과시간·자동 로그아웃 잔여시간 표시가
+ * 페이지 이동 없이 갱신할 때 쓴다.
+ *
+ *
주의 — 이 요청 자체가 세션을 갱신(lastAccessedTime 초기화)하므로,
+ * 화면 타이머는 이 엔드포인트를 주기 폴링하지 않는다. 그렇게 하면 "미사용 자동
+ * 로그아웃"이 영원히 오지 않는다. 페이지 진입 시 1회, 그리고 사용자가 연장을 선택했을 때만
+ * 호출한다.
캐시 — {@code SessionPolicyInterceptor} 가 관리자 화면 요청마다 정책을 읽으므로
+ * 매 요청 DB 조회를 피하기 위해 짧은 TTL(기본 60초) 캐시를 둔다. 저장 시에는 즉시 무효화하여
+ * "설정 저장 → 즉시 적용"을 보장한다(시안의 '설정 저장·즉시 적용').
+ *
+ *
fail-safe — 정책 조회 실패(테이블 미생성·DB 오류)는 관리자 화면 접근 자체를
+ * 막으면 안 된다. {@link #getCachedPolicy()} 는 예외를 삼키고 기본값 VO(잠금 5회·30분)를
+ * 반환한다. 기본값은 강화된 정책값이라 fail-open 이 아니다.
+ *
+ *
+ * 수정일 수정자 수정내용
+ * ---------- ------ ---------------------------
+ * 2026.08.10 URP 최초 생성 (접근통제 정책 적용 화면)
+ *
+ */
+@Service("accessControlPolicyService")
+public class AccessControlPolicyServiceImpl implements AccessControlPolicyService {
+
+ private static final Log LOG = LogFactory.getLog(AccessControlPolicyServiceImpl.class);
+
+ /** 캐시 수명(ms). 저장 시 즉시 무효화되므로 관리자 체감 지연은 없다. */
+ private static final long CACHE_TTL_MS = 60 * 1000L;
+
+ @Resource(name = "accessControlPolicyDAO")
+ private AccessControlPolicyDAO accessControlPolicyDAO;
+
+ /** 캐시된 정책. volatile 참조 교체만 하므로 락 없이 안전(읽기 다수·쓰기 소수). */
+ private volatile AccessControlPolicyVO cached;
+ private volatile long cachedAt;
+
+ @Override
+ public AccessControlPolicyVO selectPolicy() throws Exception {
+ AccessControlPolicyVO vo = accessControlPolicyDAO.selectPolicy();
+ // 정책 미설정(시드 미적용) 시 기본값 — 화면이 빈 값으로 뜨지 않게 한다.
+ return vo != null ? vo : new AccessControlPolicyVO();
+ }
+
+ @Override
+ public AccessControlPolicyVO getCachedPolicy() {
+ AccessControlPolicyVO snapshot = cached;
+ if (snapshot != null && (System.currentTimeMillis() - cachedAt) < CACHE_TTL_MS) {
+ return snapshot;
+ }
+ try {
+ AccessControlPolicyVO vo = accessControlPolicyDAO.selectPolicy();
+ if (vo == null) {
+ vo = new AccessControlPolicyVO();
+ }
+ cached = vo;
+ cachedAt = System.currentTimeMillis();
+ return vo;
+ } catch (Exception e) {
+ // 조회 실패 — 관리자 화면을 막지 않는다. 기본값(잠금 5회·30분)으로 계속 진행.
+ LOG.error("[개인정보/접근통제] 정책 조회 실패 — 기본값 적용: " + e.getMessage());
+ AccessControlPolicyVO fallback = snapshot != null ? snapshot : new AccessControlPolicyVO();
+ cached = fallback;
+ cachedAt = System.currentTimeMillis();
+ return fallback;
+ }
+ }
+
+ @Override
+ public void updatePolicy(AccessControlPolicyVO vo) throws Exception {
+ accessControlPolicyDAO.updatePolicy(vo);
+
+ // 잠금 임계값은 인증 경로가 LOGIN_LOCK_COUNT 를 읽는다 — 같은 트랜잭션에서 동기화해야
+ // '저장 즉시 활성 인증 경로에 적용'이 성립한다. 행이 없으면 생성한다.
+ if (accessControlPolicyDAO.updateLoginLockCount(vo) == 0) {
+ accessControlPolicyDAO.insertLoginLockCount(vo);
+ }
+
+ // 캐시 무효화 — 다음 요청부터 새 정책(자동 로그아웃 시간 등)이 적용된다.
+ cached = null;
+ cachedAt = 0L;
+ }
+}
diff --git a/src/main/java/egovframework/com/cmm/privacy/policy/AccessControlPolicyVO.java b/src/main/java/egovframework/com/cmm/privacy/policy/AccessControlPolicyVO.java
new file mode 100644
index 0000000..48a8dda
--- /dev/null
+++ b/src/main/java/egovframework/com/cmm/privacy/policy/AccessControlPolicyVO.java
@@ -0,0 +1,92 @@
+package egovframework.com.cmm.privacy.policy;
+
+import java.io.Serializable;
+
+/**
+ * 접근통제 정책 VO — ACCESS_CONTROL_POLICY(단일행) 매핑.
+ *
+ *
문화품앗이 개인정보보호 기능개선 사업 — 접근통제 정책 적용(SER-001 요건 4·5).
+ * 「개인정보보호강화 파일럿 개발항목」 접근통제 정책 적용(Action2) 시안 반영.
+ *
+ *
계정 잠금 임계값·자동 로그아웃 시간·세션 재발급 여부를 소스/web.xml 하드코딩이 아니라
+ * 관리자 설정값으로 운영하기 위한 정책 값 묶음이다. 필드 기본값은 정책 미설정(테이블 empty)
+ * 상황에서도 화면·인터셉터가 안전하게 동작하도록 시안 기준값과 동일하게 둔다.
+ *
+ *
+ * << 개정이력(Modification Information) >>
+ * 수정일 수정자 수정내용
+ * ---------- ------ ---------------------------
+ * 2026.08.10 URP 최초 생성 (접근통제 정책 적용 화면)
+ *
+ */
+public class AccessControlPolicyVO implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ /** 잠금 해제 방식 — 관리자 화면 해제 + 해제 이력 적재. */
+ public static final String UNLOCK_MODE_HIST = "ADMIN_HIST";
+ /** 잠금 해제 방식 — 위 + 해제 사유 필수 입력. */
+ public static final String UNLOCK_MODE_HIST_RESN = "ADMIN_HIST_RESN";
+
+ private int policyId = 1;
+
+ /** 인증 실패 계정 잠금 임계값(회). 현행 10 → 5. */
+ private int lockThreshold = 5;
+
+ /** 잠금 해제 방식(ADMIN_HIST / ADMIN_HIST_RESN). */
+ private String unlockMode = UNLOCK_MODE_HIST;
+
+ /** 미사용 자동 로그아웃(분). 현행 web.xml 60 → 30. */
+ private int autoLogoutMin = 30;
+
+ /** 세션 만료 임박 안내 사용여부(Y/N). */
+ private String expireNoticeYn = "Y";
+
+ /** 만료 임박 안내 시점(만료 N분 전). */
+ private int expireNoticeMin = 5;
+
+ /** 2차 인증 성공 시 세션 재발급(세션고정 차단) 사용여부(Y/N). */
+ private String sessionRegenYn = "Y";
+
+ private String updId;
+ private String updDt;
+
+ /** 자동 로그아웃 시간(초). 인터셉터가 setMaxInactiveInterval 에 그대로 사용한다. */
+ public int getAutoLogoutSec() {
+ return autoLogoutMin * 60;
+ }
+
+ /** 만료 임박 안내 사용 여부. */
+ public boolean isExpireNoticeUse() {
+ return "Y".equals(expireNoticeYn);
+ }
+
+ /** 세션 재발급 사용 여부. */
+ public boolean isSessionRegenUse() {
+ return "Y".equals(sessionRegenYn);
+ }
+
+ /** 잠금 해제 시 사유 필수 여부. */
+ public boolean isUnlockResnRequired() {
+ return UNLOCK_MODE_HIST_RESN.equals(unlockMode);
+ }
+
+ public int getPolicyId() { return policyId; }
+ public void setPolicyId(int policyId) { this.policyId = policyId; }
+ public int getLockThreshold() { return lockThreshold; }
+ public void setLockThreshold(int lockThreshold) { this.lockThreshold = lockThreshold; }
+ public String getUnlockMode() { return unlockMode; }
+ public void setUnlockMode(String unlockMode) { this.unlockMode = unlockMode; }
+ public int getAutoLogoutMin() { return autoLogoutMin; }
+ public void setAutoLogoutMin(int autoLogoutMin) { this.autoLogoutMin = autoLogoutMin; }
+ public String getExpireNoticeYn() { return expireNoticeYn; }
+ public void setExpireNoticeYn(String expireNoticeYn) { this.expireNoticeYn = expireNoticeYn; }
+ public int getExpireNoticeMin() { return expireNoticeMin; }
+ public void setExpireNoticeMin(int expireNoticeMin) { this.expireNoticeMin = expireNoticeMin; }
+ public String getSessionRegenYn() { return sessionRegenYn; }
+ public void setSessionRegenYn(String sessionRegenYn) { this.sessionRegenYn = sessionRegenYn; }
+ public String getUpdId() { return updId; }
+ public void setUpdId(String updId) { this.updId = updId; }
+ public String getUpdDt() { return updDt; }
+ public void setUpdDt(String updDt) { this.updDt = updDt; }
+}
diff --git a/src/main/java/egovframework/com/cmm/privacy/security/SessionRegenerator.java b/src/main/java/egovframework/com/cmm/privacy/security/SessionRegenerator.java
index eff6746..9de7b58 100644
--- a/src/main/java/egovframework/com/cmm/privacy/security/SessionRegenerator.java
+++ b/src/main/java/egovframework/com/cmm/privacy/security/SessionRegenerator.java
@@ -90,4 +90,23 @@ public final class SessionRegenerator {
}
return newSession;
}
+
+ /**
+ * 세션 재발급 없이 2차 인증 완료 표식만 세팅한다.
+ *
+ *
접근통제 정책(SER-001 요건4·5)에서 세션 재발급 '미사용'을 선택한 경우에 쓴다.
+ * 표식까지 빠지면 {@code SecondFactorInterceptor} 가 인증을 통과시키지 않아 무한 리다이렉트가
+ * 되므로, 재발급을 생략하더라도 표식은 반드시 남긴다.
[2026-08-07 접속IP 유실 복구] 종전에는 세션 {@code LoginVO.getIp()} 만 읽었는데
+ * 이 값이 실제로 채워지지 않아 {@code COMTNLOGINLOG.CONECT_IP} 가 전 건(373,959행) NULL
+ * 이었다(2026-08-07 운영 덤프 실측). 이제 현재 요청에서
+ * {@link ClientIpResolver}(#34, X-Forwarded-For 보정 + 신뢰 프록시 검증)로 직접 취득하고,
+ * 요청 컨텍스트를 얻지 못하는 경우에만 세션 값으로 폴백한다.
+ *
+ *
[2026-08-07 접속ID 복구] {@code CONECT_ID} 는 고유식별자 자리인데 실제 적재값이
+ * 16자리 hex 라 회원 테이블 4종 어디와도 매칭되지 않아(369,385행 실측) 접속자를 특정할 수
+ * 없었다. 세션 {@code LoginVO.getId()}(로그인 계정ID)와 {@code getUserSe()} 를
+ * {@code CONECT_ACNT_ID} / {@code CONECT_USER_SE} 에 직접 적재한다.
+ *
+ *
[접속위치] 취득한 IP 를 {@link IpLocationResolver} 로 판정해
+ * {@code CONECT_LC} 에 적재 시점 스냅샷으로 남긴다(나중에 대역 매핑을 바꿔도
+ * 과거 기록은 불변 — 감사증빙).
+ *
+ *
fail-safe: IP·위치 취득은 로그인/로그아웃 경로에서 실행된다.
+ * 어떤 예외도 밖으로 던지지 않는다 — 접속기록 때문에 로그인이 막히면 안 된다.
*
* @author 공통 서비스 개발팀 이삼섭
* @since 2009. 3. 11.
@@ -22,69 +51,131 @@ import egovframework.com.cmm.util.EgovUserDetailsHelper;
*
*/
public class EgovLoginLogAspect {
-
+
+ private static final Log LOG = LogFactory.getLog(EgovLoginLogAspect.class);
+
@Resource(name="EgovLoginLogService")
private EgovLoginLogService loginLogService;
+ /**
+ * 접속위치 판정기(개인정보보호 #62).
+ *
+ *
일부러 {@code @Resource} 가 아니라 {@code required=false} 로 받는다. 이 애스펙트는
+ * DispatcherServlet 컨텍스트의 {@code } 이라 필수 주입으로 걸어두면 스캔 범위가
+ * 어긋난 배포본에서 컨텍스트 기동 자체가 실패한다. 접속위치는 부가 정보이므로
+ * 미주입 시 그 칸만 비우고 로그인·접속기록 적재는 계속되게 한다.
+ */
+ @Autowired(required = false)
+ private IpLocationResolver ipLocationResolver;
+
/**
* 로그인 로그정보를 생성한다.
* EgovLoginController.actionMain Method
- *
- * @param
+ *
+ * @param
* @return void
- * @throws Exception
+ * @throws Exception
*/
public void logLogin() throws Throwable {
-
- String uniqId = "";
- String ip = "";
-
- /* Authenticated */
- Boolean isAuthenticated = EgovUserDetailsHelper.isAuthenticated();
- if(isAuthenticated.booleanValue()) {
- LoginVO user = (LoginVO)EgovUserDetailsHelper.getAuthenticatedUser();
- uniqId = user.getUniqId();
- ip = user.getIp();
- }
-
- LoginLog loginLog = new LoginLog();
- loginLog.setLoginId(uniqId);
- loginLog.setLoginIp(ip);
- loginLog.setLoginMthd("I"); // 로그인:I, 로그아웃:O
- loginLog.setErrOccrrAt("N");
- loginLog.setErrorCode("");
- loginLogService.logInsertLoginLog(loginLog);
-
+ writeLog("I");
}
-
+
/**
* 로그아웃 로그정보를 생성한다.
* EgovLoginController.actionLogout Method
- *
- * @param
+ *
+ * @param
* @return void
- * @throws Exception
+ * @throws Exception
*/
public void logLogout() throws Throwable {
-
+ writeLog("O");
+ }
+
+ /**
+ * 접속기록 1건 적재.
+ *
+ * @param mthd 접속구분 I:로그인 O:로그아웃
+ */
+ private void writeLog(String mthd) throws Exception {
+
String uniqId = "";
- String ip = "";
+ String acntId = null;
+ String userSe = null;
+ String sessionIp = null;
/* Authenticated */
- Boolean isAuthenticated = EgovUserDetailsHelper.isAuthenticated();
- if(isAuthenticated.booleanValue()) {
- LoginVO user = (LoginVO)EgovUserDetailsHelper.getAuthenticatedUser();
+ Boolean isAuthenticated = EgovUserDetailsHelper.isAuthenticated();
+ if (isAuthenticated.booleanValue()) {
+ LoginVO user = (LoginVO) EgovUserDetailsHelper.getAuthenticatedUser();
uniqId = user.getUniqId();
- ip = user.getIp();
- }
+ acntId = user.getId(); // 로그인 계정ID — 접속기록의 '접속ID'
+ userSe = user.getUserSe(); // USR/GNR/GRO/ENT
+ sessionIp = user.getIp();
+ }
- LoginLog loginLog = new LoginLog();
- loginLog.setLoginId(uniqId);
- loginLog.setLoginIp(ip);
- loginLog.setLoginMthd("O"); // 로그인:I, 로그아웃:O
- loginLog.setErrOccrrAt("N");
- loginLog.setErrorCode("");
- loginLogService.logInsertLoginLog(loginLog);
+ String ip = resolveIp(sessionIp);
+
+ LoginLog loginLog = new LoginLog();
+ loginLog.setLoginId(uniqId);
+ loginLog.setConectAcntId(acntId);
+ loginLog.setConectUserSe(userSe);
+ loginLog.setLoginIp(ip);
+ loginLog.setConectLc(resolveLocation(ip));
+ loginLog.setLoginMthd(mthd); // 로그인:I, 로그아웃:O
+ loginLog.setErrOccrrAt("N");
+ loginLog.setErrorCode("");
+
+ // ============================================================
+ // [2026-08-10] 적재 실패를 삼킨다 — 단, 조용히 삼키지는 않는다.
+ // 이 메서드는 actionMain(로그인)·actionLogout(로그아웃)의 @Before 어드바이스라
+ // 여기서 예외가 나가면 로그인/로그아웃 자체가 500 으로 죽는다. 실제로 로컬에서
+ // 'Unknown system variable tx_isolation'(MySQL 8 + mysql-connector 5.1.17 조합)
+ // 때문에 로그인·로그아웃이 통째로 막혔다. 접속기록은 부가 기록이고 인증 흐름이
+ // 본질이므로, 적재 실패가 인증을 막지 않게 한다(클래스 Javadoc 의 fail-safe 명세와 일치).
+ //
+ // ※ 다만 접속기록 유실은 SFR-004(접속기록 보관) 위반으로 직결되므로 ERROR 로 남긴다.
+ // 이 로그가 보이면 접속기록이 쌓이지 않고 있다는 뜻이니 원인(드라이버 등)을 반드시 해결할 것.
+ // ============================================================
+ try {
+ loginLogService.logInsertLoginLog(loginLog);
+ } catch (Exception e) {
+ LOG.error("[접속기록/SFR-004] 로그인 로그 적재 실패 — 인증 흐름은 계속 진행."
+ + " mthd=" + mthd + ", acntId=" + acntId + ", ip=" + ip + " / 원인=" + e, e);
+ }
+ }
+
+ /**
+ * 현재 요청에서 접속 IP 를 취득한다. 요청 컨텍스트가 없으면 세션 값으로 폴백.
+ *
+ * @param sessionIp 세션 LoginVO 의 IP(없을 수 있음)
+ * @return 접속 IP(확정 불가 시 null)
+ */
+ private String resolveIp(String sessionIp) {
+ try {
+ RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
+ if (attrs instanceof ServletRequestAttributes) {
+ HttpServletRequest request = ((ServletRequestAttributes) attrs).getRequest();
+ String ip = ClientIpResolver.getClientIp(request);
+ if (ip != null && ip.length() > 0 && !"unknown".equalsIgnoreCase(ip)) {
+ return ip;
+ }
+ }
+ } catch (RuntimeException e) {
+ // 요청 컨텍스트 취득 실패 — 세션 값으로 폴백한다.
+ }
+ return sessionIp;
+ }
+
+ /** 접속위치 판정(fail-safe — 실패 시 null 로 두고 적재는 계속한다). */
+ private String resolveLocation(String ip) {
+ if (ipLocationResolver == null) {
+ return null;
+ }
+ try {
+ return ipLocationResolver.resolve(ip);
+ } catch (RuntimeException e) {
+ return null;
+ }
}
-
}
diff --git a/src/main/java/egovframework/com/sym/log/clg/service/LoginLog.java b/src/main/java/egovframework/com/sym/log/clg/service/LoginLog.java
index 2cd95ea..594e90c 100644
--- a/src/main/java/egovframework/com/sym/log/clg/service/LoginLog.java
+++ b/src/main/java/egovframework/com/sym/log/clg/service/LoginLog.java
@@ -33,6 +33,17 @@ public class LoginLog implements Serializable {
/** 접속IP */
private String loginIp;
+ /** 접속위치명 (개인정보보호 #62 — COMTNLOGINLOG.CONECT_LC) */
+ private String conectLc;
+
+ /** 접속 계정ID = 로그인 ID (개인정보보호 #62 — COMTNLOGINLOG.CONECT_ACNT_ID).
+ * loginId(=CONECT_ID)는 고유식별자 자리인데 실제 적재값으로 접속자를 특정할 수 없어
+ * 계정ID를 별도 컬럼에 직접 남긴다. */
+ private String conectAcntId;
+
+ /** 사용자구분 USR/GNR/GRO/ENT (개인정보보호 #62 — COMTNLOGINLOG.CONECT_USER_SE) */
+ private String conectUserSe;
+
/** 접속방식 */
private String loginMthd;
@@ -169,6 +180,33 @@ public class LoginLog implements Serializable {
this.creatDt = creatDt;
}
+ /** 접속위치명 (개인정보보호 #62) */
+ public String getConectLc() {
+ return conectLc;
+ }
+
+ public void setConectLc(String conectLc) {
+ this.conectLc = conectLc;
+ }
+
+ /** 접속 계정ID (개인정보보호 #62) */
+ public String getConectAcntId() {
+ return conectAcntId;
+ }
+
+ public void setConectAcntId(String conectAcntId) {
+ this.conectAcntId = conectAcntId;
+ }
+
+ /** 사용자구분 (개인정보보호 #62) */
+ public String getConectUserSe() {
+ return conectUserSe;
+ }
+
+ public void setConectUserSe(String conectUserSe) {
+ this.conectUserSe = conectUserSe;
+ }
+
public String getSearchBgnDe() {
return searchBgnDe;
}
diff --git a/src/main/java/egovframework/com/uat/uia/web/EgovLoginController.java b/src/main/java/egovframework/com/uat/uia/web/EgovLoginController.java
index 123f96c..053bfb0 100644
--- a/src/main/java/egovframework/com/uat/uia/web/EgovLoginController.java
+++ b/src/main/java/egovframework/com/uat/uia/web/EgovLoginController.java
@@ -103,8 +103,15 @@ public class EgovLoginController {
HttpServletRequest request,
HttpServletResponse response,
ModelMap model,
- @CookieValue(value = "mobile", required = false, defaultValue = "false") Boolean mobile)
+ @RequestParam(value = "expired", required = false) String expired,
+ @CookieValue(value = "mobile", required = false, defaultValue = "false") Boolean mobile)
throws Exception {
+
+ // [개인정보보호 SER-001 요건5] 자동 로그아웃으로 돌아온 경우 사유를 알린다.
+ // 로그인 화면(login.jsp)은 ${message} 를 alert 로 띄운다 — 별도 화면 수정 불필요.
+ if ("Y".equals(expired)) {
+ model.addAttribute("message", "장시간 사용하지 않아 자동 로그아웃되었습니다. 다시 로그인해 주세요.");
+ }
System.out.println("로그인 완료");
/*
@@ -595,11 +602,32 @@ public class EgovLoginController {
* @exception Exception
*/
@RequestMapping(value="/uat/uia/actionLogout.do")
- public String actionLogout(HttpServletRequest request, ModelMap model)
+ public String actionLogout(HttpServletRequest request, ModelMap model,
+ @RequestParam(value="expired", required=false) String expired)
throws Exception {
-
+
String userIp = EgovClntInfo.getClntIP(request);
-
+
+ // ============================================================
+ // [개인정보보호 SER-001 요건5] 자동 로그아웃(미사용 세션 만료) 경로 분기
+ // · 관리자 상단(top.jsp) 타이머가 잔여시간 0에 도달하면 expired=Y 로 이 액션을 호출한다.
+ // · 이 경우는 사용자가 누른 로그아웃이 아니라 '자동 접속차단'이므로
+ // ① 세션을 통째로 무효화하고(속성 null 처리만으로는 잔여 세션 데이터가 남는다)
+ // ② 프론트 메인이 아니라 '로그인 화면'으로 이동시킨다.
+ // · 수동 로그아웃(expired 없음)은 기존 동작(프론트 메인 이동)을 그대로 둔다.
+ // ============================================================
+ if ("Y".equals(expired)) {
+ HttpSession session = request.getSession(false);
+ if (session != null) {
+ try {
+ session.invalidate();
+ } catch (IllegalStateException e) {
+ // 이미 컨테이너가 만료시킨 세션 — 무시하고 로그인 화면으로 보낸다.
+ }
+ }
+ return "redirect:/uat/uia/egovLoginUsr.do?expired=Y";
+ }
+
request.getSession().setAttribute("loginVO", null);
// return "redirect:/j_spring_security_logout";
return "redirect:/frt/main.do";
diff --git a/src/main/java/egovframework/com/uat/uia/web/SecondFactorController.java b/src/main/java/egovframework/com/uat/uia/web/SecondFactorController.java
index 97f6db2..2158ef9 100644
--- a/src/main/java/egovframework/com/uat/uia/web/SecondFactorController.java
+++ b/src/main/java/egovframework/com/uat/uia/web/SecondFactorController.java
@@ -23,6 +23,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import egovframework.com.cmm.LoginVO;
import egovframework.com.cmm.privacy.interceptor.SecondFactorInterceptor;
+import egovframework.com.cmm.privacy.policy.AccessControlPolicyService;
import egovframework.com.cmm.privacy.security.SessionRegenerator;
import egovframework.com.cmm.privacy.util.MaskingUtil;
import egovframework.com.uat.uia.service.AuthMethodResolver;
@@ -84,6 +85,27 @@ public class SecondFactorController {
@Resource(name = "otpAuthService")
private OtpAuthService otpAuthService;
+ /** 접근통제 정책(SER-001 요건4·5) — 세션 재발급 사용여부 판정에 사용. */
+ @Resource(name = "accessControlPolicyService")
+ private AccessControlPolicyService accessControlPolicyService;
+
+ /**
+ * [개인정보보호 SER-001] 2차 인증 성공 시 세션 재발급(세션 고정 차단).
+ *
+ *
수행 여부는 접근통제 정책 설정 화면의 세션 재발급 값을 따른다.
+ * 정책 조회에 실패하면 기본값이 '사용'이므로 안전한 쪽(재발급)으로 동작한다.
+ */
+ private void regenerateSessionIfEnabled(HttpServletRequest request) {
+ if (accessControlPolicyService == null
+ || accessControlPolicyService.getCachedPolicy().isSessionRegenUse()) {
+ SessionRegenerator.onSecondFactorSuccess(request);
+ } else {
+ // '미사용'이라도 2차 인증 완료 표식은 남겨야 인터셉터가 통과시킨다.
+ SessionRegenerator.markSecondFactorDone(request);
+ LOG.info("[개인정보/접근통제] 세션 재발급 정책 '미사용' — 재발급 생략");
+ }
+ }
+
// ────────────────────────────────────────────────────────── 2차 인증 화면
/**
@@ -181,7 +203,8 @@ public class SecondFactorController {
if (ok) {
// 성공 — 세션 재발급(세션 고정 차단, #16) + 완료표식 세팅. attempt 카운터는 세션과 함께 폐기됨.
- SessionRegenerator.onSecondFactorSuccess(request);
+ // 재발급 수행 여부는 접근통제 정책(세션 재발급) 설정을 따른다.
+ regenerateSessionIfEnabled(request);
LOG.info("[개인정보/#14] 2차 인증 성공 — 세션 재발급, 목적지 이동: user=" + user.getId() + ", target=" + target);
return "redirect:" + target;
}
@@ -244,7 +267,7 @@ public class SecondFactorController {
}
if (ok) {
- SessionRegenerator.onSecondFactorSuccess(request);
+ regenerateSessionIfEnabled(request);
LOG.info("[개인정보/#18] OTP 등록·최초검증 완료 — 세션 재발급: user=" + user.getId());
return "redirect:" + DEFAULT_TARGET;
}
diff --git a/src/main/java/egovframework/com/uss/umt/service/EgovAccountUnlockManageService.java b/src/main/java/egovframework/com/uss/umt/service/EgovAccountUnlockManageService.java
index e3793ba..0da6c0c 100644
--- a/src/main/java/egovframework/com/uss/umt/service/EgovAccountUnlockManageService.java
+++ b/src/main/java/egovframework/com/uss/umt/service/EgovAccountUnlockManageService.java
@@ -8,7 +8,20 @@ public interface EgovAccountUnlockManageService{
int selectUserLockCheckListTotCnt(UserDefaultVO userSearchVO);
- void updateUnlock(String user_id, String user_gubun);
-
- void updateUnlockAll();
+ /**
+ * 선택 계정 잠금 해제.
+ *
+ * @param user_id 대상 계정 ID(콤마 구분)
+ * @param user_gubun 대상 회원구분(콤마 구분, user_id 와 같은 순서)
+ * @param unlockResn 해제 사유. 접근통제 정책이 '사유 필수'일 때 화면에서 입력받는다.
+ * (개인정보보호 SER-001 요건4 — ACCOUNT_UNLOCK_HISTORY 적재값)
+ */
+ void updateUnlock(String user_id, String user_gubun, String unlockResn);
+
+ /**
+ * 전 회원 잠금 해제.
+ *
+ * @param unlockResn 해제 사유(위와 동일)
+ */
+ void updateUnlockAll(String unlockResn);
}
diff --git a/src/main/java/egovframework/com/uss/umt/service/MberManageVO.java b/src/main/java/egovframework/com/uss/umt/service/MberManageVO.java
index 41bc87d..90a8292 100644
--- a/src/main/java/egovframework/com/uss/umt/service/MberManageVO.java
+++ b/src/main/java/egovframework/com/uss/umt/service/MberManageVO.java
@@ -437,6 +437,35 @@ public class MberManageVO extends MberManageTalentVO{
public void setMberNm(String mberNm) {
this.mberNm = mberNm;
}
+
+ /**
+ * 이름 암호문(Base64) — 개인정보보호 기능개선 사업(추가제안 Action4, 2026-08-10).
+ * 등록·수정 DAO가 {@link #mberNm} 값으로부터 채워 넣는다. 화면·서비스는 이 필드를 직접 다루지 않는다.
+ */
+ private String mberNmEnc;
+ /** 이름 검색용 HMAC-SHA256 해시. 등록·수정 DAO가 채운다. */
+ private String mberNmHash;
+ /** 암호화 키 버전. 등록·수정 DAO가 채운다. */
+ private Integer cryptoKeyVer;
+
+ 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;
+ }
/**
* mberSttus attribute 값을 리턴한다.
* @return String
diff --git a/src/main/java/egovframework/com/uss/umt/service/UserDefaultVO.java b/src/main/java/egovframework/com/uss/umt/service/UserDefaultVO.java
index 28698ba..b29496a 100644
--- a/src/main/java/egovframework/com/uss/umt/service/UserDefaultVO.java
+++ b/src/main/java/egovframework/com/uss/umt/service/UserDefaultVO.java
@@ -603,6 +603,22 @@ public class UserDefaultVO implements Serializable {
this.searchKeyword = searchKeyword;
}
+ /**
+ * 이름 검색 완전일치 해시(개인정보보호 기능개선 사업 추가제안 Action4, 2026-08-10).
+ * 회원 이름이 암호화되며 부분검색(LIKE)이 불가능해져, {@code searchCondition="1"}(이름 검색)일 때
+ * {@code searchKeyword}의 HMAC-SHA256 해시를 여기에 채워 완전일치로 검색한다.
+ * 화면·서비스는 이 필드를 직접 설정하지 않는다 — 이름 검색을 수행하는 DAO가 채운다.
+ */
+ private String searchKeywordHash = "";
+
+ public String getSearchKeywordHash() {
+ return searchKeywordHash;
+ }
+
+ public void setSearchKeywordHash(String searchKeywordHash) {
+ this.searchKeywordHash = searchKeywordHash;
+ }
+
/**
* searchUseYn attribute 값을 리턴한다.
* @return String
diff --git a/src/main/java/egovframework/com/uss/umt/service/impl/EgovAccountUnlockManageServiceImpl.java b/src/main/java/egovframework/com/uss/umt/service/impl/EgovAccountUnlockManageServiceImpl.java
index 051d209..a0605aa 100644
--- a/src/main/java/egovframework/com/uss/umt/service/impl/EgovAccountUnlockManageServiceImpl.java
+++ b/src/main/java/egovframework/com/uss/umt/service/impl/EgovAccountUnlockManageServiceImpl.java
@@ -48,7 +48,7 @@ public class EgovAccountUnlockManageServiceImpl extends AbstractServiceImpl imp
}
@Override
- public void updateUnlock(String user_id, String user_gubun) {
+ public void updateUnlock(String user_id, String user_gubun, String unlockResn) {
String [] sUser_id = user_id.split(",");
String [] sUser_gubun = user_gubun.split(",");
@@ -64,13 +64,13 @@ public class EgovAccountUnlockManageServiceImpl extends AbstractServiceImpl imp
diapauseManageDAO.updateUnlock(vo);
// 개인정보보호(SER-001 / #24) — 잠금 해제 이력 적재
- recordUnlockHistory(sUser_id[i], sUser_gubun[i], "I");
+ recordUnlockHistory(sUser_id[i], sUser_gubun[i], "I", unlockResn);
}
}
@Override
- public void updateUnlockAll() {
+ public void updateUnlockAll(String unlockResn) {
loginDAO.deleteLoginSuccessTodayAll(new LoginVO());
@@ -90,7 +90,7 @@ public class EgovAccountUnlockManageServiceImpl extends AbstractServiceImpl imp
diapauseManageDAO.updateUnlockAll(vo);
// 개인정보보호(SER-001 / #24) — 전체 잠금 해제 이력 적재(대상 ID는 전체이므로 NULL)
- recordUnlockHistory(null, "ALL", "A");
+ recordUnlockHistory(null, "ALL", "A", unlockResn);
}
/**
@@ -101,8 +101,9 @@ public class EgovAccountUnlockManageServiceImpl extends AbstractServiceImpl imp
* @param trgetId 해제 대상 계정 ID(전체해제 시 null)
* @param trgetSe 대상 회원구분(GNR/GRO/ENT/USR/ALL)
* @param unlockTy 해제 유형 I:개별 A:전체
+ * @param unlockResn 화면에서 입력받은 해제 사유(미입력 시 기본 문구로 대체)
*/
- private void recordUnlockHistory(String trgetId, String trgetSe, String unlockTy) {
+ private void recordUnlockHistory(String trgetId, String trgetSe, String unlockTy, String unlockResn) {
try {
AccountUnlockHistoryVO hist = new AccountUnlockHistoryVO();
hist.setTrgetId(trgetId);
@@ -125,9 +126,18 @@ public class EgovAccountUnlockManageServiceImpl extends AbstractServiceImpl imp
}
}
- // 사유: 관리자 화면에서 사유 입력 필드 연동은 프론트 후속. 현재는 기본값.
- // TODO: 관리자 해제 화면에서 사유(필수) 입력받아 전달(#24 화면 후속)
- hist.setUnlockResn("관리자 화면 잠금 해제");
+ // 사유: 접근통제 정책이 '사유 필수'면 화면에서 반드시 입력되어 넘어온다.
+ // '필수 아님'으로 운영할 때는 미입력이 정상이므로 기본 문구로 대체한다.
+ if (unlockResn != null && unlockResn.trim().length() > 0) {
+ String resn = unlockResn.trim();
+ // UNLOCK_RESN VARCHAR(500) — 길이 초과로 해제 자체가 실패하지 않도록 절단한다.
+ if (resn.length() > 500) {
+ resn = resn.substring(0, 500);
+ }
+ hist.setUnlockResn(resn);
+ } else {
+ hist.setUnlockResn("관리자 화면 잠금 해제");
+ }
accountUnlockHistoryDAO.insertUnlockHistory(hist);
} catch (Exception e) {
diff --git a/src/main/java/egovframework/com/uss/umt/service/impl/MberManageDAO.java b/src/main/java/egovframework/com/uss/umt/service/impl/MberManageDAO.java
index 136c1ee..7b5249f 100644
--- a/src/main/java/egovframework/com/uss/umt/service/impl/MberManageDAO.java
+++ b/src/main/java/egovframework/com/uss/umt/service/impl/MberManageDAO.java
@@ -6,6 +6,8 @@ import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.stereotype.Repository;
+import egovframework.com.cmm.privacy.crypto.MemberDataCipher;
+import egovframework.com.cmm.privacy.crypto.MemberDataHasher;
import egovframework.com.cmm.service.impl.EgovComAbstractDAO;
import egovframework.com.uss.umt.service.EntrprsManageVO;
import egovframework.com.uss.umt.service.MberManageVO;
@@ -34,12 +36,38 @@ public class MberManageDAO extends EgovComAbstractDAO{
/** Log Info */
protected Log log = LogFactory.getLog(this.getClass());
+ /**
+ * 이름 검색 완전일치 해시 계산(개인정보보호 기능개선 사업 추가제안 Action4, 2026-08-10).
+ *
+ *
{@code searchCondition="1"}(이름 검색) + 검색어가 있을 때만 계산한다. 이름이 암호화되며
+ * SQL의 {@code LIKE}가 {@code MBER_NM_HASH = #searchKeywordHash#}로 바뀌었는데, 이 값은
+ * SQL이 만들 수 없으므로(HMAC pepper는 DB가 모른다) 조회 직전에 여기서 채운다.
+ *
+ *
{@link MemberDataHasher#hashName(String)}은 앞뒤 공백만 제거하고 대소문자·중간공백은
+ * 그대로 둔다 — 저장 시점(회원 등록/수정) 정규화와 반드시 같아야 검색이 맞아떨어진다.
+ * {@code mberNmHasher} 필드는 {@link #fillNmCrypto(MberManageVO)}와 공유한다(아래).
+ */
+ private void fillSearchKeywordHash(UserDefaultVO vo) {
+ if (vo == null || !"1".equals(vo.getSearchCondition())) {
+ return;
+ }
+ String kw = vo.getSearchKeyword();
+ if (kw == null || kw.trim().length() == 0) {
+ return;
+ }
+ if (mberNmHasher == null) {
+ mberNmHasher = new MemberDataHasher();
+ }
+ vo.setSearchKeywordHash(mberNmHasher.hashName(kw));
+ }
+
/**
* 기 등록된 특정 일반회원의 정보를 데이터베이스에서 읽어와 화면에 출력
* @param userSearchVO 검색조건
* @return List 기업회원 목록정보
*/
public List selectMberList(UserDefaultVO userSearchVO){
+ fillSearchKeywordHash(userSearchVO);
return list("mberManageDAO.selectMberList", userSearchVO);
}
@@ -59,6 +87,7 @@ public class MberManageDAO extends EgovComAbstractDAO{
* @return int 일반회원총갯수
*/
public int selectMberListTotCnt(UserDefaultVO userSearchVO) {
+ fillSearchKeywordHash(userSearchVO);
return (Integer)getSqlMapClientTemplate().queryForObject("mberManageDAO.selectMberListTotCnt", userSearchVO);
}
@@ -99,12 +128,43 @@ public class MberManageDAO extends EgovComAbstractDAO{
update("mberManageDAO.updateDeleteMber_S", delId);
}
+ /**
+ * 이름 암호화(개인정보보호 기능개선 사업 추가제안 Action4, 2026-08-10)를 위한 헬퍼.
+ * 지연 초기화 — 키 미설정 서버에서도 회원가입 외 다른 기능은 정상 기동하게 한다.
+ * 키가 없으면 이 DAO를 실제로 쓰는 시점(회원 등록)에만 예외가 난다({@link MemberDataCipher} 정책).
+ */
+ private MemberDataCipher mberNmCipher;
+ private MemberDataHasher mberNmHasher;
+
+ /**
+ * VO의 평문 이름({@code mberNm})으로부터 암호문·해시·키버전을 채운다.
+ * DAO가 매핑을 아는 유일한 위치가 되도록 서비스 계층은 이 필드를 직접 다루지 않는다.
+ */
+ private void fillNmCrypto(MberManageVO vo) {
+ try {
+ if (mberNmCipher == null) {
+ mberNmCipher = new MemberDataCipher();
+ }
+ if (mberNmHasher == null) {
+ mberNmHasher = new MemberDataHasher();
+ }
+ vo.setMberNmEnc(mberNmCipher.encrypt(vo.getMberNm()));
+ vo.setMberNmHash(mberNmHasher.hashName(vo.getMberNm()));
+ vo.setCryptoKeyVer(Integer.valueOf(MemberDataCipher.CURRENT_KEY_VER));
+ } catch (Exception e) {
+ // 암호화 실패를 삼키고 평문만 저장하면 이관 검증에서 "누락"으로 잡히긴 하지만
+ // 그 사이 평문 전용 상태로 개인정보가 노출된다 — 등록 자체를 막는다.
+ throw new IllegalStateException("[개인정보/암호화] 회원 이름 암호화 실패", e);
+ }
+ }
+
/**
* 일반회원의 기본정보를 화면에서 입력하여 항목의 정합성을 체크하고 데이터베이스에 저장
* @param mberManageVO 일반회원 등록정보
* @return String 등록결과
*/
public String insertMber(MberManageVO mberManageVO){
+ fillNmCrypto(mberManageVO);
return (String)insert("mberManageDAO.insertMber_S", mberManageVO);
}
@@ -197,6 +257,7 @@ public class MberManageDAO extends EgovComAbstractDAO{
* @return List 기업회원 목록정보
*/
public List selectExcelMberList(UserDefaultVO userSearchVO){
+ fillSearchKeywordHash(userSearchVO);
return list("mberManageDAO.selectExcelMberList", userSearchVO);
}
@@ -228,6 +289,7 @@ public class MberManageDAO extends EgovComAbstractDAO{
public List selectAwardList(UserDefaultVO userSearchVO) {
// TODO Auto-generated method stub
+ fillSearchKeywordHash(userSearchVO);
return list("mberManageDAO.selectAwardList", userSearchVO);
}
@@ -248,6 +310,7 @@ public class MberManageDAO extends EgovComAbstractDAO{
public int selectAwardListCnt(UserDefaultVO userSearchVO) {
// TODO Auto-generated method stub
+ fillSearchKeywordHash(userSearchVO);
return (Integer)getSqlMapClientTemplate().queryForObject("mberManageDAO.selectAwardListCnt", userSearchVO);
}
diff --git a/src/main/java/egovframework/com/uss/umt/web/EgovAccountUnlockManageController.java b/src/main/java/egovframework/com/uss/umt/web/EgovAccountUnlockManageController.java
index 5735278..4150c03 100644
--- a/src/main/java/egovframework/com/uss/umt/web/EgovAccountUnlockManageController.java
+++ b/src/main/java/egovframework/com/uss/umt/web/EgovAccountUnlockManageController.java
@@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import egovframework.com.cmm.annotation.IncludedInfo;
+import egovframework.com.cmm.privacy.policy.AccessControlPolicyService;
import egovframework.com.uss.umt.service.EgovAccountUnlockManageService;
import egovframework.com.uss.umt.service.UserDefaultVO;
import egovframework.rte.fdl.property.EgovPropertyService;
@@ -51,6 +52,13 @@ public class EgovAccountUnlockManageController {
@Resource(name = "propertiesService")
protected EgovPropertyService propertiesService;
+ /**
+ * 접근통제 정책(개인정보보호 SER-001 요건4) — 잠금 해제 방식.
+ * '관리자 화면 해제 + 해제 사유 필수'로 설정된 경우 사유 없는 해제를 거부한다.
+ */
+ @Resource(name = "accessControlPolicyService")
+ private AccessControlPolicyService accessControlPolicyService;
+
/** Log Info */
protected Log log = LogFactory.getLog(this.getClass());
@@ -95,6 +103,11 @@ public class EgovAccountUnlockManageController {
paginationInfo.setTotalRecordCount(totCnt);
model.addAttribute("paginationInfo", paginationInfo);
+ // [개인정보보호 SER-001 요건4] 해제 사유 입력 여부는 접근통제 정책 설정을 따른다.
+ // (통합관리시스템 > 시스템관리 > 권한관리 > 접근통제 정책)
+ model.addAttribute("unlockResnRequired",
+ Boolean.valueOf(accessControlPolicyService.getCachedPolicy().isUnlockResnRequired()));
+
return "egovframework/com/uss/umt/EgovAccountUnlockManage";
}
@@ -109,11 +122,17 @@ public class EgovAccountUnlockManageController {
public String deleteMber(
@RequestParam("user_id") String user_id ,
@RequestParam("user_gubun") String user_gubun ,
+ @RequestParam(value="unlockResn", required=false) String unlockResn ,
Model model
)throws Exception {
+ // [개인정보보호 SER-001 요건4] 정책이 '사유 필수'면 화면 우회 요청도 서버에서 거부한다.
+ if (isResnMissing(unlockResn)) {
+ model.addAttribute("resultMsg", "fail.privacy.unlock.resn");
+ return "forward:/uss/umt/EgovAccountUnlockManage.do";
+ }
- egovAccountUnlockManageService.updateUnlock(user_id, user_gubun);
+ egovAccountUnlockManageService.updateUnlock(user_id, user_gubun, unlockResn);
//Exception 없이 진행시 삭제성공메시지
model.addAttribute("resultMsg", "success.common.update");
@@ -126,13 +145,37 @@ public class EgovAccountUnlockManageController {
* @throws Exception
*/
@RequestMapping("/uss/umt/EgovAccountUnlockUpdateAll.do")
- public String deleteMber(Model model)throws Exception {
+ public String deleteMber(
+ @RequestParam(value="unlockResn", required=false) String unlockResn ,
+ Model model)throws Exception {
- egovAccountUnlockManageService.updateUnlockAll();
+ // [개인정보보호 SER-001 요건4] 전체 해제는 영향 범위가 커서 사유 필수 정책을 동일하게 적용한다.
+ if (isResnMissing(unlockResn)) {
+ model.addAttribute("resultMsg", "fail.privacy.unlock.resn");
+ return "forward:/uss/umt/EgovAccountUnlockManage.do";
+ }
+
+ egovAccountUnlockManageService.updateUnlockAll(unlockResn);
//Exception 없이 진행시 삭제성공메시지
model.addAttribute("resultMsg", "success.common.update");
return "forward:/uss/umt/EgovAccountUnlockManage.do";
}
+ /**
+ * 접근통제 정책이 '해제 사유 필수'인데 사유가 비어 있는지 판정한다.
+ * 정책 조회가 실패해도 해제 자체를 막지 않는다(서비스 기본값은 '필수 아님').
+ */
+ private boolean isResnMissing(String unlockResn) {
+ try {
+ if (!accessControlPolicyService.getCachedPolicy().isUnlockResnRequired()) {
+ return false;
+ }
+ } catch (Exception e) {
+ log.error("[개인정보/접근통제] 잠금 해제 정책 조회 오류 - 해제는 계속 진행: " + e.getMessage());
+ return false;
+ }
+ return unlockResn == null || unlockResn.trim().length() == 0;
+ }
+
}
\ No newline at end of file
diff --git a/src/main/java/kr/or/arko/cmm/batch/BatchScheduler.java b/src/main/java/kr/or/arko/cmm/batch/BatchScheduler.java
index 598b25e..c11ec04 100644
--- a/src/main/java/kr/or/arko/cmm/batch/BatchScheduler.java
+++ b/src/main/java/kr/or/arko/cmm/batch/BatchScheduler.java
@@ -15,6 +15,7 @@ import org.springframework.stereotype.Component;
import egovframework.com.cmm.EgovWebUtil;
import egovframework.com.cmm.LoginVO;
+import egovframework.com.cmm.privacy.auditlog.AccessLogDownloadJobService;
import egovframework.com.cmm.privacy.batch.HashChainVerifyResultVO;
import egovframework.com.cmm.privacy.batch.InspectionReportVO;
import egovframework.com.cmm.privacy.batch.PrivacyBatchService;
@@ -61,6 +62,14 @@ public class BatchScheduler {
@Resource(name = "privacyBatchService")
private PrivacyBatchService privacyBatchService;
+ /** [SFR-004-6] 비동기 다운로드 작업 — 만료 파일 파기용. */
+ @Resource(name = "accessLogDownloadJobService")
+ private AccessLogDownloadJobService accessLogDownloadJobService;
+
+ /** [SFR-001 #6] 권한이력 비동기 다운로드 작업 — 만료 파일 파기용. */
+ @Resource(name = "authorHistDownloadJobService")
+ private egovframework.com.cmm.privacy.author.AuthorHistDownloadJobService authorHistDownloadJobService;
+
/** 개인정보보호 휴면 사전알림 서비스 (#10 D-30/D-7/D-Day + 관리자 알림) */
@Resource(name = "dormantAlertService")
private egovframework.com.cmm.privacy.account.DormantAlertService dormantAlertService;
@@ -244,6 +253,35 @@ public class BatchScheduler {
+ " 총 " + report.getTotalLogCnt() + "건, 무결성=" + report.getIntegrityResult());
}
+ /**
+ * [개인정보보호 SFR-004-6] 접속기록 CSV 비동기 다운로드 파일 파기 배치.
+ *
+ *
매일 01:00. 다른 개인정보 배치는 월 1회지만 이건 매일 돈다 — 생성 파일이
+ * '대량 개인정보 파일'이라 보관기간(기본 3일)이 짧고, 월 1회로는 만료분이 최대 한 달간
+ * 디스크에 남기 때문이다. 접속기록 본체의 보관기간(730일)과는 무관한 사본의 수명이다.
+ <%-- 비동기 다운로드 작업 목록 — 요청자 본인 것만 보인다.
+ 대용량은 요청만 걸어두고 다른 일을 하다가 완료 후 내려받는다. --%>
+
+
+
+
+
+
+
+
+
+
+
+ 내 다운로드 요청
+ 파일은 암호화 보관되며 만료 시 자동 파기됩니다. 요청자 본인만 내려받을 수 있습니다.
+
+
내 다운로드 요청 목록
+
+
+
+
+
+
요청일시
검색조건
상태
진행
만료
내려받기
+
+
+
+ <%-- tr 의 id 와 td 의 dl_st/dl_pg/dl_dl 클래스는 폴링(fn_dlApply)이 제자리 갱신할
+ 지점이다. 이름을 바꾸면 진행률이 멈춘 것처럼 보이니 함께 고칠 것. --%>
+
+
+
+
+
+
+ 대기
+ 생성중
+ 완료
+ 실패
+ 파기됨
+
+
+
+
+ 건
+ % (/)
+ -
+
+
+
+
+
+
+ '); return false;">내려받기
+
+ -
+
+
+
+
+
+
+
+
+
권한변경 이력 목록
-
-
-
-
-
-
+
+
+
+
+
+
+
-
+
NO.
변경일시
-
대상자ID
+
대상자
변경구분
변경전 권한
변경후 권한
변경사유
승인자
변경자ID
+
변경자IP
@@ -117,7 +307,10 @@ function fn_download() {
-
+
+
+
+
부여
@@ -131,11 +324,17 @@ function fn_download() {
+
+
+ -
+
+
+
-
+
@@ -145,6 +344,13 @@ function fn_download() {
+
+
+ 변경자 IP — 권한을 실제로 반영한 취급자의 접속 IP입니다(프록시 환경은 X-Forwarded-For 보정값).
+ 권한 부여·변경·회수 내역은 최소 3년 보관되며 위·변조 방지를 위해 수정·삭제되지 않습니다.
+ 다운로드 — 사유 입력 후 요청하면 서버가 백그라운드에서 파일을 만듭니다.
+ 목록의 상태가 완료 로 바뀌면 내려받을 수 있고, 만료된 파일은 자동 파기됩니다.
+
@@ -170,6 +230,14 @@ function fn_reject_submit() {
+
+
+ 말소 처리 안내 — 인사이동·퇴직 발생 시 지체 없이 권한을 변경·말소해야 하며(안전성 확보조치 기준 제5조),
+ 모든 부여·변경·말소 내역은 자동으로 이력에 기록되어 최소 3년간 보관됩니다.
+ 권한 변경은 신청 후 승인 시점에 반영되며, 승인 전까지는 승인대기 로 표시됩니다.
+ 변경 전·후 역할 — 승인대기 행은 처리 대기 중인 신청 내용(신청 시점 권한 → 신청 목표 권한)을,
+ 그 외 행은 가장 최근 확정된 권한변경 이력을 표시합니다. 변경 이력이 없으면 변경 전은 ‘-’, 변경 후는 현재 역할로 표기됩니다.
+
diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/authorRequest/regist.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/authorRequest/regist.jsp
index aba0470..a9c9032 100644
--- a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/authorRequest/regist.jsp
+++ b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/authorRequest/regist.jsp
@@ -14,6 +14,10 @@
function fn_list() {
location.href = "";
}
+// 목록 [부여/변경/말소] 진입으로 대상자 계정ID가 프리필된 경우 고유식별자를 자동 조회
+function fn_prefill() {
+ if ($.trim(document.frm.trgetId.value).length > 0) { fn_find_target(); }
+}
function fn_toggleAfter() {
// 회수(R)는 목표권한 입력 불필요 → 비활성
var se = document.frm.requestSe.value;
@@ -134,7 +138,7 @@ function fn_cancel() { document.frm.reset(); fn_toggleAfter(); }
-
+
diff --git a/src/main/webapp/WEB-INF/jsp/site/mnt/sec/conectLog/list.jsp b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/conectLog/list.jsp
new file mode 100644
index 0000000..b2be44c
--- /dev/null
+++ b/src/main/webapp/WEB-INF/jsp/site/mnt/sec/conectLog/list.jsp
@@ -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 --%>
+<%--// Head --%>
+<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"%>
+<%@ taglib prefix="ui" uri="http://egovframework.gov/ctl/ui"%>
+<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt"%>
+<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form"%>
+<%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%>
+
+<%--
+ 로그인·로그아웃 접속기록 조회·다운로드 (개인정보보호 #62)
+ · COMTNLOGINLOG 적재분(접속ID·접속일시·접속IP·접속위치)을 조건별로 검색
+ · 접속ID 는 원본 CONECT_ID(고유식별자)가 아니라 회원 테이블 조인으로 얻은 계정ID
+ · 다운로드는 사유 필수 — 미입력 시 서버가 400 으로 차단하고 파일을 열지 않음
+ · 접속기록 위·변조 방지 — 이 화면은 수정·삭제를 제공하지 않는다
+--%>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ · 시스템 로그인·로그아웃 기록입니다. 기간은 yyyy-MM-dd 형식으로 입력하세요.
+ · 접속ID는 로그인에 사용된 계정ID를 기록 시점에 그대로 저장한 값입니다. 성명은 회원정보에서 보조 조회하며, 탈퇴 등으로 대조되지 않으면 비어 있습니다.
+ · 접속위치는 접속 IP가 속한 대역(CONECT_IP_LOCATION 매핑)을 기록 시점에 판정해 저장한 값입니다. 이후 매핑을 바꿔도 지난 기록은 변하지 않습니다.
+ · 2026-08-07 이전 기록은 접속ID·접속IP·접속위치가 비어 있습니다 — 종전 적재 로직이 IP를 남기지 않았고, 저장된 고유식별자로는 계정을 특정할 수 없습니다(소급 복원 불가). 이 경우 고유식별자를 회색으로 표시합니다.
+ · 다운로드는 사유 입력이 필수이며, 다운로드자·일시·IP·사유·건수가 다운로드 이력에 기록됩니다. 접속기록의 수정·삭제는 제공하지 않습니다.
+