Compare commits

...

3 Commits

Author SHA1 Message Date
infraurp
8257820b57 개인정보보호 기능개선 7차 — 업무유형 매핑·로그인 접속기록 화면 폐기, 이름복호화 백필 컨트롤러 추가
Some checks failed
deploy-wise / deploy (push) Failing after 21s
관리자 메뉴 9100045(업무유형 매핑 관리)·9100046(로그인 접속기록) 두 화면을
폐기하고 관련 5계층·sqlMap·JSP를 제거했다. 접속기록은 PERSONAL_DATA_ACCESS_LOG
경로로 일원화한다.

- JobMapping 5계층(Controller/DAO/Service/Impl/Resolver/SearchVO)과
  ConectLog 5계층, PersonalDataLogAspect 제거
- PrivacyJobMapping_SQL_{Mysql,Oracle}.xml 제거 + sql-map-config 등록 라인 삭제
- left.jsp의 9100045/9100046 분기와 상위 하이라이트 조건 제거
- 메뉴 폐기 DML 신설(권한매핑→메뉴→프로그램 순, 멱등) — 종전 등록 DML 2건 대체.
  JSP와 반드시 동반 배포해야 URL 직접 접근 경로가 남지 않는다
- privacy.install_mysql.sql에 PART 4(뷰 GNR 컷오버) 반영, 메뉴 번호 갱신
- MemberNmCryptoController 신설 — 회원 이름 암호화 백필 트리거
- SessionPolicyInterceptor에 stampLogin/clearLoginStamp 추가
  (EgovLoginController가 참조 — 이게 없으면 컴파일되지 않는다)
- pom: mysql-connector를 5.1.17로 고정(8.0.20 비활성화). 배포 환경의
  WEB-INF/lib 실측값과 일치시켜 WAR 배포 시 드라이버가 바뀌는 지뢰를 제거

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 15:19:41 +09:00
infraurp
28a0329114 MariaDB sql_mode에서 ONLY_FULL_GROUP_BY 제거
원본 MySQL 8과 동작을 맞추려고 이 모드를 켰던 것이 역효과였다. MySQL 8은
GROUP BY 컬럼에 대한 함수 종속성을 판정해 집계에 없는 컬럼도 통과시키지만
MariaDB는 그 판정을 하지 않는다. 같은 sql_mode를 그대로 옮기면 로컬에서
잘 돌던 쿼리가 서버에서만 터진다.

증상: 관리자 2차인증 로그인 직후 /mnt/main.do 가 404. 실제로는 대시보드
쿼리가 'Z.STATUS1' isn't in GROUP BY 로 실패하고 예외 처리가 404로 이어진 것.

ONLY_FULL_GROUP_BY 제외는 MariaDB의 기본값이기도 하다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 15:11:19 +09:00
infraurp
6c9d88aa2e csv.wise.ai.kr vhost에 압축·정적 캐시 추가
메인 화면 하나가 woff 폰트만 3MB 넘게 끌어오는데 압축도 캐시 헤더도 없었다.
유선망에서는 티가 안 나지만(같은 폰트 0.15초) 테더링에서는 25초가 걸려
브라우저가 로딩을 끝내지 못하고 "접속 안 됨"처럼 보인다.

- mod_deflate: HTML 52,665B → 9,516B (82% 감소)
- 폰트 1년 immutable, 이미지 7일, css/js 1시간 캐시 — 재방문 시 3MB 재다운로드 제거

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 11:42:47 +09:00
45 changed files with 912 additions and 3256 deletions

View File

@ -49,6 +49,30 @@
# 프록시를 한 단 거치므로 원본 IP 를 헤더로 전달한다. # 프록시를 한 단 거치므로 원본 IP 를 헤더로 전달한다.
RequestHeader set X-Forwarded-Proto "https" RequestHeader set X-Forwarded-Proto "https"
# ── 느린 회선 대응 ───────────────────────────────────────────────────
# 이 사이트는 메인 화면 하나에 woff 폰트만 3MB 넘게 끌어온다. 사내망에서는
# 티가 안 나지만 테더링·모바일에서는 로딩이 끝나지 않아 "접속 안 됨"으로 보인다.
# (실측: 같은 폰트가 유선망 0.15초 / 테더링 25초)
# ① 텍스트 자원 압축. HTML 52KB → 약 12KB.
# woff/woff2·이미지는 이미 압축돼 있어 넣지 않는다(CPU만 쓰고 안 줄어든다).
AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css application/javascript application/x-javascript application/json application/xml
# 압축 여부가 캐시 키에 반영되도록
Header append Vary Accept-Encoding
# ② 정적 자원 캐시. 원래는 매 방문마다 3MB 를 다시 받았다.
# 폰트는 파일명이 바뀌지 않으므로 길게, css/js 는 배포마다 바뀌므로 짧게 잡는다.
<LocationMatch "\.(woff2?|ttf|eot|otf)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</LocationMatch>
<LocationMatch "\.(png|jpe?g|gif|ico|svg|webp)$">
Header set Cache-Control "public, max-age=604800"
</LocationMatch>
<LocationMatch "\.(css|js)$">
Header set Cache-Control "public, max-age=3600"
</LocationMatch>
ProxyPass /.well-known/acme-challenge/ ! ProxyPass /.well-known/acme-challenge/ !
ProxyPass / http://127.0.0.1:11060/ ProxyPass / http://127.0.0.1:11060/
ProxyPassReverse / http://127.0.0.1:11060/ ProxyPassReverse / http://127.0.0.1:11060/

View File

@ -21,8 +21,12 @@ table_open_cache = 400
tmp_table_size = 32M tmp_table_size = 32M
max_heap_table_size = 32M max_heap_table_size = 32M
# 원본 MySQL 8은 ONLY_FULL_GROUP_BY가 켜진 상태로 운영된다. 동작을 맞춰 둔다. # 원본 MySQL 8 은 ONLY_FULL_GROUP_BY 가 켜진 상태로 운영되지만, 여기서는 빼야 한다.
sql_mode = ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION # MySQL 8 은 GROUP BY 컬럼에 대한 '함수 종속성'을 판정해 집계에 없는 컬럼도 통과시키지만
# MariaDB 는 그 판정을 하지 않는다. 같은 sql_mode 를 그대로 옮기면 로컬에서 잘 돌던
# 쿼리가 여기서만 터진다 (2026-08-26 관리자 메인 대시보드: 'Z.STATUS1' isn't in GROUP BY).
# ONLY_FULL_GROUP_BY 제외는 MariaDB 의 기본값이기도 하다.
sql_mode = STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION
default_time_zone = '+09:00' default_time_zone = '+09:00'
slow_query_log = 1 slow_query_log = 1

29
pom.xml
View File

@ -551,19 +551,42 @@
</dependency> </dependency>
<!-- 2025.05.14 나혁제 드라이버 버전 수정 <!-- ============================================================
[2026.08.10] MySQL 드라이버를 5.1.17 로 되돌린다 (8.0.20 비활성화).
사유: 배포된 환경(운영·개발서버 ROOT)의 WEB-INF/lib 는 실측 결과 모두
mysql-connector-java-5.1.17 이다. 그런데 pom 만 8.0.20 이라 여기서 만든 WAR 에는
8.0.20 이 들어간다. WAR 재배포는 WEB-INF/lib 를 통째 교체하므로,
아무도 의도하지 않은 채 다음 배포에서 서버 드라이버가 갈아끼워진다.
그게 왜 위험한가: connector 8.x 는 서버 타임존 이름을 해석하지 못하면 연결 자체를
거부한다(The server time zone value '...' is unrecognized). 현재 접속 URL
(globals-live.properties)에는 serverTimezone 파라미터가 없고, 한글 로케일 서버는
@@system_time_zone 이 한글 문자열로 나온다. 즉 WAR 배포 직후 전면 DB 연결 실패가 된다.
=> 현행 서버 구성(5.1.17)과 pom 을 일치시켜 이 지뢰를 제거한다.
드라이버 업그레이드가 필요하면 URL 파라미터(serverTimezone 등) 정비와
개발서버 선검증을 동반한 '계획된 별건 작업'으로 진행할 것.
참고: 로컬 개발 PC 는 MySQL 8.0.46 이라 5.1.17 로는 tx_isolation 조회가 깨져
접속기록(COMTNLOGINLOG) 적재가 실패한다. 이는 로컬 환경 한정 문제이며
EgovLoginLogAspect 의 fail-safe 로 로그인/로그아웃이 막히지는 않는다.
로컬에서 접속기록까지 검증해야 하면 로컬 ROOT 의 jar 와 URL 만 따로 조정한다
(커밋 대상 아님).
============================================================ -->
<dependency> <dependency>
<groupId>mysql</groupId> <groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId> <artifactId>mysql-connector-java</artifactId>
<version>5.1.17</version> <version>5.1.17</version>
</dependency> </dependency>
-->
<!-- 2025.05.14 나혁제 드라이버 버전 수정 (8.0.20) - 위 사유로 2026.08.10 비활성화
<dependency> <dependency>
<groupId>mysql</groupId> <groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId> <artifactId>mysql-connector-java</artifactId>
<version>8.0.20</version> <version>8.0.20</version>
</dependency> </dependency>
-->
<dependency> <dependency>

View File

@ -19,7 +19,7 @@ import org.springframework.stereotype.Component;
* 개인정보 접속기록 비동기 적재 큐 (개발항목 #33, SFR-004 / DBR-001). * 개인정보 접속기록 비동기 적재 큐 (개발항목 #33, SFR-004 / DBR-001).
* *
* <p><b>목적:</b> 접속기록 적재(직전 해시 조회 → 봉인 → INSERT)를 업무 트랜잭션과 분리해 * <p><b>목적:</b> 접속기록 적재(직전 해시 조회 → 봉인 → INSERT)를 업무 트랜잭션과 분리해
* 개인정보 처리 서비스의 응답시간에 영향을 주지 않게 한다({@link PersonalDataLogAspect}는 * 개인정보 처리 서비스의 응답시간에 영향을 주지 않게 한다(수집기는
* {@link #enqueue}만 호출하고 즉시 반환).</p> * {@link #enqueue}만 호출하고 즉시 반환).</p>
* *
* <p><b>순번·해시체인 무결성(#39):</b> 컨슈머는 <b>단일 스레드</b>다. 큐에서 하나씩 꺼내 * <p><b>순번·해시체인 무결성(#39):</b> 컨슈머는 <b>단일 스레드</b>다. 큐에서 하나씩 꺼내

View File

@ -1,267 +0,0 @@
package egovframework.com.cmm.privacy.auditlog;
import java.net.URLEncoder;
import java.util.List;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.validation.BindingResult;
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.util.EgovUserDetailsHelper;
import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo;
/**
* 업무유형 매핑 관리 컨트롤러 — 개인정보보호 기능개선 사업(SFR-004 / SER-002 / #32-1).
*
* <p>{@code PDAL_JOB_MAPPING}(#32) 을 화면에서 등록·수정·사용중지·삭제한다. 이 매핑은
* 접속기록 수집 Aspect(#31)가 (서비스 클래스, 메서드)를 업무코드·업무명으로 분류하는 기준이며,
* 접속기록 조회 화면(#47)의 '수행업무' 검색 드롭다운 소스이기도 하다.</p>
*
* <p><b>화면 경로</b> {@code /WEB-INF/jsp/site/mnt/sec/jobMapping/list.jsp}, {@code regist.jsp}</p>
*
* <p><b>수집 범위와의 관계(오해 주의):</b> 매핑을 지우거나 사용중지해도 <u>수집이 멈추지는 않는다</u>.
* 수집 대상은 {@code context-syslogaop.xml} 의 pointcut 이 정하고, 이 테이블은 분류만 담당한다.
* 매핑이 없으면 메서드명으로 유도한 기본 분류로 적재된다.</p>
*
* <p><b>접근통제:</b> 인터셉터 3단 중 ①인증판정이 비활성이고 ②③은 fail-open 이라
* /mnt 하위라도 무인증 노출이 가능하므로, {@link #handlerUser()} 로 화면·엔드포인트
* 단에서 직접 판정한다(PersonalDataAccessLogController 와 동일 방식).</p>
*
* <pre>
* << 개정이력(Modification Information) >>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.08.07 URP 최초 생성 (개인정보보호 SFR-004 #32-1)
* </pre>
*/
@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<JobMappingVO> 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 이면 등록, 그 외는 수정.
*
* <p>{@code BindingResult} 를 반드시 받는다 — 숫자 필드(우선순위)를 비우거나 문자로 채우면
* 바인딩이 실패하는데, 이 파라미터가 없으면 Spring 이 BindException 을 던져 500 오류 화면이 뜬다.
* 여기서 받아 사용자 메시지로 되돌린다.</p>
*/
@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;
}
}

View File

@ -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.
*
* <p>문화품앗이 개인정보보호 기능개선 사업 - 개발항목 #32.</p>
*
* @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<JobMappingVO> selectJobMappingList() {
return list("JobMappingDAO.selectJobMappingList", null);
}
// =================================================================
// 관리 화면(#32-1) CRUD — 위 selectJobMappingList 와 달리 USE_AT='N' 행도 대상
// =================================================================
/** 관리 목록 조회(검색 + 페이징). 사용중지(N) 행도 포함한다. */
@SuppressWarnings("unchecked")
public List<JobMappingVO> 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));
}
}

View File

@ -1,53 +0,0 @@
package egovframework.com.cmm.privacy.auditlog;
import java.util.List;
/**
* 업무유형 매핑(PDAL_JOB_MAPPING) 관리 서비스 — 개발항목 #32-1.
*
* <p>#32 는 테이블·로더까지만 구현되어 매핑 행을 DB 직접 INSERT 로만 운영했다.
* 본 서비스는 취급자가 화면에서 매핑을 등록·수정·사용중지·삭제할 수 있게 한다.
* 매핑이 바뀌면 {@link JobMappingResolver} 캐시를 즉시 무효화하므로 WAS 재기동이 불요하다.</p>
*
* @author URP
* @since 2026-08-07
*/
public interface JobMappingManageService {
/** 관리 목록 조회(검색 + 페이징). searchVO 에 firstIndex/totalRecordCount 를 세팅한다. */
List<JobMappingVO> selectJobMappingManageList(JobMappingSearchVO searchVO) throws Exception;
/** 매핑 단건 조회(수정 폼). 없으면 null. */
JobMappingVO selectJobMapping(long mappingId) throws Exception;
/**
* 매핑 등록.
*
* @throws IllegalArgumentException 입력값 위반 또는 (대상클래스, 메서드패턴) 중복
*/
void insertJobMapping(JobMappingVO vo, String actorId) throws Exception;
/**
* 매핑 수정.
*
* @throws IllegalArgumentException 입력값 위반 / 중복 / 대상 매핑 없음
*/
void updateJobMapping(JobMappingVO vo, String actorId) throws Exception;
/**
* 사용여부 전환(Y↔N). 삭제 대신 권장하는 비활성화 경로.
*
* @throws IllegalArgumentException 대상 매핑 없음 또는 useAt 값 오류
*/
void updateJobMappingUseAt(long mappingId, String useAt, String actorId) throws Exception;
/**
* 매핑 삭제(행 제거).
*
* @throws IllegalArgumentException 대상 매핑 없음
*/
void deleteJobMapping(long mappingId, String actorId) throws Exception;
/** Resolver 캐시 강제 재적재(DB 직접 수정분 반영용). */
void refreshCache() throws Exception;
}

View File

@ -1,181 +0,0 @@
package egovframework.com.cmm.privacy.auditlog;
import java.util.List;
import java.util.regex.Pattern;
import javax.annotation.Resource;
import org.springframework.stereotype.Service;
/**
* 업무유형 매핑(PDAL_JOB_MAPPING) 관리 서비스 구현 — 개발항목 #32-1.
*
* <p>클래스명이 {@code *Impl} 이고 패키지가 {@code egovframework.com..} 이므로
* {@code context-transaction.xml} 의 requiredTx pointcut 에 걸려 메서드 단위 트랜잭션이 적용된다
* (DB 반영 + 캐시 무효화가 함께 처리된다).</p>
*
* <p><b>캐시 무효화 범위:</b> {@link JobMappingResolver#refresh()} 는 <u>해당 JVM 의 캐시만</u>
* 비운다. arko 는 단일 Tomcat ROOT 전개라 현재는 충분하나, 다중 인스턴스로 확장하면
* 인스턴스별 재적재 수단(관리 화면의 '캐시 재적재' 버튼을 각 노드에서 호출 등)이 필요하다.</p>
*
* <p><b>입력 검증:</b> TARGET_CLASS/METHOD_PTTRN 은 Aspect 가 (클래스 단순명, 메서드명)으로
* 매칭하는 값이라 자바 식별자 형식만 허용한다. 형식을 열어두면 어떤 값도 매칭되지 않는
* 죽은 매핑이 쌓여 접속기록 분류가 조용히 폴백 처리된다.</p>
*
* @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<JobMappingVO> 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;
}
}

View File

@ -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).
*
* <p>PDAL_JOB_MAPPING 테이블을 메모리에 캐시하고, 접속기록 Aspect(#31)가 가로챈
* (대상클래스, 메서드명) 조합을 업무코드/업무명으로 해석한다.</p>
*
* <p>매칭 규칙: USE_AT='Y' 매핑을 SORT_ORDR 오름차순으로 순회하여, 대상 클래스가
* 일치(단순명 또는 FQN)하고 메서드명이 METHOD_PTTRN으로 시작(또는 '*')하는 첫 매핑을 반환.
* 구체 규칙(낮은 SORT_ORDR)이 폴백('*', 높은 SORT_ORDR)보다 우선한다.</p>
*
* @author arko-privacy-auditlog (Fork B)
* @since 2026-07-21
*/
@Component("jobMappingResolver")
public class JobMappingResolver {
@Resource(name = "jobMappingDAO")
private JobMappingDAO jobMappingDAO;
/** 캐시(불변 스냅샷 참조 교체 방식). */
private volatile List<JobMappingVO> cache;
/**
* (대상클래스 단순명, 메서드명)에 매칭되는 업무유형 매핑을 반환한다.
* 매칭이 없으면 null (호출측이 메서드명 기반 기본값으로 폴백).
*/
public JobMappingVO resolve(String targetSimpleClassName, String methodName) {
if (targetSimpleClassName == null || methodName == null) {
return null;
}
List<JobMappingVO> 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<JobMappingVO> getCache() {
List<JobMappingVO> local = this.cache;
if (local == null) {
synchronized (this) {
if (this.cache == null) {
List<JobMappingVO> loaded = null;
try {
loaded = jobMappingDAO.selectJobMappingList();
} catch (Exception e) {
// 매핑 로드 실패는 접속기록 수집 자체를 막지 않는다(메서드명 폴백으로 동작).
loaded = null;
}
this.cache = (loaded == null) ? new ArrayList<JobMappingVO>() : 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";
}
}

View File

@ -1,61 +0,0 @@
package egovframework.com.cmm.privacy.auditlog;
import java.io.Serializable;
/**
* 업무유형 매핑(PDAL_JOB_MAPPING) 관리 화면 검색조건 VO — 개발항목 #32-1.
*
* <p>매핑 조회용 {@link JobMappingVO} 와 달리 <b>USE_AT='N' 행도 조회 대상</b>이다.
* 관리 화면에서는 사용중지된 매핑도 보여야 재사용·복구가 가능하기 때문이다
* ({@code JobMappingDAO.selectJobMappingList} 는 Resolver 캐시용이라 'Y'만 조회).</p>
*
* @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; }
}

View File

@ -3,11 +3,19 @@ package egovframework.com.cmm.privacy.auditlog;
import java.io.Serializable; import java.io.Serializable;
/** /**
* 개인정보 처리 업무유형 매핑(PDAL_JOB_MAPPING) VO. * 개인정보 처리 업무유형 매핑 VO.
* *
* <p>문화품앗이 개인정보보호 기능개선 사업 - 개발항목 #32 (SFR-004 / SER-002). * <p>문화품앗이 개인정보보호 기능개선 사업 - 개발항목 #32 (SFR-004 / SER-002).
* 접속기록 수집({@link PersonalDataLogAspect})이 가로챈 서비스 메서드를 * 본래 PDAL_JOB_MAPPING 한 행을 담아, 폐기된 서비스 AOP 수집이 가로챈 서비스 메서드를
* 업무코드/업무명으로 해석하는 매핑 한 행.</p> * 업무코드/업무명으로 해석하는 데 썼다.</p>
*
* <p><b>[2026-08-11] 업무유형 매핑 관리 화면(메뉴 9100045) 삭제 후에도 이 클래스는 존치한다</b> —
* 접속기록 조회화면(9100044)의 '수행업무' 드롭다운 statement
* {@code PersonalDataAccessLogDAO.selectJobSeCodeList}(PrivacyAccessLog_SQL_Mysql.xml /
* _Oracle.xml)가 {@code resultClass} 로 이 클래스를 재사용한다. 실제 데이터는
* PDAL_JOB_MAPPING 이 아니라 PERSONAL_DATA_ACCESS_LOG 에서 직접 뽑으며
* jobSeCode/jobSeNm 두 프로퍼티만 채워진다. 삭제하면 iBatis2 특성상 기동 시가 아니라
* 해당 드롭다운 최초 조회 시점에 resultClass 로드 실패로 터진다.</p>
* *
* @author arko-privacy-auditlog (Fork B) * @author arko-privacy-auditlog (Fork B)
* @since 2026-07-21 * @since 2026-07-21

View File

@ -55,6 +55,9 @@ public class PersonalDataAccessLogController {
private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do"; private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do";
/** 화면에 띄울 '내 다운로드 요청' 최대 건수(만료 전 기준). 폭주 방지용 상한이다. */
private static final int MY_JOB_MAX = 100;
@Resource(name = "personalDataAccessLogSearchService") @Resource(name = "personalDataAccessLogSearchService")
private PersonalDataAccessLogSearchService personalDataAccessLogSearchService; private PersonalDataAccessLogSearchService personalDataAccessLogSearchService;
@ -216,7 +219,8 @@ public class PersonalDataAccessLogController {
} }
StringBuilder sb = new StringBuilder(256); StringBuilder sb = new StringBuilder(256);
List<AccessLogDownloadJobVO> list = accessLogDownloadJobService.selectMyJobs(user.getId(), 10); // 목록과 같은 범위를 봐야 한다 — 다르면 화면에 떠 있는데 폴링이 갱신하지 않는 행이 생긴다.
List<AccessLogDownloadJobVO> list = accessLogDownloadJobService.selectMyJobs(user.getId(), MY_JOB_MAX);
if (list != null) { if (list != null) {
for (AccessLogDownloadJobVO j : list) { for (AccessLogDownloadJobVO j : list) {
sb.append(j.getJobId()).append('|') sb.append(j.getJobId()).append('|')
@ -243,14 +247,26 @@ public class PersonalDataAccessLogController {
return (v == null) ? "" : v; return (v == null) ? "" : v;
} }
/** 내 다운로드 작업 목록(최근 10건). 조회 실패해도 화면은 떠야 하므로 빈 목록으로 fail-safe. */ /**
* 내 다운로드 작업 목록 — <b>만료 전 이력만</b>(2026-08-10 정책).
*
* <p>종전에는 '최근 10건'이라 이미 파일이 파기된 행까지 남아, 화면상 '완료'인데 눌러도
* 내려받아지지 않는 상태가 생겼다. 만료 기준으로 거르면 목록에 보이는 건 전부 실제로
* 내려받을 수 있는 것들이다. <b>테이블 행은 그대로 둔다</b> — 누가 언제 왜 개인정보를
* 반출 요청했는지는 감사 증적이라 지우지 않고, 화면에서만 감춘다.</p>
*
* <p>{@link #MY_JOB_MAX} 는 보관 3일 기준으로 사실상 걸리지 않지만, 단기 대량 요청 시
* 화면이 통째로 길어지는 것을 막는 안전장치다.</p>
*
* <p>조회 실패해도 화면은 떠야 하므로 빈 목록으로 fail-safe.</p>
*/
private List<AccessLogDownloadJobVO> selectMyDownloadJobs() { private List<AccessLogDownloadJobVO> selectMyDownloadJobs() {
try { try {
LoginVO user = handlerUser(); LoginVO user = handlerUser();
if (user == null) { if (user == null) {
return new ArrayList<AccessLogDownloadJobVO>(); return new ArrayList<AccessLogDownloadJobVO>();
} }
List<AccessLogDownloadJobVO> list = accessLogDownloadJobService.selectMyJobs(user.getId(), 10); List<AccessLogDownloadJobVO> list = accessLogDownloadJobService.selectMyJobs(user.getId(), MY_JOB_MAX);
return (list == null) ? new ArrayList<AccessLogDownloadJobVO>() : list; return (list == null) ? new ArrayList<AccessLogDownloadJobVO>() : list;
} catch (Exception e) { } catch (Exception e) {
return new ArrayList<AccessLogDownloadJobVO>(); return new ArrayList<AccessLogDownloadJobVO>();

View File

@ -1,280 +0,0 @@
package egovframework.com.cmm.privacy.auditlog;
import java.lang.reflect.Method;
import java.text.SimpleDateFormat;
import java.util.Collection;
import java.util.Date;
import java.util.Map;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.aspectj.lang.ProceedingJoinPoint;
import org.springframework.stereotype.Component;
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;
/**
* 개인정보 접속기록 수집 Aspect (개발항목 #31, SFR-004 / DBR-001 / SER-002).
*
* <p><b>[2026-08-07 사용 중단]</b> 이 Aspect 는 더 이상 적용되지 않는다.
* {@code context-syslogaop.xml}의 {@code <aop:config>}가 주석 처리됐고, 접속기록 수집은
* {@code egovframework.com.cmm.privacy.interceptor.MenuAccessLogInterceptor}가 담당한다.
* <b>중단 사유:</b> 서비스 impl 을 가로채는 방식은 관리자 화면과 프론트 화면이 같은 서비스
* 메서드를 공유하는 구조에서 호출 화면을 구분하지 못한다. 그 결과 무인증 프론트 방문이
* 접속자ID {@code ANONYMOUS}, 수행업무 "재능나눔 회원관리"로 적재됐다. 요구사항(SFR-004)은
* "개인정보<u>취급자</u>가 무슨 업무를 처리했는지"이므로, 요청 URL 로 관리자 화면을 식별하고
* 메뉴명을 수행업무로 남기는 인터셉터 방식으로 전환했다.
* 클래스는 판단 근거·복원 경로 보존을 위해 남겨둔다(빈은 등록되나 어떤 pointcut 에도 걸리지 않음).</p>
*
* <p><b>한정 수집:</b> {@code COMTNSYSLOG}의 select* 전 서비스 대상 방식이 아니라,
* 개인정보 처리 서비스로 한정한 pointcut(스키마 기반 {@code <aop:config>}, context-syslogaop.xml)에만
* 적용된다. 가로챈 메서드는 업무유형 매핑 테이블({@link JobMappingResolver}, #32)로 업무코드를 얻고,
* 접속 IP({@link ClientIpResolver}, #34)와 해시체인({@link HashChainService}, #39)을 결합해
* {@code PERSONAL_DATA_ACCESS_LOG}에 적재한다.</p>
*
* <p><b>본문 미적재 원칙:</b> 처리 파라미터(개인정보 본문)는 로그에 담지 않는다. 식별자(TRGET_ID)만
* 베스트에포트로 취득한다(#36).</p>
*
* <p><b>로그인 세션 한정(2026-08-07):</b> 세션에 {@code loginVO}가 없는 요청은 <b>수집하지 않는다</b>.
* 종전에는 접속자ID를 {@code ANONYMOUS}로 적재했다. 근거는 두 가지다.
* ① 제안요청서(붙임2) SFR-004 정의가 "개인정보<u>취급자</u> 접속 및 업무처리 기록 관리"이고
* 세부내용 첫 항목도 "개인정보취급자의 접속기록을 저장·관리"로, 비로그인 상태(정보주체 본인의
* 가입·실명확인 등)는 대상이 아니다(SER-002·안전성 확보조치 기준 제8조도 동일 기준).
* ② 비정상접근 탐지(#35)가 {@code GROUP BY CONECT_ID} 기반이라, 모든 익명 요청이
* {@code ANONYMOUS} 한 계정으로 뭉쳐 계정공유의심(상이 IP 다수)·대량조회에 상시 오탐을 만든다.
* 제외되는 대표 경로: 회원가입({@code insert*}), 실명확인({@code MberSsnServiceImpl}),
* 휴면해제, 계정잠금 해제, 인증번호 발송. 이 경로들의 추적은 인증 실패 이력(#22)이 담당한다.</p>
*
* <p><b>수집 범위(#36):</b> 회원·업체 2개 지점 한정에서 전 개인정보 처리 서비스로 확장.
* pointcut 대상은 {@code context-syslogaop.xml}에 개인정보 취급 서비스 impl만 열거(과수집 방지).
* 신규 화면 추가 시에는 이 pointcut(또는 대상 서비스에 편입)과 업무유형 매핑({@link JobMappingResolver})만
* 갱신하면 되고 컨트롤러 수동 호출은 불요하다.</p>
*
* <p><b>TRGET_ID 취득 규약(#36):</b> 가로챈 메서드 인자에서 <u>식별자만</u> 베스트에포트로 추출한다.
* ① 인자가 VO/도메인 빈이면 우선순위 getter({@code getEsntlId→getUniqId→getMberId→getEmplyrId→
* getEntrprsId→getGroupId→getUserId→getId→getSn}) 중 첫 non-blank 값. ② 인자가 {@code Map}이면
* 동일 우선순위 키. ③ 원시 {@code String}/{@code Number} 인자는 식별자·검색어 구분이 불가하므로
* <b>스킵</b>(검색어·개인정보 본문 오적재 방지). 어느 것도 못 얻으면 {@code null}(fail-safe).
* 처리대상 건수(TRGET_CN)는 반환값이 {@code Collection}이면 "N건"으로 기록한다.</p>
*
* <p>스키마 기반 AOP(@AspectJ 미사용)라 {@code aspectj-autoproxy}를 전역 활성화하지 않는다 —
* 기존 프로젝트 프록시 동작에 영향을 주지 않기 위함.</p>
*
* @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 <aop:around>} 대상 메서드.
* 업무는 정상 수행하고, 성공/예외 여부와 무관하게 접속기록을 남긴다(기록 실패는 업무에 영향 없음).
*/
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;
}
}

View File

@ -32,6 +32,13 @@ public class AuthorWorkflowController {
protected static final String JSON = "jsonView"; protected static final String JSON = "jsonView";
/**
* 화면에 띄울 '내 다운로드 요청' 최대 건수. 목록은 <b>만료 전 이력만</b> 보여주며
* (2026-08-10 정책, 접속기록 다운로드와 동일), 이 값은 단기 대량 요청 시 화면이
* 통째로 길어지는 것을 막는 상한이다. 테이블 행 자체는 감사 증적이라 그대로 둔다.
*/
private static final int MY_JOB_MAX = 100;
@Resource(name = "authorWorkflowService") @Resource(name = "authorWorkflowService")
private AuthorWorkflowService authorWorkflowService; private AuthorWorkflowService authorWorkflowService;
@ -201,7 +208,7 @@ public class AuthorWorkflowController {
} }
StringBuilder sb = new StringBuilder(256); StringBuilder sb = new StringBuilder(256);
List<AuthorHistDownloadJobVO> list = authorHistDownloadJobService.selectMyJobs(user.getId(), 10); List<AuthorHistDownloadJobVO> list = authorHistDownloadJobService.selectMyJobs(user.getId(), MY_JOB_MAX);
if (list != null) { if (list != null) {
for (AuthorHistDownloadJobVO j : list) { for (AuthorHistDownloadJobVO j : list) {
sb.append(j.getJobId()).append('|') sb.append(j.getJobId()).append('|')
@ -322,7 +329,7 @@ public class AuthorWorkflowController {
if (user == null) { if (user == null) {
return new java.util.ArrayList<AuthorHistDownloadJobVO>(); return new java.util.ArrayList<AuthorHistDownloadJobVO>();
} }
List<AuthorHistDownloadJobVO> list = authorHistDownloadJobService.selectMyJobs(user.getId(), 10); List<AuthorHistDownloadJobVO> list = authorHistDownloadJobService.selectMyJobs(user.getId(), MY_JOB_MAX);
return (list == null) ? new java.util.ArrayList<AuthorHistDownloadJobVO>() : list; return (list == null) ? new java.util.ArrayList<AuthorHistDownloadJobVO>() : list;
} catch (Exception e) { } catch (Exception e) {
return new java.util.ArrayList<AuthorHistDownloadJobVO>(); return new java.util.ArrayList<AuthorHistDownloadJobVO>();

View File

@ -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).
*
* <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;
}
}

View File

@ -1,52 +0,0 @@
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);
}
}

View File

@ -1,31 +0,0 @@
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;
}

View File

@ -1,122 +0,0 @@
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();
}
}

View File

@ -1,115 +0,0 @@
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; }
}

View File

@ -10,7 +10,10 @@ import egovframework.com.cmm.service.impl.EgovComAbstractDAO;
* 접속위치 매핑 DAO — 개인정보보호 기능개선 사업(#62). * 접속위치 매핑 DAO — 개인정보보호 기능개선 사업(#62).
* *
* <p>statement 는 {@code PrivacyConectLog_SQL_Mysql.xml} * <p>statement 는 {@code PrivacyConectLog_SQL_Mysql.xml}
* (namespace {@code ConectLogDAO})에 함께 정의되어 있다.</p> * (namespace {@code ConectLogDAO})에 정의되어 있다. 2026-08-11 로그인·로그아웃 접속기록
* 조회화면이 삭제되면서 그 파일에 남은 statement 는 이것 하나뿐이지만, statement id 를
* 바꾸면 iBatis2 는 기동 시가 아니라 최초 호출 시점에 실패하고 {@link IpLocationResolver}
* 가 그 예외를 삼켜 CONECT_LC 가 조용히 전부 NULL 이 된다. namespace 를 그대로 둔다.</p>
* *
* @author URP * @author URP
* @since 2026-08-07 * @since 2026-08-07

View File

@ -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회성 이관 도구.
*
* <p><b>왜 컨트롤러가 필요한가:</b> {@link MemberNmCryptoWorker}는 이미
* {@code @Component("memberNmCryptoWorker")}로 빈에 올라가 있지만 시작·조회·중지를
* 호출할 진입점이 없었다(로컬 백필은 과거 세션에서 임시 경로로 1회 실행된 것으로
* 추정 — 운영에는 재사용 불가). 상주 스케줄러에 넣지 않기로 한 설계(Worker Javadoc
* 참조)와 일관되게, 관리자가 명시적으로 시작·중지하는 이 화면 없는 API 엔드포인트로
* 대체한다.</p>
*
* <p><b>접근통제:</b> 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 개인정보 화면과 동일 규약).</p>
*
* <p><b>메뉴에 올리지 않는다.</b> 1회성 이관 도구를 상시 네비게이션에 노출하면
* 이관 종료 후에도 실수로 재클릭될 여지가 남는다. URL을 아는 관리자만
* 호출한다(운영 반영 절차 문서에 URL 기록).</p>
*
* <p><b>재실행 안전:</b> {@link MemberNmCryptoWorker#start}는 {@code MBER_NM_ENC IS NULL}
* 조건으로 대상을 고르므로, 이미 끝난 뒤 재시작해도 대상 0건으로 즉시 종료된다.</p>
*
* <pre>
* << 개정이력(Modification Information) >>
* 수정일 수정자 수정내용
* ---------- ------ ---------------------------
* 2026.08.11 URP 최초 생성 — 운영 컷오버 델타 갭 해소(회원 이름 백필 트리거)
* </pre>
*/
@Controller
public class MemberNmCryptoController {
@Resource(name = "memberNmCryptoWorker")
private MemberNmCryptoWorker worker;
/** 진행상태 조회. GET 허용(부작용 없음) — 화면 폴링용으로 쓸 수 있게. */
@RequestMapping(value = "/mnt/sec/memberNmCrypto/status.do")
@ResponseBody
public Map<String, Object> status() {
if (handlerUser() == null) {
return null;
}
Map<String, Object> 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;
}
}

View File

@ -52,6 +52,16 @@ public class SessionPolicyInterceptor extends HandlerInterceptorAdapter {
/** 로그인 시각(ms) 세션 키. top.jsp 경과시간 표시가 참조한다. */ /** 로그인 시각(ms) 세션 키. top.jsp 경과시간 표시가 참조한다. */
public static final String ATTR_LOGIN_TIME = "PRIVACY_LOGIN_TIME"; public static final String ATTR_LOGIN_TIME = "PRIVACY_LOGIN_TIME";
/**
* 로그인 시각을 찍을 당시의 계정 ID 세션 키.
*
* <p>시각만 들고 있으면 <b>다른 계정으로 갈아탄 경우</b>를 구분하지 못한다.
* 수동 로그아웃({@code actionLogout})은 세션을 무효화하지 않고 {@code loginVO} 만 null 로
* 바꾸므로 이 세션 속성들이 그대로 살아남기 때문이다. 계정 ID 를 함께 들고 있다가
* 달라지면 새 로그인으로 보고 시각을 다시 찍는다.</p>
*/
public static final String ATTR_LOGIN_ID = "PRIVACY_LOGIN_ACNT_ID";
/** 적용 중인 자동 로그아웃 시간(초) 세션 키. top.jsp 카운트다운이 참조한다. */ /** 적용 중인 자동 로그아웃 시간(초) 세션 키. top.jsp 카운트다운이 참조한다. */
public static final String ATTR_AUTO_LOGOUT_SEC = "PRIVACY_AUTO_LOGOUT_SEC"; 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); HttpSession session = request.getSession(false);
if (session == null || session.getAttribute(ATTR_LOGIN_VO) == null) { if (session == null) {
return true; return true;
} }
// 로그인 시각 도장 — 로그인 후 최초 1회만. Object loginVO = session.getAttribute(ATTR_LOGIN_VO);
if (session.getAttribute(ATTR_LOGIN_TIME) == null) { if (loginVO == null) {
session.setAttribute(ATTR_LOGIN_TIME, Long.valueOf(System.currentTimeMillis())); // 로그아웃 상태 — 남아 있는 로그인 시각 도장을 지운다. 안 지우면 같은 세션으로
// 다시 로그인했을 때 '직전 로그인 시각' 기준으로 경과시간이 계산돼
// 로그인 직후에 "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 기본값 유지. // 자동 로그아웃 시간 적용(정책값). 미주입 시 web.xml 기본값 유지.
@ -119,6 +141,45 @@ public class SessionPolicyInterceptor extends HandlerInterceptorAdapter {
return true; return true;
} }
/**
* 로그인 시각 도장을 찍는다(지금 시각 기준).
*
* <p>인증 성공 지점({@code EgovLoginController.actionLogin})에서 직접 호출하면
* 인터셉터를 기다리지 않고 정확한 로그인 시점이 기록된다.</p>
*
* @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 "";
}
/** /**
* 로그인 후 경과 시간(초). 도장이 없으면 세션 생성시각을 대신 쓴다. * 로그인 후 경과 시간(초). 도장이 없으면 세션 생성시각을 대신 쓴다.
* *

View File

@ -25,67 +25,25 @@
</aop:config> --> </aop:config> -->
<!-- ========================================================================= <!-- =========================================================================
[이력 / 2026-08-07 중단] 아래 설명과 <aop:config> 는 현재 비활성이다. [2026-08-07 중단 → 2026-08-11 삭제] 개인정보 접속기록의 서비스 AOP 수집(#31).
중단 사유와 대체 구현은 바로 다음 주석 블록 참조. 이 블록은 판단 근거 보존용으로 남긴다.
─────────────────────────────────────────────────────────────────────────
개인정보보호 기능개선 사업 (SFR-004 / DBR-001 / SER-002) — 접속기록 한정 수집(#31)
· 위 COMTNSYSLOG용 syslog AOP(전 서비스 select* 대상)는 주석 그대로 유지(활성화하지 않음).
· 아래 personalDataLogAspect 는 '개인정보 처리 서비스'로 한정한 별도 체계다.
· personalDataLogAspect / jobMappingResolver / personalDataLogService 는
component-scan(context-common.xml, base-package egovframework/kr.or.arko)으로 등록되므로
여기서는 ref 로만 참조한다(대상 서비스 빈도 동일 root 컨텍스트).
· 스키마 기반 <aop:config> 라 aspectj-autoproxy 를 전역 활성화하지 않는다.
· 대상 확장 시: 이 pointcut 에 개인정보 처리 서비스 impl 만 추가(과수집 R2 방지). 게시판/공지 등 무관 조회 제외.
· #36(2026-07-21): 회원·업체 2개 지점 → 전 개인정보 처리 서비스(16종)로 확장. TRGET_ID 취득 규약은 PersonalDataLogAspect Javadoc 참조.
[제외] main/search/data/event/dept/orgnztchart/team/stat/provol/rspofc/prbro/prmvp/midact/srv/sample/test/excel 등 개인정보 미취급 서비스.
· 2026-08-06 과수집 1건 제외: BizTalentMberManageServiceImpl.selectJoinTalentMberList*
이 메서드는 취급자 업무가 아니라 프론트 공개 목록(메인 LayoutFrtController.main, frt 재능나눔 목록)이
공개동의분(PUBLIC_YN='Y')만 노출하는 경로다. 무인증 방문 1회당 ANONYMOUS 로그 1건이
730일 보관 테이블에 쌓여(실측 41건) 정기점검·이상탐지 통계를 희석시키므로 대상에서 뺀다.
같은 클래스의 취급자 경로(selectTalentMber/selectTalentMberList/selectTalentMberSingle)는 계속 수집한다.
========================================================================= -->
<!-- =========================================================================
[2026-08-07] 서비스 AOP 접속기록 수집 중단 — 관리자 화면 메뉴 접속기록으로 전환.
아래 <aop:config> 는 서비스 impl 메서드를 가로채 접속기록을 남겼다. 그러나 서비스는 한때 이 파일에 personalDataLogAspect 용 <aop:config> 가 있었다. 서비스 impl 메서드를
관리자 화면과 프론트 화면이 함께 호출하므로 '어느 화면에서 불렀는지'를 구분할 수 없었고, 가로채 접속기록을 남기는 방식이었으나, 서비스는 관리자 화면과 프론트 화면이 함께
그 결과 무인증 프론트 방문이 접속자ID=ANONYMOUS, 수행업무="재능나눔 회원관리" 로 적재됐다 호출하므로 '어느 화면에서 불렀는지'를 구분할 수 없었다. 그 결과 무인증 프론트 방문이
(공개 재능나눔 상세 /frt/biz/talent/bizTalentView.do → selectTalentMber). 접속자ID=ANONYMOUS, 수행업무="재능나눔 회원관리" 로 적재됐다(공개 재능나눔 상세
selectJoinTalentMberList 를 pointcut 에서 빼는 식의 개별 예외로는 같은 문제가 계속 생긴다 — /frt/biz/talent/bizTalentView.do → selectTalentMber). 개별 메서드를 pointcut 에서
프론트/관리자가 공유하는 서비스 메서드가 그것 하나가 아니기 때문이다. 빼는 예외 처리로는 해결되지 않는다 — 프론트/관리자가 공유하는 서비스가 하나가 아니다.
대체 구현: egovframework.com.cmm.privacy.interceptor.MenuAccessLogInterceptor 대체 구현(현행): egovframework.com.cmm.privacy.interceptor.MenuAccessLogInterceptor
(등록: WEB-INF/config/egovframework/springmvc/egov-com-servlet.xml) (등록: WEB-INF/config/egovframework/springmvc/egov-com-servlet.xml)
요청 URL 로 관리자 화면만 골라내고, 기존 메뉴 테이블(COMTNMENUINFO)의 메뉴명을 요청 URL 로 관리자 화면만 골라내고 COMTNMENUINFO 의 메뉴명을 수행업무로 남긴다.
수행업무로 남긴다. 적재 경로(AccessLogQueue → 해시체인 → PERSONAL_DATA_ACCESS_LOG)는 동일하다. 적재 경로(AccessLogQueue → 해시체인 → PERSONAL_DATA_ACCESS_LOG)는 동일하다.
되돌리려면 아래 주석을 풀고 egov-com-servlet.xml 의 menuAccessLogInterceptor 등록을 제거할 것 2026-08-11 업무유형 매핑 관리 화면 삭제와 함께 PersonalDataLogAspect / JobMappingResolver
(둘을 동시에 켜면 한 요청이 두 경로로 중복 적재된다). 클래스를 제거했으므로, 주석 상태로 남아 있던 <aop:config> 블록도 함께 지웠다.
(ref="personalDataLogAspect" 를 남겨두면 나중에 주석을 잘못 풀었을 때 기동 실패한다)
위 COMTNSYSLOG 용 syslog AOP(전 서비스 select* 대상)는 종전대로 주석 유지 — 활성화하지 않는다.
========================================================================= --> ========================================================================= -->
<!--
<aop:config>
<aop:aspect id="personalDataLogAspect" ref="personalDataLogAspect">
<aop:pointcut id="personalDataProcessPointcut"
expression="execution(* egovframework.com.uss.umt.service.impl.EgovMberManageServiceImpl.*(..))
or execution(* egovframework.com.uss.umt.service.impl.EgovEntrprsManageServiceImpl.*(..))
or execution(* kr.or.arko.frt.biz.ssn.service.impl.MberSsnServiceImpl.*(..))
or execution(* kr.or.arko.frt.biz.merge.service.impl.BizMberMergeServiceImpl.*(..))
or execution(* egovframework.com.uss.umt.service.impl.EgovUserManageServiceImpl.*(..))
or execution(* egovframework.com.uss.umt.service.impl.EgovDiapauseManageServiceImpl.*(..))
or execution(* egovframework.com.uss.umt.service.impl.EgovAccountUnlockManageServiceImpl.*(..))
or execution(* kr.or.arko.frt.biz.note.service.impl.BizNoteServiceImpl.*(..))
or execution(* kr.or.arko.frt.biz.sms.service.impl.BizSmsServiceImpl.*(..))
or (execution(* kr.or.arko.mnt.biz.talent.service.impl.BizTalentMberManageServiceImpl.*(..))
and not execution(* kr.or.arko.mnt.biz.talent.service.impl.BizTalentMberManageServiceImpl.selectJoinTalentMberList*(..)))
or execution(* kr.or.arko.mnt.biz.proapp.service.impl.BizProappServiceImpl.*(..))
or execution(* kr.or.arko.mnt.biz.appconfirm.service.impl.BizAppconfirmServiceImpl.*(..))
or execution(* kr.or.arko.mnt.biz.proresult.service.impl.BizProresultServiceImpl.*(..))
or execution(* kr.or.arko.mnt.biz.group.service.impl.BizGroupManageServiceImpl.*(..))
or execution(* kr.or.arko.mnt.biz.mileage.service.impl.BizMileageServiceImpl.*(..))
or execution(* kr.or.arko.mnt.biz.profile.service.impl.BizProfileServiceImpl.*(..))" />
<aop:around pointcut-ref="personalDataProcessPointcut" method="around" />
</aop:aspect>
</aop:config>
-->
</beans> </beans>

View File

@ -112,9 +112,6 @@
<!-- 개인정보보호(SFR-004) - 접속기록 적재(#31, AOP 수집) --> <!-- 개인정보보호(SFR-004) - 접속기록 적재(#31, AOP 수집) -->
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLog_SQL_Mysql.xml"/> <sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyAccessLog_SQL_Mysql.xml"/>
<!-- 개인정보보호(SFR-004) - 업무유형 매핑 조회(#32) -->
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyJobMapping_SQL_Mysql.xml"/>
<!-- 개인정보보호(SFR-004-6) - 접속기록 CSV 비동기 다운로드 작업(PDAL_DOWNLOAD_JOB) --> <!-- 개인정보보호(SFR-004-6) - 접속기록 CSV 비동기 다운로드 작업(PDAL_DOWNLOAD_JOB) -->
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyDownloadJob_SQL_Mysql.xml"/> <sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyDownloadJob_SQL_Mysql.xml"/>
@ -139,7 +136,9 @@
<!-- 개인정보보호(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 + 접속위치) --> <!-- 개인정보보호(SER-001/SER-002) - 접속위치 매핑(CONECT_IP_LOCATION → EgovLoginLogAspect 의 CONECT_LC 적재)
※ 2026-08-11 로그인·로그아웃 접속기록 조회화면(#62/9100046) 삭제 후에도 이 등록은 유지한다 —
같은 파일의 ConectLogDAO.selectUsedLocationList 를 IpLocationResolver 가 사용한다. -->
<sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyConectLog_SQL_Mysql.xml"/> <sqlMap resource="egovframework/sqlmap/site/mnt/biz/privacy/PrivacyConectLog_SQL_Mysql.xml"/>
<!-- 개인정보보호(SFR-001 #6) - 권한변경 이력 CSV 비동기 다운로드 작업(AUTHOR_HIST_DOWNLOAD_JOB) --> <!-- 개인정보보호(SFR-001 #6) - 권한변경 이력 CSV 비동기 다운로드 작업(AUTHOR_HIST_DOWNLOAD_JOB) -->

View File

@ -62,6 +62,12 @@
</select> </select>
<!-- 요청자 본인의 최근 작업 목록(화면 폴링용). 파기(X)분도 보여 이력이 끊기지 않게 한다. --> <!-- 요청자 본인의 최근 작업 목록(화면 폴링용). 파기(X)분도 보여 이력이 끊기지 않게 한다. -->
<!--
화면용 '내 다운로드 요청' 목록 — 만료 전 이력만 보인다(2026-08-10 정책).
접속기록 다운로드(PrivacyDownloadJob_SQL_Mysql.xml)와 동일 정책이다.
테이블은 그대로 적재하고(감사 증적) 화면에서만 감춘다.
STATUS='X'(파기됨) 과 EXPIRE_DT 경과분을 제외하므로, 목록에 남은 건 전부 실제로 내려받을 수 있다.
-->
<select id="AuthorHistDownloadJobDAO.selectJobListByRequester" <select id="AuthorHistDownloadJobDAO.selectJobListByRequester"
parameterClass="java.util.HashMap" parameterClass="java.util.HashMap"
resultClass="egovframework.com.cmm.privacy.author.AuthorHistDownloadJobVO"> resultClass="egovframework.com.cmm.privacy.author.AuthorHistDownloadJobVO">
@ -81,6 +87,8 @@
DATE_FORMAT(EXPIRE_DT, '%Y-%m-%d %H:%i') AS expireDt DATE_FORMAT(EXPIRE_DT, '%Y-%m-%d %H:%i') AS expireDt
FROM AUTHOR_HIST_DOWNLOAD_JOB FROM AUTHOR_HIST_DOWNLOAD_JOB
WHERE REQUESTER_ID = #requesterId# WHERE REQUESTER_ID = #requesterId#
AND STATUS <> 'X'
AND (EXPIRE_DT IS NULL OR EXPIRE_DT > NOW())
ORDER BY JOB_ID DESC ORDER BY JOB_ID DESC
LIMIT #limitCnt# LIMIT #limitCnt#
]]> ]]>

View File

@ -82,6 +82,8 @@
TO_CHAR(EXPIRE_DT, 'YYYY-MM-DD HH24:MI') AS expireDt TO_CHAR(EXPIRE_DT, 'YYYY-MM-DD HH24:MI') AS expireDt
FROM AUTHOR_HIST_DOWNLOAD_JOB FROM AUTHOR_HIST_DOWNLOAD_JOB
WHERE REQUESTER_ID = #requesterId# WHERE REQUESTER_ID = #requesterId#
AND STATUS <> 'X'
AND (EXPIRE_DT IS NULL OR EXPIRE_DT > SYSDATE)
ORDER BY JOB_ID DESC ORDER BY JOB_ID DESC
) WHERE ROWNUM <= #limitCnt# ) WHERE ROWNUM <= #limitCnt#
]]> ]]>

View File

@ -2,128 +2,25 @@
<!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">
<!-- <!--
개인정보보호 기능개선 사업 (SER-001 / SER-002) — 개발항목 62. 개인정보보호 기능개선 사업 (SER-001 / SER-002) — 접속위치 매핑.
로그인·로그아웃 접속기록(COMTNLOGINLOG) 조건별 조회·다운로드 + 접속위치 매핑.
[적재 statement 는 여기 없다] logInsertLoginLog 는 표준 로그인로그 모듈 소유 [2026-08-11] 로그인·로그아웃 접속기록 조회화면(개발항목 62, 메뉴 9100046)이 삭제되면서
(egovframework/sqlmap/com/sym/log/clg/EgovLoginLog_SQL_Mysql.xml, namespace LoginLogDAO). ConectLogDAO.selectConectLogList / *ListCount / *ListForDownload / selectConectLcList 와
이중 경로 방지를 위해 이 파일에는 조회만 둔다. conectLogVO typeAlias 를 제거했다. 화면은 사라졌지만 기록 적재는 계속된다 —
COMTNLOGINLOG 적재 주체는 표준 EgovLoginLogAspect 이고 이 파일과 무관하다.
[UPDATE/DELETE 없음] 접속기록 위·변조 방지 — 화면에서도 조회·다운로드만 제공한다. [namespace 를 ConectLogDAO 로 유지하는 이유]
아래 selectUsedLocationList 는 IpLocationDAO.java 가
list("ConectLogDAO.selectUsedLocationList", null) 로 호출한다. namespace 를 바꾸면
iBatis2 는 기동 시가 아니라 최초 호출 시점에 statement 미발견으로 실패하고,
IpLocationResolver 가 예외를 삼키므로 CONECT_LC 가 조용히 전부 NULL 이 된다.
[왜 조인이 없나 — 실측 근거] [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"> <sqlMap namespace="ConectLogDAO">
<typeAlias alias="conectLogVO" type="egovframework.com.cmm.privacy.conect.ConectLogVO"/>
<typeAlias alias="ipLocationVO" type="egovframework.com.cmm.privacy.conect.IpLocationVO"/> <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 &gt;= CONCAT(#searchBgnDe#, ' 00:00:00')
</isNotEmpty>
<isNotEmpty prepend="AND" property="searchEndDe">
a.CREAT_DT &lt;= 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 캐시 적재) ================= --> <!-- ================= 접속위치 매핑(IpLocationResolver 캐시 적재) ================= -->
<select id="ConectLogDAO.selectUsedLocationList" resultClass="ipLocationVO"> <select id="ConectLogDAO.selectUsedLocationList" resultClass="ipLocationVO">
<![CDATA[ <![CDATA[

View File

@ -2,114 +2,18 @@
<!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">
<!-- <!--
개인정보보호 기능개선 사업 (SER-001 / SER-002) — 개발항목 62. 개인정보보호 기능개선 사업 (SER-001 / SER-002) — 접속위치 매핑 (Oracle 쌍).
로그인·로그아웃 접속기록(COMTNLOGINLOG) 조건별 조회·다운로드 + 접속위치 매핑 (Oracle 쌍).
[2026-08-11] 로그인·로그아웃 접속기록 조회화면(개발항목 62, 메뉴 9100046) 삭제에 맞춰
ConectLog 조회 statement 4종과 conectLogVO typeAlias 를 MySQL 판과 동일하게 제거했다.
대상 운영 DB 가 MySQL(csv)이라 sql-map-config 미등록 — 대상 운영 DB 가 MySQL(csv)이라 sql-map-config 미등록 —
다른 Privacy* Oracle 파일과 동일 관례(이중 관리 유지용). 다른 Privacy* Oracle 파일과 동일 관례(이중 관리 유지용).
MySQL 판과의 차이: CONCAT -> ||, DATE_FORMAT -> TO_CHAR, LIMIT -> ROWNUM 인라인뷰.
--> -->
<sqlMap namespace="ConectLogDAO"> <sqlMap namespace="ConectLogDAO">
<typeAlias alias="conectLogVO" type="egovframework.com.cmm.privacy.conect.ConectLogVO"/>
<typeAlias alias="ipLocationVO" type="egovframework.com.cmm.privacy.conect.IpLocationVO"/> <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 &gt;= TO_DATE(#searchBgnDe# || ' 00:00:00', 'YYYY-MM-DD HH24:MI:SS')
</isNotEmpty>
<isNotEmpty prepend="AND" property="searchEndDe">
a.CREAT_DT &lt;= 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 &lt;= #firstIndex# + #recordCountPerPage#
)
WHERE rnum &gt; #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 &lt;= #firstIndex# + #recordCountPerPage#
)
WHERE rnum &gt; #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"> <select id="ConectLogDAO.selectUsedLocationList" resultClass="ipLocationVO">
<![CDATA[ <![CDATA[
SELECT LOC_ID AS locId SELECT LOC_ID AS locId

View File

@ -65,6 +65,22 @@
</select> </select>
<!-- 요청자 본인의 최근 작업 목록(화면 폴링용). 파기(X)분도 보여 이력이 끊기지 않게 한다. --> <!-- 요청자 본인의 최근 작업 목록(화면 폴링용). 파기(X)분도 보여 이력이 끊기지 않게 한다. -->
<!--
화면용 '내 다운로드 요청' 목록 — 만료 전 이력만 보인다(2026-08-10 정책).
테이블은 그대로 적재한다(감사 증적이라 행은 지우지 않는다). 화면에서만 감춘다.
종전에는 '최근 10건'이라 이미 파일이 파기돼 내려받을 수 없는 행까지 남아,
'완료'로 보이는데 눌러도 안 되는 상태가 됐다. 만료 기준으로 바꾸면 목록에 남은 건
전부 실제로 내려받을 수 있는 것들이다.
제외 대상 두 가지:
· STATUS='X' — 파기 배치가 파일을 지우고 표시만 남긴 행
· EXPIRE_DT 경과 — 배치가 아직 안 돌았을 뿐 이미 만료된 행(배치는 매일 01:00)
EXPIRE_DT IS NULL 은 남긴다 — 만료일 미설정은 파기 대상이 아니다.
LIMIT 은 남겨둔다. 보관 3일이라 만료 전 건수는 자연히 적지만,
짧은 기간에 대량 요청 시 화면이 통째로 길어지는 것을 막는 안전장치다.
-->
<select id="AccessLogDownloadJobDAO.selectJobListByRequester" <select id="AccessLogDownloadJobDAO.selectJobListByRequester"
parameterClass="java.util.HashMap" parameterClass="java.util.HashMap"
resultClass="egovframework.com.cmm.privacy.auditlog.AccessLogDownloadJobVO"> resultClass="egovframework.com.cmm.privacy.auditlog.AccessLogDownloadJobVO">
@ -84,6 +100,8 @@
EXPIRE_DT AS expireDt EXPIRE_DT AS expireDt
FROM PDAL_DOWNLOAD_JOB FROM PDAL_DOWNLOAD_JOB
WHERE REQUESTER_ID = #requesterId# WHERE REQUESTER_ID = #requesterId#
AND STATUS <> 'X'
AND (EXPIRE_DT IS NULL OR EXPIRE_DT > NOW())
ORDER BY JOB_ID DESC ORDER BY JOB_ID DESC
LIMIT #limitCnt# LIMIT #limitCnt#
]]> ]]>

View File

@ -85,6 +85,8 @@
EXPIRE_DT AS expireDt EXPIRE_DT AS expireDt
FROM PDAL_DOWNLOAD_JOB FROM PDAL_DOWNLOAD_JOB
WHERE REQUESTER_ID = #requesterId# WHERE REQUESTER_ID = #requesterId#
AND STATUS <> 'X'
AND (EXPIRE_DT IS NULL OR EXPIRE_DT > SYSDATE)
AND ROWNUM <= #limitCnt# AND ROWNUM <= #limitCnt#
ORDER BY JOB_ID DESC ORDER BY JOB_ID DESC
]]> ]]>

View File

@ -1,165 +0,0 @@
<?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 / SER-002) — 업무유형 매핑(#32) + 관리 화면(#32-1).
· selectJobMappingList : JobMappingResolver 가 캐시에 적재(USE_AT='Y' 만)
· selectJobMappingManage* 이하 : 관리 화면 CRUD (사용중지 행도 조회)
컬럼→프로퍼티 매핑을 위해 AS 별칭을 camelCase 로 부여.
※ REGISTER_ID/REG_DT/UPDUSR_ID/UPDT_DT 는 privacy.job_mapping_audit_cols_mysql.sql 로
추가되는 컬럼이다. 이 sqlMap 을 배포하기 전에 해당 DDL 을 반드시 먼저 적용할 것.
-->
<sqlMap namespace="JobMappingDAO">
<select id="JobMappingDAO.selectJobMappingList"
resultClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO">
<![CDATA[
SELECT MAPPING_ID AS mappingId,
TARGET_CLASS AS targetClass,
METHOD_PTTRN AS methodPttrn,
JOB_SE_CODE AS jobSeCode,
JOB_SE_NM AS jobSeNm,
DML_GUBUN AS dmlGubun,
SORT_ORDR AS sortOrdr,
USE_AT AS useAt,
RM AS rm
FROM PDAL_JOB_MAPPING
WHERE USE_AT = 'Y'
ORDER BY SORT_ORDR ASC, MAPPING_ID ASC
]]>
</select>
<!-- ================================================================
#32-1 관리 화면 CRUD.
위 selectJobMappingList 는 Resolver 캐시용이라 USE_AT='Y' 만 보지만,
관리 목록은 사용중지(N) 행도 보여야 재사용·복구가 가능하므로 조건을 두지 않는다.
================================================================ -->
<select id="JobMappingDAO.selectJobMappingManageList"
parameterClass="egovframework.com.cmm.privacy.auditlog.JobMappingSearchVO"
resultClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO">
SELECT MAPPING_ID AS mappingId,
TARGET_CLASS AS targetClass,
METHOD_PTTRN AS methodPttrn,
JOB_SE_CODE AS jobSeCode,
JOB_SE_NM AS jobSeNm,
DML_GUBUN AS dmlGubun,
SORT_ORDR AS sortOrdr,
USE_AT AS useAt,
RM AS rm,
REGISTER_ID AS registerId,
REG_DT AS regDt,
UPDUSR_ID AS updusrId,
UPDT_DT AS updtDt
FROM PDAL_JOB_MAPPING
<dynamic prepend="WHERE">
<isNotEmpty prepend="AND" property="searchTargetClass"> TARGET_CLASS LIKE CONCAT('%', #searchTargetClass#, '%') </isNotEmpty>
<isNotEmpty prepend="AND" property="searchJobSe"> (JOB_SE_CODE LIKE CONCAT('%', #searchJobSe#, '%') OR JOB_SE_NM LIKE CONCAT('%', #searchJobSe#, '%')) </isNotEmpty>
<isNotEmpty prepend="AND" property="searchUseAt"> USE_AT = #searchUseAt# </isNotEmpty>
<isNotEmpty prepend="AND" property="searchDmlGubun"> DML_GUBUN = #searchDmlGubun# </isNotEmpty>
</dynamic>
ORDER BY SORT_ORDR ASC, MAPPING_ID ASC
LIMIT #firstIndex#, #recordCountPerPage#
</select>
<select id="JobMappingDAO.selectJobMappingManageListCount"
parameterClass="egovframework.com.cmm.privacy.auditlog.JobMappingSearchVO"
resultClass="java.lang.Integer">
SELECT COUNT(1)
FROM PDAL_JOB_MAPPING
<dynamic prepend="WHERE">
<isNotEmpty prepend="AND" property="searchTargetClass"> TARGET_CLASS LIKE CONCAT('%', #searchTargetClass#, '%') </isNotEmpty>
<isNotEmpty prepend="AND" property="searchJobSe"> (JOB_SE_CODE LIKE CONCAT('%', #searchJobSe#, '%') OR JOB_SE_NM LIKE CONCAT('%', #searchJobSe#, '%')) </isNotEmpty>
<isNotEmpty prepend="AND" property="searchUseAt"> USE_AT = #searchUseAt# </isNotEmpty>
<isNotEmpty prepend="AND" property="searchDmlGubun"> DML_GUBUN = #searchDmlGubun# </isNotEmpty>
</dynamic>
</select>
<select id="JobMappingDAO.selectJobMapping"
parameterClass="java.lang.Long"
resultClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO">
<![CDATA[
SELECT MAPPING_ID AS mappingId,
TARGET_CLASS AS targetClass,
METHOD_PTTRN AS methodPttrn,
JOB_SE_CODE AS jobSeCode,
JOB_SE_NM AS jobSeNm,
DML_GUBUN AS dmlGubun,
SORT_ORDR AS sortOrdr,
USE_AT AS useAt,
RM AS rm,
REGISTER_ID AS registerId,
REG_DT AS regDt,
UPDUSR_ID AS updusrId,
UPDT_DT AS updtDt
FROM PDAL_JOB_MAPPING
WHERE MAPPING_ID = #value#
]]>
</select>
<!-- (대상클래스, 메서드패턴) 중복 검사. 수정 시에는 자기 자신(MAPPING_ID)을 제외한다.
테이블에 유니크 제약이 없어(운영 중 행 추가/삭제 자유) 서비스 계층에서 막는다. -->
<select id="JobMappingDAO.selectJobMappingDuplicateCount"
parameterClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO"
resultClass="java.lang.Integer">
<![CDATA[
SELECT COUNT(1)
FROM PDAL_JOB_MAPPING
WHERE TARGET_CLASS = #targetClass#
AND METHOD_PTTRN = #methodPttrn#
AND MAPPING_ID <> #mappingId#
]]>
</select>
<insert id="JobMappingDAO.insertJobMapping"
parameterClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO">
<![CDATA[
INSERT INTO PDAL_JOB_MAPPING
(TARGET_CLASS, METHOD_PTTRN, JOB_SE_CODE, JOB_SE_NM,
DML_GUBUN, SORT_ORDR, USE_AT, RM,
REGISTER_ID, REG_DT, UPDUSR_ID, UPDT_DT)
VALUES (#targetClass#, #methodPttrn#, #jobSeCode#, #jobSeNm#,
#dmlGubun:VARCHAR#, #sortOrdr#, #useAt#, #rm:VARCHAR#,
#registerId:VARCHAR#, NOW(), #updusrId:VARCHAR#, NOW())
]]>
</insert>
<update id="JobMappingDAO.updateJobMapping"
parameterClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO">
<![CDATA[
UPDATE PDAL_JOB_MAPPING
SET TARGET_CLASS = #targetClass#,
METHOD_PTTRN = #methodPttrn#,
JOB_SE_CODE = #jobSeCode#,
JOB_SE_NM = #jobSeNm#,
DML_GUBUN = #dmlGubun:VARCHAR#,
SORT_ORDR = #sortOrdr#,
USE_AT = #useAt#,
RM = #rm:VARCHAR#,
UPDUSR_ID = #updusrId:VARCHAR#,
UPDT_DT = NOW()
WHERE MAPPING_ID = #mappingId#
]]>
</update>
<update id="JobMappingDAO.updateJobMappingUseAt"
parameterClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO">
<![CDATA[
UPDATE PDAL_JOB_MAPPING
SET USE_AT = #useAt#,
UPDUSR_ID = #updusrId:VARCHAR#,
UPDT_DT = NOW()
WHERE MAPPING_ID = #mappingId#
]]>
</update>
<delete id="JobMappingDAO.deleteJobMapping"
parameterClass="java.lang.Long">
<![CDATA[
DELETE FROM PDAL_JOB_MAPPING
WHERE MAPPING_ID = #value#
]]>
</delete>
</sqlMap>

View File

@ -1,163 +0,0 @@
<?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">
<!--
개인정보보호 기능개선 사업 — 업무유형 매핑(#32) + 관리 화면(#32-1) Oracle 쌍(이중관리 규약).
※ arko 런타임은 MySQL sqlmap 만 로드한다. Oracle 전환 시에는 아래 차이를 확인할 것.
· 페이징: LIMIT 미지원 → ROWNUM 인라인뷰
· 문자열 결합: CONCAT 2인자 제한 → '%' || v || '%'
· 현재시각: NOW() → SYSDATE
· MAPPING_ID: AUTO_INCREMENT 없음 → 시퀀스(PDAL_JOB_MAPPING_SEQ) 선행 생성 필요
-->
<sqlMap namespace="JobMappingDAO">
<select id="JobMappingDAO.selectJobMappingList"
resultClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO">
<![CDATA[
SELECT MAPPING_ID AS mappingId,
TARGET_CLASS AS targetClass,
METHOD_PTTRN AS methodPttrn,
JOB_SE_CODE AS jobSeCode,
JOB_SE_NM AS jobSeNm,
DML_GUBUN AS dmlGubun,
SORT_ORDR AS sortOrdr,
USE_AT AS useAt,
RM AS rm
FROM PDAL_JOB_MAPPING
WHERE USE_AT = 'Y'
ORDER BY SORT_ORDR ASC, MAPPING_ID ASC
]]>
</select>
<!-- ================================================================
#32-1 관리 화면 CRUD (사용중지 행도 조회)
================================================================ -->
<select id="JobMappingDAO.selectJobMappingManageList"
parameterClass="egovframework.com.cmm.privacy.auditlog.JobMappingSearchVO"
resultClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO">
SELECT * FROM (
SELECT ROWNUM AS RNUM, A.* FROM (
SELECT MAPPING_ID AS mappingId,
TARGET_CLASS AS targetClass,
METHOD_PTTRN AS methodPttrn,
JOB_SE_CODE AS jobSeCode,
JOB_SE_NM AS jobSeNm,
DML_GUBUN AS dmlGubun,
SORT_ORDR AS sortOrdr,
USE_AT AS useAt,
RM AS rm,
REGISTER_ID AS registerId,
REG_DT AS regDt,
UPDUSR_ID AS updusrId,
UPDT_DT AS updtDt
FROM PDAL_JOB_MAPPING
<dynamic prepend="WHERE">
<isNotEmpty prepend="AND" property="searchTargetClass"> TARGET_CLASS LIKE '%' || #searchTargetClass# || '%' </isNotEmpty>
<isNotEmpty prepend="AND" property="searchJobSe"> (JOB_SE_CODE LIKE '%' || #searchJobSe# || '%' OR JOB_SE_NM LIKE '%' || #searchJobSe# || '%') </isNotEmpty>
<isNotEmpty prepend="AND" property="searchUseAt"> USE_AT = #searchUseAt# </isNotEmpty>
<isNotEmpty prepend="AND" property="searchDmlGubun"> DML_GUBUN = #searchDmlGubun# </isNotEmpty>
</dynamic>
ORDER BY SORT_ORDR ASC, MAPPING_ID ASC
) A
) WHERE RNUM <![CDATA[ > ]]> #firstIndex# AND RNUM <![CDATA[ <= ]]> #firstIndex# + #recordCountPerPage#
</select>
<select id="JobMappingDAO.selectJobMappingManageListCount"
parameterClass="egovframework.com.cmm.privacy.auditlog.JobMappingSearchVO"
resultClass="java.lang.Integer">
SELECT COUNT(1)
FROM PDAL_JOB_MAPPING
<dynamic prepend="WHERE">
<isNotEmpty prepend="AND" property="searchTargetClass"> TARGET_CLASS LIKE '%' || #searchTargetClass# || '%' </isNotEmpty>
<isNotEmpty prepend="AND" property="searchJobSe"> (JOB_SE_CODE LIKE '%' || #searchJobSe# || '%' OR JOB_SE_NM LIKE '%' || #searchJobSe# || '%') </isNotEmpty>
<isNotEmpty prepend="AND" property="searchUseAt"> USE_AT = #searchUseAt# </isNotEmpty>
<isNotEmpty prepend="AND" property="searchDmlGubun"> DML_GUBUN = #searchDmlGubun# </isNotEmpty>
</dynamic>
</select>
<select id="JobMappingDAO.selectJobMapping"
parameterClass="java.lang.Long"
resultClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO">
<![CDATA[
SELECT MAPPING_ID AS mappingId,
TARGET_CLASS AS targetClass,
METHOD_PTTRN AS methodPttrn,
JOB_SE_CODE AS jobSeCode,
JOB_SE_NM AS jobSeNm,
DML_GUBUN AS dmlGubun,
SORT_ORDR AS sortOrdr,
USE_AT AS useAt,
RM AS rm,
REGISTER_ID AS registerId,
REG_DT AS regDt,
UPDUSR_ID AS updusrId,
UPDT_DT AS updtDt
FROM PDAL_JOB_MAPPING
WHERE MAPPING_ID = #value#
]]>
</select>
<select id="JobMappingDAO.selectJobMappingDuplicateCount"
parameterClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO"
resultClass="java.lang.Integer">
<![CDATA[
SELECT COUNT(1)
FROM PDAL_JOB_MAPPING
WHERE TARGET_CLASS = #targetClass#
AND METHOD_PTTRN = #methodPttrn#
AND MAPPING_ID <> #mappingId#
]]>
</select>
<insert id="JobMappingDAO.insertJobMapping"
parameterClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO">
<![CDATA[
INSERT INTO PDAL_JOB_MAPPING
(MAPPING_ID, TARGET_CLASS, METHOD_PTTRN, JOB_SE_CODE, JOB_SE_NM,
DML_GUBUN, SORT_ORDR, USE_AT, RM,
REGISTER_ID, REG_DT, UPDUSR_ID, UPDT_DT)
VALUES (PDAL_JOB_MAPPING_SEQ.NEXTVAL, #targetClass#, #methodPttrn#, #jobSeCode#, #jobSeNm#,
#dmlGubun:VARCHAR#, #sortOrdr#, #useAt#, #rm:VARCHAR#,
#registerId:VARCHAR#, SYSDATE, #updusrId:VARCHAR#, SYSDATE)
]]>
</insert>
<update id="JobMappingDAO.updateJobMapping"
parameterClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO">
<![CDATA[
UPDATE PDAL_JOB_MAPPING
SET TARGET_CLASS = #targetClass#,
METHOD_PTTRN = #methodPttrn#,
JOB_SE_CODE = #jobSeCode#,
JOB_SE_NM = #jobSeNm#,
DML_GUBUN = #dmlGubun:VARCHAR#,
SORT_ORDR = #sortOrdr#,
USE_AT = #useAt#,
RM = #rm:VARCHAR#,
UPDUSR_ID = #updusrId:VARCHAR#,
UPDT_DT = SYSDATE
WHERE MAPPING_ID = #mappingId#
]]>
</update>
<update id="JobMappingDAO.updateJobMappingUseAt"
parameterClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO">
<![CDATA[
UPDATE PDAL_JOB_MAPPING
SET USE_AT = #useAt#,
UPDUSR_ID = #updusrId:VARCHAR#,
UPDT_DT = SYSDATE
WHERE MAPPING_ID = #mappingId#
]]>
</update>
<delete id="JobMappingDAO.deleteJobMapping"
parameterClass="java.lang.Long">
<![CDATA[
DELETE FROM PDAL_JOB_MAPPING
WHERE MAPPING_ID = #value#
]]>
</delete>
</sqlMap>

View File

@ -22,6 +22,19 @@
<context:include-filter expression="org.springframework.stereotype.Controller" type="annotation"/> <context:include-filter expression="org.springframework.stereotype.Controller" type="annotation"/>
<context:exclude-filter expression="org.springframework.stereotype.Service" type="annotation"/> <context:exclude-filter expression="org.springframework.stereotype.Service" type="annotation"/>
<context:exclude-filter expression="org.springframework.stereotype.Repository" type="annotation"/> <context:exclude-filter expression="org.springframework.stereotype.Repository" type="annotation"/>
<!-- [2026-08-10] @Component 중복 생성 차단 (개인정보보호 워커/큐).
use-default-filters 가 기본 true 라 위 include/exclude 와 무관하게 @Component 가
이 자식 컨텍스트에도 스캔된다. 그 결과 루트에 이미 있는 빈이 한 벌 더 만들어지는데,
자식에는 PropertyPlaceholderConfigurer 가 없어 @Value("${...}") 가 치환되지 않고
문자열 그대로 주입된다. 실제로 다운로드 워커가 2개 떠서 경쟁했고, 자식 쪽 워커가
저장경로를 '${Globals.privacyDownloadPath:}' 라는 이름의 폴더로 만들려다 전건 실패시켰다.
큐(AccessLogQueue)도 마찬가지로 두 벌이 되어 아래 menuAccessLogInterceptor 의
ref="accessLogQueue" 가 (주석의 설명과 달리) 루트가 아닌 자식 사본을 물었다 —
해시체인은 단일 컨슈머가 순차 처리해야 하므로 이건 무결성 문제이기도 하다.
※ @Component 를 annotation 필터로 제외하면 안 된다. @Controller 가 @Component 를
메타애노테이션으로 달고 있어 컨트롤러까지 전부 사라진다. 그래서 클래스명 regex 로 막는다. -->
<context:exclude-filter type="regex"
expression="egovframework\.com\.cmm\.privacy\..*(Worker|Queue|Resolver|Aspect)"/>
</context:component-scan> </context:component-scan>
<!-- frt 기본 Pagination Tag --> <!-- frt 기본 Pagination Tag -->

View File

@ -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 '9100046' || param.page eq '9100047' || 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 '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 '9100046' || param.page eq '9100047' || 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 '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' || param.page eq '9100046' || param.page eq '9100047'}">class="on"</c:if>><a href="#none">${menuUpperVo2.menuNm}</a> <li <c:if test="${param.page eq '9100031' || param.page eq '9100034' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100047'}">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' || param.page eq '9100046' || param.page eq '9100047'}">style="display: block;"</c:if>> <ul <c:if test="${param.page eq '9100031' || param.page eq '9100034' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100047'}">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'}">
@ -126,14 +126,7 @@
<c:when test="${menuUpperVo4.menuNo eq '9100044'}"> <c:when test="${menuUpperVo4.menuNo eq '9100044'}">
<li <c:if test="${param.page eq '9100044'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li> <li <c:if test="${param.page eq '9100044'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
</c:when> </c:when>
<%-- [개인정보/#32-1] 업무유형 매핑 관리 (접속기록 수행업무 분류 기준) --%> <%-- [2026-08-11] 9100045 업무유형 매핑 관리 / 9100046 로그인·로그아웃 접속기록 분기 삭제(화면·메뉴 폐기) --%>
<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>
</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] 접근통제 정책 설정(계정잠금·자동로그아웃·세션재발급) --%> <%-- [개인정보/SER-001 요건4·5] 접근통제 정책 설정(계정잠금·자동로그아웃·세션재발급) --%>
<c:when test="${menuUpperVo4.menuNo eq '9100047'}"> <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> <li <c:if test="${param.page eq '9100047'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>

View File

@ -300,7 +300,8 @@ function fn_dml(gubun) {
<div class="dl_job"> <div class="dl_job">
<strong>내 다운로드 요청</strong> <strong>내 다운로드 요청</strong>
<span class="hash">파일은 암호화 보관되며 만료 시 자동 파기됩니다. 요청자 본인만 내려받을 수 있습니다.</span> <span class="hash">파일은 암호화 보관되며 만료 시 자동 파기됩니다. 요청자 본인만 내려받을 수 있습니다.
<strong>만료 전 요청만 표시</strong>되며, 만료된 요청도 이력에는 그대로 보존됩니다.</span>
<table summary="내 다운로드 요청 목록" class="list1"> <table summary="내 다운로드 요청 목록" class="list1">
<caption>내 다운로드 요청 목록</caption> <caption>내 다운로드 요청 목록</caption>
<colgroup> <colgroup>

View File

@ -224,7 +224,8 @@ function fn_dlEsc(s) {
<div class="dl_job"> <div class="dl_job">
<strong>내 다운로드 요청</strong> <strong>내 다운로드 요청</strong>
<span class="hash">파일은 암호화 보관되며 만료 시 자동 파기됩니다. 요청자 본인만 내려받을 수 있습니다.</span> <span class="hash">파일은 암호화 보관되며 만료 시 자동 파기됩니다. 요청자 본인만 내려받을 수 있습니다.
<strong>만료 전 요청만 표시</strong>되며, 만료된 요청도 이력에는 그대로 보존됩니다.</span>
<table summary="내 다운로드 요청 목록" class="list1"> <table summary="내 다운로드 요청 목록" class="list1">
<caption>내 다운로드 요청 목록</caption> <caption>내 다운로드 요청 목록</caption>
<colgroup> <colgroup>

View File

@ -1,209 +0,0 @@
<%@ 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>

View File

@ -1,253 +0,0 @@
<%@ page language="java" contentType="text/html; charset=utf-8" pageEncoding="utf-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%><%-- Head --%>
<c:import url = "/mnt/include/head.do" /><%--// Head --%>
<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"%>
<%@ taglib prefix="ui" uri="http://egovframework.gov/ctl/ui"%>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form"%>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%>
<%--
업무유형 매핑 관리 (개인정보보호 SFR-004 / SER-002 / #32-1)
· PDAL_JOB_MAPPING(#32) 등록·수정·사용중지·삭제. 접속기록 수집 Aspect(#31)의 분류 기준.
· 저장 즉시 JobMappingResolver 캐시가 무효화되어 WAS 재기동 없이 반영된다.
· 상태변경(save/useAt/delete/refresh)은 모두 POST — 링크 클릭으로는 변경되지 않는다.
--%>
<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/jobMapping/listView.do' />";
document.frm.submit();
}
function fn_regist(mappingId) {
var url = "<c:url value='${PATH}/sec/jobMapping/registView.do' />?page=9100045";
if (mappingId) {
url = url + "&mappingId=" + mappingId;
}
location.href = url;
}
/* 상태변경은 POST 전용 폼으로 전송한다. */
function fn_post(action, mappingId, useAt) {
document.actFrm.action = action;
document.actFrm.mappingId.value = (mappingId == null) ? "" : mappingId;
document.actFrm.useAt.value = (useAt == null) ? "" : useAt;
document.actFrm.submit();
}
/* 업무명은 사용자 입력값이라 JS 문자열로 인라인 보간하지 않는다(따옴표 포함 시 스크립트 파손·주입).
data-* 속성에 c:out 으로 escape 해 담고, 버튼 클릭 시 DOM 에서 읽는다. */
$(document).ready(function() {
$(".js-useAt").click(function() {
var id = $(this).attr("data-id");
var useAt = $(this).attr("data-useat");
var nm = $(this).attr("data-nm");
var msg = (useAt == "N")
? "[" + nm + "] 매핑을 사용중지합니다.\n\n사용중지해도 접속기록 수집은 계속되며, 이 매핑으로 분류되던 처리는\n메서드명 기반 기본 분류로 적재됩니다. 진행할까요?"
: "[" + nm + "] 매핑을 다시 사용합니다. 진행할까요?";
if (confirm(msg)) {
fn_post("<c:url value='${PATH}/sec/jobMapping/useAt.do' />", id, useAt);
}
});
$(".js-delete").click(function() {
var id = $(this).attr("data-id");
var nm = $(this).attr("data-nm");
if (confirm("[" + nm + "] 매핑을 삭제합니다.\n\n삭제하면 어떤 분류 기준이 쓰였는지 확인할 수 없습니다.\n일시적으로 끄는 것이라면 '사용중지'를 쓰세요. 그래도 삭제할까요?")) {
fn_post("<c:url value='${PATH}/sec/jobMapping/delete.do' />", id, null);
}
});
$(".js-modify").click(function() {
fn_regist($(this).attr("data-id"));
});
});
function fn_refresh() {
if (!confirm("DB를 직접 수정한 내용을 반영하기 위해 매핑 캐시를 다시 읽습니다. 진행할까요?")) {
return;
}
fn_post("<c:url value='${PATH}/sec/jobMapping/refresh.do' />", null, null);
}
//-->
</script>
<style type="text/css">
.badge { display:inline-block; padding:2px 8px; border-radius:3px; font-size:12px; color:#fff; }
.badge.y { background:#4a7ebb; }
.badge.n { background:#999; }
.dml { display:inline-block; padding:1px 6px; border-radius:3px; font-size:12px; border:1px solid #cbd5e0; color:#445; }
.dml.d { border-color:#c25b5b; color:#c25b5b; }
.dml.auto { border-style:dashed; color:#888; }
.mono { font-family:Consolas,monospace; }
.sub { font-size:11px; color:#888; }
.guide { margin:6px 0 10px; padding:8px 10px; background:#f5f7fa; border:1px solid #dde3ea; font-size:12px; color:#555; }
.msg { margin:6px 0 10px; padding:8px 10px; background:#eef5ff; border:1px solid #b8d0ef; font-size:12px; color:#24507f; }
.btn_area { margin:8px 0; text-align:right; }
.btn1 { padding:5px 14px; border:1px solid #4a7ebb; background:#4a7ebb; color:#fff; cursor:pointer; font-size:12px; }
.btn2 { padding:5px 14px; border:1px solid #9aa5b1; background:#fff; color:#445; cursor:pointer; font-size:12px; }
.btn_s { padding:2px 8px; border:1px solid #9aa5b1; background:#fff; color:#445; cursor:pointer; font-size:11px; }
.btn_s.del { border-color:#c25b5b; color:#c25b5b; }
</style>
</head>
<body>
<!-- 타이틀 -->
<c:import url = "/mnt/include/navigation.do" />
<!-- //타이틀 -->
<!-- 컨텐츠 영역 -->
<%-- 상태변경 전용 POST 폼(목록 검색폼과 분리) --%>
<form name="actFrm" id="actFrm" method="post" action="#">
<input type="hidden" name="mappingId" value="" />
<input type="hidden" name="useAt" value="" />
</form>
<form:form commandName="jobMappingSearchVO" onsubmit="fn_list('1'); return false;" id="frm" name="frm" method="get" >
<form:hidden path="pageIndex" />
<%-- 검색·페이징 후에도 좌측메뉴 하이라이트가 유지되도록 메뉴번호를 유지한다(left.jsp 는 param.page 로 판정). --%>
<input type="hidden" name="page" value="9100045" />
<!-- 검색창 -->
<div id="search_wrap">
<fieldset class="date_search">
<span>대상 클래스 :</span>
<form:input path="searchTargetClass" title="대상 클래스명" cssClass="tbox w4" />
<span>업무코드/업무명 :</span>
<form:input path="searchJobSe" title="업무코드 또는 업무명" cssClass="tbox w4" />
<span>처리구분 :</span>
<select name="searchDmlGubun" id="searchDmlGubun" title="처리구분">
<option value="" <c:if test="${empty jobMappingSearchVO.searchDmlGubun}">selected="selected"</c:if>>전체</option>
<option value="S" <c:if test="${jobMappingSearchVO.searchDmlGubun == 'S'}">selected="selected"</c:if>>조회</option>
<option value="I" <c:if test="${jobMappingSearchVO.searchDmlGubun == 'I'}">selected="selected"</c:if>>등록</option>
<option value="U" <c:if test="${jobMappingSearchVO.searchDmlGubun == 'U'}">selected="selected"</c:if>>수정</option>
<option value="D" <c:if test="${jobMappingSearchVO.searchDmlGubun == 'D'}">selected="selected"</c:if>>삭제</option>
</select>
<span>사용여부 :</span>
<select name="searchUseAt" id="searchUseAt" title="사용여부">
<option value="" <c:if test="${empty jobMappingSearchVO.searchUseAt}">selected="selected"</c:if>>전체</option>
<option value="Y" <c:if test="${jobMappingSearchVO.searchUseAt == 'Y'}">selected="selected"</c:if>>사용</option>
<option value="N" <c:if test="${jobMappingSearchVO.searchUseAt == 'N'}">selected="selected"</c:if>>사용중지</option>
</select>
<input type="image" src="<c:url value='/images/site/mnt/btn_search.gif'/>" alt="검색" />
<a href="<c:url value='${PATH}/sec/jobMapping/listView.do?page=9100045' />"><img src="<c:url value='/images/site/mnt/btn_all_list.gif'/>" alt="전체목록" /></a>
</fieldset>
</div>
<!-- //검색창 -->
<c:if test="${not empty resultMsg}">
<div class="msg"><c:out value="${resultMsg}" /></div>
</c:if>
<div class="guide">
· 접속기록(개인정보 처리기록)의 <strong>수행업무 분류 기준</strong>입니다. 수집 Aspect가 가로챈 <strong>(대상 클래스, 메서드명)</strong>을 업무코드·업무명으로 해석합니다.<br />
· 개인정보 화면이 새로 생기면 <strong>이 표에 행만 추가</strong>하면 분류됩니다(소스 수정 불요). 저장 즉시 반영되며 WAS 재기동이 필요 없습니다.<br />
· <strong>매핑을 지워도 수집이 멈추지는 않습니다.</strong> 수집 대상은 <span class="mono">context-syslogaop.xml</span> 의 pointcut이 정하며, 매핑이 없으면 메서드명 기반 기본 분류(insert→등록, update→수정, delete→삭제, 그 외 조회)로 적재됩니다.<br />
· 한 메서드에 여러 매핑이 걸리면 <strong>우선순위(작은 값)</strong>가 이깁니다. 구체적인 규칙은 낮게, <span class="mono">*</span> 폴백은 높게 두세요.
</div>
<div class="btn_area">
총 <strong><c:out value="${jobMappingSearchVO.totalRecordCount}" /></strong>건
<input type="button" class="btn2" value="캐시 재적재" onclick="fn_refresh();" title="DB를 직접 수정한 내용을 반영합니다" />
<input type="button" class="btn1" value="매핑 등록" onclick="fn_regist();" />
</div>
<table summary="업무유형 매핑 목록" class="list1">
<caption>업무유형 매핑 목록</caption>
<colgroup>
<col width="5%" />
<col width="*" />
<col width="10%" />
<col width="20%" />
<col width="7%" />
<col width="7%" />
<col width="7%" />
<col width="16%" />
</colgroup>
<thead>
<tr>
<th>순위</th>
<th>대상 클래스</th>
<th>메서드 패턴</th>
<th>업무코드 / 업무명</th>
<th>처리구분</th>
<th>사용</th>
<th>최종변경</th>
<th>관리</th>
</tr>
</thead>
<tbody>
<c:forEach items="${resultList}" var="result">
<tr>
<td><c:out value="${result.sortOrdr}" /></td>
<td class="mono">
<c:out value="${result.targetClass}" />
<c:if test="${not empty result.rm}"><br /><span class="sub"><c:out value="${result.rm}" /></span></c:if>
</td>
<td class="mono"><c:out value="${result.methodPttrn}" /></td>
<td>
<span class="mono"><c:out value="${result.jobSeCode}" /></span><br />
<c:out value="${result.jobSeNm}" />
</td>
<td>
<c:choose>
<c:when test="${result.dmlGubun == 'S'}"><span class="dml">조회</span></c:when>
<c:when test="${result.dmlGubun == 'I'}"><span class="dml">등록</span></c:when>
<c:when test="${result.dmlGubun == 'U'}"><span class="dml">수정</span></c:when>
<c:when test="${result.dmlGubun == 'D'}"><span class="dml d">삭제</span></c:when>
<c:otherwise><span class="dml auto" title="메서드명으로 유도">자동</span></c:otherwise>
</c:choose>
</td>
<td>
<c:choose>
<c:when test="${result.useAt == 'N'}"><span class="badge n">중지</span></c:when>
<c:otherwise><span class="badge y">사용</span></c:otherwise>
</c:choose>
</td>
<td class="sub">
<c:choose>
<c:when test="${empty result.updusrId}">시드</c:when>
<c:otherwise>
<c:out value="${result.updusrId}" /><br /><c:out value="${fn:substring(result.updtDt, 0, 16)}" />
</c:otherwise>
</c:choose>
</td>
<td>
<input type="button" class="btn_s js-modify" value="수정" data-id="<c:out value='${result.mappingId}'/>" />
<c:choose>
<c:when test="${result.useAt == 'N'}">
<input type="button" class="btn_s js-useAt" value="사용" data-id="<c:out value='${result.mappingId}'/>" data-useat="Y" data-nm="<c:out value='${result.jobSeNm}'/>" />
</c:when>
<c:otherwise>
<input type="button" class="btn_s js-useAt" value="중지" data-id="<c:out value='${result.mappingId}'/>" data-useat="N" data-nm="<c:out value='${result.jobSeNm}'/>" />
</c:otherwise>
</c:choose>
<input type="button" class="btn_s del js-delete" value="삭제" data-id="<c:out value='${result.mappingId}'/>" data-nm="<c:out value='${result.jobSeNm}'/>" />
</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>

View File

@ -1,188 +0,0 @@
<%@ 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="form" uri="http://www.springframework.org/tags/form"%>
<%--
업무유형 매핑 등록·수정 (개인정보보호 SFR-004 / SER-002 / #32-1)
· mappingId 가 0 이면 등록, 그 외는 수정(컨트롤러 save.do 가 분기).
· 저장은 POST 전용. 서버측에서 형식·중복을 다시 검증하므로 여기 검사는 1차 안내용이다.
--%>
<c:set var="PATH" value="/mnt" />
<c:set var="isModify" value="${jobMappingVO.mappingId > 0}" />
<script type="text/javascript" src="<c:url value='/js/egovframework/com/cmm/jquery.js'/>"></script>
<script type="text/javascript">
<!--
function fn_save() {
var f = document.frm;
if (f.targetClass.value.replace(/^\s+|\s+$/g, "") == "") {
alert("대상 클래스명을 입력하세요.");
f.targetClass.focus();
return;
}
if (f.methodPttrn.value.replace(/^\s+|\s+$/g, "") == "") {
alert("메서드 패턴을 입력하세요. 전체를 대상으로 하려면 * 를 입력합니다.");
f.methodPttrn.focus();
return;
}
if (f.jobSeCode.value.replace(/^\s+|\s+$/g, "") == "") {
alert("업무코드를 입력하세요.");
f.jobSeCode.focus();
return;
}
if (!/^[A-Za-z0-9_]+$/.test(f.jobSeCode.value)) {
alert("업무코드는 영문·숫자·밑줄만 사용할 수 있습니다.");
f.jobSeCode.focus();
return;
}
if (f.jobSeNm.value.replace(/^\s+|\s+$/g, "") == "") {
alert("업무명을 입력하세요.");
f.jobSeNm.focus();
return;
}
/* 숫자 아닌 값이 가면 서버 바인딩이 실패한다(컨트롤러가 BindingResult 로 되돌리지만 입력은 유실됨). */
if (!/^[0-9]{1,4}$/.test(f.sortOrdr.value.replace(/^\s+|\s+$/g, ""))) {
alert("우선순위는 0~9999 사이의 숫자로 입력하세요.");
f.sortOrdr.focus();
return;
}
f.method = "post";
f.action = "<c:url value='${PATH}/sec/jobMapping/save.do' />";
f.submit();
}
function fn_cancel() {
location.href = "<c:url value='${PATH}/sec/jobMapping/listView.do?page=9100045' />";
}
//-->
</script>
<style type="text/css">
.guide { margin:6px 0 10px; padding:8px 10px; background:#f5f7fa; border:1px solid #dde3ea; font-size:12px; color:#555; }
.mono { font-family:Consolas,monospace; }
.hint { display:block; margin-top:3px; font-size:11px; color:#888; }
.req { color:#c25b5b; }
.btn_area { margin:12px 0; text-align:center; }
.btn1 { padding:6px 20px; border:1px solid #4a7ebb; background:#4a7ebb; color:#fff; cursor:pointer; font-size:12px; }
.btn2 { padding:6px 20px; border:1px solid #9aa5b1; background:#fff; color:#445; cursor:pointer; font-size:12px; }
</style>
</head>
<body>
<!-- 타이틀 -->
<c:import url = "/mnt/include/navigation.do" />
<!-- //타이틀 -->
<!-- 컨텐츠 영역 -->
<div class="guide">
· 접속기록 수집 Aspect가 가로챈 <strong>(대상 클래스, 메서드명)</strong>을 업무코드·업무명으로 분류하는 규칙 한 건입니다.<br />
· 대상 클래스는 <strong>수집 대상(pointcut)에 이미 포함된 서비스</strong>여야 의미가 있습니다. 여기 등록한다고 수집 범위가 넓어지지는 않습니다
(수집 범위 변경은 <span class="mono">context-syslogaop.xml</span> 수정·배포가 필요합니다).<br />
· 저장하면 매핑 캐시가 즉시 갱신되어 WAS 재기동 없이 반영됩니다.
</div>
<form:form commandName="jobMappingVO" id="frm" name="frm" method="post" onsubmit="return false;">
<form:hidden path="mappingId" />
<table summary="업무유형 매핑 입력" class="write1">
<caption>업무유형 매핑 <c:choose><c:when test="${isModify}">수정</c:when><c:otherwise>등록</c:otherwise></c:choose></caption>
<colgroup>
<col width="20%" />
<col width="*" />
</colgroup>
<tbody>
<tr>
<th scope="row">대상 클래스 <span class="req">*</span></th>
<td>
<form:input path="targetClass" cssClass="tbox w8 mono" maxlength="200" title="대상 서비스 클래스명" />
<span class="hint">서비스 impl 단순 클래스명(예: <span class="mono">EgovMberManageServiceImpl</span>) 또는 패키지 포함 FQN. 200자 이내.</span>
</td>
</tr>
<tr>
<th scope="row">메서드 패턴 <span class="req">*</span></th>
<td>
<form:input path="methodPttrn" cssClass="tbox w4 mono" maxlength="100" title="메서드 패턴" />
<span class="hint">메서드명 <strong>접두</strong> 매칭(예: <span class="mono">select</span>, <span class="mono">insertMber</span>) 또는 <span class="mono">*</span>(해당 클래스 전체).</span>
</td>
</tr>
<tr>
<th scope="row">업무코드 <span class="req">*</span></th>
<td>
<form:input path="jobSeCode" cssClass="tbox w4 mono" maxlength="30" title="수행업무 유형 코드" />
<span class="hint">영문 대문자·숫자·밑줄 30자 이내(예: <span class="mono">MBER_VIEW</span>). 소문자로 입력해도 대문자로 저장됩니다. 접속기록 검색 드롭다운의 값이 됩니다.</span>
</td>
</tr>
<tr>
<th scope="row">업무명 <span class="req">*</span></th>
<td>
<form:input path="jobSeNm" cssClass="tbox w8" maxlength="100" title="수행업무 유형명" />
<span class="hint">화면·기능명(예: 회원관리-조회). 접속기록 목록의 '수행업무' 열에 그대로 표시됩니다. 100자 이내.</span>
</td>
</tr>
<tr>
<th scope="row">처리구분</th>
<td>
<form:select path="dmlGubun" title="처리구분">
<form:option value="" label="자동(메서드명으로 유도)" />
<form:option value="S" label="조회" />
<form:option value="I" label="등록" />
<form:option value="U" label="수정" />
<form:option value="D" label="삭제" />
</form:select>
<span class="hint">'자동' 선택 시 메서드명 접두로 유도합니다(insert·regist·creat·add·save→등록 / update·modify·chang→수정 / delete·remov→삭제 / 그 외→조회).</span>
</td>
</tr>
<tr>
<th scope="row">우선순위</th>
<td>
<form:input path="sortOrdr" cssClass="tbox w2" maxlength="4" title="매칭 우선순위" />
<span class="hint">여러 매핑이 걸리면 <strong>작은 값이 우선</strong>합니다. 구체 규칙은 10~30, <span class="mono">*</span> 폴백은 90 이상을 권장합니다(0~9999).</span>
</td>
</tr>
<tr>
<th scope="row">사용여부</th>
<td>
<form:select path="useAt" title="사용여부">
<form:option value="Y" label="사용" />
<form:option value="N" label="사용중지" />
</form:select>
<span class="hint">사용중지해도 수집은 계속되며, 이 규칙만 매칭 대상에서 빠집니다.</span>
</td>
</tr>
<tr>
<th scope="row">비고</th>
<td>
<form:input path="rm" cssClass="tbox w8" maxlength="300" title="비고" />
<span class="hint">등록 근거·주의사항 등. 300자 이내.</span>
</td>
</tr>
<c:if test="${isModify}">
<tr>
<th scope="row">변경 이력</th>
<td>
등록 <c:out value="${jobMappingVO.regDt}" />
<c:if test="${not empty jobMappingVO.registerId}">(<c:out value="${jobMappingVO.registerId}" />)</c:if>
<c:if test="${not empty jobMappingVO.updtDt}">
/ 최종변경 <c:out value="${jobMappingVO.updtDt}" />
<c:if test="${not empty jobMappingVO.updusrId}">(<c:out value="${jobMappingVO.updusrId}" />)</c:if>
</c:if>
</td>
</tr>
</c:if>
</tbody>
</table>
<div class="btn_area">
<input type="button" class="btn1" value="저장" onclick="fn_save();" />
<input type="button" class="btn2" value="취소" onclick="fn_cancel();" />
</div>
</form:form>
</div>
<!--// 컨텐츠 영역 -->
</div>
<!--// 오른쪽 영역 -->
</div>
<!--// 전체 둘러싸기 -->
</body>
</html>

View File

@ -1,72 +0,0 @@
-- =====================================================================
-- 업무유형 매핑 테이블 확장 : PDAL_JOB_MAPPING 에 변경자·변경일시 컬럼 추가
-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-004 / SER-002)
-- 개발항목 #32-1 (관리 화면) - 작성일: 2026-08-07 DB: MySQL 8 / MariaDB
-- ---------------------------------------------------------------------
-- [사유] #32 는 테이블+로더까지만 구현되어 매핑 행을 DB 직접 INSERT 로만 운영했다.
-- 관리 화면(#32-1)에서 취급자가 매핑을 등록·수정·사용중지할 수 있게 되면,
-- "누가 접속기록 분류를 바꿨는가"가 감사 대상이 된다.
-- 특히 USE_AT='N' 전환은 해당 업무의 접속기록 분류를 무력화하므로
-- (수집 자체는 계속되나 업무코드가 폴백 처리됨) 변경 주체 기록이 필요하다.
--
-- [멱등] MySQL 8 은 ADD COLUMN IF NOT EXISTS 미지원 → information_schema 로
-- 존재를 확인해 없을 때만 실행한다(재실행 안전, MySQL 8 / MariaDB 공통).
--
-- [선행] privacy.job_mapping_create_mysql.sql (PDAL_JOB_MAPPING 생성)
-- =====================================================================
-- ── 등록자 ──
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'PDAL_JOB_MAPPING' AND COLUMN_NAME = 'REGISTER_ID') > 0,
'SELECT ''SKIP: PDAL_JOB_MAPPING.REGISTER_ID already exists'' AS msg',
'ALTER TABLE PDAL_JOB_MAPPING
ADD COLUMN REGISTER_ID VARCHAR(20) NULL COMMENT ''등록자 계정ID(시드행은 NULL)'''
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ── 등록일시 ──
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'PDAL_JOB_MAPPING' AND COLUMN_NAME = 'REG_DT') > 0,
'SELECT ''SKIP: PDAL_JOB_MAPPING.REG_DT already exists'' AS msg',
'ALTER TABLE PDAL_JOB_MAPPING
ADD COLUMN REG_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''등록 일시'''
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ── 최종 변경자 ──
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'PDAL_JOB_MAPPING' AND COLUMN_NAME = 'UPDUSR_ID') > 0,
'SELECT ''SKIP: PDAL_JOB_MAPPING.UPDUSR_ID already exists'' AS msg',
'ALTER TABLE PDAL_JOB_MAPPING
ADD COLUMN UPDUSR_ID VARCHAR(20) NULL COMMENT ''최종 변경자 계정ID'''
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ── 최종 변경일시 ──
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'PDAL_JOB_MAPPING' AND COLUMN_NAME = 'UPDT_DT') > 0,
'SELECT ''SKIP: PDAL_JOB_MAPPING.UPDT_DT already exists'' AS msg',
'ALTER TABLE PDAL_JOB_MAPPING
ADD COLUMN UPDT_DT DATETIME NULL COMMENT ''최종 변경 일시'''
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- [검증]
-- SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT
-- FROM information_schema.COLUMNS
-- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'PDAL_JOB_MAPPING'
-- ORDER BY ORDINAL_POSITION;
-- -- 기대: 기존 9컬럼 + REGISTER_ID / REG_DT / UPDUSR_ID / UPDT_DT = 13컬럼
-- [롤백]
-- ALTER TABLE PDAL_JOB_MAPPING
-- DROP COLUMN REGISTER_ID, DROP COLUMN REG_DT,
-- DROP COLUMN UPDUSR_ID, DROP COLUMN UPDT_DT;

View File

@ -1,68 +0,0 @@
-- =====================================================================
-- 개인정보 처리 업무유형 매핑 테이블 : PDAL_JOB_MAPPING
-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-004 / SER-002)
-- 개발항목 #32 - 담당: arko-privacy-auditlog (Fork B)
-- 작성일: 2026-07-21 DB: MySQL 8 / MariaDB
-- ---------------------------------------------------------------------
-- [목적] 접속기록 수집(#31 PersonalDataLogAspect)이 가로챈 서비스 메서드를
-- 업무코드(JOB_SE_CODE)·업무명(JOB_SE_NM)으로 매핑한다.
-- AOP pointcut 범위(개인정보 처리 서비스 패키지) 안에서 신규 화면이 추가되면
-- 이 테이블에 행만 등록하면 수집되며, Aspect/pointcut 소스 수정이 불요하다.
--
-- [매칭 규칙] Aspect가 (대상클래스 단순명, 메서드명)으로 조회.
-- - TARGET_CLASS : 서비스 impl 단순 클래스명(예: EgovMberManageServiceImpl) 또는 FQN
-- - METHOD_PTTRN : 메서드명 접두 매칭(예: 'select','insertMber') 또는 '*'(전체)
-- - DML_GUBUN : 지정 시 우선, NULL이면 메서드명 접두로 유도(insert*→I/update*→U/delete*→D/그외 S)
-- - SORT_ORDR : 다중 매칭 시 낮은 값 우선(구체 규칙을 위로)
--
-- ※ URL 패턴 기반 수집이 필요하면(서블릿 필터형) 동일 테이블에 URL_PTTRN 컬럼을 추가해 확장.
-- =====================================================================
CREATE TABLE IF NOT EXISTS PDAL_JOB_MAPPING (
MAPPING_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '매핑 일련번호(PK)',
TARGET_CLASS VARCHAR(200) NOT NULL COMMENT '대상 서비스 impl 단순 클래스명 또는 FQN',
METHOD_PTTRN VARCHAR(100) NOT NULL DEFAULT '*' COMMENT '메서드명 접두 매칭(또는 * 전체)',
JOB_SE_CODE VARCHAR(30) NOT NULL COMMENT '수행업무 유형 코드',
JOB_SE_NM VARCHAR(100) NOT NULL COMMENT '수행업무 유형(화면·기능명)',
DML_GUBUN CHAR(1) NULL COMMENT '처리구분 강제값 S/I/U/D (NULL=메서드명 유도)',
SORT_ORDR INT NOT NULL DEFAULT 100 COMMENT '매칭 우선순위(낮을수록 우선)',
USE_AT CHAR(1) NOT NULL DEFAULT 'Y' COMMENT '사용여부 Y/N',
RM VARCHAR(300) NULL COMMENT '비고',
PRIMARY KEY (MAPPING_ID),
KEY IX_PJM_LOOKUP (USE_AT, TARGET_CLASS, SORT_ORDR)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='개인정보 처리 업무유형 매핑(접속기록 #31/#32)';
-- ---------------------------------------------------------------------
-- 초기 매핑 시드 : 현행 개인정보 처리 서비스(#31 pointcut 대상과 일치)
-- 신규 개인정보 화면 추가 시 아래 UNION ALL 목록에 행만 추가하면 수집된다.
--
-- [멱등] (TARGET_CLASS, METHOD_PTTRN) 조합이 이미 있으면 넣지 않는다.
-- 테이블에 유니크 제약이 없어(운영 중 행 추가/삭제 자유) 재실행 시
-- 시드가 통째로 중복 적재되는 것을 이 NOT EXISTS 가드로 막는다.
-- ---------------------------------------------------------------------
INSERT INTO PDAL_JOB_MAPPING (TARGET_CLASS, METHOD_PTTRN, JOB_SE_CODE, JOB_SE_NM, DML_GUBUN, SORT_ORDR, RM)
SELECT s.TARGET_CLASS, s.METHOD_PTTRN, s.JOB_SE_CODE, s.JOB_SE_NM, s.DML_GUBUN, s.SORT_ORDR, s.RM
FROM (
SELECT 'EgovMberManageServiceImpl' AS TARGET_CLASS, 'select' AS METHOD_PTTRN, 'MBER_VIEW' AS JOB_SE_CODE, '회원관리-조회' AS JOB_SE_NM, 'S' AS DML_GUBUN, 10 AS SORT_ORDR, '회원 개인정보 조회' AS RM
UNION ALL SELECT 'EgovMberManageServiceImpl', 'insert', 'MBER_REG', '회원관리-등록', 'I', 10, NULL
UNION ALL SELECT 'EgovMberManageServiceImpl', 'update', 'MBER_MOD', '회원관리-수정', 'U', 10, NULL
UNION ALL SELECT 'EgovMberManageServiceImpl', 'delete', 'MBER_DEL', '회원관리-삭제', 'D', 10, NULL
UNION ALL SELECT 'EgovMberManageServiceImpl', '*', 'MBER_ETC', '회원관리-기타', NULL, 90, '미분류 폴백'
UNION ALL SELECT 'EgovEntrprsManageServiceImpl', 'select', 'ENTRPRS_VIEW', '업체관리-조회', 'S', 10, '업체 담당자 개인정보 조회'
UNION ALL SELECT 'EgovEntrprsManageServiceImpl', 'insert', 'ENTRPRS_REG', '업체관리-등록', 'I', 10, NULL
UNION ALL SELECT 'EgovEntrprsManageServiceImpl', 'update', 'ENTRPRS_MOD', '업체관리-수정', 'U', 10, NULL
UNION ALL SELECT 'EgovEntrprsManageServiceImpl', 'delete', 'ENTRPRS_DEL', '업체관리-삭제', 'D', 10, NULL
UNION ALL SELECT 'EgovEntrprsManageServiceImpl', '*', 'ENTRPRS_ETC', '업체관리-기타', NULL, 90, '미분류 폴백'
UNION ALL SELECT 'MberSsnServiceImpl', '*', 'MBER_SSN', '회원 고유식별정보 처리', NULL, 20, '주민등록번호 등 고유식별정보'
UNION ALL SELECT 'BizMberMergeServiceImpl', '*', 'MBER_MERGE', '회원 통합/이관', NULL, 30, NULL
UNION ALL SELECT 'BizTalentMberManageServiceImpl', '*', 'TALENT_MBER', '재능나눔 회원관리', NULL, 30, NULL
) s
WHERE NOT EXISTS (
SELECT 1 FROM PDAL_JOB_MAPPING t
WHERE t.TARGET_CLASS = s.TARGET_CLASS
AND t.METHOD_PTTRN = s.METHOD_PTTRN
);
-- [검증] SELECT COUNT(*) FROM PDAL_JOB_MAPPING; -- 시드 13건(운영 추가분 별도)
-- [롤백] DROP TABLE PDAL_JOB_MAPPING;

View File

@ -1,59 +0,0 @@
-- =====================================================================
-- 관리자 메뉴 등록 : 로그인·로그아웃 접속기록 (MENU_NO 9100046)
-- 문화품앗이 개인정보보호 기능개선 사업 (SER-001 / SER-002, 개발항목 62)
-- 작성일: 2026-08-07 DB: MySQL 8 / MariaDB
-- ---------------------------------------------------------------------
-- [배치] 시스템관리(9100000) > 권한관리(9100030) 하위 (MENU_ORDR 12).
-- 선행: 9100039 인증 실패 이력 / 9100044 개인정보 접속기록 /
-- 9100045 업무유형 매핑 관리 — 같은 보안 화면 묶음.
-- [번호] 9100040~9100043 은 게시판관리 그룹이 선점 → 다음 가용 번호 9100046.
-- [권한] IS_AUTHENTICATED_FULLY, ROLE_ADMIN (선행 화면들과 동일)
-- [재실행] 모든 INSERT NOT EXISTS 가드 → 반복 실행 무해.
--
-- ★★ 이 DML 만으로는 좌측메뉴에 안 뜬다 ★★
-- left.jsp 는 메뉴번호 하드코딩 JSP라 9100046 <c:when> 분기 +
-- 상위 하이라이트 조건(param.page eq '9100046') 5곳이 함께 배포돼야 렌더된다.
-- (arko_renual 소스에는 반영 완료 — 배포본에 JSP 포함 여부만 확인)
--
-- ※ 한글 리터럴 포함 — 반드시 UTF-8 로 접속해 실행할 것
-- (mysql --default-character-set=utf8mb4). 적용 후 HEX() 로 깨짐 확인 권장.
-- =====================================================================
-- ── [1] 프로그램(URL) 등록 (COMTNMENUINFO.PROGRM_FILE_NM FK, 메뉴보다 먼저) ──
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
SELECT * FROM (
SELECT 'ConectLogList' AS PROGRM_FILE_NM,
'/mnt/sec/conectLog/' AS PROGRM_STRE_PATH,
'로그인 접속기록' AS PROGRM_KOREAN_NM,
'로그인·로그아웃 접속기록 조회·다운로드(접속ID·접속일시·IP·접속위치, 개발항목 62)' AS PROGRM_DC,
'/mnt/sec/conectLog/listView.do?page=9100046' AS URL
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM);
-- ── [2] 메뉴 등록 : 권한관리(9100030) 하위, MENU_ORDR 12 ──
INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM)
SELECT * FROM (
SELECT '로그인 접속기록' AS MENU_NM, 'ConectLogList' AS PROGRM_FILE_NM, 9100046 AS MENU_NO,
9100030 AS UPPER_MENU_NO, 12 AS MENU_ORDR, '로그인·로그아웃 접속기록 조회' AS MENU_DC,
'/' AS RELATE_IMAGE_PATH, '/' AS RELATE_IMAGE_NM
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO m WHERE m.MENU_NO = seed.MENU_NO);
-- ── [3] 권한↔메뉴 노출 매핑 (1건 × 2권한, FK 안전) ──
INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE)
SELECT 9100046, g.ac
FROM (SELECT 'IS_AUTHENTICATED_FULLY' AS ac UNION ALL SELECT 'ROLE_ADMIN') g
WHERE g.ac IN (SELECT AUTHOR_CODE FROM COMTNAUTHORINFO)
AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x
WHERE x.MENU_NO = 9100046 AND x.AUTHOR_CODE = g.ac);
-- ── [검증] ──
-- SELECT m.MENU_NO, m.MENU_NM, HEX(m.MENU_NM), m.MENU_ORDR, p.URL,
-- (SELECT COUNT(*) FROM COMTNMENUCREATDTLS d WHERE d.MENU_NO = m.MENU_NO) grants
-- FROM COMTNMENUINFO m JOIN COMTNPROGRMLIST p ON m.PROGRM_FILE_NM = p.PROGRM_FILE_NM
-- WHERE m.MENU_NO = 9100046;
--
-- ── [롤백] ──
-- DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO = 9100046;
-- DELETE FROM COMTNMENUINFO WHERE MENU_NO = 9100046;
-- DELETE FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM = 'ConectLogList';

View File

@ -0,0 +1,45 @@
-- =====================================================================
-- 관리자 메뉴 삭제 : 업무유형 매핑 관리(9100045) + 로그인 접속기록(9100046)
-- 문화품앗이 개인정보보호 기능개선 사업 — 2026-08-11 화면 폐기
-- DB: MySQL 8 / MariaDB (스키마 csv)
-- ---------------------------------------------------------------------
-- [대체] 종전 등록 DML 2건을 되돌린다(두 파일 모두 삭제됨).
-- 구 privacy.menu_job_mapping_insert_mysql.sql (9100045 / JobMappingList)
-- 구 privacy.menu_conect_log_insert_mysql.sql (9100046 / ConectLogList)
--
-- [삭제 순서] 권한매핑 → 메뉴 → 프로그램. COMTNMENUINFO.PROGRM_FILE_NM 이
-- COMTNPROGRMLIST 를 FK 로 참조하므로 역순이면 FK 위반 또는 잔재가 남는다.
--
-- [재실행 안전] DELETE ... WHERE IN 은 멱등 — 반복 실행해도 0 rows affected.
-- [한글 리터럴 없음] 캐릭터셋 사고 위험 없음(접속 인코딩 무관하게 실행 가능).
--
-- ★★ left.jsp 수정과 반드시 동반 배포 ★★
-- src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp 에서 9100045/9100046 <c:when>
-- 분기와 상위 하이라이트 조건 8곳을 제거했다.
-- · JSP 만 올리고 이 DML 을 안 돌리면 → 메뉴 행이 DB 에 남아 좌측메뉴 렌더 시
-- 어느 <c:when> 에도 안 걸려 조용히 사라지지만, URL 직접 접근 경로는 살아 있다
-- (컨트롤러가 삭제됐으므로 404).
-- · 이 DML 만 돌리고 JSP 를 안 올리면 → 분기가 매칭될 대상이 없어 무해.
-- 따라서 사고 위험이 낮은 순서는 "DML 먼저 → JSP 배포" 이다.
--
-- ※ PDAL_JOB_MAPPING / CONECT_IP_LOCATION 테이블은 DROP 하지 않는다.
-- CONECT_IP_LOCATION 은 IpLocationResolver 가 계속 조회해
-- COMTNLOGINLOG.CONECT_LC 적재에 쓰므로 DROP 하면 접속위치가 전부 NULL 이 된다.
-- =====================================================================
-- ── [1] 권한↔메뉴 노출 매핑 제거 ──
DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO IN (9100045, 9100046);
-- ── [2] 메뉴 제거 ──
DELETE FROM COMTNMENUINFO WHERE MENU_NO IN (9100045, 9100046);
-- ── [3] 프로그램(URL) 제거 ──
DELETE FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM IN ('JobMappingList', 'ConectLogList');
-- ── [검증] 세 쿼리 모두 0 건이어야 한다 ──
-- SELECT COUNT(*) FROM COMTNMENUCREATDTLS WHERE MENU_NO IN (9100045, 9100046);
-- SELECT COUNT(*) FROM COMTNMENUINFO WHERE MENU_NO IN (9100045, 9100046);
-- SELECT COUNT(*) FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM IN ('JobMappingList', 'ConectLogList');
-- -- 잔존 개인정보 메뉴 확인(9100044, 9100047 은 남아 있어야 정상):
-- SELECT MENU_NO, MENU_NM, MENU_ORDR FROM COMTNMENUINFO
-- WHERE UPPER_MENU_NO = 9100030 ORDER BY MENU_ORDR;

View File

@ -1,60 +0,0 @@
-- =====================================================================
-- 관리자 메뉴 등록 : 업무유형 매핑 관리 (개인정보보호 SFR-004 / #32-1)
-- 대상 화면 : /mnt/sec/jobMapping/listView.do
-- 작성일 : 2026-08-07 DB: MySQL 8 (운영 DB가 MySQL 이라 Oracle 쌍 미작성)
-- ---------------------------------------------------------------------
-- [배치] 권한관리(9100030) 하위, 개인정보 접속기록(9100044) 바로 다음.
-- 접속기록 화면의 '수행업무' 분류 기준을 편집하는 화면이라 인접 배치한다.
-- ※ 9100040~9100043 은 '게시판관리' 그룹이 선점 → 9100045 사용(MENU_ORDR 11)
--
-- ★★ 이 DML만으로는 좌측메뉴에 안 뜬다 — left.jsp 수정 배포 필수 ★★
-- src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp 는 메뉴번호 하드코딩 JSP라
-- ① 9100045 <c:when> 분기 1곳 + ② 상위 하이라이트 조건 4곳
-- (시스템관리 class="on" / 시스템관리 display:block / 권한관리 class="on" /
-- 권한관리 ul display:block)에 param.page eq '9100045' 가 함께 배포돼야 렌더된다.
-- (arko_renual 소스에는 반영 완료 — 배포본에 이 JSP가 포함됐는지만 확인)
--
-- [재실행 안전] 모든 INSERT NOT EXISTS 가드 → 반복 실행 무해.
-- [노출권한] IS_AUTHENTICATED_FULLY, ROLE_ADMIN (기존 개인정보 메뉴와 동일)
-- [적용순서] COMTNPROGRMLIST → COMTNMENUINFO → COMTNMENUCREATDTLS (FK 순서)
-- =====================================================================
-- ── [1] 프로그램(URL) 등록 (COMTNMENUINFO.PROGRM_FILE_NM → FK, 메뉴보다 먼저) ──
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
SELECT * FROM (
SELECT 'JobMappingList' AS PROGRM_FILE_NM, '/mnt/sec/jobMapping/' AS PROGRM_STRE_PATH,
'업무유형 매핑 관리' AS PROGRM_KOREAN_NM,
'개인정보 접속기록 업무유형 매핑 관리(SFR-004 #32-1)' AS PROGRM_DC,
'/mnt/sec/jobMapping/listView.do?page=9100045' AS URL
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM);
-- ── [2] 메뉴 등록 : 권한관리(9100030) 하위, MENU_ORDR 11 ──
INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM)
SELECT * FROM (
SELECT '업무유형 매핑 관리' AS MENU_NM, 'JobMappingList' AS PROGRM_FILE_NM, 9100045 AS MENU_NO,
9100030 AS UPPER_MENU_NO, 11 AS MENU_ORDR, '접속기록 업무유형 매핑 등록·수정' AS MENU_DC,
'/' AS RELATE_IMAGE_PATH, '/' AS RELATE_IMAGE_NM
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO m WHERE m.MENU_NO = seed.MENU_NO);
-- ── [3] 권한↔메뉴 노출 매핑 (FK 안전) ──
INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE)
SELECT 9100045, g.ac
FROM (SELECT 'IS_AUTHENTICATED_FULLY' AS ac UNION ALL SELECT 'ROLE_ADMIN') g
WHERE g.ac IN (SELECT AUTHOR_CODE FROM COMTNAUTHORINFO)
AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x
WHERE x.MENU_NO = 9100045 AND x.AUTHOR_CODE = g.ac);
-- ── [검증] ──
-- SELECT m.MENU_NO, m.UPPER_MENU_NO, m.MENU_NM, m.MENU_ORDR, p.URL,
-- (SELECT COUNT(*) FROM COMTNMENUCREATDTLS d WHERE d.MENU_NO = m.MENU_NO) grants
-- FROM COMTNMENUINFO m JOIN COMTNPROGRMLIST p ON m.PROGRM_FILE_NM = p.PROGRM_FILE_NM
-- WHERE m.MENU_NO = 9100045;
-- -- 한글 깨짐 확인: SELECT HEX(MENU_NM) FROM COMTNMENUINFO WHERE MENU_NO = 9100045;
-- -- '업' = EC9785 로 시작하면 정상(UTF-8)
-- ── [롤백] (역순) ──
-- DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO = 9100045;
-- DELETE FROM COMTNMENUINFO WHERE MENU_NO = 9100045;
-- DELETE FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM = 'JobMappingList';

View File

@ -1,18 +1,35 @@
-- ##################################################################### -- #####################################################################
-- arko_renual 설치용 통합 SQL (개인정보보호 기능개선 사업 · arko 대비 변동분) -- arko_renual 설치용 통합 SQL (개인정보보호 기능개선 사업 · arko 대비 변동분)
-- 대상 DB : MySQL 5.7 / MariaDB (스키마 csv) -- 대상 DB : MySQL 5.7 / MariaDB (스키마 csv)
-- 구성 : [PART 1] DDL(테이블 생성·변경) → [PART 2] DML(기준/시드 데이터) -- 구성 : [PART 1] DDL(테이블 생성·변경, 24개 테이블/인덱스)
-- → [PART 2] DML(기준/시드 데이터)
-- → [PART 3] 관리자 메뉴 등록 DML(좌측메뉴 노출, COMTNPROGRMLIST/ -- → [PART 3] 관리자 메뉴 등록 DML(좌측메뉴 노출, COMTNPROGRMLIST/
-- COMTNMENUINFO/COMTNMENUCREATDTLS, 9100035~9100039 + 9100044 + 9100045) -- COMTNMENUINFO/COMTNMENUCREATDTLS, 9100035~9100039 + 9100044 + 9100047)
-- 실행순서: 반드시 PART 1 → PART 2 → PART 3 순서로 실행 -- ※ 9100045(업무유형 매핑 관리)·9100046(로그인 접속기록)은 2026-08-11 폐기
-- (테이블 생성 → 시드 적재 → 메뉴 등록. 메뉴는 FK로 프로그램 등록을 먼저 요구) -- → [PART 4] 뷰 GNR 컷오버(comvnusermaster/comvnuserinfo, 회원 이름 암호화
-- 최종 단계) — **자체 게이트 내장**, 백필 미완료 시 자동 SKIP(PART 4 헤더 참조)
-- 실행순서: 반드시 PART 1 → PART 2 → PART 3 → PART 4 순서로 실행
-- (테이블 생성 → 시드 적재 → 메뉴 등록 → 뷰 컷오버. 메뉴는 FK로 프로그램
-- 등록을 먼저 요구하고, PART 4는 PART 1의 COMTNGNRLMBER 컬럼 추가를 전제한다)
-- 재실행 : **전 구간 재실행 안전(멱등)** — PART 1 은 CREATE TABLE IF NOT EXISTS 와 -- 재실행 : **전 구간 재실행 안전(멱등)** — PART 1 은 CREATE TABLE IF NOT EXISTS 와
-- information_schema 존재 확인 후 실행되는 ALTER(컬럼/인덱스), PART 2·3 은 -- information_schema 존재 확인 후 실행되는 ALTER(컬럼/인덱스), PART 2·3 은
-- NOT EXISTS 가드를 쓴다. 이미 적용된 항목은 'SKIP: ...' 메시지만 출력하고 -- NOT EXISTS 가드, PART 4 는 자체 게이트(이미 적용됨/백필 미완료 시 SKIP)를 쓴다.
-- 넘어가므로, 기존 환경에도 이 파일 하나만 그대로 실행하면 된다. -- 이미 적용된 항목은 'SKIP: ...' 메시지만 출력하고 넘어가므로, 기존 환경에도
-- 이 파일 하나만 그대로 실행하면 된다(PART 4는 백필 완료 후 재실행 1회 필요할 수 있음).
-- (2026-08-05 이전 버전은 재실행 시 1050/1060/1061 오류가 났다) -- (2026-08-05 이전 버전은 재실행 시 1050/1060/1061 오류가 났다)
-- ★★ PART 3 적용만으로는 좌측메뉴에 안 뜬다 — left.jsp 하드코딩 <c:when> 분기가 -- ★★ PART 3 적용만으로는 좌측메뉴에 안 뜬다 — left.jsp 하드코딩 <c:when> 분기가
-- 반드시 함께 배포돼야 한다(소스에는 이미 반영됨). 상세는 PART 3 헤더 참조. ★★ -- 반드시 함께 배포돼야 한다(소스에는 이미 반영됨). 상세는 PART 3 헤더 참조. ★★
-- ★★ PART 4(뷰 컷오버) 전에 회원 이름 백필 배치가 끝나 있어야 한다 —
-- /mnt/sec/memberNmCrypto/start.do?confirm=BACKFILL(POST, ROLE_ADMIN, 2026-08-11 신설
-- MemberNmCryptoController). 안 끝났으면 PART 4는 조용히 SKIP만 하고 넘어간다(에러 아님). ★★
-- 2026-08-11 개정: DDL 6건(access_control_policy/download_job/emplyr_scrty_index/
-- mber_nm_encrypt_create/mber_nm_dead_views_drop 5건→PART 1, mber_nm_view_hash_cutover
-- 1건→신설 PART 4) + 메뉴 1건(access_policy 9100047→PART 3)을 통합해 이 파일 하나가
-- 다시 완전한 정본이 되도록 했다(그 전까지 DDL 27건 중 19건만 커버하던 drift 해소).
-- `backup/privacy.mber_nm_backup_mysql.sql`(평문 백업)·
-- `backup/privacy.mber_nm_dead_views_backup_20260810.sql`(죽은 뷰 정의 백업)은
-- 되돌리기용 스냅샷 성격이라 이 통합 파일 실행 **전**에 별도로 실행하는 것을 권장하며
-- 통합하지 않았다(설치 로직이 아니라 사고 대비 아카이브라 실행 시점이 다름).
-- ##################################################################### -- #####################################################################
@ -189,103 +206,22 @@ CREATE TABLE IF NOT EXISTS AUTHOR_HIST_DOWNLOAD_HISTORY (
COMMENT='권한이력 다운로드 증빙 이력(개인정보보호 SFR-001 #6)'; COMMENT='권한이력 다운로드 증빙 이력(개인정보보호 SFR-001 #6)';
-- ───────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────
-- [privacy.job_mapping_create_mysql.sql] -- [PDAL_JOB_MAPPING — 2026-08-11 제거]
-- ───────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────
-- ===================================================================== -- =====================================================================
-- 개인정보 처리 업무유형 매핑 테이블 : PDAL_JOB_MAPPING -- 업무유형 매핑 테이블(PDAL_JOB_MAPPING) 생성 DDL 과 변경자 컬럼 확장 DDL 이
-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-004 / SER-002) -- 여기 있었다(구 privacy.job_mapping_create_mysql.sql / job_mapping_audit_cols_mysql.sql).
-- 개발항목 #32 - 담당: arko-privacy-auditlog (Fork B)
-- 작성일: 2026-07-21 DB: MySQL 8 / MariaDB
-- ---------------------------------------------------------------------
-- [목적] 접속기록 수집(#31 PersonalDataLogAspect)이 가로챈 서비스 메서드를
-- 업무코드(JOB_SE_CODE)·업무명(JOB_SE_NM)으로 매핑한다.
-- AOP pointcut 범위(개인정보 처리 서비스 패키지) 안에서 신규 화면이 추가되면
-- 이 테이블에 행만 등록하면 수집되며, Aspect/pointcut 소스 수정이 불요하다.
-- --
-- [매칭 규칙] Aspect가 (대상클래스 단순명, 메서드명)으로 조회. -- [제거 사유] 이 테이블은 폐기된 서비스 AOP 수집 방식(PersonalDataLogAspect)의
-- - TARGET_CLASS : 서비스 impl 단순 클래스명(예: EgovMberManageServiceImpl) 또는 FQN -- 분류 기준이었다. 2026-08-07 수집이 MenuAccessLogInterceptor(메뉴명 기반)로 바뀌면서
-- - METHOD_PTTRN : 메서드명 접두 매칭(예: 'select','insertMber') 또는 '*'(전체) -- 소비자가 없어졌고, 2026-08-11 관리 화면(메뉴 9100045)까지 삭제하면서 DDL 을 뺐다.
-- - DML_GUBUN : 지정 시 우선, NULL이면 메서드명 접두로 유도(insert*→I/update*→U/delete*→D/그외 S) -- 접속기록의 수행업무(JOB_SE_CODE/JOB_SE_NM) 자체는 그대로 유지된다 — 값의 출처가
-- - SORT_ORDR : 다중 매칭 시 낮은 값 우선(구체 규칙을 위로) -- 매핑 테이블에서 COMTNMENUINFO 메뉴명으로 바뀐 것뿐이다(SFR-004 요건 회귀 아님).
-- --
-- ※ URL 패턴 기반 수집이 필요하면(서블릿 필터형) 동일 테이블에 URL_PTTRN 컬럼을 추가해 확장. -- ★ 기존 운영 DB 의 PDAL_JOB_MAPPING 테이블은 DROP 하지 않는다(별도 판단 사항).
-- FK 로 참조하는 테이블이 없고 조회하는 sqlMap 도 남아 있지 않아 방치해도 무해하다.
-- ===================================================================== -- =====================================================================
CREATE TABLE IF NOT EXISTS PDAL_JOB_MAPPING (
MAPPING_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '매핑 일련번호(PK)',
TARGET_CLASS VARCHAR(200) NOT NULL COMMENT '대상 서비스 impl 단순 클래스명 또는 FQN',
METHOD_PTTRN VARCHAR(100) NOT NULL DEFAULT '*' COMMENT '메서드명 접두 매칭(또는 * 전체)',
JOB_SE_CODE VARCHAR(30) NOT NULL COMMENT '수행업무 유형 코드',
JOB_SE_NM VARCHAR(100) NOT NULL COMMENT '수행업무 유형(화면·기능명)',
DML_GUBUN CHAR(1) NULL COMMENT '처리구분 강제값 S/I/U/D (NULL=메서드명 유도)',
SORT_ORDR INT NOT NULL DEFAULT 100 COMMENT '매칭 우선순위(낮을수록 우선)',
USE_AT CHAR(1) NOT NULL DEFAULT 'Y' COMMENT '사용여부 Y/N',
RM VARCHAR(300) NULL COMMENT '비고',
PRIMARY KEY (MAPPING_ID),
KEY IX_PJM_LOOKUP (USE_AT, TARGET_CLASS, SORT_ORDR)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='개인정보 처리 업무유형 매핑(접속기록 #31/#32)';
-- ---------------------------------------------------------------------
-- 초기 매핑 시드 : 현행 개인정보 처리 서비스(#31 pointcut 대상과 일치)
-- 신규 개인정보 화면 추가 시 아래 형식으로 행만 추가하면 수집된다.
-- ※ 시드 INSERT 본문은 PART 2 로 분리되어 있다.
-- ---------------------------------------------------------------------
-- ─────────────────────────────────────────────────────────────
-- [privacy.job_mapping_audit_cols_mysql.sql]
-- ─────────────────────────────────────────────────────────────
-- =====================================================================
-- 업무유형 매핑 테이블 확장 : PDAL_JOB_MAPPING 에 변경자·변경일시 컬럼 추가
-- 개발항목 #32-1 (관리 화면) - 2026-08-07
-- ---------------------------------------------------------------------
-- [사유] 관리 화면에서 취급자가 매핑을 등록·수정·사용중지할 수 있게 되면
-- "누가 접속기록 분류를 바꿨는가"가 감사 대상이 된다.
-- [멱등] information_schema 존재 확인 후 ALTER (재실행 안전).
-- ★ 관리 화면 sqlMap(PrivacyJobMapping_SQL_Mysql.xml)이 이 4개 컬럼을 SELECT/INSERT 하므로
-- 소스 배포보다 이 DDL 적용이 반드시 선행돼야 한다(미적용 시 1054 Unknown column).
-- =====================================================================
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'PDAL_JOB_MAPPING' AND COLUMN_NAME = 'REGISTER_ID') > 0,
'SELECT ''SKIP: PDAL_JOB_MAPPING.REGISTER_ID already exists'' AS msg',
'ALTER TABLE PDAL_JOB_MAPPING
ADD COLUMN REGISTER_ID VARCHAR(20) NULL COMMENT ''등록자 계정ID(시드행은 NULL)'''
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'PDAL_JOB_MAPPING' AND COLUMN_NAME = 'REG_DT') > 0,
'SELECT ''SKIP: PDAL_JOB_MAPPING.REG_DT already exists'' AS msg',
'ALTER TABLE PDAL_JOB_MAPPING
ADD COLUMN REG_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''등록 일시'''
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'PDAL_JOB_MAPPING' AND COLUMN_NAME = 'UPDUSR_ID') > 0,
'SELECT ''SKIP: PDAL_JOB_MAPPING.UPDUSR_ID already exists'' AS msg',
'ALTER TABLE PDAL_JOB_MAPPING
ADD COLUMN UPDUSR_ID VARCHAR(20) NULL COMMENT ''최종 변경자 계정ID'''
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'PDAL_JOB_MAPPING' AND COLUMN_NAME = 'UPDT_DT') > 0,
'SELECT ''SKIP: PDAL_JOB_MAPPING.UPDT_DT already exists'' AS msg',
'ALTER TABLE PDAL_JOB_MAPPING
ADD COLUMN UPDT_DT DATETIME NULL COMMENT ''최종 변경 일시'''
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ───────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────
-- [privacy.download_history_create_mysql.sql] -- [privacy.download_history_create_mysql.sql]
@ -909,32 +845,225 @@ CREATE TABLE IF NOT EXISTS LOGIN_LOCK_EXEMPT (
COMMENT='로그인 실패 계정잠금 예외 계정(개인정보보호 SER-001 24, 최고관리자 잠김 방지)'; COMMENT='로그인 실패 계정잠금 예외 계정(개인정보보호 SER-001 24, 최고관리자 잠김 방지)';
-- ─────────────────────────────────────────────────────────────
-- [privacy.access_control_policy_create_mysql.sql] (2026-08-11 통합)
-- ─────────────────────────────────────────────────────────────
-- =====================================================================
-- 접근통제 정책 : ACCESS_CONTROL_POLICY (단일행 정책 테이블)
-- 문화품앗이 개인정보보호 기능개선 사업 (SER-001 요건 4·5 / 접근통제 정책 적용)
-- [용도] 계정 잠금 임계값·자동 로그아웃 시간·세션 재발급 여부를 관리자 설정값으로 운영.
-- [반영 경로] LOCK_THRESHOLD→LOGIN_LOCK_COUNT 동기화 / AUTO_LOGOUT_MIN→SessionPolicyInterceptor
-- / SESSION_REGEN_YN→SecondFactorController 세션 재발급
-- [재실행 안전] CREATE TABLE IF NOT EXISTS + 시드 NOT EXISTS 가드
-- =====================================================================
CREATE TABLE IF NOT EXISTS ACCESS_CONTROL_POLICY (
POLICY_ID INT NOT NULL DEFAULT 1 COMMENT '정책 식별자(단일행=1)',
LOCK_THRESHOLD INT NOT NULL DEFAULT 5 COMMENT '인증 실패 계정 잠금 임계값(회). 현행 10 -> 5',
UNLOCK_MODE VARCHAR(20) NOT NULL DEFAULT 'ADMIN_HIST' COMMENT '잠금 해제 방식 ADMIN_HIST:관리자화면+이력 / ADMIN_HIST_RESN:사유 필수',
AUTO_LOGOUT_MIN INT NOT NULL DEFAULT 30 COMMENT '미사용 자동 로그아웃(분). 현행 web.xml 60 -> 30',
EXPIRE_NOTICE_YN CHAR(1) NOT NULL DEFAULT 'Y' COMMENT '세션 만료 임박 안내 사용여부 Y/N',
EXPIRE_NOTICE_MIN INT NOT NULL DEFAULT 5 COMMENT '만료 임박 안내 시점(만료 N분 전)',
SESSION_REGEN_YN CHAR(1) NOT NULL DEFAULT 'Y' COMMENT '2차 인증 성공 시 세션 재발급(세션고정 차단) 사용여부 Y/N',
UPD_ID VARCHAR(20) NULL COMMENT '최종 수정자',
UPD_DT DATETIME NULL COMMENT '최종 수정일시',
PRIMARY KEY (POLICY_ID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='접근통제 정책(개인정보보호 SER-001 요건4-5, 단일행)';
INSERT INTO ACCESS_CONTROL_POLICY
(POLICY_ID, LOCK_THRESHOLD, UNLOCK_MODE, AUTO_LOGOUT_MIN,
EXPIRE_NOTICE_YN, EXPIRE_NOTICE_MIN, SESSION_REGEN_YN, UPD_ID, UPD_DT)
SELECT 1, 5, 'ADMIN_HIST', 30, 'Y', 5, 'Y', 'SYSTEM', NOW()
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM ACCESS_CONTROL_POLICY WHERE POLICY_ID = 1);
-- 인증 경로(EgovLoginController/EgovSpringSecurityLoginFilter)가 읽는 LOGIN_LOCK_COUNT 를
-- 정책값과 일치시킨다(화면 저장 시에도 AccessControlPolicyServiceImpl 이 동일하게 동기화).
UPDATE LOGIN_LOCK_COUNT
SET LOCK_COUNT = (SELECT LOCK_THRESHOLD FROM ACCESS_CONTROL_POLICY WHERE POLICY_ID = 1);
INSERT INTO LOGIN_LOCK_COUNT (LOCK_COUNT)
SELECT (SELECT LOCK_THRESHOLD FROM ACCESS_CONTROL_POLICY WHERE POLICY_ID = 1)
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM LOGIN_LOCK_COUNT);
-- ─────────────────────────────────────────────────────────────
-- [privacy.download_job_create_mysql.sql] (2026-08-11 통합)
-- ─────────────────────────────────────────────────────────────
-- =====================================================================
-- 개인정보 접속기록 CSV 비동기 다운로드 작업 : PDAL_DOWNLOAD_JOB (SFR-004-6 / SER-002)
-- [보안 전제] FILE_PATH 는 반드시 웹 루트 밖(Globals.privacyDownloadPath), 저장 파일은
-- 암호화(평문 CSV 미보관). EXPIRE_DT 경과분은 파기 배치가 파일 삭제+STATUS='X'.
-- [상태 전이] W(대기) -> R(생성중) -> S(완료) -> X(파기) / 실패는 F
-- =====================================================================
CREATE TABLE IF NOT EXISTS PDAL_DOWNLOAD_JOB (
JOB_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '작업 일련번호(PK)',
REQUESTER_ID VARCHAR(20) NOT NULL COMMENT '요청자 계정ID(취급자)',
REQUESTER_IP VARCHAR(45) NULL COMMENT '요청 IP(IPv6 대응 45)',
DL_REASON VARCHAR(500) NOT NULL COMMENT '다운로드 사유(필수)',
SEARCH_COND VARCHAR(1000) NULL COMMENT '검색조건 요약(개인정보 본문 미포함)',
F_CONECT_ID VARCHAR(50) NULL COMMENT '조건: 접속자ID',
F_JOB_SE_CODE VARCHAR(30) NULL COMMENT '조건: 수행업무 코드',
F_TRGET_ID VARCHAR(50) NULL COMMENT '조건: 처리대상 식별자',
F_CONECT_IP VARCHAR(45) NULL COMMENT '조건: 접속IP',
F_DML_GUBUN CHAR(1) NULL COMMENT '조건: 처리구분 S/I/U/D',
F_PROCESS_RESULT CHAR(1) NULL COMMENT '조건: 처리결과 S/F',
F_FROM_DT VARCHAR(19) NULL COMMENT '조건: 기간 시작',
F_TO_DT VARCHAR(19) NULL COMMENT '조건: 기간 종료',
STATUS CHAR(1) NOT NULL DEFAULT 'W' COMMENT '상태 W:대기 R:생성중 S:완료 F:실패 X:파기',
TOTAL_CNT INT NOT NULL DEFAULT 0 COMMENT '대상 총건수',
DONE_CNT INT NOT NULL DEFAULT 0 COMMENT '기록 완료 건수(진행률)',
FILE_NM VARCHAR(200) NULL COMMENT '내려받을 때 보여줄 파일명',
FILE_PATH VARCHAR(300) NULL COMMENT '서버 저장 절대경로(웹 루트 밖, 암호화 저장)',
FILE_SIZE BIGINT NOT NULL DEFAULT 0 COMMENT '암호화 파일 크기(byte)',
ERR_MSG VARCHAR(500) NULL COMMENT '실패 사유',
DOWNLOAD_CNT INT NOT NULL DEFAULT 0 COMMENT '내려받은 횟수',
LAST_DL_DT DATETIME NULL COMMENT '마지막 내려받은 일시',
REG_DT DATETIME NOT NULL COMMENT '요청 일시',
START_DT DATETIME NULL COMMENT '생성 시작 일시',
END_DT DATETIME NULL COMMENT '생성 종료 일시',
EXPIRE_DT DATETIME NULL COMMENT '파기 예정 일시(경과 시 파일 삭제)',
PRIMARY KEY (JOB_ID),
KEY IX_PDJ_REQUESTER (REQUESTER_ID, REG_DT),
KEY IX_PDJ_STATUS (STATUS, REG_DT)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='개인정보 접속기록 CSV 비동기 다운로드 작업';
-- ─────────────────────────────────────────────────────────────
-- [privacy.emplyr_scrty_index_mysql.sql] (2026-08-11 통합)
-- ─────────────────────────────────────────────────────────────
-- =====================================================================
-- 권한 매핑 조회 인덱스 : COMTNEMPLYRSCRTYESTBS(SCRTY_DTRMN_TRGET_ID) (SFR-001/SFR-002)
-- [문제] 인덱스 0개(운영 데이터 기준 약 8만행) — 로그인마다 권한조회 풀스캔, 계정당 약 200ms.
-- [재실행 안전] information_schema 로 존재 확인 후 생성(MySQL 은 CREATE INDEX IF NOT EXISTS 미지원)
-- =====================================================================
SET @idx := (SELECT COUNT(1) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'COMTNEMPLYRSCRTYESTBS'
AND INDEX_NAME = 'IX_EMPLYRSCRTY_TRGET');
SET @ddl := IF(@idx > 0,
'SELECT ''SKIP: IX_EMPLYRSCRTY_TRGET already exists'' AS msg',
'CREATE INDEX IX_EMPLYRSCRTY_TRGET ON COMTNEMPLYRSCRTYESTBS (SCRTY_DTRMN_TRGET_ID)'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ─────────────────────────────────────────────────────────────
-- [privacy.mber_nm_encrypt_create_mysql.sql] (2026-08-11 통합)
-- ─────────────────────────────────────────────────────────────
-- =====================================================================
-- 회원 이름(MBER_NM) 암호화 컬럼 추가 : COMTNGNRLMBER (추가제안 Action4)
-- [대상] COMTNGNRLMBER 단독(실측 77,053건). biz_mber_ssn/biz_join_event/biz_stat_summary_* 는
-- 5,000건 기준 미달로 미대상.
-- [설계] 평문 컬럼 유지 + 별도 컬럼 추가(MBER_NM_ENC/MBER_NM_HASH/CRYPTO_KEY_VER).
-- MBER_NM_ENC IS NULL 로 미암호화 여부를 판정하므로 이중 암호화 사고가 원천 차단된다.
-- [검색] AES-GCM 랜덤IV라 부분일치 불가 — 완전일치는 HMAC-SHA256 해시(MBER_NM_HASH)로 별도 지원.
-- [무중단] ALGORITHM=INPLACE, LOCK=NONE.
-- ★★ 이 DDL 적용 후, PART 4(뷰 GNR 컷오버) 실행 전에 회원 이름 백필 배치가 반드시
-- 완료돼야 한다 — /mnt/sec/memberNmCrypto/start.do?confirm=BACKFILL (POST, ROLE_ADMIN)
-- 로 트리거. PART 4 자체에 미완료 시 자동 SKIP 가드가 있다. ★★
-- =====================================================================
DROP PROCEDURE IF EXISTS PRC_PRIVACY_ADD_NM_CRYPTO_COLS;
DELIMITER $$
CREATE PROCEDURE PRC_PRIVACY_ADD_NM_CRYPTO_COLS()
BEGIN
DECLARE v_cnt INT DEFAULT 0;
SELECT COUNT(*) INTO v_cnt FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'COMTNGNRLMBER'
AND COLUMN_NAME = 'MBER_NM_ENC';
IF v_cnt = 0 THEN
ALTER TABLE COMTNGNRLMBER
ADD COLUMN MBER_NM_ENC VARCHAR(255) NULL
COMMENT '이름 암호문 Base64(IV12||CT||TAG16), AES-128/GCM',
ALGORITHM=INPLACE, LOCK=NONE;
END IF;
SELECT COUNT(*) INTO v_cnt FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'COMTNGNRLMBER'
AND COLUMN_NAME = 'MBER_NM_HASH';
IF v_cnt = 0 THEN
ALTER TABLE COMTNGNRLMBER
ADD COLUMN MBER_NM_HASH CHAR(64) NULL
COMMENT '이름 완전일치 검색용 HMAC-SHA256(pepper) 소문자 hex',
ALGORITHM=INPLACE, LOCK=NONE;
END IF;
SELECT COUNT(*) INTO v_cnt FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'COMTNGNRLMBER'
AND COLUMN_NAME = 'CRYPTO_KEY_VER';
IF v_cnt = 0 THEN
ALTER TABLE COMTNGNRLMBER
ADD COLUMN CRYPTO_KEY_VER TINYINT NULL
COMMENT '암호화 키 버전(키 교체 대비). 미암호화 행은 NULL',
ALGORITHM=INPLACE, LOCK=NONE;
END IF;
SELECT COUNT(*) INTO v_cnt FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'COMTNGNRLMBER'
AND INDEX_NAME = 'IX_GNRLMBER_NM_HASH';
IF v_cnt = 0 THEN
CREATE INDEX IX_GNRLMBER_NM_HASH ON COMTNGNRLMBER (MBER_NM_HASH);
END IF;
END$$
DELIMITER ;
CALL PRC_PRIVACY_ADD_NM_CRYPTO_COLS();
DROP PROCEDURE IF EXISTS PRC_PRIVACY_ADD_NM_CRYPTO_COLS;
-- 백필 진행상태 테이블. 커서를 잃어도 MBER_NM_ENC IS NULL 조건이 있어 재실행하면 복구된다.
-- LAST_KEY 가 VARBINARY 인 이유: COMTNGNRLMBER.MBER_ID 가 VARBINARY(32)이고 비인쇄 바이트 포함행 존재.
CREATE TABLE IF NOT EXISTS MIG_CRYPTO_PROGRESS (
JOB_ID VARCHAR(50) NOT NULL COMMENT '작업 식별자(예: COMTNGNRLMBER.MBER_NM)',
LAST_KEY VARBINARY(32) NULL COMMENT '마지막 처리 PK(커서, 원형 바이트). NULL이면 처음부터',
DONE_CNT BIGINT NOT NULL DEFAULT 0 COMMENT '누적 처리 건수',
ERR_CNT BIGINT NOT NULL DEFAULT 0 COMMENT '누적 실패 건수',
JOB_STTUS CHAR(1) NOT NULL DEFAULT 'R' COMMENT 'R:진행 S:중지요청 C:완료 E:오류',
STR_DT DATETIME NULL COMMENT '시작 일시',
END_DT DATETIME NULL COMMENT '종료 일시',
UPDT_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP COMMENT '갱신 일시',
RM VARCHAR(500) NULL COMMENT '비고(마지막 오류 메시지 등)',
PRIMARY KEY (JOB_ID)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='개인정보 암호화 이관 배치 진행상태';
-- ─────────────────────────────────────────────────────────────
-- [privacy.mber_nm_dead_views_drop_mysql.sql] (2026-08-11 통합)
-- ─────────────────────────────────────────────────────────────
-- =====================================================================
-- 회원 이름 암호화 — 죽은 뷰 8개 정리
-- [배경] COMTNGNRLMBER.MBER_NM 참조 뷰 10개 중 comvnuserinfo/comvnusermaster(PART 4 대상)만
-- 실사용. 나머지 8개는 FROM절 참조 0건인 화석(원래 뷰를 쓰던 기능이 인라인 UNION으로
-- 대체되어 남은 것) — 그대로 두면 뷰도 GNR 분기 평문을 노출한다.
-- [백업] 운영 반영 전 `src/script/mysql/backup/privacy.mber_nm_dead_views_backup_20260810.sql`
-- 로 정의를 먼저 백업해 둘 것(복원 시 그대로 재실행).
-- [재실행 안전] DROP VIEW IF EXISTS.
-- =====================================================================
DROP VIEW IF EXISTS comtnbbsuserinfo;
DROP VIEW IF EXISTS comvnusermaster2;
DROP VIEW IF EXISTS comvnusermaster_1;
DROP VIEW IF EXISTS excelappconfirmlist;
DROP VIEW IF EXISTS excelappconfirmlist_auth;
DROP VIEW IF EXISTS selectaddedoptionsinf;
DROP VIEW IF EXISTS selectboardarticlelist;
DROP VIEW IF EXISTS selectmiddleappconfirmlist;
-- ===================================================================== -- =====================================================================
-- PART 2. DML (기준/시드 데이터) ※ 멱등: NOT EXISTS 가드로 중복 적재 방지 -- PART 2. DML (기준/시드 데이터) ※ 멱등: NOT EXISTS 가드로 중복 적재 방지
-- 수록: PDAL_JOB_MAPPING(업무유형 매핑 13행), ACCOUNT_MNG_POLICY(기본정책 1행) -- 수록: ACCOUNT_MNG_POLICY(기본정책 1행)
-- 미수록: OTP_DEVICE / PERSONAL_DATA_ACCESS_LOG / *_HISTORY = 런타임 적재(시드 없음) -- 미수록: OTP_DEVICE / PERSONAL_DATA_ACCESS_LOG / *_HISTORY = 런타임 적재(시드 없음)
-- [2026-08-11] PDAL_JOB_MAPPING(업무유형 매핑 13행) 시드 제거 — 업무유형 매핑 관리 화면
-- (메뉴 9100045) 폐기. 접속기록의 수행업무는 MenuAccessLogInterceptor 가
-- COMTNMENUINFO 메뉴명에서 직접 채우므로 이 시드는 더 이상 소비되지 않는다.
-- ===================================================================== -- =====================================================================
-- 1) PDAL_JOB_MAPPING — 개인정보 처리 업무유형 매핑(접속기록 AOP 분류 기준, 필수) -- 1) ACCOUNT_MNG_POLICY — 장기 미사용 계정 관리 기본정책(단일행 POLICY_ID=1)
INSERT INTO PDAL_JOB_MAPPING (TARGET_CLASS, METHOD_PTTRN, JOB_SE_CODE, JOB_SE_NM, DML_GUBUN, SORT_ORDR, RM)
SELECT * FROM (
SELECT 'EgovMberManageServiceImpl' AS TARGET_CLASS, 'select' AS METHOD_PTTRN, 'MBER_VIEW' AS JOB_SE_CODE, '회원관리-조회' AS JOB_SE_NM, 'S' AS DML_GUBUN, 10 AS SORT_ORDR, '회원 개인정보 조회' AS RM UNION ALL
SELECT 'EgovMberManageServiceImpl', 'insert', 'MBER_REG', '회원관리-등록', 'I', 10, NULL UNION ALL
SELECT 'EgovMberManageServiceImpl', 'update', 'MBER_MOD', '회원관리-수정', 'U', 10, NULL UNION ALL
SELECT 'EgovMberManageServiceImpl', 'delete', 'MBER_DEL', '회원관리-삭제', 'D', 10, NULL UNION ALL
SELECT 'EgovMberManageServiceImpl', '*', 'MBER_ETC', '회원관리-기타', NULL, 90, '미분류 폴백' UNION ALL
SELECT 'EgovEntrprsManageServiceImpl', 'select', 'ENTRPRS_VIEW', '업체관리-조회', 'S', 10, '업체 담당자 개인정보 조회' UNION ALL
SELECT 'EgovEntrprsManageServiceImpl', 'insert', 'ENTRPRS_REG', '업체관리-등록', 'I', 10, NULL UNION ALL
SELECT 'EgovEntrprsManageServiceImpl', 'update', 'ENTRPRS_MOD', '업체관리-수정', 'U', 10, NULL UNION ALL
SELECT 'EgovEntrprsManageServiceImpl', 'delete', 'ENTRPRS_DEL', '업체관리-삭제', 'D', 10, NULL UNION ALL
SELECT 'EgovEntrprsManageServiceImpl', '*', 'ENTRPRS_ETC', '업체관리-기타', NULL, 90, '미분류 폴백' UNION ALL
SELECT 'MberSsnServiceImpl', '*', 'MBER_SSN', '회원 고유식별정보 처리', NULL, 20, '주민등록번호 등 고유식별정보' UNION ALL
SELECT 'BizMberMergeServiceImpl', '*', 'MBER_MERGE', '회원 통합/이관', NULL, 30, NULL UNION ALL
SELECT 'BizTalentMberManageServiceImpl', '*', 'TALENT_MBER', '재능나눔 회원관리', NULL, 30, NULL
) seed
WHERE NOT EXISTS (SELECT 1 FROM PDAL_JOB_MAPPING pjm WHERE pjm.JOB_SE_CODE = seed.JOB_SE_CODE);
-- 2) ACCOUNT_MNG_POLICY — 장기 미사용 계정 관리 기본정책(단일행 POLICY_ID=1)
INSERT INTO ACCOUNT_MNG_POLICY (POLICY_ID, DORMANT_DAY, ALERT_D1, ALERT_D2, ALERT_D3, DELETE_PRESERVE_DAY, BATCH_CYCLE, UPD_ID, UPD_DT) INSERT INTO ACCOUNT_MNG_POLICY (POLICY_ID, DORMANT_DAY, ALERT_D1, ALERT_D2, ALERT_D3, DELETE_PRESERVE_DAY, BATCH_CYCLE, UPD_ID, UPD_DT)
SELECT 1, 365, 30, 7, 0, 365, 'DAILY', 'SYSTEM', NOW() SELECT 1, 365, 30, 7, 0, 365, 'DAILY', 'SYSTEM', NOW()
FROM DUAL FROM DUAL
@ -1085,66 +1214,33 @@ SELECT 9100044, g.ac
AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x
WHERE x.MENU_NO = 9100044 AND x.AUTHOR_CODE = g.ac); WHERE x.MENU_NO = 9100044 AND x.AUTHOR_CODE = g.ac);
-- ── [6] 업무유형 매핑 관리 메뉴(9100045) — SFR-004 #32-1, 2026-08-07 추가 ── -- ── [6] 접근통제 정책 설정 메뉴(9100047) — SER-001 요건 4·5, 2026-08-10 추가(2026-08-11 통합) ──
-- 원본: src/script/mysql/dml/privacy.menu_job_mapping_insert_mysql.sql -- 원본: src/script/mysql/dml/privacy.menu_access_policy_insert_mysql.sql
-- ★ left.jsp 9100045 <c:when> 분기 + 하이라이트 조건 4곳 동반 배포 필수(위와 동일 제약) -- ★ left.jsp 9100047 <c:when> 분기 + 하이라이트 조건 4곳 동반 배포 필수(위와 동일 제약)
-- ※ 접속기록(9100044)의 '수행업무' 분류 기준을 편집하는 화면이라 바로 다음(MENU_ORDR 11)에 배치 -- [선행] 위 PART 1의 ACCESS_CONTROL_POLICY 가 먼저 생성돼 있어야 화면 진입 시 기본값 표시
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL) INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
SELECT * FROM ( SELECT 'AccessPolicySet', '/mnt/sec/accessPolicy/', '접근통제 정책 설정',
SELECT 'JobMappingList' AS PROGRM_FILE_NM, '/mnt/sec/jobMapping/' AS PROGRM_STRE_PATH, '계정 잠금·자동 로그아웃·세션 재발급 정책 설정(SER-001 요건 4·5)', '/mnt/sec/accessPolicy/policyView.do?page=9100047'
'업무유형 매핑 관리' AS PROGRM_KOREAN_NM, '개인정보 접속기록 업무유형 매핑 관리(SFR-004 #32-1)' AS PROGRM_DC, FROM DUAL
'/mnt/sec/jobMapping/listView.do?page=9100045' AS URL WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM = 'AccessPolicySet');
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM);
INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM) INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM)
SELECT * FROM ( SELECT '접근통제 정책', 'AccessPolicySet', 9100047, 9100030, 13, '계정 잠금·자동 로그아웃·세션 재발급 정책 설정', '/', '/'
SELECT '업무유형 매핑 관리' AS MENU_NM, 'JobMappingList' AS PROGRM_FILE_NM, 9100045 AS MENU_NO, FROM DUAL
9100030 AS UPPER_MENU_NO, 11 AS MENU_ORDR, '접속기록 업무유형 매핑 등록·수정' AS MENU_DC, WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO WHERE MENU_NO = 9100047);
'/' AS RELATE_IMAGE_PATH, '/' AS RELATE_IMAGE_NM
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO m WHERE m.MENU_NO = seed.MENU_NO);
INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE) INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE)
SELECT 9100045, g.ac SELECT 9100047, g.ac
FROM (SELECT 'IS_AUTHENTICATED_FULLY' AS ac UNION ALL SELECT 'ROLE_ADMIN') g FROM (SELECT 'IS_AUTHENTICATED_FULLY' AS ac UNION ALL SELECT 'ROLE_ADMIN') g
WHERE g.ac IN (SELECT AUTHOR_CODE FROM COMTNAUTHORINFO) WHERE g.ac IN (SELECT AUTHOR_CODE FROM COMTNAUTHORINFO)
AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x
WHERE x.MENU_NO = 9100045 AND x.AUTHOR_CODE = g.ac); WHERE x.MENU_NO = 9100047 AND x.AUTHOR_CODE = g.ac);
-- ── [7] 로그인·로그아웃 접속기록 메뉴(9100046) — 개발항목 62, 2026-08-07 추가 ──
-- 원본: src/script/mysql/dml/privacy.menu_conect_log_insert_mysql.sql
-- ★ left.jsp 9100046 <c:when> 분기 + 하이라이트 조건 4곳 동반 배포 필수(위와 동일 제약)
-- ※ 인증 실패 이력(9100039)·개인정보 접속기록(9100044)과 같은 보안 로그 묶음(MENU_ORDR 12)
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
SELECT * FROM (
SELECT 'ConectLogList' AS PROGRM_FILE_NM, '/mnt/sec/conectLog/' AS PROGRM_STRE_PATH,
'로그인 접속기록' AS PROGRM_KOREAN_NM,
'로그인·로그아웃 접속기록 조회·다운로드(접속ID·접속일시·IP·접속위치, 개발항목 62)' AS PROGRM_DC,
'/mnt/sec/conectLog/listView.do?page=9100046' AS URL
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM);
INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM)
SELECT * FROM (
SELECT '로그인 접속기록' AS MENU_NM, 'ConectLogList' AS PROGRM_FILE_NM, 9100046 AS MENU_NO,
9100030 AS UPPER_MENU_NO, 12 AS MENU_ORDR, '로그인·로그아웃 접속기록 조회' AS MENU_DC,
'/' AS RELATE_IMAGE_PATH, '/' AS RELATE_IMAGE_NM
) seed
WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO m WHERE m.MENU_NO = seed.MENU_NO);
INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE)
SELECT 9100046, g.ac
FROM (SELECT 'IS_AUTHENTICATED_FULLY' AS ac UNION ALL SELECT 'ROLE_ADMIN') g
WHERE g.ac IN (SELECT AUTHOR_CODE FROM COMTNAUTHORINFO)
AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x
WHERE x.MENU_NO = 9100046 AND x.AUTHOR_CODE = g.ac);
-- ── [검증] ── -- ── [검증] ──
-- SELECT m.MENU_NO,m.UPPER_MENU_NO,m.MENU_NM,m.MENU_ORDR,p.URL, -- SELECT m.MENU_NO,m.UPPER_MENU_NO,m.MENU_NM,m.MENU_ORDR,p.URL,
-- (SELECT COUNT(*) FROM COMTNMENUCREATDTLS d WHERE d.MENU_NO=m.MENU_NO) grants -- (SELECT COUNT(*) FROM COMTNMENUCREATDTLS d WHERE d.MENU_NO=m.MENU_NO) grants
-- FROM COMTNMENUINFO m JOIN COMTNPROGRMLIST p ON m.PROGRM_FILE_NM=p.PROGRM_FILE_NM -- FROM COMTNMENUINFO m JOIN COMTNPROGRMLIST p ON m.PROGRM_FILE_NM=p.PROGRM_FILE_NM
-- WHERE m.MENU_NO IN (9100035,9100036,9100037,9100038,9100039,9100044,9100045,9100046) ORDER BY m.MENU_ORDR; -- WHERE m.MENU_NO IN (9100035,9100036,9100037,9100038,9100039,9100044,9100047) ORDER BY m.MENU_ORDR;
-- ── [롤백] (역순 권장, 4건 일괄) ── -- ── [롤백] (역순 권장, 4건 일괄) ──
-- DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO BETWEEN 9100035 AND 9100038; -- DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO BETWEEN 9100035 AND 9100038;
@ -1152,4 +1248,179 @@ SELECT 9100046, g.ac
-- DELETE FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM IN -- DELETE FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM IN
-- ('AuthorRequestList','AuthorHistoryList','DormantStatusList','AccountPolicySet'); -- ('AuthorRequestList','AuthorHistoryList','DormantStatusList','AccountPolicySet');
-- =====================================================================
-- PART 4. 뷰 GNR 컷오버 — comvnusermaster / comvnuserinfo (2026-08-11 통합)
-- 원본: src/script/mysql/ddl/privacy.mber_nm_view_hash_cutover_mysql.sql
-- 추가제안 Action4(회원정보 암호화)의 마지막 단계. GNR 분기를 평문(MBER_NM)에서
-- 암호문(MBER_NM_ENC)으로 바꾸고 검색용 USER_NM_HASH 컬럼을 추가한다.
--
-- ★★ all-or-nothing 게이트 — 이 PART는 자동으로 스스로를 지킨다 ★★
-- 아래에서 실제 뷰 재정의를 실행하기 '전에' 두 가지를 확인한다:
-- ① comvnusermaster 에 USER_NM_HASH 가 이미 있으면(=이미 컷오버됨) SKIP
-- ② COMTNGNRLMBER 에 MBER_NM_ENC IS NULL 인 행이 하나라도 있으면(=백필 미완료) SKIP
-- → 이 경우 /mnt/sec/memberNmCrypto/start.do?confirm=BACKFILL(POST, ROLE_ADMIN)로
-- 백필을 먼저 끝내고 이 install_mysql.sql 을 다시 실행하면 된다(재실행 안전).
-- 두 조건을 모두 통과했을 때만 실제 CREATE OR REPLACE VIEW 를 수행한다 — 이 파일 하나만
-- "필요할 때마다" 반복 실행하는 것이 정상 사용법이다(설치 1회 + 백필 후 재실행 1회, 2번 실행).
--
-- [기법] information_schema.views.VIEW_DEFINITION 을 서버사이드 REPLACE()로 최소 치환
-- (PREPARE/EXECUTE 동적 SQL) — comvnuserinfo 의 한글 리터럴(USER_GUBUN_NM 등)을 클라이언트
-- 에서 절대 재입력하지 않아 셸→mysql.exe stdin 이중 인코딩 문제를 원천 회피한다.
-- [백업] 실행 전 원본 정의를 privacy_view_def_backup 테이블에 저장(롤백은 그 값으로
-- CREATE OR REPLACE VIEW 재실행).
-- =====================================================================
DROP PROCEDURE IF EXISTS PRC_PRIVACY_MBER_NM_VIEW_CUTOVER;
DELIMITER $$
CREATE PROCEDURE PRC_PRIVACY_MBER_NM_VIEW_CUTOVER()
BEGIN
DECLARE v_pending INT DEFAULT 0;
DECLARE v_done INT DEFAULT 0;
SELECT COUNT(*) INTO v_pending FROM COMTNGNRLMBER WHERE MBER_NM_ENC IS NULL;
SELECT COUNT(*) INTO v_done FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'comvnusermaster' AND COLUMN_NAME = 'USER_NM_HASH';
IF v_done > 0 THEN
SELECT 'SKIP: comvnusermaster.USER_NM_HASH 이미 존재 — 뷰 컷오버 기완료' AS PART4_GATE_STATUS;
ELSEIF v_pending > 0 THEN
SELECT CONCAT('SKIP: 백필 미완료 — COMTNGNRLMBER 미암호화 ', v_pending,
'건 남음. /mnt/sec/memberNmCrypto/start.do?confirm=BACKFILL 로 먼저 완료 후',
' 이 파일을 재실행할 것') AS PART4_GATE_STATUS;
ELSE
SELECT 'PROCEED: 백필 완료 확인(미암호화 0건) — 뷰 컷오버 진행' AS PART4_GATE_STATUS;
-- 0. 백업: 현재 뷰 정의를 별도 테이블에 저장(롤백용)
CREATE TABLE IF NOT EXISTS privacy_view_def_backup (
view_name VARCHAR(64) NOT NULL,
backup_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
view_definition MEDIUMTEXT NOT NULL,
PRIMARY KEY (view_name, backup_at)
);
INSERT INTO privacy_view_def_backup (view_name, view_definition)
SELECT 'comvnusermaster', VIEW_DEFINITION
FROM information_schema.views
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'comvnusermaster';
INSERT INTO privacy_view_def_backup (view_name, view_definition)
SELECT 'comvnuserinfo', VIEW_DEFINITION
FROM information_schema.views
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'comvnuserinfo';
-- 1. comvnusermaster 재정의
-- GNR 분기: MBER_NM(평문) -> MBER_NM_ENC(암호문), USER_NM_HASH 컬럼 추가(MBER_NM_HASH)
-- USR/ENT/GRO 분기: USER_NM_HASH 컬럼 추가(NULL — 암호화 대상 아님)
SET @def_master = (
SELECT VIEW_DEFINITION FROM information_schema.views
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'comvnusermaster'
);
SET @def_master = REPLACE(
@def_master,
'`comtngnrlmber`.`MBER_NM` AS `USER_NM`',
'`comtngnrlmber`.`MBER_NM_ENC` AS `USER_NM`'
);
SET @def_master = REPLACE(
@def_master,
' end) AS `ADULT` from `comtngnrlmber` where',
' end) AS `ADULT`,`comtngnrlmber`.`MBER_NM_HASH` AS `USER_NM_HASH` from `comtngnrlmber` where'
);
SET @def_master = REPLACE(
@def_master,
'\'N\' AS `ADULT` from `comtnemplyrinfo` union all',
'\'N\' AS `ADULT`,NULL AS `USER_NM_HASH` from `comtnemplyrinfo` union all'
);
SET @def_master = REPLACE(
@def_master,
'\'N\' AS `ADULT` from `comtnentrprsmber` where',
'\'N\' AS `ADULT`,NULL AS `USER_NM_HASH` from `comtnentrprsmber` where'
);
SET @def_master = REPLACE(
@def_master,
'\'N\' AS `ADULT` from `biz_group` where',
'\'N\' AS `ADULT`,NULL AS `USER_NM_HASH` from `biz_group` where'
);
SELECT
(LENGTH(@def_master) - LENGTH(REPLACE(@def_master, 'USER_NM_HASH', ''))) / LENGTH('USER_NM_HASH') AS hash_column_occurrences,
(@def_master LIKE '%MBER_NM_ENC%') AS gnr_uses_enc_column,
(@def_master NOT LIKE '%`comtngnrlmber`.`MBER_NM` AS `USER_NM`%') AS old_plaintext_reference_removed;
SET @sql_master = CONCAT('CREATE OR REPLACE VIEW comvnusermaster AS ', @def_master);
PREPARE stmt_master FROM @sql_master;
EXECUTE stmt_master;
DEALLOCATE PREPARE stmt_master;
-- 2. comvnuserinfo 재정의 (USER_GUBUN_NM 한글 리터럴은 REPLACE 대상에 포함하지 않는다 — 그대로 보존)
SET @def_info = (
SELECT VIEW_DEFINITION FROM information_schema.views
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'comvnuserinfo'
);
SET @def_info = REPLACE(
@def_info,
'`comtngnrlmber`.`MBER_NM` AS `USER_NM`',
'`comtngnrlmber`.`MBER_NM_ENC` AS `USER_NM`'
);
SET @def_info = REPLACE(
@def_info,
'`comtngnrlmber`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT` from `comtngnrlmber` union all',
'`comtngnrlmber`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT`,`comtngnrlmber`.`MBER_NM_HASH` AS `USER_NM_HASH` from `comtngnrlmber` union all'
);
SET @def_info = REPLACE(
@def_info,
'`biz_group`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT` from `biz_group` union all',
'`biz_group`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT`,NULL AS `USER_NM_HASH` from `biz_group` union all'
);
SET @def_info = REPLACE(
@def_info,
'`comtnentrprsmber`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT` from `comtnentrprsmber` union all',
'`comtnentrprsmber`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT`,NULL AS `USER_NM_HASH` from `comtnentrprsmber` union all'
);
SET @def_info = REPLACE(
@def_info,
'`comtnemplyrinfo`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT` from `comtnemplyrinfo`',
'`comtnemplyrinfo`.`ADMIN_UNLOCK_DT` AS `ADMIN_UNLOCK_DT`,NULL AS `USER_NM_HASH` from `comtnemplyrinfo`'
);
SELECT
(LENGTH(@def_info) - LENGTH(REPLACE(@def_info, 'USER_NM_HASH', ''))) / LENGTH('USER_NM_HASH') AS hash_column_occurrences,
(@def_info LIKE '%MBER_NM_ENC%') AS gnr_uses_enc_column,
(@def_info NOT LIKE '%`comtngnrlmber`.`MBER_NM` AS `USER_NM`%') AS old_plaintext_reference_removed;
SET @sql_info = CONCAT('CREATE OR REPLACE VIEW comvnuserinfo AS ', @def_info);
PREPARE stmt_info FROM @sql_info;
EXECUTE stmt_info;
DEALLOCATE PREPARE stmt_info;
-- 3. 반영 후 검증
SELECT TABLE_NAME, COLUMN_NAME, ORDINAL_POSITION
FROM information_schema.columns
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('comvnusermaster', 'comvnuserinfo')
AND COLUMN_NAME IN ('USER_NM', 'USER_SE', 'USER_GUBUN', 'USER_NM_HASH')
ORDER BY TABLE_NAME, ORDINAL_POSITION;
SELECT USER_ID, USER_SE, LEFT(USER_NM, 20) AS user_nm_sample, LEFT(USER_NM_HASH, 20) AS hash_sample
FROM comvnusermaster WHERE USER_SE = 'GNR' LIMIT 1;
SELECT USER_ID, USER_SE, LEFT(USER_NM, 20) AS user_nm_sample, USER_NM_HASH
FROM comvnusermaster WHERE USER_SE = 'USR' LIMIT 1;
SELECT USER_SE, COUNT(*) FROM comvnusermaster GROUP BY USER_SE;
SELECT USER_GUBUN, LEFT(USER_NM, 20) AS user_nm_sample, LEFT(USER_NM_HASH, 20) AS hash_sample
FROM comvnuserinfo WHERE USER_GUBUN = 'GNR' LIMIT 1;
SELECT USER_GUBUN, COUNT(*) FROM comvnuserinfo GROUP BY USER_GUBUN;
END IF;
END$$
DELIMITER ;
CALL PRC_PRIVACY_MBER_NM_VIEW_CUTOVER();
DROP PROCEDURE IF EXISTS PRC_PRIVACY_MBER_NM_VIEW_CUTOVER;
-- ── [롤백] ──
-- SELECT view_definition FROM privacy_view_def_backup WHERE view_name='comvnusermaster' ORDER BY backup_at DESC LIMIT 1;
-- -- 위 값을 CREATE OR REPLACE VIEW comvnusermaster AS <value> 로 재실행(comvnuserinfo 동일)
-- ############################## END ############################## -- ############################## END ##############################