diff --git a/src/main/java/egovframework/com/cmm/privacy/account/AccountActionHistoryVO.java b/src/main/java/egovframework/com/cmm/privacy/account/AccountActionHistoryVO.java new file mode 100644 index 0000000..93d1f1b --- /dev/null +++ b/src/main/java/egovframework/com/cmm/privacy/account/AccountActionHistoryVO.java @@ -0,0 +1,62 @@ +package egovframework.com.cmm.privacy.account; + +import java.io.Serializable; + +/** + * 장기 미사용 계정 조치 이력 VO — ACCOUNT_ACTION_HISTORY 매핑 (#11). + * + *
현황 화면에서 관리자가 수행한 잠금(휴면 확정)·말소 조치를 + * 조치자·조치일시·대상·사유·IP 와 함께 남긴다. 잠금 해제 이력은 기존 + * ACCOUNT_UNLOCK_HISTORY(SER-001 #24)가 담당하며 본 테이블과 역할이 다르다.
+ * + *+ * 수정일 수정자 수정내용 + * ---------- ------ --------------------------- + * 2026.08.10 URP 최초 생성 (#11 시안 반영) + *+ */ +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.*} 포인트컷에 + * 걸려 트랜잭션 경계 안에서 실행되므로, 다건 조치 중 예외가 나면 그 요청 전체가 롤백된다.
+ * + *+ * 수정일 수정자 수정내용 + * ---------- ------ --------------------------- + * 2026.07.21 URP 최초 생성 (#11 현황 조회) + * 2026.08.10 URP 시안 반영 — 요약 집계 + 잠금·말소 조치(이력 적재) + **/ @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
취급자 전체 계정수와 휴면예정·휴면/잠금·말소 건수를 한 번의 조회로 담는다. + * 카드 라벨에 쓰이는 기준일수(dormantDay/warnDay)와 정책 적용상태는 + * ACCOUNT_MNG_POLICY 값을 서비스 계층에서 채운다(하드코딩 없음, #9/#13 연계).
+ * + *+ * 수정일 수정자 수정내용 + * ---------- ------ --------------------------- + * 2026.08.10 URP 최초 생성 (#11 시안 반영) + *+ */ +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다운로드를 '요청 → 백그라운드 생성 → 완료 후 내려받기'로 나눈다. 대용량 조건에서 + * 전체를 메모리에 올린 뒤 응답에 싣던 방식의 메모리 압박·요청 타임아웃을 없애기 위함이다.
+ * + * @since 2026-08-07 + */ +public interface AccessLogDownloadJobService { + + /** + * 다운로드 작업을 등록한다(즉시 반환). 사유는 필수다. + * + * @return 등록된 작업 ID + * @throws IllegalArgumentException 사유 미입력 + */ + long requestJob(String requesterId, String requesterIp, String reason, + PersonalDataAccessLogSearchVO searchVO) throws Exception; + + /** 요청자 본인의 최근 작업 목록(화면 표시·폴링용). */ + List보관 최소화: 생성된 파일은 대량 개인정보 파일이라 오래 두지 않는다. + * {@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개인정보보호 기능개선 사업(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실패는 예외로 알린다. 조용히 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비동기 다운로드(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주의: 조건 전체를 메모리에 올린다. 대용량은 위 {@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).
+ * + *집계 실패가 화면 전체를 막지 않도록 0으로 채운 맵을 돌려준다(fail-safe).
+ */ + private Map