목적: 접속기록 적재(직전 해시 조회 → 봉인 → INSERT)를 업무 트랜잭션과 분리해
- * 개인정보 처리 서비스의 응답시간에 영향을 주지 않게 한다({@link PersonalDataLogAspect}는
+ * 개인정보 처리 서비스의 응답시간에 영향을 주지 않게 한다(수집기는
* {@link #enqueue}만 호출하고 즉시 반환).
{@code PDAL_JOB_MAPPING}(#32) 을 화면에서 등록·수정·사용중지·삭제한다. 이 매핑은
- * 접속기록 수집 Aspect(#31)가 (서비스 클래스, 메서드)를 업무코드·업무명으로 분류하는 기준이며,
- * 접속기록 조회 화면(#47)의 '수행업무' 검색 드롭다운 소스이기도 하다.
- *
- *
화면 경로 {@code /WEB-INF/jsp/site/mnt/sec/jobMapping/list.jsp}, {@code regist.jsp}
- *
- *
수집 범위와의 관계(오해 주의): 매핑을 지우거나 사용중지해도 수집이 멈추지는 않는다.
- * 수집 대상은 {@code context-syslogaop.xml} 의 pointcut 이 정하고, 이 테이블은 분류만 담당한다.
- * 매핑이 없으면 메서드명으로 유도한 기본 분류로 적재된다.
- *
- *
접근통제: 인터셉터 3단 중 ①인증판정이 비활성이고 ②③은 fail-open 이라
- * /mnt 하위라도 무인증 노출이 가능하므로, {@link #handlerUser()} 로 화면·엔드포인트
- * 단에서 직접 판정한다(PersonalDataAccessLogController 와 동일 방식).
- */
-@Controller
-public class JobMappingController {
-
- private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do";
- private static final String LIST_REDIRECT = "redirect:/mnt/sec/jobMapping/listView.do?page=9100045";
-
- @Resource(name = "jobMappingManageService")
- private JobMappingManageService jobMappingManageService;
-
- /**
- * 업무유형 매핑 목록 화면(서버 렌더링).
- * 뷰: /WEB-INF/jsp/site/mnt/sec/jobMapping/list.jsp
- */
- @RequestMapping(value = "/mnt/sec/jobMapping/listView.do")
- public String listView(@ModelAttribute("jobMappingSearchVO") JobMappingSearchVO searchVO,
- @RequestParam(value = "resultMsg", required = false) String resultMsg,
- ModelMap model) throws Exception {
- if (handlerUser() == null) {
- return LOGIN_REDIRECT;
- }
-
- // selectJobMappingManageList 내부에서 firstIndex/totalRecordCount 를 세팅한다.
- List list = jobMappingManageService.selectJobMappingManageList(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("resultMsg", resultMsg);
- return "site/mnt/sec/jobMapping/list";
- }
-
- /**
- * 등록·수정 폼 화면. mappingId 가 있으면 수정, 없으면 등록.
- * 뷰: /WEB-INF/jsp/site/mnt/sec/jobMapping/regist.jsp
- */
- @RequestMapping(value = "/mnt/sec/jobMapping/registView.do")
- public String registView(@RequestParam(value = "mappingId", required = false) Long mappingId,
- ModelMap model) throws Exception {
- if (handlerUser() == null) {
- return LOGIN_REDIRECT;
- }
-
- JobMappingVO vo = null;
- if (mappingId != null && mappingId.longValue() > 0) {
- vo = jobMappingManageService.selectJobMapping(mappingId.longValue());
- if (vo == null) {
- return listRedirect("대상 매핑이 존재하지 않습니다.");
- }
- }
- if (vo == null) {
- vo = new JobMappingVO();
- vo.setMethodPttrn("*");
- vo.setSortOrdr(100); // DDL DEFAULT 와 동일
- vo.setUseAt("Y");
- }
- model.addAttribute("jobMappingVO", vo);
- return "site/mnt/sec/jobMapping/regist";
- }
-
- /**
- * 등록·수정 저장. mappingId 가 0 이면 등록, 그 외는 수정.
- *
- *
{@code BindingResult} 를 반드시 받는다 — 숫자 필드(우선순위)를 비우거나 문자로 채우면
- * 바인딩이 실패하는데, 이 파라미터가 없으면 Spring 이 BindException 을 던져 500 오류 화면이 뜬다.
- * 여기서 받아 사용자 메시지로 되돌린다.
- */
- @RequestMapping(value = "/mnt/sec/jobMapping/save.do")
- public String save(@ModelAttribute("jobMappingVO") JobMappingVO vo,
- BindingResult bindingResult,
- HttpServletRequest request) throws Exception {
- LoginVO user = handlerUser();
- if (user == null) {
- return LOGIN_REDIRECT;
- }
- if (!isPost(request) || !isSameOrigin(request)) {
- return listRedirect("잘못된 요청 경로입니다.");
- }
- if (bindingResult.hasErrors()) {
- return listRedirect("입력값 형식이 올바르지 않습니다. 우선순위는 숫자로 입력하세요.");
- }
-
- try {
- if (vo.getMappingId() > 0) {
- jobMappingManageService.updateJobMapping(vo, user.getId());
- return listRedirect("수정되었습니다. 접속기록 분류에 즉시 반영됩니다.");
- }
- jobMappingManageService.insertJobMapping(vo, user.getId());
- return listRedirect("등록되었습니다. 접속기록 분류에 즉시 반영됩니다.");
- } catch (IllegalArgumentException e) {
- return listRedirect(e.getMessage());
- }
- }
-
- /** 사용여부 전환(Y↔N) — 삭제 대신 권장하는 비활성화 경로. */
- @RequestMapping(value = "/mnt/sec/jobMapping/useAt.do")
- public String updateUseAt(@RequestParam("mappingId") long mappingId,
- @RequestParam("useAt") String useAt,
- HttpServletRequest request) throws Exception {
- LoginVO user = handlerUser();
- if (user == null) {
- return LOGIN_REDIRECT;
- }
- if (!isPost(request) || !isSameOrigin(request)) {
- return listRedirect("잘못된 요청 경로입니다.");
- }
-
- try {
- jobMappingManageService.updateJobMappingUseAt(mappingId, useAt, user.getId());
- return listRedirect("Y".equals(useAt) ? "사용으로 전환했습니다." : "사용중지로 전환했습니다.");
- } catch (IllegalArgumentException e) {
- return listRedirect(e.getMessage());
- }
- }
-
- /** 매핑 삭제(행 제거). */
- @RequestMapping(value = "/mnt/sec/jobMapping/delete.do")
- public String delete(@RequestParam("mappingId") long mappingId,
- HttpServletRequest request) throws Exception {
- LoginVO user = handlerUser();
- if (user == null) {
- return LOGIN_REDIRECT;
- }
- if (!isPost(request) || !isSameOrigin(request)) {
- return listRedirect("잘못된 요청 경로입니다.");
- }
-
- try {
- jobMappingManageService.deleteJobMapping(mappingId, user.getId());
- return listRedirect("삭제되었습니다.");
- } catch (IllegalArgumentException e) {
- return listRedirect(e.getMessage());
- }
- }
-
- /**
- * Resolver 캐시 재적재. 화면을 거치지 않고 DB 를 직접 수정한 경우
- * WAS 재기동 없이 반영하기 위한 수단이다.
- */
- @RequestMapping(value = "/mnt/sec/jobMapping/refresh.do")
- public String refresh(HttpServletRequest request) throws Exception {
- if (handlerUser() == null) {
- return LOGIN_REDIRECT;
- }
- if (!isPost(request) || !isSameOrigin(request)) {
- return listRedirect("잘못된 요청 경로입니다.");
- }
- jobMappingManageService.refreshCache();
- return listRedirect("매핑 캐시를 재적재했습니다.");
- }
-
- /** 처리결과 메시지를 붙여 목록으로 리다이렉트. */
- private String listRedirect(String message) throws Exception {
- if (message == null) {
- return LIST_REDIRECT;
- }
- return LIST_REDIRECT + "&resultMsg=" + URLEncoder.encode(message, "UTF-8");
- }
-
- /** 상태를 바꾸는 요청은 POST 로 한정(링크 클릭·이미지 태그를 통한 변경 방지). */
- private boolean isPost(HttpServletRequest request) {
- return "POST".equalsIgnoreCase(request.getMethod());
- }
-
- /**
- * 취급자 영역(/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;
- }
-
- /**
- * CSRF 부분 완화. Spring Security 2.0.4 에는 CSRF 토큰 기능이 없으므로,
- * Origin/Referer 가 전달된 경우에 한해 요청 호스트와 동일한지 확인한다
- * (헤더 미전송 환경은 과잉차단을 피해 통과 — AccountMngController 와 동일 규약).
- */
- 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;
- }
-}
diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingDAO.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingDAO.java
deleted file mode 100644
index a3b02f3..0000000
--- a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingDAO.java
+++ /dev/null
@@ -1,78 +0,0 @@
-package egovframework.com.cmm.privacy.auditlog;
-
-import java.util.List;
-
-import org.springframework.stereotype.Repository;
-
-import egovframework.com.cmm.service.impl.EgovComAbstractDAO;
-
-/**
- * 개인정보 처리 업무유형 매핑(PDAL_JOB_MAPPING) 조회 DAO.
- *
- *
문화품앗이 개인정보보호 기능개선 사업 - 개발항목 #32.
- *
- * @author arko-privacy-auditlog (Fork B)
- * @since 2026-07-21
- */
-@Repository("jobMappingDAO")
-public class JobMappingDAO extends EgovComAbstractDAO {
-
- /**
- * 사용중(USE_AT='Y')인 업무유형 매핑 전체를 우선순위(SORT_ORDR) 오름차순으로 조회한다.
- * (Resolver가 기동 시 1회 로드해 메모리 캐시)
- */
- @SuppressWarnings("unchecked")
- public List selectJobMappingList() {
- return list("JobMappingDAO.selectJobMappingList", null);
- }
-
- // =================================================================
- // 관리 화면(#32-1) CRUD — 위 selectJobMappingList 와 달리 USE_AT='N' 행도 대상
- // =================================================================
-
- /** 관리 목록 조회(검색 + 페이징). 사용중지(N) 행도 포함한다. */
- @SuppressWarnings("unchecked")
- public List selectJobMappingManageList(JobMappingSearchVO searchVO) {
- return list("JobMappingDAO.selectJobMappingManageList", searchVO);
- }
-
- /** 관리 목록 전체 건수 */
- public int selectJobMappingManageListCount(JobMappingSearchVO searchVO) {
- Object cnt = selectByPk("JobMappingDAO.selectJobMappingManageListCount", searchVO);
- return cnt == null ? 0 : ((Integer) cnt).intValue();
- }
-
- /** 매핑 단건 조회 */
- public JobMappingVO selectJobMapping(long mappingId) {
- return (JobMappingVO) selectByPk("JobMappingDAO.selectJobMapping", Long.valueOf(mappingId));
- }
-
- /**
- * (TARGET_CLASS, METHOD_PTTRN) 중복 건수. 수정 시에는 자기 자신을 제외한다.
- * 테이블에 유니크 제약이 없어(운영 중 행 추가/삭제 자유) 서비스 계층에서 막는다.
- */
- public int selectJobMappingDuplicateCount(JobMappingVO vo) {
- Object cnt = selectByPk("JobMappingDAO.selectJobMappingDuplicateCount", vo);
- return cnt == null ? 0 : ((Integer) cnt).intValue();
- }
-
- /** 매핑 등록 */
- public void insertJobMapping(JobMappingVO vo) {
- insert("JobMappingDAO.insertJobMapping", vo);
- }
-
- /** 매핑 수정 */
- public void updateJobMapping(JobMappingVO vo) {
- update("JobMappingDAO.updateJobMapping", vo);
- }
-
- /** 매핑 사용여부 전환(Y↔N) — 삭제 대신 권장하는 비활성화 경로 */
- public void updateJobMappingUseAt(JobMappingVO vo) {
- update("JobMappingDAO.updateJobMappingUseAt", vo);
- }
-
- /** 매핑 삭제 */
- public void deleteJobMapping(long mappingId) {
- delete("JobMappingDAO.deleteJobMapping", Long.valueOf(mappingId));
- }
-}
diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingManageService.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingManageService.java
deleted file mode 100644
index 5f2f914..0000000
--- a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingManageService.java
+++ /dev/null
@@ -1,53 +0,0 @@
-package egovframework.com.cmm.privacy.auditlog;
-
-import java.util.List;
-
-/**
- * 업무유형 매핑(PDAL_JOB_MAPPING) 관리 서비스 — 개발항목 #32-1.
- *
- *
#32 는 테이블·로더까지만 구현되어 매핑 행을 DB 직접 INSERT 로만 운영했다.
- * 본 서비스는 취급자가 화면에서 매핑을 등록·수정·사용중지·삭제할 수 있게 한다.
- * 매핑이 바뀌면 {@link JobMappingResolver} 캐시를 즉시 무효화하므로 WAS 재기동이 불요하다.
클래스명이 {@code *Impl} 이고 패키지가 {@code egovframework.com..} 이므로
- * {@code context-transaction.xml} 의 requiredTx pointcut 에 걸려 메서드 단위 트랜잭션이 적용된다
- * (DB 반영 + 캐시 무효화가 함께 처리된다).
- *
- *
캐시 무효화 범위: {@link JobMappingResolver#refresh()} 는 해당 JVM 의 캐시만
- * 비운다. arko 는 단일 Tomcat ROOT 전개라 현재는 충분하나, 다중 인스턴스로 확장하면
- * 인스턴스별 재적재 수단(관리 화면의 '캐시 재적재' 버튼을 각 노드에서 호출 등)이 필요하다.
- *
- *
입력 검증: TARGET_CLASS/METHOD_PTTRN 은 Aspect 가 (클래스 단순명, 메서드명)으로
- * 매칭하는 값이라 자바 식별자 형식만 허용한다. 형식을 열어두면 어떤 값도 매칭되지 않는
- * 죽은 매핑이 쌓여 접속기록 분류가 조용히 폴백 처리된다.
- *
- * @author URP
- * @since 2026-08-07
- */
-@Service("jobMappingManageService")
-public class JobMappingManageServiceImpl implements JobMappingManageService {
-
- /** 자바 식별자(패키지 포함 FQN 허용) */
- private static final Pattern CLASS_PATTERN =
- Pattern.compile("^[A-Za-z_$][A-Za-z0-9_$]*(\\.[A-Za-z_$][A-Za-z0-9_$]*)*$");
- /** 메서드명 접두(부분 식별자) */
- private static final Pattern METHOD_PATTERN =
- Pattern.compile("^[A-Za-z_$][A-Za-z0-9_$]*$");
- /** 업무코드 — 대문자/숫자/언더스코어 */
- private static final Pattern CODE_PATTERN =
- Pattern.compile("^[A-Z0-9_]+$");
-
- @Resource(name = "jobMappingDAO")
- private JobMappingDAO jobMappingDAO;
-
- @Resource(name = "jobMappingResolver")
- private JobMappingResolver jobMappingResolver;
-
- public List selectJobMappingManageList(JobMappingSearchVO searchVO) throws Exception {
- if (searchVO.getPageIndex() < 1) {
- searchVO.setPageIndex(1);
- }
- if (searchVO.getRecordCountPerPage() < 1 || searchVO.getRecordCountPerPage() > 100) {
- searchVO.setRecordCountPerPage(20);
- }
- searchVO.setFirstIndex((searchVO.getPageIndex() - 1) * searchVO.getRecordCountPerPage());
- searchVO.setTotalRecordCount(jobMappingDAO.selectJobMappingManageListCount(searchVO));
- return jobMappingDAO.selectJobMappingManageList(searchVO);
- }
-
- public JobMappingVO selectJobMapping(long mappingId) throws Exception {
- return jobMappingDAO.selectJobMapping(mappingId);
- }
-
- public void insertJobMapping(JobMappingVO vo, String actorId) throws Exception {
- normalize(vo);
- validate(vo);
- // 신규는 자기 자신 제외가 없으므로 mappingId=0 으로 전체 대조
- vo.setMappingId(0L);
- if (jobMappingDAO.selectJobMappingDuplicateCount(vo) > 0) {
- throw new IllegalArgumentException("이미 등록된 조합입니다. (대상클래스 + 메서드패턴)");
- }
- vo.setRegisterId(actorId);
- vo.setUpdusrId(actorId);
- jobMappingDAO.insertJobMapping(vo);
- jobMappingResolver.refresh();
- }
-
- public void updateJobMapping(JobMappingVO vo, String actorId) throws Exception {
- normalize(vo);
- validate(vo);
- if (jobMappingDAO.selectJobMapping(vo.getMappingId()) == null) {
- throw new IllegalArgumentException("대상 매핑이 존재하지 않습니다.");
- }
- if (jobMappingDAO.selectJobMappingDuplicateCount(vo) > 0) {
- throw new IllegalArgumentException("이미 등록된 조합입니다. (대상클래스 + 메서드패턴)");
- }
- vo.setUpdusrId(actorId);
- jobMappingDAO.updateJobMapping(vo);
- jobMappingResolver.refresh();
- }
-
- public void updateJobMappingUseAt(long mappingId, String useAt, String actorId) throws Exception {
- if (!"Y".equals(useAt) && !"N".equals(useAt)) {
- throw new IllegalArgumentException("사용여부 값이 올바르지 않습니다.");
- }
- JobMappingVO target = jobMappingDAO.selectJobMapping(mappingId);
- if (target == null) {
- throw new IllegalArgumentException("대상 매핑이 존재하지 않습니다.");
- }
- JobMappingVO vo = new JobMappingVO();
- vo.setMappingId(mappingId);
- vo.setUseAt(useAt);
- vo.setUpdusrId(actorId);
- jobMappingDAO.updateJobMappingUseAt(vo);
- jobMappingResolver.refresh();
- }
-
- public void deleteJobMapping(long mappingId, String actorId) throws Exception {
- if (jobMappingDAO.selectJobMapping(mappingId) == null) {
- throw new IllegalArgumentException("대상 매핑이 존재하지 않습니다.");
- }
- jobMappingDAO.deleteJobMapping(mappingId);
- jobMappingResolver.refresh();
- }
-
- public void refreshCache() throws Exception {
- jobMappingResolver.refresh();
- }
-
- /** 공백 제거 + 미입력 항목 기본값 보정(체크박스/셀렉트 미전송 대비). */
- private void normalize(JobMappingVO vo) {
- vo.setTargetClass(trim(vo.getTargetClass()));
- vo.setMethodPttrn(trim(vo.getMethodPttrn()));
- vo.setJobSeCode(trim(vo.getJobSeCode()));
- vo.setJobSeNm(trim(vo.getJobSeNm()));
- vo.setDmlGubun(trim(vo.getDmlGubun()));
- vo.setRm(trim(vo.getRm()));
-
- if (vo.getMethodPttrn() == null) {
- vo.setMethodPttrn("*"); // DDL DEFAULT 와 동일
- }
- // dmlGubun 은 미입력(null) 이 곧 '메서드명으로 유도' 규약이라 보정하지 않는다.
- if (!"N".equals(vo.getUseAt())) {
- vo.setUseAt("Y");
- }
- if (vo.getJobSeCode() != null) {
- vo.setJobSeCode(vo.getJobSeCode().toUpperCase());
- }
- }
-
- /** 화면 우회 입력(직접 POST)에 대비한 서버측 검증. 위반 시 사용자 메시지로 예외. */
- private void validate(JobMappingVO vo) {
- if (vo.getTargetClass() == null) {
- throw new IllegalArgumentException("대상 클래스명은 필수입니다.");
- }
- if (vo.getTargetClass().length() > 200 || !CLASS_PATTERN.matcher(vo.getTargetClass()).matches()) {
- throw new IllegalArgumentException("대상 클래스명은 자바 클래스명(또는 FQN) 형식으로 200자 이내 입력하세요.");
- }
- if (!"*".equals(vo.getMethodPttrn())
- && (vo.getMethodPttrn().length() > 100 || !METHOD_PATTERN.matcher(vo.getMethodPttrn()).matches())) {
- throw new IllegalArgumentException("메서드 패턴은 '*' 또는 메서드명 접두(영문/숫자) 100자 이내로 입력하세요.");
- }
- if (vo.getJobSeCode() == null) {
- throw new IllegalArgumentException("업무코드는 필수입니다.");
- }
- if (vo.getJobSeCode().length() > 30 || !CODE_PATTERN.matcher(vo.getJobSeCode()).matches()) {
- throw new IllegalArgumentException("업무코드는 영문 대문자·숫자·밑줄 조합으로 30자 이내 입력하세요.");
- }
- if (vo.getJobSeNm() == null || vo.getJobSeNm().length() > 100) {
- throw new IllegalArgumentException("업무명은 필수이며 100자 이내로 입력하세요.");
- }
- if (vo.getDmlGubun() != null && !"S".equals(vo.getDmlGubun()) && !"I".equals(vo.getDmlGubun())
- && !"U".equals(vo.getDmlGubun()) && !"D".equals(vo.getDmlGubun())) {
- throw new IllegalArgumentException("처리구분 값이 올바르지 않습니다.");
- }
- if (vo.getSortOrdr() < 0 || vo.getSortOrdr() > 9999) {
- throw new IllegalArgumentException("우선순위는 0~9999 범위로 입력하세요.");
- }
- if (vo.getRm() != null && vo.getRm().length() > 300) {
- throw new IllegalArgumentException("비고는 300자 이내로 입력하세요.");
- }
- }
-
- /** 앞뒤 공백 제거 후 빈 문자열은 null 로(iBatis isNotEmpty·DB NULL 규약 일치). */
- private String trim(String v) {
- if (v == null) {
- return null;
- }
- String s = v.trim();
- return s.length() == 0 ? null : s;
- }
-}
diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingResolver.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingResolver.java
deleted file mode 100644
index 45df757..0000000
--- a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingResolver.java
+++ /dev/null
@@ -1,112 +0,0 @@
-package egovframework.com.cmm.privacy.auditlog;
-
-import java.util.ArrayList;
-import java.util.List;
-
-import javax.annotation.Resource;
-
-import org.springframework.stereotype.Component;
-
-/**
- * 업무유형 매핑 해석기 (개발항목 #32).
- *
- *
매칭 규칙: USE_AT='Y' 매핑을 SORT_ORDR 오름차순으로 순회하여, 대상 클래스가
- * 일치(단순명 또는 FQN)하고 메서드명이 METHOD_PTTRN으로 시작(또는 '*')하는 첫 매핑을 반환.
- * 구체 규칙(낮은 SORT_ORDR)이 폴백('*', 높은 SORT_ORDR)보다 우선한다.
- *
- * @author arko-privacy-auditlog (Fork B)
- * @since 2026-07-21
- */
-@Component("jobMappingResolver")
-public class JobMappingResolver {
-
- @Resource(name = "jobMappingDAO")
- private JobMappingDAO jobMappingDAO;
-
- /** 캐시(불변 스냅샷 참조 교체 방식). */
- private volatile List cache;
-
- /**
- * (대상클래스 단순명, 메서드명)에 매칭되는 업무유형 매핑을 반환한다.
- * 매칭이 없으면 null (호출측이 메서드명 기반 기본값으로 폴백).
- */
- public JobMappingVO resolve(String targetSimpleClassName, String methodName) {
- if (targetSimpleClassName == null || methodName == null) {
- return null;
- }
- List list = getCache();
- for (int i = 0; i < list.size(); i++) {
- JobMappingVO m = list.get(i);
- if (classMatches(m.getTargetClass(), targetSimpleClassName)
- && methodMatches(m.getMethodPttrn(), methodName)) {
- return m;
- }
- }
- return null;
- }
-
- /** 매핑이 변경되면(관리자 화면 등) 호출해 캐시를 무효화한다. */
- public void refresh() {
- this.cache = null;
- }
-
- private List getCache() {
- List local = this.cache;
- if (local == null) {
- synchronized (this) {
- if (this.cache == null) {
- List loaded = null;
- try {
- loaded = jobMappingDAO.selectJobMappingList();
- } catch (Exception e) {
- // 매핑 로드 실패는 접속기록 수집 자체를 막지 않는다(메서드명 폴백으로 동작).
- loaded = null;
- }
- this.cache = (loaded == null) ? new ArrayList() : loaded;
- }
- local = this.cache;
- }
- }
- return local;
- }
-
- private boolean classMatches(String pattern, String simpleName) {
- if (pattern == null) {
- return false;
- }
- // 단순명 정확 일치, 또는 FQN 등록 시 '.단순명'으로 끝나는지
- return pattern.equals(simpleName) || pattern.endsWith("." + simpleName);
- }
-
- private boolean methodMatches(String pattern, String methodName) {
- if (pattern == null || "*".equals(pattern)) {
- return true;
- }
- return methodName.startsWith(pattern);
- }
-
- /**
- * 메서드명 접두로 처리구분(DML_GUBUN)을 유도한다.
- * insert/regist/creat/add/save→I, update/modify/chang→U, delete/remov→D, 그 외 S(조회).
- */
- public static String deriveDmlGubun(String methodName) {
- if (methodName == null) {
- return "S";
- }
- String m = methodName.toLowerCase();
- if (m.startsWith("insert") || m.startsWith("regist") || m.startsWith("creat")
- || m.startsWith("add") || m.startsWith("save")) {
- return "I";
- }
- if (m.startsWith("update") || m.startsWith("modify") || m.startsWith("chang")) {
- return "U";
- }
- if (m.startsWith("delete") || m.startsWith("remov")) {
- return "D";
- }
- return "S";
- }
-}
diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingSearchVO.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingSearchVO.java
deleted file mode 100644
index e7545fe..0000000
--- a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingSearchVO.java
+++ /dev/null
@@ -1,61 +0,0 @@
-package egovframework.com.cmm.privacy.auditlog;
-
-import java.io.Serializable;
-
-/**
- * 업무유형 매핑(PDAL_JOB_MAPPING) 관리 화면 검색조건 VO — 개발항목 #32-1.
- *
- *
매핑 조회용 {@link JobMappingVO} 와 달리 USE_AT='N' 행도 조회 대상이다.
- * 관리 화면에서는 사용중지된 매핑도 보여야 재사용·복구가 가능하기 때문이다
- * ({@code JobMappingDAO.selectJobMappingList} 는 Resolver 캐시용이라 'Y'만 조회).
- *
- * @author URP
- * @since 2026-08-07
- */
-public class JobMappingSearchVO implements Serializable {
-
- private static final long serialVersionUID = 1L;
-
- /** 대상 클래스명(부분 일치) */
- private String searchTargetClass;
- /** 업무코드 또는 업무명(부분 일치) */
- private String searchJobSe;
- /** 사용여부 Y/N (빈 값이면 전체) */
- private String searchUseAt;
- /** 처리구분 S/I/U/D (빈 값이면 전체) */
- private String searchDmlGubun;
-
- // ---- 페이징 ----
- /** 현재 페이지 번호(1-base) */
- private int pageIndex = 1;
- /** 페이지당 레코드 수 */
- private int recordCountPerPage = 20;
- /** 조회 시작 offset(0-base) = (pageIndex-1) * recordCountPerPage */
- private int firstIndex = 0;
- /** 전체 레코드 수(조회 후 세팅) */
- private int totalRecordCount = 0;
-
- public String getSearchTargetClass() { return searchTargetClass; }
- public void setSearchTargetClass(String searchTargetClass) { this.searchTargetClass = searchTargetClass; }
-
- public String getSearchJobSe() { return searchJobSe; }
- public void setSearchJobSe(String searchJobSe) { this.searchJobSe = searchJobSe; }
-
- public String getSearchUseAt() { return searchUseAt; }
- public void setSearchUseAt(String searchUseAt) { this.searchUseAt = searchUseAt; }
-
- public String getSearchDmlGubun() { return searchDmlGubun; }
- public void setSearchDmlGubun(String searchDmlGubun) { this.searchDmlGubun = searchDmlGubun; }
-
- public int getPageIndex() { return pageIndex; }
- public void setPageIndex(int pageIndex) { this.pageIndex = pageIndex; }
-
- public int getRecordCountPerPage() { return recordCountPerPage; }
- public void setRecordCountPerPage(int recordCountPerPage) { this.recordCountPerPage = recordCountPerPage; }
-
- public int getFirstIndex() { return firstIndex; }
- public void setFirstIndex(int firstIndex) { this.firstIndex = firstIndex; }
-
- public int getTotalRecordCount() { return totalRecordCount; }
- public void setTotalRecordCount(int totalRecordCount) { this.totalRecordCount = totalRecordCount; }
-}
diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingVO.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingVO.java
index 119aace..f267f45 100644
--- a/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingVO.java
+++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/JobMappingVO.java
@@ -3,11 +3,19 @@ package egovframework.com.cmm.privacy.auditlog;
import java.io.Serializable;
/**
- * 개인정보 처리 업무유형 매핑(PDAL_JOB_MAPPING) VO.
+ * 개인정보 처리 업무유형 매핑 VO.
*
*
문화품앗이 개인정보보호 기능개선 사업 - 개발항목 #32 (SFR-004 / SER-002).
- * 접속기록 수집({@link PersonalDataLogAspect})이 가로챈 서비스 메서드를
- * 업무코드/업무명으로 해석하는 매핑 한 행.
+ * 본래 PDAL_JOB_MAPPING 한 행을 담아, 폐기된 서비스 AOP 수집이 가로챈 서비스 메서드를
+ * 업무코드/업무명으로 해석하는 데 썼다.
+ *
+ *
[2026-08-11] 업무유형 매핑 관리 화면(메뉴 9100045) 삭제 후에도 이 클래스는 존치한다 —
+ * 접속기록 조회화면(9100044)의 '수행업무' 드롭다운 statement
+ * {@code PersonalDataAccessLogDAO.selectJobSeCodeList}(PrivacyAccessLog_SQL_Mysql.xml /
+ * _Oracle.xml)가 {@code resultClass} 로 이 클래스를 재사용한다. 실제 데이터는
+ * PDAL_JOB_MAPPING 이 아니라 PERSONAL_DATA_ACCESS_LOG 에서 직접 뽑으며
+ * jobSeCode/jobSeNm 두 프로퍼티만 채워진다. 삭제하면 iBatis2 특성상 기동 시가 아니라
+ * 해당 드롭다운 최초 조회 시점에 resultClass 로드 실패로 터진다.
*
* @author arko-privacy-auditlog (Fork B)
* @since 2026-07-21
diff --git a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogController.java b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogController.java
index 2b4de76..35b8c98 100644
--- a/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogController.java
+++ b/src/main/java/egovframework/com/cmm/privacy/auditlog/PersonalDataAccessLogController.java
@@ -55,6 +55,9 @@ public class PersonalDataAccessLogController {
private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do";
+ /** 화면에 띄울 '내 다운로드 요청' 최대 건수(만료 전 기준). 폭주 방지용 상한이다. */
+ private static final int MY_JOB_MAX = 100;
+
@Resource(name = "personalDataAccessLogSearchService")
private PersonalDataAccessLogSearchService personalDataAccessLogSearchService;
@@ -216,7 +219,8 @@ public class PersonalDataAccessLogController {
}
StringBuilder sb = new StringBuilder(256);
- List list = accessLogDownloadJobService.selectMyJobs(user.getId(), 10);
+ // 목록과 같은 범위를 봐야 한다 — 다르면 화면에 떠 있는데 폴링이 갱신하지 않는 행이 생긴다.
+ List list = accessLogDownloadJobService.selectMyJobs(user.getId(), MY_JOB_MAX);
if (list != null) {
for (AccessLogDownloadJobVO j : list) {
sb.append(j.getJobId()).append('|')
@@ -243,14 +247,26 @@ public class PersonalDataAccessLogController {
return (v == null) ? "" : v;
}
- /** 내 다운로드 작업 목록(최근 10건). 조회 실패해도 화면은 떠야 하므로 빈 목록으로 fail-safe. */
+ /**
+ * 내 다운로드 작업 목록 — 만료 전 이력만(2026-08-10 정책).
+ *
+ *
종전에는 '최근 10건'이라 이미 파일이 파기된 행까지 남아, 화면상 '완료'인데 눌러도
+ * 내려받아지지 않는 상태가 생겼다. 만료 기준으로 거르면 목록에 보이는 건 전부 실제로
+ * 내려받을 수 있는 것들이다. 테이블 행은 그대로 둔다 — 누가 언제 왜 개인정보를
+ * 반출 요청했는지는 감사 증적이라 지우지 않고, 화면에서만 감춘다.
+ *
+ *
{@link #MY_JOB_MAX} 는 보관 3일 기준으로 사실상 걸리지 않지만, 단기 대량 요청 시
+ * 화면이 통째로 길어지는 것을 막는 안전장치다.
[2026-08-07 사용 중단] 이 Aspect 는 더 이상 적용되지 않는다.
- * {@code context-syslogaop.xml}의 {@code }가 주석 처리됐고, 접속기록 수집은
- * {@code egovframework.com.cmm.privacy.interceptor.MenuAccessLogInterceptor}가 담당한다.
- * 중단 사유: 서비스 impl 을 가로채는 방식은 관리자 화면과 프론트 화면이 같은 서비스
- * 메서드를 공유하는 구조에서 호출 화면을 구분하지 못한다. 그 결과 무인증 프론트 방문이
- * 접속자ID {@code ANONYMOUS}, 수행업무 "재능나눔 회원관리"로 적재됐다. 요구사항(SFR-004)은
- * "개인정보취급자가 무슨 업무를 처리했는지"이므로, 요청 URL 로 관리자 화면을 식별하고
- * 메뉴명을 수행업무로 남기는 인터셉터 방식으로 전환했다.
- * 클래스는 판단 근거·복원 경로 보존을 위해 남겨둔다(빈은 등록되나 어떤 pointcut 에도 걸리지 않음).
- *
- *
한정 수집: {@code COMTNSYSLOG}의 select* 전 서비스 대상 방식이 아니라,
- * 개인정보 처리 서비스로 한정한 pointcut(스키마 기반 {@code }, context-syslogaop.xml)에만
- * 적용된다. 가로챈 메서드는 업무유형 매핑 테이블({@link JobMappingResolver}, #32)로 업무코드를 얻고,
- * 접속 IP({@link ClientIpResolver}, #34)와 해시체인({@link HashChainService}, #39)을 결합해
- * {@code PERSONAL_DATA_ACCESS_LOG}에 적재한다.
- *
- *
본문 미적재 원칙: 처리 파라미터(개인정보 본문)는 로그에 담지 않는다. 식별자(TRGET_ID)만
- * 베스트에포트로 취득한다(#36).
- *
- *
로그인 세션 한정(2026-08-07): 세션에 {@code loginVO}가 없는 요청은 수집하지 않는다.
- * 종전에는 접속자ID를 {@code ANONYMOUS}로 적재했다. 근거는 두 가지다.
- * ① 제안요청서(붙임2) SFR-004 정의가 "개인정보취급자 접속 및 업무처리 기록 관리"이고
- * 세부내용 첫 항목도 "개인정보취급자의 접속기록을 저장·관리"로, 비로그인 상태(정보주체 본인의
- * 가입·실명확인 등)는 대상이 아니다(SER-002·안전성 확보조치 기준 제8조도 동일 기준).
- * ② 비정상접근 탐지(#35)가 {@code GROUP BY CONECT_ID} 기반이라, 모든 익명 요청이
- * {@code ANONYMOUS} 한 계정으로 뭉쳐 계정공유의심(상이 IP 다수)·대량조회에 상시 오탐을 만든다.
- * 제외되는 대표 경로: 회원가입({@code insert*}), 실명확인({@code MberSsnServiceImpl}),
- * 휴면해제, 계정잠금 해제, 인증번호 발송. 이 경로들의 추적은 인증 실패 이력(#22)이 담당한다.
- *
- *
수집 범위(#36): 회원·업체 2개 지점 한정에서 전 개인정보 처리 서비스로 확장.
- * pointcut 대상은 {@code context-syslogaop.xml}에 개인정보 취급 서비스 impl만 열거(과수집 방지).
- * 신규 화면 추가 시에는 이 pointcut(또는 대상 서비스에 편입)과 업무유형 매핑({@link JobMappingResolver})만
- * 갱신하면 되고 컨트롤러 수동 호출은 불요하다.
- *
- *
TRGET_ID 취득 규약(#36): 가로챈 메서드 인자에서 식별자만 베스트에포트로 추출한다.
- * ① 인자가 VO/도메인 빈이면 우선순위 getter({@code getEsntlId→getUniqId→getMberId→getEmplyrId→
- * getEntrprsId→getGroupId→getUserId→getId→getSn}) 중 첫 non-blank 값. ② 인자가 {@code Map}이면
- * 동일 우선순위 키. ③ 원시 {@code String}/{@code Number} 인자는 식별자·검색어 구분이 불가하므로
- * 스킵(검색어·개인정보 본문 오적재 방지). 어느 것도 못 얻으면 {@code null}(fail-safe).
- * 처리대상 건수(TRGET_CN)는 반환값이 {@code Collection}이면 "N건"으로 기록한다.
- *
- *
스키마 기반 AOP(@AspectJ 미사용)라 {@code aspectj-autoproxy}를 전역 활성화하지 않는다 —
- * 기존 프로젝트 프록시 동작에 영향을 주지 않기 위함.
- *
- * @author arko-privacy-auditlog (Fork B)
- * @since 2026-07-21
- */
-@Component("personalDataLogAspect")
-public class PersonalDataLogAspect {
-
- private static final Log LOG = LogFactory.getLog(PersonalDataLogAspect.class);
-
- private static final String DT_FORMAT = "yyyy-MM-dd HH:mm:ss";
-
- @Resource(name = "jobMappingResolver")
- private JobMappingResolver jobMappingResolver;
-
- /** #33 비동기 적재 큐 — Aspect는 enqueue만 하고 즉시 반환(업무 응답시간 무영향). */
- @Resource(name = "accessLogQueue")
- private AccessLogQueue accessLogQueue;
-
- /**
- * 스키마 기반 {@code } 대상 메서드.
- * 업무는 정상 수행하고, 성공/예외 여부와 무관하게 접속기록을 남긴다(기록 실패는 업무에 영향 없음).
- */
- public Object around(ProceedingJoinPoint pjp) throws Throwable {
- String processResult = "S";
- Object result = null;
- try {
- result = pjp.proceed();
- return result;
- } catch (Throwable t) {
- processResult = "F";
- throw t;
- } finally {
- try {
- writeAccessLog(pjp, processResult, result);
- } catch (Exception ignore) {
- // 접속기록 수집은 업무 흐름을 절대 방해하지 않는다.
- LOG.error("[개인정보/#31] 접속기록 수집 중 예외(무시)", ignore);
- }
- }
- }
-
- private void writeAccessLog(ProceedingJoinPoint pjp, String processResult, Object result) {
- HttpServletRequest request = currentRequest();
- // 웹 요청 컨텍스트가 없으면(배치/스케줄러 등) 접속기록 대상이 아니다.
- if (request == null) {
- return;
- }
-
- // 로그인 세션이 없는 요청은 취급자 접속기록이 아니므로 수집하지 않는다(사유는 클래스 Javadoc).
- // 매핑 조회보다 먼저 판정해 불필요한 작업을 하지 않는다.
- LoginVO login = currentLogin(request);
- if (login == null || !notBlank(login.getId())) {
- return;
- }
-
- String targetSimpleName = pjp.getTarget().getClass().getSimpleName();
- String methodName = pjp.getSignature().getName();
-
- JobMappingVO mapping = jobMappingResolver.resolve(targetSimpleName, methodName);
-
- String jobSeCode;
- String jobSeNm;
- String dmlGubun;
- if (mapping != null) {
- jobSeCode = mapping.getJobSeCode();
- jobSeNm = mapping.getJobSeNm();
- dmlGubun = (mapping.getDmlGubun() != null && mapping.getDmlGubun().length() > 0)
- ? mapping.getDmlGubun()
- : JobMappingResolver.deriveDmlGubun(methodName);
- } else {
- // pointcut 범위 안이지만 매핑 미등록 → 메서드명 기반 기본값(누락 없이 수집)
- jobSeCode = targetSimpleName;
- jobSeNm = targetSimpleName + "." + methodName;
- dmlGubun = JobMappingResolver.deriveDmlGubun(methodName);
- }
-
- PersonalDataAccessLogVO vo = new PersonalDataAccessLogVO();
- vo.setConectId(login.getId());
- vo.setConectDt(new SimpleDateFormat(DT_FORMAT).format(new Date()));
- vo.setConectIp(safeIp(request));
- vo.setJobSeCode(jobSeCode);
- vo.setJobSeNm(jobSeNm);
- // #36 처리대상 식별자(본문 아님)·건수 베스트에포트 취득 — 규약은 클래스 Javadoc 참조.
- vo.setTrgetId(extractTargetId(pjp.getArgs()));
- vo.setTrgetCn(extractTargetCount(result));
- vo.setDmlGubun(dmlGubun);
- vo.setProcessResult(processResult);
- vo.setUserSe(login != null ? login.getUserSe() : null);
-
- // #33 비동기 큐에 적재만 하고 즉시 반환 — 봉인+INSERT는 큐 단일 컨슈머가 순차 수행(순번 보장).
- accessLogQueue.enqueue(vo);
- }
-
- private HttpServletRequest currentRequest() {
- RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
- if (attrs instanceof ServletRequestAttributes) {
- return ((ServletRequestAttributes) attrs).getRequest();
- }
- return null;
- }
-
- private LoginVO currentLogin(HttpServletRequest request) {
- HttpSession session = request.getSession(false);
- if (session == null) {
- return null;
- }
- Object o = session.getAttribute("loginVO");
- return (o instanceof LoginVO) ? (LoginVO) o : null;
- }
-
- private String safeIp(HttpServletRequest request) {
- try {
- String ip = ClientIpResolver.getClientIp(request);
- return notBlank(ip) ? ip : "-";
- } catch (Exception e) {
- return "-";
- }
- }
-
- private static boolean notBlank(String s) {
- return s != null && s.trim().length() > 0;
- }
-
- // ===================== #36 처리대상 식별자/건수 취득 =====================
-
- /** 우선순위 식별자 getter(개인정보 본문이 아닌 식별자만). */
- private static final String[] ID_GETTERS = {
- "getEsntlId", "getUniqId", "getMberId", "getEmplyrId", "getEntrprsId",
- "getGroupId", "getUserId", "getId", "getSn"
- };
- /** Map 파라미터용 우선순위 식별자 키(ID_GETTERS와 동일 순서). */
- private static final String[] ID_KEYS = {
- "esntlId", "uniqId", "mberId", "emplyrId", "entrprsId",
- "groupId", "userId", "id", "sn"
- };
- /** TRGET_ID 컬럼 길이(VARCHAR(50)) 보호. */
- private static final int TRGET_ID_MAX = 50;
-
- /**
- * 인자에서 처리대상 식별자를 베스트에포트로 추출한다(규약: 클래스 Javadoc).
- * VO는 우선순위 getter, Map은 우선순위 키. 원시 String/Number 인자는 스킵(검색어 오적재 방지).
- * 실패 시 null(fail-safe) — 접속기록 자체는 계속 적재된다.
- */
- private String extractTargetId(Object[] args) {
- if (args == null) {
- return null;
- }
- for (Object arg : args) {
- if (arg == null) {
- continue;
- }
- String id = null;
- if (arg instanceof Map) {
- id = fromMap((Map, ?>) arg);
- } else if (isDomainBean(arg)) {
- id = fromBean(arg);
- }
- // 원시 String/Number/Collection/배열 인자는 식별자 확정 불가 → 스킵(본문·검색어 오적재 방지)
- if (notBlank(id)) {
- return id.trim().length() > TRGET_ID_MAX ? id.trim().substring(0, TRGET_ID_MAX) : id.trim();
- }
- }
- return null;
- }
-
- /** 반환값이 컬렉션이면 처리대상 건수(범위)를 "N건"으로 기록한다. */
- private String extractTargetCount(Object result) {
- if (result instanceof Collection) {
- return ((Collection>) result).size() + "건";
- }
- return null;
- }
-
- /** VO/도메인 빈 판정 — String/래퍼/Collection/Map/배열은 제외. */
- private boolean isDomainBean(Object arg) {
- if (arg instanceof String || arg instanceof Number || arg instanceof Boolean
- || arg instanceof Character || arg instanceof Collection || arg instanceof Map
- || arg.getClass().isArray()) {
- return false;
- }
- return true;
- }
-
- private String fromBean(Object bean) {
- for (String getter : ID_GETTERS) {
- try {
- Method m = bean.getClass().getMethod(getter);
- Object v = m.invoke(bean);
- if (v != null && notBlank(String.valueOf(v))) {
- return String.valueOf(v);
- }
- } catch (Exception ignore) {
- // 해당 getter 없음/접근 불가 → 다음 후보
- }
- }
- return null;
- }
-
- private String fromMap(Map, ?> map) {
- for (String key : ID_KEYS) {
- try {
- Object v = map.get(key);
- if (v != null && notBlank(String.valueOf(v))) {
- return String.valueOf(v);
- }
- } catch (Exception ignore) {
- // 일부 Map 구현의 키 타입 불일치 등 → 다음 후보
- }
- }
- return null;
- }
-}
diff --git a/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowController.java b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowController.java
index a9b6e99..7c2127d 100644
--- a/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowController.java
+++ b/src/main/java/egovframework/com/cmm/privacy/author/AuthorWorkflowController.java
@@ -32,6 +32,13 @@ public class AuthorWorkflowController {
protected static final String JSON = "jsonView";
+ /**
+ * 화면에 띄울 '내 다운로드 요청' 최대 건수. 목록은 만료 전 이력만 보여주며
+ * (2026-08-10 정책, 접속기록 다운로드와 동일), 이 값은 단기 대량 요청 시 화면이
+ * 통째로 길어지는 것을 막는 상한이다. 테이블 행 자체는 감사 증적이라 그대로 둔다.
+ */
+ private static final int MY_JOB_MAX = 100;
+
@Resource(name = "authorWorkflowService")
private AuthorWorkflowService authorWorkflowService;
@@ -201,7 +208,7 @@ public class AuthorWorkflowController {
}
StringBuilder sb = new StringBuilder(256);
- List list = authorHistDownloadJobService.selectMyJobs(user.getId(), 10);
+ List list = authorHistDownloadJobService.selectMyJobs(user.getId(), MY_JOB_MAX);
if (list != null) {
for (AuthorHistDownloadJobVO j : list) {
sb.append(j.getJobId()).append('|')
@@ -322,7 +329,7 @@ public class AuthorWorkflowController {
if (user == null) {
return new java.util.ArrayList();
}
- List list = authorHistDownloadJobService.selectMyJobs(user.getId(), 10);
+ List list = authorHistDownloadJobService.selectMyJobs(user.getId(), MY_JOB_MAX);
return (list == null) ? new java.util.ArrayList() : list;
} catch (Exception e) {
return new java.util.ArrayList();
diff --git a/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogController.java b/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogController.java
deleted file mode 100644
index 5e63bed..0000000
--- a/src/main/java/egovframework/com/cmm/privacy/conect/ConectLogController.java
+++ /dev/null
@@ -1,194 +0,0 @@
-package egovframework.com.cmm.privacy.conect;
-
-import java.io.OutputStream;
-import java.io.OutputStreamWriter;
-import java.io.PrintWriter;
-import java.net.URLEncoder;
-import java.text.SimpleDateFormat;
-import java.util.ArrayList;
-import java.util.Date;
-import java.util.List;
-
-import javax.annotation.Resource;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-
-import org.springframework.stereotype.Controller;
-import org.springframework.ui.ModelMap;
-import org.springframework.web.bind.annotation.ModelAttribute;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RequestParam;
-
-import egovframework.com.cmm.LoginVO;
-import egovframework.com.cmm.privacy.auditlog.ClientIpResolver;
-import egovframework.com.cmm.util.EgovUserDetailsHelper;
-import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo;
-
-/**
- * 로그인·로그아웃 접속기록 조회·다운로드 컨트롤러 — 개인정보보호 기능개선 사업(#62).
- *
- *
설정 대역(CONECT_IP_LOCATION)과 실제 적재된 값(COMTNLOGINLOG DISTINCT)을
- * 합친다. #32-1 에서 겪은 함정 — 매핑 테이블만 보면 실제 적재값과 어긋난 선택지가 떠
- * 검색 결과가 항상 0건이 된다 — 을 피하기 위한 것이다.
접속ID 주의: {@code COMTNLOGINLOG.CONECT_ID}({@link #conectId})는 고유식별자
- * 자리인데 실제 적재값이 16자리 hex 라 회원 테이블 어디와도 매칭되지 않는다(2026-08-07
- * 373,959행 실측). 그래서 화면의 '접속ID'({@link #userId})는 조인 결과가 아니라 적재 시점에
- * 세션 {@code LoginVO.getId()} 를 직접 남긴 {@code CONECT_ACNT_ID} 값이다.
- * 2026-08-07 이전 기록은 이 값이 없어 화면이 {@link #conectId} 를 대신 보여준다.
statement 는 {@code PrivacyConectLog_SQL_Mysql.xml}
- * (namespace {@code ConectLogDAO})에 함께 정의되어 있다.
+ * (namespace {@code ConectLogDAO})에 정의되어 있다. 2026-08-11 로그인·로그아웃 접속기록
+ * 조회화면이 삭제되면서 그 파일에 남은 statement 는 이것 하나뿐이지만, statement id 를
+ * 바꾸면 iBatis2 는 기동 시가 아니라 최초 호출 시점에 실패하고 {@link IpLocationResolver}
+ * 가 그 예외를 삼켜 CONECT_LC 가 조용히 전부 NULL 이 된다. namespace 를 그대로 둔다.
*
* @author URP
* @since 2026-08-07
diff --git a/src/main/java/egovframework/com/cmm/privacy/crypto/MemberNmCryptoController.java b/src/main/java/egovframework/com/cmm/privacy/crypto/MemberNmCryptoController.java
new file mode 100644
index 0000000..54da7fc
--- /dev/null
+++ b/src/main/java/egovframework/com/cmm/privacy/crypto/MemberNmCryptoController.java
@@ -0,0 +1,181 @@
+package egovframework.com.cmm.privacy.crypto;
+
+import java.util.Map;
+
+import javax.annotation.Resource;
+import javax.servlet.http.HttpServletRequest;
+
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseBody;
+
+import egovframework.com.cmm.LoginVO;
+import egovframework.com.cmm.util.EgovUserDetailsHelper;
+
+/**
+ * 회원 이름 암호화 백필(Action4) 운영 반영 트리거 — 관리자 전용, 1회성 이관 도구.
+ *
+ *
왜 컨트롤러가 필요한가: {@link MemberNmCryptoWorker}는 이미
+ * {@code @Component("memberNmCryptoWorker")}로 빈에 올라가 있지만 시작·조회·중지를
+ * 호출할 진입점이 없었다(로컬 백필은 과거 세션에서 임시 경로로 1회 실행된 것으로
+ * 추정 — 운영에는 재사용 불가). 상주 스케줄러에 넣지 않기로 한 설계(Worker Javadoc
+ * 참조)와 일관되게, 관리자가 명시적으로 시작·중지하는 이 화면 없는 API 엔드포인트로
+ * 대체한다.
+ *
+ *
접근통제: URL이 {@code /mnt/sec/} 대역이라 별도 설정 없이 기존 인터셉터
+ * 3종이 그대로 적용된다 — AuthorityInterceptor(ROLE_ADMIN, urlRoleMapping
+ * {@code \A/mnt/sec/.*\Z}) · SecondFactorInterceptor(2차 인증) ·
+ * MenuAccessLogInterceptor(includePatterns {@code \A/mnt/(sec|privacy)/.*\Z} — 실행
+ * 이력이 접속기록에 남는다). {@link #handlerUser()}는 그 위에 얹는 방어 계층이다
+ * (다른 /mnt/sec 개인정보 화면과 동일 규약).
+ *
+ *
메뉴에 올리지 않는다. 1회성 이관 도구를 상시 네비게이션에 노출하면
+ * 이관 종료 후에도 실수로 재클릭될 여지가 남는다. URL을 아는 관리자만
+ * 호출한다(운영 반영 절차 문서에 URL 기록).
+ *
+ *
재실행 안전: {@link MemberNmCryptoWorker#start}는 {@code MBER_NM_ENC IS NULL}
+ * 조건으로 대상을 고르므로, 이미 끝난 뒤 재시작해도 대상 0건으로 즉시 종료된다.
+ *
+ *
+ * << 개정이력(Modification Information) >>
+ * 수정일 수정자 수정내용
+ * ---------- ------ ---------------------------
+ * 2026.08.11 URP 최초 생성 — 운영 컷오버 델타 갭 해소(회원 이름 백필 트리거)
+ *
+ */
+@Controller
+public class MemberNmCryptoController {
+
+ @Resource(name = "memberNmCryptoWorker")
+ private MemberNmCryptoWorker worker;
+
+ /** 진행상태 조회. GET 허용(부작용 없음) — 화면 폴링용으로 쓸 수 있게. */
+ @RequestMapping(value = "/mnt/sec/memberNmCrypto/status.do")
+ @ResponseBody
+ public Map status() {
+ if (handlerUser() == null) {
+ return null;
+ }
+ Map progress = worker.getProgress();
+ if (progress != null) {
+ progress.put("running", Boolean.valueOf(worker.isRunning()));
+ }
+ return progress;
+ }
+
+ /**
+ * 백필 시작. 실수 클릭 방지를 위해 {@code confirm=BACKFILL} 파라미터를 강제한다.
+ * 이미 실행 중이면 무시(중복 시작 방지는 Worker 내부에서도 한 번 더 걸린다).
+ */
+ @RequestMapping(value = "/mnt/sec/memberNmCrypto/start.do")
+ @ResponseBody
+ public String start(@RequestParam(value = "confirm", required = false) String confirm,
+ @RequestParam(value = "chunkSize", required = false, defaultValue = "1000") int chunkSize,
+ @RequestParam(value = "sleepMs", required = false, defaultValue = "50") long sleepMs,
+ HttpServletRequest request) {
+ LoginVO user = handlerUser();
+ if (user == null) {
+ return "ERROR: 인증되지 않은 요청입니다.";
+ }
+ if (!isPost(request) || !isSameOrigin(request)) {
+ return "ERROR: 잘못된 요청 경로입니다.";
+ }
+ if (!"BACKFILL".equals(confirm)) {
+ return "ERROR: confirm=BACKFILL 파라미터 없이는 시작하지 않습니다(실수 방지).";
+ }
+ boolean started = worker.start(chunkSize, sleepMs);
+ return started
+ ? "OK: 백필을 시작했습니다(requester=" + user.getId() + "). /status.do 로 진행률을 확인하세요."
+ : "SKIP: 이미 실행 중입니다.";
+ }
+
+ /** 중지 요청. 진행 중인 청크는 마치고 멈춘다(커서 보존, 재시작 시 이어감). */
+ @RequestMapping(value = "/mnt/sec/memberNmCrypto/stop.do")
+ @ResponseBody
+ public String stop(HttpServletRequest request) {
+ if (handlerUser() == null) {
+ return "ERROR: 인증되지 않은 요청입니다.";
+ }
+ if (!isPost(request) || !isSameOrigin(request)) {
+ return "ERROR: 잘못된 요청 경로입니다.";
+ }
+ worker.requestStop();
+ return "OK: 중지를 요청했습니다. 진행 중인 청크가 끝나면 멈춥니다.";
+ }
+
+ /**
+ * 이관 검증(전건 복호화 대사). 77,053건 기준 수십 초~수 분 소요 — 요청 스레드를
+ * 그만큼 점유한다. 1회성 도구라 비동기화하지 않았다(뷰 컷오버 직전 1회 호출 용도).
+ */
+ @RequestMapping(value = "/mnt/sec/memberNmCrypto/verify.do")
+ @ResponseBody
+ public String verify(@RequestParam(value = "chunkSize", required = false, defaultValue = "1000") int chunkSize,
+ HttpServletRequest request) {
+ if (handlerUser() == null) {
+ return "ERROR: 인증되지 않은 요청입니다.";
+ }
+ if (!isPost(request) || !isSameOrigin(request)) {
+ return "ERROR: 잘못된 요청 경로입니다.";
+ }
+ if (worker.isRunning()) {
+ return "ERROR: 백필이 아직 실행 중입니다 — 완료 후 검증하세요.";
+ }
+ return worker.verify(chunkSize);
+ }
+
+ private boolean isPost(HttpServletRequest request) {
+ return "POST".equalsIgnoreCase(request.getMethod());
+ }
+
+ /**
+ * 취급자 영역(/mnt) 인증·구분 판정(다른 /mnt/sec 개인정보 화면과 동일 규약).
+ *
+ * @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;
+ }
+
+ 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;
+ }
+}
diff --git a/src/main/java/egovframework/com/cmm/privacy/interceptor/SessionPolicyInterceptor.java b/src/main/java/egovframework/com/cmm/privacy/interceptor/SessionPolicyInterceptor.java
index 5923a1e..0d1e77c 100644
--- a/src/main/java/egovframework/com/cmm/privacy/interceptor/SessionPolicyInterceptor.java
+++ b/src/main/java/egovframework/com/cmm/privacy/interceptor/SessionPolicyInterceptor.java
@@ -52,6 +52,16 @@ public class SessionPolicyInterceptor extends HandlerInterceptorAdapter {
/** 로그인 시각(ms) 세션 키. top.jsp 경과시간 표시가 참조한다. */
public static final String ATTR_LOGIN_TIME = "PRIVACY_LOGIN_TIME";
+ /**
+ * 로그인 시각을 찍을 당시의 계정 ID 세션 키.
+ *
+ *
시각만 들고 있으면 다른 계정으로 갈아탄 경우를 구분하지 못한다.
+ * 수동 로그아웃({@code actionLogout})은 세션을 무효화하지 않고 {@code loginVO} 만 null 로
+ * 바꾸므로 이 세션 속성들이 그대로 살아남기 때문이다. 계정 ID 를 함께 들고 있다가
+ * 달라지면 새 로그인으로 보고 시각을 다시 찍는다.
+ */
+ public static final String ATTR_LOGIN_ID = "PRIVACY_LOGIN_ACNT_ID";
+
/** 적용 중인 자동 로그아웃 시간(초) 세션 키. top.jsp 카운트다운이 참조한다. */
public static final String ATTR_AUTO_LOGOUT_SEC = "PRIVACY_AUTO_LOGOUT_SEC";
@@ -88,13 +98,25 @@ public class SessionPolicyInterceptor extends HandlerInterceptorAdapter {
// 세션을 새로 만들지 않는다 — 무인증 요청까지 세션을 생성하면 불필요한 부하가 된다.
HttpSession session = request.getSession(false);
- if (session == null || session.getAttribute(ATTR_LOGIN_VO) == null) {
+ if (session == null) {
return true;
}
- // 로그인 시각 도장 — 로그인 후 최초 1회만.
- if (session.getAttribute(ATTR_LOGIN_TIME) == null) {
- session.setAttribute(ATTR_LOGIN_TIME, Long.valueOf(System.currentTimeMillis()));
+ Object loginVO = session.getAttribute(ATTR_LOGIN_VO);
+ if (loginVO == null) {
+ // 로그아웃 상태 — 남아 있는 로그인 시각 도장을 지운다. 안 지우면 같은 세션으로
+ // 다시 로그인했을 때 '직전 로그인 시각' 기준으로 경과시간이 계산돼
+ // 로그인 직후에 "1시간 N분 경과" 같은 값이 뜬다.
+ clearLoginStamp(session);
+ return true;
+ }
+
+ // 로그인 시각 도장 — 계정이 바뀌었거나 도장이 없으면 새로 찍는다.
+ String acntId = accountIdOf(loginVO);
+ Object stampedAt = session.getAttribute(ATTR_LOGIN_TIME);
+ Object stampedId = session.getAttribute(ATTR_LOGIN_ID);
+ if (stampedAt == null || stampedId == null || !stampedId.equals(acntId)) {
+ stampLogin(session, acntId);
}
// 자동 로그아웃 시간 적용(정책값). 미주입 시 web.xml 기본값 유지.
@@ -119,6 +141,45 @@ public class SessionPolicyInterceptor extends HandlerInterceptorAdapter {
return true;
}
+ /**
+ * 로그인 시각 도장을 찍는다(지금 시각 기준).
+ *
+ *
인증 성공 지점({@code EgovLoginController.actionLogin})에서 직접 호출하면
+ * 인터셉터를 기다리지 않고 정확한 로그인 시점이 기록된다.
+ *
+ * @param session 로그인 세션
+ * @param acntId 로그인 계정 ID(널 허용)
+ */
+ public static void stampLogin(HttpSession session, String acntId) {
+ if (session == null) {
+ return;
+ }
+ session.setAttribute(ATTR_LOGIN_TIME, Long.valueOf(System.currentTimeMillis()));
+ session.setAttribute(ATTR_LOGIN_ID, acntId == null ? "" : acntId);
+ }
+
+ /**
+ * 로그인 시각 도장을 지운다. 로그아웃 지점에서 호출한다.
+ *
+ * @param session 세션(널이면 무시)
+ */
+ public static void clearLoginStamp(HttpSession session) {
+ if (session == null) {
+ return;
+ }
+ session.removeAttribute(ATTR_LOGIN_TIME);
+ session.removeAttribute(ATTR_LOGIN_ID);
+ }
+
+ /** 세션의 loginVO 에서 계정 ID 를 꺼낸다. 타입이 달라도 깨지지 않게 리플렉션 없이 방어적으로 처리. */
+ private static String accountIdOf(Object loginVO) {
+ if (loginVO instanceof egovframework.com.cmm.LoginVO) {
+ String id = ((egovframework.com.cmm.LoginVO) loginVO).getId();
+ return id == null ? "" : id;
+ }
+ return "";
+ }
+
/**
* 로그인 후 경과 시간(초). 도장이 없으면 세션 생성시각을 대신 쓴다.
*
diff --git a/src/main/resources/egovframework/spring/com/context-syslogaop.xml b/src/main/resources/egovframework/spring/com/context-syslogaop.xml
index a31d38b..113be19 100644
--- a/src/main/resources/egovframework/spring/com/context-syslogaop.xml
+++ b/src/main/resources/egovframework/spring/com/context-syslogaop.xml
@@ -25,67 +25,25 @@
-->
-
-
\ No newline at end of file
diff --git a/src/main/resources/egovframework/sqlmap/config/mysql/sql-map-config-mysql-0-site.xml b/src/main/resources/egovframework/sqlmap/config/mysql/sql-map-config-mysql-0-site.xml
index 6f9d77e..7ed2e38 100644
--- a/src/main/resources/egovframework/sqlmap/config/mysql/sql-map-config-mysql-0-site.xml
+++ b/src/main/resources/egovframework/sqlmap/config/mysql/sql-map-config-mysql-0-site.xml
@@ -112,9 +112,6 @@
-
-
-
@@ -139,7 +136,9 @@
-
+
diff --git a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorHistDownloadJob_SQL_Mysql.xml b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorHistDownloadJob_SQL_Mysql.xml
index 974bace..6164e12 100644
--- a/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorHistDownloadJob_SQL_Mysql.xml
+++ b/src/main/resources/egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAuthorHistDownloadJob_SQL_Mysql.xml
@@ -62,6 +62,12 @@
+