개인정보보호 기능개선 3차 — 접근통제 정책 화면 외 미커밋 산출물 일괄 커밋
5개 세션에 걸쳐 쌓인 미커밋 변경분을 한 번에 정리한다. 주 산출물은 이번 세션의 접근통제 정책 적용(SER-001 요건 4·5)이고, 나머지는 앞선 세션들의 백로그다. ■ 접근통제 정책 적용 (SER-001 요건 4·5, 이번 세션) 「개인정보보호강화 파일럿 개발항목」 접근통제 정책 적용(Action2) 시안 반영. - ACCESS_CONTROL_POLICY(단일행) + 관리자 화면 신규 (메뉴 9100047) · 계정 잠금: 인증 실패 임계값(10 -> 5), 잠금 해제 방식(관리자화면+이력 / +사유필수) · 자동 로그아웃: 미사용 시간(60 -> 30분), 만료 임박 안내 사용여부·시점 · 세션 재발급: 2차 인증 성공 시 세션 고정(Session Fixation) 차단 여부 - SessionPolicyInterceptor 신규 — /mnt/ 요청마다 정책값으로 세션 타임아웃 적용. web.xml 재배포 없이 값이 바뀐다(2025.9 자료요청 3번 "시간 설정 기능 없음" 해소). web.xml <session-timeout>30 은 이제 폴백값이다. - 잠금 임계값 저장 시 인증 경로가 읽는 LOGIN_LOCK_COUNT 를 함께 동기화. 정책 테이블만 고치면 화면 숫자만 바뀌고 실제 인증은 옛 값을 계속 쓰기 때문이다. - 관리자 상단(top.jsp): 로그인 후 경과시간 + 자동 로그아웃 잔여 카운트다운, 만료 임박 안내와 연장(/mnt/session/extend.do). 세션 상태를 주기 폴링하지 않는다 — 그 요청 자체가 세션을 갱신해 '미사용 자동 로그아웃'이 영원히 오지 않기 때문이다. - 자동 로그아웃 시 로그인 화면 이동: actionLogout.do?expired=Y 로 세션 invalidate 후 로그인 화면 + 사유 안내. 수동 로그아웃(프론트 메인 이동)은 기존 동작 유지. - 계정잠금관리: 정책이 '사유 필수'면 해제 사유 강제, ACCOUNT_UNLOCK_HISTORY 적재. ■ EgovLoginLogAspect fail-safe (회귀 수정) 접속기록 적재 예외가 @Before 어드바이스 밖으로 나가 로그인·로그아웃이 통째로 500 이 되고 있었다(로컬: MySQL 8 + mysql-connector 5.1.17 의 tx_isolation 조회 실패). 클래스 Javadoc 이 이미 fail-safe 를 명세하고 있었으나 구현이 따르지 않았다. try/catch 로 인증 흐름을 살리되, 접속기록 유실은 SFR-004 직결이라 ERROR 로 남긴다. ■ 앞선 세션 백로그 - 접속기록(SFR-004): 조회·CSV 비동기 다운로드(PDAL_DOWNLOAD_JOB), 업무유형 매핑 - 로그인 접속기록(#62): COMTNLOGINLOG 접속IP·접속ID 유실 복구 + 접속위치 스냅샷 - 권한관리(SFR-001): 권한변경 이력 CSV 비동기 다운로드, 신청·승인 워크플로 화면 정리 - 미사용계정(SFR-002): 현황 화면 시안 반영(요약·역할·잠금/말소·조치 이력) - 회원정보 암호화(추가제안 Action4): AES 암복호화·검색용 해시, 이관 배치와 사전 백업 ■ 커밋 제외 font/(글꼴 설치본 22M), 제안서 문서(pptx/hwp), .claude/(하네스) 는 소스가 아니라 제외. .claude/ 버전관리 여부는 별도 판단이 필요하다. 배포 산출물: _workspace/deploy/20260810_1300/ (DELTA.md, sql/, APPLY_LOG.md) 브라우저 검증(2단계 인증 필요)은 APPLY_LOG.md B1~B7 로 남겨둔다. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
f2029a00a0
commit
94da0fe9ae
@ -0,0 +1,62 @@
|
|||||||
|
package egovframework.com.cmm.privacy.account;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 장기 미사용 계정 조치 이력 VO — ACCOUNT_ACTION_HISTORY 매핑 (#11).
|
||||||
|
*
|
||||||
|
* <p>현황 화면에서 관리자가 수행한 <b>잠금(휴면 확정)</b>·<b>말소</b> 조치를
|
||||||
|
* 조치자·조치일시·대상·사유·IP 와 함께 남긴다. 잠금 <i>해제</i> 이력은 기존
|
||||||
|
* ACCOUNT_UNLOCK_HISTORY(SER-001 #24)가 담당하며 본 테이블과 역할이 다르다.</p>
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* 수정일 수정자 수정내용
|
||||||
|
* ---------- ------ ---------------------------
|
||||||
|
* 2026.08.10 URP 최초 생성 (#11 시안 반영)
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
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; }
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
package egovframework.com.cmm.privacy.account;
|
package egovframework.com.cmm.privacy.account;
|
||||||
|
|
||||||
|
import java.net.URLEncoder;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
@ -9,8 +10,10 @@ import org.springframework.stereotype.Controller;
|
|||||||
import org.springframework.ui.ModelMap;
|
import org.springframework.ui.ModelMap;
|
||||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
||||||
import egovframework.com.cmm.LoginVO;
|
import egovframework.com.cmm.LoginVO;
|
||||||
|
import egovframework.com.cmm.privacy.auditlog.ClientIpResolver;
|
||||||
import egovframework.com.cmm.util.EgovUserDetailsHelper;
|
import egovframework.com.cmm.util.EgovUserDetailsHelper;
|
||||||
import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo;
|
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 {
|
public class AccountMngController {
|
||||||
|
|
||||||
private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do";
|
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")
|
@Resource(name = "accountMngPolicyService")
|
||||||
private AccountMngPolicyService accountMngPolicyService;
|
private AccountMngPolicyService accountMngPolicyService;
|
||||||
@ -200,10 +205,20 @@ public class AccountMngController {
|
|||||||
*/
|
*/
|
||||||
@RequestMapping(value = "/mnt/sec/dormantStatus/listView.do")
|
@RequestMapping(value = "/mnt/sec/dormantStatus/listView.do")
|
||||||
public String dormantStatusListView(@ModelAttribute("dormantAccountStatusVO") DormantAccountStatusVO searchVO,
|
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 {
|
ModelMap model) throws Exception {
|
||||||
if (handlerUser() == null) {
|
if (handlerUser() == null) {
|
||||||
return LOGIN_REDIRECT;
|
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 paginationInfo = new PaginationInfo();
|
||||||
paginationInfo.setCurrentPageNo(searchVO.getPageIndex());
|
paginationInfo.setCurrentPageNo(searchVO.getPageIndex());
|
||||||
paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage() < 1 ? 10 : searchVO.getRecordCountPerPage());
|
paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage() < 1 ? 10 : searchVO.getRecordCountPerPage());
|
||||||
@ -215,6 +230,70 @@ public class AccountMngController {
|
|||||||
|
|
||||||
model.addAttribute("resultList", list);
|
model.addAttribute("resultList", list);
|
||||||
model.addAttribute("paginationInfo", paginationInfo);
|
model.addAttribute("paginationInfo", paginationInfo);
|
||||||
|
model.addAttribute("summary", summary);
|
||||||
|
model.addAttribute("resultMsg", resultMsg);
|
||||||
return "site/mnt/sec/dormantStatus/list";
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,4 +46,45 @@ public class AccountMngDAO extends EgovComAbstractDAO {
|
|||||||
param.put("alertBefore", Integer.valueOf(alertBefore));
|
param.put("alertBefore", Integer.valueOf(alertBefore));
|
||||||
return list("AccountMngDAO.selectDormantAlertTargets", param);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,4 +12,26 @@ public interface DormantAccountStatusService {
|
|||||||
|
|
||||||
/** 현황 전체 건수. */
|
/** 현황 전체 건수. */
|
||||||
int selectDormantStatusTotCnt(DormantAccountStatusVO searchVO) throws Exception;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,14 +7,31 @@ import javax.annotation.Resource;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 장기 미사용 계정 현황 조회 서비스 구현 (#11).
|
* 장기 미사용 계정 현황 조회·조치 서비스 구현 (#11).
|
||||||
|
*
|
||||||
|
* <p>조회(목록·요약)와 현황 화면의 조치(잠금=휴면 확정 / 말소)를 담당한다.
|
||||||
|
* 메서드 전체가 context-transaction.xml 의 {@code egovframework.com..*Impl.*} 포인트컷에
|
||||||
|
* 걸려 트랜잭션 경계 안에서 실행되므로, 다건 조치 중 예외가 나면 그 요청 전체가 롤백된다.</p>
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* 수정일 수정자 수정내용
|
||||||
|
* ---------- ------ ---------------------------
|
||||||
|
* 2026.07.21 URP 최초 생성 (#11 현황 조회)
|
||||||
|
* 2026.08.10 URP 시안 반영 — 요약 집계 + 잠금·말소 조치(이력 적재)
|
||||||
|
* </pre>
|
||||||
*/
|
*/
|
||||||
@Service("dormantAccountStatusService")
|
@Service("dormantAccountStatusService")
|
||||||
public class DormantAccountStatusServiceImpl implements DormantAccountStatusService {
|
public class DormantAccountStatusServiceImpl implements DormantAccountStatusService {
|
||||||
|
|
||||||
|
/** 조치 사유 최대 길이 — ACCOUNT_ACTION_HISTORY.ACTION_RESN VARCHAR(500). */
|
||||||
|
private static final int MAX_RESN_LEN = 500;
|
||||||
|
|
||||||
@Resource(name = "accountMngDAO")
|
@Resource(name = "accountMngDAO")
|
||||||
private AccountMngDAO accountMngDAO;
|
private AccountMngDAO accountMngDAO;
|
||||||
|
|
||||||
|
@Resource(name = "accountMngPolicyService")
|
||||||
|
private AccountMngPolicyService accountMngPolicyService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<DormantAccountStatusVO> selectDormantStatusList(DormantAccountStatusVO searchVO) throws Exception {
|
public List<DormantAccountStatusVO> selectDormantStatusList(DormantAccountStatusVO searchVO) throws Exception {
|
||||||
int pageIndex = searchVO.getPageIndex() < 1 ? 1 : searchVO.getPageIndex();
|
int pageIndex = searchVO.getPageIndex() < 1 ? 1 : searchVO.getPageIndex();
|
||||||
@ -30,4 +47,106 @@ public class DormantAccountStatusServiceImpl implements DormantAccountStatusServ
|
|||||||
public int selectDormantStatusTotCnt(DormantAccountStatusVO searchVO) throws Exception {
|
public int selectDormantStatusTotCnt(DormantAccountStatusVO searchVO) throws Exception {
|
||||||
return accountMngDAO.selectDormantStatusListCount(searchVO);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,9 +23,18 @@ public class DormantAccountStatusVO implements Serializable {
|
|||||||
private String sttusCode; // 상태코드(EMPLYR_STTUS_CODE)
|
private String sttusCode; // 상태코드(EMPLYR_STTUS_CODE)
|
||||||
private String dormantExpectDt; // 휴면예정일(yyyy-MM-dd)
|
private String dormantExpectDt; // 휴면예정일(yyyy-MM-dd)
|
||||||
private String mberTyCode; // 회원유형(EMP/GNR)
|
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) ----
|
// ---- 검색조건(#11) ----
|
||||||
private int minNoConnectDays = 0; // 미접속일수 하한 필터
|
private int minNoConnectDays = 0; // 미접속일수 하한 필터
|
||||||
|
private String searchKeyword; // 취급자명 또는 계정ID 통합검색
|
||||||
|
private String searchState; // 계정상태 필터(N/W/L/D) — 파생 acntState 대상
|
||||||
|
|
||||||
// ---- 페이징 ----
|
// ---- 페이징 ----
|
||||||
private int pageIndex = 1;
|
private int pageIndex = 1;
|
||||||
@ -53,8 +62,16 @@ public class DormantAccountStatusVO implements Serializable {
|
|||||||
public void setDormantExpectDt(String dormantExpectDt) { this.dormantExpectDt = dormantExpectDt; }
|
public void setDormantExpectDt(String dormantExpectDt) { this.dormantExpectDt = dormantExpectDt; }
|
||||||
public String getMberTyCode() { return mberTyCode; }
|
public String getMberTyCode() { return mberTyCode; }
|
||||||
public void setMberTyCode(String mberTyCode) { this.mberTyCode = 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 int getMinNoConnectDays() { return minNoConnectDays; }
|
||||||
public void setMinNoConnectDays(int minNoConnectDays) { this.minNoConnectDays = 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 int getPageIndex() { return pageIndex; }
|
||||||
public void setPageIndex(int pageIndex) { this.pageIndex = pageIndex; }
|
public void setPageIndex(int pageIndex) { this.pageIndex = pageIndex; }
|
||||||
public int getRecordCountPerPage() { return recordCountPerPage; }
|
public int getRecordCountPerPage() { return recordCountPerPage; }
|
||||||
|
|||||||
@ -0,0 +1,56 @@
|
|||||||
|
package egovframework.com.cmm.privacy.account;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 장기 미사용 계정 현황 요약 VO (#11 화면 상단 요약 카드).
|
||||||
|
*
|
||||||
|
* <p>취급자 전체 계정수와 휴면예정·휴면/잠금·말소 건수를 한 번의 조회로 담는다.
|
||||||
|
* 카드 라벨에 쓰이는 기준일수(dormantDay/warnDay)와 정책 적용상태는
|
||||||
|
* ACCOUNT_MNG_POLICY 값을 서비스 계층에서 채운다(하드코딩 없음, #9/#13 연계).</p>
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* 수정일 수정자 수정내용
|
||||||
|
* ---------- ------ ---------------------------
|
||||||
|
* 2026.08.10 URP 최초 생성 (#11 시안 반영)
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
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; }
|
||||||
|
}
|
||||||
@ -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.
|
||||||
|
*
|
||||||
|
* <p>개인정보보호 기능개선 사업(SFR-004-6 / SER-002). statement 는
|
||||||
|
* {@code PrivacyDownloadJob_SQL_Mysql.xml}(namespace {@code AccessLogDownloadJobDAO}).
|
||||||
|
* 신규 sqlMap 이므로 {@code sql-map-config-mysql-0-site.xml} 등록이 필요하다.</p>
|
||||||
|
*
|
||||||
|
* @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<AccessLogDownloadJobVO> selectJobListByRequester(String requesterId, int limitCnt) {
|
||||||
|
Map<String, Object> param = new HashMap<String, Object>();
|
||||||
|
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<AccessLogDownloadJobVO> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
package egovframework.com.cmm.privacy.auditlog;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 접속기록 CSV 비동기 다운로드 작업 서비스 (SFR-004-6 / SER-002).
|
||||||
|
*
|
||||||
|
* <p>다운로드를 '요청 → 백그라운드 생성 → 완료 후 내려받기'로 나눈다. 대용량 조건에서
|
||||||
|
* 전체를 메모리에 올린 뒤 응답에 싣던 방식의 메모리 압박·요청 타임아웃을 없애기 위함이다.</p>
|
||||||
|
*
|
||||||
|
* @since 2026-08-07
|
||||||
|
*/
|
||||||
|
public interface AccessLogDownloadJobService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다운로드 작업을 등록한다(즉시 반환). 사유는 필수다.
|
||||||
|
*
|
||||||
|
* @return 등록된 작업 ID
|
||||||
|
* @throws IllegalArgumentException 사유 미입력
|
||||||
|
*/
|
||||||
|
long requestJob(String requesterId, String requesterIp, String reason,
|
||||||
|
PersonalDataAccessLogSearchVO searchVO) throws Exception;
|
||||||
|
|
||||||
|
/** 요청자 본인의 최근 작업 목록(화면 표시·폴링용). */
|
||||||
|
List<AccessLogDownloadJobVO> selectMyJobs(String requesterId, int limitCnt) throws Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 내려받기용 작업 조회 — <b>요청자 본인인지 재검증한다</b>.
|
||||||
|
* 작업ID를 바꿔치기해 남의 다운로드 파일을 가져가는 것을 막는다.
|
||||||
|
*
|
||||||
|
* @return 본인의 내려받기 가능한 작업. 아니면 null
|
||||||
|
*/
|
||||||
|
AccessLogDownloadJobVO selectDownloadableJob(long jobId, String requesterId) throws Exception;
|
||||||
|
|
||||||
|
/** 내려받기 성공 시 횟수·일시를 기록한다(다운로드 이력은 별도 적재). */
|
||||||
|
void markDownloaded(long jobId) throws Exception;
|
||||||
|
|
||||||
|
/** 만료된 작업 파일을 삭제하고 상태를 파기(X)로 정리한다. 파기 건수 반환. */
|
||||||
|
int purgeExpiredJobs() throws Exception;
|
||||||
|
}
|
||||||
@ -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).
|
||||||
|
*
|
||||||
|
* <p><b>보관 최소화:</b> 생성된 파일은 대량 개인정보 파일이라 오래 두지 않는다.
|
||||||
|
* {@link #expireDays}(기본 3일) 경과분은 파기 배치가 파일을 지우고 상태를 X 로 바꾼다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>권한 재검증:</b> 내려받기는 <u>요청자 본인만</u> 가능하다. 작업ID는 연속 정수라
|
||||||
|
* 추측이 쉬우므로, 조회 단계에서 요청자ID 일치를 확인하지 않으면 남의 파일이 새어나간다.</p>
|
||||||
|
*
|
||||||
|
* @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<AccessLogDownloadJobVO> 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<AccessLogDownloadJobVO> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,161 @@
|
|||||||
|
package egovframework.com.cmm.privacy.auditlog;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개인정보 접속기록 CSV 비동기 다운로드 작업(PDAL_DOWNLOAD_JOB) VO.
|
||||||
|
*
|
||||||
|
* <p>개인정보보호 기능개선 사업(SFR-004-6 / SER-002) — 대용량 다운로드를 요청 즉시 응답하고
|
||||||
|
* 백그라운드에서 파일을 만들기 위한 작업 한 건.</p>
|
||||||
|
*
|
||||||
|
* <p><b>검색조건을 컬럼으로 보존하는 이유:</b> 백그라운드 워커는 요청 세션이 끝난 뒤 실행되므로
|
||||||
|
* 조건을 스스로 재현해야 한다. 담는 값은 식별자·코드·기간뿐이라 접속기록 본문 미적재 원칙에
|
||||||
|
* 어긋나지 않는다.</p>
|
||||||
|
*
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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).
|
||||||
|
*
|
||||||
|
* <p>대기(W) 작업을 하나씩 집어 CSV 파일을 만든다. <b>단일 워커 스레드</b>다 —
|
||||||
|
* 대량 조회가 동시에 여러 건 돌면 DB 부하가 그대로 서비스 지연이 되기 때문이다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>메모리를 쓰지 않는 방식:</b> 전체를 List 로 올리지 않고 {@link #CHUNK_ROWS}건씩
|
||||||
|
* 끊어 조회하며 곧바로 파일에 쓴다. 100만 건이어도 상주 메모리는 한 청크뿐이다.
|
||||||
|
* 이것이 이 작업의 핵심이며, 종전 동기 다운로드의 OOM 위험을 없앤다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>파일은 암호화해 둔다:</b> 생성 결과는 '대량 개인정보 파일'이다. 평문 CSV 를 디스크에
|
||||||
|
* 남기지 않기 위해 {@link EgovFileScrty#encryptFile}로 암호화한 뒤 평문 임시파일을 지운다.
|
||||||
|
* 내려받을 때 복호화해 스트리밍한다. 저장 위치는 반드시 <b>웹 루트 밖</b>이어야 한다 —
|
||||||
|
* webapps 하위에 두면 URL 직접 접근으로 무인증 유출이 된다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>기동 시 고아 복구:</b> 파일 생성 중 WAS 가 죽으면 작업이 R 로 굳는다. 기동 시
|
||||||
|
* R → W 로 되돌려 재시도한다(화면에 '생성중'이 영원히 멈춰 보이는 것 방지).</p>
|
||||||
|
*
|
||||||
|
* @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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 생성 파일 저장 디렉터리. <b>반드시 웹 루트(webapps) 밖</b>이어야 한다.
|
||||||
|
* 미설정 시 기동 로그에 경고를 남기고 워커를 띄우지 않는다 — 임의 경로에
|
||||||
|
* 개인정보 파일을 만드는 것보다 기능을 멈추는 편이 안전하다.
|
||||||
|
*/
|
||||||
|
@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<PersonalDataAccessLogVO> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장 디렉터리를 확보한다(없으면 생성).
|
||||||
|
*
|
||||||
|
* <p>실패는 <b>예외로 알린다</b>. 조용히 null 을 돌려주면 호출측이 그냥 넘어가고,
|
||||||
|
* 그 결과가 '영원한 대기'였다. 메시지는 그대로 작업의 실패사유(ERR_MSG)로 화면에 노출되므로
|
||||||
|
* 운영자가 원인을 바로 알 수 있게 경로를 포함해 적는다.</p>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -25,4 +25,21 @@ public interface AccessLogDownloadService {
|
|||||||
*/
|
*/
|
||||||
List<PersonalDataAccessLogVO> downloadAccessLog(String downloaderId, String downloaderIp,
|
List<PersonalDataAccessLogVO> downloadAccessLog(String downloaderId, String downloaderIp,
|
||||||
String reason, PersonalDataAccessLogSearchVO searchVO) throws Exception;
|
String reason, PersonalDataAccessLogSearchVO searchVO) throws Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다운로드 이력만 적재한다(조회 없이).
|
||||||
|
*
|
||||||
|
* <p>비동기 다운로드(SFR-004-6)는 파일을 백그라운드에서 미리 만들어 두므로, 실제로
|
||||||
|
* <b>내려받은 시점</b>에 이력을 남겨야 증빙이 맞다. 조회는 이미 끝나 있어 건수·조건을
|
||||||
|
* 작업 정보에서 그대로 받는다.</p>
|
||||||
|
*
|
||||||
|
* @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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,6 +58,18 @@ public class AccessLogDownloadServiceImpl implements AccessLogDownloadService {
|
|||||||
return result;
|
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) {
|
private String summarizeCond(PersonalDataAccessLogSearchVO vo) {
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
package egovframework.com.cmm.privacy.auditlog;
|
package egovframework.com.cmm.privacy.auditlog;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
import java.io.OutputStreamWriter;
|
import java.io.OutputStreamWriter;
|
||||||
import java.io.PrintWriter;
|
import java.io.PrintWriter;
|
||||||
@ -7,7 +9,9 @@ import java.net.URLEncoder;
|
|||||||
import java.text.SimpleDateFormat;
|
import java.text.SimpleDateFormat;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import javax.servlet.http.HttpServletRequest;
|
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.LoginVO;
|
||||||
import egovframework.com.cmm.util.EgovUserDetailsHelper;
|
import egovframework.com.cmm.util.EgovUserDetailsHelper;
|
||||||
|
import egovframework.com.utl.sim.service.EgovFileScrty;
|
||||||
import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo;
|
import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -59,6 +64,9 @@ public class PersonalDataAccessLogController {
|
|||||||
@Resource(name = "personalDataAccessLogSearchDAO")
|
@Resource(name = "personalDataAccessLogSearchDAO")
|
||||||
private PersonalDataAccessLogSearchDAO personalDataAccessLogSearchDAO;
|
private PersonalDataAccessLogSearchDAO personalDataAccessLogSearchDAO;
|
||||||
|
|
||||||
|
@Resource(name = "accessLogDownloadJobService")
|
||||||
|
private AccessLogDownloadJobService accessLogDownloadJobService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 개인정보 접속기록 조회 화면(서버 렌더링).
|
* 개인정보 접속기록 조회 화면(서버 렌더링).
|
||||||
* 뷰: /WEB-INF/jsp/site/mnt/sec/accessLog/list.jsp
|
* 뷰: /WEB-INF/jsp/site/mnt/sec/accessLog/list.jsp
|
||||||
@ -82,12 +90,178 @@ public class PersonalDataAccessLogController {
|
|||||||
model.addAttribute("resultList", list);
|
model.addAttribute("resultList", list);
|
||||||
model.addAttribute("paginationInfo", paginationInfo);
|
model.addAttribute("paginationInfo", paginationInfo);
|
||||||
model.addAttribute("jobSeList", selectJobSeCodeList());
|
model.addAttribute("jobSeList", selectJobSeCodeList());
|
||||||
|
// 현재 검색조건 기준 조회/등록/수정/삭제 건수 — 목록을 넘겨보지 않고 처리행위를 판별하기 위함
|
||||||
|
model.addAttribute("dmlSummary", selectDmlSummary(searchVO));
|
||||||
|
// 내 다운로드 작업 목록(비동기) — 상태·진행률 확인 및 내려받기
|
||||||
|
model.addAttribute("dlJobList", selectMyDownloadJobs());
|
||||||
return "site/mnt/sec/accessLog/list";
|
return "site/mnt/sec/accessLog/list";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 접속기록 다운로드(CSV) — 사유 필수 + 다운로드 이력 적재(#42).
|
* 다운로드 <b>요청</b>(비동기) — 작업만 등록하고 즉시 목록으로 돌아온다.
|
||||||
* 사유 미입력·차단 시 파일을 열지 않고 400 으로 응답한다.
|
*
|
||||||
|
* <p>대용량 조건에서 전체를 메모리에 올려 응답에 싣던 방식(아래 {@link #download})은
|
||||||
|
* OOM·요청 타임아웃 위험이 있어, 파일 생성은 백그라운드 워커가 맡는다.
|
||||||
|
* 사유는 여기서도 필수이며, 미입력이면 작업을 만들지 않는다.</p>
|
||||||
|
*/
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 완료된 다운로드 파일 내려받기 — <b>요청자 본인만</b> 가능하다.
|
||||||
|
*
|
||||||
|
* <p>저장 파일은 암호화돼 있어 복호화 후 스트리밍한다. 임시 복호화본은 전송 직후 지운다.
|
||||||
|
* 실제 내려받은 시점의 사유·건수 이력은 기존 {@code PDAL_DOWNLOAD_HISTORY} 에 적재한다.</p>
|
||||||
|
*/
|
||||||
|
@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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 내 다운로드 작업의 <b>상태만</b> 돌려주는 폴링 전용 엔드포인트(#47 후속).
|
||||||
|
*
|
||||||
|
* <p><b>왜 따로 뒀나:</b> 종전 화면은 진행률을 보려고 목록 화면 전체를 5초마다 다시 열었다.
|
||||||
|
* 그런데 이 화면은 접속기록 수집 화이트리스트({@code /mnt/sec/**}) 안이라 <u>새로고침 한 번이
|
||||||
|
* 접속기록 1행</u>이 된다. 즉 폴링이 도는 동안 감사로그가 분당 12행씩 자기 자신을 기록했고,
|
||||||
|
* 이는 정기점검·비정상접근 탐지(SER-002)의 신호를 그대로 오염시킨다. 상태 조회를 별도 경로로
|
||||||
|
* 빼고 그 경로를 {@code excludePatterns} 에 넣어 <b>수집 대상에서 제외</b>한다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>본문 원칙:</b> 개인정보는커녕 검색조건도 싣지 않는다. 작업ID·상태·진행률·건수와
|
||||||
|
* 실패사유뿐이며, 모두 <u>본인 작업</u>에 한정된다({@code selectMyJobs(본인ID)}).</p>
|
||||||
|
*
|
||||||
|
* <p>형식은 한 줄 1작업의 {@code |} 구분 텍스트다 — 이 스택(Spring 3.0.5)에 JSON 메시지
|
||||||
|
* 컨버터를 새로 물리지 않고도 확실히 동작하며, 파싱이 단순해 화면 쪽 실패 여지가 적다.</p>
|
||||||
|
*/
|
||||||
|
@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<AccessLogDownloadJobVO> 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<AccessLogDownloadJobVO> selectMyDownloadJobs() {
|
||||||
|
try {
|
||||||
|
LoginVO user = handlerUser();
|
||||||
|
if (user == null) {
|
||||||
|
return new ArrayList<AccessLogDownloadJobVO>();
|
||||||
|
}
|
||||||
|
List<AccessLogDownloadJobVO> list = accessLogDownloadJobService.selectMyJobs(user.getId(), 10);
|
||||||
|
return (list == null) ? new ArrayList<AccessLogDownloadJobVO>() : list;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return new ArrayList<AccessLogDownloadJobVO>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 접속기록 다운로드(CSV) — 동기 방식(기존 #42). 사유 필수 + 다운로드 이력 적재.
|
||||||
|
*
|
||||||
|
* <p><b>주의:</b> 조건 전체를 메모리에 올린다. 대용량은 위 {@link #downloadRequest} 비동기 경로를
|
||||||
|
* 쓴다. 이 엔드포인트는 소량 즉시 다운로드와 기존 링크 호환을 위해 남겨둔다.</p>
|
||||||
*/
|
*/
|
||||||
@RequestMapping(value = "/mnt/sec/accessLog/download.do")
|
@RequestMapping(value = "/mnt/sec/accessLog/download.do")
|
||||||
public void download(@ModelAttribute("personalDataAccessLogSearchVO") PersonalDataAccessLogSearchVO searchVO,
|
public void download(@ModelAttribute("personalDataAccessLogSearchVO") PersonalDataAccessLogSearchVO searchVO,
|
||||||
@ -153,6 +327,43 @@ public class PersonalDataAccessLogController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 검색조건 기준 처리구분별 건수 요약.
|
||||||
|
*
|
||||||
|
* <p>"이 취급자가 개인정보를 조회만 했는지, 수정·삭제까지 했는지"를 목록을 넘겨보지 않고
|
||||||
|
* 판별하기 위한 값이다. 화면이 {@code ${dmlSummary.S}} 처럼 바로 꺼내 쓰도록
|
||||||
|
* S/I/U/D 네 키를 항상 채워 반환한다(해당 기록이 없으면 0).</p>
|
||||||
|
*
|
||||||
|
* <p>집계 실패가 화면 전체를 막지 않도록 0으로 채운 맵을 돌려준다(fail-safe).</p>
|
||||||
|
*/
|
||||||
|
private Map<String, Long> selectDmlSummary(PersonalDataAccessLogSearchVO searchVO) {
|
||||||
|
Map<String, Long> summary = new LinkedHashMap<String, Long>();
|
||||||
|
summary.put("S", Long.valueOf(0L));
|
||||||
|
summary.put("I", Long.valueOf(0L));
|
||||||
|
summary.put("U", Long.valueOf(0L));
|
||||||
|
summary.put("D", Long.valueOf(0L));
|
||||||
|
try {
|
||||||
|
List<Map<String, Object>> rows = personalDataAccessLogSearchDAO.selectAccessLogDmlSummary(searchVO);
|
||||||
|
if (rows == null) {
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
for (Map<String, Object> row : rows) {
|
||||||
|
Object code = row.get("dmlGubun");
|
||||||
|
Object cnt = row.get("cnt");
|
||||||
|
if (code == null || !summary.containsKey(String.valueOf(code))) {
|
||||||
|
// S/I/U/D 외 값(레거시 적재분 등)은 요약에 넣지 않는다 — 목록에서 확인한다.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
long v = (cnt instanceof Number) ? ((Number) cnt).longValue() : 0L;
|
||||||
|
summary.put(String.valueOf(code), Long.valueOf(v));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 요약은 보조 정보다 — 실패해도 목록은 떠야 한다.
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
/** CSV 셀 이스케이프(구분자·따옴표·개행 방어). */
|
/** CSV 셀 이스케이프(구분자·따옴표·개행 방어). */
|
||||||
private String csv(String v) {
|
private String csv(String v) {
|
||||||
if (v == null) {
|
if (v == null) {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package egovframework.com.cmm.privacy.auditlog;
|
package egovframework.com.cmm.privacy.auditlog;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
@ -42,4 +43,17 @@ public class PersonalDataAccessLogSearchDAO extends EgovComAbstractDAO {
|
|||||||
public List<JobMappingVO> selectJobSeCodeList() {
|
public List<JobMappingVO> selectJobSeCodeList() {
|
||||||
return list("PersonalDataAccessLogDAO.selectJobSeCodeList", null);
|
return list("PersonalDataAccessLogDAO.selectJobSeCodeList", null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 검색조건 기준 처리구분(S/I/U/D)별 건수.
|
||||||
|
*
|
||||||
|
* <p>"이 취급자가 개인정보를 조회만 했는지, 수정·삭제까지 했는지"를 목록을 넘겨보지 않고
|
||||||
|
* 확인하기 위한 집계다. 검색조건은 목록 조회와 동일하다.</p>
|
||||||
|
*
|
||||||
|
* @return {dmlGubun, cnt} 맵 목록. 해당 구분의 기록이 없으면 그 행 자체가 없다.
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public List<Map<String, Object>> selectAccessLogDmlSummary(PersonalDataAccessLogSearchVO searchVO) {
|
||||||
|
return list("PersonalDataAccessLogDAO.selectAccessLogDmlSummary", searchVO);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,19 @@ import java.io.Serializable;
|
|||||||
* 본 VO는 접속자 ID·업무유형·처리대상·IP·처리구분·처리결과·기간을 복합 검색조건으로 제공하며,
|
* 본 VO는 접속자 ID·업무유형·처리대상·IP·처리구분·처리결과·기간을 복합 검색조건으로 제공하며,
|
||||||
* {@code IDX_PDAL_01(CONECT_ID, CONECT_DT)} 인덱스로 접속자 ID 검색 결함을 해소한다.</p>
|
* {@code IDX_PDAL_01(CONECT_ID, CONECT_DT)} 인덱스로 접속자 ID 검색 결함을 해소한다.</p>
|
||||||
*
|
*
|
||||||
* <p>화면(JSP)은 본 작업 범위 밖이다 — 백엔드까지 구현. (TODO: 화면 #47 연계)</p>
|
* <p>화면: {@code /WEB-INF/jsp/site/mnt/sec/accessLog/list.jsp} (#47 연계 완료)</p>
|
||||||
|
*
|
||||||
|
* <p><b>검색조건 정규화(2026-08-07).</b> 원본 입력값은 화면 재표시를 위해 그대로 두고,
|
||||||
|
* SQL 바인딩에는 아래 파생 getter를 쓴다. 이렇게 하면 MySQL/Oracle 두 sqlMap에
|
||||||
|
* 방언 차이({@code CONCAT} vs {@code ||})를 새로 만들지 않아도 된다.</p>
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link #getToDtParam()} — <b>종료일 경계 결함 수정.</b> 종전에는 {@code CONECT_DT <= '2026-08-07'}이
|
||||||
|
* {@code 2026-08-07 00:00:00}으로 해석돼 <b>종료일 하루가 통째로 누락</b>됐다(실측: 8/6~8/7 검색이
|
||||||
|
* 39건 중 38건만 반환). 날짜만 입력하면 {@code 23:59:59}를 붙여 당일을 포함시킨다.</li>
|
||||||
|
* <li>{@link #getConectIdLike()} 등 — 전방일치 검색. 종전 완전일치는 ID 전체를 정확히 알아야 해서
|
||||||
|
* SFR-004-6(조회·검색)의 실효가 없었다. 전방일치는 {@code IDX_PDAL_01(CONECT_ID, CONECT_DT)}
|
||||||
|
* 인덱스를 그대로 탄다(중간일치 {@code %x%}는 인덱스를 못 써서 채택하지 않았다).</li>
|
||||||
|
* </ul>
|
||||||
*
|
*
|
||||||
* @author arko-privacy-auditlog (Fork B - 5차)
|
* @author arko-privacy-auditlog (Fork B - 5차)
|
||||||
* @since 2026-07-21
|
* @since 2026-07-21
|
||||||
@ -81,4 +93,74 @@ public class PersonalDataAccessLogSearchVO implements Serializable {
|
|||||||
|
|
||||||
public int getTotalRecordCount() { return totalRecordCount; }
|
public int getTotalRecordCount() { return totalRecordCount; }
|
||||||
public void setTotalRecordCount(int totalRecordCount) { this.totalRecordCount = 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}까지 포함한다.
|
||||||
|
*
|
||||||
|
* <p>이 보정이 없으면 종료일 당일 기록이 전부 빠진다 — 화면 안내가 {@code yyyy-MM-dd} 입력을
|
||||||
|
* 권장하므로 반드시 밟게 되는 함정이었다.</p>
|
||||||
|
*/
|
||||||
|
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 %} 한 글자로 전건이 조회된다.
|
||||||
|
*
|
||||||
|
* <p>이스케이프 문자로 백슬래시가 아닌 {@code !}를 쓴다. MySQL 문자열 리터럴에서 {@code '\'}는
|
||||||
|
* 뒤따르는 따옴표를 이스케이프해 SQL 자체가 깨지므로 {@code ESCAPE '\'}를 쓸 수 없고,
|
||||||
|
* MySQL만 {@code '\\'}로 적으면 Oracle 쌍과 표현이 갈린다. {@code !}는 두 방언 모두 동일하다.</p>
|
||||||
|
*/
|
||||||
|
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); }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,8 @@ public class AuthorChangeHistoryVO implements Serializable {
|
|||||||
private String trgetUniqId;
|
private String trgetUniqId;
|
||||||
/** 변경 대상자 계정ID */
|
/** 변경 대상자 계정ID */
|
||||||
private String trgetId;
|
private String trgetId;
|
||||||
|
/** 변경 대상자 성명 — COMTNEMPLYRINFO 조인 표기용(비영속) */
|
||||||
|
private String trgetNm;
|
||||||
/** 변경구분 G:부여 R:회수 M:변경 */
|
/** 변경구분 G:부여 R:회수 M:변경 */
|
||||||
private String chgSeCode;
|
private String chgSeCode;
|
||||||
private String beforeAuthor;
|
private String beforeAuthor;
|
||||||
@ -54,6 +56,8 @@ public class AuthorChangeHistoryVO implements Serializable {
|
|||||||
public void setTrgetUniqId(String trgetUniqId) { this.trgetUniqId = trgetUniqId; }
|
public void setTrgetUniqId(String trgetUniqId) { this.trgetUniqId = trgetUniqId; }
|
||||||
public String getTrgetId() { return trgetId; }
|
public String getTrgetId() { return trgetId; }
|
||||||
public void setTrgetId(String trgetId) { this.trgetId = 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 String getChgSeCode() { return chgSeCode; }
|
||||||
public void setChgSeCode(String chgSeCode) { this.chgSeCode = chgSeCode; }
|
public void setChgSeCode(String chgSeCode) { this.chgSeCode = chgSeCode; }
|
||||||
public String getBeforeAuthor() { return beforeAuthor; }
|
public String getBeforeAuthor() { return beforeAuthor; }
|
||||||
|
|||||||
@ -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.
|
||||||
|
*
|
||||||
|
* <p>개인정보보호 기능개선 사업(SFR-001 / 개발항목 #6). statement 는
|
||||||
|
* {@code PrivacyAuthorHistDownloadJob_SQL_Mysql.xml}
|
||||||
|
* (namespace {@code AuthorHistDownloadJobDAO}). 신규 sqlMap 이므로
|
||||||
|
* {@code sql-map-config-mysql-0-site.xml} 등록이 필요하다.</p>
|
||||||
|
*
|
||||||
|
* @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<AuthorHistDownloadJobVO> selectJobListByRequester(String requesterId, int limitCnt) {
|
||||||
|
Map<String, Object> param = new HashMap<String, Object>();
|
||||||
|
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<AuthorHistDownloadJobVO> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
package egovframework.com.cmm.privacy.author;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 권한변경 이력 CSV 비동기 다운로드 작업 서비스 (SFR-001 / 개발항목 #6).
|
||||||
|
*
|
||||||
|
* <p>요청은 작업만 등록하고 즉시 반환한다. 파일 생성은
|
||||||
|
* {@link AuthorHistDownloadJobWorker} 가 백그라운드에서 맡는다.</p>
|
||||||
|
*
|
||||||
|
* @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<AuthorHistDownloadJobVO> 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;
|
||||||
|
}
|
||||||
@ -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).
|
||||||
|
*
|
||||||
|
* <p><b>보관 최소화:</b> 생성 파일은 취급자 권한 변동 내역이 담긴 개인정보 파일이라 오래 두지 않는다.
|
||||||
|
* {@link #expireDays}(기본 3일) 경과분은 파기 배치가 파일을 지우고 상태를 X 로 바꾼다.
|
||||||
|
* 권한이력 본체의 보관기간(기본 1,095일)과는 무관한 '내려받기용 사본'의 수명이다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>권한 재검증:</b> 내려받기는 <u>요청자 본인만</u> 가능하다. 작업ID는 연속 정수라
|
||||||
|
* 추측이 쉬우므로, 조회 단계에서 요청자ID 일치를 확인하지 않으면 남의 파일이 새어나간다.</p>
|
||||||
|
*
|
||||||
|
* @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<AuthorHistDownloadJobVO> 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<AuthorHistDownloadJobVO> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,148 @@
|
|||||||
|
package egovframework.com.cmm.privacy.author;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 권한변경 이력 CSV 비동기 다운로드 작업(AUTHOR_HIST_DOWNLOAD_JOB) VO.
|
||||||
|
*
|
||||||
|
* <p>개인정보보호 기능개선 사업(SFR-001 / 개발항목 #6) — 대용량 다운로드를 요청 즉시 응답하고
|
||||||
|
* 백그라운드에서 파일을 만들기 위한 작업 한 건. 접속기록 쪽
|
||||||
|
* {@code AccessLogDownloadJobVO} 와 같은 패턴이며 검색조건 필드만 다르다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>검색조건을 컬럼으로 보존하는 이유:</b> 백그라운드 워커는 요청 세션이 끝난 뒤 실행되므로
|
||||||
|
* 조건을 스스로 재현해야 한다. 담는 값은 식별자·코드·기간뿐이다.</p>
|
||||||
|
*
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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).
|
||||||
|
*
|
||||||
|
* <p>대기(W) 작업을 하나씩 집어 CSV 파일을 만든다. <b>단일 워커 스레드</b>다 —
|
||||||
|
* 대량 조회가 동시에 여러 건 돌면 DB 부하가 그대로 서비스 지연이 되기 때문이다.
|
||||||
|
* 접속기록 워커({@code AccessLogDownloadJobWorker})와 같은 구조이며 조회 대상만 다르다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>메모리를 쓰지 않는 방식:</b> 전체를 List 로 올리지 않고 {@link #CHUNK_ROWS}건씩
|
||||||
|
* 끊어 조회하며 곧바로 파일에 쓴다. 종전 동기 다운로드(전량 추출)의 OOM 위험을 없앤다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>파일은 암호화해 둔다:</b> 취급자 권한 변동 내역이 담긴 개인정보 파일이라 평문 CSV 를
|
||||||
|
* 디스크에 남기지 않는다. {@link EgovFileScrty#encryptFile}로 암호화한 뒤 평문 임시파일을 지우고,
|
||||||
|
* 내려받을 때 복호화해 스트리밍한다. 저장 위치는 반드시 <b>웹 루트 밖</b>이어야 한다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>기동 시 고아 복구:</b> 파일 생성 중 WAS 가 죽으면 작업이 R 로 굳는다. 기동 시
|
||||||
|
* R → W 로 되돌려 재시도한다(화면에 '생성중'이 영원히 멈춰 보이는 것 방지).</p>
|
||||||
|
*
|
||||||
|
* @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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 생성 파일 저장 디렉터리. <b>반드시 웹 루트(webapps) 밖</b>이어야 한다.
|
||||||
|
* 접속기록 비동기 다운로드와 같은 설정값을 공유한다. 미설정 시 기동 로그에 경고를 남기고
|
||||||
|
* 워커를 띄우지 않는다 — 임의 경로에 개인정보 파일을 만드는 것보다 기능을 멈추는 편이 안전하다.
|
||||||
|
*/
|
||||||
|
@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<AuthorChangeHistoryVO> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -21,4 +21,18 @@ public interface AuthorHistoryService {
|
|||||||
* @return 추출된 이력 목록(파일 생성은 화면/컨트롤러 계층에서)
|
* @return 추출된 이력 목록(파일 생성은 화면/컨트롤러 계층에서)
|
||||||
*/
|
*/
|
||||||
List<AuthorChangeHistoryVO> downloadHistory(AuthorChangeHistoryVO searchVo, AuthorHistDownloadVO download) throws Exception;
|
List<AuthorChangeHistoryVO> 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,4 +48,17 @@ public class AuthorHistoryServiceImpl implements AuthorHistoryService {
|
|||||||
|
|
||||||
return list;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,38 +38,38 @@ public class AuthorWorkflowController {
|
|||||||
@Resource(name = "authorHistoryService")
|
@Resource(name = "authorHistoryService")
|
||||||
private AuthorHistoryService authorHistoryService;
|
private AuthorHistoryService authorHistoryService;
|
||||||
|
|
||||||
|
/** 권한이력 CSV 비동기 다운로드 작업(#6) */
|
||||||
|
@Resource(name = "authorHistDownloadJobService")
|
||||||
|
private AuthorHistDownloadJobService authorHistDownloadJobService;
|
||||||
|
|
||||||
private LoginVO loginUser(HttpServletRequest request) {
|
private LoginVO loginUser(HttpServletRequest request) {
|
||||||
Object o = request.getSession().getAttribute("loginVO");
|
Object o = request.getSession().getAttribute("loginVO");
|
||||||
return (o instanceof LoginVO) ? (LoginVO) o : null;
|
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<AuthorChangeRequestVO> 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
|
* 뷰: /WEB-INF/jsp/site/mnt/sec/authorRequest/list.jsp
|
||||||
*/
|
*/
|
||||||
@RequestMapping(value = "/mnt/sec/authorRequest/listView.do")
|
@RequestMapping(value = "/mnt/sec/authorRequest/listView.do")
|
||||||
public String requestListView(@ModelAttribute("authorChangeRequestVO") AuthorChangeRequestVO searchVO,
|
public String userAuthorListView(@ModelAttribute("privacyUserAuthorVO") PrivacyUserAuthorVO searchVO,
|
||||||
ModelMap model) throws Exception {
|
ModelMap model) throws Exception {
|
||||||
PaginationInfo paginationInfo = new PaginationInfo();
|
PaginationInfo paginationInfo = new PaginationInfo();
|
||||||
paginationInfo.setCurrentPageNo(searchVO.getPageIndex());
|
paginationInfo.setCurrentPageNo(searchVO.getPageIndex());
|
||||||
paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage() < 1 ? 10 : searchVO.getRecordCountPerPage());
|
paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage() < 1 ? 10 : searchVO.getRecordCountPerPage());
|
||||||
paginationInfo.setPageSize(10);
|
paginationInfo.setPageSize(10);
|
||||||
|
|
||||||
// selectRequestList 내부에서 firstIndex/totalRecordCount 를 세팅한다.
|
// selectUserAuthorList 내부에서 firstIndex/totalRecordCount 를 세팅한다.
|
||||||
List<AuthorChangeRequestVO> list = authorWorkflowService.selectRequestList(searchVO);
|
List<PrivacyUserAuthorVO> list = authorWorkflowService.selectUserAuthorList(searchVO);
|
||||||
paginationInfo.setTotalRecordCount(searchVO.getTotalRecordCount());
|
paginationInfo.setTotalRecordCount(searchVO.getTotalRecordCount());
|
||||||
|
|
||||||
model.addAttribute("resultList", list);
|
model.addAttribute("resultList", list);
|
||||||
model.addAttribute("paginationInfo", paginationInfo);
|
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";
|
return "site/mnt/sec/authorRequest/list";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -163,7 +163,7 @@ public class AuthorWorkflowController {
|
|||||||
*/
|
*/
|
||||||
@RequestMapping(value = "/mnt/sec/authorHistory/listView.do")
|
@RequestMapping(value = "/mnt/sec/authorHistory/listView.do")
|
||||||
public String historyListView(@ModelAttribute("authorChangeHistoryVO") AuthorChangeHistoryVO searchVO,
|
public String historyListView(@ModelAttribute("authorChangeHistoryVO") AuthorChangeHistoryVO searchVO,
|
||||||
ModelMap model) throws Exception {
|
HttpServletRequest request, ModelMap model) throws Exception {
|
||||||
PaginationInfo paginationInfo = new PaginationInfo();
|
PaginationInfo paginationInfo = new PaginationInfo();
|
||||||
paginationInfo.setCurrentPageNo(searchVO.getPageIndex());
|
paginationInfo.setCurrentPageNo(searchVO.getPageIndex());
|
||||||
paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage() < 1 ? 10 : searchVO.getRecordCountPerPage());
|
paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage() < 1 ? 10 : searchVO.getRecordCountPerPage());
|
||||||
@ -175,10 +175,166 @@ public class AuthorWorkflowController {
|
|||||||
|
|
||||||
model.addAttribute("resultList", list);
|
model.addAttribute("resultList", list);
|
||||||
model.addAttribute("paginationInfo", paginationInfo);
|
model.addAttribute("paginationInfo", paginationInfo);
|
||||||
|
// 내 다운로드 작업 목록(비동기) — 상태·진행률 확인 및 내려받기
|
||||||
|
model.addAttribute("dlJobList", selectMyDownloadJobs(request));
|
||||||
return "site/mnt/sec/authorHistory/list";
|
return "site/mnt/sec/authorHistory/list";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 권한 변경 이력 다운로드 — 사유 필수 + 다운로드 이력 적재 + CSV 파일 스트리밍 */
|
/**
|
||||||
|
* 내 권한이력 다운로드 작업의 <b>상태만</b> 돌려주는 폴링 전용 엔드포인트.
|
||||||
|
*
|
||||||
|
* <p>접속기록 화면과 같은 이유다 — 진행률을 보려고 목록 화면을 5초마다 다시 열면
|
||||||
|
* 이 화면이 접속기록 수집 화이트리스트({@code /mnt/sec/**}) 안이라 <u>새로고침마다 감사로그 1행</u>이
|
||||||
|
* 쌓인다. 상태 조회를 별도 경로로 빼고 {@code excludePatterns} 로 수집에서 제외한다.
|
||||||
|
* 본문에는 작업ID·상태·진행률·건수·실패사유만 담으며 본인 작업으로 한정한다.</p>
|
||||||
|
*/
|
||||||
|
@RequestMapping(value = "/mnt/sec/authorHistory/jobStatus.do")
|
||||||
|
public void historyJobStatus(HttpServletRequest request, 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 = loginUser(request);
|
||||||
|
if (user == null) {
|
||||||
|
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder(256);
|
||||||
|
List<AuthorHistDownloadJobVO> list = authorHistDownloadJobService.selectMyJobs(user.getId(), 10);
|
||||||
|
if (list != null) {
|
||||||
|
for (AuthorHistDownloadJobVO j : list) {
|
||||||
|
sb.append(j.getJobId()).append('|')
|
||||||
|
.append(j.getStatus() == null ? "" : j.getStatus()).append('|')
|
||||||
|
.append(j.getProgressPct()).append('|')
|
||||||
|
.append(j.getDoneCnt()).append('|')
|
||||||
|
.append(j.getTotalCnt()).append('|')
|
||||||
|
.append(jobStatusOneLine(j.getErrMsg())).append('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.getWriter().write(sb.toString());
|
||||||
|
response.getWriter().flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 구분자·개행이 섞이면 화면 파싱이 깨지므로 한 줄로 눌러 담는다. */
|
||||||
|
private String jobStatusOneLine(String v) {
|
||||||
|
if (v == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return v.replace('|', '/').replace('\r', ' ').replace('\n', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 권한이력 다운로드 <b>요청</b>(비동기) — 작업만 등록하고 즉시 목록으로 돌아온다.
|
||||||
|
*
|
||||||
|
* <p>종전 동기 방식({@link #historyDownload})은 조건 전체를 List 로 메모리에 올려
|
||||||
|
* OOM·요청 타임아웃 위험이 있었다. 파일 생성은 백그라운드 워커가 맡는다.
|
||||||
|
* 사유는 여기서도 필수이며, 미입력이면 작업을 만들지 않는다.</p>
|
||||||
|
*/
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 완료된 권한이력 다운로드 파일 내려받기 — <b>요청자 본인만</b> 가능하다.
|
||||||
|
*
|
||||||
|
* <p>저장 파일은 암호화돼 있어 복호화 후 스트리밍한다. 임시 복호화본은 전송 직후 지운다.
|
||||||
|
* 실제 내려받은 시점의 사유·건수 이력은 기존 {@code AUTHOR_HIST_DOWNLOAD_HISTORY} 에 적재한다.</p>
|
||||||
|
*/
|
||||||
|
@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<AuthorHistDownloadJobVO> selectMyDownloadJobs(HttpServletRequest request) {
|
||||||
|
try {
|
||||||
|
LoginVO user = loginUser(request);
|
||||||
|
if (user == null) {
|
||||||
|
return new java.util.ArrayList<AuthorHistDownloadJobVO>();
|
||||||
|
}
|
||||||
|
List<AuthorHistDownloadJobVO> list = authorHistDownloadJobService.selectMyJobs(user.getId(), 10);
|
||||||
|
return (list == null) ? new java.util.ArrayList<AuthorHistDownloadJobVO>() : list;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return new java.util.ArrayList<AuthorHistDownloadJobVO>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 권한 변경 이력 다운로드 — 동기 방식(기존). 사유 필수 + 다운로드 이력 적재 + CSV 파일 스트리밍.
|
||||||
|
*
|
||||||
|
* <p><b>주의:</b> 조건 전체를 메모리에 올린다. 화면은 위 {@link #historyDownloadRequest}
|
||||||
|
* 비동기 경로를 쓴다. 이 엔드포인트는 소량 즉시 다운로드와 기존 링크 호환을 위해 남겨둔다.</p>
|
||||||
|
*/
|
||||||
@RequestMapping(value = "/mnt/sec/authorHistory/download.do")
|
@RequestMapping(value = "/mnt/sec/authorHistory/download.do")
|
||||||
public void historyDownload(AuthorChangeHistoryVO searchVO,
|
public void historyDownload(AuthorChangeHistoryVO searchVO,
|
||||||
@RequestParam(value = "dwldResn", required = false) String dwldResn,
|
@RequestParam(value = "dwldResn", required = false) String dwldResn,
|
||||||
@ -211,10 +367,12 @@ public class AuthorWorkflowController {
|
|||||||
OutputStream os = response.getOutputStream();
|
OutputStream os = response.getOutputStream();
|
||||||
os.write(0xEF); os.write(0xBB); os.write(0xBF); // UTF-8 BOM (엑셀 한글 호환)
|
os.write(0xEF); os.write(0xBB); os.write(0xBF); // UTF-8 BOM (엑셀 한글 호환)
|
||||||
PrintWriter pw = new PrintWriter(new OutputStreamWriter(os, "UTF-8"));
|
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) {
|
if (list != null) {
|
||||||
for (AuthorChangeHistoryVO h : list) {
|
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.getBeforeAuthor()) + "," + csv(h.getAfterAuthor()) + "," + csv(h.getChgResn())
|
||||||
+ "," + csv(h.getAprovlId()) + "," + csv(h.getChangerId()) + "," + csv(h.getChangerIp()));
|
+ "," + csv(h.getAprovlId()) + "," + csv(h.getChangerId()) + "," + csv(h.getChangerIp()));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,18 +30,6 @@ public class AuthorWorkflowDAO extends EgovComAbstractDAO {
|
|||||||
return (AuthorChangeRequestVO) selectByPk("AuthorWorkflowDAO.selectRequest", Long.valueOf(requestId));
|
return (AuthorChangeRequestVO) selectByPk("AuthorWorkflowDAO.selectRequest", Long.valueOf(requestId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 신청 목록 조회(상태/대상 검색 + 페이징) */
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
public List<AuthorChangeRequestVO> 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) */
|
/** 신청 상태 갱신(승인 A / 반려 J) */
|
||||||
public void updateRequestStatus(AuthorChangeRequestVO vo) {
|
public void updateRequestStatus(AuthorChangeRequestVO vo) {
|
||||||
update("AuthorWorkflowDAO.updateRequestStatus", vo);
|
update("AuthorWorkflowDAO.updateRequestStatus", vo);
|
||||||
@ -77,4 +65,28 @@ public class AuthorWorkflowDAO extends EgovComAbstractDAO {
|
|||||||
public egovframework.rte.psl.dataaccess.util.EgovMap selectTargetByLoginId(String trgetId) {
|
public egovframework.rte.psl.dataaccess.util.EgovMap selectTargetByLoginId(String trgetId) {
|
||||||
return (egovframework.rte.psl.dataaccess.util.EgovMap) selectByPk("AuthorWorkflowDAO.selectTargetByLoginId", trgetId);
|
return (egovframework.rte.psl.dataaccess.util.EgovMap) selectByPk("AuthorWorkflowDAO.selectTargetByLoginId", trgetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 개인정보취급자 권한 목록(소속기관·역할·접근범위·권한상태·최근변경일 + 페이징) */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public List<PrivacyUserAuthorVO> 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<egovframework.rte.psl.dataaccess.util.EgovMap> selectOrgnztList() {
|
||||||
|
return list("AuthorWorkflowDAO.selectOrgnztList", null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,12 +10,6 @@ public interface AuthorWorkflowService {
|
|||||||
/** 권한 변경 신청 등록(상태 R) */
|
/** 권한 변경 신청 등록(상태 R) */
|
||||||
void submitRequest(AuthorChangeRequestVO vo) throws Exception;
|
void submitRequest(AuthorChangeRequestVO vo) throws Exception;
|
||||||
|
|
||||||
/** 신청 목록 조회 */
|
|
||||||
List<AuthorChangeRequestVO> selectRequestList(AuthorChangeRequestVO vo) throws Exception;
|
|
||||||
|
|
||||||
/** 신청 목록 건수 */
|
|
||||||
int selectRequestListCount(AuthorChangeRequestVO vo) throws Exception;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 승인(A) — 권한 반영(COMTNEMPLYRSCRTYESTBS)과 이력 적재(AUTHOR_CHANGE_HISTORY)를
|
* 승인(A) — 권한 반영(COMTNEMPLYRSCRTYESTBS)과 이력 적재(AUTHOR_CHANGE_HISTORY)를
|
||||||
* 단일 트랜잭션으로 처리한다.
|
* 단일 트랜잭션으로 처리한다.
|
||||||
@ -34,4 +28,13 @@ public interface AuthorWorkflowService {
|
|||||||
|
|
||||||
/** 대상자 계정ID → 고유식별자·성명 자동조회 (없으면 null) */
|
/** 대상자 계정ID → 고유식별자·성명 자동조회 (없으면 null) */
|
||||||
egovframework.rte.psl.dataaccess.util.EgovMap selectTargetByLoginId(String trgetId) throws Exception;
|
egovframework.rte.psl.dataaccess.util.EgovMap selectTargetByLoginId(String trgetId) throws Exception;
|
||||||
|
|
||||||
|
/** 개인정보취급자 권한 목록 조회 — firstIndex/totalRecordCount 를 vo 에 세팅한다 */
|
||||||
|
List<PrivacyUserAuthorVO> selectUserAuthorList(PrivacyUserAuthorVO vo) throws Exception;
|
||||||
|
|
||||||
|
/** 목록 상단 요약용 전체 승인대기 건수 */
|
||||||
|
int selectPendingRequestCount() throws Exception;
|
||||||
|
|
||||||
|
/** 검색조건 드롭다운용 소속기관 목록 */
|
||||||
|
List<egovframework.rte.psl.dataaccess.util.EgovMap> selectOrgnztList() throws Exception;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,21 +31,6 @@ public class AuthorWorkflowServiceImpl implements AuthorWorkflowService {
|
|||||||
authorWorkflowDAO.insertRequest(vo);
|
authorWorkflowDAO.insertRequest(vo);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<AuthorChangeRequestVO> 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
|
@Override
|
||||||
public void approveRequest(long requestId, String confmId, String confmUniqId, String confmIp) throws Exception {
|
public void approveRequest(long requestId, String confmId, String confmUniqId, String confmIp) throws Exception {
|
||||||
AuthorChangeRequestVO req = authorWorkflowDAO.selectRequest(requestId);
|
AuthorChangeRequestVO req = authorWorkflowDAO.selectRequest(requestId);
|
||||||
@ -119,6 +104,26 @@ public class AuthorWorkflowServiceImpl implements AuthorWorkflowService {
|
|||||||
return authorWorkflowDAO.selectTargetByLoginId(trgetId.trim());
|
return authorWorkflowDAO.selectTargetByLoginId(trgetId.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<PrivacyUserAuthorVO> 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<egovframework.rte.psl.dataaccess.util.EgovMap> selectOrgnztList() throws Exception {
|
||||||
|
return authorWorkflowDAO.selectOrgnztList();
|
||||||
|
}
|
||||||
|
|
||||||
/** 신청구분(N/C/R) → 이력 변경구분(G:부여/M:변경/R:회수) */
|
/** 신청구분(N/C/R) → 이력 변경구분(G:부여/M:변경/R:회수) */
|
||||||
private String mapChgSeCode(String requestSe) {
|
private String mapChgSeCode(String requestSe) {
|
||||||
if ("R".equals(requestSe)) {
|
if ("R".equals(requestSe)) {
|
||||||
|
|||||||
@ -0,0 +1,116 @@
|
|||||||
|
package egovframework.com.cmm.privacy.author;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개인정보취급자 권한 관리 목록 VO (개인정보보호 SFR-001 / #3 화면 #46).
|
||||||
|
*
|
||||||
|
* <p>취급자(COMTNEMPLYRINFO) 1행 = 목록 1행. 소속기관(COMTNORGNZTINFO),
|
||||||
|
* 역할·접근범위(COMTNEMPLYRSCRTYESTBS + COMTNAUTHORINFO), 권한상태(대기신청/권한유무),
|
||||||
|
* 최근변경일(AUTHOR_CHANGE_HISTORY)을 조인해 한 화면에 제공한다.</p>
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* 개정이력
|
||||||
|
* 2026.08.07 URP 최초 생성 — 신청목록 화면을 취급자 권한현황 화면으로 재구성
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
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; }
|
||||||
|
}
|
||||||
@ -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).
|
||||||
|
*
|
||||||
|
* <p>{@code COMTNLOGINLOG} 적재분(접속ID·접속일시·접속IP·접속위치)을 조건별로 검색하고
|
||||||
|
* CSV 로 내려받는다. 화면: {@code /WEB-INF/jsp/site/mnt/sec/conectLog/list.jsp}</p>
|
||||||
|
*
|
||||||
|
* <p><b>다운로드:</b> 사유 입력이 필수이며 미입력 시 400 으로 차단하고 파일을 열지 않는다.
|
||||||
|
* 다운로드자·일시·IP·사유·건수는 {@code PDAL_DOWNLOAD_HISTORY}(#42 재사용)에 적재된다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>접근통제:</b> 인터셉터 3단 중 ①인증판정이 비활성이고 ②③은 fail-open 이라
|
||||||
|
* /mnt 하위라도 무인증 노출이 가능하므로, {@link #handlerUser()} 로 화면·엔드포인트
|
||||||
|
* 단에서 직접 판정한다(PersonalDataAccessLogController 와 동일 방식).</p>
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* << 개정이력(Modification Information) >>
|
||||||
|
* 수정일 수정자 수정내용
|
||||||
|
* ---------- ------ ---------------------------
|
||||||
|
* 2026.08.07 URP 최초 생성 (개인정보보호 #62)
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
@Controller
|
||||||
|
public class ConectLogController {
|
||||||
|
|
||||||
|
private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do";
|
||||||
|
|
||||||
|
@Resource(name = "conectLogService")
|
||||||
|
private ConectLogService conectLogService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인·로그아웃 접속기록 조회 화면(서버 렌더링).
|
||||||
|
* 뷰: /WEB-INF/jsp/site/mnt/sec/conectLog/list.jsp
|
||||||
|
*/
|
||||||
|
@RequestMapping(value = "/mnt/sec/conectLog/listView.do")
|
||||||
|
public String listView(@ModelAttribute("conectLogVO") ConectLogVO searchVO,
|
||||||
|
ModelMap model) throws Exception {
|
||||||
|
if (handlerUser() == null) {
|
||||||
|
return LOGIN_REDIRECT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectConectLogList 내부에서 firstIndex/totalRecordCount 를 세팅한다.
|
||||||
|
List<ConectLogVO> list = conectLogService.selectConectLogList(searchVO);
|
||||||
|
|
||||||
|
PaginationInfo paginationInfo = new PaginationInfo();
|
||||||
|
paginationInfo.setCurrentPageNo(searchVO.getPageIndex());
|
||||||
|
paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage());
|
||||||
|
paginationInfo.setPageSize(10);
|
||||||
|
paginationInfo.setTotalRecordCount(searchVO.getTotalRecordCount());
|
||||||
|
|
||||||
|
model.addAttribute("resultList", list);
|
||||||
|
model.addAttribute("paginationInfo", paginationInfo);
|
||||||
|
model.addAttribute("conectLcList", selectConectLcList());
|
||||||
|
return "site/mnt/sec/conectLog/list";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 접속기록 목록(AJAX/JSON). */
|
||||||
|
@RequestMapping(value = "/mnt/privacy/conectLogList.do")
|
||||||
|
public String selectList(@ModelAttribute("searchVO") ConectLogVO searchVO,
|
||||||
|
ModelMap model) throws Exception {
|
||||||
|
if (handlerUser() == null) {
|
||||||
|
model.addAttribute("result", "fail");
|
||||||
|
model.addAttribute("message", "접근 권한이 없습니다. 관리자 계정으로 로그인 후 이용하세요.");
|
||||||
|
return "jsonView";
|
||||||
|
}
|
||||||
|
model.addAttribute("resultList", conectLogService.selectConectLogList(searchVO));
|
||||||
|
model.addAttribute("totalCount", Integer.valueOf(searchVO.getTotalRecordCount()));
|
||||||
|
return "jsonView";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 접속기록 다운로드(CSV) — 사유 필수 + 다운로드 이력 적재.
|
||||||
|
* 사유 미입력·차단 시 파일을 열지 않고 400 으로 응답한다.
|
||||||
|
*/
|
||||||
|
@RequestMapping(value = "/mnt/sec/conectLog/download.do")
|
||||||
|
public void download(@ModelAttribute("conectLogVO") ConectLogVO searchVO,
|
||||||
|
@RequestParam(value = "dlReason", required = false) String dlReason,
|
||||||
|
HttpServletRequest request, HttpServletResponse response) throws Exception {
|
||||||
|
|
||||||
|
LoginVO user = handlerUser();
|
||||||
|
if (user == null) {
|
||||||
|
response.sendError(HttpServletResponse.SC_FORBIDDEN, "접근 권한이 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ConectLogVO> list;
|
||||||
|
try {
|
||||||
|
list = conectLogService.downloadConectLog(user.getId(),
|
||||||
|
ClientIpResolver.getClientIp(request), dlReason, searchVO);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
// 사유 미입력 등 차단 — 다운로드를 열지 않고 오류만 응답
|
||||||
|
response.sendError(HttpServletResponse.SC_BAD_REQUEST, e.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String fileName = URLEncoder.encode(
|
||||||
|
"로그인접속기록_" + new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) + ".csv", "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");
|
||||||
|
|
||||||
|
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,성명,사용자구분,접속구분,접속IP,접속위치");
|
||||||
|
if (list != null) {
|
||||||
|
for (ConectLogVO log : list) {
|
||||||
|
pw.println(csv(log.getLogId()) + "," + csv(log.getCreatDt())
|
||||||
|
+ "," + csv(log.getUserId()) + "," + csv(log.getUserNm())
|
||||||
|
+ "," + csv(userSeLabel(log.getUserSe()))
|
||||||
|
+ "," + csv(mthdLabel(log.getConectMthd()))
|
||||||
|
+ "," + csv(log.getConectIp()) + "," + csv(log.getConectLc()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pw.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 검색조건 드롭다운용 접속위치 목록.
|
||||||
|
* 조회 실패해도 화면은 떠야 하므로 빈 목록으로 fail-safe.
|
||||||
|
*/
|
||||||
|
private List<String> selectConectLcList() {
|
||||||
|
try {
|
||||||
|
List<String> list = conectLogService.selectConectLcList();
|
||||||
|
return (list == null) ? new ArrayList<String>() : list;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return new ArrayList<String>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CSV 셀 이스케이프(구분자·따옴표·개행 방어). */
|
||||||
|
private String csv(String v) {
|
||||||
|
if (v == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String s = v.replace("\"", "\"\"");
|
||||||
|
return "\"" + s.replace("\r", " ").replace("\n", " ") + "\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String mthdLabel(String code) {
|
||||||
|
if ("I".equals(code)) return "로그인";
|
||||||
|
if ("O".equals(code)) return "로그아웃";
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String userSeLabel(String code) {
|
||||||
|
if ("USR".equals(code)) return "업무사용자";
|
||||||
|
if ("GNR".equals(code)) return "개인";
|
||||||
|
if ("GRO".equals(code)) return "그룹";
|
||||||
|
if ("ENT".equals(code)) return "활동처";
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 취급자 영역(/mnt) 인증·구분 판정.
|
||||||
|
*
|
||||||
|
* @return 인증된 업무사용자(USR) LoginVO, 그 외에는 null
|
||||||
|
*/
|
||||||
|
private LoginVO handlerUser() {
|
||||||
|
if (!Boolean.TRUE.equals(EgovUserDetailsHelper.isAuthenticated())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Object o = EgovUserDetailsHelper.getAuthenticatedUser();
|
||||||
|
if (!(o instanceof LoginVO)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
LoginVO loginVO = (LoginVO) o;
|
||||||
|
return "USR".equals(loginVO.getUserSe()) ? loginVO : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
package egovframework.com.cmm.privacy.conect;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import egovframework.com.cmm.service.impl.EgovComAbstractDAO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인·로그아웃 접속기록 DAO — 개인정보보호 기능개선 사업(#62).
|
||||||
|
*
|
||||||
|
* <p>{@code COMTNLOGINLOG} 조회 전용. 이 화면은 접속기록에 대한 수정·삭제를
|
||||||
|
* 제공하지 않는다(위·변조 방지) — UPDATE/DELETE statement 를 정의하지 않는다.
|
||||||
|
* statement 는 {@code PrivacyConectLog_SQL_Mysql.xml}
|
||||||
|
* (namespace {@code ConectLogDAO}), sql-map-config 등록.</p>
|
||||||
|
*
|
||||||
|
* @author URP
|
||||||
|
* @since 2026-08-07
|
||||||
|
*/
|
||||||
|
@Repository("conectLogDAO")
|
||||||
|
public class ConectLogDAO extends EgovComAbstractDAO {
|
||||||
|
|
||||||
|
/** 조건별 목록(페이징). */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public List<ConectLogVO> selectConectLogList(ConectLogVO searchVO) {
|
||||||
|
return list("ConectLogDAO.selectConectLogList", searchVO);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 조건별 총건수. */
|
||||||
|
public int selectConectLogListCount(ConectLogVO searchVO) {
|
||||||
|
Object cnt = selectByPk("ConectLogDAO.selectConectLogListCount", searchVO);
|
||||||
|
return (cnt == null) ? 0 : ((Integer) cnt).intValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 다운로드용 목록(페이징 없음, 상한은 서비스에서 건다). */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public List<ConectLogVO> selectConectLogListForDownload(ConectLogVO searchVO) {
|
||||||
|
return list("ConectLogDAO.selectConectLogListForDownload", searchVO);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 검색조건 드롭다운용 접속위치 목록.
|
||||||
|
*
|
||||||
|
* <p>설정 대역(CONECT_IP_LOCATION)과 <b>실제 적재된 값</b>(COMTNLOGINLOG DISTINCT)을
|
||||||
|
* 합친다. #32-1 에서 겪은 함정 — 매핑 테이블만 보면 실제 적재값과 어긋난 선택지가 떠
|
||||||
|
* 검색 결과가 항상 0건이 된다 — 을 피하기 위한 것이다.</p>
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public List<String> selectConectLcList() {
|
||||||
|
return list("ConectLogDAO.selectConectLcList", null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package egovframework.com.cmm.privacy.conect;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인·로그아웃 접속기록 조회·다운로드 서비스 — 개인정보보호 기능개선 사업(#62).
|
||||||
|
*
|
||||||
|
* @author URP
|
||||||
|
* @since 2026-08-07
|
||||||
|
*/
|
||||||
|
public interface ConectLogService {
|
||||||
|
|
||||||
|
/** 조건별 접속기록 목록(페이징). searchVO 에 firstIndex/totalRecordCount 를 채워 돌려준다. */
|
||||||
|
List<ConectLogVO> selectConectLogList(ConectLogVO searchVO) throws Exception;
|
||||||
|
|
||||||
|
/** 검색조건 드롭다운용 접속위치 목록. */
|
||||||
|
List<String> selectConectLcList() throws Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 접속기록 다운로드 — <b>사유 필수</b>.
|
||||||
|
*
|
||||||
|
* @param downloaderId 다운로드자 계정ID
|
||||||
|
* @param downloaderIp 다운로드자 접속 IP
|
||||||
|
* @param dlReason 다운로드 사유(공백이면 차단)
|
||||||
|
* @param searchVO 검색조건
|
||||||
|
* @return 다운로드 대상 목록
|
||||||
|
* @throws IllegalArgumentException 사유 미입력
|
||||||
|
*/
|
||||||
|
List<ConectLogVO> downloadConectLog(String downloaderId, String downloaderIp,
|
||||||
|
String dlReason, ConectLogVO searchVO) throws Exception;
|
||||||
|
}
|
||||||
@ -0,0 +1,122 @@
|
|||||||
|
package egovframework.com.cmm.privacy.conect;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import egovframework.com.cmm.privacy.auditlog.AccessLogDownloadHistoryDAO;
|
||||||
|
import egovframework.com.cmm.privacy.auditlog.AccessLogDownloadHistoryVO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인·로그아웃 접속기록 조회·다운로드 서비스 구현 — 개인정보보호 기능개선 사업(#62).
|
||||||
|
*
|
||||||
|
* <p><b>다운로드 이력:</b> 별도 테이블을 만들지 않고 {@code PDAL_DOWNLOAD_HISTORY}(#42)를
|
||||||
|
* 재사용한다. 어떤 화면에서 받아간 것인지 구분되도록 검색조건 요약 앞에
|
||||||
|
* {@code [로그인기록]} 을 붙인다. 개인정보 본문은 이력에 남기지 않는다(검색조건 요약만).</p>
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* << 개정이력(Modification Information) >>
|
||||||
|
* 수정일 수정자 수정내용
|
||||||
|
* ---------- ------ ---------------------------
|
||||||
|
* 2026.08.07 URP 최초 생성 (개인정보보호 #62)
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
@Service("conectLogService")
|
||||||
|
public class ConectLogServiceImpl implements ConectLogService {
|
||||||
|
|
||||||
|
/** 1회 다운로드 상한(건). 무제한 반출·OOM 방지. */
|
||||||
|
private static final int DOWNLOAD_LIMIT = 100000;
|
||||||
|
|
||||||
|
/** 다운로드 이력 구분 표식(PDAL_DOWNLOAD_HISTORY 공용 사용). */
|
||||||
|
private static final String DL_TAG = "[로그인기록] ";
|
||||||
|
|
||||||
|
@Resource(name = "conectLogDAO")
|
||||||
|
private ConectLogDAO conectLogDAO;
|
||||||
|
|
||||||
|
@Resource(name = "accessLogDownloadHistoryDAO")
|
||||||
|
private AccessLogDownloadHistoryDAO accessLogDownloadHistoryDAO;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ConectLogVO> selectConectLogList(ConectLogVO searchVO) throws Exception {
|
||||||
|
normalize(searchVO);
|
||||||
|
searchVO.setFirstIndex((searchVO.getPageIndex() - 1) * searchVO.getRecordCountPerPage());
|
||||||
|
searchVO.setTotalRecordCount(conectLogDAO.selectConectLogListCount(searchVO));
|
||||||
|
return conectLogDAO.selectConectLogList(searchVO);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> selectConectLcList() throws Exception {
|
||||||
|
List<String> list = conectLogDAO.selectConectLcList();
|
||||||
|
return (list == null) ? new ArrayList<String>() : list;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ConectLogVO> downloadConectLog(String downloaderId, String downloaderIp,
|
||||||
|
String dlReason, ConectLogVO searchVO) throws Exception {
|
||||||
|
|
||||||
|
// 사유 미입력이면 조회 자체를 하지 않는다 — 이력 없는 반출을 원천 차단.
|
||||||
|
if (dlReason == null || dlReason.trim().length() == 0) {
|
||||||
|
throw new IllegalArgumentException("다운로드 사유는 필수입니다.");
|
||||||
|
}
|
||||||
|
normalize(searchVO);
|
||||||
|
searchVO.setFirstIndex(0);
|
||||||
|
searchVO.setRecordCountPerPage(DOWNLOAD_LIMIT);
|
||||||
|
|
||||||
|
List<ConectLogVO> list = conectLogDAO.selectConectLogListForDownload(searchVO);
|
||||||
|
if (list == null) {
|
||||||
|
list = new ArrayList<ConectLogVO>();
|
||||||
|
}
|
||||||
|
|
||||||
|
AccessLogDownloadHistoryVO history = new AccessLogDownloadHistoryVO();
|
||||||
|
history.setDownloaderId(downloaderId);
|
||||||
|
history.setDownloaderIp(downloaderIp);
|
||||||
|
history.setDlReason(dlReason.trim());
|
||||||
|
history.setSearchCond(summarize(searchVO));
|
||||||
|
history.setResultCnt(list.size());
|
||||||
|
accessLogDownloadHistoryDAO.insertDownloadHistory(history);
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 페이징 파라미터 방어(음수·0·과대 요청). */
|
||||||
|
private void normalize(ConectLogVO searchVO) {
|
||||||
|
if (searchVO.getPageIndex() < 1) {
|
||||||
|
searchVO.setPageIndex(1);
|
||||||
|
}
|
||||||
|
if (searchVO.getRecordCountPerPage() < 1 || searchVO.getRecordCountPerPage() > 100) {
|
||||||
|
searchVO.setRecordCountPerPage(10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 검색조건 요약(개인정보 본문 미포함). PDAL_DOWNLOAD_HISTORY.SEARCH_COND 는
|
||||||
|
* VARCHAR(1000) 이므로 넘치면 자른다.
|
||||||
|
*/
|
||||||
|
private String summarize(ConectLogVO vo) {
|
||||||
|
StringBuilder sb = new StringBuilder(DL_TAG);
|
||||||
|
append(sb, "접속ID", vo.getSearchUserId());
|
||||||
|
append(sb, "접속구분", vo.getSearchMthd());
|
||||||
|
append(sb, "사용자구분", vo.getSearchUserSe());
|
||||||
|
append(sb, "IP", vo.getSearchIp());
|
||||||
|
append(sb, "접속위치", vo.getSearchLc());
|
||||||
|
append(sb, "기간", nvl(vo.getSearchBgnDe()) + "~" + nvl(vo.getSearchEndDe()));
|
||||||
|
String s = sb.toString();
|
||||||
|
return (s.length() > 1000) ? s.substring(0, 1000) : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void append(StringBuilder sb, String label, String value) {
|
||||||
|
if (value != null && value.trim().length() > 0 && !"~".equals(value.trim())) {
|
||||||
|
if (sb.length() > DL_TAG.length()) {
|
||||||
|
sb.append(", ");
|
||||||
|
}
|
||||||
|
sb.append(label).append('=').append(value.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String nvl(String s) {
|
||||||
|
return (s == null) ? "" : s.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,115 @@
|
|||||||
|
package egovframework.com.cmm.privacy.conect;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인·로그아웃 접속기록 VO — 개인정보보호 기능개선 사업(#62).
|
||||||
|
*
|
||||||
|
* <p>{@code COMTNLOGINLOG} 조회 결과 + 검색조건 + 페이징.</p>
|
||||||
|
*
|
||||||
|
* <p><b>접속ID 주의:</b> {@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} 를 대신 보여준다.</p>
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* << 개정이력(Modification Information) >>
|
||||||
|
* 수정일 수정자 수정내용
|
||||||
|
* ---------- ------ ---------------------------
|
||||||
|
* 2026.08.07 URP 최초 생성 (개인정보보호 #62)
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
public class ConectLogVO implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
// ---- 조회 결과 ----
|
||||||
|
/** 로그 일련번호(COMTNLOGINLOG.LOG_ID). */
|
||||||
|
private String logId;
|
||||||
|
/** 접속일시(yyyy-MM-dd HH:mm:ss). */
|
||||||
|
private String creatDt;
|
||||||
|
/** 접속구분 I:로그인 O:로그아웃. */
|
||||||
|
private String conectMthd;
|
||||||
|
/** 접속 IP. */
|
||||||
|
private String conectIp;
|
||||||
|
/** 접속위치명(적재시점 스냅샷). */
|
||||||
|
private String conectLc;
|
||||||
|
/** 고유식별자(원본 CONECT_ID). */
|
||||||
|
private String conectId;
|
||||||
|
/** 접속ID(계정ID — CONECT_ACNT_ID. 2026-08-07 이전 레거시 기록은 null). */
|
||||||
|
private String userId;
|
||||||
|
/** 성명/기관명(표시용 보조 조회 — 탈퇴 등으로 대조 실패 시 null). */
|
||||||
|
private String userNm;
|
||||||
|
/** 사용자구분 USR:업무사용자 GNR:개인 GRO:그룹 ENT:활동처. */
|
||||||
|
private String userSe;
|
||||||
|
/** 오류발생여부 Y/N. */
|
||||||
|
private String errOccrrAt;
|
||||||
|
|
||||||
|
// ---- 검색조건 ----
|
||||||
|
/** 접속ID(계정ID, 부분일치). */
|
||||||
|
private String searchUserId;
|
||||||
|
/** 접속구분(I/O, 공백=전체). */
|
||||||
|
private String searchMthd;
|
||||||
|
/** 사용자구분(USR/GNR/GRO/ENT, 공백=전체). */
|
||||||
|
private String searchUserSe;
|
||||||
|
/** 접속 IP(부분일치). */
|
||||||
|
private String searchIp;
|
||||||
|
/** 접속위치(완전일치, 공백=전체). */
|
||||||
|
private String searchLc;
|
||||||
|
/** 조회 시작일(yyyy-MM-dd). */
|
||||||
|
private String searchBgnDe;
|
||||||
|
/** 조회 종료일(yyyy-MM-dd). */
|
||||||
|
private String searchEndDe;
|
||||||
|
|
||||||
|
// ---- 페이징 ----
|
||||||
|
private int pageIndex = 1;
|
||||||
|
private int recordCountPerPage = 10;
|
||||||
|
private int firstIndex = 0;
|
||||||
|
private int totalRecordCount = 0;
|
||||||
|
|
||||||
|
public String getLogId() { return logId; }
|
||||||
|
public void setLogId(String logId) { this.logId = logId; }
|
||||||
|
public String getCreatDt() { return creatDt; }
|
||||||
|
public void setCreatDt(String creatDt) { this.creatDt = creatDt; }
|
||||||
|
public String getConectMthd() { return conectMthd; }
|
||||||
|
public void setConectMthd(String conectMthd) { this.conectMthd = conectMthd; }
|
||||||
|
public String getConectIp() { return conectIp; }
|
||||||
|
public void setConectIp(String conectIp) { this.conectIp = conectIp; }
|
||||||
|
public String getConectLc() { return conectLc; }
|
||||||
|
public void setConectLc(String conectLc) { this.conectLc = conectLc; }
|
||||||
|
public String getConectId() { return conectId; }
|
||||||
|
public void setConectId(String conectId) { this.conectId = conectId; }
|
||||||
|
public String getUserId() { return userId; }
|
||||||
|
public void setUserId(String userId) { this.userId = userId; }
|
||||||
|
public String getUserNm() { return userNm; }
|
||||||
|
public void setUserNm(String userNm) { this.userNm = userNm; }
|
||||||
|
public String getUserSe() { return userSe; }
|
||||||
|
public void setUserSe(String userSe) { this.userSe = userSe; }
|
||||||
|
public String getErrOccrrAt() { return errOccrrAt; }
|
||||||
|
public void setErrOccrrAt(String errOccrrAt) { this.errOccrrAt = errOccrrAt; }
|
||||||
|
|
||||||
|
public String getSearchUserId() { return searchUserId; }
|
||||||
|
public void setSearchUserId(String searchUserId) { this.searchUserId = searchUserId; }
|
||||||
|
public String getSearchMthd() { return searchMthd; }
|
||||||
|
public void setSearchMthd(String searchMthd) { this.searchMthd = searchMthd; }
|
||||||
|
public String getSearchUserSe() { return searchUserSe; }
|
||||||
|
public void setSearchUserSe(String searchUserSe) { this.searchUserSe = searchUserSe; }
|
||||||
|
public String getSearchIp() { return searchIp; }
|
||||||
|
public void setSearchIp(String searchIp) { this.searchIp = searchIp; }
|
||||||
|
public String getSearchLc() { return searchLc; }
|
||||||
|
public void setSearchLc(String searchLc) { this.searchLc = searchLc; }
|
||||||
|
public String getSearchBgnDe() { return searchBgnDe; }
|
||||||
|
public void setSearchBgnDe(String searchBgnDe) { this.searchBgnDe = searchBgnDe; }
|
||||||
|
public String getSearchEndDe() { return searchEndDe; }
|
||||||
|
public void setSearchEndDe(String searchEndDe) { this.searchEndDe = searchEndDe; }
|
||||||
|
|
||||||
|
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; }
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
package egovframework.com.cmm.privacy.conect;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import egovframework.com.cmm.service.impl.EgovComAbstractDAO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 접속위치 매핑 DAO — 개인정보보호 기능개선 사업(#62).
|
||||||
|
*
|
||||||
|
* <p>statement 는 {@code PrivacyConectLog_SQL_Mysql.xml}
|
||||||
|
* (namespace {@code ConectLogDAO})에 함께 정의되어 있다.</p>
|
||||||
|
*
|
||||||
|
* @author URP
|
||||||
|
* @since 2026-08-07
|
||||||
|
*/
|
||||||
|
@Repository("ipLocationDAO")
|
||||||
|
public class IpLocationDAO extends EgovComAbstractDAO {
|
||||||
|
|
||||||
|
/** 사용중(USE_AT='Y')인 IP 대역 매핑 전체. */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public List<IpLocationVO> selectUsedLocationList() {
|
||||||
|
return list("ConectLogDAO.selectUsedLocationList", null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,189 @@
|
|||||||
|
package egovframework.com.cmm.privacy.conect;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IP → 접속위치명 판정기 — 개인정보보호 기능개선 사업(#62).
|
||||||
|
*
|
||||||
|
* <p>{@code CONECT_IP_LOCATION}(IP 대역 → 위치명)을 캐시해 두고, 접속 IP 가 속하는
|
||||||
|
* 대역 중 <b>가장 긴 프리픽스</b> 1건의 위치명을 돌려준다. 매칭이 없으면
|
||||||
|
* 사설/루프백 대역 판정에 따른 내장 폴백을 쓴다 — 테이블이 비어 있어도 동작한다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>왜 스냅샷인가:</b> 판정 결과는 적재 시점에
|
||||||
|
* {@code COMTNLOGINLOG.CONECT_LC} 로 기록된다. 나중에 대역 매핑을 바꿔도 과거
|
||||||
|
* 기록의 접속위치는 변하지 않아야 감사증빙으로 쓸 수 있다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>fail-safe:</b> 이 판정은 로그인/로그아웃 경로에서 호출된다. 어떤 예외도
|
||||||
|
* 밖으로 던지지 않고 폴백 값을 돌려준다 — 접속위치 때문에 로그인이 막히면 안 된다.</p>
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* << 개정이력(Modification Information) >>
|
||||||
|
* 수정일 수정자 수정내용
|
||||||
|
* ---------- ------ ---------------------------
|
||||||
|
* 2026.08.07 URP 최초 생성 (개인정보보호 #62)
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
@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<IpLocationVO> cache = new ArrayList<IpLocationVO>();
|
||||||
|
/** 마지막 적재 시각(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<IpLocationVO> currentCache() {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (loadedAt != 0L && (now - loadedAt) < RELOAD_INTERVAL_MS) {
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
List<IpLocationVO> loaded = ipLocationDAO.selectUsedLocationList();
|
||||||
|
if (loaded == null) {
|
||||||
|
loaded = new ArrayList<IpLocationVO>();
|
||||||
|
}
|
||||||
|
for (IpLocationVO loc : loaded) {
|
||||||
|
parseCidr(loc);
|
||||||
|
}
|
||||||
|
Collections.sort(loaded, new Comparator<IpLocationVO>() {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
package egovframework.com.cmm.privacy.conect;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 접속위치 매핑 VO — 개인정보보호 기능개선 사업(#62, 로그인·로그아웃 접속기록).
|
||||||
|
*
|
||||||
|
* <p>{@code CONECT_IP_LOCATION} 1행. IP 대역(CIDR)과 그 대역에 붙일 위치명이다.
|
||||||
|
* 접속기록 적재 시점에 {@link IpLocationResolver}가 가장 긴 프리픽스 1건을 골라
|
||||||
|
* {@code COMTNLOGINLOG.CONECT_LC}에 스냅샷으로 남긴다 — 나중에 매핑을 바꿔도
|
||||||
|
* 과거 기록의 위치는 변하지 않는다(감사증빙).</p>
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* << 개정이력(Modification Information) >>
|
||||||
|
* 수정일 수정자 수정내용
|
||||||
|
* ---------- ------ ---------------------------
|
||||||
|
* 2026.08.07 URP 최초 생성 (개인정보보호 #62)
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
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; }
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 개인정보 항목을 <b>가역 암호화(AES-128/GCM)</b>하여 저장하고, 표시 시점에만 복호화한다.
|
||||||
|
*
|
||||||
|
* <p><b>대상:</b> 1단계는 일반회원 이름({@code COMTNGNRLMBER.MBER_NM}, 실측 77,053건).
|
||||||
|
* 이메일·휴대폰·주소는 동일 클래스를 재사용해 후속 단계에서 확장한다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>랜덤 IV를 쓰는 이유:</b> 같은 이름이라도 매번 다른 암호문이 나온다. 결정적 암호(고정 IV)를
|
||||||
|
* 쓰면 암호문만 보고 "이 두 회원은 동명이인"이라는 사실이 새어나가고, 빈도분석으로 흔한 이름부터
|
||||||
|
* 역추적된다. 대신 <u>검색이 불가능해지므로</u> 완전일치 검색은
|
||||||
|
* {@link MemberDataHasher}의 해시 컬럼이 전담한다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>저장 형식:</b> {@code Base64( IV(12) || ciphertext || GCM tag(16) )}.
|
||||||
|
* GCM은 무결성 태그를 포함하므로 저장된 암호문이 변조되면 복호화 시 예외가 발생한다 —
|
||||||
|
* 위·변조 탐지가 덤으로 따라온다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>키 관리 — 이 클래스는 개발용 폴백을 두지 않는다.</b>
|
||||||
|
* 기존 {@code OtpSecretCipher}는 키 미설정 시 소스에 박힌 개발용 키로 폴백하는데, 회원정보에
|
||||||
|
* 그 방식을 승계하면 "암호화했으나 키가 소스에 있는 상태"가 되어 관리실태 점검 지적이 재발한다.
|
||||||
|
* 따라서 키가 없으면 {@link IllegalStateException}으로 <b>기동을 실패시킨다</b>. 조용히 약한
|
||||||
|
* 암호로 돌아가는 것보다 뜨는 편이 안전하다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>키 설정:</b> 시스템 프로퍼티 {@code arko.mber.master.key} 또는 환경변수
|
||||||
|
* {@code ARKO_MBER_MASTER_KEY}에 <b>Base64 인코딩된 16바이트</b> 키를 넣는다.
|
||||||
|
* WAR 안에 두지 않는다(서버 {@code setenv.sh} 또는 퍼미션 600 키파일).</p>
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* # 키 생성 예시
|
||||||
|
* openssl rand -base64 16
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p><b>키 길이가 AES-128인 이유:</b> AES-256은 구형 JDK에서 JCE 무제한 정책 파일을 요구한다.
|
||||||
|
* 운영 JDK가 8u161 이상이면 무제한이 기본이므로 상향 가능하나, 운영 JDK 확인 전까지는
|
||||||
|
* {@code OtpSecretCipher}와 동일하게 128로 맞춰 배포 리스크를 없앤다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>스레드 안전:</b> {@link Cipher}는 매 호출마다 새로 얻는다(재사용 불가). 이 클래스 자체는
|
||||||
|
* 불변이므로 싱글턴 빈으로 공유해도 된다.</p>
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마스터 키를 로드한다.
|
||||||
|
* <b>폴백 없음</b> — 미설정·형식오류는 예외다.
|
||||||
|
*/
|
||||||
|
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 문자열로 반환한다.
|
||||||
|
*
|
||||||
|
* <p>{@code null}은 {@code null}로, 빈 문자열은 빈 문자열로 그대로 돌려준다.
|
||||||
|
* arko는 회원 탈퇴 시 개인정보 컬럼을 빈 문자열로 채우는 로직이 있어
|
||||||
|
* ({@code EgovMberManage_SQL_Mysql.xml}) NULL과 ''를 구분해 보존해야 파기 상태가 뭉개지지 않는다.</p>
|
||||||
|
*
|
||||||
|
* @param plain 평문(null/빈문자 허용)
|
||||||
|
* @return 암호문 Base64. 입력이 null이면 null, 빈문자면 빈문자
|
||||||
|
*/
|
||||||
|
public String encrypt(String plain) throws Exception {
|
||||||
|
if (plain == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (plain.length() == 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
byte[] iv = new byte[GCM_IV_BYTES];
|
||||||
|
random.nextBytes(iv);
|
||||||
|
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(masterKey, "AES"),
|
||||||
|
new GCMParameterSpec(GCM_TAG_BITS, iv));
|
||||||
|
byte[] ct = cipher.doFinal(plain.getBytes("UTF-8"));
|
||||||
|
byte[] out = new byte[iv.length + ct.length];
|
||||||
|
System.arraycopy(iv, 0, out, 0, iv.length);
|
||||||
|
System.arraycopy(ct, 0, out, iv.length, ct.length);
|
||||||
|
return new String(Base64.encodeBase64(out), "US-ASCII");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link #encrypt(String)} 결과를 복호화한다.
|
||||||
|
*
|
||||||
|
* <p>암호문이 변조됐거나 다른 키로 만든 것이면 GCM 태그 검증에 실패해 예외가 난다.
|
||||||
|
* 호출측은 이를 "복호화 실패"로 처리하고 <b>평문을 화면에 대체 출력하지 않는다</b>.</p>
|
||||||
|
*
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 암호화된 개인정보 항목의 <b>완전일치 검색</b>을 위한 키드 해시를 만든다.
|
||||||
|
*
|
||||||
|
* <p><b>왜 필요한가:</b> {@link MemberDataCipher}는 랜덤 IV라 같은 이름도 매번 다른 암호문이 된다.
|
||||||
|
* 따라서 암호문으로는 검색할 수 없다. 검색 전용으로 <u>결정적인</u> 값을 별도 컬럼
|
||||||
|
* ({@code MBER_NM_HASH})에 두고 인덱스를 건다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>왜 순수 SHA-256이 아니라 HMAC인가:</b> 제안서 문구는 "SHA-256"이지만, 이름·휴대폰번호는
|
||||||
|
* 엔트로피가 극히 낮다. 한국인 이름은 사실상 수십만 가지, 휴대폰번호는 1억 미만이라 순수 해시는
|
||||||
|
* 전수대입 한 번으로 원문이 전부 복원된다 — 해시를 걸어도 평문이나 마찬가지가 된다.
|
||||||
|
* 비밀 pepper를 섞는 HMAC이면 pepper를 모르는 공격자는 사전을 만들 수 없다.
|
||||||
|
* <b>제안 범위보다 강화되는 방향</b>이므로 사업상 문제가 없고 점검 대응력이 올라간다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>정규화가 해시 일치의 전제다.</b> 같은 값이 다른 해시로 갈리면 검색이 조용히 실패한다.
|
||||||
|
* 항목별 정규화 규칙을 이 클래스 한 곳에만 두고, 저장 시점과 검색 시점이 <u>반드시 같은
|
||||||
|
* 메서드</u>를 쓰게 한다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>pepper 설정:</b> 시스템 프로퍼티 {@code arko.mber.hash.pepper} 또는 환경변수
|
||||||
|
* {@code ARKO_MBER_HASH_PEPPER}. 마스터 키와 <b>다른 값</b>이어야 한다(하나가 유출돼도
|
||||||
|
* 다른 하나는 살아 있게). 미설정 시 폴백 없이 실패한다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>pepper는 교체할 수 없다.</b> 바꾸면 저장된 해시 전체를 재계산해야 한다
|
||||||
|
* (원본 복호화 → 재해시 배치). 처음에 충분히 긴 값으로 정하고 고정한다.</p>
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <b>이름</b> 검색용 해시. 정규화는 앞뒤 공백 제거만 한다.
|
||||||
|
*
|
||||||
|
* <p>가운데 공백은 지우지 않는다 — "남궁 민수"와 "남궁민수"는 실제로 다르게 등록된
|
||||||
|
* 별개 표기이며, 임의로 합치면 저장 시점과 관리자 검색 시점의 정규화가 어긋날 여지가 커진다.
|
||||||
|
* 소문자화도 하지 않는다(한글에 무의미하고 영문 이름의 대소문자는 유의미할 수 있다).</p>
|
||||||
|
*
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <b>이메일</b> 검색용 해시. 앞뒤 공백 제거 + 소문자화.
|
||||||
|
* (후속 단계용 — 이름 단계에서는 사용하지 않는다.)
|
||||||
|
*/
|
||||||
|
public String hashEmail(String email) {
|
||||||
|
if (email == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String norm = email.trim().toLowerCase();
|
||||||
|
if (norm.length() == 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return hmacHex(norm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <b>휴대폰번호</b> 검색용 해시. 숫자 이외 문자를 모두 제거한다.
|
||||||
|
* ({@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 → 진행상태 기록.
|
||||||
|
*
|
||||||
|
* <p><b>평문 컬럼은 절대 UPDATE하지 않는다.</b> 이 DAO의 update문은 {@code MBER_NM_ENC} /
|
||||||
|
* {@code MBER_NM_HASH} / {@code CRYPTO_KEY_VER}만 건드린다. 평문이 그대로 남아 있어야
|
||||||
|
* ① 재실행 시 미처리 판정이 가능하고 ② 읽기 전환 전까지 롤백 창구가 유지된다.</p>
|
||||||
|
*
|
||||||
|
* @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<MemberNmCryptoVO> selectChunk(String lastKey, int chunkSize) {
|
||||||
|
Map<String, Object> param = new HashMap<String, Object>();
|
||||||
|
param.put("lastKey", lastKey);
|
||||||
|
param.put("chunkSize", Integer.valueOf(chunkSize));
|
||||||
|
return list("MemberNmCryptoDAO.selectChunk", param);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 청크 한 덩어리를 <b>단일 배치</b>로 UPDATE한다.
|
||||||
|
*
|
||||||
|
* <p>건별 executeUpdate를 77,053번 왕복하면 네트워크 라운드트립이 지배적이 된다.
|
||||||
|
* iBatis2 배치로 묶으면 한 번의 왕복으로 처리되어 수 분이 수십 초로 줄어든다.</p>
|
||||||
|
*
|
||||||
|
* @return 실제 반영 건수
|
||||||
|
*/
|
||||||
|
public int updateEncryptedBatch(final List<MemberNmCryptoVO> 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<String, Object> param = new HashMap<String, Object>();
|
||||||
|
param.put("jobId", JOB_ID);
|
||||||
|
insert("MemberNmCryptoDAO.insertProgressIfAbsent", param);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 청크 완료 시 커서·누적건수를 갱신한다. */
|
||||||
|
public void updateProgress(String lastKey, int doneDelta, int errDelta) {
|
||||||
|
Map<String, Object> param = new HashMap<String, Object>();
|
||||||
|
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<String, Object> param = new HashMap<String, Object>();
|
||||||
|
param.put("jobId", JOB_ID);
|
||||||
|
param.put("jobSttus", status);
|
||||||
|
param.put("rm", remark);
|
||||||
|
update("MemberNmCryptoDAO.updateStatus", param);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 진행상태 1건 조회(화면 표시·중지요청 확인용). */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public Map<String, Object> selectProgress() {
|
||||||
|
Map<String, Object> param = new HashMap<String, Object>();
|
||||||
|
param.put("jobId", JOB_ID);
|
||||||
|
return (Map<String, Object>) selectByPk("MemberNmCryptoDAO.selectProgress", param);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 검증용 집계 — 평문 보유 건수와 암호문 보유 건수, 그리고 누락 건수.
|
||||||
|
* 누락(평문 있는데 암호문 없음)이 0이어야 이관 완료다.
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public Map<String, Object> selectVerifyCounts() {
|
||||||
|
return (Map<String, Object>) selectByPk("MemberNmCryptoDAO.selectVerifyCounts", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 검증용 — 암호화된 행을 커서 기준으로 청크 조회(복호화 대사에 사용). */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public List<MemberNmCryptoVO> selectVerifyChunk(String lastKey, int chunkSize) {
|
||||||
|
Map<String, Object> param = new HashMap<String, Object>();
|
||||||
|
param.put("lastKey", lastKey);
|
||||||
|
param.put("chunkSize", Integer.valueOf(chunkSize));
|
||||||
|
return list("MemberNmCryptoDAO.selectVerifyChunk", param);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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와 이름 관련 컬럼만 담는다.
|
||||||
|
*
|
||||||
|
* <p>회원 전체 컬럼(40여 개)을 끌고 오지 않는 이유는 두 가지다. 이관 배치가 다루는 개인정보를
|
||||||
|
* 최소화해야 하고(필요 최소 수집), 77,053건을 청크로 훑을 때 불필요한 I/O를 줄여야 한다.</p>
|
||||||
|
*/
|
||||||
|
public class MemberNmCryptoVO implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원ID — COMTNGNRLMBER의 PK이자 청크 커서 키.
|
||||||
|
*
|
||||||
|
* <p><b>실측 주의:</b> 이 컬럼의 DB 타입은 {@code VARCHAR}가 아니라 <b>{@code VARBINARY(32)}</b>이며,
|
||||||
|
* 77,053건 중 39건(LVMS 연계 계정 {@code ...@lvms})에 비인쇄 바이트가 들어 있다.
|
||||||
|
* 다만 전건 감사 결과 <b>UTF-8 문자열 왕복이 무손실</b>임을 확인했으므로(왕복 실패 0건)
|
||||||
|
* String 매핑을 유지한다.</p>
|
||||||
|
*
|
||||||
|
* <p>혹시 변환이 어긋나더라도 실패 양상은 안전하다 — UPDATE가 0건이 되어 해당 행의
|
||||||
|
* {@code MBER_NM_ENC}가 NULL로 남고, 검증 단계의 누락 건수에 그대로 잡힌다.
|
||||||
|
* 데이터가 잘못 덮어써지는 경우는 발생하지 않는다.</p>
|
||||||
|
*/
|
||||||
|
private String mberId;
|
||||||
|
|
||||||
|
/** 평문 이름(읽기 전용. 배치는 이 컬럼을 갱신하지 않는다). */
|
||||||
|
private String mberNm;
|
||||||
|
|
||||||
|
/** 이름 암호문 Base64. */
|
||||||
|
private String mberNmEnc;
|
||||||
|
|
||||||
|
/** 이름 검색용 HMAC-SHA256 hex. */
|
||||||
|
private String mberNmHash;
|
||||||
|
|
||||||
|
/** 암호화 키 버전. */
|
||||||
|
private Integer cryptoKeyVer;
|
||||||
|
|
||||||
|
public String getMberId() {
|
||||||
|
return mberId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMberId(String mberId) {
|
||||||
|
this.mberId = mberId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMberNm() {
|
||||||
|
return mberNm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMberNm(String mberNm) {
|
||||||
|
this.mberNm = mberNm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그·디버깅용. <b>이름 값은 절대 포함하지 않는다</b> —
|
||||||
|
* 이관 로그에 평문이 남으면 암호화의 의미가 사라진다.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "MemberNmCryptoVO[mberId=" + mberId
|
||||||
|
+ ", encSet=" + (mberNmEnc != null)
|
||||||
|
+ ", hashSet=" + (mberNmHash != null)
|
||||||
|
+ ", keyVer=" + cryptoKeyVer + "]";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,309 @@
|
|||||||
|
/**
|
||||||
|
* Class Name : MemberNmCryptoWorker.java
|
||||||
|
* Description : 회원 이름 암호화 백필/검증 배치 — 개인정보보호 기능개선 사업(추가제안 Action4)
|
||||||
|
*
|
||||||
|
* Modification Information
|
||||||
|
*
|
||||||
|
* 수정일 수정자 수정내용
|
||||||
|
* ------- -------- ---------------------------
|
||||||
|
* 2026.08.10 개인정보보호 최초 생성
|
||||||
|
*
|
||||||
|
* @since 2026. 08. 10
|
||||||
|
* @version 1.0
|
||||||
|
*/
|
||||||
|
package egovframework.com.cmm.privacy.crypto;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미 적재된 회원 이름을 일괄 암호화하는 백필 배치와, 그 결과를 대사하는 검증 배치.
|
||||||
|
*
|
||||||
|
* <p><b>대상 규모(2026-08-10 실측):</b> {@code COMTNGNRLMBER} 77,053건 / 39.6MB /
|
||||||
|
* PK {@code MBER_ID} 단일. 1,000건 청크 기준 78회 순회이며 실측 소요는 수십 초~수 분이다.
|
||||||
|
* 야간 점검창구가 필요한 규모가 아니다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>중단·재개 설계:</b> 청크마다 {@code MIG_CRYPTO_PROGRESS}에 커서를 남긴다. 그러나
|
||||||
|
* 재개의 진짜 근거는 커서가 아니라 <b>{@code MBER_NM_ENC IS NULL} 조건</b>이다 —
|
||||||
|
* 진행상태 테이블이 통째로 날아가도 처음부터 다시 돌리면 이미 처리된 행은 건너뛴다.
|
||||||
|
* 커서는 "다시 훑는 비용"을 줄이는 최적화일 뿐 정합성의 근거가 아니다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>왜 상주 스케줄러에 넣지 않는가:</b> 1회성 이관 작업이다. {@code BatchScheduler}에
|
||||||
|
* 등록하면 이관이 끝난 뒤에도 매 주기 깨어나 빈 조회를 반복하고, 실수로 재실행될 여지가 남는다.
|
||||||
|
* 관리자가 명시적으로 시작·중지하는 형태가 맞다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>평문을 로그에 남기지 않는다.</b> 이 클래스의 모든 로그는 건수·회원ID까지만 남긴다.
|
||||||
|
* 이관 로그에 이름이 찍히면 암호화를 하고도 평문이 파일로 새어 나간다.</p>
|
||||||
|
*
|
||||||
|
* @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<String, Object> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 백필 본체. 커서 페이징으로 미암호화 행을 청크 단위로 암호화한다.
|
||||||
|
*
|
||||||
|
* <p>한 건이 실패해도 청크 전체를 버리지 않는다 — 실패 행만 건너뛰고 계속한다.
|
||||||
|
* 실패 건은 {@code MBER_NM_ENC}가 NULL로 남으므로 다음 실행에서 자연히 재시도된다.</p>
|
||||||
|
*/
|
||||||
|
void runBackfill(int chunkSize, long sleepMs) {
|
||||||
|
long started = System.currentTimeMillis();
|
||||||
|
int totalDone = 0;
|
||||||
|
int totalErr = 0;
|
||||||
|
try {
|
||||||
|
ensureCrypto();
|
||||||
|
dao.insertProgressIfAbsent();
|
||||||
|
dao.updateStatus(STTUS_RUNNING, "백필 시작 chunk=" + chunkSize);
|
||||||
|
|
||||||
|
int remaining = dao.selectRemainingCount();
|
||||||
|
LOG.info("[개인정보/암호화] 이름 백필 시작 — 대상 {}건, chunk={}, sleep={}ms",
|
||||||
|
new Object[] { Integer.valueOf(remaining), Integer.valueOf(chunkSize), Long.valueOf(sleepMs) });
|
||||||
|
|
||||||
|
String lastKey = null;
|
||||||
|
while (true) {
|
||||||
|
if (stopRequested) {
|
||||||
|
dao.updateStatus(STTUS_STOP, "관리자 중지 요청 — 처리 " + totalDone + "건");
|
||||||
|
LOG.info("[개인정보/암호화] 이름 백필 중지 — 누적 {}건", Integer.valueOf(totalDone));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<MemberNmCryptoVO> rows = dao.selectChunk(lastKey, chunkSize);
|
||||||
|
if (rows == null || rows.isEmpty()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<MemberNmCryptoVO> ready = new ArrayList<MemberNmCryptoVO>(rows.size());
|
||||||
|
for (int i = 0; i < rows.size(); i++) {
|
||||||
|
MemberNmCryptoVO row = rows.get(i);
|
||||||
|
lastKey = row.getMberId();
|
||||||
|
try {
|
||||||
|
// NULL/빈문자 구분 보존은 cipher/hasher가 담당한다.
|
||||||
|
// arko는 탈퇴 시 개인정보 컬럼을 빈 문자열로 채우므로 ''를 NULL로 뭉개면
|
||||||
|
// 파기 상태가 사라진다.
|
||||||
|
row.setMberNmEnc(cipher.encrypt(row.getMberNm()));
|
||||||
|
row.setMberNmHash(hasher.hashName(row.getMberNm()));
|
||||||
|
row.setCryptoKeyVer(Integer.valueOf(MemberDataCipher.CURRENT_KEY_VER));
|
||||||
|
ready.add(row);
|
||||||
|
} catch (Exception e) {
|
||||||
|
totalErr++;
|
||||||
|
// 회원ID만 남긴다 — 이름은 절대 로그에 쓰지 않는다.
|
||||||
|
LOG.error("[개인정보/암호화] 행 암호화 실패(건너뜀) mberId=" + row.getMberId(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int updated = 0;
|
||||||
|
if (!ready.isEmpty()) {
|
||||||
|
updated = dao.updateEncryptedBatch(ready);
|
||||||
|
}
|
||||||
|
totalDone += updated;
|
||||||
|
dao.updateProgress(lastKey, updated, 0);
|
||||||
|
|
||||||
|
if (LOG.isInfoEnabled()) {
|
||||||
|
LOG.info("[개인정보/암호화] 청크 완료 — 누적 {}건, 실패 {}건, cursor={}",
|
||||||
|
new Object[] { Integer.valueOf(totalDone), Integer.valueOf(totalErr), lastKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sleepMs > 0) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(sleepMs);
|
||||||
|
} catch (InterruptedException ie) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
dao.updateStatus(STTUS_STOP, "인터럽트 — 처리 " + totalDone + "건");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
long elapsed = System.currentTimeMillis() - started;
|
||||||
|
String msg = "백필 완료 — 처리 " + totalDone + "건, 실패 " + totalErr + "건, 소요 " + (elapsed / 1000) + "초";
|
||||||
|
dao.updateStatus(totalErr > 0 ? STTUS_ERROR : STTUS_DONE, msg);
|
||||||
|
LOG.info("[개인정보/암호화] {}", msg);
|
||||||
|
|
||||||
|
} catch (Throwable t) {
|
||||||
|
String msg = "백필 중단 — " + t.getClass().getSimpleName() + ": " + t.getMessage();
|
||||||
|
try {
|
||||||
|
dao.updateStatus(STTUS_ERROR, msg);
|
||||||
|
} catch (Exception ignore) {
|
||||||
|
LOG.error("[개인정보/암호화] 진행상태 기록 실패", ignore);
|
||||||
|
}
|
||||||
|
LOG.error("[개인정보/암호화] 이름 백필 예외 — 누적 " + totalDone + "건", t);
|
||||||
|
} finally {
|
||||||
|
this.runner = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이관 검증 — <b>전건</b> 복호화 대사.
|
||||||
|
*
|
||||||
|
* <p>77,053건 규모에서 샘플링할 이유가 없다. 전건을 복호화해 평문과 비교하고, 해시를
|
||||||
|
* 재계산해 저장값과 대조한다. 하나라도 어긋나면 읽기 전환을 해서는 안 된다.</p>
|
||||||
|
*
|
||||||
|
* <p>제안서가 요구하는 "이관 전후 건수·값 검증"의 값 검증 부분이며,
|
||||||
|
* 반환 문자열을 그대로 조치결과보고서 첨부에 쓸 수 있게 구성했다.</p>
|
||||||
|
*
|
||||||
|
* @param chunkSize 청크 크기
|
||||||
|
* @return 검증 결과 요약
|
||||||
|
*/
|
||||||
|
public String verify(int chunkSize) {
|
||||||
|
int chunk = (chunkSize < 1) ? DEFAULT_CHUNK_SIZE : chunkSize;
|
||||||
|
ensureCrypto();
|
||||||
|
|
||||||
|
Map<String, Object> counts = dao.selectVerifyCounts();
|
||||||
|
long checked = 0;
|
||||||
|
long decMismatch = 0;
|
||||||
|
long hashMismatch = 0;
|
||||||
|
long decError = 0;
|
||||||
|
|
||||||
|
String lastKey = null;
|
||||||
|
while (true) {
|
||||||
|
List<MemberNmCryptoVO> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ package egovframework.com.cmm.privacy.interceptor;
|
|||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
import java.text.SimpleDateFormat;
|
import java.text.SimpleDateFormat;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -15,8 +16,11 @@ import javax.servlet.http.HttpServletResponse;
|
|||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.web.servlet.ModelAndView;
|
||||||
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
|
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.LoginVO;
|
||||||
import egovframework.com.cmm.privacy.auditlog.AccessLogQueue;
|
import egovframework.com.cmm.privacy.auditlog.AccessLogQueue;
|
||||||
import egovframework.com.cmm.privacy.auditlog.ClientIpResolver;
|
import egovframework.com.cmm.privacy.auditlog.ClientIpResolver;
|
||||||
@ -114,14 +118,40 @@ public class MenuAccessLogInterceptor extends HandlerInterceptorAdapter {
|
|||||||
private static final int TRGET_ID_MAX = 50;
|
private static final int TRGET_ID_MAX = 50;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 처리대상 식별자 파라미터 우선순위. 식별자로 확정 가능한 이름만 둔다
|
* 처리대상 식별자 파라미터 우선순위 — <b>화이트리스트 대상 컨트롤러의 {@code @RequestParam}
|
||||||
* (searchKeyword 등 검색어는 개인정보 본문일 수 있어 절대 넣지 않는다).
|
* 실측치</b>다(추정 아님). 개인 식별자를 앞에, 단체·일련번호를 뒤에 둬서 한 요청에 여러 개가
|
||||||
|
* 실려도 "누구의 개인정보인가"가 먼저 잡히게 한다.
|
||||||
|
*
|
||||||
|
* <p><b>넣지 않는 것:</b> {@code tmpNm}(이름)·{@code searchKeyword}(검색어)처럼 값 자체가
|
||||||
|
* 개인정보 본문이거나 본문일 수 있는 파라미터. 접속기록에 본문을 담지 않는다는 원칙 때문이며,
|
||||||
|
* 식별자만으로 정보주체를 특정할 수 있으므로 추적에 부족하지 않다.</p>
|
||||||
|
*
|
||||||
|
* <p>{@code tmpId}(해당 회원ID)·{@code tmpGId}(그룹ID)·{@code tmpAppId}(신청ID)·
|
||||||
|
* {@code checkedIdForUp}(일괄 수정 대상)은 arko 관용 파라미터명이라 표준 이름만으로는 못 잡는다.
|
||||||
|
* 이들이 빠지면 신청·활동확인·일괄승인 화면의 처리대상이 통째로 비어버린다.</p>
|
||||||
*/
|
*/
|
||||||
private static final String[] TRGET_ID_PARAMS = {
|
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 목록·엑셀 화면 관용명).
|
||||||
|
* 코드목록 같은 부수 리스트를 세지 않도록 <b>명시 후보만</b> 본다.
|
||||||
|
*/
|
||||||
|
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 setMenuAuthDAO(PrivacyMenuAuthDAO menuAuthDAO) { this.menuAuthDAO = menuAuthDAO; }
|
||||||
public void setAccessLogQueue(AccessLogQueue accessLogQueue) { this.accessLogQueue = accessLogQueue; }
|
public void setAccessLogQueue(AccessLogQueue accessLogQueue) { this.accessLogQueue = accessLogQueue; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 처리 건수를 확보한다 — "몇 건의 개인정보를 열람·반출했는가".
|
||||||
|
*
|
||||||
|
* <p>서비스 AOP 시절에는 반환값이 {@code Collection}이면 크기를 셌다. 요청 인터셉터는 서비스
|
||||||
|
* 반환값을 볼 수 없으므로, 컨트롤러가 모델에 담은 목록에서 대신 얻는다. 이 값이 없으면
|
||||||
|
* 명단 조회·<b>엑셀 다운로드</b>가 "처리대상 미상"으로 남아 무엇을 반출했는지 확인할 수 없다.</p>
|
||||||
|
*
|
||||||
|
* <p>{@code postHandle}은 앞선 인터셉터가 요청을 차단하면 호출되지 않는다. 그때는 건수가
|
||||||
|
* 없는 채로 {@code afterCompletion}이 실패(F) 기록을 남긴다 — 차단된 요청에 건수가 없는 것이 옳다.</p>
|
||||||
|
*/
|
||||||
|
@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건을 남긴다.
|
* 요청 종료 시점에 접속기록 1건을 남긴다.
|
||||||
* 처리결과(성공/실패)를 판정해야 하므로 preHandle 이 아닌 afterCompletion 이다.
|
* 처리결과(성공/실패)를 판정해야 하므로 preHandle 이 아닌 afterCompletion 이다.
|
||||||
@ -558,8 +612,30 @@ public class MenuAccessLogInterceptor extends HandlerInterceptorAdapter {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 다건 선택(체크박스 등)일 때만 "N건"을 남긴다. 단건은 TRGET_ID 로 충분하다. */
|
/**
|
||||||
|
* 처리대상 범위(TRGET_CN)를 만든다 — "몇 건을 다뤘는가".
|
||||||
|
*
|
||||||
|
* <p>두 출처를 합친다.
|
||||||
|
* ① 다건 선택(체크박스 파라미터가 여러 값) → "선택 N건"
|
||||||
|
* ② 모델 목록 건수({@link #postHandle}이 담아둔 값) → "조회 N건" / "조회 N건(전체 M건)"
|
||||||
|
* 둘 다 있으면 함께 남긴다. 단건 상세는 TRGET_ID 로 특정되므로 비어도 무방하다.</p>
|
||||||
|
*/
|
||||||
private String extractTrgetCn(HttpServletRequest request) {
|
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++) {
|
for (int i = 0; i < TRGET_ID_PARAMS.length; i++) {
|
||||||
String[] values = request.getParameterValues(TRGET_ID_PARAMS[i]);
|
String[] values = request.getParameterValues(TRGET_ID_PARAMS[i]);
|
||||||
if (values == null) {
|
if (values == null) {
|
||||||
@ -572,15 +648,77 @@ public class MenuAccessLogInterceptor extends HandlerInterceptorAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (count > 1) {
|
if (count > 1) {
|
||||||
return count + "건";
|
return "선택 " + count + "건";
|
||||||
}
|
}
|
||||||
if (count == 1) {
|
if (count == 1) {
|
||||||
|
// 단일 값이지만 콤마로 여러 ID를 이어 보내는 화면이 있다(checkedIdForDel 관용).
|
||||||
|
int commas = countChar(values[0], ',');
|
||||||
|
if (commas > 0) {
|
||||||
|
return "선택 " + (commas + 1) + "건";
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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<String, Object> 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) {
|
private static boolean contains(String s, String token) {
|
||||||
|
|||||||
@ -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, 자동 접속차단).
|
||||||
|
*
|
||||||
|
* <p>「개인정보보호강화 파일럿 개발항목」 접근통제 정책 적용(Action2) 시안의
|
||||||
|
* <b>'미사용 자동 로그아웃'을 web.xml 수정·재배포 없이 관리자 설정값으로 즉시 반영</b>하는 지점이다.
|
||||||
|
* 2025.9 자료요청 3번 "시간 설정 기능 없음" 지적에 대응한다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>하는 일 두 가지</b>
|
||||||
|
* <ol>
|
||||||
|
* <li>관리자 화면({@code /mnt/}) 요청마다 로그인 세션의
|
||||||
|
* {@code maxInactiveInterval} 을 정책값(ACCESS_CONTROL_POLICY.AUTO_LOGOUT_MIN)으로 맞춘다.
|
||||||
|
* web.xml 의 {@code <session-timeout>} 은 세션 생성 시 기본값일 뿐이며, 여기서 덮어쓴 값이
|
||||||
|
* 해당 세션의 실제 타임아웃이 된다.</li>
|
||||||
|
* <li>로그인 시각({@value #ATTR_LOGIN_TIME})을 세션에 도장 찍는다. 관리자 상단(top.jsp)의
|
||||||
|
* <b>"로그인 후 경과시간"</b> 표시가 이 값을 쓴다.
|
||||||
|
* <p>로그인 경로가 여럿(actionLogin / EgovSpringSecurityLoginFilter / SSO 필터)이라
|
||||||
|
* 각 경로를 고쳐 도장을 찍는 대신, 세션에 loginVO 가 생긴 뒤 <b>첫 관리자 요청</b>에서
|
||||||
|
* 한 번만 찍는다. 로그인 직후 곧바로 관리자 화면으로 이동하므로 실제 오차는 1초 미만이고,
|
||||||
|
* 인증 경로를 건드리지 않아 회귀 위험이 없다.</p></li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p><b>적용 범위</b> — {@code applyPathPrefix}(기본 {@code /mnt/}) 이면서 세션에 loginVO 가 있을 때만
|
||||||
|
* 동작한다. 무인증 프론트(frt) 세션은 건드리지 않아 일반회원 이용에 영향이 없다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>fail-safe</b> — 정책 조회 실패는 화면 접근을 막지 않는다. 서비스가 기본값(30분)을 돌려주며,
|
||||||
|
* 서비스 미주입 시에는 아무 것도 하지 않고 통과한다(web.xml 기본값이 그대로 유효).</p>
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* << 개정이력(Modification Information) >>
|
||||||
|
* 수정일 수정자 수정내용
|
||||||
|
* ---------- ------ ---------------------------
|
||||||
|
* 2026.08.10 URP 최초 생성 (접근통제 정책 적용 — 자동 로그아웃 정책값화)
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 로그아웃까지 남은 시간(초).
|
||||||
|
*
|
||||||
|
* <p>세션 타임아웃은 <b>마지막 접근시각</b>부터 흐른다. 이 메서드는 화면 렌더 시점 기준의
|
||||||
|
* 잔여시간을 돌려주며, 화면 타이머는 이 값에서 카운트다운한다.</p>
|
||||||
|
*
|
||||||
|
* @param session 현재 세션(널이거나 타임아웃 미설정이면 0)
|
||||||
|
* @return 잔여 초(0 하한)
|
||||||
|
*/
|
||||||
|
public static long remainSec(HttpSession session) {
|
||||||
|
if (session == null) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
int timeoutSec = session.getMaxInactiveInterval();
|
||||||
|
if (timeoutSec <= 0) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
long idle = (System.currentTimeMillis() - session.getLastAccessedTime()) / 1000L;
|
||||||
|
long remain = timeoutSec - idle;
|
||||||
|
return remain < 0L ? 0L : remain;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,253 @@
|
|||||||
|
package egovframework.com.cmm.privacy.policy;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpSession;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.ui.ModelMap;
|
||||||
|
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
|
||||||
|
import egovframework.com.cmm.LoginVO;
|
||||||
|
import egovframework.com.cmm.privacy.interceptor.SessionPolicyInterceptor;
|
||||||
|
import egovframework.com.cmm.util.EgovUserDetailsHelper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 접근통제 정책 설정 컨트롤러 (SER-001 요건 4·5).
|
||||||
|
*
|
||||||
|
* <p>「개인정보보호강화 파일럿 개발항목」 접근통제 정책 적용(Action2) 시안의
|
||||||
|
* <b>접근통제 정책 설정</b> 화면을 담당한다.</p>
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code /mnt/sec/accessPolicy/policyView.do} — 정책 설정 화면(서버 렌더링)</li>
|
||||||
|
* <li>{@code /mnt/privacy/accessPolicyUpdate.do} — 정책 저장(AJAX/JSON)</li>
|
||||||
|
* <li>{@code /mnt/session/extend.do} — 세션 연장(만료 임박 안내에서 호출)</li>
|
||||||
|
* <li>{@code /mnt/session/status.do} — 로그인 경과/잔여시간 조회(상단 표시 갱신용)</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p><b>경로 선정 근거</b> — 세션 연장·상태 조회는 전체관리자 전용이 아니라
|
||||||
|
* <b>로그인한 모든 취급자</b>가 써야 한다. {@code /mnt/privacy/*}·{@code /mnt/sec/*} 는
|
||||||
|
* AuthorityInterceptor 에서 ROLE_ADMIN 전용으로 매핑돼 있어 시도·시군구 관리자가 403 을
|
||||||
|
* 맞으므로, 별도 대역 {@code /mnt/session/} 을 쓰고 두 판정 인터셉터의 예외에 등록한다.</p>
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* 수정일 수정자 수정내용
|
||||||
|
* ---------- ------ ---------------------------
|
||||||
|
* 2026.08.10 URP 최초 생성 (접근통제 정책 적용 화면)
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
@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)에 대비한 서버측 범위 검증. 위반 시 사용자 메시지 반환.
|
||||||
|
*
|
||||||
|
* <p>상한을 두는 이유: 자동 로그아웃을 사실상 무한대로 늘리거나 잠금 임계값을 크게 잡아
|
||||||
|
* 안전성 확보조치 기준 제6조를 우회하는 설정을 막는다.</p>
|
||||||
|
*/
|
||||||
|
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). 관리자 상단의 <b>로그인 경과시간·자동 로그아웃 잔여시간</b> 표시가
|
||||||
|
* 페이지 이동 없이 갱신할 때 쓴다.
|
||||||
|
*
|
||||||
|
* <p><b>주의</b> — 이 요청 자체가 세션을 갱신(lastAccessedTime 초기화)하므로,
|
||||||
|
* 화면 타이머는 이 엔드포인트를 <b>주기 폴링하지 않는다</b>. 그렇게 하면 "미사용 자동
|
||||||
|
* 로그아웃"이 영원히 오지 않는다. 페이지 진입 시 1회, 그리고 사용자가 연장을 선택했을 때만
|
||||||
|
* 호출한다.</p>
|
||||||
|
*/
|
||||||
|
@RequestMapping(value = "/mnt/session/status.do")
|
||||||
|
public String sessionStatus(HttpServletRequest request, ModelMap model) throws Exception {
|
||||||
|
HttpSession session = request.getSession(false);
|
||||||
|
if (session == null || handlerUser() == null) {
|
||||||
|
model.addAttribute("result", "expired");
|
||||||
|
return "jsonView";
|
||||||
|
}
|
||||||
|
model.addAttribute("result", "success");
|
||||||
|
model.addAttribute("elapsedSec", Long.valueOf(SessionPolicyInterceptor.elapsedLoginSec(session)));
|
||||||
|
model.addAttribute("timeoutSec", Integer.valueOf(session.getMaxInactiveInterval()));
|
||||||
|
return "jsonView";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세션 연장(JSON). 만료 임박 안내에서 사용자가 '연장'을 선택하면 호출된다.
|
||||||
|
*
|
||||||
|
* <p>연장이라 해도 별도 처리는 필요 없다 — 이 요청이 컨테이너에 도달한 순간
|
||||||
|
* lastAccessedTime 이 갱신되어 타임아웃이 처음부터 다시 흐른다. 응답으로 갱신된
|
||||||
|
* 잔여시간을 돌려주어 화면 타이머를 재동기화한다.</p>
|
||||||
|
*/
|
||||||
|
@RequestMapping(value = "/mnt/session/extend.do")
|
||||||
|
public String sessionExtend(HttpServletRequest request, ModelMap model) throws Exception {
|
||||||
|
HttpSession session = request.getSession(false);
|
||||||
|
if (session == null || handlerUser() == null) {
|
||||||
|
model.addAttribute("result", "expired");
|
||||||
|
return "jsonView";
|
||||||
|
}
|
||||||
|
// 정책값을 다시 적용해 두면 연장 직후부터 최신 설정이 반영된다.
|
||||||
|
session.setMaxInactiveInterval(accessControlPolicyService.getCachedPolicy().getAutoLogoutSec());
|
||||||
|
|
||||||
|
model.addAttribute("result", "success");
|
||||||
|
model.addAttribute("elapsedSec", Long.valueOf(SessionPolicyInterceptor.elapsedLoginSec(session)));
|
||||||
|
model.addAttribute("timeoutSec", Integer.valueOf(session.getMaxInactiveInterval()));
|
||||||
|
return "jsonView";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────── 공통
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 취급자 영역(/mnt) 인증·구분 판정. 인터셉터 3단만으로는 무인증 노출 가능성이 있어
|
||||||
|
* 컨트롤러 단에서 직접 판정한다(AccountMngController 와 동일 규약).
|
||||||
|
*
|
||||||
|
* @return 인증된 업무사용자(USR) LoginVO, 그 외에는 null
|
||||||
|
*/
|
||||||
|
private LoginVO handlerUser() {
|
||||||
|
if (!Boolean.TRUE.equals(EgovUserDetailsHelper.isAuthenticated())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Object o = EgovUserDetailsHelper.getAuthenticatedUser();
|
||||||
|
if (!(o instanceof LoginVO)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
LoginVO loginVO = (LoginVO) o;
|
||||||
|
return "USR".equals(loginVO.getUserSe()) ? loginVO : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSRF 부분 완화. Spring Security 2.0.4 에는 CSRF 토큰 기능이 없으므로,
|
||||||
|
* Origin/Referer 가 전달된 경우에 한해 요청 호스트와 동일한지 확인한다.
|
||||||
|
*/
|
||||||
|
private boolean isSameOrigin(HttpServletRequest request) {
|
||||||
|
String source = request.getHeader("Origin");
|
||||||
|
if (source == null) {
|
||||||
|
source = request.getHeader("Referer");
|
||||||
|
}
|
||||||
|
if (source == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String sourceHost = hostOf(source);
|
||||||
|
String requestHost = hostOf(request.getHeader("Host"));
|
||||||
|
return sourceHost != null && sourceHost.equalsIgnoreCase(requestHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "https://host:8080/path" · "host:8080" 등에서 호스트명만 추출. */
|
||||||
|
private String hostOf(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String host = value;
|
||||||
|
int scheme = host.indexOf("://");
|
||||||
|
if (scheme >= 0) {
|
||||||
|
host = host.substring(scheme + 3);
|
||||||
|
}
|
||||||
|
int slash = host.indexOf('/');
|
||||||
|
if (slash >= 0) {
|
||||||
|
host = host.substring(0, slash);
|
||||||
|
}
|
||||||
|
int colon = host.indexOf(':');
|
||||||
|
if (colon >= 0) {
|
||||||
|
host = host.substring(0, colon);
|
||||||
|
}
|
||||||
|
return host.length() == 0 ? null : host;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String denyJson(ModelMap model) {
|
||||||
|
return failJson(model, "접근 권한이 없습니다. 관리자 계정으로 로그인 후 이용하세요.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String failJson(ModelMap model, String message) {
|
||||||
|
model.addAttribute("result", "fail");
|
||||||
|
model.addAttribute("message", message);
|
||||||
|
return "jsonView";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
package egovframework.com.cmm.privacy.policy;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import egovframework.com.cmm.service.impl.EgovComAbstractDAO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 접근통제 정책 DAO (SER-001 요건 4·5).
|
||||||
|
*
|
||||||
|
* <p>statement 는 {@code PrivacyAccessControlPolicy_SQL_Mysql.xml}
|
||||||
|
* (namespace {@code AccessControlPolicyDAO}), sql-map-config 등록.</p>
|
||||||
|
*
|
||||||
|
* <p>루트 컨텍스트 {@code @Repository} 빈이므로 자식(servlet) 컨텍스트의
|
||||||
|
* 인터셉터에서도 참조할 수 있다.</p>
|
||||||
|
*/
|
||||||
|
@Repository("accessControlPolicyDAO")
|
||||||
|
public class AccessControlPolicyDAO extends EgovComAbstractDAO {
|
||||||
|
|
||||||
|
/** 정책 단일행 조회. 없으면 null(호출부 기본값 fallback). */
|
||||||
|
public AccessControlPolicyVO selectPolicy() {
|
||||||
|
return (AccessControlPolicyVO) selectByPk("AccessControlPolicyDAO.selectPolicy", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 정책 수정. */
|
||||||
|
public int updatePolicy(AccessControlPolicyVO vo) {
|
||||||
|
return update("AccessControlPolicyDAO.updatePolicy", vo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정책 잠금 임계값을 인증 경로가 읽는 LOGIN_LOCK_COUNT 에 동기화한다.
|
||||||
|
*
|
||||||
|
* <p>인증 실패 잠금 판정은 기존 자산({@code loginDAO.selectFailTotalCount} → LOGIN_LOCK_COUNT)을
|
||||||
|
* 그대로 쓰므로, 정책 저장 시 이 한 줄이 있어야 "설정 즉시 활성 인증 경로에 적용"이 성립한다.</p>
|
||||||
|
*
|
||||||
|
* @return 갱신 행 수(0이면 LOGIN_LOCK_COUNT 가 비어 있는 것 — 호출부에서 INSERT 보정)
|
||||||
|
*/
|
||||||
|
public int updateLoginLockCount(AccessControlPolicyVO vo) {
|
||||||
|
return update("AccessControlPolicyDAO.updateLoginLockCount", vo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LOGIN_LOCK_COUNT 가 비어 있을 때 임계값 1행을 생성한다. */
|
||||||
|
public void insertLoginLockCount(AccessControlPolicyVO vo) {
|
||||||
|
insert("AccessControlPolicyDAO.insertLoginLockCount", vo);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package egovframework.com.cmm.privacy.policy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 접근통제 정책 서비스 (SER-001 요건 4·5).
|
||||||
|
*
|
||||||
|
* <p>계정 잠금 임계값·자동 로그아웃 시간·세션 재발급 여부의 조회/수정.
|
||||||
|
* 인터셉터·인증 컨트롤러가 요청마다 호출하므로 조회는 짧은 TTL 캐시를 둔다.</p>
|
||||||
|
*/
|
||||||
|
public interface AccessControlPolicyService {
|
||||||
|
|
||||||
|
/** 현재 정책 조회(미설정 시 기본값 VO 반환). DB 직접 조회 — 관리자 화면용. */
|
||||||
|
AccessControlPolicyVO selectPolicy() throws Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 정책 조회(TTL 캐시). 인터셉터·인증 경로 등 요청마다 호출되는 지점 전용.
|
||||||
|
*
|
||||||
|
* <p>조회 실패 시에도 예외를 던지지 않고 기본값 VO 를 반환한다(사이트 잠금 방지).</p>
|
||||||
|
*/
|
||||||
|
AccessControlPolicyVO getCachedPolicy();
|
||||||
|
|
||||||
|
/** 정책 수정 + LOGIN_LOCK_COUNT 동기화 + 캐시 무효화. */
|
||||||
|
void updatePolicy(AccessControlPolicyVO vo) throws Exception;
|
||||||
|
}
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
package egovframework.com.cmm.privacy.policy;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
|
||||||
|
import org.apache.commons.logging.Log;
|
||||||
|
import org.apache.commons.logging.LogFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 접근통제 정책 서비스 구현 (SER-001 요건 4·5).
|
||||||
|
*
|
||||||
|
* <p><b>캐시</b> — {@code SessionPolicyInterceptor} 가 관리자 화면 요청마다 정책을 읽으므로
|
||||||
|
* 매 요청 DB 조회를 피하기 위해 짧은 TTL(기본 60초) 캐시를 둔다. 저장 시에는 즉시 무효화하여
|
||||||
|
* "설정 저장 → 즉시 적용"을 보장한다(시안의 '설정 저장·즉시 적용').</p>
|
||||||
|
*
|
||||||
|
* <p><b>fail-safe</b> — 정책 조회 실패(테이블 미생성·DB 오류)는 관리자 화면 접근 자체를
|
||||||
|
* 막으면 안 된다. {@link #getCachedPolicy()} 는 예외를 삼키고 기본값 VO(잠금 5회·30분)를
|
||||||
|
* 반환한다. 기본값은 강화된 정책값이라 fail-open 이 아니다.</p>
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* 수정일 수정자 수정내용
|
||||||
|
* ---------- ------ ---------------------------
|
||||||
|
* 2026.08.10 URP 최초 생성 (접근통제 정책 적용 화면)
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
package egovframework.com.cmm.privacy.policy;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 접근통제 정책 VO — ACCESS_CONTROL_POLICY(단일행) 매핑.
|
||||||
|
*
|
||||||
|
* <p>문화품앗이 개인정보보호 기능개선 사업 — 접근통제 정책 적용(SER-001 요건 4·5).
|
||||||
|
* 「개인정보보호강화 파일럿 개발항목」 접근통제 정책 적용(Action2) 시안 반영.</p>
|
||||||
|
*
|
||||||
|
* <p>계정 잠금 임계값·자동 로그아웃 시간·세션 재발급 여부를 소스/web.xml 하드코딩이 아니라
|
||||||
|
* 관리자 설정값으로 운영하기 위한 정책 값 묶음이다. 필드 기본값은 정책 미설정(테이블 empty)
|
||||||
|
* 상황에서도 화면·인터셉터가 안전하게 동작하도록 시안 기준값과 동일하게 둔다.</p>
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* << 개정이력(Modification Information) >>
|
||||||
|
* 수정일 수정자 수정내용
|
||||||
|
* ---------- ------ ---------------------------
|
||||||
|
* 2026.08.10 URP 최초 생성 (접근통제 정책 적용 화면)
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
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; }
|
||||||
|
}
|
||||||
@ -90,4 +90,23 @@ public final class SessionRegenerator {
|
|||||||
}
|
}
|
||||||
return newSession;
|
return newSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세션 재발급 없이 2차 인증 완료 표식만 세팅한다.
|
||||||
|
*
|
||||||
|
* <p>접근통제 정책(SER-001 요건4·5)에서 <b>세션 재발급 '미사용'</b>을 선택한 경우에 쓴다.
|
||||||
|
* 표식까지 빠지면 {@code SecondFactorInterceptor} 가 인증을 통과시키지 않아 무한 리다이렉트가
|
||||||
|
* 되므로, 재발급을 생략하더라도 표식은 반드시 남긴다.</p>
|
||||||
|
*
|
||||||
|
* @param request HTTP 요청
|
||||||
|
* @return 표식이 세팅된 현재 세션(요청이 널이면 null)
|
||||||
|
*/
|
||||||
|
public static HttpSession markSecondFactorDone(HttpServletRequest request) {
|
||||||
|
if (request == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
HttpSession session = request.getSession(true);
|
||||||
|
session.setAttribute(SECOND_FACTOR_DONE, Boolean.TRUE);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,18 @@
|
|||||||
package egovframework.com.sym.log.clg.service;
|
package egovframework.com.sym.log.clg.service;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
|
import org.apache.commons.logging.Log;
|
||||||
|
import org.apache.commons.logging.LogFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.web.context.request.RequestAttributes;
|
||||||
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
|
|
||||||
import egovframework.com.cmm.LoginVO;
|
import egovframework.com.cmm.LoginVO;
|
||||||
|
import egovframework.com.cmm.privacy.auditlog.ClientIpResolver;
|
||||||
|
import egovframework.com.cmm.privacy.conect.IpLocationResolver;
|
||||||
import egovframework.com.cmm.util.EgovUserDetailsHelper;
|
import egovframework.com.cmm.util.EgovUserDetailsHelper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -13,7 +23,26 @@ import egovframework.com.cmm.util.EgovUserDetailsHelper;
|
|||||||
* 수정일 수정자 수정내용
|
* 수정일 수정자 수정내용
|
||||||
* ------- ------- -------------------
|
* ------- ------- -------------------
|
||||||
* 2009. 3. 11. 이삼섭 최초생성
|
* 2009. 3. 11. 이삼섭 최초생성
|
||||||
* 2011. 7. 01. 이기하 패키지 분리(sym.log -> sym.log.clg)
|
* 2011. 7. 01. 이기하 패키지 분리(sym.log -> sym.log.clg)
|
||||||
|
* 2026. 8. 07. URP 개인정보보호 #62 — 접속IP 유실 복구 + 접속위치 기록
|
||||||
|
*
|
||||||
|
* <p><b>[2026-08-07 접속IP 유실 복구]</b> 종전에는 세션 {@code LoginVO.getIp()} 만 읽었는데
|
||||||
|
* 이 값이 실제로 채워지지 않아 {@code COMTNLOGINLOG.CONECT_IP} 가 <b>전 건(373,959행) NULL</b>
|
||||||
|
* 이었다(2026-08-07 운영 덤프 실측). 이제 현재 요청에서
|
||||||
|
* {@link ClientIpResolver}(#34, X-Forwarded-For 보정 + 신뢰 프록시 검증)로 직접 취득하고,
|
||||||
|
* 요청 컨텍스트를 얻지 못하는 경우에만 세션 값으로 폴백한다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>[2026-08-07 접속ID 복구]</b> {@code CONECT_ID} 는 고유식별자 자리인데 실제 적재값이
|
||||||
|
* 16자리 hex 라 회원 테이블 4종 어디와도 매칭되지 않아(369,385행 실측) 접속자를 특정할 수
|
||||||
|
* 없었다. 세션 {@code LoginVO.getId()}(로그인 계정ID)와 {@code getUserSe()} 를
|
||||||
|
* {@code CONECT_ACNT_ID} / {@code CONECT_USER_SE} 에 직접 적재한다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>[접속위치]</b> 취득한 IP 를 {@link IpLocationResolver} 로 판정해
|
||||||
|
* {@code CONECT_LC} 에 <b>적재 시점 스냅샷</b>으로 남긴다(나중에 대역 매핑을 바꿔도
|
||||||
|
* 과거 기록은 불변 — 감사증빙).</p>
|
||||||
|
*
|
||||||
|
* <p><b>fail-safe:</b> IP·위치 취득은 로그인/로그아웃 경로에서 실행된다.
|
||||||
|
* 어떤 예외도 밖으로 던지지 않는다 — 접속기록 때문에 로그인이 막히면 안 된다.</p>
|
||||||
*
|
*
|
||||||
* @author 공통 서비스 개발팀 이삼섭
|
* @author 공통 서비스 개발팀 이삼섭
|
||||||
* @since 2009. 3. 11.
|
* @since 2009. 3. 11.
|
||||||
@ -23,9 +52,22 @@ import egovframework.com.cmm.util.EgovUserDetailsHelper;
|
|||||||
*/
|
*/
|
||||||
public class EgovLoginLogAspect {
|
public class EgovLoginLogAspect {
|
||||||
|
|
||||||
|
private static final Log LOG = LogFactory.getLog(EgovLoginLogAspect.class);
|
||||||
|
|
||||||
@Resource(name="EgovLoginLogService")
|
@Resource(name="EgovLoginLogService")
|
||||||
private EgovLoginLogService loginLogService;
|
private EgovLoginLogService loginLogService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 접속위치 판정기(개인정보보호 #62).
|
||||||
|
*
|
||||||
|
* <p>일부러 {@code @Resource} 가 아니라 {@code required=false} 로 받는다. 이 애스펙트는
|
||||||
|
* DispatcherServlet 컨텍스트의 {@code <bean>} 이라 필수 주입으로 걸어두면 스캔 범위가
|
||||||
|
* 어긋난 배포본에서 <b>컨텍스트 기동 자체가 실패</b>한다. 접속위치는 부가 정보이므로
|
||||||
|
* 미주입 시 그 칸만 비우고 로그인·접속기록 적재는 계속되게 한다.</p>
|
||||||
|
*/
|
||||||
|
@Autowired(required = false)
|
||||||
|
private IpLocationResolver ipLocationResolver;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 로그인 로그정보를 생성한다.
|
* 로그인 로그정보를 생성한다.
|
||||||
* EgovLoginController.actionMain Method
|
* EgovLoginController.actionMain Method
|
||||||
@ -35,26 +77,7 @@ public class EgovLoginLogAspect {
|
|||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public void logLogin() throws Throwable {
|
public void logLogin() throws Throwable {
|
||||||
|
writeLog("I");
|
||||||
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);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -66,25 +89,93 @@ public class EgovLoginLogAspect {
|
|||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public void logLogout() throws Throwable {
|
public void logLogout() throws Throwable {
|
||||||
|
writeLog("O");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 접속기록 1건 적재.
|
||||||
|
*
|
||||||
|
* @param mthd 접속구분 I:로그인 O:로그아웃
|
||||||
|
*/
|
||||||
|
private void writeLog(String mthd) throws Exception {
|
||||||
|
|
||||||
String uniqId = "";
|
String uniqId = "";
|
||||||
String ip = "";
|
String acntId = null;
|
||||||
|
String userSe = null;
|
||||||
|
String sessionIp = null;
|
||||||
|
|
||||||
/* Authenticated */
|
/* Authenticated */
|
||||||
Boolean isAuthenticated = EgovUserDetailsHelper.isAuthenticated();
|
Boolean isAuthenticated = EgovUserDetailsHelper.isAuthenticated();
|
||||||
if(isAuthenticated.booleanValue()) {
|
if (isAuthenticated.booleanValue()) {
|
||||||
LoginVO user = (LoginVO)EgovUserDetailsHelper.getAuthenticatedUser();
|
LoginVO user = (LoginVO) EgovUserDetailsHelper.getAuthenticatedUser();
|
||||||
uniqId = user.getUniqId();
|
uniqId = user.getUniqId();
|
||||||
ip = user.getIp();
|
acntId = user.getId(); // 로그인 계정ID — 접속기록의 '접속ID'
|
||||||
|
userSe = user.getUserSe(); // USR/GNR/GRO/ENT
|
||||||
|
sessionIp = user.getIp();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String ip = resolveIp(sessionIp);
|
||||||
|
|
||||||
LoginLog loginLog = new LoginLog();
|
LoginLog loginLog = new LoginLog();
|
||||||
loginLog.setLoginId(uniqId);
|
loginLog.setLoginId(uniqId);
|
||||||
|
loginLog.setConectAcntId(acntId);
|
||||||
|
loginLog.setConectUserSe(userSe);
|
||||||
loginLog.setLoginIp(ip);
|
loginLog.setLoginIp(ip);
|
||||||
loginLog.setLoginMthd("O"); // 로그인:I, 로그아웃:O
|
loginLog.setConectLc(resolveLocation(ip));
|
||||||
|
loginLog.setLoginMthd(mthd); // 로그인:I, 로그아웃:O
|
||||||
loginLog.setErrOccrrAt("N");
|
loginLog.setErrOccrrAt("N");
|
||||||
loginLog.setErrorCode("");
|
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);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,6 +33,17 @@ public class LoginLog implements Serializable {
|
|||||||
/** 접속IP */
|
/** 접속IP */
|
||||||
private String loginIp;
|
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;
|
private String loginMthd;
|
||||||
|
|
||||||
@ -169,6 +180,33 @@ public class LoginLog implements Serializable {
|
|||||||
this.creatDt = creatDt;
|
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() {
|
public String getSearchBgnDe() {
|
||||||
return searchBgnDe;
|
return searchBgnDe;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -103,9 +103,16 @@ public class EgovLoginController {
|
|||||||
HttpServletRequest request,
|
HttpServletRequest request,
|
||||||
HttpServletResponse response,
|
HttpServletResponse response,
|
||||||
ModelMap model,
|
ModelMap model,
|
||||||
|
@RequestParam(value = "expired", required = false) String expired,
|
||||||
@CookieValue(value = "mobile", required = false, defaultValue = "false") Boolean mobile)
|
@CookieValue(value = "mobile", required = false, defaultValue = "false") Boolean mobile)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
|
|
||||||
|
// [개인정보보호 SER-001 요건5] 자동 로그아웃으로 돌아온 경우 사유를 알린다.
|
||||||
|
// 로그인 화면(login.jsp)은 ${message} 를 alert 로 띄운다 — 별도 화면 수정 불필요.
|
||||||
|
if ("Y".equals(expired)) {
|
||||||
|
model.addAttribute("message", "장시간 사용하지 않아 자동 로그아웃되었습니다. 다시 로그인해 주세요.");
|
||||||
|
}
|
||||||
|
|
||||||
System.out.println("로그인 완료");
|
System.out.println("로그인 완료");
|
||||||
/*
|
/*
|
||||||
MberSsnVO ssnvo = new MberSsnVO();
|
MberSsnVO ssnvo = new MberSsnVO();
|
||||||
@ -595,11 +602,32 @@ public class EgovLoginController {
|
|||||||
* @exception Exception
|
* @exception Exception
|
||||||
*/
|
*/
|
||||||
@RequestMapping(value="/uat/uia/actionLogout.do")
|
@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 {
|
throws Exception {
|
||||||
|
|
||||||
String userIp = EgovClntInfo.getClntIP(request);
|
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);
|
request.getSession().setAttribute("loginVO", null);
|
||||||
// return "redirect:/j_spring_security_logout";
|
// return "redirect:/j_spring_security_logout";
|
||||||
return "redirect:/frt/main.do";
|
return "redirect:/frt/main.do";
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import org.springframework.web.bind.annotation.RequestParam;
|
|||||||
|
|
||||||
import egovframework.com.cmm.LoginVO;
|
import egovframework.com.cmm.LoginVO;
|
||||||
import egovframework.com.cmm.privacy.interceptor.SecondFactorInterceptor;
|
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.security.SessionRegenerator;
|
||||||
import egovframework.com.cmm.privacy.util.MaskingUtil;
|
import egovframework.com.cmm.privacy.util.MaskingUtil;
|
||||||
import egovframework.com.uat.uia.service.AuthMethodResolver;
|
import egovframework.com.uat.uia.service.AuthMethodResolver;
|
||||||
@ -84,6 +85,27 @@ public class SecondFactorController {
|
|||||||
@Resource(name = "otpAuthService")
|
@Resource(name = "otpAuthService")
|
||||||
private OtpAuthService otpAuthService;
|
private OtpAuthService otpAuthService;
|
||||||
|
|
||||||
|
/** 접근통제 정책(SER-001 요건4·5) — 세션 재발급 사용여부 판정에 사용. */
|
||||||
|
@Resource(name = "accessControlPolicyService")
|
||||||
|
private AccessControlPolicyService accessControlPolicyService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [개인정보보호 SER-001] 2차 인증 성공 시 세션 재발급(세션 고정 차단).
|
||||||
|
*
|
||||||
|
* <p>수행 여부는 접근통제 정책 설정 화면의 <b>세션 재발급</b> 값을 따른다.
|
||||||
|
* 정책 조회에 실패하면 기본값이 '사용'이므로 안전한 쪽(재발급)으로 동작한다.</p>
|
||||||
|
*/
|
||||||
|
private void regenerateSessionIfEnabled(HttpServletRequest request) {
|
||||||
|
if (accessControlPolicyService == null
|
||||||
|
|| accessControlPolicyService.getCachedPolicy().isSessionRegenUse()) {
|
||||||
|
SessionRegenerator.onSecondFactorSuccess(request);
|
||||||
|
} else {
|
||||||
|
// '미사용'이라도 2차 인증 완료 표식은 남겨야 인터셉터가 통과시킨다.
|
||||||
|
SessionRegenerator.markSecondFactorDone(request);
|
||||||
|
LOG.info("[개인정보/접근통제] 세션 재발급 정책 '미사용' — 재발급 생략");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────── 2차 인증 화면
|
// ────────────────────────────────────────────────────────── 2차 인증 화면
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -181,7 +203,8 @@ public class SecondFactorController {
|
|||||||
|
|
||||||
if (ok) {
|
if (ok) {
|
||||||
// 성공 — 세션 재발급(세션 고정 차단, #16) + 완료표식 세팅. attempt 카운터는 세션과 함께 폐기됨.
|
// 성공 — 세션 재발급(세션 고정 차단, #16) + 완료표식 세팅. attempt 카운터는 세션과 함께 폐기됨.
|
||||||
SessionRegenerator.onSecondFactorSuccess(request);
|
// 재발급 수행 여부는 접근통제 정책(세션 재발급) 설정을 따른다.
|
||||||
|
regenerateSessionIfEnabled(request);
|
||||||
LOG.info("[개인정보/#14] 2차 인증 성공 — 세션 재발급, 목적지 이동: user=" + user.getId() + ", target=" + target);
|
LOG.info("[개인정보/#14] 2차 인증 성공 — 세션 재발급, 목적지 이동: user=" + user.getId() + ", target=" + target);
|
||||||
return "redirect:" + target;
|
return "redirect:" + target;
|
||||||
}
|
}
|
||||||
@ -244,7 +267,7 @@ public class SecondFactorController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (ok) {
|
if (ok) {
|
||||||
SessionRegenerator.onSecondFactorSuccess(request);
|
regenerateSessionIfEnabled(request);
|
||||||
LOG.info("[개인정보/#18] OTP 등록·최초검증 완료 — 세션 재발급: user=" + user.getId());
|
LOG.info("[개인정보/#18] OTP 등록·최초검증 완료 — 세션 재발급: user=" + user.getId());
|
||||||
return "redirect:" + DEFAULT_TARGET;
|
return "redirect:" + DEFAULT_TARGET;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,20 @@ public interface EgovAccountUnlockManageService{
|
|||||||
|
|
||||||
int selectUserLockCheckListTotCnt(UserDefaultVO userSearchVO);
|
int selectUserLockCheckListTotCnt(UserDefaultVO userSearchVO);
|
||||||
|
|
||||||
void updateUnlock(String user_id, String user_gubun);
|
/**
|
||||||
|
* 선택 계정 잠금 해제.
|
||||||
|
*
|
||||||
|
* @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);
|
||||||
|
|
||||||
void updateUnlockAll();
|
/**
|
||||||
|
* 전 회원 잠금 해제.
|
||||||
|
*
|
||||||
|
* @param unlockResn 해제 사유(위와 동일)
|
||||||
|
*/
|
||||||
|
void updateUnlockAll(String unlockResn);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -437,6 +437,35 @@ public class MberManageVO extends MberManageTalentVO{
|
|||||||
public void setMberNm(String mberNm) {
|
public void setMberNm(String mberNm) {
|
||||||
this.mberNm = 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 값을 리턴한다.
|
* mberSttus attribute 값을 리턴한다.
|
||||||
* @return String
|
* @return String
|
||||||
|
|||||||
@ -603,6 +603,22 @@ public class UserDefaultVO implements Serializable {
|
|||||||
this.searchKeyword = searchKeyword;
|
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 값을 리턴한다.
|
* searchUseYn attribute 값을 리턴한다.
|
||||||
* @return String
|
* @return String
|
||||||
|
|||||||
@ -48,7 +48,7 @@ public class EgovAccountUnlockManageServiceImpl extends AbstractServiceImpl imp
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@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_id = user_id.split(",");
|
||||||
String [] sUser_gubun = user_gubun.split(",");
|
String [] sUser_gubun = user_gubun.split(",");
|
||||||
|
|
||||||
@ -64,13 +64,13 @@ public class EgovAccountUnlockManageServiceImpl extends AbstractServiceImpl imp
|
|||||||
diapauseManageDAO.updateUnlock(vo);
|
diapauseManageDAO.updateUnlock(vo);
|
||||||
|
|
||||||
// 개인정보보호(SER-001 / #24) — 잠금 해제 이력 적재
|
// 개인정보보호(SER-001 / #24) — 잠금 해제 이력 적재
|
||||||
recordUnlockHistory(sUser_id[i], sUser_gubun[i], "I");
|
recordUnlockHistory(sUser_id[i], sUser_gubun[i], "I", unlockResn);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void updateUnlockAll() {
|
public void updateUnlockAll(String unlockResn) {
|
||||||
|
|
||||||
loginDAO.deleteLoginSuccessTodayAll(new LoginVO());
|
loginDAO.deleteLoginSuccessTodayAll(new LoginVO());
|
||||||
|
|
||||||
@ -90,7 +90,7 @@ public class EgovAccountUnlockManageServiceImpl extends AbstractServiceImpl imp
|
|||||||
diapauseManageDAO.updateUnlockAll(vo);
|
diapauseManageDAO.updateUnlockAll(vo);
|
||||||
|
|
||||||
// 개인정보보호(SER-001 / #24) — 전체 잠금 해제 이력 적재(대상 ID는 전체이므로 NULL)
|
// 개인정보보호(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 trgetId 해제 대상 계정 ID(전체해제 시 null)
|
||||||
* @param trgetSe 대상 회원구분(GNR/GRO/ENT/USR/ALL)
|
* @param trgetSe 대상 회원구분(GNR/GRO/ENT/USR/ALL)
|
||||||
* @param unlockTy 해제 유형 I:개별 A:전체
|
* @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 {
|
try {
|
||||||
AccountUnlockHistoryVO hist = new AccountUnlockHistoryVO();
|
AccountUnlockHistoryVO hist = new AccountUnlockHistoryVO();
|
||||||
hist.setTrgetId(trgetId);
|
hist.setTrgetId(trgetId);
|
||||||
@ -125,9 +126,18 @@ public class EgovAccountUnlockManageServiceImpl extends AbstractServiceImpl imp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 사유: 관리자 화면에서 사유 입력 필드 연동은 프론트 후속. 현재는 기본값.
|
// 사유: 접근통제 정책이 '사유 필수'면 화면에서 반드시 입력되어 넘어온다.
|
||||||
// TODO: 관리자 해제 화면에서 사유(필수) 입력받아 전달(#24 화면 후속)
|
// '필수 아님'으로 운영할 때는 미입력이 정상이므로 기본 문구로 대체한다.
|
||||||
|
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("관리자 화면 잠금 해제");
|
hist.setUnlockResn("관리자 화면 잠금 해제");
|
||||||
|
}
|
||||||
|
|
||||||
accountUnlockHistoryDAO.insertUnlockHistory(hist);
|
accountUnlockHistoryDAO.insertUnlockHistory(hist);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import org.apache.commons.logging.Log;
|
|||||||
import org.apache.commons.logging.LogFactory;
|
import org.apache.commons.logging.LogFactory;
|
||||||
import org.springframework.stereotype.Repository;
|
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.cmm.service.impl.EgovComAbstractDAO;
|
||||||
import egovframework.com.uss.umt.service.EntrprsManageVO;
|
import egovframework.com.uss.umt.service.EntrprsManageVO;
|
||||||
import egovframework.com.uss.umt.service.MberManageVO;
|
import egovframework.com.uss.umt.service.MberManageVO;
|
||||||
@ -34,12 +36,38 @@ public class MberManageDAO extends EgovComAbstractDAO{
|
|||||||
/** Log Info */
|
/** Log Info */
|
||||||
protected Log log = LogFactory.getLog(this.getClass());
|
protected Log log = LogFactory.getLog(this.getClass());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이름 검색 완전일치 해시 계산(개인정보보호 기능개선 사업 추가제안 Action4, 2026-08-10).
|
||||||
|
*
|
||||||
|
* <p>{@code searchCondition="1"}(이름 검색) + 검색어가 있을 때만 계산한다. 이름이 암호화되며
|
||||||
|
* SQL의 {@code LIKE}가 {@code MBER_NM_HASH = #searchKeywordHash#}로 바뀌었는데, 이 값은
|
||||||
|
* SQL이 만들 수 없으므로(HMAC pepper는 DB가 모른다) 조회 직전에 여기서 채운다.</p>
|
||||||
|
*
|
||||||
|
* <p>{@link MemberDataHasher#hashName(String)}은 앞뒤 공백만 제거하고 대소문자·중간공백은
|
||||||
|
* 그대로 둔다 — 저장 시점(회원 등록/수정) 정규화와 반드시 같아야 검색이 맞아떨어진다.
|
||||||
|
* {@code mberNmHasher} 필드는 {@link #fillNmCrypto(MberManageVO)}와 공유한다(아래).</p>
|
||||||
|
*/
|
||||||
|
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 검색조건
|
* @param userSearchVO 검색조건
|
||||||
* @return List<MberManageVO> 기업회원 목록정보
|
* @return List<MberManageVO> 기업회원 목록정보
|
||||||
*/
|
*/
|
||||||
public List<MberManageVO> selectMberList(UserDefaultVO userSearchVO){
|
public List<MberManageVO> selectMberList(UserDefaultVO userSearchVO){
|
||||||
|
fillSearchKeywordHash(userSearchVO);
|
||||||
return list("mberManageDAO.selectMberList", userSearchVO);
|
return list("mberManageDAO.selectMberList", userSearchVO);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,6 +87,7 @@ public class MberManageDAO extends EgovComAbstractDAO{
|
|||||||
* @return int 일반회원총갯수
|
* @return int 일반회원총갯수
|
||||||
*/
|
*/
|
||||||
public int selectMberListTotCnt(UserDefaultVO userSearchVO) {
|
public int selectMberListTotCnt(UserDefaultVO userSearchVO) {
|
||||||
|
fillSearchKeywordHash(userSearchVO);
|
||||||
return (Integer)getSqlMapClientTemplate().queryForObject("mberManageDAO.selectMberListTotCnt", userSearchVO);
|
return (Integer)getSqlMapClientTemplate().queryForObject("mberManageDAO.selectMberListTotCnt", userSearchVO);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,12 +128,43 @@ public class MberManageDAO extends EgovComAbstractDAO{
|
|||||||
update("mberManageDAO.updateDeleteMber_S", delId);
|
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 일반회원 등록정보
|
* @param mberManageVO 일반회원 등록정보
|
||||||
* @return String 등록결과
|
* @return String 등록결과
|
||||||
*/
|
*/
|
||||||
public String insertMber(MberManageVO mberManageVO){
|
public String insertMber(MberManageVO mberManageVO){
|
||||||
|
fillNmCrypto(mberManageVO);
|
||||||
return (String)insert("mberManageDAO.insertMber_S", mberManageVO);
|
return (String)insert("mberManageDAO.insertMber_S", mberManageVO);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -197,6 +257,7 @@ public class MberManageDAO extends EgovComAbstractDAO{
|
|||||||
* @return List<MberManageVO> 기업회원 목록정보
|
* @return List<MberManageVO> 기업회원 목록정보
|
||||||
*/
|
*/
|
||||||
public List<MberManageVO> selectExcelMberList(UserDefaultVO userSearchVO){
|
public List<MberManageVO> selectExcelMberList(UserDefaultVO userSearchVO){
|
||||||
|
fillSearchKeywordHash(userSearchVO);
|
||||||
return list("mberManageDAO.selectExcelMberList", userSearchVO);
|
return list("mberManageDAO.selectExcelMberList", userSearchVO);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -228,6 +289,7 @@ public class MberManageDAO extends EgovComAbstractDAO{
|
|||||||
|
|
||||||
public List<MberManageVO> selectAwardList(UserDefaultVO userSearchVO) {
|
public List<MberManageVO> selectAwardList(UserDefaultVO userSearchVO) {
|
||||||
// TODO Auto-generated method stub
|
// TODO Auto-generated method stub
|
||||||
|
fillSearchKeywordHash(userSearchVO);
|
||||||
return list("mberManageDAO.selectAwardList", userSearchVO);
|
return list("mberManageDAO.selectAwardList", userSearchVO);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -248,6 +310,7 @@ public class MberManageDAO extends EgovComAbstractDAO{
|
|||||||
|
|
||||||
public int selectAwardListCnt(UserDefaultVO userSearchVO) {
|
public int selectAwardListCnt(UserDefaultVO userSearchVO) {
|
||||||
// TODO Auto-generated method stub
|
// TODO Auto-generated method stub
|
||||||
|
fillSearchKeywordHash(userSearchVO);
|
||||||
return (Integer)getSqlMapClientTemplate().queryForObject("mberManageDAO.selectAwardListCnt", userSearchVO);
|
return (Integer)getSqlMapClientTemplate().queryForObject("mberManageDAO.selectAwardListCnt", userSearchVO);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
||||||
import egovframework.com.cmm.annotation.IncludedInfo;
|
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.EgovAccountUnlockManageService;
|
||||||
import egovframework.com.uss.umt.service.UserDefaultVO;
|
import egovframework.com.uss.umt.service.UserDefaultVO;
|
||||||
import egovframework.rte.fdl.property.EgovPropertyService;
|
import egovframework.rte.fdl.property.EgovPropertyService;
|
||||||
@ -51,6 +52,13 @@ public class EgovAccountUnlockManageController {
|
|||||||
@Resource(name = "propertiesService")
|
@Resource(name = "propertiesService")
|
||||||
protected EgovPropertyService propertiesService;
|
protected EgovPropertyService propertiesService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 접근통제 정책(개인정보보호 SER-001 요건4) — 잠금 해제 방식.
|
||||||
|
* '관리자 화면 해제 + 해제 사유 필수'로 설정된 경우 사유 없는 해제를 거부한다.
|
||||||
|
*/
|
||||||
|
@Resource(name = "accessControlPolicyService")
|
||||||
|
private AccessControlPolicyService accessControlPolicyService;
|
||||||
|
|
||||||
/** Log Info */
|
/** Log Info */
|
||||||
protected Log log = LogFactory.getLog(this.getClass());
|
protected Log log = LogFactory.getLog(this.getClass());
|
||||||
|
|
||||||
@ -95,6 +103,11 @@ public class EgovAccountUnlockManageController {
|
|||||||
paginationInfo.setTotalRecordCount(totCnt);
|
paginationInfo.setTotalRecordCount(totCnt);
|
||||||
model.addAttribute("paginationInfo", paginationInfo);
|
model.addAttribute("paginationInfo", paginationInfo);
|
||||||
|
|
||||||
|
// [개인정보보호 SER-001 요건4] 해제 사유 입력 여부는 접근통제 정책 설정을 따른다.
|
||||||
|
// (통합관리시스템 > 시스템관리 > 권한관리 > 접근통제 정책)
|
||||||
|
model.addAttribute("unlockResnRequired",
|
||||||
|
Boolean.valueOf(accessControlPolicyService.getCachedPolicy().isUnlockResnRequired()));
|
||||||
|
|
||||||
return "egovframework/com/uss/umt/EgovAccountUnlockManage";
|
return "egovframework/com/uss/umt/EgovAccountUnlockManage";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,11 +122,17 @@ public class EgovAccountUnlockManageController {
|
|||||||
public String deleteMber(
|
public String deleteMber(
|
||||||
@RequestParam("user_id") String user_id ,
|
@RequestParam("user_id") String user_id ,
|
||||||
@RequestParam("user_gubun") String user_gubun ,
|
@RequestParam("user_gubun") String user_gubun ,
|
||||||
|
@RequestParam(value="unlockResn", required=false) String unlockResn ,
|
||||||
Model model
|
Model model
|
||||||
)throws Exception {
|
)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 없이 진행시 삭제성공메시지
|
//Exception 없이 진행시 삭제성공메시지
|
||||||
model.addAttribute("resultMsg", "success.common.update");
|
model.addAttribute("resultMsg", "success.common.update");
|
||||||
@ -126,13 +145,37 @@ public class EgovAccountUnlockManageController {
|
|||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
@RequestMapping("/uss/umt/EgovAccountUnlockUpdateAll.do")
|
@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 없이 진행시 삭제성공메시지
|
//Exception 없이 진행시 삭제성공메시지
|
||||||
model.addAttribute("resultMsg", "success.common.update");
|
model.addAttribute("resultMsg", "success.common.update");
|
||||||
return "forward:/uss/umt/EgovAccountUnlockManage.do";
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -15,6 +15,7 @@ import org.springframework.stereotype.Component;
|
|||||||
|
|
||||||
import egovframework.com.cmm.EgovWebUtil;
|
import egovframework.com.cmm.EgovWebUtil;
|
||||||
import egovframework.com.cmm.LoginVO;
|
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.HashChainVerifyResultVO;
|
||||||
import egovframework.com.cmm.privacy.batch.InspectionReportVO;
|
import egovframework.com.cmm.privacy.batch.InspectionReportVO;
|
||||||
import egovframework.com.cmm.privacy.batch.PrivacyBatchService;
|
import egovframework.com.cmm.privacy.batch.PrivacyBatchService;
|
||||||
@ -61,6 +62,14 @@ public class BatchScheduler {
|
|||||||
@Resource(name = "privacyBatchService")
|
@Resource(name = "privacyBatchService")
|
||||||
private PrivacyBatchService 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 + 관리자 알림) */
|
/** 개인정보보호 휴면 사전알림 서비스 (#10 D-30/D-7/D-Day + 관리자 알림) */
|
||||||
@Resource(name = "dormantAlertService")
|
@Resource(name = "dormantAlertService")
|
||||||
private egovframework.com.cmm.privacy.account.DormantAlertService dormantAlertService;
|
private egovframework.com.cmm.privacy.account.DormantAlertService dormantAlertService;
|
||||||
@ -244,6 +253,35 @@ public class BatchScheduler {
|
|||||||
+ " 총 " + report.getTotalLogCnt() + "건, 무결성=" + report.getIntegrityResult());
|
+ " 총 " + report.getTotalLogCnt() + "건, 무결성=" + report.getIntegrityResult());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [개인정보보호 SFR-004-6] 접속기록 CSV 비동기 다운로드 파일 파기 배치.
|
||||||
|
*
|
||||||
|
* <p><b>매일</b> 01:00. 다른 개인정보 배치는 월 1회지만 이건 매일 돈다 — 생성 파일이
|
||||||
|
* '대량 개인정보 파일'이라 보관기간(기본 3일)이 짧고, 월 1회로는 만료분이 최대 한 달간
|
||||||
|
* 디스크에 남기 때문이다. 접속기록 본체의 보관기간(730일)과는 무관한 사본의 수명이다.</p>
|
||||||
|
*
|
||||||
|
* @throws Exception 파기 오류
|
||||||
|
*/
|
||||||
|
@Scheduled(cron = "0 0 1 * * ?")
|
||||||
|
public void purgeExpiredDownloadFileScheduler() throws Exception {
|
||||||
|
int purged = accessLogDownloadJobService.purgeExpiredJobs();
|
||||||
|
log.info("[개인정보/SFR-004-6] 만료 다운로드 파일 파기 배치 완료 — " + purged + "건");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [개인정보보호 SFR-001 #6] 권한변경 이력 CSV 비동기 다운로드 파일 파기 배치.
|
||||||
|
*
|
||||||
|
* <p>매일 01:10. 접속기록 파기(01:00)와 10분 간격을 둬 같은 저장 디렉터리를 동시에
|
||||||
|
* 훑지 않게 한다. 보관기간은 접속기록 사본과 같은 설정값(기본 3일)을 쓴다.</p>
|
||||||
|
*
|
||||||
|
* @throws Exception 파기 오류
|
||||||
|
*/
|
||||||
|
@Scheduled(cron = "0 10 1 * * ?")
|
||||||
|
public void purgeExpiredAuthorHistDownloadFileScheduler() throws Exception {
|
||||||
|
int purged = authorHistDownloadJobService.purgeExpiredJobs();
|
||||||
|
log.info("[개인정보/SFR-001-6] 만료 권한이력 다운로드 파일 파기 배치 완료 — " + purged + "건");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [개인정보보호 #10] 휴면 전 사전 알림 배치 — D-30/D-7/D-Day 3단계 관리자 알림.
|
* [개인정보보호 #10] 휴면 전 사전 알림 배치 — D-30/D-7/D-Day 3단계 관리자 알림.
|
||||||
* 매일 06:00, 정책(ACCOUNT_MNG_POLICY)의 ALERT_D1/D2/D3 시점에 해당하는 휴면 예정
|
* 매일 06:00, 정책(ACCOUNT_MNG_POLICY)의 ALERT_D1/D2/D3 시점에 해당하는 휴면 예정
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import java.util.List;
|
|||||||
|
|
||||||
import org.springframework.stereotype.Repository;
|
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.cmm.service.impl.EgovComAbstractDAO;
|
||||||
import egovframework.com.uss.umt.service.EntrprsManageVO;
|
import egovframework.com.uss.umt.service.EntrprsManageVO;
|
||||||
import egovframework.com.uss.umt.service.MberManageVO;
|
import egovframework.com.uss.umt.service.MberManageVO;
|
||||||
@ -12,6 +14,30 @@ import kr.or.arko.frt.biz.merge.service.LibMberManageVO;
|
|||||||
@Repository("BizMberMergeDAO")
|
@Repository("BizMberMergeDAO")
|
||||||
public class BizMberMergeDAO extends EgovComAbstractDAO {
|
public class BizMberMergeDAO extends EgovComAbstractDAO {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이름 암호화(개인정보보호 기능개선 사업 추가제안 Action4, 2026-08-10) 헬퍼.
|
||||||
|
* 지연 초기화 — {@link MemberDataCipher} 정책과 동일하게 실제 저장 시점에만 키를 요구한다.
|
||||||
|
*/
|
||||||
|
private MemberDataCipher mberNmCipher;
|
||||||
|
private MemberDataHasher mberNmHasher;
|
||||||
|
|
||||||
|
/** VO의 평문 이름({@code mberNm})으로부터 암호문·해시·키버전을 채운다. */
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public List<LibMberManageVO> selectMberList(LibMberManageVO libMberManageVO) throws Exception {
|
public List<LibMberManageVO> selectMberList(LibMberManageVO libMberManageVO) throws Exception {
|
||||||
return list("bizMberMergeDAO.selectLibMberList", libMberManageVO);
|
return list("bizMberMergeDAO.selectLibMberList", libMberManageVO);
|
||||||
}
|
}
|
||||||
@ -28,6 +54,7 @@ public class BizMberMergeDAO extends EgovComAbstractDAO {
|
|||||||
return (LibMberManageVO) selectByPk("bizMberMergeDAO.selectLiActivity_S", libMberManageVO);
|
return (LibMberManageVO) selectByPk("bizMberMergeDAO.selectLiActivity_S", libMberManageVO);
|
||||||
}
|
}
|
||||||
public void updateLibMber(MberManageVO mberManageVO){
|
public void updateLibMber(MberManageVO mberManageVO){
|
||||||
|
fillNmCrypto(mberManageVO);
|
||||||
update("bizMberMergeDAO.updateMber_S",mberManageVO);
|
update("bizMberMergeDAO.updateMber_S",mberManageVO);
|
||||||
}
|
}
|
||||||
public void updateLibMberToVol(MberManageVO mberManageVO){
|
public void updateLibMberToVol(MberManageVO mberManageVO){
|
||||||
|
|||||||
@ -310,3 +310,4 @@ volunteer.sms.msg.12 = [\ubb38\ud654\uccb4\uc721\uc790\uc6d0\ubd09\uc0ac]\ubd09\
|
|||||||
|
|
||||||
volunteer.sms.msg.admin = \ubb38\ud654\ud488\uc557e - \uad00\ub9ac\uc790 \uacc4\uc815(ID:admin)\uc73c\ub85c \ub85c\uadf8\uc778 \ud558\uc168\uc2b5\ub2c8\ub2e4.
|
volunteer.sms.msg.admin = \ubb38\ud654\ud488\uc557e - \uad00\ub9ac\uc790 \uacc4\uc815(ID:admin)\uc73c\ub85c \ub85c\uadf8\uc778 \ud558\uc168\uc2b5\ub2c8\ub2e4.
|
||||||
volunteer.sms.msg.phone = 01034340507
|
volunteer.sms.msg.phone = 01034340507
|
||||||
|
fail.privacy.unlock.resn=Unlock reason is required by the access control policy.
|
||||||
|
|||||||
@ -441,3 +441,4 @@ volunteer.sms.msg.12 = [\ubb38\ud654\uccb4\uc721\uc790\uc6d0\ubd09\uc0ac]\ubd09\
|
|||||||
volunteer.sms.msg.admin = \ubb38\ud654\ud488\uc557e - \uad00\ub9ac\uc790 \uacc4\uc815(ID:admin)\uc73c\ub85c \ub85c\uadf8\uc778 \ud558\uc168\uc2b5\ub2c8\ub2e4.
|
volunteer.sms.msg.admin = \ubb38\ud654\ud488\uc557e - \uad00\ub9ac\uc790 \uacc4\uc815(ID:admin)\uc73c\ub85c \ub85c\uadf8\uc778 \ud558\uc168\uc2b5\ub2c8\ub2e4.
|
||||||
volunteer.sms.msg.phone = 01034340507
|
volunteer.sms.msg.phone = 01034340507
|
||||||
|
|
||||||
|
fail.privacy.unlock.resn=\uacc4\uc815 \uc7a0\uae08 \ud574\uc81c \uc0ac\uc720\ub97c \uc785\ub825\ud558\uc138\uc694. (\uc811\uadfc\ud1b5\uc81c \uc815\ucc45 - \ud574\uc81c \uc0ac\uc720 \ud544\uc218)
|
||||||
|
|||||||
@ -56,20 +56,30 @@
|
|||||||
</update>
|
</update>
|
||||||
|
|
||||||
<!-- 로그인 로그 등록 -->
|
<!-- 로그인 로그 등록 -->
|
||||||
|
<!-- ★ 개인정보보호 #62: CONECT_LC(접속위치) / CONECT_ACNT_ID(접속 계정ID) /
|
||||||
|
CONECT_USER_SE(사용자구분) 3개 컬럼이 추가되었다.
|
||||||
|
privacy.conect_log_extend_mysql.sql (DDL) 없이 이 sqlmap 만 배포하면
|
||||||
|
로그인 시 Unknown column 으로 접속기록 적재가 깨진다 — DDL 동반 필수. -->
|
||||||
<insert id="LoginLogDAO.logInsertLoginLog" parameterClass="loginLog">
|
<insert id="LoginLogDAO.logInsertLoginLog" parameterClass="loginLog">
|
||||||
<![CDATA[
|
<![CDATA[
|
||||||
INSERT INTO COMTNLOGINLOG
|
INSERT INTO COMTNLOGINLOG
|
||||||
( LOG_ID
|
( LOG_ID
|
||||||
, CONECT_MTHD
|
, CONECT_MTHD
|
||||||
, CONECT_ID
|
, CONECT_ID
|
||||||
|
, CONECT_ACNT_ID
|
||||||
|
, CONECT_USER_SE
|
||||||
, CONECT_IP
|
, CONECT_IP
|
||||||
|
, CONECT_LC
|
||||||
, ERROR_OCCRRNC_AT
|
, ERROR_OCCRRNC_AT
|
||||||
, ERROR_CODE
|
, ERROR_CODE
|
||||||
, CREAT_DT )
|
, CREAT_DT )
|
||||||
VALUES ( #logId#
|
VALUES ( #logId#
|
||||||
, #loginMthd#
|
, #loginMthd#
|
||||||
, #loginId#
|
, #loginId#
|
||||||
|
, #conectAcntId#
|
||||||
|
, #conectUserSe#
|
||||||
, #loginIp#
|
, #loginIp#
|
||||||
|
, #conectLc#
|
||||||
, #errOccrrAt#
|
, #errOccrrAt#
|
||||||
, #errorCode#
|
, #errorCode#
|
||||||
, SYSDATE())
|
, SYSDATE())
|
||||||
|
|||||||
@ -63,14 +63,20 @@
|
|||||||
( LOG_ID
|
( LOG_ID
|
||||||
, CONECT_MTHD
|
, CONECT_MTHD
|
||||||
, CONECT_ID
|
, CONECT_ID
|
||||||
|
, CONECT_ACNT_ID
|
||||||
|
, CONECT_USER_SE
|
||||||
, CONECT_IP
|
, CONECT_IP
|
||||||
|
, CONECT_LC
|
||||||
, ERROR_OCCRRNC_AT
|
, ERROR_OCCRRNC_AT
|
||||||
, ERROR_CODE
|
, ERROR_CODE
|
||||||
, CREAT_DT )
|
, CREAT_DT )
|
||||||
VALUES ( #logId#
|
VALUES ( #logId#
|
||||||
, #loginMthd#
|
, #loginMthd#
|
||||||
, #loginId#
|
, #loginId#
|
||||||
|
, #conectAcntId#
|
||||||
|
, #conectUserSe#
|
||||||
, #loginIp#
|
, #loginIp#
|
||||||
|
, #conectLc#
|
||||||
, #errOccrrAt#
|
, #errOccrrAt#
|
||||||
, #errorCode#
|
, #errorCode#
|
||||||
, sysdate)
|
, sysdate)
|
||||||
|
|||||||
@ -1,6 +1,17 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
|
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
이름 검색(searchCondition=1) 완전일치 전환 — 개인정보보호 기능개선 사업(추가제안 Action4, 2026-08-10).
|
||||||
|
|
||||||
|
이름을 AES-GCM(랜덤 IV)으로 암호화하면서 부분검색(LIKE '%..%')이 원리적으로 불가능해졌다.
|
||||||
|
아래 5개 조회는 원래 a.MBER_NM LIKE CONCAT('%',#searchKeyword#,'%') 로 부분일치 검색했으나,
|
||||||
|
이제 MBER_NM_HASH = #searchKeywordHash# 완전일치로 바뀌었다.
|
||||||
|
|
||||||
|
#searchKeywordHash# 는 SQL이 계산하지 않는다 — MberManageDAO 가 호출 직전에
|
||||||
|
MemberDataHasher.hashName(searchKeyword) 로 계산해 userSearchVO 에 채워 넣는다
|
||||||
|
(저장 시점과 동일한 정규화 규칙을 한 곳에서만 쓰기 위함). 이 SQL만 고쳐서는 동작하지 않는다.
|
||||||
|
-->
|
||||||
<sqlMap namespace="Mber">
|
<sqlMap namespace="Mber">
|
||||||
|
|
||||||
<typeAlias alias="egovMap" type = "egovframework.rte.psl.dataaccess.util.EgovMap"/>
|
<typeAlias alias="egovMap" type = "egovframework.rte.psl.dataaccess.util.EgovMap"/>
|
||||||
@ -94,7 +105,7 @@ FROM(
|
|||||||
</isEqual>
|
</isEqual>
|
||||||
<isEqual prepend="AND" property="searchCondition" compareValue="1">
|
<isEqual prepend="AND" property="searchCondition" compareValue="1">
|
||||||
<isNotEmpty property="searchKeyword">
|
<isNotEmpty property="searchKeyword">
|
||||||
a.MBER_NM LIKE CONCAT('%',#searchKeyword#,'%')
|
a.MBER_NM_HASH = #searchKeywordHash#
|
||||||
</isNotEmpty>
|
</isNotEmpty>
|
||||||
</isEqual>
|
</isEqual>
|
||||||
<isNotEmpty prepend="AND" property="searchCondition1">
|
<isNotEmpty prepend="AND" property="searchCondition1">
|
||||||
@ -197,7 +208,7 @@ FROM(
|
|||||||
</isEqual>
|
</isEqual>
|
||||||
<isEqual prepend="AND" property="searchCondition" compareValue="1">
|
<isEqual prepend="AND" property="searchCondition" compareValue="1">
|
||||||
<isNotEmpty property="searchKeyword">
|
<isNotEmpty property="searchKeyword">
|
||||||
a.MBER_NM LIKE CONCAT('%',#searchKeyword#,'%')
|
a.MBER_NM_HASH = #searchKeywordHash#
|
||||||
</isNotEmpty>
|
</isNotEmpty>
|
||||||
</isEqual>
|
</isEqual>
|
||||||
<isNotEmpty prepend="AND" property="searchCondition7">
|
<isNotEmpty prepend="AND" property="searchCondition7">
|
||||||
@ -297,7 +308,7 @@ FROM(
|
|||||||
</isEqual>
|
</isEqual>
|
||||||
<isEqual prepend="AND" property="searchCondition" compareValue="1">
|
<isEqual prepend="AND" property="searchCondition" compareValue="1">
|
||||||
<isNotEmpty property="searchKeyword">
|
<isNotEmpty property="searchKeyword">
|
||||||
a.MBER_NM LIKE CONCAT('%',#searchKeyword#,'%')
|
a.MBER_NM_HASH = #searchKeywordHash#
|
||||||
</isNotEmpty>
|
</isNotEmpty>
|
||||||
</isEqual>
|
</isEqual>
|
||||||
<isNotEmpty prepend="AND" property="searchCondition7">
|
<isNotEmpty prepend="AND" property="searchCondition7">
|
||||||
@ -351,7 +362,7 @@ FROM(
|
|||||||
</isEqual>
|
</isEqual>
|
||||||
<isEqual prepend="AND" property="searchCondition" compareValue="1">
|
<isEqual prepend="AND" property="searchCondition" compareValue="1">
|
||||||
<isNotEmpty property="searchKeyword">
|
<isNotEmpty property="searchKeyword">
|
||||||
MBER_NM LIKE CONCAT('%',#searchKeyword#,'%')
|
MBER_NM_HASH = #searchKeywordHash#
|
||||||
</isNotEmpty>
|
</isNotEmpty>
|
||||||
</isEqual>
|
</isEqual>
|
||||||
<isNotEmpty prepend="AND" property="searchCondition1">
|
<isNotEmpty prepend="AND" property="searchCondition1">
|
||||||
@ -381,6 +392,9 @@ FROM(
|
|||||||
ESNTL_ID ,
|
ESNTL_ID ,
|
||||||
MBER_ID ,
|
MBER_ID ,
|
||||||
MBER_NM ,
|
MBER_NM ,
|
||||||
|
MBER_NM_ENC ,
|
||||||
|
MBER_NM_HASH ,
|
||||||
|
CRYPTO_KEY_VER ,
|
||||||
PASSWORD ,
|
PASSWORD ,
|
||||||
IHIDNUM ,
|
IHIDNUM ,
|
||||||
SEXDSTN_CODE ,
|
SEXDSTN_CODE ,
|
||||||
@ -424,6 +438,9 @@ FROM(
|
|||||||
#uniqId#,
|
#uniqId#,
|
||||||
#mberId#,
|
#mberId#,
|
||||||
#mberNm#,
|
#mberNm#,
|
||||||
|
#mberNmEnc#,
|
||||||
|
#mberNmHash#,
|
||||||
|
#cryptoKeyVer#,
|
||||||
#password#,
|
#password#,
|
||||||
#ihidnum#,
|
#ihidnum#,
|
||||||
#sexdstnCode#,
|
#sexdstnCode#,
|
||||||
@ -896,7 +913,7 @@ FROM(
|
|||||||
</isEqual>
|
</isEqual>
|
||||||
<isEqual prepend="AND" property="searchCondition" compareValue="1">
|
<isEqual prepend="AND" property="searchCondition" compareValue="1">
|
||||||
<isNotEmpty property="searchKeyword">
|
<isNotEmpty property="searchKeyword">
|
||||||
a.MBER_NM LIKE CONCAT('%',#searchKeyword#,'%')
|
a.MBER_NM_HASH = #searchKeywordHash#
|
||||||
</isNotEmpty>
|
</isNotEmpty>
|
||||||
</isEqual>
|
</isEqual>
|
||||||
<isNotEmpty prepend="AND" property="searchCondition1">
|
<isNotEmpty prepend="AND" property="searchCondition1">
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
|
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
이름 검색(searchCondition=1) 완전일치 전환(추가제안 Action4, 2026-08-10) — Mysql 파일과 동일 사유.
|
||||||
|
#searchKeywordHash# 는 MberManageDAO 가 호출 전에 채운다. 이 Oracle 파일은 Mysql과 이중 관리되며
|
||||||
|
이미 selectAwardList/selectAwardListCnt 가 누락돼 있었다(기존 drift, 이번 변경으로 생긴 것 아님).
|
||||||
|
-->
|
||||||
<sqlMap namespace="Mber">
|
<sqlMap namespace="Mber">
|
||||||
|
|
||||||
<typeAlias alias="egovMap" type = "egovframework.rte.psl.dataaccess.util.EgovMap"/>
|
<typeAlias alias="egovMap" type = "egovframework.rte.psl.dataaccess.util.EgovMap"/>
|
||||||
@ -69,7 +74,7 @@ SELECT * FROM ( SELECT rownum rn, TB.* FROM (
|
|||||||
a.MBER_ID LIKE #searchKeyword#
|
a.MBER_ID LIKE #searchKeyword#
|
||||||
</isEqual>
|
</isEqual>
|
||||||
<isEqual prepend="AND" property="searchCondition" compareValue="1">
|
<isEqual prepend="AND" property="searchCondition" compareValue="1">
|
||||||
a.MBER_NM LIKE '%' ||#searchKeyword#|| '%'
|
a.MBER_NM_HASH = #searchKeywordHash#
|
||||||
</isEqual>
|
</isEqual>
|
||||||
<isNotEmpty prepend="AND" property="searchCondition1">
|
<isNotEmpty prepend="AND" property="searchCondition1">
|
||||||
a.ACTIVE_SIDO_AREA1 = #searchCondition1#
|
a.ACTIVE_SIDO_AREA1 = #searchCondition1#
|
||||||
@ -105,7 +110,7 @@ SELECT * FROM ( SELECT rownum rn, TB.* FROM (
|
|||||||
MBER_ID LIKE #searchKeyword#
|
MBER_ID LIKE #searchKeyword#
|
||||||
</isEqual>
|
</isEqual>
|
||||||
<isEqual prepend="AND" property="searchCondition" compareValue="1">
|
<isEqual prepend="AND" property="searchCondition" compareValue="1">
|
||||||
MBER_NM LIKE '%' ||#searchKeyword#|| '%'
|
MBER_NM_HASH = #searchKeywordHash#
|
||||||
</isEqual>
|
</isEqual>
|
||||||
<isNotEmpty prepend="AND" property="searchCondition1">
|
<isNotEmpty prepend="AND" property="searchCondition1">
|
||||||
ACTIVE_SIDO_AREA1 = #searchCondition1#
|
ACTIVE_SIDO_AREA1 = #searchCondition1#
|
||||||
@ -134,6 +139,9 @@ SELECT * FROM ( SELECT rownum rn, TB.* FROM (
|
|||||||
ESNTL_ID ,
|
ESNTL_ID ,
|
||||||
MBER_ID ,
|
MBER_ID ,
|
||||||
MBER_NM ,
|
MBER_NM ,
|
||||||
|
MBER_NM_ENC ,
|
||||||
|
MBER_NM_HASH ,
|
||||||
|
CRYPTO_KEY_VER ,
|
||||||
PASSWORD ,
|
PASSWORD ,
|
||||||
IHIDNUM ,
|
IHIDNUM ,
|
||||||
SEXDSTN_CODE ,
|
SEXDSTN_CODE ,
|
||||||
@ -176,6 +184,9 @@ SELECT * FROM ( SELECT rownum rn, TB.* FROM (
|
|||||||
#uniqId#,
|
#uniqId#,
|
||||||
#mberId#,
|
#mberId#,
|
||||||
#mberNm#,
|
#mberNm#,
|
||||||
|
#mberNmEnc#,
|
||||||
|
#mberNmHash#,
|
||||||
|
#cryptoKeyVer#,
|
||||||
#password#,
|
#password#,
|
||||||
#ihidnum#,
|
#ihidnum#,
|
||||||
#sexdstnCode#,
|
#sexdstnCode#,
|
||||||
@ -594,7 +605,7 @@ SELECT * FROM ( SELECT rownum rn, TB.* FROM (
|
|||||||
a.MBER_ID LIKE #searchKeyword#
|
a.MBER_ID LIKE #searchKeyword#
|
||||||
</isEqual>
|
</isEqual>
|
||||||
<isEqual prepend="AND" property="searchCondition" compareValue="1">
|
<isEqual prepend="AND" property="searchCondition" compareValue="1">
|
||||||
a.MBER_NM LIKE '%' ||#searchKeyword#|| '%'
|
a.MBER_NM_HASH = #searchKeywordHash#
|
||||||
</isEqual>
|
</isEqual>
|
||||||
<isNotEmpty prepend="AND" property="searchCondition1">
|
<isNotEmpty prepend="AND" property="searchCondition1">
|
||||||
a.ACTIVE_SIDO_AREA1 = #searchCondition1#
|
a.ACTIVE_SIDO_AREA1 = #searchCondition1#
|
||||||
|
|||||||
@ -115,6 +115,9 @@
|
|||||||
<!-- 개인정보보호(SFR-004) - 업무유형 매핑 조회(#32) -->
|
<!-- 개인정보보호(SFR-004) - 업무유형 매핑 조회(#32) -->
|
||||||
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyJobMapping_SQL_Mysql.xml"/>
|
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyJobMapping_SQL_Mysql.xml"/>
|
||||||
|
|
||||||
|
<!-- 개인정보보호(SFR-004-6) - 접속기록 CSV 비동기 다운로드 작업(PDAL_DOWNLOAD_JOB) -->
|
||||||
|
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyDownloadJob_SQL_Mysql.xml"/>
|
||||||
|
|
||||||
<!-- 개인정보보호(SER-001) - 계정 잠금 해제 이력(#24) -->
|
<!-- 개인정보보호(SER-001) - 계정 잠금 해제 이력(#24) -->
|
||||||
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountUnlockHistory_SQL_Mysql.xml"/>
|
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccountUnlockHistory_SQL_Mysql.xml"/>
|
||||||
|
|
||||||
@ -135,4 +138,16 @@
|
|||||||
|
|
||||||
<!-- 개인정보보호(SER-001) - 인증 실패 이력 조회·파기(#22-1, IP 기록 + LOGIN_HIST_KEEP_DAY 보관기간) -->
|
<!-- 개인정보보호(SER-001) - 인증 실패 이력 조회·파기(#22-1, IP 기록 + LOGIN_HIST_KEEP_DAY 보관기간) -->
|
||||||
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyLoginFailHistory_SQL_Mysql.xml"/>
|
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyLoginFailHistory_SQL_Mysql.xml"/>
|
||||||
|
|
||||||
|
<!-- 개인정보보호(SER-001/SER-002) - 로그인·로그아웃 접속기록 조회·다운로드(#62, COMTNLOGINLOG + 접속위치) -->
|
||||||
|
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyConectLog_SQL_Mysql.xml"/>
|
||||||
|
|
||||||
|
<!-- 개인정보보호(SFR-001 #6) - 권한변경 이력 CSV 비동기 다운로드 작업(AUTHOR_HIST_DOWNLOAD_JOB) -->
|
||||||
|
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorHistDownloadJob_SQL_Mysql.xml"/>
|
||||||
|
|
||||||
|
<!-- 개인정보보호(추가제안 Action4) - 회원 이름(MBER_NM) 암호화 이관 배치(COMTNGNRLMBER + MIG_CRYPTO_PROGRESS) -->
|
||||||
|
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyMberNmCrypto_SQL_Mysql.xml"/>
|
||||||
|
|
||||||
|
<!-- 개인정보보호(SER-001 요건4·5) - 접근통제 정책(ACCESS_CONTROL_POLICY + LOGIN_LOCK_COUNT 동기화) -->
|
||||||
|
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessControlPolicy_SQL_Mysql.xml"/>
|
||||||
</sqlMapConfig>
|
</sqlMapConfig>
|
||||||
|
|||||||
@ -172,6 +172,9 @@
|
|||||||
SET
|
SET
|
||||||
MBER_ID = #mberId#,
|
MBER_ID = #mberId#,
|
||||||
MBER_NM = #mberNm#,
|
MBER_NM = #mberNm#,
|
||||||
|
MBER_NM_ENC = #mberNmEnc#,
|
||||||
|
MBER_NM_HASH = #mberNmHash#,
|
||||||
|
CRYPTO_KEY_VER = #cryptoKeyVer#,
|
||||||
PASSWORD = #password#,
|
PASSWORD = #password#,
|
||||||
SEXDSTN_CODE = #sexdstnCode#,
|
SEXDSTN_CODE = #sexdstnCode#,
|
||||||
BIRTH_YEAR = #birthYear#,
|
BIRTH_YEAR = #birthYear#,
|
||||||
|
|||||||
@ -172,6 +172,9 @@
|
|||||||
SET
|
SET
|
||||||
MBER_ID = #mberId#,
|
MBER_ID = #mberId#,
|
||||||
MBER_NM = #mberNm#,
|
MBER_NM = #mberNm#,
|
||||||
|
MBER_NM_ENC = #mberNmEnc#,
|
||||||
|
MBER_NM_HASH = #mberNmHash#,
|
||||||
|
CRYPTO_KEY_VER = #cryptoKeyVer#,
|
||||||
PASSWORD = #password#,
|
PASSWORD = #password#,
|
||||||
SEXDSTN_CODE = #sexdstnCode#,
|
SEXDSTN_CODE = #sexdstnCode#,
|
||||||
BIRTH_YEAR = #birthYear#,
|
BIRTH_YEAR = #birthYear#,
|
||||||
|
|||||||
@ -0,0 +1,70 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
개인정보보호 기능개선 사업 (SER-001 요건 4·5) — 접근통제 정책.
|
||||||
|
「개인정보보호강화 파일럿 개발항목」 접근통제 정책 적용(Action2) 시안 반영.
|
||||||
|
|
||||||
|
· ACCESS_CONTROL_POLICY(단일행) 조회/수정
|
||||||
|
· 잠금 임계값을 인증 경로가 읽는 LOGIN_LOCK_COUNT 로 동기화
|
||||||
|
|
||||||
|
[LOGIN_LOCK_COUNT 동기화 이유]
|
||||||
|
인증 실패 잠금 판정은 기존 자산(loginDAO.selectFailTotalCount → LOGIN_LOCK_COUNT)을
|
||||||
|
그대로 쓴다. 정책 테이블만 고치면 화면 숫자만 바뀌고 실제 인증 경로는 옛 값을 계속 쓰므로,
|
||||||
|
저장 시 두 곳을 함께 맞춘다. LOGIN_LOCK_COUNT 는 기존 테이블이라 스키마 변경이 없다.
|
||||||
|
|
||||||
|
[주의] iBatis 2 는 '#' 을 파라미터 구분자로 쓴다. SQL 주석에 '#' 을 쓰지 말 것.
|
||||||
|
-->
|
||||||
|
<sqlMap namespace="AccessControlPolicyDAO">
|
||||||
|
|
||||||
|
<typeAlias alias="accessControlPolicyVO"
|
||||||
|
type="egovframework.com.cmm.privacy.policy.AccessControlPolicyVO"/>
|
||||||
|
|
||||||
|
<!-- ================= 정책 조회(단일행) ================= -->
|
||||||
|
<select id="AccessControlPolicyDAO.selectPolicy" resultClass="accessControlPolicyVO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT POLICY_ID AS policyId,
|
||||||
|
LOCK_THRESHOLD AS lockThreshold,
|
||||||
|
UNLOCK_MODE AS unlockMode,
|
||||||
|
AUTO_LOGOUT_MIN AS autoLogoutMin,
|
||||||
|
EXPIRE_NOTICE_YN AS expireNoticeYn,
|
||||||
|
EXPIRE_NOTICE_MIN AS expireNoticeMin,
|
||||||
|
SESSION_REGEN_YN AS sessionRegenYn,
|
||||||
|
UPD_ID AS updId,
|
||||||
|
DATE_FORMAT(UPD_DT, '%Y-%m-%d %H:%i:%s') AS updDt
|
||||||
|
FROM ACCESS_CONTROL_POLICY
|
||||||
|
WHERE POLICY_ID = 1
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- ================= 정책 수정 ================= -->
|
||||||
|
<update id="AccessControlPolicyDAO.updatePolicy" parameterClass="accessControlPolicyVO">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE ACCESS_CONTROL_POLICY
|
||||||
|
SET LOCK_THRESHOLD = #lockThreshold#,
|
||||||
|
UNLOCK_MODE = #unlockMode#,
|
||||||
|
AUTO_LOGOUT_MIN = #autoLogoutMin#,
|
||||||
|
EXPIRE_NOTICE_YN = #expireNoticeYn#,
|
||||||
|
EXPIRE_NOTICE_MIN = #expireNoticeMin#,
|
||||||
|
SESSION_REGEN_YN = #sessionRegenYn#,
|
||||||
|
UPD_ID = #updId#,
|
||||||
|
UPD_DT = NOW()
|
||||||
|
WHERE POLICY_ID = 1
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<!-- ================= 잠금 임계값 동기화(기존 테이블) ================= -->
|
||||||
|
<update id="AccessControlPolicyDAO.updateLoginLockCount" parameterClass="accessControlPolicyVO">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE LOGIN_LOCK_COUNT
|
||||||
|
SET LOCK_COUNT = #lockThreshold#
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<insert id="AccessControlPolicyDAO.insertLoginLockCount" parameterClass="accessControlPolicyVO">
|
||||||
|
<![CDATA[
|
||||||
|
INSERT INTO LOGIN_LOCK_COUNT (LOCK_COUNT) VALUES (#lockThreshold#)
|
||||||
|
]]>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
</sqlMap>
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
개인정보보호 기능개선 사업 (SER-001 요건 4·5) — 접근통제 정책 Oracle 쌍(이중관리 규약).
|
||||||
|
※ arko 런타임은 MySQL sqlmap 만 로드. 본 파일은 이중관리 관례상 병행 유지.
|
||||||
|
2026-08-10: 접근통제 정책 적용 화면 신규.
|
||||||
|
-->
|
||||||
|
<sqlMap namespace="AccessControlPolicyDAO">
|
||||||
|
|
||||||
|
<typeAlias alias="accessControlPolicyVO"
|
||||||
|
type="egovframework.com.cmm.privacy.policy.AccessControlPolicyVO"/>
|
||||||
|
|
||||||
|
<select id="AccessControlPolicyDAO.selectPolicy" resultClass="accessControlPolicyVO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT POLICY_ID AS policyId,
|
||||||
|
LOCK_THRESHOLD AS lockThreshold,
|
||||||
|
UNLOCK_MODE AS unlockMode,
|
||||||
|
AUTO_LOGOUT_MIN AS autoLogoutMin,
|
||||||
|
EXPIRE_NOTICE_YN AS expireNoticeYn,
|
||||||
|
EXPIRE_NOTICE_MIN AS expireNoticeMin,
|
||||||
|
SESSION_REGEN_YN AS sessionRegenYn,
|
||||||
|
UPD_ID AS updId,
|
||||||
|
TO_CHAR(UPD_DT, 'YYYY-MM-DD HH24:MI:SS') AS updDt
|
||||||
|
FROM ACCESS_CONTROL_POLICY
|
||||||
|
WHERE POLICY_ID = 1
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<update id="AccessControlPolicyDAO.updatePolicy" parameterClass="accessControlPolicyVO">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE ACCESS_CONTROL_POLICY
|
||||||
|
SET LOCK_THRESHOLD = #lockThreshold#,
|
||||||
|
UNLOCK_MODE = #unlockMode#,
|
||||||
|
AUTO_LOGOUT_MIN = #autoLogoutMin#,
|
||||||
|
EXPIRE_NOTICE_YN = #expireNoticeYn#,
|
||||||
|
EXPIRE_NOTICE_MIN = #expireNoticeMin#,
|
||||||
|
SESSION_REGEN_YN = #sessionRegenYn#,
|
||||||
|
UPD_ID = #updId#,
|
||||||
|
UPD_DT = SYSDATE
|
||||||
|
WHERE POLICY_ID = 1
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="AccessControlPolicyDAO.updateLoginLockCount" parameterClass="accessControlPolicyVO">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE LOGIN_LOCK_COUNT SET LOCK_COUNT = #lockThreshold#
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<insert id="AccessControlPolicyDAO.insertLoginLockCount" parameterClass="accessControlPolicyVO">
|
||||||
|
<![CDATA[
|
||||||
|
INSERT INTO LOGIN_LOCK_COUNT (LOCK_COUNT) VALUES (#lockThreshold#)
|
||||||
|
]]>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
</sqlMap>
|
||||||
@ -104,14 +104,17 @@
|
|||||||
PREV_HASH_VAL AS prevHashVal
|
PREV_HASH_VAL AS prevHashVal
|
||||||
FROM PERSONAL_DATA_ACCESS_LOG
|
FROM PERSONAL_DATA_ACCESS_LOG
|
||||||
<dynamic prepend="WHERE">
|
<dynamic prepend="WHERE">
|
||||||
<isNotEmpty prepend="AND" property="conectId"> CONECT_ID = #conectId# </isNotEmpty>
|
<!-- 2026-08-07 검색 개선: ID·IP 는 전방일치(LIKE ... ESCAPE '!'), 기간은 종료일 포함 보정.
|
||||||
|
바인딩값은 PersonalDataAccessLogSearchVO 의 파생 getter가 만든다(방언 무관).
|
||||||
|
판정(isNotEmpty)은 원본 필드로, 바인딩은 파생값으로 하는 짝을 지킬 것. -->
|
||||||
|
<isNotEmpty prepend="AND" property="conectId"> CONECT_ID LIKE #conectIdLike# ESCAPE '!' </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="jobSeCode"> JOB_SE_CODE = #jobSeCode# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="jobSeCode"> JOB_SE_CODE = #jobSeCode# </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="trgetId"> TRGET_ID = #trgetId# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="trgetId"> TRGET_ID LIKE #trgetIdLike# ESCAPE '!' </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="conectIp"> CONECT_IP = #conectIp# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="conectIp"> CONECT_IP LIKE #conectIpLike# ESCAPE '!' </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="dmlGubun"> DML_GUBUN = #dmlGubun# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="dmlGubun"> DML_GUBUN = #dmlGubun# </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="processResult"> PROCESS_RESULT = #processResult# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="processResult"> PROCESS_RESULT = #processResult# </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="fromDt"> CONECT_DT >= #fromDt# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="fromDt"> CONECT_DT >= #fromDtParam# </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="toDt"> CONECT_DT <= #toDt# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="toDt"> CONECT_DT <= #toDtParam# </isNotEmpty>
|
||||||
</dynamic>
|
</dynamic>
|
||||||
ORDER BY LOG_ID DESC
|
ORDER BY LOG_ID DESC
|
||||||
LIMIT #firstIndex#, #recordCountPerPage#
|
LIMIT #firstIndex#, #recordCountPerPage#
|
||||||
@ -123,17 +126,49 @@
|
|||||||
SELECT COUNT(1)
|
SELECT COUNT(1)
|
||||||
FROM PERSONAL_DATA_ACCESS_LOG
|
FROM PERSONAL_DATA_ACCESS_LOG
|
||||||
<dynamic prepend="WHERE">
|
<dynamic prepend="WHERE">
|
||||||
<isNotEmpty prepend="AND" property="conectId"> CONECT_ID = #conectId# </isNotEmpty>
|
<!-- 2026-08-07 검색 개선: ID·IP 는 전방일치(LIKE ... ESCAPE '!'), 기간은 종료일 포함 보정.
|
||||||
|
바인딩값은 PersonalDataAccessLogSearchVO 의 파생 getter가 만든다(방언 무관).
|
||||||
|
판정(isNotEmpty)은 원본 필드로, 바인딩은 파생값으로 하는 짝을 지킬 것. -->
|
||||||
|
<isNotEmpty prepend="AND" property="conectId"> CONECT_ID LIKE #conectIdLike# ESCAPE '!' </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="jobSeCode"> JOB_SE_CODE = #jobSeCode# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="jobSeCode"> JOB_SE_CODE = #jobSeCode# </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="trgetId"> TRGET_ID = #trgetId# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="trgetId"> TRGET_ID LIKE #trgetIdLike# ESCAPE '!' </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="conectIp"> CONECT_IP = #conectIp# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="conectIp"> CONECT_IP LIKE #conectIpLike# ESCAPE '!' </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="dmlGubun"> DML_GUBUN = #dmlGubun# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="dmlGubun"> DML_GUBUN = #dmlGubun# </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="processResult"> PROCESS_RESULT = #processResult# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="processResult"> PROCESS_RESULT = #processResult# </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="fromDt"> CONECT_DT >= #fromDt# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="fromDt"> CONECT_DT >= #fromDtParam# </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="toDt"> CONECT_DT <= #toDt# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="toDt"> CONECT_DT <= #toDtParam# </isNotEmpty>
|
||||||
</dynamic>
|
</dynamic>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
[SFR-004 / 2026.08.07] 처리구분별 건수 요약.
|
||||||
|
조회화면에서 "이 취급자가 개인정보를 조회만 했는지, 수정·삭제까지 했는지"를 목록을 넘겨보지 않고
|
||||||
|
바로 확인하기 위한 집계다. 검색조건은 목록(selectAccessLogList)과 동일해야 요약과 목록이 어긋나지 않는다
|
||||||
|
— 아래 동적 WHERE 는 목록/카운트 쿼리와 같은 조건을 유지할 것.
|
||||||
|
DML_GUBUN 자체가 검색조건이면 그 한 줄만 나오는 게 정상이다.
|
||||||
|
-->
|
||||||
|
<select id="PersonalDataAccessLogDAO.selectAccessLogDmlSummary"
|
||||||
|
parameterClass="egovframework.com.cmm.privacy.auditlog.PersonalDataAccessLogSearchVO"
|
||||||
|
resultClass="java.util.HashMap">
|
||||||
|
SELECT DML_GUBUN AS dmlGubun,
|
||||||
|
COUNT(1) AS cnt
|
||||||
|
FROM PERSONAL_DATA_ACCESS_LOG
|
||||||
|
<dynamic prepend="WHERE">
|
||||||
|
<!-- 2026-08-07 검색 개선: ID·IP 는 전방일치(LIKE ... ESCAPE '!'), 기간은 종료일 포함 보정.
|
||||||
|
바인딩값은 PersonalDataAccessLogSearchVO 의 파생 getter가 만든다(방언 무관).
|
||||||
|
판정(isNotEmpty)은 원본 필드로, 바인딩은 파생값으로 하는 짝을 지킬 것. -->
|
||||||
|
<isNotEmpty prepend="AND" property="conectId"> CONECT_ID LIKE #conectIdLike# ESCAPE '!' </isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="jobSeCode"> JOB_SE_CODE = #jobSeCode# </isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="trgetId"> TRGET_ID LIKE #trgetIdLike# ESCAPE '!' </isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="conectIp"> CONECT_IP LIKE #conectIpLike# ESCAPE '!' </isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="dmlGubun"> DML_GUBUN = #dmlGubun# </isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="processResult"> PROCESS_RESULT = #processResult# </isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="fromDt"> CONECT_DT >= #fromDtParam# </isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="toDt"> CONECT_DT <= #toDtParam# </isNotEmpty>
|
||||||
|
</dynamic>
|
||||||
|
GROUP BY DML_GUBUN
|
||||||
|
</select>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
[SFR-004 / 2026.08.07] 조회화면 '수행업무' 드롭다운 소스.
|
[SFR-004 / 2026.08.07] 조회화면 '수행업무' 드롭다운 소스.
|
||||||
종전에는 PDAL_JOB_MAPPING(서비스 클래스·메서드 매핑)을 소스로 썼으나, 수집이
|
종전에는 PDAL_JOB_MAPPING(서비스 클래스·메서드 매핑)을 소스로 썼으나, 수집이
|
||||||
|
|||||||
@ -89,14 +89,15 @@
|
|||||||
HASH_VAL AS hashVal, PREV_HASH_VAL AS prevHashVal
|
HASH_VAL AS hashVal, PREV_HASH_VAL AS prevHashVal
|
||||||
FROM PERSONAL_DATA_ACCESS_LOG
|
FROM PERSONAL_DATA_ACCESS_LOG
|
||||||
<dynamic prepend="WHERE">
|
<dynamic prepend="WHERE">
|
||||||
<isNotEmpty prepend="AND" property="conectId"> CONECT_ID = #conectId# </isNotEmpty>
|
<!-- 2026-08-07 검색 개선 — MySQL 쌍과 동일 의미(파생 getter 바인딩). -->
|
||||||
|
<isNotEmpty prepend="AND" property="conectId"> CONECT_ID LIKE #conectIdLike# ESCAPE '!' </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="jobSeCode"> JOB_SE_CODE = #jobSeCode# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="jobSeCode"> JOB_SE_CODE = #jobSeCode# </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="trgetId"> TRGET_ID = #trgetId# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="trgetId"> TRGET_ID LIKE #trgetIdLike# ESCAPE '!' </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="conectIp"> CONECT_IP = #conectIp# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="conectIp"> CONECT_IP LIKE #conectIpLike# ESCAPE '!' </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="dmlGubun"> DML_GUBUN = #dmlGubun# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="dmlGubun"> DML_GUBUN = #dmlGubun# </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="processResult"> PROCESS_RESULT = #processResult# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="processResult"> PROCESS_RESULT = #processResult# </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="fromDt"> CONECT_DT >= #fromDt# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="fromDt"> CONECT_DT >= #fromDtParam# </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="toDt"> CONECT_DT <= #toDt# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="toDt"> CONECT_DT <= #toDtParam# </isNotEmpty>
|
||||||
</dynamic>
|
</dynamic>
|
||||||
ORDER BY LOG_ID DESC
|
ORDER BY LOG_ID DESC
|
||||||
) T WHERE ROWNUM <= #firstIndex# + #recordCountPerPage#
|
) T WHERE ROWNUM <= #firstIndex# + #recordCountPerPage#
|
||||||
@ -109,17 +110,39 @@
|
|||||||
SELECT COUNT(1)
|
SELECT COUNT(1)
|
||||||
FROM PERSONAL_DATA_ACCESS_LOG
|
FROM PERSONAL_DATA_ACCESS_LOG
|
||||||
<dynamic prepend="WHERE">
|
<dynamic prepend="WHERE">
|
||||||
<isNotEmpty prepend="AND" property="conectId"> CONECT_ID = #conectId# </isNotEmpty>
|
<!-- 2026-08-07 검색 개선 — MySQL 쌍과 동일 의미(파생 getter 바인딩). -->
|
||||||
|
<isNotEmpty prepend="AND" property="conectId"> CONECT_ID LIKE #conectIdLike# ESCAPE '!' </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="jobSeCode"> JOB_SE_CODE = #jobSeCode# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="jobSeCode"> JOB_SE_CODE = #jobSeCode# </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="trgetId"> TRGET_ID = #trgetId# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="trgetId"> TRGET_ID LIKE #trgetIdLike# ESCAPE '!' </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="conectIp"> CONECT_IP = #conectIp# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="conectIp"> CONECT_IP LIKE #conectIpLike# ESCAPE '!' </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="dmlGubun"> DML_GUBUN = #dmlGubun# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="dmlGubun"> DML_GUBUN = #dmlGubun# </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="processResult"> PROCESS_RESULT = #processResult# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="processResult"> PROCESS_RESULT = #processResult# </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="fromDt"> CONECT_DT >= #fromDt# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="fromDt"> CONECT_DT >= #fromDtParam# </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="toDt"> CONECT_DT <= #toDt# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="toDt"> CONECT_DT <= #toDtParam# </isNotEmpty>
|
||||||
</dynamic>
|
</dynamic>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!-- [SFR-004 / 2026.08.07] 처리구분별 건수 요약 — MySQL판과 동일 의미(문법 차이 없음). -->
|
||||||
|
<select id="PersonalDataAccessLogDAO.selectAccessLogDmlSummary"
|
||||||
|
parameterClass="egovframework.com.cmm.privacy.auditlog.PersonalDataAccessLogSearchVO"
|
||||||
|
resultClass="java.util.HashMap">
|
||||||
|
SELECT DML_GUBUN AS dmlGubun,
|
||||||
|
COUNT(1) AS cnt
|
||||||
|
FROM PERSONAL_DATA_ACCESS_LOG
|
||||||
|
<dynamic prepend="WHERE">
|
||||||
|
<!-- 2026-08-07 검색 개선 — MySQL 쌍과 동일 의미(파생 getter 바인딩). -->
|
||||||
|
<isNotEmpty prepend="AND" property="conectId"> CONECT_ID LIKE #conectIdLike# ESCAPE '!' </isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="jobSeCode"> JOB_SE_CODE = #jobSeCode# </isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="trgetId"> TRGET_ID LIKE #trgetIdLike# ESCAPE '!' </isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="conectIp"> CONECT_IP LIKE #conectIpLike# ESCAPE '!' </isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="dmlGubun"> DML_GUBUN = #dmlGubun# </isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="processResult"> PROCESS_RESULT = #processResult# </isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="fromDt"> CONECT_DT >= #fromDtParam# </isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="toDt"> CONECT_DT <= #toDtParam# </isNotEmpty>
|
||||||
|
</dynamic>
|
||||||
|
GROUP BY DML_GUBUN
|
||||||
|
</select>
|
||||||
|
|
||||||
<!-- [SFR-004 / 2026.08.07] 조회화면 '수행업무' 드롭다운 소스 — MySQL판과 동일 의미. -->
|
<!-- [SFR-004 / 2026.08.07] 조회화면 '수행업무' 드롭다운 소스 — MySQL판과 동일 의미. -->
|
||||||
<select id="PersonalDataAccessLogDAO.selectJobSeCodeList"
|
<select id="PersonalDataAccessLogDAO.selectJobSeCodeList"
|
||||||
resultClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO">
|
resultClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO">
|
||||||
|
|||||||
@ -46,11 +46,19 @@
|
|||||||
]]>
|
]]>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- 일반회원 개인정보 파기(마스킹) — 기존 회원탈퇴(MBER_STTUS='D') 컨벤션과 동일 규칙 재사용 -->
|
<!--
|
||||||
|
일반회원 개인정보 파기(마스킹) — 기존 회원탈퇴(MBER_STTUS='D') 컨벤션과 동일 규칙 재사용.
|
||||||
|
|
||||||
|
MBER_NM_ENC/MBER_NM_HASH 를 함께 NULL 처리한다(추가제안 Action4, 2026-08-10).
|
||||||
|
평문 MBER_NM 은 첫 글자+마스킹으로 바뀌는데 암호문 컬럼을 그대로 두면, 파기된 계정도
|
||||||
|
키를 가진 사람이 복호화하면 마스킹 이전 실명이 그대로 나온다 — 마스킹이 무효화된다.
|
||||||
|
-->
|
||||||
<update id="AccountDeleteDAO.anonymizeGnrMember" parameterClass="java.lang.String">
|
<update id="AccountDeleteDAO.anonymizeGnrMember" parameterClass="java.lang.String">
|
||||||
<![CDATA[
|
<![CDATA[
|
||||||
UPDATE COMTNGNRLMBER
|
UPDATE COMTNGNRLMBER
|
||||||
SET MBER_NM = CONCAT(LEFT(MBER_NM,1), REPEAT('*', GREATEST(CHAR_LENGTH(MBER_NM)-1,0)))
|
SET MBER_NM = CONCAT(LEFT(MBER_NM,1), REPEAT('*', GREATEST(CHAR_LENGTH(MBER_NM)-1,0)))
|
||||||
|
, MBER_NM_ENC = NULL
|
||||||
|
, MBER_NM_HASH = NULL
|
||||||
, MBER_EMAIL_ADRES = NULL
|
, MBER_EMAIL_ADRES = NULL
|
||||||
, MBTLNUM = NULL
|
, MBTLNUM = NULL
|
||||||
, MBER_FXNUM = NULL
|
, MBER_FXNUM = NULL
|
||||||
|
|||||||
@ -36,10 +36,16 @@
|
|||||||
]]>
|
]]>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
MBER_NM_ENC/MBER_NM_HASH 를 함께 NULL 처리한다(추가제안 Action4, 2026-08-10).
|
||||||
|
암호문을 남겨두면 파기 후에도 키를 가진 사람이 복호화해 마스킹 이전 실명을 복원할 수 있다.
|
||||||
|
-->
|
||||||
<update id="AccountDeleteDAO.anonymizeGnrMember" parameterClass="java.lang.String">
|
<update id="AccountDeleteDAO.anonymizeGnrMember" parameterClass="java.lang.String">
|
||||||
<![CDATA[
|
<![CDATA[
|
||||||
UPDATE COMTNGNRLMBER
|
UPDATE COMTNGNRLMBER
|
||||||
SET MBER_NM = SUBSTR(MBER_NM,1,1) || RPAD('*', GREATEST(LENGTH(MBER_NM)-1,0), '*')
|
SET MBER_NM = SUBSTR(MBER_NM,1,1) || RPAD('*', GREATEST(LENGTH(MBER_NM)-1,0), '*')
|
||||||
|
, MBER_NM_ENC = NULL
|
||||||
|
, MBER_NM_HASH = NULL
|
||||||
, MBER_EMAIL_ADRES = NULL
|
, MBER_EMAIL_ADRES = NULL
|
||||||
, MBTLNUM = NULL
|
, MBTLNUM = NULL
|
||||||
, MBER_FXNUM = NULL
|
, MBER_FXNUM = NULL
|
||||||
|
|||||||
@ -6,9 +6,26 @@
|
|||||||
· #13/#9: ACCOUNT_MNG_POLICY 조회/수정(단일행)
|
· #13/#9: ACCOUNT_MNG_POLICY 조회/수정(단일행)
|
||||||
· #11 : 장기 미사용 계정 현황 조회(취급자 COMTNEMPLYRINFO + 최종로그인 기준)
|
· #11 : 장기 미사용 계정 현황 조회(취급자 COMTNEMPLYRINFO + 최종로그인 기준)
|
||||||
· #10 : 휴면 사전알림 대상(D-N일 전) 조회
|
· #10 : 휴면 사전알림 대상(D-N일 전) 조회
|
||||||
|
· #11-2 : 현황 화면 조치(잠금=휴면 확정 / 말소) + 조치 이력 적재 (2026-08-10, 시안 반영)
|
||||||
|
|
||||||
|
[파생 계정상태 acntState] 화면 표시용 상태로, SQL이 정책값 기준으로 계산한다.
|
||||||
|
D(말소) : EMPLYR_STTUS_CODE='D'
|
||||||
|
L(휴면·잠금) : EMPLYR_STTUS_CODE='S' 이거나 휴면 확정(COMTNDIAPAUSEMBER.DIAPAUSE_STAT='P')
|
||||||
|
이거나 미접속일수 >= DORMANT_DAY
|
||||||
|
W(휴면예정) : 미접속일수 >= (DORMANT_DAY - ALERT_D1)
|
||||||
|
N(사용) : 그 외
|
||||||
|
기준일수는 하드코딩하지 않고 ACCOUNT_MNG_POLICY(#9/#13)에서 그때그때 읽는다.
|
||||||
|
(정책 기본값 DORMANT_DAY=90 / ALERT_D1=30 이면 시안의 '60일 휴면예정 / 90일 자동잠금'과 일치)
|
||||||
-->
|
-->
|
||||||
<sqlMap namespace="AccountMngDAO">
|
<sqlMap namespace="AccountMngDAO">
|
||||||
|
|
||||||
|
<typeAlias alias="dormantAccountStatusVO"
|
||||||
|
type="egovframework.com.cmm.privacy.account.DormantAccountStatusVO"/>
|
||||||
|
<typeAlias alias="dormantAccountSummaryVO"
|
||||||
|
type="egovframework.com.cmm.privacy.account.DormantAccountSummaryVO"/>
|
||||||
|
<typeAlias alias="accountActionHistoryVO"
|
||||||
|
type="egovframework.com.cmm.privacy.account.AccountActionHistoryVO"/>
|
||||||
|
|
||||||
<!-- ================= #13/#9 계정 관리 정책 ================= -->
|
<!-- ================= #13/#9 계정 관리 정책 ================= -->
|
||||||
<select id="AccountMngDAO.selectPolicy"
|
<select id="AccountMngDAO.selectPolicy"
|
||||||
resultClass="egovframework.com.cmm.privacy.account.AccountMngPolicyVO">
|
resultClass="egovframework.com.cmm.privacy.account.AccountMngPolicyVO">
|
||||||
@ -50,55 +67,211 @@
|
|||||||
</update>
|
</update>
|
||||||
|
|
||||||
<!-- ================= #11 장기 미사용 계정 현황(취급자) ================= -->
|
<!-- ================= #11 장기 미사용 계정 현황(취급자) ================= -->
|
||||||
<!-- 미접속일수 = 오늘 - 최종로그인(없으면 가입일). 휴면예정일 = 최종로그인 + 기준일수. -->
|
<!--
|
||||||
<select id="AccountMngDAO.selectDormantStatusList"
|
목록·건수·요약이 같은 파생식을 쓰도록 SELECT 절과 FROM 절을 공유한다.
|
||||||
parameterClass="egovframework.com.cmm.privacy.account.DormantAccountStatusVO"
|
미접속일수 = 오늘 - 최종로그인(없으면 가입일). 휴면예정일 = 최종로그인 + 기준일수.
|
||||||
resultClass="egovframework.com.cmm.privacy.account.DormantAccountStatusVO">
|
휴면 확정 여부 판정을 위해 COMTNDIAPAUSEMBER 를 계정ID+고유ID 로 붙인다
|
||||||
<![CDATA[
|
(일반회원과 ID가 겹쳐도 서로를 물지 않도록 ESNTL_ID 동시 비교).
|
||||||
SELECT e.EMPLYR_ID AS accountId,
|
-->
|
||||||
|
<sql id="AccountMngDAO.dormantSelectCols">
|
||||||
|
e.EMPLYR_ID AS accountId,
|
||||||
e.USER_NM AS userNm,
|
e.USER_NM AS userNm,
|
||||||
e.ORGNZT_ID AS orgnztId,
|
e.ORGNZT_ID AS orgnztId,
|
||||||
e.OFCPS_NM AS ofcpsNm,
|
e.OFCPS_NM AS ofcpsNm,
|
||||||
|
e.ESNTL_ID AS esntlId,
|
||||||
DATE_FORMAT(IFNULL(e.LAST_LOGIN_DT, e.SBSCRB_DE), '%Y-%m-%d') AS lastLoginDt,
|
DATE_FORMAT(IFNULL(e.LAST_LOGIN_DT, e.SBSCRB_DE), '%Y-%m-%d') AS lastLoginDt,
|
||||||
DATEDIFF(NOW(), IFNULL(e.LAST_LOGIN_DT, e.SBSCRB_DE)) AS noConnectDays,
|
DATEDIFF(NOW(), IFNULL(e.LAST_LOGIN_DT, e.SBSCRB_DE)) AS noConnectDays,
|
||||||
e.EMPLYR_STTUS_CODE AS sttusCode,
|
e.EMPLYR_STTUS_CODE AS sttusCode,
|
||||||
DATE_FORMAT(DATE_ADD(IFNULL(e.LAST_LOGIN_DT, e.SBSCRB_DE),
|
DATE_FORMAT(DATE_ADD(IFNULL(e.LAST_LOGIN_DT, e.SBSCRB_DE),
|
||||||
INTERVAL (SELECT IFNULL(MAX(DORMANT_DAY),365) FROM ACCOUNT_MNG_POLICY) DAY), '%Y-%m-%d') AS dormantExpectDt,
|
INTERVAL p.DORMANT_DAY DAY), '%Y-%m-%d') AS dormantExpectDt,
|
||||||
|
CASE WHEN e.EMPLYR_STTUS_CODE = 'D' THEN 'D'
|
||||||
|
WHEN e.EMPLYR_STTUS_CODE = 'S' THEN 'L'
|
||||||
|
WHEN d.DIAPAUSE_STAT = 'P' THEN 'L'
|
||||||
|
WHEN DATEDIFF(NOW(), IFNULL(e.LAST_LOGIN_DT, e.SBSCRB_DE)) >= p.DORMANT_DAY THEN 'L'
|
||||||
|
WHEN DATEDIFF(NOW(), IFNULL(e.LAST_LOGIN_DT, e.SBSCRB_DE)) >= (p.DORMANT_DAY - p.ALERT_D1) THEN 'W'
|
||||||
|
ELSE 'N' END AS acntState,
|
||||||
'EMP' AS mberTyCode
|
'EMP' AS mberTyCode
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<sql id="AccountMngDAO.dormantFrom">
|
||||||
FROM COMTNEMPLYRINFO e
|
FROM COMTNEMPLYRINFO e
|
||||||
]]>
|
LEFT JOIN COMTNDIAPAUSEMBER d
|
||||||
|
ON d.MBER_ID = e.EMPLYR_ID
|
||||||
|
AND d.ESNTL_ID = e.ESNTL_ID
|
||||||
|
CROSS JOIN (SELECT IFNULL(MAX(DORMANT_DAY), 365) AS DORMANT_DAY,
|
||||||
|
IFNULL(MAX(ALERT_D1), 30) AS ALERT_D1
|
||||||
|
FROM ACCOUNT_MNG_POLICY) p
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<!-- 파생 컬럼(acntState 등)으로 걸러야 하므로 인라인뷰 바깥에서 조건을 건다. -->
|
||||||
|
<sql id="AccountMngDAO.dormantWhere">
|
||||||
<dynamic prepend="WHERE">
|
<dynamic prepend="WHERE">
|
||||||
<isNotEmpty prepend="AND" property="accountId"> e.EMPLYR_ID = #accountId# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="accountId"> t.accountId = #accountId# </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="userNm"> e.USER_NM LIKE CONCAT('%', #userNm#, '%') </isNotEmpty>
|
<isNotEmpty prepend="AND" property="userNm"> t.userNm LIKE CONCAT('%', #userNm#, '%') </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="sttusCode"> e.EMPLYR_STTUS_CODE = #sttusCode# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="searchKeyword">
|
||||||
|
(t.userNm LIKE CONCAT('%', #searchKeyword#, '%') OR t.accountId LIKE CONCAT('%', #searchKeyword#, '%'))
|
||||||
|
</isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="sttusCode"> t.sttusCode = #sttusCode# </isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="searchState"> t.acntState = #searchState# </isNotEmpty>
|
||||||
<isGreaterThan prepend="AND" property="minNoConnectDays" compareValue="0">
|
<isGreaterThan prepend="AND" property="minNoConnectDays" compareValue="0">
|
||||||
DATEDIFF(NOW(), IFNULL(e.LAST_LOGIN_DT, e.SBSCRB_DE)) >= #minNoConnectDays#
|
t.noConnectDays >= #minNoConnectDays#
|
||||||
</isGreaterThan>
|
</isGreaterThan>
|
||||||
</dynamic>
|
</dynamic>
|
||||||
ORDER BY noConnectDays DESC
|
</sql>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
역할명(roleNm)은 인라인뷰 '바깥'에서 뽑는다. 안쪽에 두면 건수·요약 조회에서도 계정마다
|
||||||
|
COMTNEMPLYRSCRTYESTBS 를 훑게 되어 조회가 크게 느려진다(권한매핑 8만행 기준 실측 계정당
|
||||||
|
~200ms). 목록에서만 필요한 값이므로 목록 statement 에서만 계산한다.
|
||||||
|
※ 함께 배포되는 privacy.emplyr_scrty_index_mysql.sql(SCRTY_DTRMN_TRGET_ID 인덱스)이
|
||||||
|
적용돼야 이 조회와 로그인 시 권한조회가 모두 정상 속도로 동작한다.
|
||||||
|
-->
|
||||||
|
<select id="AccountMngDAO.selectDormantStatusList"
|
||||||
|
parameterClass="dormantAccountStatusVO"
|
||||||
|
resultClass="dormantAccountStatusVO">
|
||||||
|
SELECT t.*,
|
||||||
|
(SELECT GROUP_CONCAT(
|
||||||
|
COALESCE(NULLIF(NULLIF(a.AUTHOR_DC,''),'***'),
|
||||||
|
NULLIF(NULLIF(a.AUTHOR_NM,''),'***'),
|
||||||
|
a.AUTHOR_CODE)
|
||||||
|
ORDER BY a.AUTHOR_CODE SEPARATOR ', ')
|
||||||
|
FROM COMTNEMPLYRSCRTYESTBS s
|
||||||
|
JOIN COMTNAUTHORINFO a ON a.AUTHOR_CODE = s.AUTHOR_CODE
|
||||||
|
WHERE s.SCRTY_DTRMN_TRGET_ID = t.esntlId) AS roleNm
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
<include refid="AccountMngDAO.dormantSelectCols"/>
|
||||||
|
<include refid="AccountMngDAO.dormantFrom"/>
|
||||||
|
) t
|
||||||
|
<include refid="AccountMngDAO.dormantWhere"/>
|
||||||
|
ORDER BY t.noConnectDays DESC, t.accountId
|
||||||
LIMIT #firstIndex#, #recordCountPerPage#
|
LIMIT #firstIndex#, #recordCountPerPage#
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="AccountMngDAO.selectDormantStatusListCount"
|
<select id="AccountMngDAO.selectDormantStatusListCount"
|
||||||
parameterClass="egovframework.com.cmm.privacy.account.DormantAccountStatusVO"
|
parameterClass="dormantAccountStatusVO"
|
||||||
resultClass="java.lang.Integer">
|
resultClass="java.lang.Integer">
|
||||||
SELECT COUNT(1)
|
SELECT COUNT(1) FROM (
|
||||||
FROM COMTNEMPLYRINFO e
|
SELECT
|
||||||
<dynamic prepend="WHERE">
|
<include refid="AccountMngDAO.dormantSelectCols"/>
|
||||||
<isNotEmpty prepend="AND" property="accountId"> e.EMPLYR_ID = #accountId# </isNotEmpty>
|
<include refid="AccountMngDAO.dormantFrom"/>
|
||||||
<isNotEmpty prepend="AND" property="userNm"> e.USER_NM LIKE CONCAT('%', #userNm#, '%') </isNotEmpty>
|
) t
|
||||||
<isNotEmpty prepend="AND" property="sttusCode"> e.EMPLYR_STTUS_CODE = #sttusCode# </isNotEmpty>
|
<include refid="AccountMngDAO.dormantWhere"/>
|
||||||
<isGreaterThan prepend="AND" property="minNoConnectDays" compareValue="0">
|
|
||||||
DATEDIFF(NOW(), IFNULL(e.LAST_LOGIN_DT, e.SBSCRB_DE)) >= #minNoConnectDays#
|
|
||||||
</isGreaterThan>
|
|
||||||
</dynamic>
|
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!-- 상단 요약 카드 — 검색조건과 무관한 전체 집계(정책 라벨값은 서비스가 채운다). -->
|
||||||
|
<select id="AccountMngDAO.selectDormantSummary" resultClass="dormantAccountSummaryVO">
|
||||||
|
SELECT COUNT(1) AS totCnt,
|
||||||
|
SUM(CASE WHEN t.acntState = 'W' THEN 1 ELSE 0 END) AS warnCnt,
|
||||||
|
SUM(CASE WHEN t.acntState = 'L' THEN 1 ELSE 0 END) AS lockCnt,
|
||||||
|
SUM(CASE WHEN t.acntState = 'D' THEN 1 ELSE 0 END) AS expiredCnt
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
<include refid="AccountMngDAO.dormantSelectCols"/>
|
||||||
|
<include refid="AccountMngDAO.dormantFrom"/>
|
||||||
|
) t
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 단건 파생상태 — 조치(잠금/말소) 전 서버측 대상 검증용. -->
|
||||||
|
<select id="AccountMngDAO.selectDormantAccountState"
|
||||||
|
parameterClass="java.lang.String" resultClass="java.lang.String">
|
||||||
|
SELECT t.acntState FROM (
|
||||||
|
SELECT
|
||||||
|
<include refid="AccountMngDAO.dormantSelectCols"/>
|
||||||
|
<include refid="AccountMngDAO.dormantFrom"/>
|
||||||
|
) t
|
||||||
|
WHERE t.accountId = #value#
|
||||||
|
LIMIT 1
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="AccountMngDAO.selectDormantAccountDays"
|
||||||
|
parameterClass="java.lang.String" resultClass="java.lang.Integer">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT DATEDIFF(NOW(), IFNULL(e.LAST_LOGIN_DT, e.SBSCRB_DE))
|
||||||
|
FROM COMTNEMPLYRINFO e
|
||||||
|
WHERE e.EMPLYR_ID = #value#
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- ================= #11-2 현황 화면 조치 ================= -->
|
||||||
|
<!--
|
||||||
|
[잠금] 자동 배치가 하는 '휴면 확정'을 관리자가 앞당겨 수행한다.
|
||||||
|
계정 상태코드(EMPLYR_STTUS_CODE)는 건드리지 않고 COMTNDIAPAUSEMBER 만 'P'로 만든다.
|
||||||
|
- 로그인 차단: EgovLoginController 의 휴면 확인(loginDAO.diapauseChk) 경로가 그대로 동작
|
||||||
|
- 되돌리기 : 기존 휴면 해제 경로(활성화/토큰)로 복구 가능 — 비가역 처리 아님
|
||||||
|
- 후속 : 보존기간 경과 시 #12 파기 배치의 대상이 된다(COMTNDIAPAUSEMBER 기준)
|
||||||
|
UPDATE 를 먼저 던지고 0건일 때만 INSERT 한다(서비스 계층 순서). 두 문장 모두 재실행 무해.
|
||||||
|
-->
|
||||||
|
<!-- 휴면행 상태 조회 — 'P'(이미 확정)면 조치를 건너뛰어 이력 중복 적재를 막는다. 행이 없으면 null. -->
|
||||||
|
<select id="AccountMngDAO.selectDiapauseStatEmp"
|
||||||
|
parameterClass="java.lang.String" resultClass="java.lang.String">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT IFNULL(d.DIAPAUSE_STAT, '')
|
||||||
|
FROM COMTNDIAPAUSEMBER d
|
||||||
|
WHERE d.MBER_ID = #value#
|
||||||
|
AND IFNULL(d.MBER_TY_CODE, 'EMP') = 'EMP'
|
||||||
|
ORDER BY d.DIAPAUSE_STAT DESC
|
||||||
|
LIMIT 1
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<update id="AccountMngDAO.updateDiapauseConfirmEmp" parameterClass="java.lang.String">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE COMTNDIAPAUSEMBER
|
||||||
|
SET DIAPAUSE_STAT = 'P',
|
||||||
|
DIAPAUSE_DT = NOW()
|
||||||
|
WHERE MBER_ID = #value#
|
||||||
|
AND IFNULL(MBER_TY_CODE, 'EMP') = 'EMP'
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<insert id="AccountMngDAO.insertDiapauseConfirmEmp" parameterClass="java.lang.String">
|
||||||
|
<![CDATA[
|
||||||
|
INSERT INTO COMTNDIAPAUSEMBER (MBER_ID, ESNTL_ID, LAST_LOG, DIAPAUSE_STAT, DIAPAUSE_DT, MBER_TY_CODE)
|
||||||
|
SELECT e.EMPLYR_ID,
|
||||||
|
e.ESNTL_ID,
|
||||||
|
IFNULL(e.LAST_LOGIN_DT, e.SBSCRB_DE),
|
||||||
|
'P',
|
||||||
|
NOW(),
|
||||||
|
'EMP'
|
||||||
|
FROM COMTNEMPLYRINFO e
|
||||||
|
LEFT JOIN COMTNDIAPAUSEMBER d ON d.MBER_ID = e.EMPLYR_ID
|
||||||
|
WHERE e.EMPLYR_ID = #value#
|
||||||
|
AND d.MBER_ID IS NULL
|
||||||
|
]]>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
[말소] 계정을 사용 불가로 확정한다(EMPLYR_STTUS_CODE='D').
|
||||||
|
개인정보 마스킹(파기)은 여기서 하지 않는다 — 보존기간 경과 후 #12 파기 배치가 수행한다.
|
||||||
|
이미 말소된 계정은 0건 갱신(재실행 무해).
|
||||||
|
-->
|
||||||
|
<update id="AccountMngDAO.updateEmplyrExpire" parameterClass="java.lang.String">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE COMTNEMPLYRINFO
|
||||||
|
SET EMPLYR_STTUS_CODE = 'D',
|
||||||
|
LOCK_YN = 'Y'
|
||||||
|
WHERE EMPLYR_ID = #value#
|
||||||
|
AND EMPLYR_STTUS_CODE <> 'D'
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<!-- 조치 이력 적재(INSERT 전용 — UPDATE/DELETE statement 미정의) -->
|
||||||
|
<insert id="AccountMngDAO.insertAccountActionHistory" parameterClass="accountActionHistoryVO">
|
||||||
|
<![CDATA[
|
||||||
|
INSERT INTO ACCOUNT_ACTION_HISTORY (
|
||||||
|
TRGET_ID, TRGET_SE, ACTION_TY, ACTION_USER_ID, ACTION_RESN, ACTION_IP, NO_CONECT_DAY, ACTION_DT
|
||||||
|
) VALUES (
|
||||||
|
#trgetId#, #trgetSe#, #actionTy#, #actionUserId#, #actionResn#, #actionIp#, #noConectDay#, NOW()
|
||||||
|
)
|
||||||
|
]]>
|
||||||
|
</insert>
|
||||||
|
|
||||||
<!-- ================= #10 휴면 사전알림 대상(D-N일 전) ================= -->
|
<!-- ================= #10 휴면 사전알림 대상(D-N일 전) ================= -->
|
||||||
<!-- 미접속일수 == (기준일수 - N) 인 취급자 = 휴면 D-N일 전. N은 #alertBefore# 로 전달. -->
|
<!-- 미접속일수 == (기준일수 - N) 인 취급자 = 휴면 D-N일 전. N은 #alertBefore# 로 전달. -->
|
||||||
<select id="AccountMngDAO.selectDormantAlertTargets"
|
<select id="AccountMngDAO.selectDormantAlertTargets"
|
||||||
parameterClass="java.util.HashMap"
|
parameterClass="java.util.HashMap"
|
||||||
resultClass="egovframework.com.cmm.privacy.account.DormantAccountStatusVO">
|
resultClass="dormantAccountStatusVO">
|
||||||
<![CDATA[
|
<![CDATA[
|
||||||
SELECT e.EMPLYR_ID AS accountId,
|
SELECT e.EMPLYR_ID AS accountId,
|
||||||
e.USER_NM AS userNm,
|
e.USER_NM AS userNm,
|
||||||
|
|||||||
@ -4,9 +4,17 @@
|
|||||||
<!--
|
<!--
|
||||||
개인정보보호 기능개선 사업 (SFR-002) — 계정 관리 정책·현황·사전알림 Oracle 쌍(이중관리 규약).
|
개인정보보호 기능개선 사업 (SFR-002) — 계정 관리 정책·현황·사전알림 Oracle 쌍(이중관리 규약).
|
||||||
※ arko 런타임은 MySQL sqlmap 만 로드. 본 파일은 이중관리 관례상 병행 유지.
|
※ arko 런타임은 MySQL sqlmap 만 로드. 본 파일은 이중관리 관례상 병행 유지.
|
||||||
|
2026-08-10: #11 현황 화면 시안 반영(역할·파생 계정상태·요약·조치) 동기화.
|
||||||
-->
|
-->
|
||||||
<sqlMap namespace="AccountMngDAO">
|
<sqlMap namespace="AccountMngDAO">
|
||||||
|
|
||||||
|
<typeAlias alias="dormantAccountStatusVO"
|
||||||
|
type="egovframework.com.cmm.privacy.account.DormantAccountStatusVO"/>
|
||||||
|
<typeAlias alias="dormantAccountSummaryVO"
|
||||||
|
type="egovframework.com.cmm.privacy.account.DormantAccountSummaryVO"/>
|
||||||
|
<typeAlias alias="accountActionHistoryVO"
|
||||||
|
type="egovframework.com.cmm.privacy.account.AccountActionHistoryVO"/>
|
||||||
|
|
||||||
<select id="AccountMngDAO.selectPolicy"
|
<select id="AccountMngDAO.selectPolicy"
|
||||||
resultClass="egovframework.com.cmm.privacy.account.AccountMngPolicyVO">
|
resultClass="egovframework.com.cmm.privacy.account.AccountMngPolicyVO">
|
||||||
<![CDATA[
|
<![CDATA[
|
||||||
@ -34,50 +42,171 @@
|
|||||||
]]>
|
]]>
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
<select id="AccountMngDAO.selectDormantStatusList"
|
<!-- ================= #11 장기 미사용 계정 현황(취급자) ================= -->
|
||||||
parameterClass="egovframework.com.cmm.privacy.account.DormantAccountStatusVO"
|
<sql id="AccountMngDAO.dormantSelectCols">
|
||||||
resultClass="egovframework.com.cmm.privacy.account.DormantAccountStatusVO">
|
e.EMPLYR_ID AS accountId, e.USER_NM AS userNm, e.ORGNZT_ID AS orgnztId,
|
||||||
SELECT * FROM (
|
e.OFCPS_NM AS ofcpsNm, e.ESNTL_ID AS esntlId,
|
||||||
SELECT ROWNUM AS RN, T.* FROM (
|
|
||||||
SELECT e.EMPLYR_ID AS accountId, e.USER_NM AS userNm, e.ORGNZT_ID AS orgnztId,
|
|
||||||
e.OFCPS_NM AS ofcpsNm,
|
|
||||||
TO_CHAR(NVL(e.LAST_LOGIN_DT, e.SBSCRB_DE), 'YYYY-MM-DD') AS lastLoginDt,
|
TO_CHAR(NVL(e.LAST_LOGIN_DT, e.SBSCRB_DE), 'YYYY-MM-DD') AS lastLoginDt,
|
||||||
TRUNC(SYSDATE) - TRUNC(NVL(e.LAST_LOGIN_DT, e.SBSCRB_DE)) AS noConnectDays,
|
TRUNC(SYSDATE) - TRUNC(NVL(e.LAST_LOGIN_DT, e.SBSCRB_DE)) AS noConnectDays,
|
||||||
e.EMPLYR_STTUS_CODE AS sttusCode,
|
e.EMPLYR_STTUS_CODE AS sttusCode,
|
||||||
TO_CHAR(NVL(e.LAST_LOGIN_DT, e.SBSCRB_DE)
|
TO_CHAR(NVL(e.LAST_LOGIN_DT, e.SBSCRB_DE) + p.DORMANT_DAY, 'YYYY-MM-DD') AS dormantExpectDt,
|
||||||
+ (SELECT NVL(MAX(DORMANT_DAY),365) FROM ACCOUNT_MNG_POLICY), 'YYYY-MM-DD') AS dormantExpectDt,
|
CASE WHEN e.EMPLYR_STTUS_CODE = 'D' THEN 'D'
|
||||||
|
WHEN e.EMPLYR_STTUS_CODE = 'S' THEN 'L'
|
||||||
|
WHEN d.DIAPAUSE_STAT = 'P' THEN 'L'
|
||||||
|
WHEN (TRUNC(SYSDATE) - TRUNC(NVL(e.LAST_LOGIN_DT, e.SBSCRB_DE))) >= p.DORMANT_DAY THEN 'L'
|
||||||
|
WHEN (TRUNC(SYSDATE) - TRUNC(NVL(e.LAST_LOGIN_DT, e.SBSCRB_DE))) >= (p.DORMANT_DAY - p.ALERT_D1) THEN 'W'
|
||||||
|
ELSE 'N' END AS acntState,
|
||||||
'EMP' AS mberTyCode
|
'EMP' AS mberTyCode
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<sql id="AccountMngDAO.dormantFrom">
|
||||||
FROM COMTNEMPLYRINFO e
|
FROM COMTNEMPLYRINFO e
|
||||||
|
LEFT JOIN COMTNDIAPAUSEMBER d
|
||||||
|
ON d.MBER_ID = e.EMPLYR_ID
|
||||||
|
AND d.ESNTL_ID = e.ESNTL_ID
|
||||||
|
CROSS JOIN (SELECT NVL(MAX(DORMANT_DAY), 365) AS DORMANT_DAY,
|
||||||
|
NVL(MAX(ALERT_D1), 30) AS ALERT_D1
|
||||||
|
FROM ACCOUNT_MNG_POLICY) p
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<sql id="AccountMngDAO.dormantWhere">
|
||||||
<dynamic prepend="WHERE">
|
<dynamic prepend="WHERE">
|
||||||
<isNotEmpty prepend="AND" property="accountId"> e.EMPLYR_ID = #accountId# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="accountId"> t.accountId = #accountId# </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="userNm"> e.USER_NM LIKE '%' || #userNm# || '%' </isNotEmpty>
|
<isNotEmpty prepend="AND" property="userNm"> t.userNm LIKE '%' || #userNm# || '%' </isNotEmpty>
|
||||||
<isNotEmpty prepend="AND" property="sttusCode"> e.EMPLYR_STTUS_CODE = #sttusCode# </isNotEmpty>
|
<isNotEmpty prepend="AND" property="searchKeyword">
|
||||||
|
(t.userNm LIKE '%' || #searchKeyword# || '%' OR t.accountId LIKE '%' || #searchKeyword# || '%')
|
||||||
|
</isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="sttusCode"> t.sttusCode = #sttusCode# </isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="searchState"> t.acntState = #searchState# </isNotEmpty>
|
||||||
<isGreaterThan prepend="AND" property="minNoConnectDays" compareValue="0">
|
<isGreaterThan prepend="AND" property="minNoConnectDays" compareValue="0">
|
||||||
(TRUNC(SYSDATE) - TRUNC(NVL(e.LAST_LOGIN_DT, e.SBSCRB_DE))) >= #minNoConnectDays#
|
t.noConnectDays >= #minNoConnectDays#
|
||||||
</isGreaterThan>
|
</isGreaterThan>
|
||||||
</dynamic>
|
</dynamic>
|
||||||
ORDER BY noConnectDays DESC
|
</sql>
|
||||||
) T WHERE ROWNUM <= #firstIndex# + #recordCountPerPage#
|
|
||||||
|
<select id="AccountMngDAO.selectDormantStatusList"
|
||||||
|
parameterClass="dormantAccountStatusVO"
|
||||||
|
resultClass="dormantAccountStatusVO">
|
||||||
|
SELECT * FROM (
|
||||||
|
SELECT ROWNUM AS RN, X.* FROM (
|
||||||
|
SELECT t.*,
|
||||||
|
(SELECT LISTAGG(COALESCE(NULLIF(a.AUTHOR_DC,'***'),
|
||||||
|
NULLIF(a.AUTHOR_NM,'***'),
|
||||||
|
a.AUTHOR_CODE), ', ')
|
||||||
|
WITHIN GROUP (ORDER BY a.AUTHOR_CODE)
|
||||||
|
FROM COMTNEMPLYRSCRTYESTBS s
|
||||||
|
JOIN COMTNAUTHORINFO a ON a.AUTHOR_CODE = s.AUTHOR_CODE
|
||||||
|
WHERE s.SCRTY_DTRMN_TRGET_ID = t.esntlId) AS roleNm
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
<include refid="AccountMngDAO.dormantSelectCols"/>
|
||||||
|
<include refid="AccountMngDAO.dormantFrom"/>
|
||||||
|
) t
|
||||||
|
<include refid="AccountMngDAO.dormantWhere"/>
|
||||||
|
ORDER BY t.noConnectDays DESC, t.accountId
|
||||||
|
) X WHERE ROWNUM <= #firstIndex# + #recordCountPerPage#
|
||||||
) WHERE RN > #firstIndex#
|
) WHERE RN > #firstIndex#
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="AccountMngDAO.selectDormantStatusListCount"
|
<select id="AccountMngDAO.selectDormantStatusListCount"
|
||||||
parameterClass="egovframework.com.cmm.privacy.account.DormantAccountStatusVO"
|
parameterClass="dormantAccountStatusVO"
|
||||||
resultClass="java.lang.Integer">
|
resultClass="java.lang.Integer">
|
||||||
SELECT COUNT(1) FROM COMTNEMPLYRINFO e
|
SELECT COUNT(1) FROM (
|
||||||
<dynamic prepend="WHERE">
|
SELECT
|
||||||
<isNotEmpty prepend="AND" property="accountId"> e.EMPLYR_ID = #accountId# </isNotEmpty>
|
<include refid="AccountMngDAO.dormantSelectCols"/>
|
||||||
<isNotEmpty prepend="AND" property="userNm"> e.USER_NM LIKE '%' || #userNm# || '%' </isNotEmpty>
|
<include refid="AccountMngDAO.dormantFrom"/>
|
||||||
<isNotEmpty prepend="AND" property="sttusCode"> e.EMPLYR_STTUS_CODE = #sttusCode# </isNotEmpty>
|
) t
|
||||||
<isGreaterThan prepend="AND" property="minNoConnectDays" compareValue="0">
|
<include refid="AccountMngDAO.dormantWhere"/>
|
||||||
(TRUNC(SYSDATE) - TRUNC(NVL(e.LAST_LOGIN_DT, e.SBSCRB_DE))) >= #minNoConnectDays#
|
|
||||||
</isGreaterThan>
|
|
||||||
</dynamic>
|
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<select id="AccountMngDAO.selectDormantSummary" resultClass="dormantAccountSummaryVO">
|
||||||
|
SELECT COUNT(1) AS totCnt,
|
||||||
|
SUM(CASE WHEN t.acntState = 'W' THEN 1 ELSE 0 END) AS warnCnt,
|
||||||
|
SUM(CASE WHEN t.acntState = 'L' THEN 1 ELSE 0 END) AS lockCnt,
|
||||||
|
SUM(CASE WHEN t.acntState = 'D' THEN 1 ELSE 0 END) AS expiredCnt
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
<include refid="AccountMngDAO.dormantSelectCols"/>
|
||||||
|
<include refid="AccountMngDAO.dormantFrom"/>
|
||||||
|
) t
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="AccountMngDAO.selectDormantAccountState"
|
||||||
|
parameterClass="java.lang.String" resultClass="java.lang.String">
|
||||||
|
SELECT t.acntState FROM (
|
||||||
|
SELECT
|
||||||
|
<include refid="AccountMngDAO.dormantSelectCols"/>
|
||||||
|
<include refid="AccountMngDAO.dormantFrom"/>
|
||||||
|
) t
|
||||||
|
WHERE t.accountId = #value#
|
||||||
|
AND ROWNUM = 1
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="AccountMngDAO.selectDormantAccountDays"
|
||||||
|
parameterClass="java.lang.String" resultClass="java.lang.Integer">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT TRUNC(SYSDATE) - TRUNC(NVL(e.LAST_LOGIN_DT, e.SBSCRB_DE))
|
||||||
|
FROM COMTNEMPLYRINFO e
|
||||||
|
WHERE e.EMPLYR_ID = #value#
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- ================= #11-2 현황 화면 조치 ================= -->
|
||||||
|
<select id="AccountMngDAO.selectDiapauseStatEmp"
|
||||||
|
parameterClass="java.lang.String" resultClass="java.lang.String">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT NVL(d.DIAPAUSE_STAT, ' ')
|
||||||
|
FROM COMTNDIAPAUSEMBER d
|
||||||
|
WHERE d.MBER_ID = #value#
|
||||||
|
AND NVL(d.MBER_TY_CODE, 'EMP') = 'EMP'
|
||||||
|
AND ROWNUM = 1
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<update id="AccountMngDAO.updateDiapauseConfirmEmp" parameterClass="java.lang.String">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE COMTNDIAPAUSEMBER
|
||||||
|
SET DIAPAUSE_STAT = 'P',
|
||||||
|
DIAPAUSE_DT = SYSDATE
|
||||||
|
WHERE MBER_ID = #value#
|
||||||
|
AND NVL(MBER_TY_CODE, 'EMP') = 'EMP'
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<insert id="AccountMngDAO.insertDiapauseConfirmEmp" parameterClass="java.lang.String">
|
||||||
|
<![CDATA[
|
||||||
|
INSERT INTO COMTNDIAPAUSEMBER (MBER_ID, ESNTL_ID, LAST_LOG, DIAPAUSE_STAT, DIAPAUSE_DT, MBER_TY_CODE)
|
||||||
|
SELECT e.EMPLYR_ID, e.ESNTL_ID, NVL(e.LAST_LOGIN_DT, e.SBSCRB_DE), 'P', SYSDATE, 'EMP'
|
||||||
|
FROM COMTNEMPLYRINFO e
|
||||||
|
LEFT JOIN COMTNDIAPAUSEMBER d ON d.MBER_ID = e.EMPLYR_ID
|
||||||
|
WHERE e.EMPLYR_ID = #value#
|
||||||
|
AND d.MBER_ID IS NULL
|
||||||
|
]]>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<update id="AccountMngDAO.updateEmplyrExpire" parameterClass="java.lang.String">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE COMTNEMPLYRINFO
|
||||||
|
SET EMPLYR_STTUS_CODE = 'D',
|
||||||
|
LOCK_YN = 'Y'
|
||||||
|
WHERE EMPLYR_ID = #value#
|
||||||
|
AND EMPLYR_STTUS_CODE <> 'D'
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<insert id="AccountMngDAO.insertAccountActionHistory" parameterClass="accountActionHistoryVO">
|
||||||
|
<![CDATA[
|
||||||
|
INSERT INTO ACCOUNT_ACTION_HISTORY (
|
||||||
|
TRGET_ID, TRGET_SE, ACTION_TY, ACTION_USER_ID, ACTION_RESN, ACTION_IP, NO_CONECT_DAY, ACTION_DT
|
||||||
|
) VALUES (
|
||||||
|
#trgetId#, #trgetSe#, #actionTy#, #actionUserId#, #actionResn#, #actionIp#, #noConectDay#, SYSDATE
|
||||||
|
)
|
||||||
|
]]>
|
||||||
|
</insert>
|
||||||
|
|
||||||
<select id="AccountMngDAO.selectDormantAlertTargets"
|
<select id="AccountMngDAO.selectDormantAlertTargets"
|
||||||
parameterClass="java.util.HashMap"
|
parameterClass="java.util.HashMap"
|
||||||
resultClass="egovframework.com.cmm.privacy.account.DormantAccountStatusVO">
|
resultClass="dormantAccountStatusVO">
|
||||||
<![CDATA[
|
<![CDATA[
|
||||||
SELECT e.EMPLYR_ID AS accountId, e.USER_NM AS userNm, e.ESNTL_ID AS esntlId,
|
SELECT e.EMPLYR_ID AS accountId, e.USER_NM AS userNm, e.ESNTL_ID AS esntlId,
|
||||||
TRUNC(SYSDATE) - TRUNC(NVL(e.LAST_LOGIN_DT, e.SBSCRB_DE)) AS noConnectDays,
|
TRUNC(SYSDATE) - TRUNC(NVL(e.LAST_LOGIN_DT, e.SBSCRB_DE)) AS noConnectDays,
|
||||||
|
|||||||
@ -0,0 +1,186 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
개인정보보호 기능개선 사업 (SFR-001 / 개발항목 6) — 권한변경 이력 CSV 비동기 다운로드 작업.
|
||||||
|
· 요청 등록(insert) -> 워커가 대기건 선점(claim) -> 진행률 갱신 -> 완료/실패 확정
|
||||||
|
· 내려받기 시 요청자 본인 확인은 서비스 계층에서, 조회는 selectJob 으로 한다.
|
||||||
|
· 접속기록 쪽 PrivacyDownloadJob_SQL_Mysql.xml 과 같은 패턴, 테이블·조건 컬럼만 다르다.
|
||||||
|
※ 주석에 '샾' 기호를 쓰지 않는다 — iBatis2 가 파라미터로 오인해 파싱이 깨진다.
|
||||||
|
-->
|
||||||
|
<sqlMap namespace="AuthorHistDownloadJobDAO">
|
||||||
|
|
||||||
|
<insert id="AuthorHistDownloadJobDAO.insertJob"
|
||||||
|
parameterClass="egovframework.com.cmm.privacy.author.AuthorHistDownloadJobVO">
|
||||||
|
<![CDATA[
|
||||||
|
INSERT INTO AUTHOR_HIST_DOWNLOAD_JOB (
|
||||||
|
REQUESTER_ID, REQUESTER_IP, DL_REASON, SEARCH_COND,
|
||||||
|
F_TRGET_ID, F_CHANGER_ID, F_CHG_SE_CODE, F_FROM_DT, F_TO_DT,
|
||||||
|
STATUS, TOTAL_CNT, DONE_CNT, REG_DT, EXPIRE_DT
|
||||||
|
) VALUES (
|
||||||
|
#requesterId#, #requesterIp:VARCHAR#, #dlReason#, #searchCond:VARCHAR#,
|
||||||
|
#condTrgetId:VARCHAR#, #condChangerId:VARCHAR#, #condChgSeCode:VARCHAR#,
|
||||||
|
#condFromDt:VARCHAR#, #condToDt:VARCHAR#,
|
||||||
|
'W', 0, 0, NOW(), DATE_ADD(NOW(), INTERVAL #expireDays# DAY)
|
||||||
|
)
|
||||||
|
]]>
|
||||||
|
<selectKey resultClass="java.lang.Long" keyProperty="jobId">
|
||||||
|
SELECT LAST_INSERT_ID()
|
||||||
|
</selectKey>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<select id="AuthorHistDownloadJobDAO.selectJob"
|
||||||
|
parameterClass="java.lang.Long"
|
||||||
|
resultClass="egovframework.com.cmm.privacy.author.AuthorHistDownloadJobVO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT JOB_ID AS jobId,
|
||||||
|
REQUESTER_ID AS requesterId,
|
||||||
|
REQUESTER_IP AS requesterIp,
|
||||||
|
DL_REASON AS dlReason,
|
||||||
|
SEARCH_COND AS searchCond,
|
||||||
|
F_TRGET_ID AS condTrgetId,
|
||||||
|
F_CHANGER_ID AS condChangerId,
|
||||||
|
F_CHG_SE_CODE AS condChgSeCode,
|
||||||
|
F_FROM_DT AS condFromDt,
|
||||||
|
F_TO_DT AS condToDt,
|
||||||
|
STATUS AS status,
|
||||||
|
TOTAL_CNT AS totalCnt,
|
||||||
|
DONE_CNT AS doneCnt,
|
||||||
|
FILE_NM AS fileNm,
|
||||||
|
FILE_PATH AS filePath,
|
||||||
|
FILE_SIZE AS fileSize,
|
||||||
|
ERR_MSG AS errMsg,
|
||||||
|
DOWNLOAD_CNT AS downloadCnt,
|
||||||
|
LAST_DL_DT AS lastDlDt,
|
||||||
|
REG_DT AS regDt,
|
||||||
|
START_DT AS startDt,
|
||||||
|
END_DT AS endDt,
|
||||||
|
EXPIRE_DT AS expireDt
|
||||||
|
FROM AUTHOR_HIST_DOWNLOAD_JOB
|
||||||
|
WHERE JOB_ID = #value#
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 요청자 본인의 최근 작업 목록(화면 폴링용). 파기(X)분도 보여 이력이 끊기지 않게 한다. -->
|
||||||
|
<select id="AuthorHistDownloadJobDAO.selectJobListByRequester"
|
||||||
|
parameterClass="java.util.HashMap"
|
||||||
|
resultClass="egovframework.com.cmm.privacy.author.AuthorHistDownloadJobVO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT JOB_ID AS jobId,
|
||||||
|
DL_REASON AS dlReason,
|
||||||
|
SEARCH_COND AS searchCond,
|
||||||
|
STATUS AS status,
|
||||||
|
TOTAL_CNT AS totalCnt,
|
||||||
|
DONE_CNT AS doneCnt,
|
||||||
|
FILE_NM AS fileNm,
|
||||||
|
FILE_SIZE AS fileSize,
|
||||||
|
ERR_MSG AS errMsg,
|
||||||
|
DOWNLOAD_CNT AS downloadCnt,
|
||||||
|
DATE_FORMAT(REG_DT, '%Y-%m-%d %H:%i:%s') AS regDt,
|
||||||
|
DATE_FORMAT(END_DT, '%Y-%m-%d %H:%i:%s') AS endDt,
|
||||||
|
DATE_FORMAT(EXPIRE_DT, '%Y-%m-%d %H:%i') AS expireDt
|
||||||
|
FROM AUTHOR_HIST_DOWNLOAD_JOB
|
||||||
|
WHERE REQUESTER_ID = #requesterId#
|
||||||
|
ORDER BY JOB_ID DESC
|
||||||
|
LIMIT #limitCnt#
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
워커의 대기건 선점. STATUS='W' 인 가장 오래된 1건만 'R' 로 바꾼다.
|
||||||
|
WHERE 에 STATUS='W' 를 다시 두는 것이 핵심이다 — 워커가 둘 이상이어도
|
||||||
|
UPDATE 영향행수 1을 받은 쪽만 그 작업을 갖는다(중복 생성 방지).
|
||||||
|
-->
|
||||||
|
<update id="AuthorHistDownloadJobDAO.claimJob" parameterClass="java.lang.Long">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE AUTHOR_HIST_DOWNLOAD_JOB
|
||||||
|
SET STATUS = 'R', START_DT = NOW()
|
||||||
|
WHERE JOB_ID = #value#
|
||||||
|
AND STATUS = 'W'
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<select id="AuthorHistDownloadJobDAO.selectNextWaitingJobId" resultClass="java.lang.Long">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT JOB_ID
|
||||||
|
FROM AUTHOR_HIST_DOWNLOAD_JOB
|
||||||
|
WHERE STATUS = 'W'
|
||||||
|
ORDER BY JOB_ID ASC
|
||||||
|
LIMIT 1
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<update id="AuthorHistDownloadJobDAO.updateProgress"
|
||||||
|
parameterClass="egovframework.com.cmm.privacy.author.AuthorHistDownloadJobVO">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE AUTHOR_HIST_DOWNLOAD_JOB
|
||||||
|
SET TOTAL_CNT = #totalCnt#, DONE_CNT = #doneCnt#
|
||||||
|
WHERE JOB_ID = #jobId#
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="AuthorHistDownloadJobDAO.updateSuccess"
|
||||||
|
parameterClass="egovframework.com.cmm.privacy.author.AuthorHistDownloadJobVO">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE AUTHOR_HIST_DOWNLOAD_JOB
|
||||||
|
SET STATUS = 'S', END_DT = NOW(),
|
||||||
|
TOTAL_CNT = #totalCnt#, DONE_CNT = #doneCnt#,
|
||||||
|
FILE_NM = #fileNm#, FILE_PATH = #filePath#, FILE_SIZE = #fileSize#
|
||||||
|
WHERE JOB_ID = #jobId#
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="AuthorHistDownloadJobDAO.updateFail"
|
||||||
|
parameterClass="egovframework.com.cmm.privacy.author.AuthorHistDownloadJobVO">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE AUTHOR_HIST_DOWNLOAD_JOB
|
||||||
|
SET STATUS = 'F', END_DT = NOW(), ERR_MSG = #errMsg:VARCHAR#
|
||||||
|
WHERE JOB_ID = #jobId#
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="AuthorHistDownloadJobDAO.updateDownloaded" parameterClass="java.lang.Long">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE AUTHOR_HIST_DOWNLOAD_JOB
|
||||||
|
SET DOWNLOAD_CNT = DOWNLOAD_CNT + 1, LAST_DL_DT = NOW()
|
||||||
|
WHERE JOB_ID = #value#
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<!-- 파기 대상: 만료 경과했고 아직 파일을 들고 있는 완료/실패 건. -->
|
||||||
|
<select id="AuthorHistDownloadJobDAO.selectExpiredJobs"
|
||||||
|
resultClass="egovframework.com.cmm.privacy.author.AuthorHistDownloadJobVO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT JOB_ID AS jobId,
|
||||||
|
FILE_PATH AS filePath,
|
||||||
|
FILE_NM AS fileNm,
|
||||||
|
EXPIRE_DT AS expireDt
|
||||||
|
FROM AUTHOR_HIST_DOWNLOAD_JOB
|
||||||
|
WHERE STATUS IN ('S','F')
|
||||||
|
AND EXPIRE_DT IS NOT NULL
|
||||||
|
AND EXPIRE_DT <= NOW()
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 파기 확정: 파일 경로를 지워 재다운로드를 원천 차단한다. -->
|
||||||
|
<update id="AuthorHistDownloadJobDAO.updatePurged" parameterClass="java.lang.Long">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE AUTHOR_HIST_DOWNLOAD_JOB
|
||||||
|
SET STATUS = 'X', FILE_PATH = NULL
|
||||||
|
WHERE JOB_ID = #value#
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
기동 시 고아 작업 복구. 파일 생성 중(R)에 WAS 가 죽으면 영원히 R 로 남아
|
||||||
|
화면에 '생성중'이 멈춰 보인다. 기동 시 대기(W)로 되돌려 재시도하게 한다.
|
||||||
|
-->
|
||||||
|
<update id="AuthorHistDownloadJobDAO.resetRunningJobs">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE AUTHOR_HIST_DOWNLOAD_JOB
|
||||||
|
SET STATUS = 'W', START_DT = NULL
|
||||||
|
WHERE STATUS = 'R'
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
</sqlMap>
|
||||||
@ -0,0 +1,177 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
개인정보보호 기능개선 사업 (SFR-001 / 개발항목 6) — 권한변경 이력 CSV 비동기 다운로드 작업
|
||||||
|
(AUTHOR_HIST_DOWNLOAD_JOB) — Oracle 쌍(이중관리 규약).
|
||||||
|
※ arko 런타임은 MySQL sqlmap 만 로드한다. 시퀀스 AUTHOR_HIST_DL_JOB_SEQ 전제.
|
||||||
|
※ 주석에 '샾' 기호를 쓰지 않는다 — iBatis2 가 파라미터로 오인해 파싱이 깨진다.
|
||||||
|
Oracle 치환 항목: NOW()->SYSDATE, DATE_ADD(...)->SYSDATE + n, LIMIT->ROWNUM,
|
||||||
|
DATE_FORMAT->TO_CHAR, AUTO_INCREMENT->시퀀스.
|
||||||
|
-->
|
||||||
|
<sqlMap namespace="AuthorHistDownloadJobDAO">
|
||||||
|
|
||||||
|
<insert id="AuthorHistDownloadJobDAO.insertJob"
|
||||||
|
parameterClass="egovframework.com.cmm.privacy.author.AuthorHistDownloadJobVO">
|
||||||
|
<![CDATA[
|
||||||
|
INSERT INTO AUTHOR_HIST_DOWNLOAD_JOB (
|
||||||
|
JOB_ID, REQUESTER_ID, REQUESTER_IP, DL_REASON, SEARCH_COND,
|
||||||
|
F_TRGET_ID, F_CHANGER_ID, F_CHG_SE_CODE, F_FROM_DT, F_TO_DT,
|
||||||
|
STATUS, TOTAL_CNT, DONE_CNT, REG_DT, EXPIRE_DT
|
||||||
|
) VALUES (
|
||||||
|
AUTHOR_HIST_DL_JOB_SEQ.NEXTVAL, #requesterId#, #requesterIp:VARCHAR#, #dlReason#, #searchCond:VARCHAR#,
|
||||||
|
#condTrgetId:VARCHAR#, #condChangerId:VARCHAR#, #condChgSeCode:VARCHAR#,
|
||||||
|
#condFromDt:VARCHAR#, #condToDt:VARCHAR#,
|
||||||
|
'W', 0, 0, SYSDATE, SYSDATE + #expireDays#
|
||||||
|
)
|
||||||
|
]]>
|
||||||
|
<selectKey resultClass="java.lang.Long" keyProperty="jobId">
|
||||||
|
SELECT AUTHOR_HIST_DL_JOB_SEQ.CURRVAL FROM DUAL
|
||||||
|
</selectKey>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<select id="AuthorHistDownloadJobDAO.selectJob"
|
||||||
|
parameterClass="java.lang.Long"
|
||||||
|
resultClass="egovframework.com.cmm.privacy.author.AuthorHistDownloadJobVO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT JOB_ID AS jobId,
|
||||||
|
REQUESTER_ID AS requesterId,
|
||||||
|
REQUESTER_IP AS requesterIp,
|
||||||
|
DL_REASON AS dlReason,
|
||||||
|
SEARCH_COND AS searchCond,
|
||||||
|
F_TRGET_ID AS condTrgetId,
|
||||||
|
F_CHANGER_ID AS condChangerId,
|
||||||
|
F_CHG_SE_CODE AS condChgSeCode,
|
||||||
|
F_FROM_DT AS condFromDt,
|
||||||
|
F_TO_DT AS condToDt,
|
||||||
|
STATUS AS status,
|
||||||
|
TOTAL_CNT AS totalCnt,
|
||||||
|
DONE_CNT AS doneCnt,
|
||||||
|
FILE_NM AS fileNm,
|
||||||
|
FILE_PATH AS filePath,
|
||||||
|
FILE_SIZE AS fileSize,
|
||||||
|
ERR_MSG AS errMsg,
|
||||||
|
DOWNLOAD_CNT AS downloadCnt,
|
||||||
|
LAST_DL_DT AS lastDlDt,
|
||||||
|
REG_DT AS regDt,
|
||||||
|
START_DT AS startDt,
|
||||||
|
END_DT AS endDt,
|
||||||
|
EXPIRE_DT AS expireDt
|
||||||
|
FROM AUTHOR_HIST_DOWNLOAD_JOB
|
||||||
|
WHERE JOB_ID = #value#
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="AuthorHistDownloadJobDAO.selectJobListByRequester"
|
||||||
|
parameterClass="java.util.HashMap"
|
||||||
|
resultClass="egovframework.com.cmm.privacy.author.AuthorHistDownloadJobVO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT * FROM (
|
||||||
|
SELECT JOB_ID AS jobId,
|
||||||
|
DL_REASON AS dlReason,
|
||||||
|
SEARCH_COND AS searchCond,
|
||||||
|
STATUS AS status,
|
||||||
|
TOTAL_CNT AS totalCnt,
|
||||||
|
DONE_CNT AS doneCnt,
|
||||||
|
FILE_NM AS fileNm,
|
||||||
|
FILE_SIZE AS fileSize,
|
||||||
|
ERR_MSG AS errMsg,
|
||||||
|
DOWNLOAD_CNT AS downloadCnt,
|
||||||
|
TO_CHAR(REG_DT, 'YYYY-MM-DD HH24:MI:SS') AS regDt,
|
||||||
|
TO_CHAR(END_DT, 'YYYY-MM-DD HH24:MI:SS') AS endDt,
|
||||||
|
TO_CHAR(EXPIRE_DT, 'YYYY-MM-DD HH24:MI') AS expireDt
|
||||||
|
FROM AUTHOR_HIST_DOWNLOAD_JOB
|
||||||
|
WHERE REQUESTER_ID = #requesterId#
|
||||||
|
ORDER BY JOB_ID DESC
|
||||||
|
) WHERE ROWNUM <= #limitCnt#
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<update id="AuthorHistDownloadJobDAO.claimJob" parameterClass="java.lang.Long">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE AUTHOR_HIST_DOWNLOAD_JOB
|
||||||
|
SET STATUS = 'R', START_DT = SYSDATE
|
||||||
|
WHERE JOB_ID = #value#
|
||||||
|
AND STATUS = 'W'
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<select id="AuthorHistDownloadJobDAO.selectNextWaitingJobId" resultClass="java.lang.Long">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT JOB_ID FROM (
|
||||||
|
SELECT JOB_ID
|
||||||
|
FROM AUTHOR_HIST_DOWNLOAD_JOB
|
||||||
|
WHERE STATUS = 'W'
|
||||||
|
ORDER BY JOB_ID ASC
|
||||||
|
) WHERE ROWNUM <= 1
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<update id="AuthorHistDownloadJobDAO.updateProgress"
|
||||||
|
parameterClass="egovframework.com.cmm.privacy.author.AuthorHistDownloadJobVO">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE AUTHOR_HIST_DOWNLOAD_JOB
|
||||||
|
SET TOTAL_CNT = #totalCnt#, DONE_CNT = #doneCnt#
|
||||||
|
WHERE JOB_ID = #jobId#
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="AuthorHistDownloadJobDAO.updateSuccess"
|
||||||
|
parameterClass="egovframework.com.cmm.privacy.author.AuthorHistDownloadJobVO">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE AUTHOR_HIST_DOWNLOAD_JOB
|
||||||
|
SET STATUS = 'S', END_DT = SYSDATE,
|
||||||
|
TOTAL_CNT = #totalCnt#, DONE_CNT = #doneCnt#,
|
||||||
|
FILE_NM = #fileNm#, FILE_PATH = #filePath#, FILE_SIZE = #fileSize#
|
||||||
|
WHERE JOB_ID = #jobId#
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="AuthorHistDownloadJobDAO.updateFail"
|
||||||
|
parameterClass="egovframework.com.cmm.privacy.author.AuthorHistDownloadJobVO">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE AUTHOR_HIST_DOWNLOAD_JOB
|
||||||
|
SET STATUS = 'F', END_DT = SYSDATE, ERR_MSG = #errMsg:VARCHAR#
|
||||||
|
WHERE JOB_ID = #jobId#
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="AuthorHistDownloadJobDAO.updateDownloaded" parameterClass="java.lang.Long">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE AUTHOR_HIST_DOWNLOAD_JOB
|
||||||
|
SET DOWNLOAD_CNT = DOWNLOAD_CNT + 1, LAST_DL_DT = SYSDATE
|
||||||
|
WHERE JOB_ID = #value#
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<select id="AuthorHistDownloadJobDAO.selectExpiredJobs"
|
||||||
|
resultClass="egovframework.com.cmm.privacy.author.AuthorHistDownloadJobVO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT JOB_ID AS jobId,
|
||||||
|
FILE_PATH AS filePath,
|
||||||
|
FILE_NM AS fileNm,
|
||||||
|
EXPIRE_DT AS expireDt
|
||||||
|
FROM AUTHOR_HIST_DOWNLOAD_JOB
|
||||||
|
WHERE STATUS IN ('S','F')
|
||||||
|
AND EXPIRE_DT IS NOT NULL
|
||||||
|
AND EXPIRE_DT <= SYSDATE
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<update id="AuthorHistDownloadJobDAO.updatePurged" parameterClass="java.lang.Long">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE AUTHOR_HIST_DOWNLOAD_JOB
|
||||||
|
SET STATUS = 'X', FILE_PATH = NULL
|
||||||
|
WHERE JOB_ID = #value#
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="AuthorHistDownloadJobDAO.resetRunningJobs">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE AUTHOR_HIST_DOWNLOAD_JOB
|
||||||
|
SET STATUS = 'W', START_DT = NULL
|
||||||
|
WHERE STATUS = 'R'
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
</sqlMap>
|
||||||
@ -60,32 +60,12 @@
|
|||||||
WHERE REQUEST_ID = #value#
|
WHERE REQUEST_ID = #value#
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="AuthorWorkflowDAO.selectRequestList"
|
<!--
|
||||||
parameterClass="egovframework.com.cmm.privacy.author.AuthorChangeRequestVO"
|
[2026-08-10 제거] AuthorWorkflowDAO.selectRequestList / selectRequestListCount
|
||||||
resultClass="egovframework.com.cmm.privacy.author.AuthorChangeRequestVO">
|
'신청 이력' 화면(reqListView.do + requestList.jsp)과 함께 삭제했다.
|
||||||
SELECT REQUEST_ID AS requestId, TRGET_UNIQ_ID AS trgetUniqId, TRGET_ID AS trgetId,
|
승인·반려는 취급자 권한 관리 목록(화면 46)에서 직접 처리하므로 신청 목록 조회가 필요 없다.
|
||||||
REQUEST_SE AS requestSe, AFTER_AUTHOR AS afterAuthor, REQUEST_RESN AS requestResn,
|
승인대기 건수는 selectPendingRequestCount, 대기 신청 단건은 selectRequest 가 담당한다.
|
||||||
REQSTER_ID AS reqsterId, DATE_FORMAT(REQUEST_DT, '%Y-%m-%d %H:%i:%s') AS requestDt,
|
-->
|
||||||
CONFM_STTUS AS confmSttus, CONFM_ID AS confmId,
|
|
||||||
DATE_FORMAT(CONFM_DT, '%Y-%m-%d %H:%i:%s') AS confmDt
|
|
||||||
FROM AUTHOR_CHANGE_REQUEST
|
|
||||||
<dynamic prepend="WHERE">
|
|
||||||
<isNotEmpty prepend="AND" property="searchSttus"> CONFM_STTUS = #searchSttus# </isNotEmpty>
|
|
||||||
<isNotEmpty prepend="AND" property="searchTrgetId"> TRGET_ID = #searchTrgetId# </isNotEmpty>
|
|
||||||
</dynamic>
|
|
||||||
ORDER BY REQUEST_ID DESC
|
|
||||||
LIMIT #firstIndex#, #recordCountPerPage#
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select id="AuthorWorkflowDAO.selectRequestListCount"
|
|
||||||
parameterClass="egovframework.com.cmm.privacy.author.AuthorChangeRequestVO"
|
|
||||||
resultClass="java.lang.Integer">
|
|
||||||
SELECT COUNT(1) FROM AUTHOR_CHANGE_REQUEST
|
|
||||||
<dynamic prepend="WHERE">
|
|
||||||
<isNotEmpty prepend="AND" property="searchSttus"> CONFM_STTUS = #searchSttus# </isNotEmpty>
|
|
||||||
<isNotEmpty prepend="AND" property="searchTrgetId"> TRGET_ID = #searchTrgetId# </isNotEmpty>
|
|
||||||
</dynamic>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<update id="AuthorWorkflowDAO.updateRequestStatus"
|
<update id="AuthorWorkflowDAO.updateRequestStatus"
|
||||||
parameterClass="egovframework.com.cmm.privacy.author.AuthorChangeRequestVO">
|
parameterClass="egovframework.com.cmm.privacy.author.AuthorChangeRequestVO">
|
||||||
@ -142,37 +122,53 @@
|
|||||||
|
|
||||||
<!-- ===================== #6 권한이력 조회·다운로드 (AuthorHistoryDAO) ===================== -->
|
<!-- ===================== #6 권한이력 조회·다운로드 (AuthorHistoryDAO) ===================== -->
|
||||||
|
|
||||||
|
<!--
|
||||||
|
대상자 이름 표기용 조인. ESNTL_ID 는 COMTNEMPLYRINFO 의 유일키라 행이 늘지 않으므로
|
||||||
|
목록·건수 쿼리가 같은 조인을 공유해도 COUNT 값이 틀어지지 않는다.
|
||||||
|
권한 대상자는 항상 취급자(COMTNEMPLYRINFO)다 — 일반회원 테이블은 보지 않는다.
|
||||||
|
-->
|
||||||
|
<sql id="authorHistoryTrgetJoin">
|
||||||
|
LEFT JOIN COMTNEMPLYRINFO te ON te.ESNTL_ID = h.TRGET_UNIQ_ID
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<!-- 목록·건수 공용 검색조건. 조인 후 모호해지지 않도록 전부 h. 로 한정한다. -->
|
||||||
|
<sql id="authorHistoryWhere">
|
||||||
|
<dynamic prepend="WHERE">
|
||||||
|
<isNotEmpty prepend="AND" property="searchTrgetId"> (h.TRGET_ID = #searchTrgetId# OR h.TRGET_UNIQ_ID = #searchTrgetId#) </isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="searchChangerId"> h.CHANGER_ID = #searchChangerId# </isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="searchChgSeCode"> h.CHG_SE_CODE = #searchChgSeCode# </isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="fromDt"> h.CHG_DT >= #fromDt# </isNotEmpty>
|
||||||
|
<!--
|
||||||
|
종료일은 '그 날까지 포함'이어야 한다. CHG_DT 는 DATETIME 이라
|
||||||
|
CHG_DT <= '2026-08-10' 으로 비교하면 그 날 00:00:00 까지만 걸려
|
||||||
|
종료일 당일 기록이 통째로 빠진다. DATE() 로 시각을 잘라 하루를 더해
|
||||||
|
'다음날 0시 미만'으로 비교한다(달력이 날짜만 주므로 이 해석이 맞다).
|
||||||
|
-->
|
||||||
|
<isNotEmpty prepend="AND" property="toDt"> h.CHG_DT < DATE_ADD(DATE(#toDt#), INTERVAL 1 DAY) </isNotEmpty>
|
||||||
|
</dynamic>
|
||||||
|
</sql>
|
||||||
|
|
||||||
<select id="AuthorHistoryDAO.selectHistoryList"
|
<select id="AuthorHistoryDAO.selectHistoryList"
|
||||||
parameterClass="egovframework.com.cmm.privacy.author.AuthorChangeHistoryVO"
|
parameterClass="egovframework.com.cmm.privacy.author.AuthorChangeHistoryVO"
|
||||||
resultClass="egovframework.com.cmm.privacy.author.AuthorChangeHistoryVO">
|
resultClass="egovframework.com.cmm.privacy.author.AuthorChangeHistoryVO">
|
||||||
SELECT HIST_ID AS histId, TRGET_UNIQ_ID AS trgetUniqId, TRGET_ID AS trgetId,
|
SELECT h.HIST_ID AS histId, h.TRGET_UNIQ_ID AS trgetUniqId, h.TRGET_ID AS trgetId,
|
||||||
CHG_SE_CODE AS chgSeCode, BEFORE_AUTHOR AS beforeAuthor, AFTER_AUTHOR AS afterAuthor,
|
COALESCE(NULLIF(te.USER_NM, ''), '-') AS trgetNm,
|
||||||
CHG_RESN AS chgResn, APROVL_ID AS aprovlId, CHANGER_UNIQ_ID AS changerUniqId,
|
h.CHG_SE_CODE AS chgSeCode, h.BEFORE_AUTHOR AS beforeAuthor, h.AFTER_AUTHOR AS afterAuthor,
|
||||||
CHANGER_ID AS changerId, CHANGER_IP AS changerIp,
|
h.CHG_RESN AS chgResn, h.APROVL_ID AS aprovlId, h.CHANGER_UNIQ_ID AS changerUniqId,
|
||||||
DATE_FORMAT(CHG_DT, '%Y-%m-%d %H:%i:%s') AS chgDt
|
h.CHANGER_ID AS changerId, h.CHANGER_IP AS changerIp,
|
||||||
FROM AUTHOR_CHANGE_HISTORY
|
DATE_FORMAT(h.CHG_DT, '%Y-%m-%d %H:%i:%s') AS chgDt
|
||||||
<dynamic prepend="WHERE">
|
FROM AUTHOR_CHANGE_HISTORY h
|
||||||
<isNotEmpty prepend="AND" property="searchTrgetId"> (TRGET_ID = #searchTrgetId# OR TRGET_UNIQ_ID = #searchTrgetId#) </isNotEmpty>
|
<include refid="authorHistoryTrgetJoin"/>
|
||||||
<isNotEmpty prepend="AND" property="searchChangerId"> CHANGER_ID = #searchChangerId# </isNotEmpty>
|
<include refid="authorHistoryWhere"/>
|
||||||
<isNotEmpty prepend="AND" property="searchChgSeCode"> CHG_SE_CODE = #searchChgSeCode# </isNotEmpty>
|
ORDER BY h.HIST_ID DESC
|
||||||
<isNotEmpty prepend="AND" property="fromDt"> CHG_DT >= #fromDt# </isNotEmpty>
|
|
||||||
<isNotEmpty prepend="AND" property="toDt"> CHG_DT <= #toDt# </isNotEmpty>
|
|
||||||
</dynamic>
|
|
||||||
ORDER BY HIST_ID DESC
|
|
||||||
LIMIT #firstIndex#, #recordCountPerPage#
|
LIMIT #firstIndex#, #recordCountPerPage#
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="AuthorHistoryDAO.selectHistoryListCount"
|
<select id="AuthorHistoryDAO.selectHistoryListCount"
|
||||||
parameterClass="egovframework.com.cmm.privacy.author.AuthorChangeHistoryVO"
|
parameterClass="egovframework.com.cmm.privacy.author.AuthorChangeHistoryVO"
|
||||||
resultClass="java.lang.Integer">
|
resultClass="java.lang.Integer">
|
||||||
SELECT COUNT(1) FROM AUTHOR_CHANGE_HISTORY
|
SELECT COUNT(1) FROM AUTHOR_CHANGE_HISTORY h
|
||||||
<dynamic prepend="WHERE">
|
<include refid="authorHistoryWhere"/>
|
||||||
<isNotEmpty prepend="AND" property="searchTrgetId"> (TRGET_ID = #searchTrgetId# OR TRGET_UNIQ_ID = #searchTrgetId#) </isNotEmpty>
|
|
||||||
<isNotEmpty prepend="AND" property="searchChangerId"> CHANGER_ID = #searchChangerId# </isNotEmpty>
|
|
||||||
<isNotEmpty prepend="AND" property="searchChgSeCode"> CHG_SE_CODE = #searchChgSeCode# </isNotEmpty>
|
|
||||||
<isNotEmpty prepend="AND" property="fromDt"> CHG_DT >= #fromDt# </isNotEmpty>
|
|
||||||
<isNotEmpty prepend="AND" property="toDt"> CHG_DT <= #toDt# </isNotEmpty>
|
|
||||||
</dynamic>
|
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<insert id="AuthorHistoryDAO.insertDownloadHistory"
|
<insert id="AuthorHistoryDAO.insertDownloadHistory"
|
||||||
@ -186,4 +182,135 @@
|
|||||||
]]>
|
]]>
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
|
<!-- ============== 개인정보취급자 권한 관리 목록(화면 #46) ==============
|
||||||
|
취급자 1명 = 1행. 목록 열은 소속기관/변경 전 역할/변경 후 역할/접근범위/권한상태/최근변경일/관리.
|
||||||
|
· 역할 : COMTNEMPLYRSCRTYESTBS 는 PK(SCRTY_DTRMN_TRGET_ID) 라 1인 1역할.
|
||||||
|
· ★ 역할명 출처 주의 : 운영 COMTNAUTHORINFO 는 AUTHOR_NM 이 16행 전부 리터럴 '***' 이고
|
||||||
|
실제 표기명은 AUTHOR_DC 에 있다(ROLE_ADMIN→'슈퍼관리자', ROLE_SIDO_ADMIN→'시도관리자' 등).
|
||||||
|
그래서 역할명은 AUTHOR_DC → AUTHOR_NM → AUTHOR_CODE 순으로 고르고 '***' 는 빈 값 취급한다.
|
||||||
|
검색 드롭다운(selectAuthorityList)도 같은 우선순위라 목록·검색 표기가 일치한다.
|
||||||
|
· 변경 전·후 역할(파일럿 시안 p.6 "변경 전·후 권한") :
|
||||||
|
- 승인대기 신청이 있으면 그 신청(pr)의 BEFORE_AUTHOR / AFTER_AUTHOR 를 그대로 표기
|
||||||
|
(회수 신청 REQUEST_SE='R' 은 변경 후를 '권한없음' 으로 고정)
|
||||||
|
- 대기 신청이 없으면 최근 확정 이력(hh = AUTHOR_CHANGE_HISTORY MAX(HIST_ID)) 기준
|
||||||
|
- 이력도 없으면 변경 전 '-', 변경 후는 현재 역할(a.AUTHOR_NM)
|
||||||
|
- 권한코드→역할명은 COMTNAUTHORINFO(ab/aa) 조인, 이름이 없으면 코드 원문 노출
|
||||||
|
(ab/aa/pr/hh 모두 PK 기준 LEFT JOIN 이라 행 수가 늘지 않음 → COUNT 쿼리 공유 안전)
|
||||||
|
· 접근범위 : 권한설명(AUTHOR_DC)이 비어 있는 운영 데이터가 많아
|
||||||
|
비었으면 COMTNMENUCREATDTLS 허용 메뉴 수로 대체 표기.
|
||||||
|
· 권한상태 : W(승인대기)=처리대기 신청 존재 / R(말소)=권한행 없음 or 계정말소 / N(정상)
|
||||||
|
· 최근변경일: AUTHOR_CHANGE_HISTORY 최신 변경일, 없으면 가입일.
|
||||||
|
※ 상태는 계산식이라 WHERE 에서 별칭 참조 불가 → 조건을 분기별로 다시 적는다.
|
||||||
|
================================================================= -->
|
||||||
|
|
||||||
|
<sql id="userAuthorFrom">
|
||||||
|
FROM COMTNEMPLYRINFO e
|
||||||
|
LEFT JOIN COMTNORGNZTINFO o ON o.ORGNZT_ID = e.ORGNZT_ID
|
||||||
|
LEFT JOIN COMTNEMPLYRSCRTYESTBS s ON s.SCRTY_DTRMN_TRGET_ID = e.ESNTL_ID
|
||||||
|
LEFT JOIN COMTNAUTHORINFO a ON a.AUTHOR_CODE = s.AUTHOR_CODE
|
||||||
|
LEFT JOIN (SELECT TRGET_UNIQ_ID, MAX(REQUEST_ID) AS REQUEST_ID
|
||||||
|
FROM AUTHOR_CHANGE_REQUEST
|
||||||
|
WHERE CONFM_STTUS = 'R'
|
||||||
|
GROUP BY TRGET_UNIQ_ID) p ON p.TRGET_UNIQ_ID = e.ESNTL_ID
|
||||||
|
LEFT JOIN AUTHOR_CHANGE_REQUEST pr ON pr.REQUEST_ID = p.REQUEST_ID
|
||||||
|
LEFT JOIN (SELECT TRGET_UNIQ_ID, MAX(HIST_ID) AS HIST_ID
|
||||||
|
FROM AUTHOR_CHANGE_HISTORY
|
||||||
|
GROUP BY TRGET_UNIQ_ID) h ON h.TRGET_UNIQ_ID = e.ESNTL_ID
|
||||||
|
LEFT JOIN AUTHOR_CHANGE_HISTORY hh ON hh.HIST_ID = h.HIST_ID
|
||||||
|
LEFT JOIN COMTNAUTHORINFO ab
|
||||||
|
ON ab.AUTHOR_CODE = COALESCE(NULLIF(pr.BEFORE_AUTHOR, ''), NULLIF(hh.BEFORE_AUTHOR, ''))
|
||||||
|
LEFT JOIN COMTNAUTHORINFO aa
|
||||||
|
ON aa.AUTHOR_CODE = COALESCE(NULLIF(pr.AFTER_AUTHOR, ''), NULLIF(hh.AFTER_AUTHOR, ''))
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<sql id="userAuthorWhere">
|
||||||
|
<dynamic prepend="WHERE">
|
||||||
|
<isNotEmpty prepend="AND" property="searchOrgnztId"> e.ORGNZT_ID = #searchOrgnztId# </isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="searchAuthorCode"> s.AUTHOR_CODE = #searchAuthorCode# </isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="searchKeyword">
|
||||||
|
(e.EMPLYR_ID LIKE CONCAT('%', #searchKeyword#, '%') OR e.USER_NM LIKE CONCAT('%', #searchKeyword#, '%'))
|
||||||
|
</isNotEmpty>
|
||||||
|
<isEqual prepend="AND" property="searchAuthorSttus" compareValue="W">
|
||||||
|
p.REQUEST_ID IS NOT NULL
|
||||||
|
</isEqual>
|
||||||
|
<isEqual prepend="AND" property="searchAuthorSttus" compareValue="R">
|
||||||
|
p.REQUEST_ID IS NULL AND (s.AUTHOR_CODE IS NULL OR e.EMPLYR_STTUS_CODE = 'D')
|
||||||
|
</isEqual>
|
||||||
|
<isEqual prepend="AND" property="searchAuthorSttus" compareValue="N">
|
||||||
|
p.REQUEST_ID IS NULL AND s.AUTHOR_CODE IS NOT NULL AND e.EMPLYR_STTUS_CODE != 'D'
|
||||||
|
</isEqual>
|
||||||
|
</dynamic>
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<select id="AuthorWorkflowDAO.selectUserAuthorList"
|
||||||
|
parameterClass="egovframework.com.cmm.privacy.author.PrivacyUserAuthorVO"
|
||||||
|
resultClass="egovframework.com.cmm.privacy.author.PrivacyUserAuthorVO">
|
||||||
|
SELECT CAST(e.EMPLYR_ID AS CHAR) AS emplyrId,
|
||||||
|
e.ESNTL_ID AS uniqId,
|
||||||
|
e.USER_NM AS userNm,
|
||||||
|
e.ORGNZT_ID AS orgnztId,
|
||||||
|
COALESCE(NULLIF(o.ORGNZT_NM, ''), '-') AS orgnztNm,
|
||||||
|
s.AUTHOR_CODE AS authorCode,
|
||||||
|
COALESCE(NULLIF(NULLIF(a.AUTHOR_DC, ''), '***'),
|
||||||
|
NULLIF(NULLIF(a.AUTHOR_NM, ''), '***'),
|
||||||
|
s.AUTHOR_CODE, '권한없음') AS authorNm,
|
||||||
|
CASE WHEN COALESCE(NULLIF(pr.BEFORE_AUTHOR, ''), NULLIF(hh.BEFORE_AUTHOR, '')) IS NULL THEN '-'
|
||||||
|
ELSE COALESCE(NULLIF(NULLIF(ab.AUTHOR_DC, ''), '***'),
|
||||||
|
NULLIF(NULLIF(ab.AUTHOR_NM, ''), '***'),
|
||||||
|
NULLIF(pr.BEFORE_AUTHOR, ''), NULLIF(hh.BEFORE_AUTHOR, ''))
|
||||||
|
END AS beforeAuthorNm,
|
||||||
|
CASE WHEN pr.REQUEST_SE = 'R' THEN '권한없음'
|
||||||
|
WHEN COALESCE(NULLIF(pr.AFTER_AUTHOR, ''), NULLIF(hh.AFTER_AUTHOR, '')) IS NOT NULL
|
||||||
|
THEN COALESCE(NULLIF(NULLIF(aa.AUTHOR_DC, ''), '***'),
|
||||||
|
NULLIF(NULLIF(aa.AUTHOR_NM, ''), '***'),
|
||||||
|
NULLIF(pr.AFTER_AUTHOR, ''), NULLIF(hh.AFTER_AUTHOR, ''))
|
||||||
|
WHEN s.AUTHOR_CODE IS NOT NULL
|
||||||
|
THEN COALESCE(NULLIF(NULLIF(a.AUTHOR_DC, ''), '***'),
|
||||||
|
NULLIF(NULLIF(a.AUTHOR_NM, ''), '***'), s.AUTHOR_CODE)
|
||||||
|
ELSE '-'
|
||||||
|
END AS afterAuthorNm,
|
||||||
|
pr.REQUEST_SE AS pendingRequestSe,
|
||||||
|
CASE WHEN s.AUTHOR_CODE IS NULL THEN '-'
|
||||||
|
WHEN COALESCE(a.AUTHOR_DC, '') != '' THEN a.AUTHOR_DC
|
||||||
|
ELSE CONCAT('허용 메뉴 ',
|
||||||
|
(SELECT COUNT(1) FROM COMTNMENUCREATDTLS d WHERE d.AUTHOR_CODE = s.AUTHOR_CODE),
|
||||||
|
'개')
|
||||||
|
END AS accessScope,
|
||||||
|
CASE WHEN p.REQUEST_ID IS NOT NULL THEN 'W'
|
||||||
|
WHEN s.AUTHOR_CODE IS NULL OR e.EMPLYR_STTUS_CODE = 'D' THEN 'R'
|
||||||
|
ELSE 'N'
|
||||||
|
END AS authorSttus,
|
||||||
|
COALESCE(DATE_FORMAT(hh.CHG_DT, '%Y-%m-%d'),
|
||||||
|
DATE_FORMAT(e.SBSCRB_DE, '%Y-%m-%d'), '-') AS lastChgDt,
|
||||||
|
COALESCE(p.REQUEST_ID, 0) AS pendingRequestId,
|
||||||
|
e.EMPLYR_STTUS_CODE AS emplyrSttusCode
|
||||||
|
<include refid="userAuthorFrom"/>
|
||||||
|
<include refid="userAuthorWhere"/>
|
||||||
|
ORDER BY CASE WHEN p.REQUEST_ID IS NOT NULL THEN 0 ELSE 1 END,
|
||||||
|
hh.CHG_DT DESC, e.EMPLYR_ID
|
||||||
|
LIMIT #firstIndex#, #recordCountPerPage#
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="AuthorWorkflowDAO.selectUserAuthorListCount"
|
||||||
|
parameterClass="egovframework.com.cmm.privacy.author.PrivacyUserAuthorVO"
|
||||||
|
resultClass="java.lang.Integer">
|
||||||
|
SELECT COUNT(1)
|
||||||
|
<include refid="userAuthorFrom"/>
|
||||||
|
<include refid="userAuthorWhere"/>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 목록 상단 요약: 검색조건과 무관한 전체 승인대기 건수 -->
|
||||||
|
<select id="AuthorWorkflowDAO.selectPendingRequestCount" resultClass="java.lang.Integer">
|
||||||
|
SELECT COUNT(1) FROM AUTHOR_CHANGE_REQUEST WHERE CONFM_STTUS = 'R'
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 검색조건 드롭다운: 소속기관(취급자가 실제로 소속된 기관만) -->
|
||||||
|
<select id="AuthorWorkflowDAO.selectOrgnztList" resultClass="egovMap">
|
||||||
|
SELECT o.ORGNZT_ID AS orgnztId,
|
||||||
|
o.ORGNZT_NM AS orgnztNm
|
||||||
|
FROM COMTNORGNZTINFO o
|
||||||
|
WHERE EXISTS (SELECT 1 FROM COMTNEMPLYRINFO e WHERE e.ORGNZT_ID = o.ORGNZT_ID)
|
||||||
|
ORDER BY o.ORGNZT_NM
|
||||||
|
</select>
|
||||||
|
|
||||||
</sqlMap>
|
</sqlMap>
|
||||||
|
|||||||
@ -59,35 +59,11 @@
|
|||||||
WHERE REQUEST_ID = #value#
|
WHERE REQUEST_ID = #value#
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="AuthorWorkflowDAO.selectRequestList"
|
<!--
|
||||||
parameterClass="egovframework.com.cmm.privacy.author.AuthorChangeRequestVO"
|
[2026-08-10 제거] AuthorWorkflowDAO.selectRequestList / selectRequestListCount
|
||||||
resultClass="egovframework.com.cmm.privacy.author.AuthorChangeRequestVO">
|
'신청 이력' 화면(reqListView.do + requestList.jsp)과 함께 삭제했다. MySQL 쌍과 동일.
|
||||||
SELECT * FROM (
|
-->
|
||||||
SELECT ROWNUM AS RN, T.* FROM (
|
|
||||||
SELECT REQUEST_ID AS requestId, TRGET_UNIQ_ID AS trgetUniqId, TRGET_ID AS trgetId,
|
|
||||||
REQUEST_SE AS requestSe, AFTER_AUTHOR AS afterAuthor, REQUEST_RESN AS requestResn,
|
|
||||||
REQSTER_ID AS reqsterId, TO_CHAR(REQUEST_DT, 'YYYY-MM-DD HH24:MI:SS') AS requestDt,
|
|
||||||
CONFM_STTUS AS confmSttus, CONFM_ID AS confmId,
|
|
||||||
TO_CHAR(CONFM_DT, 'YYYY-MM-DD HH24:MI:SS') AS confmDt
|
|
||||||
FROM AUTHOR_CHANGE_REQUEST
|
|
||||||
<dynamic prepend="WHERE">
|
|
||||||
<isNotEmpty prepend="AND" property="searchSttus"> CONFM_STTUS = #searchSttus# </isNotEmpty>
|
|
||||||
<isNotEmpty prepend="AND" property="searchTrgetId"> TRGET_ID = #searchTrgetId# </isNotEmpty>
|
|
||||||
</dynamic>
|
|
||||||
ORDER BY REQUEST_ID DESC
|
|
||||||
) T WHERE ROWNUM <= #firstIndex# + #recordCountPerPage#
|
|
||||||
) WHERE RN > #firstIndex#
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select id="AuthorWorkflowDAO.selectRequestListCount"
|
|
||||||
parameterClass="egovframework.com.cmm.privacy.author.AuthorChangeRequestVO"
|
|
||||||
resultClass="java.lang.Integer">
|
|
||||||
SELECT COUNT(1) FROM AUTHOR_CHANGE_REQUEST
|
|
||||||
<dynamic prepend="WHERE">
|
|
||||||
<isNotEmpty prepend="AND" property="searchSttus"> CONFM_STTUS = #searchSttus# </isNotEmpty>
|
|
||||||
<isNotEmpty prepend="AND" property="searchTrgetId"> TRGET_ID = #searchTrgetId# </isNotEmpty>
|
|
||||||
</dynamic>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<update id="AuthorWorkflowDAO.updateRequestStatus"
|
<update id="AuthorWorkflowDAO.updateRequestStatus"
|
||||||
parameterClass="egovframework.com.cmm.privacy.author.AuthorChangeRequestVO">
|
parameterClass="egovframework.com.cmm.privacy.author.AuthorChangeRequestVO">
|
||||||
@ -135,25 +111,41 @@
|
|||||||
]]>
|
]]>
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
|
<!-- 대상자 이름 표기용 조인(MySQL 쌍 참조). ESNTL_ID 유일키라 행이 늘지 않는다. -->
|
||||||
|
<sql id="authorHistoryTrgetJoin">
|
||||||
|
LEFT JOIN COMTNEMPLYRINFO te ON te.ESNTL_ID = h.TRGET_UNIQ_ID
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
목록·건수 공용 검색조건. 종료일은 '당일 포함'이어야 하므로 시각을 잘라 하루를 더한
|
||||||
|
'다음날 0시 미만'으로 비교한다(MySQL 의 DATE_ADD(DATE(x),INTERVAL 1 DAY) 대응).
|
||||||
|
SUBSTR 로 앞 10자만 취해 날짜·일시 어느 형식이 들어와도 파싱이 깨지지 않게 한다.
|
||||||
|
-->
|
||||||
|
<sql id="authorHistoryWhere">
|
||||||
|
<dynamic prepend="WHERE">
|
||||||
|
<isNotEmpty prepend="AND" property="searchTrgetId"> (h.TRGET_ID = #searchTrgetId# OR h.TRGET_UNIQ_ID = #searchTrgetId#) </isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="searchChangerId"> h.CHANGER_ID = #searchChangerId# </isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="searchChgSeCode"> h.CHG_SE_CODE = #searchChgSeCode# </isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="fromDt"> h.CHG_DT >= #fromDt# </isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="toDt"> h.CHG_DT < TRUNC(TO_DATE(SUBSTR(#toDt#, 1, 10), 'YYYY-MM-DD')) + 1 </isNotEmpty>
|
||||||
|
</dynamic>
|
||||||
|
</sql>
|
||||||
|
|
||||||
<select id="AuthorHistoryDAO.selectHistoryList"
|
<select id="AuthorHistoryDAO.selectHistoryList"
|
||||||
parameterClass="egovframework.com.cmm.privacy.author.AuthorChangeHistoryVO"
|
parameterClass="egovframework.com.cmm.privacy.author.AuthorChangeHistoryVO"
|
||||||
resultClass="egovframework.com.cmm.privacy.author.AuthorChangeHistoryVO">
|
resultClass="egovframework.com.cmm.privacy.author.AuthorChangeHistoryVO">
|
||||||
SELECT * FROM (
|
SELECT * FROM (
|
||||||
SELECT ROWNUM AS RN, T.* FROM (
|
SELECT ROWNUM AS RN, T.* FROM (
|
||||||
SELECT HIST_ID AS histId, TRGET_UNIQ_ID AS trgetUniqId, TRGET_ID AS trgetId,
|
SELECT h.HIST_ID AS histId, h.TRGET_UNIQ_ID AS trgetUniqId, h.TRGET_ID AS trgetId,
|
||||||
CHG_SE_CODE AS chgSeCode, BEFORE_AUTHOR AS beforeAuthor, AFTER_AUTHOR AS afterAuthor,
|
COALESCE(te.USER_NM, '-') AS trgetNm,
|
||||||
CHG_RESN AS chgResn, APROVL_ID AS aprovlId, CHANGER_UNIQ_ID AS changerUniqId,
|
h.CHG_SE_CODE AS chgSeCode, h.BEFORE_AUTHOR AS beforeAuthor, h.AFTER_AUTHOR AS afterAuthor,
|
||||||
CHANGER_ID AS changerId, CHANGER_IP AS changerIp,
|
h.CHG_RESN AS chgResn, h.APROVL_ID AS aprovlId, h.CHANGER_UNIQ_ID AS changerUniqId,
|
||||||
TO_CHAR(CHG_DT, 'YYYY-MM-DD HH24:MI:SS') AS chgDt
|
h.CHANGER_ID AS changerId, h.CHANGER_IP AS changerIp,
|
||||||
FROM AUTHOR_CHANGE_HISTORY
|
TO_CHAR(h.CHG_DT, 'YYYY-MM-DD HH24:MI:SS') AS chgDt
|
||||||
<dynamic prepend="WHERE">
|
FROM AUTHOR_CHANGE_HISTORY h
|
||||||
<isNotEmpty prepend="AND" property="searchTrgetId"> (TRGET_ID = #searchTrgetId# OR TRGET_UNIQ_ID = #searchTrgetId#) </isNotEmpty>
|
<include refid="authorHistoryTrgetJoin"/>
|
||||||
<isNotEmpty prepend="AND" property="searchChangerId"> CHANGER_ID = #searchChangerId# </isNotEmpty>
|
<include refid="authorHistoryWhere"/>
|
||||||
<isNotEmpty prepend="AND" property="searchChgSeCode"> CHG_SE_CODE = #searchChgSeCode# </isNotEmpty>
|
ORDER BY h.HIST_ID DESC
|
||||||
<isNotEmpty prepend="AND" property="fromDt"> CHG_DT >= #fromDt# </isNotEmpty>
|
|
||||||
<isNotEmpty prepend="AND" property="toDt"> CHG_DT <= #toDt# </isNotEmpty>
|
|
||||||
</dynamic>
|
|
||||||
ORDER BY HIST_ID DESC
|
|
||||||
) T WHERE ROWNUM <= #firstIndex# + #recordCountPerPage#
|
) T WHERE ROWNUM <= #firstIndex# + #recordCountPerPage#
|
||||||
) WHERE RN > #firstIndex#
|
) WHERE RN > #firstIndex#
|
||||||
</select>
|
</select>
|
||||||
@ -161,14 +153,8 @@
|
|||||||
<select id="AuthorHistoryDAO.selectHistoryListCount"
|
<select id="AuthorHistoryDAO.selectHistoryListCount"
|
||||||
parameterClass="egovframework.com.cmm.privacy.author.AuthorChangeHistoryVO"
|
parameterClass="egovframework.com.cmm.privacy.author.AuthorChangeHistoryVO"
|
||||||
resultClass="java.lang.Integer">
|
resultClass="java.lang.Integer">
|
||||||
SELECT COUNT(1) FROM AUTHOR_CHANGE_HISTORY
|
SELECT COUNT(1) FROM AUTHOR_CHANGE_HISTORY h
|
||||||
<dynamic prepend="WHERE">
|
<include refid="authorHistoryWhere"/>
|
||||||
<isNotEmpty prepend="AND" property="searchTrgetId"> (TRGET_ID = #searchTrgetId# OR TRGET_UNIQ_ID = #searchTrgetId#) </isNotEmpty>
|
|
||||||
<isNotEmpty prepend="AND" property="searchChangerId"> CHANGER_ID = #searchChangerId# </isNotEmpty>
|
|
||||||
<isNotEmpty prepend="AND" property="searchChgSeCode"> CHG_SE_CODE = #searchChgSeCode# </isNotEmpty>
|
|
||||||
<isNotEmpty prepend="AND" property="fromDt"> CHG_DT >= #fromDt# </isNotEmpty>
|
|
||||||
<isNotEmpty prepend="AND" property="toDt"> CHG_DT <= #toDt# </isNotEmpty>
|
|
||||||
</dynamic>
|
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<insert id="AuthorHistoryDAO.insertDownloadHistory"
|
<insert id="AuthorHistoryDAO.insertDownloadHistory"
|
||||||
@ -183,4 +169,119 @@
|
|||||||
]]>
|
]]>
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
|
<!-- ============== 개인정보취급자 권한 관리 목록(화면 #46) ==============
|
||||||
|
MySQL 쌍(PrivacyAuthorWorkflow_SQL_Mysql.xml) 참조. Oracle 치환 항목:
|
||||||
|
DATE_FORMAT→TO_CHAR, CONCAT(a,b,c)→||, LIMIT→ROWNUM 2중 인라인뷰,
|
||||||
|
LIKE CONCAT('%',x,'%')→'%'||x||'%'.
|
||||||
|
변경 전·후 역할 열: Oracle 은 빈 문자열이 곧 NULL 이라 MySQL 쌍의
|
||||||
|
NULLIF(x,'') 방어를 생략하고 COALESCE 만 사용한다(동작 동일).
|
||||||
|
================================================================= -->
|
||||||
|
|
||||||
|
<sql id="userAuthorFrom">
|
||||||
|
FROM COMTNEMPLYRINFO e
|
||||||
|
LEFT JOIN COMTNORGNZTINFO o ON o.ORGNZT_ID = e.ORGNZT_ID
|
||||||
|
LEFT JOIN COMTNEMPLYRSCRTYESTBS s ON s.SCRTY_DTRMN_TRGET_ID = e.ESNTL_ID
|
||||||
|
LEFT JOIN COMTNAUTHORINFO a ON a.AUTHOR_CODE = s.AUTHOR_CODE
|
||||||
|
LEFT JOIN (SELECT TRGET_UNIQ_ID, MAX(REQUEST_ID) AS REQUEST_ID
|
||||||
|
FROM AUTHOR_CHANGE_REQUEST
|
||||||
|
WHERE CONFM_STTUS = 'R'
|
||||||
|
GROUP BY TRGET_UNIQ_ID) p ON p.TRGET_UNIQ_ID = e.ESNTL_ID
|
||||||
|
LEFT JOIN AUTHOR_CHANGE_REQUEST pr ON pr.REQUEST_ID = p.REQUEST_ID
|
||||||
|
LEFT JOIN (SELECT TRGET_UNIQ_ID, MAX(HIST_ID) AS HIST_ID
|
||||||
|
FROM AUTHOR_CHANGE_HISTORY
|
||||||
|
GROUP BY TRGET_UNIQ_ID) h ON h.TRGET_UNIQ_ID = e.ESNTL_ID
|
||||||
|
LEFT JOIN AUTHOR_CHANGE_HISTORY hh ON hh.HIST_ID = h.HIST_ID
|
||||||
|
LEFT JOIN COMTNAUTHORINFO ab
|
||||||
|
ON ab.AUTHOR_CODE = COALESCE(pr.BEFORE_AUTHOR, hh.BEFORE_AUTHOR)
|
||||||
|
LEFT JOIN COMTNAUTHORINFO aa
|
||||||
|
ON aa.AUTHOR_CODE = COALESCE(pr.AFTER_AUTHOR, hh.AFTER_AUTHOR)
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<sql id="userAuthorWhere">
|
||||||
|
<dynamic prepend="WHERE">
|
||||||
|
<isNotEmpty prepend="AND" property="searchOrgnztId"> e.ORGNZT_ID = #searchOrgnztId# </isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="searchAuthorCode"> s.AUTHOR_CODE = #searchAuthorCode# </isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="searchKeyword">
|
||||||
|
(e.EMPLYR_ID LIKE '%' || #searchKeyword# || '%' OR e.USER_NM LIKE '%' || #searchKeyword# || '%')
|
||||||
|
</isNotEmpty>
|
||||||
|
<isEqual prepend="AND" property="searchAuthorSttus" compareValue="W">
|
||||||
|
p.REQUEST_ID IS NOT NULL
|
||||||
|
</isEqual>
|
||||||
|
<isEqual prepend="AND" property="searchAuthorSttus" compareValue="R">
|
||||||
|
p.REQUEST_ID IS NULL AND (s.AUTHOR_CODE IS NULL OR e.EMPLYR_STTUS_CODE = 'D')
|
||||||
|
</isEqual>
|
||||||
|
<isEqual prepend="AND" property="searchAuthorSttus" compareValue="N">
|
||||||
|
p.REQUEST_ID IS NULL AND s.AUTHOR_CODE IS NOT NULL AND e.EMPLYR_STTUS_CODE != 'D'
|
||||||
|
</isEqual>
|
||||||
|
</dynamic>
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<select id="AuthorWorkflowDAO.selectUserAuthorList"
|
||||||
|
parameterClass="egovframework.com.cmm.privacy.author.PrivacyUserAuthorVO"
|
||||||
|
resultClass="egovframework.com.cmm.privacy.author.PrivacyUserAuthorVO">
|
||||||
|
SELECT * FROM (
|
||||||
|
SELECT ROWNUM AS RN, T.* FROM (
|
||||||
|
SELECT CAST(e.EMPLYR_ID AS VARCHAR2(20)) AS emplyrId,
|
||||||
|
e.ESNTL_ID AS uniqId,
|
||||||
|
e.USER_NM AS userNm,
|
||||||
|
e.ORGNZT_ID AS orgnztId,
|
||||||
|
COALESCE(o.ORGNZT_NM, '-') AS orgnztNm,
|
||||||
|
s.AUTHOR_CODE AS authorCode,
|
||||||
|
COALESCE(NULLIF(a.AUTHOR_DC, '***'), NULLIF(a.AUTHOR_NM, '***'),
|
||||||
|
s.AUTHOR_CODE, '권한없음') AS authorNm,
|
||||||
|
CASE WHEN COALESCE(pr.BEFORE_AUTHOR, hh.BEFORE_AUTHOR) IS NULL THEN '-'
|
||||||
|
ELSE COALESCE(NULLIF(ab.AUTHOR_DC, '***'), NULLIF(ab.AUTHOR_NM, '***'),
|
||||||
|
pr.BEFORE_AUTHOR, hh.BEFORE_AUTHOR)
|
||||||
|
END AS beforeAuthorNm,
|
||||||
|
CASE WHEN pr.REQUEST_SE = 'R' THEN '권한없음'
|
||||||
|
WHEN COALESCE(pr.AFTER_AUTHOR, hh.AFTER_AUTHOR) IS NOT NULL
|
||||||
|
THEN COALESCE(NULLIF(aa.AUTHOR_DC, '***'), NULLIF(aa.AUTHOR_NM, '***'),
|
||||||
|
pr.AFTER_AUTHOR, hh.AFTER_AUTHOR)
|
||||||
|
WHEN s.AUTHOR_CODE IS NOT NULL
|
||||||
|
THEN COALESCE(NULLIF(a.AUTHOR_DC, '***'), NULLIF(a.AUTHOR_NM, '***'), s.AUTHOR_CODE)
|
||||||
|
ELSE '-'
|
||||||
|
END AS afterAuthorNm,
|
||||||
|
pr.REQUEST_SE AS pendingRequestSe,
|
||||||
|
CASE WHEN s.AUTHOR_CODE IS NULL THEN '-'
|
||||||
|
WHEN a.AUTHOR_DC IS NOT NULL THEN a.AUTHOR_DC
|
||||||
|
ELSE '허용 메뉴 ' ||
|
||||||
|
(SELECT COUNT(1) FROM COMTNMENUCREATDTLS d WHERE d.AUTHOR_CODE = s.AUTHOR_CODE)
|
||||||
|
|| '개'
|
||||||
|
END AS accessScope,
|
||||||
|
CASE WHEN p.REQUEST_ID IS NOT NULL THEN 'W'
|
||||||
|
WHEN s.AUTHOR_CODE IS NULL OR e.EMPLYR_STTUS_CODE = 'D' THEN 'R'
|
||||||
|
ELSE 'N'
|
||||||
|
END AS authorSttus,
|
||||||
|
COALESCE(TO_CHAR(hh.CHG_DT, 'YYYY-MM-DD'),
|
||||||
|
TO_CHAR(e.SBSCRB_DE, 'YYYY-MM-DD'), '-') AS lastChgDt,
|
||||||
|
COALESCE(p.REQUEST_ID, 0) AS pendingRequestId,
|
||||||
|
e.EMPLYR_STTUS_CODE AS emplyrSttusCode
|
||||||
|
<include refid="userAuthorFrom"/>
|
||||||
|
<include refid="userAuthorWhere"/>
|
||||||
|
ORDER BY CASE WHEN p.REQUEST_ID IS NOT NULL THEN 0 ELSE 1 END,
|
||||||
|
hh.CHG_DT DESC, e.EMPLYR_ID
|
||||||
|
) T WHERE ROWNUM <= #firstIndex# + #recordCountPerPage#
|
||||||
|
) WHERE RN > #firstIndex#
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="AuthorWorkflowDAO.selectUserAuthorListCount"
|
||||||
|
parameterClass="egovframework.com.cmm.privacy.author.PrivacyUserAuthorVO"
|
||||||
|
resultClass="java.lang.Integer">
|
||||||
|
SELECT COUNT(1)
|
||||||
|
<include refid="userAuthorFrom"/>
|
||||||
|
<include refid="userAuthorWhere"/>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="AuthorWorkflowDAO.selectPendingRequestCount" resultClass="java.lang.Integer">
|
||||||
|
SELECT COUNT(1) FROM AUTHOR_CHANGE_REQUEST WHERE CONFM_STTUS = 'R'
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="AuthorWorkflowDAO.selectOrgnztList" resultClass="egovMap">
|
||||||
|
SELECT o.ORGNZT_ID AS orgnztId,
|
||||||
|
o.ORGNZT_NM AS orgnztNm
|
||||||
|
FROM COMTNORGNZTINFO o
|
||||||
|
WHERE EXISTS (SELECT 1 FROM COMTNEMPLYRINFO e WHERE e.ORGNZT_ID = o.ORGNZT_ID)
|
||||||
|
ORDER BY o.ORGNZT_NM
|
||||||
|
</select>
|
||||||
|
|
||||||
</sqlMap>
|
</sqlMap>
|
||||||
|
|||||||
@ -0,0 +1,143 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
개인정보보호 기능개선 사업 (SER-001 / SER-002) — 개발항목 62.
|
||||||
|
로그인·로그아웃 접속기록(COMTNLOGINLOG) 조건별 조회·다운로드 + 접속위치 매핑.
|
||||||
|
|
||||||
|
[적재 statement 는 여기 없다] logInsertLoginLog 는 표준 로그인로그 모듈 소유
|
||||||
|
(egovframework/sqlmap/com/sym/log/clg/EgovLoginLog_SQL_Mysql.xml, namespace LoginLogDAO).
|
||||||
|
이중 경로 방지를 위해 이 파일에는 조회만 둔다.
|
||||||
|
|
||||||
|
[UPDATE/DELETE 없음] 접속기록 위·변조 방지 — 화면에서도 조회·다운로드만 제공한다.
|
||||||
|
|
||||||
|
[왜 조인이 없나 — 실측 근거]
|
||||||
|
CONECT_ID 는 컬럼 의미상 고유식별자(ESNTL_ID)지만 실제 적재값은 16자리 hex 라
|
||||||
|
회원 테이블 4종(COMTNEMPLYRINFO/COMTNGNRLMBER/BIZ_GROUP/COMTNENTRPRSMBER)
|
||||||
|
어디와도 0건 매칭이다(2026-08-07 운영 덤프 373,959행 실측). 그래서 조인으로
|
||||||
|
계정ID를 복원하지 않고, 적재 시점에 CONECT_ACNT_ID/CONECT_USER_SE 로 직접 남긴 값을 쓴다.
|
||||||
|
검색·정렬이 전부 COMTNLOGINLOG 단일 테이블 안에서 끝나므로 37만행에서도 인덱스로 처리된다.
|
||||||
|
성명만 표시용으로 스칼라 서브쿼리를 쓴다 — LIMIT 이후 반환 행(1페이지 10건)에 대해서만
|
||||||
|
평가되므로 목록 성능에 영향이 없고, 성명을 로그 테이블에 중복 보관하지 않아도 된다
|
||||||
|
(접속기록에 개인정보 본문을 늘리지 않는다는 원칙).
|
||||||
|
-->
|
||||||
|
<sqlMap namespace="ConectLogDAO">
|
||||||
|
|
||||||
|
<typeAlias alias="conectLogVO" type="egovframework.com.cmm.privacy.conect.ConectLogVO"/>
|
||||||
|
<typeAlias alias="ipLocationVO" type="egovframework.com.cmm.privacy.conect.IpLocationVO"/>
|
||||||
|
|
||||||
|
<!-- ================= 검색조건 (목록·건수·다운로드 동일 적용) ================= -->
|
||||||
|
<sql id="ConectLogDAO.searchCondition">
|
||||||
|
<dynamic prepend="WHERE">
|
||||||
|
<isNotEmpty prepend="AND" property="searchUserId">
|
||||||
|
a.CONECT_ACNT_ID LIKE CONCAT('%', #searchUserId#, '%')
|
||||||
|
</isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="searchMthd">
|
||||||
|
a.CONECT_MTHD = #searchMthd#
|
||||||
|
</isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="searchUserSe">
|
||||||
|
a.CONECT_USER_SE = #searchUserSe#
|
||||||
|
</isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="searchIp">
|
||||||
|
a.CONECT_IP LIKE CONCAT('%', #searchIp#, '%')
|
||||||
|
</isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="searchLc">
|
||||||
|
a.CONECT_LC = #searchLc#
|
||||||
|
</isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="searchBgnDe">
|
||||||
|
a.CREAT_DT >= CONCAT(#searchBgnDe#, ' 00:00:00')
|
||||||
|
</isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="searchEndDe">
|
||||||
|
a.CREAT_DT <= CONCAT(#searchEndDe#, ' 23:59:59')
|
||||||
|
</isNotEmpty>
|
||||||
|
</dynamic>
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<!-- ================= 조회 컬럼 (목록·다운로드 공통) ================= -->
|
||||||
|
<!-- userNm 은 구분별 회원 테이블 PK 로 찾는다. 대조 실패(탈퇴·레거시)는 정상 케이스라 NULL. -->
|
||||||
|
<sql id="ConectLogDAO.selectColumns">
|
||||||
|
SELECT a.LOG_ID AS logId
|
||||||
|
, DATE_FORMAT(a.CREAT_DT, '%Y-%m-%d %H:%i:%s') AS creatDt
|
||||||
|
, a.CONECT_MTHD AS conectMthd
|
||||||
|
, a.CONECT_IP AS conectIp
|
||||||
|
, a.CONECT_LC AS conectLc
|
||||||
|
, a.CONECT_ID AS conectId
|
||||||
|
, a.CONECT_ACNT_ID AS userId
|
||||||
|
, a.CONECT_USER_SE AS userSe
|
||||||
|
, a.ERROR_OCCRRNC_AT AS errOccrrAt
|
||||||
|
, CASE a.CONECT_USER_SE
|
||||||
|
WHEN 'USR' THEN (SELECT e.USER_NM FROM COMTNEMPLYRINFO e WHERE e.EMPLYR_ID = a.CONECT_ACNT_ID)
|
||||||
|
WHEN 'GNR' THEN (SELECT g.MBER_NM FROM COMTNGNRLMBER g WHERE g.MBER_ID = a.CONECT_ACNT_ID)
|
||||||
|
WHEN 'GRO' THEN (SELECT b.GROUP_NM FROM BIZ_GROUP b WHERE b.GROUP_ID = a.CONECT_ACNT_ID)
|
||||||
|
WHEN 'ENT' THEN (SELECT n.CMPNY_NM FROM COMTNENTRPRSMBER n WHERE n.ENTRPRS_MBER_ID = a.CONECT_ACNT_ID)
|
||||||
|
ELSE NULL
|
||||||
|
END AS userNm
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<!-- ================= 목록 조회(페이징) ================= -->
|
||||||
|
<select id="ConectLogDAO.selectConectLogList"
|
||||||
|
parameterClass="conectLogVO"
|
||||||
|
resultClass="conectLogVO">
|
||||||
|
<include refid="ConectLogDAO.selectColumns"/>
|
||||||
|
FROM COMTNLOGINLOG a
|
||||||
|
<include refid="ConectLogDAO.searchCondition"/>
|
||||||
|
ORDER BY a.CREAT_DT DESC, a.LOG_ID DESC
|
||||||
|
LIMIT #firstIndex#, #recordCountPerPage#
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="ConectLogDAO.selectConectLogListCount"
|
||||||
|
parameterClass="conectLogVO"
|
||||||
|
resultClass="java.lang.Integer">
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM COMTNLOGINLOG a
|
||||||
|
<include refid="ConectLogDAO.searchCondition"/>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- ================= 다운로드용 목록 ================= -->
|
||||||
|
<!-- 페이징 대신 서비스가 건 상한(recordCountPerPage)만 적용한다. -->
|
||||||
|
<select id="ConectLogDAO.selectConectLogListForDownload"
|
||||||
|
parameterClass="conectLogVO"
|
||||||
|
resultClass="conectLogVO">
|
||||||
|
<include refid="ConectLogDAO.selectColumns"/>
|
||||||
|
FROM COMTNLOGINLOG a
|
||||||
|
<include refid="ConectLogDAO.searchCondition"/>
|
||||||
|
ORDER BY a.CREAT_DT DESC, a.LOG_ID DESC
|
||||||
|
LIMIT #firstIndex#, #recordCountPerPage#
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- ================= 검색조건 드롭다운 : 접속위치 ================= -->
|
||||||
|
<!-- 설정 대역명 + 실제 적재된 값의 합집합.
|
||||||
|
적재값을 빼면(설정만 보면) 검색 결과가 항상 0건이 되는 함정이 있다(32-1 교훈).
|
||||||
|
반대로 적재값만 쓰면 컬럼 신설 직후 목록이 비므로 둘을 합친다.
|
||||||
|
IX_CLG_LC 인덱스가 있어야 DISTINCT 가 37만행 풀스캔이 되지 않는다. -->
|
||||||
|
<select id="ConectLogDAO.selectConectLcList" resultClass="java.lang.String">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT LOC_NM AS locNm
|
||||||
|
FROM CONECT_IP_LOCATION
|
||||||
|
WHERE USE_AT = 'Y'
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT CONECT_LC
|
||||||
|
FROM COMTNLOGINLOG
|
||||||
|
WHERE CONECT_LC IS NOT NULL
|
||||||
|
AND CONECT_LC <> ''
|
||||||
|
ORDER BY 1
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- ================= 접속위치 매핑(IpLocationResolver 캐시 적재) ================= -->
|
||||||
|
<select id="ConectLogDAO.selectUsedLocationList" resultClass="ipLocationVO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT LOC_ID AS locId
|
||||||
|
, IP_CIDR AS ipCidr
|
||||||
|
, LOC_NM AS locNm
|
||||||
|
, LOC_SE AS locSe
|
||||||
|
, SORT_ORDR AS sortOrdr
|
||||||
|
, USE_AT AS useAt
|
||||||
|
, REMARK AS remark
|
||||||
|
FROM CONECT_IP_LOCATION
|
||||||
|
WHERE USE_AT = 'Y'
|
||||||
|
ORDER BY SORT_ORDR, LOC_ID
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</sqlMap>
|
||||||
@ -0,0 +1,128 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
개인정보보호 기능개선 사업 (SER-001 / SER-002) — 개발항목 62.
|
||||||
|
로그인·로그아웃 접속기록(COMTNLOGINLOG) 조건별 조회·다운로드 + 접속위치 매핑 (Oracle 쌍).
|
||||||
|
|
||||||
|
대상 운영 DB 가 MySQL(csv)이라 sql-map-config 미등록 —
|
||||||
|
다른 Privacy* Oracle 파일과 동일 관례(이중 관리 유지용).
|
||||||
|
MySQL 판과의 차이: CONCAT -> ||, DATE_FORMAT -> TO_CHAR, LIMIT -> ROWNUM 인라인뷰.
|
||||||
|
-->
|
||||||
|
<sqlMap namespace="ConectLogDAO">
|
||||||
|
|
||||||
|
<typeAlias alias="conectLogVO" type="egovframework.com.cmm.privacy.conect.ConectLogVO"/>
|
||||||
|
<typeAlias alias="ipLocationVO" type="egovframework.com.cmm.privacy.conect.IpLocationVO"/>
|
||||||
|
|
||||||
|
<sql id="ConectLogDAO.searchCondition">
|
||||||
|
<dynamic prepend="WHERE">
|
||||||
|
<isNotEmpty prepend="AND" property="searchUserId">
|
||||||
|
a.CONECT_ACNT_ID LIKE '%' || #searchUserId# || '%'
|
||||||
|
</isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="searchMthd">
|
||||||
|
a.CONECT_MTHD = #searchMthd#
|
||||||
|
</isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="searchUserSe">
|
||||||
|
a.CONECT_USER_SE = #searchUserSe#
|
||||||
|
</isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="searchIp">
|
||||||
|
a.CONECT_IP LIKE '%' || #searchIp# || '%'
|
||||||
|
</isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="searchLc">
|
||||||
|
a.CONECT_LC = #searchLc#
|
||||||
|
</isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="searchBgnDe">
|
||||||
|
a.CREAT_DT >= TO_DATE(#searchBgnDe# || ' 00:00:00', 'YYYY-MM-DD HH24:MI:SS')
|
||||||
|
</isNotEmpty>
|
||||||
|
<isNotEmpty prepend="AND" property="searchEndDe">
|
||||||
|
a.CREAT_DT <= TO_DATE(#searchEndDe# || ' 23:59:59', 'YYYY-MM-DD HH24:MI:SS')
|
||||||
|
</isNotEmpty>
|
||||||
|
</dynamic>
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<sql id="ConectLogDAO.selectColumns">
|
||||||
|
SELECT a.LOG_ID AS logId
|
||||||
|
, TO_CHAR(a.CREAT_DT, 'YYYY-MM-DD HH24:MI:SS') AS creatDt
|
||||||
|
, a.CONECT_MTHD AS conectMthd
|
||||||
|
, a.CONECT_IP AS conectIp
|
||||||
|
, a.CONECT_LC AS conectLc
|
||||||
|
, a.CONECT_ID AS conectId
|
||||||
|
, a.CONECT_ACNT_ID AS userId
|
||||||
|
, a.CONECT_USER_SE AS userSe
|
||||||
|
, a.ERROR_OCCRRNC_AT AS errOccrrAt
|
||||||
|
, CASE a.CONECT_USER_SE
|
||||||
|
WHEN 'USR' THEN (SELECT e.USER_NM FROM COMTNEMPLYRINFO e WHERE e.EMPLYR_ID = a.CONECT_ACNT_ID)
|
||||||
|
WHEN 'GNR' THEN (SELECT g.MBER_NM FROM COMTNGNRLMBER g WHERE g.MBER_ID = a.CONECT_ACNT_ID)
|
||||||
|
WHEN 'GRO' THEN (SELECT b.GROUP_NM FROM BIZ_GROUP b WHERE b.GROUP_ID = a.CONECT_ACNT_ID)
|
||||||
|
WHEN 'ENT' THEN (SELECT n.CMPNY_NM FROM COMTNENTRPRSMBER n WHERE n.ENTRPRS_MBER_ID = a.CONECT_ACNT_ID)
|
||||||
|
ELSE NULL
|
||||||
|
END AS userNm
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<select id="ConectLogDAO.selectConectLogList"
|
||||||
|
parameterClass="conectLogVO"
|
||||||
|
resultClass="conectLogVO">
|
||||||
|
SELECT * FROM (
|
||||||
|
SELECT inner_t.*, ROWNUM AS rnum FROM (
|
||||||
|
<include refid="ConectLogDAO.selectColumns"/>
|
||||||
|
FROM COMTNLOGINLOG a
|
||||||
|
<include refid="ConectLogDAO.searchCondition"/>
|
||||||
|
ORDER BY a.CREAT_DT DESC, a.LOG_ID DESC
|
||||||
|
) inner_t
|
||||||
|
WHERE ROWNUM <= #firstIndex# + #recordCountPerPage#
|
||||||
|
)
|
||||||
|
WHERE rnum > #firstIndex#
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="ConectLogDAO.selectConectLogListCount"
|
||||||
|
parameterClass="conectLogVO"
|
||||||
|
resultClass="java.lang.Integer">
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM COMTNLOGINLOG a
|
||||||
|
<include refid="ConectLogDAO.searchCondition"/>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="ConectLogDAO.selectConectLogListForDownload"
|
||||||
|
parameterClass="conectLogVO"
|
||||||
|
resultClass="conectLogVO">
|
||||||
|
SELECT * FROM (
|
||||||
|
SELECT inner_t.*, ROWNUM AS rnum FROM (
|
||||||
|
<include refid="ConectLogDAO.selectColumns"/>
|
||||||
|
FROM COMTNLOGINLOG a
|
||||||
|
<include refid="ConectLogDAO.searchCondition"/>
|
||||||
|
ORDER BY a.CREAT_DT DESC, a.LOG_ID DESC
|
||||||
|
) inner_t
|
||||||
|
WHERE ROWNUM <= #firstIndex# + #recordCountPerPage#
|
||||||
|
)
|
||||||
|
WHERE rnum > #firstIndex#
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="ConectLogDAO.selectConectLcList" resultClass="java.lang.String">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT LOC_NM
|
||||||
|
FROM CONECT_IP_LOCATION
|
||||||
|
WHERE USE_AT = 'Y'
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT CONECT_LC
|
||||||
|
FROM COMTNLOGINLOG
|
||||||
|
WHERE CONECT_LC IS NOT NULL
|
||||||
|
ORDER BY 1
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="ConectLogDAO.selectUsedLocationList" resultClass="ipLocationVO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT LOC_ID AS locId
|
||||||
|
, IP_CIDR AS ipCidr
|
||||||
|
, LOC_NM AS locNm
|
||||||
|
, LOC_SE AS locSe
|
||||||
|
, SORT_ORDR AS sortOrdr
|
||||||
|
, USE_AT AS useAt
|
||||||
|
, REMARK AS remark
|
||||||
|
FROM CONECT_IP_LOCATION
|
||||||
|
WHERE USE_AT = 'Y'
|
||||||
|
ORDER BY SORT_ORDR, LOC_ID
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</sqlMap>
|
||||||
@ -0,0 +1,189 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
개인정보보호 기능개선 사업 (SFR-004-6 / SER-002) — 접속기록 CSV 비동기 다운로드 작업(PDAL_DOWNLOAD_JOB).
|
||||||
|
· 요청 등록(insert) → 워커가 대기건 선점(claim) → 진행률 갱신 → 완료/실패 확정
|
||||||
|
· 내려받기 시 요청자 본인 확인은 서비스 계층에서, 조회는 selectJob 으로 한다.
|
||||||
|
※ 주석에 '#' 을 쓰지 않는다 — iBatis2 가 #param# 으로 오인해 파싱이 깨진다.
|
||||||
|
-->
|
||||||
|
<sqlMap namespace="AccessLogDownloadJobDAO">
|
||||||
|
|
||||||
|
<insert id="AccessLogDownloadJobDAO.insertJob"
|
||||||
|
parameterClass="egovframework.com.cmm.privacy.auditlog.AccessLogDownloadJobVO">
|
||||||
|
<![CDATA[
|
||||||
|
INSERT INTO PDAL_DOWNLOAD_JOB (
|
||||||
|
REQUESTER_ID, REQUESTER_IP, DL_REASON, SEARCH_COND,
|
||||||
|
F_CONECT_ID, F_JOB_SE_CODE, F_TRGET_ID, F_CONECT_IP,
|
||||||
|
F_DML_GUBUN, F_PROCESS_RESULT, F_FROM_DT, F_TO_DT,
|
||||||
|
STATUS, TOTAL_CNT, DONE_CNT, REG_DT, EXPIRE_DT
|
||||||
|
) VALUES (
|
||||||
|
#requesterId#, #requesterIp:VARCHAR#, #dlReason#, #searchCond:VARCHAR#,
|
||||||
|
#condConectId:VARCHAR#, #condJobSeCode:VARCHAR#, #condTrgetId:VARCHAR#, #condConectIp:VARCHAR#,
|
||||||
|
#condDmlGubun:VARCHAR#, #condProcessResult:VARCHAR#, #condFromDt:VARCHAR#, #condToDt:VARCHAR#,
|
||||||
|
'W', 0, 0, NOW(), DATE_ADD(NOW(), INTERVAL #expireDays# DAY)
|
||||||
|
)
|
||||||
|
]]>
|
||||||
|
<selectKey resultClass="java.lang.Long" keyProperty="jobId">
|
||||||
|
SELECT LAST_INSERT_ID()
|
||||||
|
</selectKey>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<select id="AccessLogDownloadJobDAO.selectJob"
|
||||||
|
parameterClass="java.lang.Long"
|
||||||
|
resultClass="egovframework.com.cmm.privacy.auditlog.AccessLogDownloadJobVO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT JOB_ID AS jobId,
|
||||||
|
REQUESTER_ID AS requesterId,
|
||||||
|
REQUESTER_IP AS requesterIp,
|
||||||
|
DL_REASON AS dlReason,
|
||||||
|
SEARCH_COND AS searchCond,
|
||||||
|
F_CONECT_ID AS condConectId,
|
||||||
|
F_JOB_SE_CODE AS condJobSeCode,
|
||||||
|
F_TRGET_ID AS condTrgetId,
|
||||||
|
F_CONECT_IP AS condConectIp,
|
||||||
|
F_DML_GUBUN AS condDmlGubun,
|
||||||
|
F_PROCESS_RESULT AS condProcessResult,
|
||||||
|
F_FROM_DT AS condFromDt,
|
||||||
|
F_TO_DT AS condToDt,
|
||||||
|
STATUS AS status,
|
||||||
|
TOTAL_CNT AS totalCnt,
|
||||||
|
DONE_CNT AS doneCnt,
|
||||||
|
FILE_NM AS fileNm,
|
||||||
|
FILE_PATH AS filePath,
|
||||||
|
FILE_SIZE AS fileSize,
|
||||||
|
ERR_MSG AS errMsg,
|
||||||
|
DOWNLOAD_CNT AS downloadCnt,
|
||||||
|
LAST_DL_DT AS lastDlDt,
|
||||||
|
REG_DT AS regDt,
|
||||||
|
START_DT AS startDt,
|
||||||
|
END_DT AS endDt,
|
||||||
|
EXPIRE_DT AS expireDt
|
||||||
|
FROM PDAL_DOWNLOAD_JOB
|
||||||
|
WHERE JOB_ID = #value#
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 요청자 본인의 최근 작업 목록(화면 폴링용). 파기(X)분도 보여 이력이 끊기지 않게 한다. -->
|
||||||
|
<select id="AccessLogDownloadJobDAO.selectJobListByRequester"
|
||||||
|
parameterClass="java.util.HashMap"
|
||||||
|
resultClass="egovframework.com.cmm.privacy.auditlog.AccessLogDownloadJobVO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT JOB_ID AS jobId,
|
||||||
|
DL_REASON AS dlReason,
|
||||||
|
SEARCH_COND AS searchCond,
|
||||||
|
STATUS AS status,
|
||||||
|
TOTAL_CNT AS totalCnt,
|
||||||
|
DONE_CNT AS doneCnt,
|
||||||
|
FILE_NM AS fileNm,
|
||||||
|
FILE_SIZE AS fileSize,
|
||||||
|
ERR_MSG AS errMsg,
|
||||||
|
DOWNLOAD_CNT AS downloadCnt,
|
||||||
|
REG_DT AS regDt,
|
||||||
|
END_DT AS endDt,
|
||||||
|
EXPIRE_DT AS expireDt
|
||||||
|
FROM PDAL_DOWNLOAD_JOB
|
||||||
|
WHERE REQUESTER_ID = #requesterId#
|
||||||
|
ORDER BY JOB_ID DESC
|
||||||
|
LIMIT #limitCnt#
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
워커의 대기건 선점. STATUS='W' 인 가장 오래된 1건만 'R' 로 바꾼다.
|
||||||
|
WHERE 에 STATUS='W' 를 다시 두는 것이 핵심이다 — 워커가 둘 이상이어도
|
||||||
|
UPDATE 영향행수 1을 받은 쪽만 그 작업을 갖는다(중복 생성 방지).
|
||||||
|
-->
|
||||||
|
<update id="AccessLogDownloadJobDAO.claimJob" parameterClass="java.lang.Long">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE PDAL_DOWNLOAD_JOB
|
||||||
|
SET STATUS = 'R', START_DT = NOW()
|
||||||
|
WHERE JOB_ID = #value#
|
||||||
|
AND STATUS = 'W'
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<select id="AccessLogDownloadJobDAO.selectNextWaitingJobId" resultClass="java.lang.Long">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT JOB_ID
|
||||||
|
FROM PDAL_DOWNLOAD_JOB
|
||||||
|
WHERE STATUS = 'W'
|
||||||
|
ORDER BY JOB_ID ASC
|
||||||
|
LIMIT 1
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<update id="AccessLogDownloadJobDAO.updateProgress"
|
||||||
|
parameterClass="egovframework.com.cmm.privacy.auditlog.AccessLogDownloadJobVO">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE PDAL_DOWNLOAD_JOB
|
||||||
|
SET TOTAL_CNT = #totalCnt#, DONE_CNT = #doneCnt#
|
||||||
|
WHERE JOB_ID = #jobId#
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="AccessLogDownloadJobDAO.updateSuccess"
|
||||||
|
parameterClass="egovframework.com.cmm.privacy.auditlog.AccessLogDownloadJobVO">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE PDAL_DOWNLOAD_JOB
|
||||||
|
SET STATUS = 'S', END_DT = NOW(),
|
||||||
|
TOTAL_CNT = #totalCnt#, DONE_CNT = #doneCnt#,
|
||||||
|
FILE_NM = #fileNm#, FILE_PATH = #filePath#, FILE_SIZE = #fileSize#
|
||||||
|
WHERE JOB_ID = #jobId#
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="AccessLogDownloadJobDAO.updateFail"
|
||||||
|
parameterClass="egovframework.com.cmm.privacy.auditlog.AccessLogDownloadJobVO">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE PDAL_DOWNLOAD_JOB
|
||||||
|
SET STATUS = 'F', END_DT = NOW(), ERR_MSG = #errMsg:VARCHAR#
|
||||||
|
WHERE JOB_ID = #jobId#
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="AccessLogDownloadJobDAO.updateDownloaded" parameterClass="java.lang.Long">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE PDAL_DOWNLOAD_JOB
|
||||||
|
SET DOWNLOAD_CNT = DOWNLOAD_CNT + 1, LAST_DL_DT = NOW()
|
||||||
|
WHERE JOB_ID = #value#
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<!-- 파기 대상: 만료 경과했고 아직 파일을 들고 있는 완료/실패 건. -->
|
||||||
|
<select id="AccessLogDownloadJobDAO.selectExpiredJobs"
|
||||||
|
resultClass="egovframework.com.cmm.privacy.auditlog.AccessLogDownloadJobVO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT JOB_ID AS jobId,
|
||||||
|
FILE_PATH AS filePath,
|
||||||
|
FILE_NM AS fileNm,
|
||||||
|
EXPIRE_DT AS expireDt
|
||||||
|
FROM PDAL_DOWNLOAD_JOB
|
||||||
|
WHERE STATUS IN ('S','F')
|
||||||
|
AND EXPIRE_DT IS NOT NULL
|
||||||
|
AND EXPIRE_DT <= NOW()
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 파기 확정: 파일 경로를 지워 재다운로드를 원천 차단한다. -->
|
||||||
|
<update id="AccessLogDownloadJobDAO.updatePurged" parameterClass="java.lang.Long">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE PDAL_DOWNLOAD_JOB
|
||||||
|
SET STATUS = 'X', FILE_PATH = NULL
|
||||||
|
WHERE JOB_ID = #value#
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
기동 시 고아 작업 복구. 파일 생성 중(R)에 WAS 가 죽으면 영원히 R 로 남아
|
||||||
|
화면에 '생성중'이 멈춰 보인다. 기동 시 대기(W)로 되돌려 재시도하게 한다.
|
||||||
|
-->
|
||||||
|
<update id="AccessLogDownloadJobDAO.resetRunningJobs">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE PDAL_DOWNLOAD_JOB
|
||||||
|
SET STATUS = 'W', START_DT = NULL
|
||||||
|
WHERE STATUS = 'R'
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
</sqlMap>
|
||||||
@ -0,0 +1,190 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
개인정보보호 기능개선 사업 (SFR-004-6 / SER-002) — 접속기록 CSV 비동기 다운로드 작업(PDAL_DOWNLOAD_JOB) — Oracle 쌍(이중관리 규약).
|
||||||
|
※ arko 런타임은 MySQL sqlmap 만 로드한다. 시퀀스 PDAL_DOWNLOAD_JOB_SEQ 전제.
|
||||||
|
· 요청 등록(insert) → 워커가 대기건 선점(claim) → 진행률 갱신 → 완료/실패 확정
|
||||||
|
· 내려받기 시 요청자 본인 확인은 서비스 계층에서, 조회는 selectJob 으로 한다.
|
||||||
|
※ 주석에 '#' 을 쓰지 않는다 — iBatis2 가 #param# 으로 오인해 파싱이 깨진다.
|
||||||
|
-->
|
||||||
|
<sqlMap namespace="AccessLogDownloadJobDAO">
|
||||||
|
|
||||||
|
<insert id="AccessLogDownloadJobDAO.insertJob"
|
||||||
|
parameterClass="egovframework.com.cmm.privacy.auditlog.AccessLogDownloadJobVO">
|
||||||
|
<![CDATA[
|
||||||
|
INSERT INTO PDAL_DOWNLOAD_JOB (
|
||||||
|
JOB_ID, REQUESTER_ID, REQUESTER_IP, DL_REASON, SEARCH_COND,
|
||||||
|
F_CONECT_ID, F_JOB_SE_CODE, F_TRGET_ID, F_CONECT_IP,
|
||||||
|
F_DML_GUBUN, F_PROCESS_RESULT, F_FROM_DT, F_TO_DT,
|
||||||
|
STATUS, TOTAL_CNT, DONE_CNT, REG_DT, EXPIRE_DT
|
||||||
|
) VALUES (
|
||||||
|
PDAL_DOWNLOAD_JOB_SEQ.NEXTVAL, #requesterId#, #requesterIp:VARCHAR#, #dlReason#, #searchCond:VARCHAR#,
|
||||||
|
#condConectId:VARCHAR#, #condJobSeCode:VARCHAR#, #condTrgetId:VARCHAR#, #condConectIp:VARCHAR#,
|
||||||
|
#condDmlGubun:VARCHAR#, #condProcessResult:VARCHAR#, #condFromDt:VARCHAR#, #condToDt:VARCHAR#,
|
||||||
|
'W', 0, 0, SYSDATE, SYSDATE + #expireDays#
|
||||||
|
)
|
||||||
|
]]>
|
||||||
|
<selectKey resultClass="java.lang.Long" keyProperty="jobId">
|
||||||
|
SELECT PDAL_DOWNLOAD_JOB_SEQ.CURRVAL FROM DUAL
|
||||||
|
</selectKey>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<select id="AccessLogDownloadJobDAO.selectJob"
|
||||||
|
parameterClass="java.lang.Long"
|
||||||
|
resultClass="egovframework.com.cmm.privacy.auditlog.AccessLogDownloadJobVO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT JOB_ID AS jobId,
|
||||||
|
REQUESTER_ID AS requesterId,
|
||||||
|
REQUESTER_IP AS requesterIp,
|
||||||
|
DL_REASON AS dlReason,
|
||||||
|
SEARCH_COND AS searchCond,
|
||||||
|
F_CONECT_ID AS condConectId,
|
||||||
|
F_JOB_SE_CODE AS condJobSeCode,
|
||||||
|
F_TRGET_ID AS condTrgetId,
|
||||||
|
F_CONECT_IP AS condConectIp,
|
||||||
|
F_DML_GUBUN AS condDmlGubun,
|
||||||
|
F_PROCESS_RESULT AS condProcessResult,
|
||||||
|
F_FROM_DT AS condFromDt,
|
||||||
|
F_TO_DT AS condToDt,
|
||||||
|
STATUS AS status,
|
||||||
|
TOTAL_CNT AS totalCnt,
|
||||||
|
DONE_CNT AS doneCnt,
|
||||||
|
FILE_NM AS fileNm,
|
||||||
|
FILE_PATH AS filePath,
|
||||||
|
FILE_SIZE AS fileSize,
|
||||||
|
ERR_MSG AS errMsg,
|
||||||
|
DOWNLOAD_CNT AS downloadCnt,
|
||||||
|
LAST_DL_DT AS lastDlDt,
|
||||||
|
REG_DT AS regDt,
|
||||||
|
START_DT AS startDt,
|
||||||
|
END_DT AS endDt,
|
||||||
|
EXPIRE_DT AS expireDt
|
||||||
|
FROM PDAL_DOWNLOAD_JOB
|
||||||
|
WHERE JOB_ID = #value#
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 요청자 본인의 최근 작업 목록(화면 폴링용). 파기(X)분도 보여 이력이 끊기지 않게 한다. -->
|
||||||
|
<select id="AccessLogDownloadJobDAO.selectJobListByRequester"
|
||||||
|
parameterClass="java.util.HashMap"
|
||||||
|
resultClass="egovframework.com.cmm.privacy.auditlog.AccessLogDownloadJobVO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT JOB_ID AS jobId,
|
||||||
|
DL_REASON AS dlReason,
|
||||||
|
SEARCH_COND AS searchCond,
|
||||||
|
STATUS AS status,
|
||||||
|
TOTAL_CNT AS totalCnt,
|
||||||
|
DONE_CNT AS doneCnt,
|
||||||
|
FILE_NM AS fileNm,
|
||||||
|
FILE_SIZE AS fileSize,
|
||||||
|
ERR_MSG AS errMsg,
|
||||||
|
DOWNLOAD_CNT AS downloadCnt,
|
||||||
|
REG_DT AS regDt,
|
||||||
|
END_DT AS endDt,
|
||||||
|
EXPIRE_DT AS expireDt
|
||||||
|
FROM PDAL_DOWNLOAD_JOB
|
||||||
|
WHERE REQUESTER_ID = #requesterId#
|
||||||
|
AND ROWNUM <= #limitCnt#
|
||||||
|
ORDER BY JOB_ID DESC
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
워커의 대기건 선점. STATUS='W' 인 가장 오래된 1건만 'R' 로 바꾼다.
|
||||||
|
WHERE 에 STATUS='W' 를 다시 두는 것이 핵심이다 — 워커가 둘 이상이어도
|
||||||
|
UPDATE 영향행수 1을 받은 쪽만 그 작업을 갖는다(중복 생성 방지).
|
||||||
|
-->
|
||||||
|
<update id="AccessLogDownloadJobDAO.claimJob" parameterClass="java.lang.Long">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE PDAL_DOWNLOAD_JOB
|
||||||
|
SET STATUS = 'R', START_DT = SYSDATE
|
||||||
|
WHERE JOB_ID = #value#
|
||||||
|
AND STATUS = 'W'
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<select id="AccessLogDownloadJobDAO.selectNextWaitingJobId" resultClass="java.lang.Long">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT JOB_ID
|
||||||
|
FROM PDAL_DOWNLOAD_JOB
|
||||||
|
WHERE STATUS = 'W'
|
||||||
|
AND ROWNUM = 1
|
||||||
|
ORDER BY JOB_ID ASC
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<update id="AccessLogDownloadJobDAO.updateProgress"
|
||||||
|
parameterClass="egovframework.com.cmm.privacy.auditlog.AccessLogDownloadJobVO">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE PDAL_DOWNLOAD_JOB
|
||||||
|
SET TOTAL_CNT = #totalCnt#, DONE_CNT = #doneCnt#
|
||||||
|
WHERE JOB_ID = #jobId#
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="AccessLogDownloadJobDAO.updateSuccess"
|
||||||
|
parameterClass="egovframework.com.cmm.privacy.auditlog.AccessLogDownloadJobVO">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE PDAL_DOWNLOAD_JOB
|
||||||
|
SET STATUS = 'S', END_DT = SYSDATE,
|
||||||
|
TOTAL_CNT = #totalCnt#, DONE_CNT = #doneCnt#,
|
||||||
|
FILE_NM = #fileNm#, FILE_PATH = #filePath#, FILE_SIZE = #fileSize#
|
||||||
|
WHERE JOB_ID = #jobId#
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="AccessLogDownloadJobDAO.updateFail"
|
||||||
|
parameterClass="egovframework.com.cmm.privacy.auditlog.AccessLogDownloadJobVO">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE PDAL_DOWNLOAD_JOB
|
||||||
|
SET STATUS = 'F', END_DT = SYSDATE, ERR_MSG = #errMsg:VARCHAR#
|
||||||
|
WHERE JOB_ID = #jobId#
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="AccessLogDownloadJobDAO.updateDownloaded" parameterClass="java.lang.Long">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE PDAL_DOWNLOAD_JOB
|
||||||
|
SET DOWNLOAD_CNT = DOWNLOAD_CNT + 1, LAST_DL_DT = SYSDATE
|
||||||
|
WHERE JOB_ID = #value#
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<!-- 파기 대상: 만료 경과했고 아직 파일을 들고 있는 완료/실패 건. -->
|
||||||
|
<select id="AccessLogDownloadJobDAO.selectExpiredJobs"
|
||||||
|
resultClass="egovframework.com.cmm.privacy.auditlog.AccessLogDownloadJobVO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT JOB_ID AS jobId,
|
||||||
|
FILE_PATH AS filePath,
|
||||||
|
FILE_NM AS fileNm,
|
||||||
|
EXPIRE_DT AS expireDt
|
||||||
|
FROM PDAL_DOWNLOAD_JOB
|
||||||
|
WHERE STATUS IN ('S','F')
|
||||||
|
AND EXPIRE_DT IS NOT NULL
|
||||||
|
AND EXPIRE_DT <= SYSDATE
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 파기 확정: 파일 경로를 지워 재다운로드를 원천 차단한다. -->
|
||||||
|
<update id="AccessLogDownloadJobDAO.updatePurged" parameterClass="java.lang.Long">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE PDAL_DOWNLOAD_JOB
|
||||||
|
SET STATUS = 'X', FILE_PATH = NULL
|
||||||
|
WHERE JOB_ID = #value#
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
기동 시 고아 작업 복구. 파일 생성 중(R)에 WAS 가 죽으면 영원히 R 로 남아
|
||||||
|
화면에 '생성중'이 멈춰 보인다. 기동 시 대기(W)로 되돌려 재시도하게 한다.
|
||||||
|
-->
|
||||||
|
<update id="AccessLogDownloadJobDAO.resetRunningJobs">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE PDAL_DOWNLOAD_JOB
|
||||||
|
SET STATUS = 'W', START_DT = NULL
|
||||||
|
WHERE STATUS = 'R'
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
</sqlMap>
|
||||||
@ -0,0 +1,145 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
회원 이름(MBER_NM) 암호화 이관 SQL — 개인정보보호 기능개선 사업(추가제안 Action4)
|
||||||
|
작성일 2026-08-10 / 대상 COMTNGNRLMBER (실측 77,053건)
|
||||||
|
|
||||||
|
[원칙] 평문 컬럼 MBER_NM 은 이 파일 어디에서도 UPDATE 하지 않는다.
|
||||||
|
암호문·해시·키버전 3개 컬럼만 채운다.
|
||||||
|
[주의] iBatis2 는 '#' 를 파라미터 구분자로 쓴다. SQL 주석에 '#' 를 쓰면 파싱이 깨지므로
|
||||||
|
주석은 반드시 슬래시-별표 형식만 사용한다.
|
||||||
|
-->
|
||||||
|
<sqlMap namespace="MemberNmCryptoDAO">
|
||||||
|
|
||||||
|
<typeAlias alias="memberNmCryptoVO" type="egovframework.com.cmm.privacy.crypto.MemberNmCryptoVO" />
|
||||||
|
|
||||||
|
<resultMap id="memberNmCryptoResult" class="memberNmCryptoVO">
|
||||||
|
<result property="mberId" column="MBER_ID" columnIndex="1" />
|
||||||
|
<result property="mberNm" column="MBER_NM" columnIndex="2" />
|
||||||
|
</resultMap>
|
||||||
|
|
||||||
|
<resultMap id="memberNmVerifyResult" class="memberNmCryptoVO">
|
||||||
|
<result property="mberId" column="MBER_ID" columnIndex="1" />
|
||||||
|
<result property="mberNm" column="MBER_NM" columnIndex="2" />
|
||||||
|
<result property="mberNmEnc" column="MBER_NM_ENC" columnIndex="3" />
|
||||||
|
<result property="mberNmHash" column="MBER_NM_HASH" columnIndex="4" />
|
||||||
|
</resultMap>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
미암호화 행을 커서 기준으로 청크 조회한다.
|
||||||
|
|
||||||
|
MBER_NM_ENC IS NULL 이 재실행 안전성의 핵심이다. 커서(lastKey)를 잃어버려도
|
||||||
|
처음부터 다시 돌리면 이미 처리된 행은 자동으로 걸러진다.
|
||||||
|
OFFSET 페이징을 쓰지 않는 이유는 뒤로 갈수록 선형 저하되기 때문이다.
|
||||||
|
-->
|
||||||
|
<select id="MemberNmCryptoDAO.selectChunk" parameterClass="map" resultMap="memberNmCryptoResult">
|
||||||
|
/* MemberNmCryptoDAO.selectChunk */
|
||||||
|
SELECT MBER_ID
|
||||||
|
, MBER_NM
|
||||||
|
FROM COMTNGNRLMBER
|
||||||
|
WHERE MBER_NM_ENC IS NULL
|
||||||
|
<isNotEmpty property="lastKey">
|
||||||
|
AND MBER_ID > #lastKey#
|
||||||
|
</isNotEmpty>
|
||||||
|
ORDER BY MBER_ID
|
||||||
|
LIMIT #chunkSize#
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
암호문·해시·키버전만 반영. WHERE 는 PK 단건이라 락 범위가 최소다.
|
||||||
|
평문 MBER_NM 은 건드리지 않는다(롤백 창구 유지).
|
||||||
|
-->
|
||||||
|
<update id="MemberNmCryptoDAO.updateEncrypted" parameterClass="memberNmCryptoVO">
|
||||||
|
/* MemberNmCryptoDAO.updateEncrypted */
|
||||||
|
UPDATE COMTNGNRLMBER
|
||||||
|
SET MBER_NM_ENC = #mberNmEnc#
|
||||||
|
, MBER_NM_HASH = #mberNmHash#
|
||||||
|
, CRYPTO_KEY_VER = #cryptoKeyVer#
|
||||||
|
WHERE MBER_ID = #mberId#
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<select id="MemberNmCryptoDAO.selectRemainingCount" resultClass="java.lang.Integer">
|
||||||
|
/* MemberNmCryptoDAO.selectRemainingCount */
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM COMTNGNRLMBER
|
||||||
|
WHERE MBER_NM_ENC IS NULL
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
검증 집계. MISSING_CNT 가 0 이어야 이관이 끝난 것이다.
|
||||||
|
ENC_CNT 만 보면 "평문이 없어서 암호문도 없는" 행과 "누락된" 행을 구분하지 못한다.
|
||||||
|
-->
|
||||||
|
<select id="MemberNmCryptoDAO.selectVerifyCounts" resultClass="egovMap">
|
||||||
|
/* MemberNmCryptoDAO.selectVerifyCounts */
|
||||||
|
SELECT COUNT(*) AS TOTAL_CNT
|
||||||
|
, SUM(CASE WHEN MBER_NM IS NOT NULL THEN 1 ELSE 0 END) AS PLAIN_CNT
|
||||||
|
, SUM(CASE WHEN MBER_NM_ENC IS NOT NULL THEN 1 ELSE 0 END) AS ENC_CNT
|
||||||
|
, SUM(CASE WHEN MBER_NM_HASH IS NOT NULL THEN 1 ELSE 0 END) AS HASH_CNT
|
||||||
|
, SUM(CASE WHEN MBER_NM IS NOT NULL AND MBER_NM_ENC IS NULL
|
||||||
|
THEN 1 ELSE 0 END) AS MISSING_CNT
|
||||||
|
FROM COMTNGNRLMBER
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 검증용 — 암호화된 행을 커서로 훑어 복호화 대사한다. -->
|
||||||
|
<select id="MemberNmCryptoDAO.selectVerifyChunk" parameterClass="map" resultMap="memberNmVerifyResult">
|
||||||
|
/* MemberNmCryptoDAO.selectVerifyChunk */
|
||||||
|
SELECT MBER_ID
|
||||||
|
, MBER_NM
|
||||||
|
, MBER_NM_ENC
|
||||||
|
, MBER_NM_HASH
|
||||||
|
FROM COMTNGNRLMBER
|
||||||
|
WHERE MBER_NM_ENC IS NOT NULL
|
||||||
|
<isNotEmpty property="lastKey">
|
||||||
|
AND MBER_ID > #lastKey#
|
||||||
|
</isNotEmpty>
|
||||||
|
ORDER BY MBER_ID
|
||||||
|
LIMIT #chunkSize#
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- ===================== 진행상태 ===================== -->
|
||||||
|
|
||||||
|
<!-- 없을 때만 만든다. 재실행해도 기존 커서를 초기화하지 않는다. -->
|
||||||
|
<insert id="MemberNmCryptoDAO.insertProgressIfAbsent" parameterClass="map">
|
||||||
|
/* MemberNmCryptoDAO.insertProgressIfAbsent */
|
||||||
|
INSERT INTO MIG_CRYPTO_PROGRESS (JOB_ID, LAST_KEY, DONE_CNT, ERR_CNT, JOB_STTUS, STR_DT)
|
||||||
|
SELECT #jobId#, NULL, 0, 0, 'R', NOW()
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM MIG_CRYPTO_PROGRESS WHERE JOB_ID = #jobId#
|
||||||
|
)
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<update id="MemberNmCryptoDAO.updateProgress" parameterClass="map">
|
||||||
|
/* MemberNmCryptoDAO.updateProgress */
|
||||||
|
UPDATE MIG_CRYPTO_PROGRESS
|
||||||
|
SET LAST_KEY = #lastKey#
|
||||||
|
, DONE_CNT = DONE_CNT + #doneDelta#
|
||||||
|
, ERR_CNT = ERR_CNT + #errDelta#
|
||||||
|
WHERE JOB_ID = #jobId#
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="MemberNmCryptoDAO.updateStatus" parameterClass="map">
|
||||||
|
/* MemberNmCryptoDAO.updateStatus */
|
||||||
|
UPDATE MIG_CRYPTO_PROGRESS
|
||||||
|
SET JOB_STTUS = #jobSttus#
|
||||||
|
, RM = #rm#
|
||||||
|
, END_DT = CASE WHEN #jobSttus# IN ('C','E') THEN NOW() ELSE END_DT END
|
||||||
|
WHERE JOB_ID = #jobId#
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<select id="MemberNmCryptoDAO.selectProgress" parameterClass="map" resultClass="egovMap">
|
||||||
|
/* MemberNmCryptoDAO.selectProgress */
|
||||||
|
SELECT JOB_ID AS "jobId"
|
||||||
|
, LAST_KEY AS "lastKey"
|
||||||
|
, DONE_CNT AS "doneCnt"
|
||||||
|
, ERR_CNT AS "errCnt"
|
||||||
|
, JOB_STTUS AS "jobSttus"
|
||||||
|
, STR_DT AS "strDt"
|
||||||
|
, END_DT AS "endDt"
|
||||||
|
, RM AS "rm"
|
||||||
|
FROM MIG_CRYPTO_PROGRESS
|
||||||
|
WHERE JOB_ID = #jobId#
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</sqlMap>
|
||||||
@ -0,0 +1,130 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE sqlMap PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN" "http://www.ibatis.com/dtd/sql-map-2.dtd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
회원 이름(MBER_NM) 암호화 이관 SQL (Oracle 쌍) — 개인정보보호 기능개선 사업(추가제안 Action4)
|
||||||
|
작성일 2026-08-10
|
||||||
|
|
||||||
|
arko 운영 DB는 MySQL 8이며 Oracle 파일은 이중 관리 규칙에 따른 쌍이다(현재 미사용).
|
||||||
|
LIMIT 대신 ROWNUM, NOW() 대신 SYSDATE, DUAL 은 그대로 사용한다.
|
||||||
|
-->
|
||||||
|
<sqlMap namespace="MemberNmCryptoDAO">
|
||||||
|
|
||||||
|
<typeAlias alias="memberNmCryptoVO" type="egovframework.com.cmm.privacy.crypto.MemberNmCryptoVO" />
|
||||||
|
|
||||||
|
<resultMap id="memberNmCryptoResult" class="memberNmCryptoVO">
|
||||||
|
<result property="mberId" column="MBER_ID" columnIndex="1" />
|
||||||
|
<result property="mberNm" column="MBER_NM" columnIndex="2" />
|
||||||
|
</resultMap>
|
||||||
|
|
||||||
|
<resultMap id="memberNmVerifyResult" class="memberNmCryptoVO">
|
||||||
|
<result property="mberId" column="MBER_ID" columnIndex="1" />
|
||||||
|
<result property="mberNm" column="MBER_NM" columnIndex="2" />
|
||||||
|
<result property="mberNmEnc" column="MBER_NM_ENC" columnIndex="3" />
|
||||||
|
<result property="mberNmHash" column="MBER_NM_HASH" columnIndex="4" />
|
||||||
|
</resultMap>
|
||||||
|
|
||||||
|
<select id="MemberNmCryptoDAO.selectChunk" parameterClass="map" resultMap="memberNmCryptoResult">
|
||||||
|
/* MemberNmCryptoDAO.selectChunk */
|
||||||
|
SELECT MBER_ID, MBER_NM FROM (
|
||||||
|
SELECT MBER_ID
|
||||||
|
, MBER_NM
|
||||||
|
FROM COMTNGNRLMBER
|
||||||
|
WHERE MBER_NM_ENC IS NULL
|
||||||
|
<isNotEmpty property="lastKey">
|
||||||
|
AND MBER_ID > #lastKey#
|
||||||
|
</isNotEmpty>
|
||||||
|
ORDER BY MBER_ID
|
||||||
|
)
|
||||||
|
WHERE ROWNUM <= #chunkSize#
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<update id="MemberNmCryptoDAO.updateEncrypted" parameterClass="memberNmCryptoVO">
|
||||||
|
/* MemberNmCryptoDAO.updateEncrypted */
|
||||||
|
UPDATE COMTNGNRLMBER
|
||||||
|
SET MBER_NM_ENC = #mberNmEnc#
|
||||||
|
, MBER_NM_HASH = #mberNmHash#
|
||||||
|
, CRYPTO_KEY_VER = #cryptoKeyVer#
|
||||||
|
WHERE MBER_ID = #mberId#
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<select id="MemberNmCryptoDAO.selectRemainingCount" resultClass="java.lang.Integer">
|
||||||
|
/* MemberNmCryptoDAO.selectRemainingCount */
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM COMTNGNRLMBER
|
||||||
|
WHERE MBER_NM_ENC IS NULL
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="MemberNmCryptoDAO.selectVerifyCounts" resultClass="egovMap">
|
||||||
|
/* MemberNmCryptoDAO.selectVerifyCounts */
|
||||||
|
SELECT COUNT(*) AS TOTAL_CNT
|
||||||
|
, SUM(CASE WHEN MBER_NM IS NOT NULL THEN 1 ELSE 0 END) AS PLAIN_CNT
|
||||||
|
, SUM(CASE WHEN MBER_NM_ENC IS NOT NULL THEN 1 ELSE 0 END) AS ENC_CNT
|
||||||
|
, SUM(CASE WHEN MBER_NM_HASH IS NOT NULL THEN 1 ELSE 0 END) AS HASH_CNT
|
||||||
|
, SUM(CASE WHEN MBER_NM IS NOT NULL AND MBER_NM_ENC IS NULL
|
||||||
|
THEN 1 ELSE 0 END) AS MISSING_CNT
|
||||||
|
FROM COMTNGNRLMBER
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="MemberNmCryptoDAO.selectVerifyChunk" parameterClass="map" resultMap="memberNmVerifyResult">
|
||||||
|
/* MemberNmCryptoDAO.selectVerifyChunk */
|
||||||
|
SELECT MBER_ID, MBER_NM, MBER_NM_ENC, MBER_NM_HASH FROM (
|
||||||
|
SELECT MBER_ID
|
||||||
|
, MBER_NM
|
||||||
|
, MBER_NM_ENC
|
||||||
|
, MBER_NM_HASH
|
||||||
|
FROM COMTNGNRLMBER
|
||||||
|
WHERE MBER_NM_ENC IS NOT NULL
|
||||||
|
<isNotEmpty property="lastKey">
|
||||||
|
AND MBER_ID > #lastKey#
|
||||||
|
</isNotEmpty>
|
||||||
|
ORDER BY MBER_ID
|
||||||
|
)
|
||||||
|
WHERE ROWNUM <= #chunkSize#
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- ===================== 진행상태 ===================== -->
|
||||||
|
|
||||||
|
<insert id="MemberNmCryptoDAO.insertProgressIfAbsent" parameterClass="map">
|
||||||
|
/* MemberNmCryptoDAO.insertProgressIfAbsent */
|
||||||
|
INSERT INTO MIG_CRYPTO_PROGRESS (JOB_ID, LAST_KEY, DONE_CNT, ERR_CNT, JOB_STTUS, STR_DT)
|
||||||
|
SELECT #jobId#, NULL, 0, 0, 'R', SYSDATE
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM MIG_CRYPTO_PROGRESS WHERE JOB_ID = #jobId#
|
||||||
|
)
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<update id="MemberNmCryptoDAO.updateProgress" parameterClass="map">
|
||||||
|
/* MemberNmCryptoDAO.updateProgress */
|
||||||
|
UPDATE MIG_CRYPTO_PROGRESS
|
||||||
|
SET LAST_KEY = #lastKey#
|
||||||
|
, DONE_CNT = DONE_CNT + #doneDelta#
|
||||||
|
, ERR_CNT = ERR_CNT + #errDelta#
|
||||||
|
WHERE JOB_ID = #jobId#
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="MemberNmCryptoDAO.updateStatus" parameterClass="map">
|
||||||
|
/* MemberNmCryptoDAO.updateStatus */
|
||||||
|
UPDATE MIG_CRYPTO_PROGRESS
|
||||||
|
SET JOB_STTUS = #jobSttus#
|
||||||
|
, RM = #rm#
|
||||||
|
, END_DT = CASE WHEN #jobSttus# IN ('C','E') THEN SYSDATE ELSE END_DT END
|
||||||
|
WHERE JOB_ID = #jobId#
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<select id="MemberNmCryptoDAO.selectProgress" parameterClass="map" resultClass="egovMap">
|
||||||
|
/* MemberNmCryptoDAO.selectProgress */
|
||||||
|
SELECT JOB_ID AS "jobId"
|
||||||
|
, LAST_KEY AS "lastKey"
|
||||||
|
, DONE_CNT AS "doneCnt"
|
||||||
|
, ERR_CNT AS "errCnt"
|
||||||
|
, JOB_STTUS AS "jobSttus"
|
||||||
|
, STR_DT AS "strDt"
|
||||||
|
, END_DT AS "endDt"
|
||||||
|
, RM AS "rm"
|
||||||
|
FROM MIG_CRYPTO_PROGRESS
|
||||||
|
WHERE JOB_ID = #jobId#
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</sqlMap>
|
||||||
@ -75,6 +75,10 @@
|
|||||||
============================================================ -->
|
============================================================ -->
|
||||||
<!-- ① 인증판정 (기존, 재활성 시 이 위치 유지) -->
|
<!-- ① 인증판정 (기존, 재활성 시 이 위치 유지) -->
|
||||||
<!-- <ref local="authenticInterceptor" /> 로그인 체크 -->
|
<!-- <ref local="authenticInterceptor" /> 로그인 체크 -->
|
||||||
|
<!-- 접근통제 세션 정책(SER-001 요건5) — 판정이 아니라 '세션 설정'이며 아무것도 막지 않는다.
|
||||||
|
②③ 판정보다 먼저 두어, 2차 인증 미완료·권한 미허용으로 차단되는 요청에도
|
||||||
|
자동 로그아웃 시간이 동일하게 적용되도록 한다. -->
|
||||||
|
<ref local="sessionPolicyInterceptor" />
|
||||||
<ref local="IpObtainInterceptor" />
|
<ref local="IpObtainInterceptor" />
|
||||||
<ref local="siteURLInterceptor" />
|
<ref local="siteURLInterceptor" />
|
||||||
<!-- 관리자 화면 메뉴 접속기록(SFR-004) — 판정이 아니라 기록이며, preHandle 은
|
<!-- 관리자 화면 메뉴 접속기록(SFR-004) — 판정이 아니라 기록이며, preHandle 은
|
||||||
@ -122,6 +126,20 @@
|
|||||||
· authorityInterceptor(#2): urlRoleMapping 기본 비어 있음(전 경로 통과). 운영에서 매핑 점진 등록.
|
· authorityInterceptor(#2): urlRoleMapping 기본 비어 있음(전 경로 통과). 운영에서 매핑 점진 등록.
|
||||||
· 순서: secondFactorInterceptor → authorityInterceptor (#26)
|
· 순서: secondFactorInterceptor → authorityInterceptor (#26)
|
||||||
============================================================ -->
|
============================================================ -->
|
||||||
|
<!-- ============================================================
|
||||||
|
[개인정보보호 SER-001 요건5 / 2026.08.10] 접근통제 세션 정책.
|
||||||
|
ACCESS_CONTROL_POLICY.AUTO_LOGOUT_MIN 을 /mnt/ 요청마다 세션에 적용해
|
||||||
|
web.xml <session-timeout> 재배포 없이 '미사용 자동 로그아웃' 시간을 바꾼다
|
||||||
|
(2025.9 자료요청 3번 "시간 설정 기능 없음" 해소).
|
||||||
|
로그인 시각 도장(PRIVACY_LOGIN_TIME)도 여기서 찍는다 — 관리자 상단 경과시간 표시용.
|
||||||
|
· accessControlPolicyService 는 루트 컨텍스트 @Service — 자식→부모 가시성으로 참조.
|
||||||
|
· 판정하지 않으므로 항상 true 반환(fail-safe). 정책 조회 실패 시 기본값 30분.
|
||||||
|
============================================================ -->
|
||||||
|
<bean id="sessionPolicyInterceptor" class="egovframework.com.cmm.privacy.interceptor.SessionPolicyInterceptor">
|
||||||
|
<property name="applyPathPrefix" value="/mnt/" />
|
||||||
|
<property name="accessControlPolicyService" ref="accessControlPolicyService" />
|
||||||
|
</bean>
|
||||||
|
|
||||||
<bean id="secondFactorInterceptor" class="egovframework.com.cmm.privacy.interceptor.SecondFactorInterceptor">
|
<bean id="secondFactorInterceptor" class="egovframework.com.cmm.privacy.interceptor.SecondFactorInterceptor">
|
||||||
<!-- 2차 인증 화면(#52)·OTP 인증수단(#18)·authMethodResolver 빈(#21) 완료 → enforce 활성화(2026.07.22).
|
<!-- 2차 인증 화면(#52)·OTP 인증수단(#18)·authMethodResolver 빈(#21) 완료 → enforce 활성화(2026.07.22).
|
||||||
안전장치: authMethodResolver 주입 상태에서 '수단 미설정 사용자는 통과'(interceptor #6)가 유지되므로
|
안전장치: authMethodResolver 주입 상태에서 '수단 미설정 사용자는 통과'(interceptor #6)가 유지되므로
|
||||||
@ -135,6 +153,9 @@
|
|||||||
<value>\A/mnt/(main|left|top|right|footer)\.do.*\Z</value>
|
<value>\A/mnt/(main|left|top|right|footer)\.do.*\Z</value>
|
||||||
<value>\A/mnt/(include)/.*\.do.*\Z</value>
|
<value>\A/mnt/(include)/.*\.do.*\Z</value>
|
||||||
<value>\A/uat/uia/.*\Z</value>
|
<value>\A/uat/uia/.*\Z</value>
|
||||||
|
<!-- 세션 상태·연장(SER-001 요건5) — JSON 엔드포인트라 리다이렉트를 받으면 안 된다.
|
||||||
|
2차 인증 미완료 상태에서도 상단 표시가 동작해야 하므로 판정에서 제외한다. -->
|
||||||
|
<value>\A/mnt/session/.*\Z</value>
|
||||||
</list>
|
</list>
|
||||||
</property>
|
</property>
|
||||||
<!-- 취급자별 인증수단 판정·잠금 회피 안전장치(#21). 미주입 시 무조건 리다이렉트가 되어
|
<!-- 취급자별 인증수단 판정·잠금 회피 안전장치(#21). 미주입 시 무조건 리다이렉트가 되어
|
||||||
@ -184,6 +205,9 @@
|
|||||||
<value>\A/mnt/(main|left|top|right|footer)\.do.*\Z</value>
|
<value>\A/mnt/(main|left|top|right|footer)\.do.*\Z</value>
|
||||||
<value>\A/mnt/(include)/.*\.do.*\Z</value>
|
<value>\A/mnt/(include)/.*\.do.*\Z</value>
|
||||||
<value>\A/uat/uia/.*\Z</value>
|
<value>\A/uat/uia/.*\Z</value>
|
||||||
|
<!-- 세션 상태·연장(SER-001 요건5) — 전체관리자 전용이 아니라 로그인한 모든 취급자가
|
||||||
|
쓰는 공통 엔드포인트다. 메뉴에 없는 URL 이라 메뉴권한 판정에 걸리므로 제외한다. -->
|
||||||
|
<value>\A/mnt/session/.*\Z</value>
|
||||||
</list>
|
</list>
|
||||||
</property>
|
</property>
|
||||||
<!-- [개인정보보호 SFR-001 / #2] URL별 역할 차등접근 (2026.07.22 활성화):
|
<!-- [개인정보보호 SFR-001 / #2] URL별 역할 차등접근 (2026.07.22 활성화):
|
||||||
@ -273,6 +297,13 @@
|
|||||||
<value>\A/uss/umt/dpt/.*\Z</value>
|
<value>\A/uss/umt/dpt/.*\Z</value>
|
||||||
<!-- 로그인/2차인증 화면 자신 — 인증 이력은 #22 인증 실패 이력이 담당 -->
|
<!-- 로그인/2차인증 화면 자신 — 인증 이력은 #22 인증 실패 이력이 담당 -->
|
||||||
<value>\A/uat/uia/.*\Z</value>
|
<value>\A/uat/uia/.*\Z</value>
|
||||||
|
<!-- 비동기 다운로드 진행률 폴링 전용 경로.
|
||||||
|
화면이 5초마다 부르는 상태조회다. 빼지 않으면 '파일 생성 중'인 동안
|
||||||
|
감사로그가 분당 12행씩 자기 자신을 기록해(=사용자는 가만히 있는데 행이 증식)
|
||||||
|
정기점검·비정상접근 탐지(SER-002)의 신호가 묻힌다.
|
||||||
|
응답에 개인정보·검색조건은 없고 본인 작업의 상태·진행률뿐이라 기록 가치도 없다.
|
||||||
|
※ 실제 반출 행위인 downloadRequest/downloadFile 은 화이트리스트에 그대로 남아 기록된다. -->
|
||||||
|
<value>\A/mnt/sec/(accessLog|authorHistory)/jobStatus\.do.*\Z</value>
|
||||||
</list>
|
</list>
|
||||||
</property>
|
</property>
|
||||||
<property name="handlerUserSes">
|
<property name="handlerUserSes">
|
||||||
|
|||||||
@ -79,6 +79,8 @@ function fnRun(){
|
|||||||
|
|
||||||
if(checkedIds.length > 0) {
|
if(checkedIds.length > 0) {
|
||||||
if(confirm("선택된 사용자의 잠금을 해제하시겠습니까?")){
|
if(confirm("선택된 사용자의 잠금을 해제하시겠습니까?")){
|
||||||
|
// [개인정보보호 SER-001 요건4] 접근통제 정책이 '해제 사유 필수'면 사유를 받아 이력에 남긴다.
|
||||||
|
if(!fnUnlockResn()) { return; }
|
||||||
document.listForm.user_id.value=checkedIds;
|
document.listForm.user_id.value=checkedIds;
|
||||||
document.listForm.user_gubun.value=checkedGubuns;
|
document.listForm.user_gubun.value=checkedGubuns;
|
||||||
document.listForm.action = "<c:url value='/uss/umt/EgovAccountUnlockUpdate.do'/>";
|
document.listForm.action = "<c:url value='/uss/umt/EgovAccountUnlockUpdate.do'/>";
|
||||||
@ -89,6 +91,34 @@ function fnRun(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*********************************************************
|
||||||
|
* [개인정보보호 SER-001 요건4] 잠금 해제 사유 입력
|
||||||
|
* · 접근통제 정책(시스템관리 > 권한관리 > 접근통제 정책)이
|
||||||
|
* '관리자 화면 해제 + 해제 사유 필수'일 때만 입력을 요구한다.
|
||||||
|
* · 입력값은 ACCOUNT_UNLOCK_HISTORY.UNLOCK_RESN 으로 적재되어 감사 대상이 된다.
|
||||||
|
* @return 진행 가능하면 true, 사용자가 취소하거나 미입력이면 false
|
||||||
|
******************************************************** */
|
||||||
|
function fnUnlockResn() {
|
||||||
|
var required = ${unlockResnRequired eq true ? 'true' : 'false'};
|
||||||
|
if (!required) {
|
||||||
|
document.listForm.unlockResn.value = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
var resn = prompt("잠금 해제 사유를 입력하세요. (해제 이력에 기록됩니다)", "");
|
||||||
|
if (resn === null) { return false; }
|
||||||
|
resn = resn.replace(/^\s+|\s+$/g, "");
|
||||||
|
if (resn.length === 0) {
|
||||||
|
alert("해제 사유는 필수 입력입니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (resn.length > 500) {
|
||||||
|
alert("해제 사유는 500자 이내로 입력하세요.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
document.listForm.unlockResn.value = resn;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/*********************************************************
|
/*********************************************************
|
||||||
* 전체선택
|
* 전체선택
|
||||||
@ -123,6 +153,8 @@ function fnCheckAll() {
|
|||||||
******************************************************** */
|
******************************************************** */
|
||||||
function fnUserAll(){
|
function fnUserAll(){
|
||||||
if(confirm("전 회원(개인,그룹,활동처,기관관리자)의 잠금을 해제하시겠습니까?")){
|
if(confirm("전 회원(개인,그룹,활동처,기관관리자)의 잠금을 해제하시겠습니까?")){
|
||||||
|
// 전체 해제는 영향 범위가 커서 개별 해제와 동일하게 사유 정책을 적용한다.
|
||||||
|
if(!fnUnlockResn()) { return; }
|
||||||
document.listForm.action = "<c:url value='/uss/umt/EgovAccountUnlockUpdateAll.do'/>";
|
document.listForm.action = "<c:url value='/uss/umt/EgovAccountUnlockUpdateAll.do'/>";
|
||||||
document.listForm.submit();
|
document.listForm.submit();
|
||||||
}
|
}
|
||||||
@ -252,6 +284,9 @@ box-sizing: inherit;
|
|||||||
|
|
||||||
<input name="user_id" type="hidden" />
|
<input name="user_id" type="hidden" />
|
||||||
<input name="user_gubun" type="hidden" />
|
<input name="user_gubun" type="hidden" />
|
||||||
|
<%-- [개인정보보호 SER-001 요건4] 잠금 해제 사유. 접근통제 정책이 '해제 사유 필수'일 때
|
||||||
|
fnRun()/fnUserAll() 이 입력받아 채우며, ACCOUNT_UNLOCK_HISTORY 에 적재된다. --%>
|
||||||
|
<input name="unlockResn" type="hidden" />
|
||||||
|
|
||||||
<table width="700" cellpadding="0" class="list1" border="0">
|
<table width="700" cellpadding="0" class="list1" border="0">
|
||||||
<CAPTION style="display: none;">회원정보 목록</CAPTION>
|
<CAPTION style="display: none;">회원정보 목록</CAPTION>
|
||||||
|
|||||||
@ -48,13 +48,13 @@
|
|||||||
|| param.page eq '9100022' || param.page eq '9100031' || param.page eq '9100034' || param.page eq '9100041'
|
|| param.page eq '9100022' || param.page eq '9100031' || param.page eq '9100034' || param.page eq '9100041'
|
||||||
|| param.page eq '9100042'|| param.page eq '9100043'
|
|| param.page eq '9100042'|| param.page eq '9100043'
|
||||||
|| param.page eq '9100071' || param.page eq '9100072' || param.page eq '9100080'
|
|| param.page eq '9100071' || param.page eq '9100072' || param.page eq '9100080'
|
||||||
|| param.page eq '9100091' || param.page eq '9100092' || param.page eq '9100093' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100045' || param.page eq ''}">class="on"</c:if>><a href="#none">시스템관리</a>
|
|| param.page eq '9100091' || param.page eq '9100092' || param.page eq '9100093' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100045' || param.page eq '9100046' || param.page eq '9100047' || param.page eq ''}">class="on"</c:if>><a href="#none">시스템관리</a>
|
||||||
<!-- 2 Depth -->
|
<!-- 2 Depth -->
|
||||||
<ul <c:if test="${param.page eq '9100012'|| param.page eq '9100012' || param.page eq '9100020' || param.page eq '9100021'
|
<ul <c:if test="${param.page eq '9100012'|| param.page eq '9100012' || param.page eq '9100020' || param.page eq '9100021'
|
||||||
|| param.page eq '9100022' || param.page eq '9100031' || param.page eq '9100034'|| param.page eq '9100041'
|
|| param.page eq '9100022' || param.page eq '9100031' || param.page eq '9100034'|| param.page eq '9100041'
|
||||||
|| param.page eq '9100042'|| param.page eq '9100043'
|
|| param.page eq '9100042'|| param.page eq '9100043'
|
||||||
|| param.page eq '9100071' || param.page eq '9100072' || param.page eq '9100080'
|
|| param.page eq '9100071' || param.page eq '9100072' || param.page eq '9100080'
|
||||||
|| param.page eq '9100091' || param.page eq '9100092' || param.page eq '9100093' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100045' || param.page eq ''}">style="display: block;"</c:if>>
|
|| param.page eq '9100091' || param.page eq '9100092' || param.page eq '9100093' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100045' || param.page eq '9100046' || param.page eq '9100047' || param.page eq ''}">style="display: block;"</c:if>>
|
||||||
<c:forEach items="${list_menulist}" var="menuUpperVo2">
|
<c:forEach items="${list_menulist}" var="menuUpperVo2">
|
||||||
<c:if test="${menuUpperVo2.menuNo eq '9100010'}">
|
<c:if test="${menuUpperVo2.menuNo eq '9100010'}">
|
||||||
<li <c:if test="${param.page eq '9100012'}">class="on"</c:if>><a href="#none">${menuUpperVo2.menuNm}</a>
|
<li <c:if test="${param.page eq '9100012'}">class="on"</c:if>><a href="#none">${menuUpperVo2.menuNm}</a>
|
||||||
@ -94,10 +94,10 @@
|
|||||||
</li>
|
</li>
|
||||||
</c:if>
|
</c:if>
|
||||||
<c:if test="${menuUpperVo2.menuNo eq '9100030'}">
|
<c:if test="${menuUpperVo2.menuNo eq '9100030'}">
|
||||||
<li <c:if test="${param.page eq '9100031' || param.page eq '9100034' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100045'}">class="on"</c:if>><a href="#none">${menuUpperVo2.menuNm}</a>
|
<li <c:if test="${param.page eq '9100031' || param.page eq '9100034' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100045' || param.page eq '9100046' || param.page eq '9100047'}">class="on"</c:if>><a href="#none">${menuUpperVo2.menuNm}</a>
|
||||||
<c:forEach items="${list_menulist}" var="menuUpperVo3">
|
<c:forEach items="${list_menulist}" var="menuUpperVo3">
|
||||||
<c:if test="${menuUpperVo3.menuNo eq '9100030'}">
|
<c:if test="${menuUpperVo3.menuNo eq '9100030'}">
|
||||||
<ul <c:if test="${param.page eq '9100031' || param.page eq '9100034' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100045'}">style="display: block;"</c:if>>
|
<ul <c:if test="${param.page eq '9100031' || param.page eq '9100034' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100045' || param.page eq '9100046' || param.page eq '9100047'}">style="display: block;"</c:if>>
|
||||||
<c:forEach items="${list_menulist}" var="menuUpperVo4">
|
<c:forEach items="${list_menulist}" var="menuUpperVo4">
|
||||||
<c:choose>
|
<c:choose>
|
||||||
<c:when test="${menuUpperVo4.menuNo eq '9100031'}">
|
<c:when test="${menuUpperVo4.menuNo eq '9100031'}">
|
||||||
@ -130,6 +130,14 @@
|
|||||||
<c:when test="${menuUpperVo4.menuNo eq '9100045'}">
|
<c:when test="${menuUpperVo4.menuNo eq '9100045'}">
|
||||||
<li <c:if test="${param.page eq '9100045'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
|
<li <c:if test="${param.page eq '9100045'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
|
||||||
</c:when>
|
</c:when>
|
||||||
|
<%-- [개인정보/#62] 로그인·로그아웃 접속기록 조회·다운로드 --%>
|
||||||
|
<c:when test="${menuUpperVo4.menuNo eq '9100046'}">
|
||||||
|
<li <c:if test="${param.page eq '9100046'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
|
||||||
|
</c:when>
|
||||||
|
<%-- [개인정보/SER-001 요건4·5] 접근통제 정책 설정(계정잠금·자동로그아웃·세션재발급) --%>
|
||||||
|
<c:when test="${menuUpperVo4.menuNo eq '9100047'}">
|
||||||
|
<li <c:if test="${param.page eq '9100047'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
|
||||||
|
</c:when>
|
||||||
</c:choose>
|
</c:choose>
|
||||||
</c:forEach>
|
</c:forEach>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@ -42,6 +42,135 @@ function fn_download() {
|
|||||||
document.frm.dlReason.value = "";
|
document.frm.dlReason.value = "";
|
||||||
document.frm.action = "<c:url value='${PATH}/sec/accessLog/listView.do' />";
|
document.frm.action = "<c:url value='${PATH}/sec/accessLog/listView.do' />";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 다운로드 '요청'(비동기) : 사유를 받아 작업만 등록하고 목록으로 돌아온다.
|
||||||
|
파일 생성은 서버 백그라운드가 맡으므로 화면은 기다리지 않는다. */
|
||||||
|
function fn_downloadRequest() {
|
||||||
|
var reason = prompt("다운로드 사유를 입력하세요. (필수 — 다운로드 이력에 기록됩니다)", "");
|
||||||
|
if (reason == null) {
|
||||||
|
return; /* 취소 */
|
||||||
|
}
|
||||||
|
if (reason.replace(/^\s+|\s+$/g, "") == "") {
|
||||||
|
alert("다운로드 사유는 필수입니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.frm.dlReason.value = reason;
|
||||||
|
document.frm.method = "get";
|
||||||
|
document.frm.action = "<c:url value='${PATH}/sec/accessLog/downloadRequest.do' />";
|
||||||
|
document.frm.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 완료된 파일 내려받기. 요청자 본인만 가능하며 서버에서 다시 확인한다. */
|
||||||
|
function fn_downloadFile(jobId) {
|
||||||
|
location.href = "<c:url value='${PATH}/sec/accessLog/downloadFile.do' />?jobId=" + jobId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 다운로드 진행률 폴링 ─────────────────────────────────────────────
|
||||||
|
종전에는 5초마다 이 목록화면을 통째로 다시 열었다. 그런데 이 화면은 접속기록 수집
|
||||||
|
대상(/mnt/sec/**)이라 새로고침 한 번이 곧 접속기록 1행이었다 — 사용자는 가만히 있는데
|
||||||
|
분당 12행씩 감사로그가 자기 자신을 기록했고, 정기점검·이상탐지 신호를 오염시켰다.
|
||||||
|
지금은 수집에서 제외된 jobStatus.do 만 호출해 상태 칸만 제자리에서 고쳐 쓴다.
|
||||||
|
화면 전체 재요청이 없으므로 폴링 중 접속기록은 단 1행도 늘지 않는다. */
|
||||||
|
var DL_POLL_MS = 5000;
|
||||||
|
|
||||||
|
function fn_pollIfRunning() {
|
||||||
|
if (fn_dlAnyRunning()) {
|
||||||
|
setTimeout(fn_dlPollOnce, DL_POLL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fn_dlAnyRunning() {
|
||||||
|
var el = document.getElementById("dlJobRunning");
|
||||||
|
return !!(el && el.value == "Y");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fn_dlPollOnce() {
|
||||||
|
$.ajax({
|
||||||
|
url: "<c:url value='${PATH}/sec/accessLog/jobStatus.do' />",
|
||||||
|
cache: false,
|
||||||
|
dataType: "text"
|
||||||
|
}).done(function(txt) {
|
||||||
|
if (fn_dlApply(txt)) {
|
||||||
|
setTimeout(fn_dlPollOnce, DL_POLL_MS); /* 아직 진행중 — 계속 본다 */
|
||||||
|
}
|
||||||
|
/* 전부 끝났으면 폴링을 멈춘다. 목록은 이미 제자리 갱신돼 있어 새로고침이 필요 없다. */
|
||||||
|
}).fail(function() {
|
||||||
|
/* 세션만료·일시적 오류 — 조용히 간격을 늘려 재시도한다(화면을 막지 않는다). */
|
||||||
|
setTimeout(fn_dlPollOnce, DL_POLL_MS * 3);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 응답(한 줄 1작업: jobId|status|pct|done|total|errMsg)을 표에 반영.
|
||||||
|
@return 아직 대기/생성중인 작업이 남았으면 true */
|
||||||
|
function fn_dlApply(txt) {
|
||||||
|
var lines = (txt || "").split("\n");
|
||||||
|
var anyRunning = false;
|
||||||
|
for (var i = 0; i < lines.length; i++) {
|
||||||
|
var line = lines[i];
|
||||||
|
if (!line) { continue; }
|
||||||
|
var f = line.split("|");
|
||||||
|
if (f.length < 5) { continue; }
|
||||||
|
|
||||||
|
var jobId = f[0], st = f[1], pct = f[2], done = f[3], total = f[4];
|
||||||
|
var err = (f.length > 5) ? f[5] : "";
|
||||||
|
var row = document.getElementById("dlrow-" + jobId);
|
||||||
|
if (!row) { continue; } /* 폴링 중 새로 생긴 작업은 다음 화면 진입에서 보인다 */
|
||||||
|
|
||||||
|
if (st == "W" || st == "R") { anyRunning = true; }
|
||||||
|
fn_dlCell(row, "dl_st", fn_dlStatusHtml(st, err));
|
||||||
|
fn_dlCell(row, "dl_pg", fn_dlProgressHtml(st, pct, done, total));
|
||||||
|
fn_dlCell(row, "dl_dl", fn_dlLinkHtml(st, jobId));
|
||||||
|
}
|
||||||
|
var flag = document.getElementById("dlJobRunning");
|
||||||
|
if (flag) { flag.value = anyRunning ? "Y" : "N"; }
|
||||||
|
return anyRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fn_dlCell(row, cls, html) {
|
||||||
|
var cells = row.getElementsByTagName("td");
|
||||||
|
for (var i = 0; i < cells.length; i++) {
|
||||||
|
if (cells[i].className.indexOf(cls) >= 0) {
|
||||||
|
cells[i].innerHTML = html;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fn_dlStatusHtml(st, err) {
|
||||||
|
if (st == "W") { return '<span class="dml">대기</span>'; }
|
||||||
|
if (st == "R") { return '<span class="dml">생성중</span>'; }
|
||||||
|
if (st == "S") { return '<span class="badge s">완료</span>'; }
|
||||||
|
if (st == "F") { return '<span class="badge f" title="' + fn_dlEsc(err) + '">실패</span>'; }
|
||||||
|
return '<span class="nodata">파기됨</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fn_dlProgressHtml(st, pct, done, total) {
|
||||||
|
if (st == "S") { return fn_dlEsc(done) + '건'; }
|
||||||
|
if (st == "R") { return fn_dlEsc(pct) + '% (' + fn_dlEsc(done) + '/' + fn_dlEsc(total) + ')'; }
|
||||||
|
return '<span class="nodata">-</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fn_dlLinkHtml(st, jobId) {
|
||||||
|
if (st == "S") {
|
||||||
|
return '<a href="#none" onclick="fn_downloadFile(\'' + fn_dlEsc(jobId) + '\'); return false;">내려받기</a>';
|
||||||
|
}
|
||||||
|
return '<span class="nodata">-</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 서버 문자열을 그대로 innerHTML 에 넣지 않는다(실패사유에 태그가 섞일 수 있다). */
|
||||||
|
function fn_dlEsc(s) {
|
||||||
|
return String(s == null ? "" : s)
|
||||||
|
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """).replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 처리행위 요약 클릭 : 해당 처리구분으로 좁혀 재검색한다.
|
||||||
|
같은 구분을 다시 누르면 해제(전체)해서 토글로 쓰게 한다. */
|
||||||
|
function fn_dml(gubun) {
|
||||||
|
var sel = document.getElementById("dmlGubun");
|
||||||
|
sel.value = (sel.value == gubun) ? "" : gubun;
|
||||||
|
fn_list('1');
|
||||||
|
}
|
||||||
//-->
|
//-->
|
||||||
</script>
|
</script>
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
@ -54,11 +183,28 @@ function fn_download() {
|
|||||||
.hash { font-family:Consolas,monospace; font-size:11px; color:#888; }
|
.hash { font-family:Consolas,monospace; font-size:11px; color:#888; }
|
||||||
.guide { margin:6px 0 10px; padding:8px 10px; background:#f5f7fa; border:1px solid #dde3ea; font-size:12px; color:#555; }
|
.guide { margin:6px 0 10px; padding:8px 10px; background:#f5f7fa; border:1px solid #dde3ea; font-size:12px; color:#555; }
|
||||||
.btn_area { margin:8px 0; text-align:right; }
|
.btn_area { margin:8px 0; text-align:right; }
|
||||||
|
.dml_summary { margin:0 10px; font-size:12px; color:#445; }
|
||||||
|
.dml_summary a { color:#445; text-decoration:none; }
|
||||||
|
.dml_summary a:hover { text-decoration:underline; }
|
||||||
|
.dml_summary strong.d { color:#c25b5b; }
|
||||||
|
.nodata { color:#aaa; }
|
||||||
|
.dl_job { margin:10px 0 16px; }
|
||||||
|
.dl_job strong { font-size:13px; color:#445; margin-right:8px; }
|
||||||
|
.dl_job .cond { font-size:12px; color:#666; text-align:left; word-break:break-all; }
|
||||||
|
/* 검색창 행 고정 — 공용 .w4(width:25%)로는 입력칸 4개가 컨테이너 폭을 다 먹어 줄이 접힌다.
|
||||||
|
이 화면에서만 폭을 px 로 고정하고 nowrap 으로 행을 묶는다(공용 CSS 는 건드리지 않는다). */
|
||||||
|
#search_wrap .srow { white-space:nowrap; }
|
||||||
|
#search_wrap .srow + .srow { margin-top:4px; }
|
||||||
|
#search_wrap .srow input.tbox { width:110px; }
|
||||||
|
#search_wrap .srow input.tbox.dt { width:140px; }
|
||||||
|
#search_wrap .srow select { max-width:170px; }
|
||||||
|
#search_wrap .srow span { padding-left:12px; }
|
||||||
|
#search_wrap .srow > span:first-child { padding-left:0; }
|
||||||
.btn_dl { padding:5px 14px; border:1px solid #4a7ebb; background:#4a7ebb; color:#fff; cursor:pointer; font-size:12px; }
|
.btn_dl { padding:5px 14px; border:1px solid #4a7ebb; background:#4a7ebb; color:#fff; cursor:pointer; font-size:12px; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body onload="fn_pollIfRunning();">
|
||||||
<!-- 타이틀 -->
|
<!-- 타이틀 -->
|
||||||
<c:import url = "/mnt/include/navigation.do" />
|
<c:import url = "/mnt/include/navigation.do" />
|
||||||
<!-- //타이틀 -->
|
<!-- //타이틀 -->
|
||||||
@ -71,6 +217,10 @@ function fn_download() {
|
|||||||
<!-- 검색창 -->
|
<!-- 검색창 -->
|
||||||
<div id="search_wrap">
|
<div id="search_wrap">
|
||||||
<fieldset class="date_search">
|
<fieldset class="date_search">
|
||||||
|
<%-- 검색조건은 2행 고정이다. 행 구분을 <br /> 이 아니라 .srow 로 감싸는 이유는
|
||||||
|
공용 .w4(width:25%) 때문에 입력칸 4개만으로 컨테이너 폭을 다 먹어
|
||||||
|
의도와 다르게 줄이 접혔기 때문이다. 이 화면 전용 폭 지정은 <style> 참조. --%>
|
||||||
|
<div class="srow">
|
||||||
<span>접속자ID :</span>
|
<span>접속자ID :</span>
|
||||||
<form:input path="conectId" title="접속자ID" cssClass="tbox w4" />
|
<form:input path="conectId" title="접속자ID" cssClass="tbox w4" />
|
||||||
<span>수행업무 :</span>
|
<span>수행업무 :</span>
|
||||||
@ -84,7 +234,8 @@ function fn_download() {
|
|||||||
<form:input path="trgetId" title="처리대상 식별자" cssClass="tbox w4" />
|
<form:input path="trgetId" title="처리대상 식별자" cssClass="tbox w4" />
|
||||||
<span>접속IP :</span>
|
<span>접속IP :</span>
|
||||||
<form:input path="conectIp" title="접속IP" cssClass="tbox w4" />
|
<form:input path="conectIp" title="접속IP" cssClass="tbox w4" />
|
||||||
<br />
|
</div>
|
||||||
|
<div class="srow">
|
||||||
<span>처리구분 :</span>
|
<span>처리구분 :</span>
|
||||||
<select name="dmlGubun" id="dmlGubun" title="처리구분">
|
<select name="dmlGubun" id="dmlGubun" title="처리구분">
|
||||||
<option value="" <c:if test="${empty personalDataAccessLogSearchVO.dmlGubun}">selected="selected"</c:if>>전체</option>
|
<option value="" <c:if test="${empty personalDataAccessLogSearchVO.dmlGubun}">selected="selected"</c:if>>전체</option>
|
||||||
@ -100,27 +251,105 @@ function fn_download() {
|
|||||||
<option value="F" <c:if test="${personalDataAccessLogSearchVO.processResult == 'F'}">selected="selected"</c:if>>실패/차단</option>
|
<option value="F" <c:if test="${personalDataAccessLogSearchVO.processResult == 'F'}">selected="selected"</c:if>>실패/차단</option>
|
||||||
</select>
|
</select>
|
||||||
<span>기간 :</span>
|
<span>기간 :</span>
|
||||||
<form:input path="fromDt" title="조회 시작일시" cssClass="tbox w4" maxlength="19" />
|
<form:input path="fromDt" title="조회 시작일시" cssClass="tbox dt" maxlength="19" />
|
||||||
~
|
~
|
||||||
<form:input path="toDt" title="조회 종료일시" cssClass="tbox w4" maxlength="19" />
|
<form:input path="toDt" title="조회 종료일시" cssClass="tbox dt" maxlength="19" />
|
||||||
<input type="image" src="<c:url value='/images/site/mnt/btn_search.gif'/>" alt="검색" />
|
<input type="image" src="<c:url value='/images/site/mnt/btn_search.gif'/>" alt="검색" />
|
||||||
<a href="<c:url value='${PATH}/sec/accessLog/listView.do' />"><img src="<c:url value='/images/site/mnt/btn_all_list.gif'/>" alt="전체목록" /></a>
|
<a href="<c:url value='${PATH}/sec/accessLog/listView.do' />"><img src="<c:url value='/images/site/mnt/btn_all_list.gif'/>" alt="전체목록" /></a>
|
||||||
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
<!-- //검색창 -->
|
<!-- //검색창 -->
|
||||||
|
|
||||||
<div class="guide">
|
<div class="guide">
|
||||||
· 개인정보를 처리(조회·등록·수정·삭제)한 기록입니다. 기간은 <strong>yyyy-MM-dd</strong> 또는 <strong>yyyy-MM-dd HH:mm:ss</strong> 형식으로 입력하세요.<br />
|
· 개인정보를 처리(조회·등록·수정·삭제)한 기록입니다. 기간은 <strong>yyyy-MM-dd</strong> 또는 <strong>yyyy-MM-dd HH:mm:ss</strong> 형식으로 입력하세요.
|
||||||
|
날짜만 입력하면 <strong>종료일 당일(23:59:59)까지 포함</strong>해 검색합니다.<br />
|
||||||
|
· <strong>접속자ID·처리대상ID·접속IP는 앞부분만 입력해도 검색</strong>됩니다(예: IP에 <code>192.168.</code> 입력 시 해당 대역 전체).<br />
|
||||||
· 보관기간은 <strong>730일</strong>이며, 파기는 개별 삭제가 아니라 <strong>월 단위 파티션 삭제</strong>로만 수행됩니다(파기 내역은 별도 이력으로 보존).<br />
|
· 보관기간은 <strong>730일</strong>이며, 파기는 개별 삭제가 아니라 <strong>월 단위 파티션 삭제</strong>로만 수행됩니다(파기 내역은 별도 이력으로 보존).<br />
|
||||||
· 각 기록은 <strong>SHA-256 해시체인</strong>으로 연결되어 있어 중간 변조 시 무결성 검증 배치가 탐지합니다. 이 화면에서는 수정·삭제를 제공하지 않습니다.<br />
|
· 각 기록은 <strong>SHA-256 해시체인</strong>으로 연결되어 있어 중간 변조 시 무결성 검증 배치가 탐지합니다. 이 화면에서는 수정·삭제를 제공하지 않습니다.<br />
|
||||||
· <strong>다운로드는 사유 입력이 필수</strong>이며, 다운로드자·일시·IP·사유·건수가 다운로드 이력에 기록됩니다.
|
· <strong>다운로드는 사유 입력이 필수</strong>이며, 다운로드자·일시·IP·사유·건수가 다운로드 이력에 기록됩니다.<br />
|
||||||
|
· 다운로드는 <strong>요청 방식</strong>입니다 — 요청만 걸어두면 서버가 백그라운드로 파일을 만들고, 완료되면 아래 목록에서 내려받습니다(대용량이어도 화면이 멈추지 않습니다).<br />
|
||||||
|
· 생성된 파일은 <strong>암호화 보관</strong>되며 <strong>만료 시 자동 파기</strong>됩니다. <strong>요청자 본인만</strong> 내려받을 수 있습니다.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="btn_area">
|
<div class="btn_area">
|
||||||
총 <strong><c:out value="${personalDataAccessLogSearchVO.totalRecordCount}" /></strong>건
|
총 <strong><c:out value="${personalDataAccessLogSearchVO.totalRecordCount}" /></strong>건
|
||||||
<input type="button" class="btn_dl" value="CSV 다운로드(사유 필수)" onclick="fn_download();" />
|
<%-- 처리행위 요약 — 현재 검색조건 기준. 목록을 넘겨보지 않고
|
||||||
|
"조회만 했는지 / 수정·삭제까지 했는지"를 바로 판별하기 위한 값이다.
|
||||||
|
각 항목을 누르면 해당 처리구분으로 다시 검색한다. --%>
|
||||||
|
<span class="dml_summary">
|
||||||
|
[<a href="#none" onclick="fn_dml('S'); return false;" title="조회 기록만 보기">조회 <strong><c:out value="${dmlSummary.S}" /></strong></a>
|
||||||
|
· <a href="#none" onclick="fn_dml('I'); return false;" title="등록 기록만 보기">등록 <strong><c:out value="${dmlSummary.I}" /></strong></a>
|
||||||
|
· <a href="#none" onclick="fn_dml('U'); return false;" title="수정 기록만 보기">수정 <strong><c:out value="${dmlSummary.U}" /></strong></a>
|
||||||
|
· <a href="#none" onclick="fn_dml('D'); return false;" title="삭제 기록만 보기">삭제 <strong class="d"><c:out value="${dmlSummary.D}" /></strong></a>]
|
||||||
|
</span>
|
||||||
|
<input type="button" class="btn_dl" value="CSV 다운로드 요청(사유 필수)" onclick="fn_downloadRequest();" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<%-- 비동기 다운로드 작업 목록 — 요청자 본인 것만 보인다.
|
||||||
|
대용량은 요청만 걸어두고 다른 일을 하다가 완료 후 내려받는다. --%>
|
||||||
|
<c:if test="${not empty dlMessage}">
|
||||||
|
<div class="guide" style="border-color:#c25b5b; color:#c25b5b;"><c:out value="${dlMessage}" /></div>
|
||||||
|
</c:if>
|
||||||
|
<c:if test="${not empty dlJobList}">
|
||||||
|
<c:set var="anyRunning" value="N" />
|
||||||
|
<c:forEach items="${dlJobList}" var="j">
|
||||||
|
<c:if test="${j.status eq 'W' or j.status eq 'R'}"><c:set var="anyRunning" value="Y" /></c:if>
|
||||||
|
</c:forEach>
|
||||||
|
<input type="hidden" id="dlJobRunning" value="${anyRunning}" />
|
||||||
|
|
||||||
|
<div class="dl_job">
|
||||||
|
<strong>내 다운로드 요청</strong>
|
||||||
|
<span class="hash">파일은 암호화 보관되며 만료 시 자동 파기됩니다. 요청자 본인만 내려받을 수 있습니다.</span>
|
||||||
|
<table summary="내 다운로드 요청 목록" class="list1">
|
||||||
|
<caption>내 다운로드 요청 목록</caption>
|
||||||
|
<colgroup>
|
||||||
|
<col width="14%" /><col width="*" /><col width="12%" /><col width="14%" /><col width="14%" /><col width="12%" />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>요청일시</th><th>검색조건</th><th>상태</th><th>진행</th><th>만료</th><th>내려받기</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<%-- tr 의 id 와 td 의 dl_st/dl_pg/dl_dl 클래스는 폴링(fn_dlApply)이 제자리 갱신할
|
||||||
|
지점이다. 이름을 바꾸면 진행률이 멈춘 것처럼 보이니 함께 고칠 것. --%>
|
||||||
|
<c:forEach items="${dlJobList}" var="j">
|
||||||
|
<tr id="dlrow-${j.jobId}">
|
||||||
|
<td><c:out value="${j.regDt}" /></td>
|
||||||
|
<td class="cond"><c:out value="${j.searchCond}" /></td>
|
||||||
|
<td class="dl_st">
|
||||||
|
<c:choose>
|
||||||
|
<c:when test="${j.status eq 'W'}"><span class="dml">대기</span></c:when>
|
||||||
|
<c:when test="${j.status eq 'R'}"><span class="dml">생성중</span></c:when>
|
||||||
|
<c:when test="${j.status eq 'S'}"><span class="badge s">완료</span></c:when>
|
||||||
|
<c:when test="${j.status eq 'F'}"><span class="badge f" title="<c:out value='${j.errMsg}'/>">실패</span></c:when>
|
||||||
|
<c:otherwise><span class="nodata">파기됨</span></c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
</td>
|
||||||
|
<td class="dl_pg">
|
||||||
|
<c:choose>
|
||||||
|
<c:when test="${j.status eq 'S'}"><c:out value="${j.doneCnt}" />건</c:when>
|
||||||
|
<c:when test="${j.status eq 'R'}"><c:out value="${j.progressPct}" />% (<c:out value="${j.doneCnt}" />/<c:out value="${j.totalCnt}" />)</c:when>
|
||||||
|
<c:otherwise><span class="nodata">-</span></c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
</td>
|
||||||
|
<td><c:out value="${j.expireDt}" /></td>
|
||||||
|
<td class="dl_dl">
|
||||||
|
<c:choose>
|
||||||
|
<c:when test="${j.status eq 'S'}">
|
||||||
|
<a href="#none" onclick="fn_downloadFile('<c:out value="${j.jobId}"/>'); return false;">내려받기</a>
|
||||||
|
</c:when>
|
||||||
|
<c:otherwise><span class="nodata">-</span></c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</c:forEach>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</c:if>
|
||||||
|
|
||||||
<table summary="개인정보 접속기록 목록" class="list1">
|
<table summary="개인정보 접속기록 목록" class="list1">
|
||||||
<caption>개인정보 접속기록 목록</caption>
|
<caption>개인정보 접속기록 목록</caption>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
@ -167,9 +396,17 @@ function fn_download() {
|
|||||||
<c:otherwise><c:out value="${result.dmlGubun}" /></c:otherwise>
|
<c:otherwise><c:out value="${result.dmlGubun}" /></c:otherwise>
|
||||||
</c:choose>
|
</c:choose>
|
||||||
</td>
|
</td>
|
||||||
|
<%-- 처리대상 : 식별자(누구의 개인정보인가) + 범위(몇 건인가).
|
||||||
|
둘 다 없으면 목록 진입 등 특정 대상이 없는 요청이므로 '대상없음'으로 명시한다
|
||||||
|
— 빈칸으로 두면 '수집 실패'와 '대상 없음'이 구분되지 않는다. --%>
|
||||||
<td>
|
<td>
|
||||||
<c:out value="${result.trgetId}" />
|
<c:choose>
|
||||||
<c:if test="${not empty result.trgetCn}"><br /><span class="hash"><c:out value="${result.trgetCn}" /></span></c:if>
|
<c:when test="${not empty result.trgetId or not empty result.trgetCn}">
|
||||||
|
<c:if test="${not empty result.trgetId}"><c:out value="${result.trgetId}" /></c:if>
|
||||||
|
<c:if test="${not empty result.trgetCn}"><c:if test="${not empty result.trgetId}"><br /></c:if><span class="hash"><c:out value="${result.trgetCn}" /></span></c:if>
|
||||||
|
</c:when>
|
||||||
|
<c:otherwise><span class="nodata">대상없음</span></c:otherwise>
|
||||||
|
</c:choose>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<c:choose>
|
<c:choose>
|
||||||
|
|||||||
226
src/main/webapp/WEB-INF/jsp/site/mnt/sec/accessPolicy/policy.jsp
Normal file
226
src/main/webapp/WEB-INF/jsp/site/mnt/sec/accessPolicy/policy.jsp
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
<%@ page language="java" contentType="text/html; charset=utf-8" pageEncoding="utf-8"%>
|
||||||
|
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%><%-- Head --%>
|
||||||
|
<c:import url = "/mnt/include/head.do" /><%--// Head --%>
|
||||||
|
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"%>
|
||||||
|
<%@ taglib prefix="ui" uri="http://egovframework.gov/ctl/ui"%>
|
||||||
|
<%@ taglib prefix="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"%>
|
||||||
|
|
||||||
|
<%--
|
||||||
|
접근통제 정책 설정 (SER-001 요건 4·5)
|
||||||
|
「개인정보보호강화 파일럿 개발항목」 접근통제 정책 적용(Action2) 시안 반영.
|
||||||
|
|
||||||
|
· 계정 잠금 정책 : 인증 실패 잠금 횟수(현행 10 -> 5), 잠금 해제 방식(현행 DB 직접 해제)
|
||||||
|
· 자동 로그아웃 : 미사용 자동 로그아웃 분(현행 web.xml 60 -> 30), 만료 임박 안내
|
||||||
|
· 세션 재발급 : 2차 인증 성공 시 세션 재발급(현행 재발급 없음)
|
||||||
|
|
||||||
|
저장 시 web.xml 수정·재배포 없이 즉시 반영된다(SessionPolicyInterceptor / LOGIN_LOCK_COUNT 동기화).
|
||||||
|
--%>
|
||||||
|
<c:set var="PATH" value="/mnt" />
|
||||||
|
<script type="text/javascript" src="<c:url value='/js/egovframework/com/cmm/jquery.js'/>"></script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
<!--
|
||||||
|
function fn_num(v) {
|
||||||
|
v = $.trim(v);
|
||||||
|
if (v.length === 0 || !/^[0-9]+$/.test(v)) { return -1; }
|
||||||
|
return parseInt(v, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 만료 임박 안내를 끄면 '몇 분 전' 입력은 의미가 없으므로 비활성화한다.
|
||||||
|
function fn_toggleNotice() {
|
||||||
|
var use = document.frm.expireNoticeYn.value === "Y";
|
||||||
|
document.frm.expireNoticeMin.disabled = !use;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fn_save() {
|
||||||
|
var frm = document.frm;
|
||||||
|
|
||||||
|
var lockThreshold = fn_num(frm.lockThreshold.value);
|
||||||
|
if (lockThreshold < 1 || lockThreshold > 10) {
|
||||||
|
alert("인증 실패 계정 잠금 횟수는 1~10회 범위의 숫자로 입력하세요.\n(안전성 확보조치 기준 권고 5회)");
|
||||||
|
frm.lockThreshold.focus(); return;
|
||||||
|
}
|
||||||
|
if (lockThreshold > 5) {
|
||||||
|
if (!confirm("인증 실패 계정 잠금 횟수를 " + lockThreshold + "회로 설정합니다.\n\n"
|
||||||
|
+ "개인정보의 안전성 확보조치 기준 제6조 대응값은 5회입니다.\n"
|
||||||
|
+ "그보다 크게 설정하면 무차별 대입 시도를 그만큼 늦게 차단합니다.\n\n계속하시겠습니까?")) {
|
||||||
|
frm.lockThreshold.focus(); return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var autoLogoutMin = fn_num(frm.autoLogoutMin.value);
|
||||||
|
if (autoLogoutMin < 5 || autoLogoutMin > 120) {
|
||||||
|
alert("미사용 자동 로그아웃 시간은 5~120분 범위의 숫자로 입력하세요.\n(안전성 확보조치 기준 권고 30분)");
|
||||||
|
frm.autoLogoutMin.focus(); return;
|
||||||
|
}
|
||||||
|
if (autoLogoutMin > 30) {
|
||||||
|
if (!confirm("미사용 자동 로그아웃 시간을 " + autoLogoutMin + "분으로 설정합니다.\n\n"
|
||||||
|
+ "개인정보의 안전성 확보조치 기준 제6조 대응값은 30분입니다.\n"
|
||||||
|
+ "그보다 길게 설정하면 자리를 비운 취급자 화면이 그만큼 오래 열려 있습니다.\n\n계속하시겠습니까?")) {
|
||||||
|
frm.autoLogoutMin.focus(); return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frm.expireNoticeYn.value === "Y") {
|
||||||
|
var noticeMin = fn_num(frm.expireNoticeMin.value);
|
||||||
|
if (noticeMin < 1 || noticeMin >= autoLogoutMin) {
|
||||||
|
alert("만료 임박 안내 시점은 1분 이상이며 자동 로그아웃 시간보다 작아야 합니다.");
|
||||||
|
frm.expireNoticeMin.focus(); return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frm.sessionRegenYn.value === "N") {
|
||||||
|
if (!confirm("세션 재발급을 '미사용'으로 설정합니다.\n\n"
|
||||||
|
+ "2차 인증 성공 시 세션 ID가 그대로 유지되어 세션 고정(Session Fixation)\n"
|
||||||
|
+ "공격에 노출될 수 있습니다.\n\n계속하시겠습니까?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm("접근통제 정책을 저장하시겠습니까?\n"
|
||||||
|
+ "저장 즉시 인증·세션 경로에 반영되며, 재배포가 필요하지 않습니다.")) { return; }
|
||||||
|
|
||||||
|
// disabled 입력은 serialize 대상에서 빠지므로 전송 직전 해제한다.
|
||||||
|
frm.expireNoticeMin.disabled = false;
|
||||||
|
|
||||||
|
$.post("<c:url value='${PATH}/privacy/accessPolicyUpdate.do' />", $(frm).serialize(), function(data) {
|
||||||
|
if (data && data.result === "success") {
|
||||||
|
alert("저장되었습니다.\n자동 로그아웃 시간은 현재 접속 중인 세션부터 바로 적용됩니다.");
|
||||||
|
location.href = "<c:url value='${PATH}/sec/accessPolicy/policyView.do' />?page=9100047";
|
||||||
|
} else {
|
||||||
|
fn_toggleNotice();
|
||||||
|
alert("저장에 실패하였습니다.\n" + (data && data.message ? data.message : ""));
|
||||||
|
}
|
||||||
|
}, "json").fail(function() {
|
||||||
|
fn_toggleNotice();
|
||||||
|
alert("저장 처리 중 오류가 발생하였습니다.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fn_reload() {
|
||||||
|
location.href = "<c:url value='${PATH}/sec/accessPolicy/policyView.do' />?page=9100047";
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).ready(function() {
|
||||||
|
fn_toggleNotice();
|
||||||
|
});
|
||||||
|
//-->
|
||||||
|
</script>
|
||||||
|
<style type="text/css">
|
||||||
|
.unit { margin-left:4px; color:#666; }
|
||||||
|
.sec_head { margin:16px 0 6px 0; padding-left:8px; border-left:3px solid #1f5f9c; font-weight:bold; color:#222; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<!-- 타이틀 -->
|
||||||
|
<c:import url = "/mnt/include/navigation.do" />
|
||||||
|
<!-- //타이틀 -->
|
||||||
|
<!-- 컨텐츠 영역 -->
|
||||||
|
|
||||||
|
<form:form commandName="policy" id="frm" name="frm" method="post" onsubmit="fn_save(); return false;">
|
||||||
|
<form:hidden path="policyId" />
|
||||||
|
|
||||||
|
<div class="sec_head">계정 잠금 정책</div>
|
||||||
|
<table summary="계정 잠금 정책" class="write">
|
||||||
|
<caption>계정 잠금 정책</caption>
|
||||||
|
<colgroup>
|
||||||
|
<col width="25%" />
|
||||||
|
<col width="*" />
|
||||||
|
</colgroup>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th><label for="lockThreshold">인증 실패 계정 잠금</label> *</th>
|
||||||
|
<td>
|
||||||
|
<form:input path="lockThreshold" title="인증 실패 계정 잠금 횟수" class="tbox" maxlength="2" cssStyle="width:60px; text-align:right;" />
|
||||||
|
<span class="unit">회</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th><label for="unlockMode">잠금 해제 방식</label> *</th>
|
||||||
|
<td>
|
||||||
|
<select name="unlockMode" id="unlockMode" title="잠금 해제 방식">
|
||||||
|
<option value="ADMIN_HIST" <c:if test="${policy.unlockMode ne 'ADMIN_HIST_RESN'}">selected="selected"</c:if>>관리자 화면 해제 + 해제 이력 적재</option>
|
||||||
|
<option value="ADMIN_HIST_RESN" <c:if test="${policy.unlockMode eq 'ADMIN_HIST_RESN'}">selected="selected"</c:if>>관리자 화면 해제 + 해제 사유 필수 + 이력 적재</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="sec_head">자동 로그아웃 (세션 타임아웃)</div>
|
||||||
|
<table summary="자동 로그아웃 정책" class="write">
|
||||||
|
<caption>자동 로그아웃 정책</caption>
|
||||||
|
<colgroup>
|
||||||
|
<col width="25%" />
|
||||||
|
<col width="*" />
|
||||||
|
</colgroup>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th><label for="autoLogoutMin">미사용 자동 로그아웃</label> *</th>
|
||||||
|
<td>
|
||||||
|
<form:input path="autoLogoutMin" title="미사용 자동 로그아웃 시간(분)" class="tbox" maxlength="3" cssStyle="width:60px; text-align:right;" />
|
||||||
|
<span class="unit">분</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th><label for="expireNoticeYn">만료 임박 안내</label> *</th>
|
||||||
|
<td>
|
||||||
|
<select name="expireNoticeYn" id="expireNoticeYn" title="만료 임박 안내 사용여부" onchange="fn_toggleNotice();">
|
||||||
|
<option value="Y" <c:if test="${policy.expireNoticeYn ne 'N'}">selected="selected"</c:if>>사용</option>
|
||||||
|
<option value="N" <c:if test="${policy.expireNoticeYn eq 'N'}">selected="selected"</c:if>>미사용</option>
|
||||||
|
</select>
|
||||||
|
만료
|
||||||
|
<form:input path="expireNoticeMin" title="만료 임박 안내 시점(분 전)" class="tbox" maxlength="3" cssStyle="width:50px; text-align:right;" />
|
||||||
|
<span class="unit">분 전 안내</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="sec_head">세션 재발급</div>
|
||||||
|
<table summary="세션 재발급 정책" class="write">
|
||||||
|
<caption>세션 재발급 정책</caption>
|
||||||
|
<colgroup>
|
||||||
|
<col width="25%" />
|
||||||
|
<col width="*" />
|
||||||
|
</colgroup>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th><label for="sessionRegenYn">세션 재발급 시점</label> *</th>
|
||||||
|
<td>
|
||||||
|
<select name="sessionRegenYn" id="sessionRegenYn" title="세션 재발급 사용여부">
|
||||||
|
<option value="Y" <c:if test="${policy.sessionRegenYn ne 'N'}">selected="selected"</c:if>>사용 — 2차 인증 성공 시 세션 재발급</option>
|
||||||
|
<option value="N" <c:if test="${policy.sessionRegenYn eq 'N'}">selected="selected"</c:if>>미사용</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>최종 수정</th>
|
||||||
|
<td>
|
||||||
|
<c:choose>
|
||||||
|
<c:when test="${empty policy.updDt}">-</c:when>
|
||||||
|
<c:otherwise><c:out value="${policy.updDt}" /> / <c:out value="${policy.updId}" /></c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- 버튼 -->
|
||||||
|
<div class="btn_zone">
|
||||||
|
<a href="javascript:fn_save();" class="btn2">설정 저장 · 즉시 적용</a>
|
||||||
|
<a href="javascript:fn_reload();" class="btn1">현행값 되돌리기</a>
|
||||||
|
</div>
|
||||||
|
<!--// 버튼 -->
|
||||||
|
</form:form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!--// 컨텐츠 영역 -->
|
||||||
|
</div>
|
||||||
|
<!--// 오른쪽 영역 -->
|
||||||
|
</div>
|
||||||
|
<!--// 전체 둘러싸기 -->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -9,6 +9,8 @@
|
|||||||
|
|
||||||
<c:set var="PATH" value="/mnt" />
|
<c:set var="PATH" value="/mnt" />
|
||||||
<script type="text/javascript" src="<c:url value='/js/egovframework/com/cmm/jquery.js'/>"></script>
|
<script type="text/javascript" src="<c:url value='/js/egovframework/com/cmm/jquery.js'/>"></script>
|
||||||
|
<!-- jquery 달력 — 기간 검색용(class="datepicker", dateFormat yy-mm-dd) -->
|
||||||
|
<c:import url = "/mnt/include/jqCalendar.do" />
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
<!--
|
<!--
|
||||||
function fn_list(pageIndex) {
|
function fn_list(pageIndex) {
|
||||||
@ -17,26 +19,123 @@ function fn_list(pageIndex) {
|
|||||||
document.frm.action = "<c:url value='${PATH}/sec/authorHistory/listView.do' />";
|
document.frm.action = "<c:url value='${PATH}/sec/authorHistory/listView.do' />";
|
||||||
document.frm.submit();
|
document.frm.submit();
|
||||||
}
|
}
|
||||||
function fn_download() {
|
|
||||||
var resn = prompt("다운로드 사유를 입력하세요.(필수)", "");
|
/* 다운로드 '요청'(비동기) : 사유를 받아 작업만 등록하고 목록으로 돌아온다.
|
||||||
|
파일 생성은 서버 백그라운드가 맡으므로 화면은 기다리지 않는다. */
|
||||||
|
function fn_downloadRequest() {
|
||||||
|
var resn = prompt("다운로드 사유를 입력하세요. (필수 — 다운로드 이력에 기록됩니다)", "");
|
||||||
if (resn === null) {
|
if (resn === null) {
|
||||||
return;
|
return; /* 취소 */
|
||||||
}
|
}
|
||||||
resn = $.trim(resn);
|
resn = resn.replace(/^\s+|\s+$/g, "");
|
||||||
if (resn.length === 0) {
|
if (resn.length === 0) {
|
||||||
alert("다운로드 사유는 필수입니다.");
|
alert("다운로드 사유는 필수입니다.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var params = {
|
document.frm.dwldResn.value = resn;
|
||||||
searchTrgetId: $("#searchTrgetId").val(),
|
document.frm.method = "post";
|
||||||
searchChangerId: $("#searchChangerId").val(),
|
document.frm.action = "<c:url value='${PATH}/sec/authorHistory/downloadRequest.do' />";
|
||||||
searchChgSeCode: $("#searchChgSeCode").val(),
|
document.frm.submit();
|
||||||
fromDt: $("#fromDt").val(),
|
}
|
||||||
toDt: $("#toDt").val(),
|
|
||||||
dwldResn: resn
|
/* 완료된 파일 내려받기. 요청자 본인만 가능하며 서버에서 다시 확인한다. */
|
||||||
};
|
function fn_downloadFile(jobId) {
|
||||||
var qs = $.param(params);
|
location.href = "<c:url value='${PATH}/sec/authorHistory/downloadFile.do' />?jobId=" + jobId;
|
||||||
location.href = "<c:url value='${PATH}/sec/authorHistory/download.do' />?" + qs;
|
}
|
||||||
|
|
||||||
|
/* ── 다운로드 진행률 폴링 ─────────────────────────────────────────────
|
||||||
|
종전에는 5초마다 이 목록화면을 통째로 다시 열었다. 이 화면은 접속기록 수집 대상(/mnt/sec/**)이라
|
||||||
|
새로고침 한 번이 곧 접속기록 1행이었고, 사용자가 가만히 있어도 감사로그가 자기 자신을 기록했다.
|
||||||
|
지금은 수집에서 제외된 jobStatus.do 만 호출해 상태 칸만 제자리에서 고쳐 쓴다. */
|
||||||
|
var DL_POLL_MS = 5000;
|
||||||
|
|
||||||
|
function fn_pollIfRunning() {
|
||||||
|
if (fn_dlAnyRunning()) {
|
||||||
|
setTimeout(fn_dlPollOnce, DL_POLL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fn_dlAnyRunning() {
|
||||||
|
var el = document.getElementById("dlJobRunning");
|
||||||
|
return !!(el && el.value == "Y");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fn_dlPollOnce() {
|
||||||
|
$.ajax({
|
||||||
|
url: "<c:url value='${PATH}/sec/authorHistory/jobStatus.do' />",
|
||||||
|
cache: false,
|
||||||
|
dataType: "text"
|
||||||
|
}).done(function(txt) {
|
||||||
|
if (fn_dlApply(txt)) {
|
||||||
|
setTimeout(fn_dlPollOnce, DL_POLL_MS);
|
||||||
|
}
|
||||||
|
}).fail(function() {
|
||||||
|
setTimeout(fn_dlPollOnce, DL_POLL_MS * 3);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 응답(한 줄 1작업: jobId|status|pct|done|total|errMsg)을 표에 반영.
|
||||||
|
@return 아직 대기/생성중인 작업이 남았으면 true */
|
||||||
|
function fn_dlApply(txt) {
|
||||||
|
var lines = (txt || "").split("\n");
|
||||||
|
var anyRunning = false;
|
||||||
|
for (var i = 0; i < lines.length; i++) {
|
||||||
|
var line = lines[i];
|
||||||
|
if (!line) { continue; }
|
||||||
|
var f = line.split("|");
|
||||||
|
if (f.length < 5) { continue; }
|
||||||
|
|
||||||
|
var jobId = f[0], st = f[1], pct = f[2], done = f[3], total = f[4];
|
||||||
|
var err = (f.length > 5) ? f[5] : "";
|
||||||
|
var row = document.getElementById("dlrow-" + jobId);
|
||||||
|
if (!row) { continue; }
|
||||||
|
|
||||||
|
if (st == "W" || st == "R") { anyRunning = true; }
|
||||||
|
fn_dlCell(row, "dl_st", fn_dlStatusHtml(st, err));
|
||||||
|
fn_dlCell(row, "dl_pg", fn_dlProgressHtml(st, pct, done, total));
|
||||||
|
fn_dlCell(row, "dl_dl", fn_dlLinkHtml(st, jobId));
|
||||||
|
}
|
||||||
|
var flag = document.getElementById("dlJobRunning");
|
||||||
|
if (flag) { flag.value = anyRunning ? "Y" : "N"; }
|
||||||
|
return anyRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fn_dlCell(row, cls, html) {
|
||||||
|
var cells = row.getElementsByTagName("td");
|
||||||
|
for (var i = 0; i < cells.length; i++) {
|
||||||
|
if (cells[i].className.indexOf(cls) >= 0) {
|
||||||
|
cells[i].innerHTML = html;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fn_dlStatusHtml(st, err) {
|
||||||
|
if (st == "W") { return '<span class="dml">대기</span>'; }
|
||||||
|
if (st == "R") { return '<span class="dml">생성중</span>'; }
|
||||||
|
if (st == "S") { return '<span class="badge s">완료</span>'; }
|
||||||
|
if (st == "F") { return '<span class="badge f" title="' + fn_dlEsc(err) + '">실패</span>'; }
|
||||||
|
return '<span class="nodata">파기됨</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fn_dlProgressHtml(st, pct, done, total) {
|
||||||
|
if (st == "S") { return fn_dlEsc(done) + '건'; }
|
||||||
|
if (st == "R") { return fn_dlEsc(pct) + '% (' + fn_dlEsc(done) + '/' + fn_dlEsc(total) + ')'; }
|
||||||
|
return '<span class="nodata">-</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fn_dlLinkHtml(st, jobId) {
|
||||||
|
if (st == "S") {
|
||||||
|
return '<a href="#none" onclick="fn_downloadFile(\'' + fn_dlEsc(jobId) + '\'); return false;">내려받기</a>';
|
||||||
|
}
|
||||||
|
return '<span class="nodata">-</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 서버 문자열을 그대로 innerHTML 에 넣지 않는다(실패사유에 태그가 섞일 수 있다). */
|
||||||
|
function fn_dlEsc(s) {
|
||||||
|
return String(s == null ? "" : s)
|
||||||
|
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """).replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
//-->
|
//-->
|
||||||
</script>
|
</script>
|
||||||
@ -45,10 +144,32 @@ function fn_download() {
|
|||||||
.badge.g { background:#3a9d5d; }
|
.badge.g { background:#3a9d5d; }
|
||||||
.badge.m { background:#5b7ec2; }
|
.badge.m { background:#5b7ec2; }
|
||||||
.badge.r { background:#c25b5b; }
|
.badge.r { background:#c25b5b; }
|
||||||
|
.badge.s { background:#3a9d5d; }
|
||||||
|
.badge.f { background:#c25b5b; }
|
||||||
|
.trget_nm { display:block; }
|
||||||
|
.trget_id { display:block; font-size:11px; color:#888; }
|
||||||
|
.ip { font-family:Consolas, monospace; font-size:12px; color:#444; }
|
||||||
|
.nodata { color:#aaa; }
|
||||||
|
.dml { display:inline-block; padding:2px 8px; border-radius:3px; font-size:12px; background:#eee; color:#555; }
|
||||||
|
.dl_job { margin:12px 0; padding:10px 12px; border:1px solid #d6e2f0; background:#f7fafd; }
|
||||||
|
.dl_job strong { font-size:13px; }
|
||||||
|
.dl_job .hash { margin-left:8px; font-size:11px; color:#777; }
|
||||||
|
.guide { margin:10px 0; padding:10px 12px; border:1px solid #e5d9a8; background:#fdfaef; font-size:12px; line-height:1.7; }
|
||||||
|
/* 검색 조건 5종(대상자ID·변경자ID·변경구분·기간·전체보기)을 한 줄에 둔다.
|
||||||
|
공용 .w4 가 width:25% 라 입력칸 4개만으로 100%를 넘겨 줄바꿈이 생긴다 →
|
||||||
|
이 화면에서는 px 고정폭을 쓰고 nowrap 으로 묶는다. 좁은 창에서는 가로 스크롤로 흘린다. */
|
||||||
|
#search_wrap { overflow-x:auto; }
|
||||||
|
#search_wrap .date_search { white-space:nowrap; }
|
||||||
|
#search_wrap .date_search span { padding-left:8px; }
|
||||||
|
#search_wrap .date_search input.sch_txt { width:100px; }
|
||||||
|
#search_wrap .date_search input.sch_date { width:86px; }
|
||||||
|
#search_wrap .date_search select { max-width:90px; }
|
||||||
|
/* datepicker 가 showOn:"both" 로 붙이는 달력 아이콘 — 입력칸에 바짝 붙인다. */
|
||||||
|
#search_wrap .date_search img.ui-datepicker-trigger { margin-left:3px; cursor:pointer; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body onload="fn_pollIfRunning();">
|
||||||
<!-- 타이틀 -->
|
<!-- 타이틀 -->
|
||||||
<c:import url = "/mnt/include/navigation.do" />
|
<c:import url = "/mnt/include/navigation.do" />
|
||||||
<!-- //타이틀 -->
|
<!-- //타이틀 -->
|
||||||
@ -56,14 +177,16 @@ function fn_download() {
|
|||||||
|
|
||||||
<form:form commandName="authorChangeHistoryVO" onsubmit="fn_list('1'); return false;" id="frm" name="frm" method="get" >
|
<form:form commandName="authorChangeHistoryVO" onsubmit="fn_list('1'); return false;" id="frm" name="frm" method="get" >
|
||||||
<form:hidden path="pageIndex" />
|
<form:hidden path="pageIndex" />
|
||||||
|
<%-- 다운로드 사유 — 요청 시에만 채워 보낸다(검색 submit 에는 빈 값). --%>
|
||||||
|
<input type="hidden" name="dwldResn" id="dwldResn" value="" />
|
||||||
|
|
||||||
<!-- 검색창 -->
|
<!-- 검색창 -->
|
||||||
<div id="search_wrap">
|
<div id="search_wrap">
|
||||||
<fieldset class="date_search">
|
<fieldset class="date_search">
|
||||||
<span>대상자ID :</span>
|
<span>대상자ID :</span>
|
||||||
<form:input path="searchTrgetId" title="대상자ID" cssClass="tbox w4" />
|
<form:input path="searchTrgetId" title="대상자ID" cssClass="tbox sch_txt" />
|
||||||
<span>변경자ID :</span>
|
<span>변경자ID :</span>
|
||||||
<form:input path="searchChangerId" title="변경자ID" cssClass="tbox w4" />
|
<form:input path="searchChangerId" title="변경자ID" cssClass="tbox sch_txt" />
|
||||||
<span>변경구분 :</span>
|
<span>변경구분 :</span>
|
||||||
<select name="searchChgSeCode" id="searchChgSeCode" title="변경구분">
|
<select name="searchChgSeCode" id="searchChgSeCode" title="변경구분">
|
||||||
<option value="" <c:if test="${empty authorChangeHistoryVO.searchChgSeCode}">selected="selected"</c:if>>전체</option>
|
<option value="" <c:if test="${empty authorChangeHistoryVO.searchChgSeCode}">selected="selected"</c:if>>전체</option>
|
||||||
@ -72,9 +195,9 @@ function fn_download() {
|
|||||||
<option value="R" <c:if test="${authorChangeHistoryVO.searchChgSeCode == 'R'}">selected="selected"</c:if>>회수</option>
|
<option value="R" <c:if test="${authorChangeHistoryVO.searchChgSeCode == 'R'}">selected="selected"</c:if>>회수</option>
|
||||||
</select>
|
</select>
|
||||||
<span>기간 :</span>
|
<span>기간 :</span>
|
||||||
<form:input path="fromDt" title="검색시작일" cssClass="tbox w4" />
|
<form:input path="fromDt" title="검색시작일" cssClass="tbox sch_date datepicker" readonly="true" />
|
||||||
~
|
~
|
||||||
<form:input path="toDt" title="검색종료일" cssClass="tbox w4" />
|
<form:input path="toDt" title="검색종료일" cssClass="tbox sch_date datepicker" readonly="true" />
|
||||||
<input type="image" src="<c:url value='/images/site/mnt/btn_search.gif'/>" alt="검색" />
|
<input type="image" src="<c:url value='/images/site/mnt/btn_search.gif'/>" alt="검색" />
|
||||||
<a href="<c:url value='${PATH}/sec/authorHistory/listView.do' />"><img src="<c:url value='/images/site/mnt/btn_all_list.gif'/>" alt="전체목록" /></a>
|
<a href="<c:url value='${PATH}/sec/authorHistory/listView.do' />"><img src="<c:url value='/images/site/mnt/btn_all_list.gif'/>" alt="전체목록" /></a>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@ -83,33 +206,100 @@ function fn_download() {
|
|||||||
|
|
||||||
<!-- 버튼 -->
|
<!-- 버튼 -->
|
||||||
<div class="btn_zone">
|
<div class="btn_zone">
|
||||||
<a href="javascript:fn_download();" class="btn2">다운로드(CSV)</a>
|
총 <strong><c:out value="${authorChangeHistoryVO.totalRecordCount}" /></strong>건
|
||||||
|
<a href="javascript:fn_downloadRequest();" class="btn2">CSV 다운로드 요청(사유 필수)</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<%-- 비동기 다운로드 작업 목록 — 요청자 본인 것만 보인다.
|
||||||
|
대용량은 요청만 걸어두고 다른 일을 하다가 완료 후 내려받는다. --%>
|
||||||
|
<c:if test="${not empty dlMessage}">
|
||||||
|
<div class="guide" style="border-color:#c25b5b; color:#c25b5b;"><c:out value="${dlMessage}" /></div>
|
||||||
|
</c:if>
|
||||||
|
<c:if test="${not empty dlJobList}">
|
||||||
|
<c:set var="anyRunning" value="N" />
|
||||||
|
<c:forEach items="${dlJobList}" var="j">
|
||||||
|
<c:if test="${j.status eq 'W' or j.status eq 'R'}"><c:set var="anyRunning" value="Y" /></c:if>
|
||||||
|
</c:forEach>
|
||||||
|
<input type="hidden" id="dlJobRunning" value="${anyRunning}" />
|
||||||
|
|
||||||
|
<div class="dl_job">
|
||||||
|
<strong>내 다운로드 요청</strong>
|
||||||
|
<span class="hash">파일은 암호화 보관되며 만료 시 자동 파기됩니다. 요청자 본인만 내려받을 수 있습니다.</span>
|
||||||
|
<table summary="내 다운로드 요청 목록" class="list1">
|
||||||
|
<caption>내 다운로드 요청 목록</caption>
|
||||||
|
<colgroup>
|
||||||
|
<col width="14%" /><col width="*" /><col width="10%" /><col width="14%" /><col width="14%" /><col width="11%" />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>요청일시</th><th>검색조건</th><th>상태</th><th>진행</th><th>만료</th><th>내려받기</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<%-- tr 의 id 와 td 의 dl_st/dl_pg/dl_dl 클래스는 폴링(fn_dlApply)이 제자리 갱신할
|
||||||
|
지점이다. 이름을 바꾸면 진행률이 멈춘 것처럼 보이니 함께 고칠 것. --%>
|
||||||
|
<c:forEach items="${dlJobList}" var="j">
|
||||||
|
<tr id="dlrow-${j.jobId}">
|
||||||
|
<td><c:out value="${j.regDt}" /></td>
|
||||||
|
<td class="sbj"><c:out value="${j.searchCond}" /></td>
|
||||||
|
<td class="dl_st">
|
||||||
|
<c:choose>
|
||||||
|
<c:when test="${j.status eq 'W'}"><span class="dml">대기</span></c:when>
|
||||||
|
<c:when test="${j.status eq 'R'}"><span class="dml">생성중</span></c:when>
|
||||||
|
<c:when test="${j.status eq 'S'}"><span class="badge s">완료</span></c:when>
|
||||||
|
<c:when test="${j.status eq 'F'}"><span class="badge f" title="<c:out value='${j.errMsg}'/>">실패</span></c:when>
|
||||||
|
<c:otherwise><span class="nodata">파기됨</span></c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
</td>
|
||||||
|
<td class="dl_pg">
|
||||||
|
<c:choose>
|
||||||
|
<c:when test="${j.status eq 'S'}"><c:out value="${j.doneCnt}" />건</c:when>
|
||||||
|
<c:when test="${j.status eq 'R'}"><c:out value="${j.progressPct}" />% (<c:out value="${j.doneCnt}" />/<c:out value="${j.totalCnt}" />)</c:when>
|
||||||
|
<c:otherwise><span class="nodata">-</span></c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
</td>
|
||||||
|
<td><c:out value="${j.expireDt}" /></td>
|
||||||
|
<td class="dl_dl">
|
||||||
|
<c:choose>
|
||||||
|
<c:when test="${j.status eq 'S'}">
|
||||||
|
<a href="#none" onclick="fn_downloadFile('<c:out value="${j.jobId}"/>'); return false;">내려받기</a>
|
||||||
|
</c:when>
|
||||||
|
<c:otherwise><span class="nodata">-</span></c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</c:forEach>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</c:if>
|
||||||
|
|
||||||
<table summary="권한변경 이력 목록" class="list1">
|
<table summary="권한변경 이력 목록" class="list1">
|
||||||
<caption>권한변경 이력 목록</caption>
|
<caption>권한변경 이력 목록</caption>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col width="5%" />
|
<col width="4%" />
|
||||||
<col width="13%" />
|
<col width="12%" />
|
||||||
<col width="10%" />
|
<col width="11%" />
|
||||||
<col width="8%" />
|
<col width="7%" />
|
||||||
<col width="14%" />
|
<col width="12%" />
|
||||||
<col width="14%" />
|
<col width="12%" />
|
||||||
<col width="*" />
|
<col width="*" />
|
||||||
|
<col width="8%" />
|
||||||
<col width="9%" />
|
<col width="9%" />
|
||||||
<col width="9%" />
|
<col width="10%" />
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>NO.</th>
|
<th>NO.</th>
|
||||||
<th>변경일시</th>
|
<th>변경일시</th>
|
||||||
<th>대상자ID</th>
|
<th>대상자</th>
|
||||||
<th>변경구분</th>
|
<th>변경구분</th>
|
||||||
<th>변경전 권한</th>
|
<th>변경전 권한</th>
|
||||||
<th>변경후 권한</th>
|
<th>변경후 권한</th>
|
||||||
<th>변경사유</th>
|
<th>변경사유</th>
|
||||||
<th>승인자</th>
|
<th>승인자</th>
|
||||||
<th>변경자ID</th>
|
<th>변경자ID</th>
|
||||||
|
<th>변경자IP</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -117,7 +307,10 @@ function fn_download() {
|
|||||||
<tr>
|
<tr>
|
||||||
<td><c:out value="${authorChangeHistoryVO.totalRecordCount - (status.index + (authorChangeHistoryVO.pageIndex-1)*authorChangeHistoryVO.recordCountPerPage)}" /></td>
|
<td><c:out value="${authorChangeHistoryVO.totalRecordCount - (status.index + (authorChangeHistoryVO.pageIndex-1)*authorChangeHistoryVO.recordCountPerPage)}" /></td>
|
||||||
<td><c:out value="${result.chgDt}" /></td>
|
<td><c:out value="${result.chgDt}" /></td>
|
||||||
<td><c:out value="${result.trgetId}" /></td>
|
<td>
|
||||||
|
<span class="trget_nm"><c:out value="${result.trgetNm}" /></span>
|
||||||
|
<span class="trget_id"><c:out value="${result.trgetId}" /></span>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<c:choose>
|
<c:choose>
|
||||||
<c:when test="${result.chgSeCode == 'G'}"><span class="badge g">부여</span></c:when>
|
<c:when test="${result.chgSeCode == 'G'}"><span class="badge g">부여</span></c:when>
|
||||||
@ -131,11 +324,17 @@ function fn_download() {
|
|||||||
<td class="sbj"><c:out value="${result.chgResn}" /></td>
|
<td class="sbj"><c:out value="${result.chgResn}" /></td>
|
||||||
<td><c:out value="${result.aprovlId}" /></td>
|
<td><c:out value="${result.aprovlId}" /></td>
|
||||||
<td><c:out value="${result.changerId}" /></td>
|
<td><c:out value="${result.changerId}" /></td>
|
||||||
|
<td>
|
||||||
|
<c:choose>
|
||||||
|
<c:when test="${empty result.changerIp}"><span class="nodata">-</span></c:when>
|
||||||
|
<c:otherwise><span class="ip"><c:out value="${result.changerIp}" /></span></c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</c:forEach>
|
</c:forEach>
|
||||||
<c:if test="${fn:length(resultList) == 0}">
|
<c:if test="${fn:length(resultList) == 0}">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="9"><spring:message code="common.nodata.msg" /></td>
|
<td colspan="10"><spring:message code="common.nodata.msg" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
</c:if>
|
</c:if>
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -145,6 +344,13 @@ function fn_download() {
|
|||||||
<ui:pagination paginationInfo="${paginationInfo}" type="image" jsFunction="fn_list" />
|
<ui:pagination paginationInfo="${paginationInfo}" type="image" jsFunction="fn_list" />
|
||||||
</div>
|
</div>
|
||||||
<!--// paginate -->
|
<!--// paginate -->
|
||||||
|
|
||||||
|
<div class="guide">
|
||||||
|
<strong>변경자 IP</strong> — 권한을 실제로 반영한 취급자의 접속 IP입니다(프록시 환경은 X-Forwarded-For 보정값).
|
||||||
|
권한 부여·변경·회수 내역은 <strong>최소 3년</strong> 보관되며 위·변조 방지를 위해 수정·삭제되지 않습니다.<br />
|
||||||
|
<strong>다운로드</strong> — 사유 입력 후 요청하면 서버가 백그라운드에서 파일을 만듭니다.
|
||||||
|
목록의 상태가 <span class="badge s">완료</span> 로 바뀌면 내려받을 수 있고, 만료된 파일은 자동 파기됩니다.
|
||||||
|
</div>
|
||||||
</form:form>
|
</form:form>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -17,8 +17,14 @@ function fn_list(pageIndex) {
|
|||||||
document.frm.action = "<c:url value='${PATH}/sec/authorRequest/listView.do' />";
|
document.frm.action = "<c:url value='${PATH}/sec/authorRequest/listView.do' />";
|
||||||
document.frm.submit();
|
document.frm.submit();
|
||||||
}
|
}
|
||||||
function fn_regist() {
|
// 권한 부여(신설) 신청 — 대상자 미지정
|
||||||
location.href = "<c:url value='${PATH}/sec/authorRequest/registView.do' />";
|
function fn_grant() {
|
||||||
|
location.href = "<c:url value='${PATH}/sec/authorRequest/registView.do' />?requestSe=N";
|
||||||
|
}
|
||||||
|
// 권한 변경/말소 신청 — 대상자 계정ID 프리필
|
||||||
|
function fn_request(trgetId, requestSe) {
|
||||||
|
location.href = "<c:url value='${PATH}/sec/authorRequest/registView.do' />?requestSe=" + requestSe
|
||||||
|
+ "&trgetId=" + encodeURIComponent(trgetId);
|
||||||
}
|
}
|
||||||
function fn_approve(requestId) {
|
function fn_approve(requestId) {
|
||||||
if (!confirm("해당 권한변경 신청을 승인하시겠습니까?\n승인 시 권한이 즉시 반영되고 변경이력이 적재됩니다.")) {
|
if (!confirm("해당 권한변경 신청을 승인하시겠습니까?\n승인 시 권한이 즉시 반영되고 변경이력이 적재됩니다.")) {
|
||||||
@ -65,9 +71,17 @@ function fn_reject_submit() {
|
|||||||
#rejectLayer .box textarea { width:100%; height:120px; }
|
#rejectLayer .box textarea { width:100%; height:120px; }
|
||||||
#rejectLayer .box .btn_zone { margin-top:12px; text-align:right; }
|
#rejectLayer .box .btn_zone { margin-top:12px; text-align:right; }
|
||||||
.badge { display:inline-block; padding:2px 8px; border-radius:3px; font-size:12px; color:#fff; }
|
.badge { display:inline-block; padding:2px 8px; border-radius:3px; font-size:12px; color:#fff; }
|
||||||
.badge.r { background:#5b7ec2; }
|
.badge.n { background:#3a9d5d; }
|
||||||
.badge.a { background:#3a9d5d; }
|
.badge.w { background:#d09a2c; }
|
||||||
.badge.j { background:#c25b5b; }
|
.badge.r { background:#888; }
|
||||||
|
.role { display:inline-block; padding:2px 8px; border-radius:3px; font-size:12px; background:#e8eff8; color:#2c5c94; }
|
||||||
|
.role.none { background:#f0f0f0; color:#888; }
|
||||||
|
.role.before { background:#f2f2f2; color:#666; }
|
||||||
|
.role.after { background:#e8f3ea; color:#256c3c; }
|
||||||
|
.req_se { display:block; margin-top:3px; font-size:11px; color:#c07a12; }
|
||||||
|
.list_summary { margin:8px 0; font-size:13px; }
|
||||||
|
.list_summary strong { color:#1a6fb5; }
|
||||||
|
.notice_box { margin-top:14px; padding:12px 14px; border:1px solid #e5d9a8; background:#fdfaef; font-size:12px; line-height:1.7; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@ -77,90 +91,136 @@ function fn_reject_submit() {
|
|||||||
<!-- //타이틀 -->
|
<!-- //타이틀 -->
|
||||||
<!-- 컨텐츠 영역 -->
|
<!-- 컨텐츠 영역 -->
|
||||||
|
|
||||||
<form:form commandName="authorChangeRequestVO" onsubmit="fn_list('1'); return false;" id="frm" name="frm" method="post" >
|
<form:form commandName="privacyUserAuthorVO" onsubmit="fn_list('1'); return false;" id="frm" name="frm" method="get" >
|
||||||
<form:hidden path="pageIndex" />
|
<form:hidden path="pageIndex" />
|
||||||
|
|
||||||
<!-- 검색창 -->
|
<!-- 검색창 -->
|
||||||
<div id="search_wrap">
|
<div id="search_wrap">
|
||||||
<fieldset class="date_search">
|
<fieldset class="date_search">
|
||||||
<select name="searchSttus" id="searchSttus" title="처리상태">
|
<span>소속기관 :</span>
|
||||||
<option value="" <c:if test="${empty authorChangeRequestVO.searchSttus}">selected="selected"</c:if>>전체</option>
|
<select name="searchOrgnztId" id="searchOrgnztId" title="소속기관">
|
||||||
<option value="R" <c:if test="${authorChangeRequestVO.searchSttus == 'R'}">selected="selected"</c:if>>신청</option>
|
<option value="">전체</option>
|
||||||
<option value="A" <c:if test="${authorChangeRequestVO.searchSttus == 'A'}">selected="selected"</c:if>>승인</option>
|
<c:forEach items="${orgnztList}" var="org">
|
||||||
<option value="J" <c:if test="${authorChangeRequestVO.searchSttus == 'J'}">selected="selected"</c:if>>반려</option>
|
<option value="${org.orgnztId}" <c:if test="${privacyUserAuthorVO.searchOrgnztId eq org.orgnztId}">selected="selected"</c:if>><c:out value="${org.orgnztNm}" /></option>
|
||||||
|
</c:forEach>
|
||||||
</select>
|
</select>
|
||||||
<span>대상자ID :</span>
|
<span>역할 :</span>
|
||||||
<form:input path="searchTrgetId" title="대상자ID" class="tbox w4" />
|
<select name="searchAuthorCode" id="searchAuthorCode" title="역할">
|
||||||
|
<option value="">전체</option>
|
||||||
|
<c:forEach items="${authorityList}" var="au">
|
||||||
|
<option value="${au.authorCode}" <c:if test="${privacyUserAuthorVO.searchAuthorCode eq au.authorCode}">selected="selected"</c:if>><c:out value="${au.authorNm}" /></option>
|
||||||
|
</c:forEach>
|
||||||
|
</select>
|
||||||
|
<span>권한상태 :</span>
|
||||||
|
<select name="searchAuthorSttus" id="searchAuthorSttus" title="권한상태">
|
||||||
|
<option value="" <c:if test="${empty privacyUserAuthorVO.searchAuthorSttus}">selected="selected"</c:if>>전체</option>
|
||||||
|
<option value="N" <c:if test="${privacyUserAuthorVO.searchAuthorSttus == 'N'}">selected="selected"</c:if>>정상</option>
|
||||||
|
<option value="W" <c:if test="${privacyUserAuthorVO.searchAuthorSttus == 'W'}">selected="selected"</c:if>>승인대기</option>
|
||||||
|
<option value="R" <c:if test="${privacyUserAuthorVO.searchAuthorSttus == 'R'}">selected="selected"</c:if>>말소</option>
|
||||||
|
</select>
|
||||||
|
<span>취급자명 :</span>
|
||||||
|
<form:input path="searchKeyword" title="취급자명 또는 아이디" cssClass="tbox w4" />
|
||||||
<input type="image" src="<c:url value='/images/site/mnt/btn_search.gif'/>" alt="검색" />
|
<input type="image" src="<c:url value='/images/site/mnt/btn_search.gif'/>" alt="검색" />
|
||||||
<a href="<c:url value='${PATH}/sec/authorRequest/listView.do' />"><img src="<c:url value='/images/site/mnt/btn_all_list.gif'/>" alt="전체목록" /></a>
|
<a href="<c:url value='${PATH}/sec/authorRequest/listView.do' />"><img src="<c:url value='/images/site/mnt/btn_all_list.gif'/>" alt="전체목록" /></a>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
<!-- //검색창 -->
|
<!-- //검색창 -->
|
||||||
|
|
||||||
<!-- 버튼 -->
|
<!-- 요약 · 버튼 -->
|
||||||
|
<div class="list_summary">
|
||||||
|
전체 <strong><c:out value="${privacyUserAuthorVO.totalRecordCount}" /></strong>명
|
||||||
|
· 승인대기 <strong><c:out value="${pendingCount}" /></strong>건
|
||||||
|
</div>
|
||||||
<div class="btn_zone">
|
<div class="btn_zone">
|
||||||
<a href="javascript:fn_regist();" class="btn2">권한변경 신청</a>
|
<a href="javascript:fn_grant();" class="btn2">권한 부여</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table summary="권한변경 신청 목록" class="list1">
|
<table summary="개인정보취급자 권한 관리 목록" class="list1">
|
||||||
<caption>권한변경 신청 목록</caption>
|
<caption>개인정보취급자 권한 관리 목록</caption>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col width="5%" />
|
<col width="4%" />
|
||||||
<col width="14%" />
|
<col width="9%" />
|
||||||
|
<col width="10%" />
|
||||||
<col width="12%" />
|
<col width="12%" />
|
||||||
<col width="10%" />
|
<col width="11%" />
|
||||||
|
<col width="11%" />
|
||||||
<col width="*" />
|
<col width="*" />
|
||||||
<col width="10%" />
|
|
||||||
<col width="8%" />
|
<col width="8%" />
|
||||||
|
<col width="9%" />
|
||||||
<col width="13%" />
|
<col width="13%" />
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>NO.</th>
|
<th>NO.</th>
|
||||||
<th>신청일시</th>
|
<th>취급자명</th>
|
||||||
<th>대상자ID</th>
|
<th>아이디</th>
|
||||||
<th>신청구분</th>
|
<th>소속기관</th>
|
||||||
<th>신청사유</th>
|
<th>변경 전 역할</th>
|
||||||
<th>신청자</th>
|
<th>변경 후 역할</th>
|
||||||
<th>처리상태</th>
|
<th>접근범위</th>
|
||||||
<th>처리</th>
|
<th>권한상태</th>
|
||||||
|
<th>최근변경일</th>
|
||||||
|
<th>관리</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<c:forEach items="${resultList}" var="result" varStatus="status">
|
<c:forEach items="${resultList}" var="result" varStatus="status">
|
||||||
<tr>
|
<tr>
|
||||||
<td><c:out value="${authorChangeRequestVO.totalRecordCount - (status.index + (authorChangeRequestVO.pageIndex-1)*authorChangeRequestVO.recordCountPerPage)}" /></td>
|
<td><c:out value="${privacyUserAuthorVO.totalRecordCount - (status.index + (privacyUserAuthorVO.pageIndex-1)*privacyUserAuthorVO.recordCountPerPage)}" /></td>
|
||||||
<td><c:out value="${result.requestDt}" /></td>
|
<td><c:out value="${result.userNm}" /></td>
|
||||||
<td><c:out value="${result.trgetId}" /></td>
|
<td><c:out value="${result.emplyrId}" /></td>
|
||||||
|
<td><c:out value="${result.orgnztNm}" /></td>
|
||||||
<td>
|
<td>
|
||||||
<c:choose>
|
<c:choose>
|
||||||
<c:when test="${result.requestSe == 'N'}">권한신설</c:when>
|
<c:when test="${empty result.beforeAuthorNm || result.beforeAuthorNm == '-'}"><span class="role none">-</span></c:when>
|
||||||
<c:when test="${result.requestSe == 'C'}">권한변경</c:when>
|
<c:otherwise><span class="role before"><c:out value="${result.beforeAuthorNm}" /></span></c:otherwise>
|
||||||
<c:when test="${result.requestSe == 'R'}">권한회수</c:when>
|
|
||||||
<c:otherwise><c:out value="${result.requestSe}" /></c:otherwise>
|
|
||||||
</c:choose>
|
|
||||||
</td>
|
|
||||||
<td class="sbj"><c:out value="${result.requestResn}" /></td>
|
|
||||||
<td><c:out value="${result.reqsterId}" /></td>
|
|
||||||
<td>
|
|
||||||
<c:choose>
|
|
||||||
<c:when test="${result.confmSttus == 'R'}"><span class="badge r">신청</span></c:when>
|
|
||||||
<c:when test="${result.confmSttus == 'A'}"><span class="badge a">승인</span></c:when>
|
|
||||||
<c:when test="${result.confmSttus == 'J'}"><span class="badge j">반려</span></c:when>
|
|
||||||
<c:otherwise><c:out value="${result.confmSttus}" /></c:otherwise>
|
|
||||||
</c:choose>
|
</c:choose>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<c:if test="${result.confmSttus == 'R'}">
|
<c:choose>
|
||||||
<a href="javascript:fn_approve('<c:out value="${result.requestId}"/>');" class="btn2">승인</a>
|
<c:when test="${empty result.afterAuthorNm || result.afterAuthorNm == '-'}"><span class="role none">-</span></c:when>
|
||||||
<a href="javascript:fn_reject_open('<c:out value="${result.requestId}"/>');" class="btn1">반려</a>
|
<c:when test="${result.afterAuthorNm == '권한없음'}"><span class="role none">권한없음</span></c:when>
|
||||||
|
<c:otherwise><span class="role after"><c:out value="${result.afterAuthorNm}" /></span></c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
<c:if test="${not empty result.pendingRequestSe}">
|
||||||
|
<span class="req_se">(<c:choose>
|
||||||
|
<c:when test="${result.pendingRequestSe == 'N'}">신설신청</c:when>
|
||||||
|
<c:when test="${result.pendingRequestSe == 'C'}">변경신청</c:when>
|
||||||
|
<c:when test="${result.pendingRequestSe == 'R'}">말소신청</c:when>
|
||||||
|
<c:otherwise><c:out value="${result.pendingRequestSe}" /></c:otherwise>
|
||||||
|
</c:choose>)</span>
|
||||||
</c:if>
|
</c:if>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="sbj"><c:out value="${result.accessScope}" /></td>
|
||||||
|
<td>
|
||||||
|
<c:choose>
|
||||||
|
<c:when test="${result.authorSttus == 'N'}"><span class="badge n">정상</span></c:when>
|
||||||
|
<c:when test="${result.authorSttus == 'W'}"><span class="badge w">승인대기</span></c:when>
|
||||||
|
<c:when test="${result.authorSttus == 'R'}"><span class="badge r">말소</span></c:when>
|
||||||
|
<c:otherwise><c:out value="${result.authorSttus}" /></c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
</td>
|
||||||
|
<td><c:out value="${result.lastChgDt}" /></td>
|
||||||
|
<td>
|
||||||
|
<c:choose>
|
||||||
|
<c:when test="${result.authorSttus == 'W'}">
|
||||||
|
<a href="javascript:fn_approve('<c:out value="${result.pendingRequestId}"/>');" class="btn2">승인</a>
|
||||||
|
<a href="javascript:fn_reject_open('<c:out value="${result.pendingRequestId}"/>');" class="btn1">반려</a>
|
||||||
|
</c:when>
|
||||||
|
<c:when test="${empty result.authorCode}">
|
||||||
|
<a href="javascript:fn_request('<c:out value="${result.emplyrId}"/>', 'N');" class="btn3">부여</a>
|
||||||
|
</c:when>
|
||||||
|
<c:otherwise>
|
||||||
|
<a href="javascript:fn_request('<c:out value="${result.emplyrId}"/>', 'C');" class="btn3">변경</a>
|
||||||
|
<a href="javascript:fn_request('<c:out value="${result.emplyrId}"/>', 'R');" class="btn1">말소</a>
|
||||||
|
</c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</c:forEach>
|
</c:forEach>
|
||||||
<c:if test="${fn:length(resultList) == 0}">
|
<c:if test="${fn:length(resultList) == 0}">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="8"><spring:message code="common.nodata.msg" /></td>
|
<td colspan="10"><spring:message code="common.nodata.msg" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
</c:if>
|
</c:if>
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -170,6 +230,14 @@ function fn_reject_submit() {
|
|||||||
<ui:pagination paginationInfo="${paginationInfo}" type="image" jsFunction="fn_list" />
|
<ui:pagination paginationInfo="${paginationInfo}" type="image" jsFunction="fn_list" />
|
||||||
</div>
|
</div>
|
||||||
<!--// paginate -->
|
<!--// paginate -->
|
||||||
|
|
||||||
|
<div class="notice_box">
|
||||||
|
<strong>말소 처리 안내</strong> — 인사이동·퇴직 발생 시 <strong>지체 없이</strong> 권한을 변경·말소해야 하며(안전성 확보조치 기준 제5조),
|
||||||
|
모든 부여·변경·말소 내역은 자동으로 이력에 기록되어 <strong>최소 3년간</strong> 보관됩니다.
|
||||||
|
권한 변경은 신청 후 승인 시점에 반영되며, 승인 전까지는 <span class="badge w">승인대기</span> 로 표시됩니다.<br />
|
||||||
|
<strong>변경 전·후 역할</strong> — <span class="badge w">승인대기</span> 행은 <strong>처리 대기 중인 신청 내용</strong>(신청 시점 권한 → 신청 목표 권한)을,
|
||||||
|
그 외 행은 <strong>가장 최근 확정된 권한변경 이력</strong>을 표시합니다. 변경 이력이 없으면 변경 전은 ‘-’, 변경 후는 현재 역할로 표기됩니다.
|
||||||
|
</div>
|
||||||
</form:form>
|
</form:form>
|
||||||
|
|
||||||
<!-- 반려 사유 입력 레이어 -->
|
<!-- 반려 사유 입력 레이어 -->
|
||||||
|
|||||||
@ -14,6 +14,10 @@
|
|||||||
function fn_list() {
|
function fn_list() {
|
||||||
location.href = "<c:url value='${PATH}/sec/authorRequest/listView.do' />";
|
location.href = "<c:url value='${PATH}/sec/authorRequest/listView.do' />";
|
||||||
}
|
}
|
||||||
|
// 목록 [부여/변경/말소] 진입으로 대상자 계정ID가 프리필된 경우 고유식별자를 자동 조회
|
||||||
|
function fn_prefill() {
|
||||||
|
if ($.trim(document.frm.trgetId.value).length > 0) { fn_find_target(); }
|
||||||
|
}
|
||||||
function fn_toggleAfter() {
|
function fn_toggleAfter() {
|
||||||
// 회수(R)는 목표권한 입력 불필요 → 비활성
|
// 회수(R)는 목표권한 입력 불필요 → 비활성
|
||||||
var se = document.frm.requestSe.value;
|
var se = document.frm.requestSe.value;
|
||||||
@ -134,7 +138,7 @@ function fn_cancel() { document.frm.reset(); fn_toggleAfter(); }
|
|||||||
</div>
|
</div>
|
||||||
<!--// 버튼 -->
|
<!--// 버튼 -->
|
||||||
</form:form>
|
</form:form>
|
||||||
<script type="text/javascript">fn_toggleAfter();</script>
|
<script type="text/javascript">fn_toggleAfter(); fn_prefill();</script>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<!--// 컨텐츠 영역 -->
|
<!--// 컨텐츠 영역 -->
|
||||||
|
|||||||
209
src/main/webapp/WEB-INF/jsp/site/mnt/sec/conectLog/list.jsp
Normal file
209
src/main/webapp/WEB-INF/jsp/site/mnt/sec/conectLog/list.jsp
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
<%@ page language="java" contentType="text/html; charset=utf-8" pageEncoding="utf-8"%>
|
||||||
|
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%><%-- Head --%>
|
||||||
|
<c:import url = "/mnt/include/head.do" /><%--// Head --%>
|
||||||
|
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"%>
|
||||||
|
<%@ taglib prefix="ui" uri="http://egovframework.gov/ctl/ui"%>
|
||||||
|
<%@ taglib prefix="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 으로 차단하고 파일을 열지 않음
|
||||||
|
· 접속기록 위·변조 방지 — 이 화면은 수정·삭제를 제공하지 않는다
|
||||||
|
--%>
|
||||||
|
<c:set var="PATH" value="/mnt" />
|
||||||
|
<script type="text/javascript" src="<c:url value='/js/egovframework/com/cmm/jquery.js'/>"></script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
<!--
|
||||||
|
function fn_list(pageIndex) {
|
||||||
|
document.frm.pageIndex.value = pageIndex;
|
||||||
|
document.frm.method = "get";
|
||||||
|
document.frm.action = "<c:url value='${PATH}/sec/conectLog/listView.do' />";
|
||||||
|
document.frm.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 다운로드 : 사유 필수. 사유·검색조건을 그대로 넘기고 이력을 남긴다. */
|
||||||
|
function fn_download() {
|
||||||
|
var reason = prompt("다운로드 사유를 입력하세요. (필수 — 다운로드 이력에 기록됩니다)", "");
|
||||||
|
if (reason == null) {
|
||||||
|
return; /* 취소 */
|
||||||
|
}
|
||||||
|
if (reason.replace(/^\s+|\s+$/g, "") == "") {
|
||||||
|
alert("다운로드 사유는 필수입니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.frm.dlReason.value = reason;
|
||||||
|
document.frm.method = "get";
|
||||||
|
document.frm.action = "<c:url value='${PATH}/sec/conectLog/download.do' />";
|
||||||
|
document.frm.submit();
|
||||||
|
/* 다음 검색이 다운로드로 가지 않도록 원복 */
|
||||||
|
document.frm.dlReason.value = "";
|
||||||
|
document.frm.action = "<c:url value='${PATH}/sec/conectLog/listView.do' />";
|
||||||
|
}
|
||||||
|
//-->
|
||||||
|
</script>
|
||||||
|
<style type="text/css">
|
||||||
|
.badge { display:inline-block; padding:2px 8px; border-radius:3px; font-size:12px; color:#fff; }
|
||||||
|
.badge.i { background:#4a7ebb; }
|
||||||
|
.badge.o { background:#888; }
|
||||||
|
.ip { font-family:Consolas,monospace; }
|
||||||
|
.dim { color:#999; }
|
||||||
|
.guide { margin:6px 0 10px; padding:8px 10px; background:#f5f7fa; border:1px solid #dde3ea; font-size:12px; color:#555; }
|
||||||
|
.btn_area { margin:8px 0; text-align:right; }
|
||||||
|
.btn_dl { padding:5px 14px; border:1px solid #4a7ebb; background:#4a7ebb; color:#fff; cursor:pointer; font-size:12px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<!-- 타이틀 -->
|
||||||
|
<c:import url = "/mnt/include/navigation.do" />
|
||||||
|
<!-- //타이틀 -->
|
||||||
|
<!-- 컨텐츠 영역 -->
|
||||||
|
|
||||||
|
<form:form commandName="conectLogVO" onsubmit="fn_list('1'); return false;" id="frm" name="frm" method="get" >
|
||||||
|
<form:hidden path="pageIndex" />
|
||||||
|
<input type="hidden" name="dlReason" id="dlReason" value="" />
|
||||||
|
|
||||||
|
<!-- 검색창 -->
|
||||||
|
<div id="search_wrap">
|
||||||
|
<fieldset class="date_search">
|
||||||
|
<span>접속ID :</span>
|
||||||
|
<form:input path="searchUserId" title="접속ID(계정ID)" cssClass="tbox w4" />
|
||||||
|
<span>접속구분 :</span>
|
||||||
|
<select name="searchMthd" id="searchMthd" title="접속구분">
|
||||||
|
<option value="" <c:if test="${empty conectLogVO.searchMthd}">selected="selected"</c:if>>전체</option>
|
||||||
|
<option value="I" <c:if test="${conectLogVO.searchMthd == 'I'}">selected="selected"</c:if>>로그인</option>
|
||||||
|
<option value="O" <c:if test="${conectLogVO.searchMthd == 'O'}">selected="selected"</c:if>>로그아웃</option>
|
||||||
|
</select>
|
||||||
|
<span>사용자구분 :</span>
|
||||||
|
<select name="searchUserSe" id="searchUserSe" title="사용자구분">
|
||||||
|
<option value="" <c:if test="${empty conectLogVO.searchUserSe}">selected="selected"</c:if>>전체</option>
|
||||||
|
<option value="USR" <c:if test="${conectLogVO.searchUserSe == 'USR'}">selected="selected"</c:if>>업무사용자</option>
|
||||||
|
<option value="GNR" <c:if test="${conectLogVO.searchUserSe == 'GNR'}">selected="selected"</c:if>>개인</option>
|
||||||
|
<option value="GRO" <c:if test="${conectLogVO.searchUserSe == 'GRO'}">selected="selected"</c:if>>그룹</option>
|
||||||
|
<option value="ENT" <c:if test="${conectLogVO.searchUserSe == 'ENT'}">selected="selected"</c:if>>활동처</option>
|
||||||
|
</select>
|
||||||
|
<br />
|
||||||
|
<span>접속IP :</span>
|
||||||
|
<form:input path="searchIp" title="접속IP" cssClass="tbox w4" />
|
||||||
|
<span>접속위치 :</span>
|
||||||
|
<select name="searchLc" id="searchLc" title="접속위치">
|
||||||
|
<option value="" <c:if test="${empty conectLogVO.searchLc}">selected="selected"</c:if>>전체</option>
|
||||||
|
<c:forEach items="${conectLcList}" var="loc">
|
||||||
|
<option value="<c:out value='${loc}'/>" <c:if test="${conectLogVO.searchLc == loc}">selected="selected"</c:if>><c:out value="${loc}" /></option>
|
||||||
|
</c:forEach>
|
||||||
|
</select>
|
||||||
|
<span>기간 :</span>
|
||||||
|
<form:input path="searchBgnDe" title="조회 시작일" cssClass="tbox w4" maxlength="10" />
|
||||||
|
~
|
||||||
|
<form:input path="searchEndDe" title="조회 종료일" cssClass="tbox w4" maxlength="10" />
|
||||||
|
<input type="image" src="<c:url value='/images/site/mnt/btn_search.gif'/>" alt="검색" />
|
||||||
|
<a href="<c:url value='${PATH}/sec/conectLog/listView.do' />"><img src="<c:url value='/images/site/mnt/btn_all_list.gif'/>" alt="전체목록" /></a>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<!-- //검색창 -->
|
||||||
|
|
||||||
|
<div class="guide">
|
||||||
|
· 시스템 로그인·로그아웃 기록입니다. 기간은 <strong>yyyy-MM-dd</strong> 형식으로 입력하세요.<br />
|
||||||
|
· <strong>접속ID</strong>는 로그인에 사용된 계정ID를 기록 시점에 그대로 저장한 값입니다. 성명은 회원정보에서 보조 조회하며, 탈퇴 등으로 대조되지 않으면 비어 있습니다.<br />
|
||||||
|
· <strong>접속위치</strong>는 접속 IP가 속한 대역(<code>CONECT_IP_LOCATION</code> 매핑)을 <u>기록 시점에</u> 판정해 저장한 값입니다. 이후 매핑을 바꿔도 지난 기록은 변하지 않습니다.<br />
|
||||||
|
· <strong>2026-08-07 이전 기록은 접속ID·접속IP·접속위치가 비어 있습니다</strong> — 종전 적재 로직이 IP를 남기지 않았고, 저장된 고유식별자로는 계정을 특정할 수 없습니다(소급 복원 불가). 이 경우 고유식별자를 회색으로 표시합니다.<br />
|
||||||
|
· <strong>다운로드는 사유 입력이 필수</strong>이며, 다운로드자·일시·IP·사유·건수가 다운로드 이력에 기록됩니다. 접속기록의 수정·삭제는 제공하지 않습니다.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn_area">
|
||||||
|
총 <strong><c:out value="${conectLogVO.totalRecordCount}" /></strong>건
|
||||||
|
<input type="button" class="btn_dl" value="CSV 다운로드(사유 필수)" onclick="fn_download();" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table summary="로그인·로그아웃 접속기록 목록" class="list1">
|
||||||
|
<caption>로그인·로그아웃 접속기록 목록</caption>
|
||||||
|
<colgroup>
|
||||||
|
<col width="6%" />
|
||||||
|
<col width="16%" />
|
||||||
|
<col width="14%" />
|
||||||
|
<col width="14%" />
|
||||||
|
<col width="10%" />
|
||||||
|
<col width="9%" />
|
||||||
|
<col width="16%" />
|
||||||
|
<col width="*" />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>NO.</th>
|
||||||
|
<th>접속일시</th>
|
||||||
|
<th>접속ID</th>
|
||||||
|
<th>성명</th>
|
||||||
|
<th>사용자구분</th>
|
||||||
|
<th>접속구분</th>
|
||||||
|
<th>접속IP</th>
|
||||||
|
<th>접속위치</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<c:forEach items="${resultList}" var="result" varStatus="status">
|
||||||
|
<tr>
|
||||||
|
<td><c:out value="${conectLogVO.totalRecordCount - (status.index + (conectLogVO.pageIndex-1)*conectLogVO.recordCountPerPage)}" /></td>
|
||||||
|
<td><c:out value="${result.creatDt}" /></td>
|
||||||
|
<td>
|
||||||
|
<c:choose>
|
||||||
|
<c:when test="${not empty result.userId}"><c:out value="${result.userId}" /></c:when>
|
||||||
|
<c:otherwise><span class="dim" title="계정ID 미기록(2026-08-07 이전) — 원본 고유식별자"><c:out value="${result.conectId}" /></span></c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
</td>
|
||||||
|
<td><c:out value="${result.userNm}" /></td>
|
||||||
|
<td>
|
||||||
|
<c:choose>
|
||||||
|
<c:when test="${result.userSe == 'USR'}">업무사용자</c:when>
|
||||||
|
<c:when test="${result.userSe == 'GNR'}">개인</c:when>
|
||||||
|
<c:when test="${result.userSe == 'GRO'}">그룹</c:when>
|
||||||
|
<c:when test="${result.userSe == 'ENT'}">활동처</c:when>
|
||||||
|
<c:otherwise><span class="dim">-</span></c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<c:choose>
|
||||||
|
<c:when test="${result.conectMthd == 'I'}"><span class="badge i">로그인</span></c:when>
|
||||||
|
<c:when test="${result.conectMthd == 'O'}"><span class="badge o">로그아웃</span></c:when>
|
||||||
|
<c:otherwise><c:out value="${result.conectMthd}" /></c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
</td>
|
||||||
|
<td class="ip">
|
||||||
|
<c:choose>
|
||||||
|
<c:when test="${not empty result.conectIp}"><c:out value="${result.conectIp}" /></c:when>
|
||||||
|
<c:otherwise><span class="dim">미기록</span></c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<c:choose>
|
||||||
|
<c:when test="${not empty result.conectLc}"><c:out value="${result.conectLc}" /></c:when>
|
||||||
|
<c:otherwise><span class="dim">미기록</span></c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</c:forEach>
|
||||||
|
<c:if test="${fn:length(resultList) == 0}">
|
||||||
|
<tr>
|
||||||
|
<td colspan="8"><spring:message code="common.nodata.msg" /></td>
|
||||||
|
</tr>
|
||||||
|
</c:if>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<!-- paginate -->
|
||||||
|
<div class="paginate">
|
||||||
|
<ui:pagination paginationInfo="${paginationInfo}" type="image" jsFunction="fn_list" />
|
||||||
|
</div>
|
||||||
|
<!--// paginate -->
|
||||||
|
</form:form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!--// 컨텐츠 영역 -->
|
||||||
|
</div>
|
||||||
|
<!--// 오른쪽 영역 -->
|
||||||
|
</div>
|
||||||
|
<!--// 전체 둘러싸기 -->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -13,18 +13,108 @@
|
|||||||
<!--
|
<!--
|
||||||
function fn_list(pageIndex) {
|
function fn_list(pageIndex) {
|
||||||
document.frm.pageIndex.value = pageIndex;
|
document.frm.pageIndex.value = pageIndex;
|
||||||
|
document.frm.searched.value = "Y";
|
||||||
document.frm.method = "get";
|
document.frm.method = "get";
|
||||||
document.frm.action = "<c:url value='${PATH}/sec/dormantStatus/listView.do' />";
|
document.frm.action = "<c:url value='${PATH}/sec/dormantStatus/listView.do' />";
|
||||||
document.frm.submit();
|
document.frm.submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 목록 전체 선택/해제 */
|
||||||
|
function fn_checkAll(box) {
|
||||||
|
var items = document.getElementsByName("checkedIds");
|
||||||
|
for (var i = 0; i < items.length; i++) {
|
||||||
|
if (!items[i].disabled) {
|
||||||
|
items[i].checked = box.checked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 선택된 계정ID 개수 */
|
||||||
|
function fn_checkedCount() {
|
||||||
|
var items = document.getElementsByName("checkedIds");
|
||||||
|
var cnt = 0;
|
||||||
|
for (var i = 0; i < items.length; i++) {
|
||||||
|
if (items[i].checked) {
|
||||||
|
cnt++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cnt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 잠금(휴면 확정)·말소 공통 — 사유를 받아 POST 로 보낸다(사유는 조치 이력에 남는다).
|
||||||
|
ty : "L" 잠금 / "D" 말소 */
|
||||||
|
function fn_action(ty) {
|
||||||
|
if (fn_checkedCount() === 0) {
|
||||||
|
alert("대상 계정을 선택하세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var label = (ty === "L") ? "잠금" : "말소";
|
||||||
|
if (ty === "D") {
|
||||||
|
if (!confirm("선택한 계정을 말소합니다.\n말소된 계정은 로그인할 수 없으며, 보존기간이 지나면 개인정보가 파기됩니다.\n계속하시겠습니까?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!confirm("선택한 계정을 잠금(휴면 확정)합니다.\n계속하시겠습니까?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var resn = prompt(label + " 사유를 입력하세요. (필수 — 조치 이력에 기록됩니다)", "");
|
||||||
|
if (resn === null) {
|
||||||
|
return; /* 취소 */
|
||||||
|
}
|
||||||
|
resn = resn.replace(/^\s+|\s+$/g, "");
|
||||||
|
if (resn.length === 0) {
|
||||||
|
alert(label + " 사유는 필수입니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.frm.actionResn.value = resn;
|
||||||
|
document.frm.method = "post";
|
||||||
|
document.frm.action = (ty === "L")
|
||||||
|
? "<c:url value='${PATH}/sec/dormantStatus/lock.do' />"
|
||||||
|
: "<c:url value='${PATH}/sec/dormantStatus/expire.do' />";
|
||||||
|
document.frm.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 행 단위 조치 — 해당 행만 선택한 뒤 공통 처리로 넘긴다. */
|
||||||
|
function fn_rowAction(accountId, ty) {
|
||||||
|
var items = document.getElementsByName("checkedIds");
|
||||||
|
for (var i = 0; i < items.length; i++) {
|
||||||
|
items[i].checked = (items[i].value === accountId);
|
||||||
|
}
|
||||||
|
var all = document.getElementById("checkAll");
|
||||||
|
if (all) {
|
||||||
|
all.checked = false;
|
||||||
|
}
|
||||||
|
fn_action(ty);
|
||||||
|
}
|
||||||
//-->
|
//-->
|
||||||
</script>
|
</script>
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
.badge { display:inline-block; padding:2px 8px; border-radius:3px; font-size:12px; color:#fff; }
|
.badge { display:inline-block; padding:2px 8px; border-radius:3px; font-size:12px; color:#fff; }
|
||||||
.badge.p { background:#3a9d5d; }
|
.badge.n { background:#3a9d5d; }
|
||||||
.badge.s { background:#c25b5b; }
|
.badge.w { background:#d98c26; }
|
||||||
.badge.a { background:#5b7ec2; }
|
.badge.l { background:#888; }
|
||||||
.badge.d { background:#888; }
|
.badge.d { background:#c25b5b; }
|
||||||
|
.role { display:inline-block; padding:2px 8px; border-radius:3px; font-size:12px; background:#eaf1fb; color:#3a5f9e; }
|
||||||
|
.nodata { color:#aaa; }
|
||||||
|
.days { font-weight:bold; }
|
||||||
|
.days.w { color:#d98c26; }
|
||||||
|
.days.l { color:#c25b5b; }
|
||||||
|
.acnt_id { font-family:Consolas, monospace; font-size:12px; color:#444; }
|
||||||
|
.sum_wrap { width:100%; margin:10px 0 14px 0; border:1px solid #d6e2f0; background:#f7fafd; border-collapse:collapse; }
|
||||||
|
.sum_wrap td { padding:12px 0; text-align:center; border-right:1px solid #e3ebf4; }
|
||||||
|
.sum_wrap td.last { border-right:0; }
|
||||||
|
.sum_wrap .num { display:block; font-size:20px; font-weight:bold; line-height:1.4; }
|
||||||
|
.sum_wrap .num.b { color:#20509e; }
|
||||||
|
.sum_wrap .num.w { color:#d98c26; }
|
||||||
|
.sum_wrap .num.r { color:#c25b5b; }
|
||||||
|
.sum_wrap .num.g { color:#2f8f57; }
|
||||||
|
.sum_wrap .lbl { font-size:12px; color:#666; }
|
||||||
|
.btn_zone .cnt { font-size:13px; }
|
||||||
|
.btn3 { display:inline-block; padding:3px 10px; border:1px solid #888; background:#f5f5f5; font-size:12px; color:#333; text-decoration:none; }
|
||||||
|
.btn3.red { border-color:#c25b5b; color:#c25b5b; }
|
||||||
|
.guide { margin:10px 0; padding:10px 12px; border:1px solid #e5d9a8; background:#fdfaef; font-size:12px; line-height:1.7; }
|
||||||
|
.msg { margin:10px 0; padding:10px 12px; border:1px solid #9ec2e8; background:#f2f8fd; font-size:12px; color:#20509e; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@ -36,81 +126,172 @@ function fn_list(pageIndex) {
|
|||||||
|
|
||||||
<form:form commandName="dormantAccountStatusVO" onsubmit="fn_list('1'); return false;" id="frm" name="frm" method="get" >
|
<form:form commandName="dormantAccountStatusVO" onsubmit="fn_list('1'); return false;" id="frm" name="frm" method="get" >
|
||||||
<form:hidden path="pageIndex" />
|
<form:hidden path="pageIndex" />
|
||||||
|
<%-- 최초 진입 여부 표시 — 'Y' 면 사용자가 검색조건을 직접 지정한 것으로 본다. --%>
|
||||||
|
<input type="hidden" name="searched" id="searched" value="<c:out value='${param.searched}'/>" />
|
||||||
|
<%-- 조치 사유 — 잠금·말소 요청 시에만 채워 보낸다(검색 submit 에는 빈 값). --%>
|
||||||
|
<input type="hidden" name="actionResn" id="actionResn" value="" />
|
||||||
|
|
||||||
|
<c:if test="${not empty resultMsg}">
|
||||||
|
<div class="msg"><c:out value="${resultMsg}" /></div>
|
||||||
|
</c:if>
|
||||||
|
|
||||||
|
<!-- 요약 -->
|
||||||
|
<table class="sum_wrap" summary="장기 미사용 계정 요약">
|
||||||
|
<caption style="display:none;">장기 미사용 계정 요약</caption>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="num b"><c:out value="${summary.totCnt}" /></span>
|
||||||
|
<span class="lbl">전체 계정</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="num w"><c:out value="${summary.warnCnt}" /></span>
|
||||||
|
<span class="lbl">휴면 예정(<c:out value="${summary.warnDay}" />일↑)</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="num r"><c:out value="${summary.lockCnt}" /></span>
|
||||||
|
<span class="lbl">휴면/잠금(<c:out value="${summary.dormantDay}" />일↑)</span>
|
||||||
|
</td>
|
||||||
|
<td class="last">
|
||||||
|
<span class="num g">
|
||||||
|
<c:choose>
|
||||||
|
<c:when test="${summary.batchCycle eq 'DAILY'}">자동(매일)</c:when>
|
||||||
|
<c:when test="${summary.batchCycle eq 'WEEKLY'}">자동(매주)</c:when>
|
||||||
|
<c:when test="${summary.batchCycle eq 'MONTHLY'}">자동(매월)</c:when>
|
||||||
|
<c:otherwise>수동</c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
</span>
|
||||||
|
<span class="lbl">정책 적용중<c:if test="${summary.purgeUseYn eq 'Y'}"> · 파기 사용</c:if></span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<!-- //요약 -->
|
||||||
|
|
||||||
<!-- 검색창 -->
|
<!-- 검색창 -->
|
||||||
<div id="search_wrap">
|
<div id="search_wrap">
|
||||||
<fieldset class="date_search">
|
<fieldset class="date_search">
|
||||||
<span>계정ID :</span>
|
<span>미접속 기간 :</span>
|
||||||
<form:input path="accountId" title="계정ID" cssClass="tbox w4" />
|
<select name="minNoConnectDays" id="minNoConnectDays" title="미접속 기간">
|
||||||
<span>성명 :</span>
|
<option value="0" <c:if test="${dormantAccountStatusVO.minNoConnectDays == 0}">selected="selected"</c:if>>전체</option>
|
||||||
<form:input path="userNm" title="성명" cssClass="tbox w4" />
|
<option value="30" <c:if test="${dormantAccountStatusVO.minNoConnectDays == 30}">selected="selected"</c:if>>30일 이상</option>
|
||||||
<span>상태 :</span>
|
<option value="60" <c:if test="${dormantAccountStatusVO.minNoConnectDays == 60}">selected="selected"</c:if>>60일 이상</option>
|
||||||
<select name="sttusCode" id="sttusCode" title="상태">
|
<option value="90" <c:if test="${dormantAccountStatusVO.minNoConnectDays == 90}">selected="selected"</c:if>>90일 이상</option>
|
||||||
<option value="" <c:if test="${empty dormantAccountStatusVO.sttusCode}">selected="selected"</c:if>>전체</option>
|
<option value="180" <c:if test="${dormantAccountStatusVO.minNoConnectDays == 180}">selected="selected"</c:if>>180일 이상</option>
|
||||||
<option value="P" <c:if test="${dormantAccountStatusVO.sttusCode == 'P'}">selected="selected"</c:if>>사용</option>
|
<option value="365" <c:if test="${dormantAccountStatusVO.minNoConnectDays == 365}">selected="selected"</c:if>>365일 이상</option>
|
||||||
<option value="A" <c:if test="${dormantAccountStatusVO.sttusCode == 'A'}">selected="selected"</c:if>>승인대기</option>
|
<%-- 정책 기준일수가 위 고정 항목에 없으면 현재값을 항목으로 덧붙여 유실을 막는다. --%>
|
||||||
<option value="S" <c:if test="${dormantAccountStatusVO.sttusCode == 'S'}">selected="selected"</c:if>>사용중지</option>
|
<c:if test="${dormantAccountStatusVO.minNoConnectDays > 0
|
||||||
<option value="D" <c:if test="${dormantAccountStatusVO.sttusCode == 'D'}">selected="selected"</c:if>>말소</option>
|
and dormantAccountStatusVO.minNoConnectDays != 30
|
||||||
|
and dormantAccountStatusVO.minNoConnectDays != 60
|
||||||
|
and dormantAccountStatusVO.minNoConnectDays != 90
|
||||||
|
and dormantAccountStatusVO.minNoConnectDays != 180
|
||||||
|
and dormantAccountStatusVO.minNoConnectDays != 365}">
|
||||||
|
<option value="<c:out value='${dormantAccountStatusVO.minNoConnectDays}'/>" selected="selected"><c:out value="${dormantAccountStatusVO.minNoConnectDays}" />일 이상</option>
|
||||||
|
</c:if>
|
||||||
</select>
|
</select>
|
||||||
<span>미접속일수(이상) :</span>
|
<span>계정 상태 :</span>
|
||||||
<form:input path="minNoConnectDays" title="미접속일수 하한" cssClass="tbox w4" />
|
<select name="searchState" id="searchState" title="계정 상태">
|
||||||
|
<option value="" <c:if test="${empty dormantAccountStatusVO.searchState}">selected="selected"</c:if>>전체</option>
|
||||||
|
<option value="N" <c:if test="${dormantAccountStatusVO.searchState == 'N'}">selected="selected"</c:if>>사용</option>
|
||||||
|
<option value="W" <c:if test="${dormantAccountStatusVO.searchState == 'W'}">selected="selected"</c:if>>휴면예정</option>
|
||||||
|
<option value="L" <c:if test="${dormantAccountStatusVO.searchState == 'L'}">selected="selected"</c:if>>휴면·잠금</option>
|
||||||
|
<option value="D" <c:if test="${dormantAccountStatusVO.searchState == 'D'}">selected="selected"</c:if>>말소</option>
|
||||||
|
</select>
|
||||||
|
<span>취급자/ID :</span>
|
||||||
|
<form:input path="searchKeyword" title="취급자명 또는 아이디" cssClass="tbox w4" />
|
||||||
<input type="image" src="<c:url value='/images/site/mnt/btn_search.gif'/>" alt="검색" />
|
<input type="image" src="<c:url value='/images/site/mnt/btn_search.gif'/>" alt="검색" />
|
||||||
<a href="<c:url value='${PATH}/sec/dormantStatus/listView.do' />"><img src="<c:url value='/images/site/mnt/btn_all_list.gif'/>" alt="전체목록" /></a>
|
<a href="<c:url value='${PATH}/sec/dormantStatus/listView.do?page=9100037&searched=Y' />"><img src="<c:url value='/images/site/mnt/btn_all_list.gif'/>" alt="전체목록" /></a>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
<!-- //검색창 -->
|
<!-- //검색창 -->
|
||||||
|
|
||||||
|
<!-- 버튼 -->
|
||||||
|
<div class="btn_zone">
|
||||||
|
<span class="cnt">
|
||||||
|
<c:choose>
|
||||||
|
<c:when test="${dormantAccountStatusVO.minNoConnectDays > 0}">미접속 <c:out value="${dormantAccountStatusVO.minNoConnectDays}" />일 이상 </c:when>
|
||||||
|
<c:otherwise>전체 </c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
<strong><c:out value="${dormantAccountStatusVO.totalRecordCount}" /></strong>건
|
||||||
|
</span>
|
||||||
|
<a href="javascript:fn_action('L');" class="btn3">선택 잠금</a>
|
||||||
|
<a href="javascript:fn_action('D');" class="btn3 red">선택 말소</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<table summary="장기 미사용 계정 현황 목록" class="list1">
|
<table summary="장기 미사용 계정 현황 목록" class="list1">
|
||||||
<caption>장기 미사용 계정 현황 목록</caption>
|
<caption>장기 미사용 계정 현황 목록</caption>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col width="5%" />
|
<col width="4%" />
|
||||||
|
<col width="12%" />
|
||||||
|
<col width="13%" />
|
||||||
|
<col width="15%" />
|
||||||
<col width="12%" />
|
<col width="12%" />
|
||||||
<col width="10%" />
|
<col width="10%" />
|
||||||
<col width="12%" />
|
<col width="11%" />
|
||||||
<col width="10%" />
|
|
||||||
<col width="12%" />
|
|
||||||
<col width="9%" />
|
|
||||||
<col width="9%" />
|
|
||||||
<col width="*" />
|
<col width="*" />
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>NO.</th>
|
<th><input type="checkbox" id="checkAll" title="전체 선택" onclick="fn_checkAll(this);" /></th>
|
||||||
<th>계정ID</th>
|
<th>취급자명</th>
|
||||||
<th>성명</th>
|
<th>아이디</th>
|
||||||
<th>소속</th>
|
<th>역할(Role)</th>
|
||||||
<th>직위</th>
|
<th>마지막 접속일</th>
|
||||||
<th>최종로그인일</th>
|
<th>미접속 일수</th>
|
||||||
<th>미접속일수</th>
|
<th>계정상태</th>
|
||||||
<th>상태</th>
|
<th>조치</th>
|
||||||
<th>휴면예정일</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<c:forEach items="${resultList}" var="result" varStatus="status">
|
<c:forEach items="${resultList}" var="result" varStatus="status">
|
||||||
<tr>
|
<tr>
|
||||||
<td><c:out value="${dormantAccountStatusVO.totalRecordCount - (status.index + (dormantAccountStatusVO.pageIndex-1)*dormantAccountStatusVO.recordCountPerPage)}" /></td>
|
|
||||||
<td><c:out value="${result.accountId}" /></td>
|
|
||||||
<td><c:out value="${result.userNm}" /></td>
|
|
||||||
<td><c:out value="${result.orgnztId}" /></td>
|
|
||||||
<td><c:out value="${result.ofcpsNm}" /></td>
|
|
||||||
<td><c:out value="${result.lastLoginDt}" /></td>
|
|
||||||
<td><c:out value="${result.noConnectDays}" /></td>
|
|
||||||
<td>
|
<td>
|
||||||
<c:choose>
|
<c:choose>
|
||||||
<c:when test="${result.sttusCode == 'P'}"><span class="badge p">사용</span></c:when>
|
<c:when test="${result.acntState == 'D'}">
|
||||||
<c:when test="${result.sttusCode == 'A'}"><span class="badge a">승인대기</span></c:when>
|
<input type="checkbox" name="checkedIds" value="<c:out value='${result.accountId}'/>" disabled="disabled" title="말소된 계정" />
|
||||||
<c:when test="${result.sttusCode == 'S'}"><span class="badge s">사용중지</span></c:when>
|
</c:when>
|
||||||
<c:when test="${result.sttusCode == 'D'}"><span class="badge d">말소</span></c:when>
|
<c:otherwise>
|
||||||
|
<input type="checkbox" name="checkedIds" value="<c:out value='${result.accountId}'/>" title="<c:out value='${result.accountId}'/> 선택" />
|
||||||
|
</c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
</td>
|
||||||
|
<td title="소속 <c:out value='${result.orgnztId}'/> / 직위 <c:out value='${result.ofcpsNm}'/>"><c:out value="${result.userNm}" /></td>
|
||||||
|
<td><span class="acnt_id"><c:out value="${result.accountId}" /></span></td>
|
||||||
|
<td>
|
||||||
|
<c:choose>
|
||||||
|
<c:when test="${empty result.roleNm}"><span class="nodata">-</span></c:when>
|
||||||
|
<c:otherwise><span class="role"><c:out value="${result.roleNm}" /></span></c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
</td>
|
||||||
|
<td><c:out value="${result.lastLoginDt}" /></td>
|
||||||
|
<td>
|
||||||
|
<span class="days <c:if test="${result.acntState == 'W'}">w</c:if><c:if test="${result.acntState == 'L'}">l</c:if>"><c:out value="${result.noConnectDays}" />일</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<c:choose>
|
||||||
|
<c:when test="${result.acntState == 'N'}"><span class="badge n">사용</span></c:when>
|
||||||
|
<c:when test="${result.acntState == 'W'}"><span class="badge w">휴면예정</span></c:when>
|
||||||
|
<c:when test="${result.acntState == 'L'}"><span class="badge l">휴면·잠금</span></c:when>
|
||||||
|
<c:when test="${result.acntState == 'D'}"><span class="badge d">말소</span></c:when>
|
||||||
<c:otherwise><c:out value="${result.sttusCode}" /></c:otherwise>
|
<c:otherwise><c:out value="${result.sttusCode}" /></c:otherwise>
|
||||||
</c:choose>
|
</c:choose>
|
||||||
</td>
|
</td>
|
||||||
<td><c:out value="${result.dormantExpectDt}" /></td>
|
<td>
|
||||||
|
<c:choose>
|
||||||
|
<c:when test="${result.acntState == 'L'}">
|
||||||
|
<a href="#none" class="btn3 red" onclick="fn_rowAction('<c:out value="${result.accountId}"/>', 'D'); return false;">말소</a>
|
||||||
|
</c:when>
|
||||||
|
<c:when test="${result.acntState == 'W'}">
|
||||||
|
<a href="#none" class="btn3" onclick="fn_rowAction('<c:out value="${result.accountId}"/>', 'L'); return false;">잠금</a>
|
||||||
|
</c:when>
|
||||||
|
<c:otherwise><span class="nodata">-</span></c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</c:forEach>
|
</c:forEach>
|
||||||
<c:if test="${fn:length(resultList) == 0}">
|
<c:if test="${fn:length(resultList) == 0}">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="9"><spring:message code="common.nodata.msg" /></td>
|
<td colspan="8"><spring:message code="common.nodata.msg" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
</c:if>
|
</c:if>
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -120,6 +301,14 @@ function fn_list(pageIndex) {
|
|||||||
<ui:pagination paginationInfo="${paginationInfo}" type="image" jsFunction="fn_list" />
|
<ui:pagination paginationInfo="${paginationInfo}" type="image" jsFunction="fn_list" />
|
||||||
</div>
|
</div>
|
||||||
<!--// paginate -->
|
<!--// paginate -->
|
||||||
|
|
||||||
|
<div class="guide">
|
||||||
|
<strong>자동 정책</strong> — 미접속 <strong><c:out value="${summary.warnDay}" />일</strong> 경과 시 <span class="badge w">휴면예정</span> 으로 표시하고 사전알림을 보냅니다.
|
||||||
|
미접속 <strong><c:out value="${summary.dormantDay}" />일</strong> 경과 시 <span class="badge l">휴면·잠금</span> 으로 자동 전환되어 로그인이 차단되고,
|
||||||
|
잠금 후에도 미사용이면 관리자가 확인해 <span class="badge d">말소</span> 합니다. <strong>모든 조치는 사유와 함께 이력에 기록</strong>됩니다.<br />
|
||||||
|
기준일수·알림시점·배치주기는 <strong>시스템관리 > 권한관리 > 계정 관리 정책</strong> 화면에서 변경할 수 있습니다(소스 하드코딩 아님).
|
||||||
|
말소된 계정의 개인정보는 보존기간 경과 후 파기 배치가 처리합니다. 관리자 <strong>본인 계정은 조치 대상에서 제외</strong>됩니다.
|
||||||
|
</div>
|
||||||
</form:form>
|
</form:form>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user