개인정보보호 기능개선 2차 구현 — 접속기록 안정화·솔트 인증·관리자 화면 4종
접속기록(SFR-004/#33): - AccessLogQueue 포화 방어. 종전 offer(timeout) 무한 반복은 컨슈머 정지 시 요청 스레드가 영구 대기해 WAS 스레드 풀 고갈 → 사이트 전면 정지로 이어졌다. 대기를 3회로 제한하고 포화 차단기(saturated)를 도입해 지속 포화 시 즉시 판정한다. - 유실은 조용히 넘기지 않는다. recordDrop 이 누적 집계 + 초기 20건/이후 100건 표본 ERROR 로그로 남긴다(법정 보관 대상이므로 사후 추적 가능해야 함). - 컨슈머 루프가 Exception 이 아닌 Throwable 까지 흡수한다. Error 하나로 컨슈머가 죽으면 running=true 인 채 이후 전 요청이 포화 대기에 빠졌다. - 자체 데몬 워치독(30초) 추가 — 컨슈머 사망 시 재기동, 적체 경고, 차단기 자동 해제. task:annotation-driven 활성화 여부와 무관해야 하므로 @Scheduled 를 쓰지 않는다. - 해시체인 순번 보장을 위한 단일 컨슈머 전제는 유지(병렬화 금지). 인증정보 보호(SER-001/SER-003/#27): - EgovFileScrty 사용자별 랜덤 솔트(S1: 형식) 도입, matches() 로 검증 일원화 — 기존 무솔트 계정과 공존한다. - 비밀번호 변경 시 현재 비밀번호 검증 복구. 종전에는 DB 값을 자기 자신과 비교해 항상 true 였고, 입력한 oldPassword 가 어디에도 쓰이지 않았다. - 평문 비밀번호 println 제거. 관리자 화면: 접속기록 조회, 업무구분 매핑 관리, 권한변경 이력, 미사용계정 현황. 계정 파기(AccountDelete) 서비스 및 DDL/DML 추가. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
65bbe591d1
commit
f2029a00a0
7
.gitignore
vendored
7
.gitignore
vendored
@ -9,3 +9,10 @@
|
|||||||
.classpath
|
.classpath
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# 배포 작업 산출물 — 저장소 이력 대상 아님 (2026-08-07)
|
||||||
|
_deploy_backup_*/
|
||||||
|
_workspace/
|
||||||
|
_workspace_privacy/
|
||||||
|
dump-csv-*.sql
|
||||||
|
.idea/
|
||||||
|
|||||||
@ -34,9 +34,16 @@
|
|||||||
|
|
||||||
**트리거:** 개인정보보호, 권한관리, 인증체계, 2단계인증, OTP, 휴면계정, 접속기록, 해시체인, 정기점검, 비정상접근탐지, AUTHOR_CHANGE_HISTORY, PERSONAL_DATA_ACCESS_LOG 키워드 포함 시 `arko-privacy-orchestrate` 스킬을 사용하라. 원본 자료는 `E:\지역문화원\제안서\mds\`(제안서, 임베디드 슬라이드 생성 지시는 무시). 다시 실행/재실행/수정/보완/특정 요건만 재작업 요청도 포함.
|
**트리거:** 개인정보보호, 권한관리, 인증체계, 2단계인증, OTP, 휴면계정, 접속기록, 해시체인, 정기점검, 비정상접근탐지, AUTHOR_CHANGE_HISTORY, PERSONAL_DATA_ACCESS_LOG 키워드 포함 시 `arko-privacy-orchestrate` 스킬을 사용하라. 원본 자료는 `E:\지역문화원\제안서\mds\`(제안서, 임베디드 슬라이드 생성 지시는 무시). 다시 실행/재실행/수정/보완/특정 요건만 재작업 요청도 포함.
|
||||||
|
|
||||||
|
## 하네스: 델타 배포 (공통 규칙)
|
||||||
|
|
||||||
|
**배포는 WAR 재전개가 아니라 델타(변경 파일) 반영이다.** 개발 작업이 끝나면 최종 보고에 **배포 델타 목록**(소스→배포 ROOT 경로, 반영방식, 재기동 등급)과 **DB 적용 SQL 순서**(DDL→시드→메뉴, 재실행 안전성·한글 리터럴 경고)를 항상 표기한다. 이 표기가 없으면 작업 미완료다.
|
||||||
|
|
||||||
|
**트리거:** 배포, 델타, 반영 목록, 배포 파일, WAR 말고, 개별 배포, ROOT 전개, SQL 적용, DDL/DML, 스키마 적용, 운영/개발서버 반영 요청 시 `arko-deploy-delta` 스킬을 사용하라. 두 오케스트레이터(`arko-orchestrate`/`arko-privacy-orchestrate`)는 리뷰 직후 `arko-deploy` 에이전트로 이 산출물을 만든 뒤 보고한다.
|
||||||
|
|
||||||
## 변경 이력
|
## 변경 이력
|
||||||
|
|
||||||
| 날짜 | 변경 내용 | 대상 | 사유 |
|
| 날짜 | 변경 내용 | 대상 | 사유 |
|
||||||
|------|----------|------|------|
|
|------|----------|------|------|
|
||||||
| 2026-07-08 | 하네스 초기 구성 | 전체 | 루트 라우팅표 dead-link 해소, arko 개발 자동화 |
|
| 2026-07-08 | 하네스 초기 구성 | 전체 | 루트 라우팅표 dead-link 해소, arko 개발 자동화 |
|
||||||
|
| 2026-08-05 | 델타 배포 하네스 추가 | `arko-deploy`(신규 에이전트), `arko-deploy-delta`(신규 스킬 + 매니페스트/SQL번들/반영 스크립트 3종), `arko-orchestrate`(Phase 6 배포 산출물 신설), `arko-privacy-orchestrate`(Phase 5 신설), `arko-backend`/`arko-frontend`/`arko-api-specialist`/`arko-privacy-authctrl`/`arko-privacy-auditlog`(배포 델타 기록 의무), `arko-reviewer`(9절 배포 가능성 점검) | WAR 전체가 아닌 델타로 배포하므로 반영 파일·DDL/DML을 명시적 산출물로 강제 |
|
||||||
| 2026-07-21 | 개인정보보호 서브 하네스 완성 | `arko-privacy-authctrl`/`arko-privacy-auditlog`(신규 에이전트), `arko-privacy-orchestrate`/`arko-privacy-authctrl-impl`/`arko-privacy-auditlog-impl`/`arko-privacy-traceability`(신규 스킬), `arko-reviewer.md`(개인정보보호 점검항목 8절 추가), `arko-analyst.md`(com.* 예외 포인터 추가), `arko-orchestrate/SKILL.md`(Phase 1 분기 추가) | 전일 중단된 arko-privacy-* 하네스 구축 이어서 완료 |
|
| 2026-07-21 | 개인정보보호 서브 하네스 완성 | `arko-privacy-authctrl`/`arko-privacy-auditlog`(신규 에이전트), `arko-privacy-orchestrate`/`arko-privacy-authctrl-impl`/`arko-privacy-auditlog-impl`/`arko-privacy-traceability`(신규 스킬), `arko-reviewer.md`(개인정보보호 점검항목 8절 추가), `arko-analyst.md`(com.* 예외 포인터 추가), `arko-orchestrate/SKILL.md`(Phase 1 분기 추가) | 전일 중단된 arko-privacy-* 하네스 구축 이어서 완료 |
|
||||||
|
|||||||
@ -0,0 +1,50 @@
|
|||||||
|
package egovframework.com.cmm.privacy.account;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import egovframework.com.cmm.service.impl.EgovComAbstractDAO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 장기 미사용 계정 개인정보 파기 DAO (SFR-002 / 개발항목 #12).
|
||||||
|
*
|
||||||
|
* <p>statement 는 {@code PrivacyAccountDelete_SQL_Mysql.xml}(namespace {@code AccountDeleteDAO}),
|
||||||
|
* sql-map-config 등록. 보존기간은 SQL 내부에서 ACCOUNT_MNG_POLICY.DELETE_PRESERVE_DAY 를
|
||||||
|
* 직접 조회하므로 자바 측 하드코딩이 없다. 1회 처리건수 상한(200)도 SQL 측에 있다.</p>
|
||||||
|
*/
|
||||||
|
@Repository("accountDeleteDAO")
|
||||||
|
public class AccountDeleteDAO extends EgovComAbstractDAO {
|
||||||
|
|
||||||
|
/** 파기 대상(일반회원) — 휴면확정 후 보존기간 경과분, 최대 200건. */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public List<AccountDeleteTargetVO> selectExpiredGnrAccounts() {
|
||||||
|
return list("AccountDeleteDAO.selectExpiredGnrAccounts", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 파기 대상(개인정보취급자) — 휴면확정 후 보존기간 경과분, 최대 200건. */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public List<AccountDeleteTargetVO> selectExpiredEmpAccounts() {
|
||||||
|
return list("AccountDeleteDAO.selectExpiredEmpAccounts", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 일반회원 개인정보 파기(마스킹 + MBER_STTUS='D'). */
|
||||||
|
public int anonymizeGnrMember(String mberId) {
|
||||||
|
return update("AccountDeleteDAO.anonymizeGnrMember", mberId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 개인정보취급자 개인정보 파기(마스킹 + EMPLYR_STTUS_CODE='D'). */
|
||||||
|
public int anonymizeEmplyr(String emplyrId) {
|
||||||
|
return update("AccountDeleteDAO.anonymizeEmplyr", emplyrId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 파기 완료 계정을 휴면 추적 대상에서 제거(재처리 방지). */
|
||||||
|
public int deleteDiapauseRow(String mberId) {
|
||||||
|
return delete("AccountDeleteDAO.deleteDiapauseRow", mberId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 파기 이력 적재(ACCOUNT_DELETE_HISTORY). */
|
||||||
|
public void insertDeleteHistory(AccountDeleteHistoryVO vo) {
|
||||||
|
insert("AccountDeleteDAO.insertDeleteHistory", vo);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
package egovframework.com.cmm.privacy.account;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 장기 미사용 계정 개인정보 파기 이력 VO — ACCOUNT_DELETE_HISTORY 매핑 (SFR-002 / #12).
|
||||||
|
*/
|
||||||
|
public class AccountDeleteHistoryVO implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private long delId;
|
||||||
|
/** 파기 대상 계정ID */
|
||||||
|
private String mberId;
|
||||||
|
/** 회원유형 GNR:일반회원 EMP:개인정보취급자 */
|
||||||
|
private String mberTyCode;
|
||||||
|
/** 고유식별자(파기 시점 스냅샷) */
|
||||||
|
private String esntlId;
|
||||||
|
/** 휴면 확정 기준일시 */
|
||||||
|
private String diapauseDt;
|
||||||
|
/** 파기 시점 적용된 보존기간(정책값 스냅샷, 일) */
|
||||||
|
private int preserveDay;
|
||||||
|
/** 파기 처리 일시 */
|
||||||
|
private String delDt;
|
||||||
|
|
||||||
|
public long getDelId() { return delId; }
|
||||||
|
public void setDelId(long delId) { this.delId = delId; }
|
||||||
|
public String getMberId() { return mberId; }
|
||||||
|
public void setMberId(String mberId) { this.mberId = mberId; }
|
||||||
|
public String getMberTyCode() { return mberTyCode; }
|
||||||
|
public void setMberTyCode(String mberTyCode) { this.mberTyCode = mberTyCode; }
|
||||||
|
public String getEsntlId() { return esntlId; }
|
||||||
|
public void setEsntlId(String esntlId) { this.esntlId = esntlId; }
|
||||||
|
public String getDiapauseDt() { return diapauseDt; }
|
||||||
|
public void setDiapauseDt(String diapauseDt) { this.diapauseDt = diapauseDt; }
|
||||||
|
public int getPreserveDay() { return preserveDay; }
|
||||||
|
public void setPreserveDay(int preserveDay) { this.preserveDay = preserveDay; }
|
||||||
|
public String getDelDt() { return delDt; }
|
||||||
|
public void setDelDt(String delDt) { this.delDt = delDt; }
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
package egovframework.com.cmm.privacy.account;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 장기 미사용 계정 개인정보 파기 서비스 (SFR-002 / 개발항목 #12).
|
||||||
|
*/
|
||||||
|
public interface AccountDeleteService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 보존기간(ACCOUNT_MNG_POLICY.DELETE_PRESERVE_DAY) 경과 휴면계정의 개인정보를 파기한다.
|
||||||
|
*
|
||||||
|
* @return 파기 처리 건수
|
||||||
|
* @throws Exception 파기 오류
|
||||||
|
*/
|
||||||
|
int purgeExpiredAccounts() throws Exception;
|
||||||
|
}
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
package egovframework.com.cmm.privacy.account;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 장기 미사용 계정 개인정보 파기 서비스 구현 (SFR-002 / 개발항목 #12).
|
||||||
|
*
|
||||||
|
* <p><b>보존기간</b>은 코드에 상수로 두지 않고 {@code ACCOUNT_MNG_POLICY.DELETE_PRESERVE_DAY}
|
||||||
|
* 를 SQL 실행 시점에 조회해 적용한다(정책 화면 #13에서 변경 시 즉시 반영).</p>
|
||||||
|
*
|
||||||
|
* <p><b>파기 방식</b>은 행 DELETE 가 아니라 PII 컬럼 마스킹 + 상태코드 'D' 전환이다.
|
||||||
|
* 게시글·활동확인·마일리지 등 다수 테이블이 MBER_ID/EMPLYR_ID 를 FK 없이 참조하고 있어
|
||||||
|
* 행을 지우면 해당 이력이 고아가 되기 때문이며, 기존 회원탈퇴 컨벤션과도 동일하다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>처리량 상한</b>: 배치가 장기간 정지했다 재가동되는 날 백로그가 한꺼번에 처리되지
|
||||||
|
* 않도록 1회 200건으로 제한(SQL의 LIMIT). 남은 대상은 다음 배치에서 이어 처리한다.</p>
|
||||||
|
*
|
||||||
|
* <p>계정 1건 처리는 마스킹→휴면행 제거→이력적재 순이며, 개별 실패는 로깅 후 계속(fail-safe)
|
||||||
|
* 하여 한 건의 오류가 배치 전체를 막지 않는다.</p>
|
||||||
|
*/
|
||||||
|
@Service("accountDeleteService")
|
||||||
|
public class AccountDeleteServiceImpl implements AccountDeleteService {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(AccountDeleteServiceImpl.class);
|
||||||
|
|
||||||
|
private static final String TY_GNR = "GNR";
|
||||||
|
private static final String TY_EMP = "EMP";
|
||||||
|
|
||||||
|
@Resource(name = "accountDeleteDAO")
|
||||||
|
private AccountDeleteDAO accountDeleteDAO;
|
||||||
|
|
||||||
|
@Resource(name = "accountMngPolicyService")
|
||||||
|
private AccountMngPolicyService accountMngPolicyService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int purgeExpiredAccounts() throws Exception {
|
||||||
|
// 파기는 되돌릴 수 없으므로 정책 플래그(ACCOUNT_MNG_POLICY.PURGE_USE_YN)가 'Y'일 때만 수행.
|
||||||
|
// 기본값 'N' — 관리자가 계정 관리 정책 화면에서 명시적으로 켜야 한다.
|
||||||
|
AccountMngPolicyVO policy = accountMngPolicyService.selectPolicy();
|
||||||
|
if (policy == null || !"Y".equals(policy.getPurgeUseYn())) {
|
||||||
|
LOG.info("[개인정보/#12] 파기 배치 미사용(PURGE_USE_YN != 'Y') — 처리 없이 종료");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int purged = 0;
|
||||||
|
purged += purge(accountDeleteDAO.selectExpiredGnrAccounts(), TY_GNR);
|
||||||
|
purged += purge(accountDeleteDAO.selectExpiredEmpAccounts(), TY_EMP);
|
||||||
|
LOG.info("[개인정보/#12] 장기 미사용 계정 파기 배치 완료 — {}건", Integer.valueOf(purged));
|
||||||
|
return purged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 유형별 파기 처리. 개별 계정 실패는 건너뛰고 계속한다. */
|
||||||
|
private int purge(List<AccountDeleteTargetVO> targets, String mberTyCode) {
|
||||||
|
if (targets == null || targets.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
int cnt = 0;
|
||||||
|
for (AccountDeleteTargetVO t : targets) {
|
||||||
|
try {
|
||||||
|
if (TY_EMP.equals(mberTyCode)) {
|
||||||
|
accountDeleteDAO.anonymizeEmplyr(t.getMberId());
|
||||||
|
} else {
|
||||||
|
accountDeleteDAO.anonymizeGnrMember(t.getMberId());
|
||||||
|
}
|
||||||
|
accountDeleteDAO.deleteDiapauseRow(t.getMberId());
|
||||||
|
|
||||||
|
AccountDeleteHistoryVO hist = new AccountDeleteHistoryVO();
|
||||||
|
hist.setMberId(t.getMberId());
|
||||||
|
hist.setMberTyCode(mberTyCode);
|
||||||
|
hist.setEsntlId(t.getEsntlId());
|
||||||
|
hist.setDiapauseDt(t.getDiapauseDt());
|
||||||
|
hist.setPreserveDay(t.getPreserveDay());
|
||||||
|
accountDeleteDAO.insertDeleteHistory(hist);
|
||||||
|
|
||||||
|
cnt++;
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.error("[개인정보/#12] 계정 파기 실패 — 건너뜀 (id={}, ty={}): {}",
|
||||||
|
new Object[] { t.getMberId(), mberTyCode, e.getMessage() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cnt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
package egovframework.com.cmm.privacy.account;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 장기 미사용 계정 파기 대상 VO (SFR-002 / 개발항목 #12).
|
||||||
|
*
|
||||||
|
* <p>휴면 확정('P') 후 정책상 보존기간(ACCOUNT_MNG_POLICY.DELETE_PRESERVE_DAY)이
|
||||||
|
* 경과한 계정. preserveDay 는 조회 시점의 정책값으로, 파기 이력에 스냅샷으로 남긴다.</p>
|
||||||
|
*/
|
||||||
|
public class AccountDeleteTargetVO implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/** 파기 대상 계정ID(일반회원 MBER_ID / 취급자 EMPLYR_ID) */
|
||||||
|
private String mberId;
|
||||||
|
/** 고유식별자(ESNTL_ID) */
|
||||||
|
private String esntlId;
|
||||||
|
/** 휴면 확정 기준일시 */
|
||||||
|
private String diapauseDt;
|
||||||
|
/** 조회 시점 적용된 보존기간(일) */
|
||||||
|
private int preserveDay;
|
||||||
|
|
||||||
|
public String getMberId() { return mberId; }
|
||||||
|
public void setMberId(String mberId) { this.mberId = mberId; }
|
||||||
|
public String getEsntlId() { return esntlId; }
|
||||||
|
public void setEsntlId(String esntlId) { this.esntlId = esntlId; }
|
||||||
|
public String getDiapauseDt() { return diapauseDt; }
|
||||||
|
public void setDiapauseDt(String diapauseDt) { this.diapauseDt = diapauseDt; }
|
||||||
|
public int getPreserveDay() { return preserveDay; }
|
||||||
|
public void setPreserveDay(int preserveDay) { this.preserveDay = preserveDay; }
|
||||||
|
}
|
||||||
@ -1,7 +1,9 @@
|
|||||||
package egovframework.com.cmm.privacy.account;
|
package egovframework.com.cmm.privacy.account;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
|
|
||||||
@ -13,12 +15,19 @@ import egovframework.com.cop.ems.service.EgovSndngMailRegistService;
|
|||||||
import egovframework.com.cop.ems.service.SndngMailVO;
|
import egovframework.com.cop.ems.service.SndngMailVO;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 휴면 전 사전 알림 서비스 구현 (#10, D-30/D-7/D-Day + 관리자 알림).
|
* 휴면 전 사전 알림 서비스 구현 (#10, D-30/D-7/D-Day 관리자 알림).
|
||||||
*
|
*
|
||||||
* <p>정책(ACCOUNT_MNG_POLICY)의 ALERT_D1/D2/D3(기본 30/7/0)일 전 대상을 조회해
|
* <p>정책(ACCOUNT_MNG_POLICY)의 ALERT_D1/D2/D3(기본 30/7/0)일 전 대상을 조회해
|
||||||
* 본인과 관리자(ADMIN_EMAIL)에게 사전 알림 메일을 발송한다. 기존 휴면 배치의
|
* 관리자(ADMIN_EMAIL)에게 배치 1회 실행당 전체 대상을 모은 요약(digest) 메일
|
||||||
* 유예/확정 본인 메일(EgovLoginServiceImpl.processDiapauseList)과 독립적으로 동작하며,
|
* 1통으로 발송한다. 대상자가 많을 때 관리자 메일함이 건별 메일로 도배되는 것을
|
||||||
* 발송 실패가 배치 본류를 막지 않도록 전 구간 fail-safe.</p>
|
* 막기 위함(2026-07-28 개선). 발송 실패가 배치 본류를 막지 않도록 전 구간 fail-safe.</p>
|
||||||
|
*
|
||||||
|
* <p><b>2026-07-30 본인 발송 제거</b>: 발주 요구사항(Ⅲ-2.3, 요구사항 #4)은
|
||||||
|
* '비활성화 전 <b>관리자</b> 알림 D-30/D-7/D-Day'이며 대상자 본인 개별 통지는 요건이
|
||||||
|
* 아니다. 대상 5만건 규모에서 본인 건별 발송이 배치 처리량을 지배해 일일 상한을
|
||||||
|
* 200건으로 묶어야 했고(휴면 전환 소진에 수년 소요), 이를 해소하기 위해 본인 발송을
|
||||||
|
* 걷어냈다. 휴면 확정 체인의 본인 메일 2종도 함께 제거
|
||||||
|
* (EgovLoginServiceImpl.processDiapauseList 주석 참조).</p>
|
||||||
*/
|
*/
|
||||||
@Service("dormantAlertService")
|
@Service("dormantAlertService")
|
||||||
public class DormantAlertServiceImpl implements DormantAlertService {
|
public class DormantAlertServiceImpl implements DormantAlertService {
|
||||||
@ -46,6 +55,11 @@ public class DormantAlertServiceImpl implements DormantAlertService {
|
|||||||
|
|
||||||
String adminEmail = policy.getAdminEmail();
|
String adminEmail = policy.getAdminEmail();
|
||||||
int sent = 0;
|
int sent = 0;
|
||||||
|
int targetCnt = 0;
|
||||||
|
|
||||||
|
// 단계별 대상자를 모아두었다가 관리자에게는 배치 종료 후 요약 1통만 발송한다.
|
||||||
|
Map<Integer, List<DormantAccountStatusVO>> stageTargets =
|
||||||
|
new LinkedHashMap<Integer, List<DormantAccountStatusVO>>();
|
||||||
|
|
||||||
for (Integer before : stages) {
|
for (Integer before : stages) {
|
||||||
List<DormantAccountStatusVO> targets;
|
List<DormantAccountStatusVO> targets;
|
||||||
@ -55,14 +69,19 @@ public class DormantAlertServiceImpl implements DormantAlertService {
|
|||||||
LOG.error("[개인정보/#10] 사전알림 대상 조회 오류(D-{}) — 건너뜀: {}", before, e.getMessage());
|
LOG.error("[개인정보/#10] 사전알림 대상 조회 오류(D-{}) — 건너뜀: {}", before, e.getMessage());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (targets == null) {
|
if (targets == null || targets.isEmpty()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (DormantAccountStatusVO t : targets) {
|
stageTargets.put(before, targets);
|
||||||
sent += sendOne(t, before.intValue(), adminEmail);
|
targetCnt += targets.size();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (adminEmail != null && adminEmail.trim().length() > 0 && !stageTargets.isEmpty()) {
|
||||||
|
sent += sendAdminDigest(stageTargets, adminEmail);
|
||||||
}
|
}
|
||||||
LOG.info("[개인정보/#10] 휴면 사전알림 발송 완료 — {}건", Integer.valueOf(sent));
|
|
||||||
|
LOG.info("[개인정보/#10] 휴면 사전알림 완료 — 대상 {}건, 관리자 요약 {}통 발송",
|
||||||
|
Integer.valueOf(targetCnt), Integer.valueOf(sent));
|
||||||
return sent;
|
return sent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,21 +92,30 @@ public class DormantAlertServiceImpl implements DormantAlertService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 본인 + 관리자 발송(각각 fail-safe). 발송 성공 건수 반환. */
|
/** 관리자 요약 발송(fail-safe). 배치 1회 실행당 단계 무관 1통. 발송 성공 시 1, 실패 시 0. */
|
||||||
private int sendOne(DormantAccountStatusVO t, int before, String adminEmail) {
|
private int sendAdminDigest(Map<Integer, List<DormantAccountStatusVO>> stageTargets, String adminEmail) {
|
||||||
int cnt = 0;
|
int totalCount = 0;
|
||||||
String dLabel = before == 0 ? "D-Day" : ("D-" + before);
|
for (List<DormantAccountStatusVO> targets : stageTargets.values()) {
|
||||||
String subject = "[문화품앗이] 장기 미접속 계정 휴면 전환 사전 안내 (" + dLabel + ")";
|
totalCount += targets.size();
|
||||||
String body = (t.getUserNm() == null ? "" : t.getUserNm()) + "(" + t.getAccountId()
|
|
||||||
+ ") 계정이 미접속 " + t.getNoConnectDays() + "일로 곧 휴면 전환됩니다. 로그인 시 유지됩니다.";
|
|
||||||
|
|
||||||
// 본인 발송(대상 이메일이 조회 VO에 없으면 관리자에게만 — 취급자 메일 컬럼 미보유 방어)
|
|
||||||
cnt += sendMail(t.getAccountId(), subject, body);
|
|
||||||
// 관리자 발송(#10 핵심 — 관리자 알림 추가)
|
|
||||||
if (adminEmail != null && adminEmail.trim().length() > 0) {
|
|
||||||
cnt += sendMail(adminEmail, subject + " - 관리자통지", body);
|
|
||||||
}
|
}
|
||||||
return cnt;
|
|
||||||
|
StringBuilder body = new StringBuilder();
|
||||||
|
body.append("휴면 전환 사전 알림 대상 총 ").append(totalCount).append("건\n\n");
|
||||||
|
for (Map.Entry<Integer, List<DormantAccountStatusVO>> entry : stageTargets.entrySet()) {
|
||||||
|
int before = entry.getKey().intValue();
|
||||||
|
String dLabel = before == 0 ? "D-Day" : ("D-" + before);
|
||||||
|
List<DormantAccountStatusVO> targets = entry.getValue();
|
||||||
|
body.append("[").append(dLabel).append("] ").append(targets.size()).append("건\n");
|
||||||
|
for (DormantAccountStatusVO t : targets) {
|
||||||
|
body.append(" - ").append(t.getUserNm() == null ? "" : t.getUserNm())
|
||||||
|
.append("(").append(t.getAccountId()).append(") 미접속 ")
|
||||||
|
.append(t.getNoConnectDays()).append("일\n");
|
||||||
|
}
|
||||||
|
body.append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
String subject = "[문화품앗이] 휴면 전환 사전 알림 요약 (총 " + totalCount + "건)";
|
||||||
|
return sendMail(adminEmail, subject, body.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
private int sendMail(String recipient, String subject, String body) {
|
private int sendMail(String recipient, String subject, String body) {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import java.util.List;
|
|||||||
import java.util.concurrent.BlockingQueue;
|
import java.util.concurrent.BlockingQueue;
|
||||||
import java.util.concurrent.LinkedBlockingQueue;
|
import java.util.concurrent.LinkedBlockingQueue;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
import javax.annotation.PostConstruct;
|
import javax.annotation.PostConstruct;
|
||||||
import javax.annotation.PreDestroy;
|
import javax.annotation.PreDestroy;
|
||||||
@ -23,11 +24,23 @@ import org.springframework.stereotype.Component;
|
|||||||
*
|
*
|
||||||
* <p><b>순번·해시체인 무결성(#39):</b> 컨슈머는 <b>단일 스레드</b>다. 큐에서 하나씩 꺼내
|
* <p><b>순번·해시체인 무결성(#39):</b> 컨슈머는 <b>단일 스레드</b>다. 큐에서 하나씩 꺼내
|
||||||
* {@link PersonalDataLogService#persistNow}를 순차 호출하므로 {@code PREV_HASH_VAL}이 항상
|
* {@link PersonalDataLogService#persistNow}를 순차 호출하므로 {@code PREV_HASH_VAL}이 항상
|
||||||
* 직전 레코드를 정확히 가리킨다. 병렬 컨슈머를 두지 않는다(전체 순번 보장).</p>
|
* 직전 레코드를 정확히 가리킨다. 병렬 컨슈머를 두지 않는다(전체 순번 보장).
|
||||||
|
* 컨슈머 재기동·종료 flush 등 예외 경로에서도 {@code persistNow}가 {@code synchronized}라
|
||||||
|
* 두 스레드가 동시에 봉인·적재하는 일은 없다.</p>
|
||||||
*
|
*
|
||||||
* <p><b>드롭 금지·백프레셔:</b> 큐가 가득 차면 레코드를 버리지 않고 {@code offer(timeout)}로
|
* <p><b>백프레셔 상한(2026-07-30):</b> 종전에는 큐가 가득 차면 {@code offer(timeout)}를
|
||||||
* 대기(백프레셔)한다. enqueue는 업무 트랜잭션 커밋 이후(Aspect의 finally) 호출되므로 짧은
|
* <u>무한 반복</u>해 대기했다. 컨슈머가 정지하면(예: 파티션 DROP·전체 무결성 검증 중 테이블
|
||||||
* 대기가 업무 데이터 정합성을 해치지 않는다.</p>
|
* 잠금으로 {@code persistNow}가 블로킹, 또는 컨슈머 스레드 사망) 요청 스레드가 이 지점에서
|
||||||
|
* 영구 대기하고, 그 상태가 지속되면 WAS 스레드 풀이 고갈돼 <b>사이트 전체가 정지</b>한다.
|
||||||
|
* 복구 경로가 없는 구조라 대기를 {@link #MAX_OFFER_ATTEMPT}회로 제한한다. 상한을 넘기면
|
||||||
|
* 해당 1건을 포기하되 <b>유실 사실과 식별 정보를 ERROR 로그로 남기고 누적 건수를 집계</b>한다
|
||||||
|
* (접속기록은 법정 보관 대상이므로 조용한 유실은 금지 — 사후 추적 가능하게 남긴다).</p>
|
||||||
|
*
|
||||||
|
* <p><b>컨슈머 감시(2026-07-30):</b> 적재 루프가 {@code Throwable}까지 흡수해 스스로 죽지
|
||||||
|
* 않지만, 그것만으로는 외부 요인(스레드 강제 종료 등)에 대비할 수 없다. 별도 감시 스레드가
|
||||||
|
* 주기적으로 컨슈머 생존과 큐 적체를 점검하고, 죽어 있으면 재기동한다. 감시는 스케줄러
|
||||||
|
* 설정({@code task:annotation-driven})에 의존하지 않는 자체 데몬 스레드다 — 스케줄러 활성화
|
||||||
|
* 여부와 무관하게 동작해야 하기 때문이다.</p>
|
||||||
*
|
*
|
||||||
* <p><b>종료 flush:</b> 컨테이너 종료 시 잔여 큐를 모두 적재한 뒤 스레드를 정리한다(유실 방지).</p>
|
* <p><b>종료 flush:</b> 컨테이너 종료 시 잔여 큐를 모두 적재한 뒤 스레드를 정리한다(유실 방지).</p>
|
||||||
*
|
*
|
||||||
@ -39,12 +52,24 @@ public class AccessLogQueue {
|
|||||||
|
|
||||||
private static final Log LOG = LogFactory.getLog(AccessLogQueue.class);
|
private static final Log LOG = LogFactory.getLog(AccessLogQueue.class);
|
||||||
|
|
||||||
/** 큐 용량(백프레셔 경계). 초과 시 드롭하지 않고 대기한다. */
|
/** 큐 용량(백프레셔 경계). */
|
||||||
private static final int CAPACITY = 10000;
|
private static final int CAPACITY = 10000;
|
||||||
/** enqueue 대기 1회 최대 시간(초). 초과하면 경고 로깅 후 재대기(무한 유실 방지). */
|
/** enqueue 대기 1회 최대 시간(초). */
|
||||||
private static final long OFFER_WAIT_SEC = 5L;
|
private static final long OFFER_WAIT_SEC = 5L;
|
||||||
|
/** enqueue 대기 최대 횟수 — 초과 시 해당 건을 포기한다(요청 스레드 영구 대기 방지). */
|
||||||
|
private static final int MAX_OFFER_ATTEMPT = 3;
|
||||||
/** 종료 시 컨슈머 스레드 join 최대 대기(ms). */
|
/** 종료 시 컨슈머 스레드 join 최대 대기(ms). */
|
||||||
private static final long SHUTDOWN_JOIN_MS = 10000L;
|
private static final long SHUTDOWN_JOIN_MS = 10000L;
|
||||||
|
/** 컨슈머 생존·적체 감시 주기(ms). */
|
||||||
|
private static final long WATCHDOG_INTERVAL_MS = 30000L;
|
||||||
|
/** 적체 경고 임계치(용량 대비). 이 이상 쌓이면 감시 스레드가 경고한다. */
|
||||||
|
private static final int BACKLOG_WARN_SIZE = (int) (CAPACITY * 0.8);
|
||||||
|
/** 포화 해제(차단기 복구) 임계치 — 잔량이 이 수준까지 빠지면 정상 경로로 복귀한다. */
|
||||||
|
private static final int SATURATION_CLEAR_SIZE = CAPACITY / 2;
|
||||||
|
/** 유실 로그 상세를 남기는 초기 건수(이후는 표본 기록 — 로그 폭주 방지). */
|
||||||
|
private static final long DROP_LOG_HEAD = 20L;
|
||||||
|
/** 초기 건수 이후 유실 로그 표본 주기. */
|
||||||
|
private static final long DROP_LOG_SAMPLE = 100L;
|
||||||
|
|
||||||
private final BlockingQueue<PersonalDataAccessLogVO> queue =
|
private final BlockingQueue<PersonalDataAccessLogVO> queue =
|
||||||
new LinkedBlockingQueue<PersonalDataAccessLogVO>(CAPACITY);
|
new LinkedBlockingQueue<PersonalDataAccessLogVO>(CAPACITY);
|
||||||
@ -53,54 +78,127 @@ public class AccessLogQueue {
|
|||||||
private PersonalDataLogService personalDataLogService;
|
private PersonalDataLogService personalDataLogService;
|
||||||
|
|
||||||
private volatile boolean running = false;
|
private volatile boolean running = false;
|
||||||
private Thread consumer;
|
private volatile Thread consumer;
|
||||||
|
private volatile Thread watchdog;
|
||||||
|
|
||||||
|
/** 큐 포화로 포기한 접속기록 누적 건수(운영 점검용). */
|
||||||
|
private final AtomicLong droppedCount = new AtomicLong(0L);
|
||||||
|
/** 컨슈머 재기동 누적 횟수(운영 점검용). */
|
||||||
|
private final AtomicLong consumerRestartCount = new AtomicLong(0L);
|
||||||
|
/**
|
||||||
|
* 포화 차단기. 한 번 포기가 발생하면 켜지고, 잔량이 {@link #SATURATION_CLEAR_SIZE}까지
|
||||||
|
* 빠지면 감시 스레드가 끈다. 켜져 있는 동안 {@link #enqueue}는 대기 없이 즉시 판정한다 —
|
||||||
|
* 포화가 지속되는 상황에서 요청마다 최대 {@code OFFER_WAIT_SEC × MAX_OFFER_ATTEMPT}초를
|
||||||
|
* 물게 하면 사이트 전체가 느려지기 때문이다.
|
||||||
|
*/
|
||||||
|
private volatile boolean saturated = false;
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void start() {
|
public void start() {
|
||||||
running = true;
|
running = true;
|
||||||
consumer = new Thread(new Runnable() {
|
startConsumer();
|
||||||
|
startWatchdog();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 컨슈머 스레드를 기동한다. 이미 살아 있으면 아무것도 하지 않는다
|
||||||
|
* (해시체인 순번 보장 전제상 컨슈머는 반드시 하나여야 하므로 동기화한다).
|
||||||
|
*/
|
||||||
|
private synchronized void startConsumer() {
|
||||||
|
if (!running) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Thread cur = consumer;
|
||||||
|
if (cur != null && cur.isAlive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Thread t = new Thread(new Runnable() {
|
||||||
public void run() {
|
public void run() {
|
||||||
consumeLoop();
|
consumeLoop();
|
||||||
}
|
}
|
||||||
}, "pdal-accesslog-consumer");
|
}, "pdal-accesslog-consumer");
|
||||||
consumer.setDaemon(true);
|
t.setDaemon(true);
|
||||||
consumer.start();
|
consumer = t;
|
||||||
|
t.start();
|
||||||
LOG.info("[개인정보/#33] 접속기록 비동기 컨슈머 기동 — 단일 스레드, capacity=" + CAPACITY);
|
LOG.info("[개인정보/#33] 접속기록 비동기 컨슈머 기동 — 단일 스레드, capacity=" + CAPACITY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 컨슈머 생존·큐 적체 감시 스레드를 기동한다. */
|
||||||
|
private synchronized void startWatchdog() {
|
||||||
|
Thread cur = watchdog;
|
||||||
|
if (cur != null && cur.isAlive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Thread t = new Thread(new Runnable() {
|
||||||
|
public void run() {
|
||||||
|
watchdogLoop();
|
||||||
|
}
|
||||||
|
}, "pdal-accesslog-watchdog");
|
||||||
|
t.setDaemon(true);
|
||||||
|
watchdog = t;
|
||||||
|
t.start();
|
||||||
|
LOG.info("[개인정보/#33] 접속기록 컨슈머 감시 기동 — 주기 " + (WATCHDOG_INTERVAL_MS / 1000) + "초");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 접속기록 1건을 큐에 적재한다(비동기, 드롭 금지).
|
* 접속기록 1건을 큐에 적재한다(비동기).
|
||||||
* 큐 포화 시 백프레셔로 대기한다. 종료 진행 중이면 인라인 적재로 유실을 방지한다.
|
*
|
||||||
|
* <p>큐가 가득 차면 {@link #MAX_OFFER_ATTEMPT}회까지만 대기한다. 그 이상은 요청 스레드를
|
||||||
|
* 붙잡지 않고 포기하며, 유실 사실을 ERROR로 남긴다. 종료 진행 중이면 인라인 적재한다
|
||||||
|
* ({@code persistNow}가 {@code synchronized}라 컨슈머와 동시 실행돼도 순번은 안전).</p>
|
||||||
*/
|
*/
|
||||||
public void enqueue(PersonalDataAccessLogVO vo) {
|
public void enqueue(PersonalDataAccessLogVO vo) {
|
||||||
if (vo == null) {
|
if (vo == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
if (!running) {
|
||||||
boolean queued = false;
|
// 종료 중 — 큐에 넣어도 처리되지 않을 수 있으므로 인라인 적재로 유실을 막는다.
|
||||||
while (running && !queued) {
|
|
||||||
queued = queue.offer(vo, OFFER_WAIT_SEC, TimeUnit.SECONDS);
|
|
||||||
if (!queued) {
|
|
||||||
LOG.warn("[개인정보/#33] 접속기록 큐 포화(size=" + queue.size()
|
|
||||||
+ ") — 컨슈머 지연. 드롭하지 않고 계속 대기.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!queued) {
|
|
||||||
// 종료 중(running=false) — 컨슈머가 이미 멈췄으므로 단일 라이터 보장 하에 인라인 적재.
|
|
||||||
personalDataLogService.persistNow(vo);
|
personalDataLogService.persistNow(vo);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
// 차단기 ON — 대기하지 않고 즉시 판정(요청 지연 확산 차단).
|
||||||
|
if (saturated) {
|
||||||
|
if (!queue.offer(vo)) {
|
||||||
|
recordDrop(vo);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
for (int attempt = 1; attempt <= MAX_OFFER_ATTEMPT; attempt++) {
|
||||||
|
if (!running) {
|
||||||
|
personalDataLogService.persistNow(vo);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (queue.offer(vo, OFFER_WAIT_SEC, TimeUnit.SECONDS)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LOG.warn("[개인정보/#33] 접속기록 큐 포화(size=" + queue.size() + ") — 컨슈머 지연, 대기 "
|
||||||
|
+ attempt + "/" + MAX_OFFER_ATTEMPT);
|
||||||
|
}
|
||||||
|
// 상한 초과 — 요청 스레드를 더 붙잡지 않는다. 무한 대기로 WAS 스레드를 고갈시키는 것보다
|
||||||
|
// 1건 유실이 낫다는 판단(단, recordDrop 로그로 사후 추적 가능하게 남긴다).
|
||||||
|
saturated = true;
|
||||||
|
LOG.error("[개인정보/#33] 접속기록 큐 포화 차단기 ON — 이후 대기 없이 즉시 판정한다. "
|
||||||
|
+ "잔량이 " + SATURATION_CLEAR_SIZE + " 이하로 빠지면 자동 복구.");
|
||||||
|
recordDrop(vo);
|
||||||
} catch (InterruptedException ie) {
|
} catch (InterruptedException ie) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
LOG.error("[개인정보/#33] 접속기록 enqueue 인터럽트 — 인라인 적재 시도.", ie);
|
LOG.error("[개인정보/#33] 접속기록 enqueue 인터럽트 — 인라인 적재 시도. " + describe(vo), ie);
|
||||||
try {
|
try {
|
||||||
personalDataLogService.persistNow(vo);
|
personalDataLogService.persistNow(vo);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.error("[개인정보/#33] 인라인 적재 실패", e);
|
LOG.error("[개인정보/#33] 인라인 적재 실패 — " + describe(vo), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 단일 컨슈머 루프 — 큐에서 하나씩 꺼내 순차 적재(순번 보장). */
|
/**
|
||||||
|
* 단일 컨슈머 루프 — 큐에서 하나씩 꺼내 순차 적재(순번 보장).
|
||||||
|
*
|
||||||
|
* <p>{@code Throwable}까지 흡수한다. {@code Exception}만 잡던 종전 구조에서는
|
||||||
|
* {@code Error}(OOM 등) 하나로 컨슈머가 죽고, {@code running}은 true로 남아 이후 모든
|
||||||
|
* {@link #enqueue}가 포화 대기에 빠졌다.</p>
|
||||||
|
*/
|
||||||
private void consumeLoop() {
|
private void consumeLoop() {
|
||||||
while (running || !queue.isEmpty()) {
|
while (running || !queue.isEmpty()) {
|
||||||
try {
|
try {
|
||||||
@ -111,20 +209,99 @@ public class AccessLogQueue {
|
|||||||
} catch (InterruptedException ie) {
|
} catch (InterruptedException ie) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
// 종료 신호 — 남은 큐를 마저 비우고 루프 조건에서 빠져나간다.
|
// 종료 신호 — 남은 큐를 마저 비우고 루프 조건에서 빠져나간다.
|
||||||
} catch (Exception e) {
|
} catch (Throwable t) {
|
||||||
LOG.error("[개인정보/#33] 접속기록 적재 처리 실패(로깅 후 계속)", e);
|
LOG.error("[개인정보/#33] 접속기록 적재 처리 실패(로깅 후 계속)", t);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LOG.info("[개인정보/#33] 접속기록 비동기 컨슈머 종료 — 잔여 큐 flush 완료.");
|
LOG.info("[개인정보/#33] 접속기록 비동기 컨슈머 종료 — 잔여 큐 flush 완료.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 컨슈머 생존과 큐 적체를 주기 점검한다. 컨슈머가 죽어 있으면 재기동한다. */
|
||||||
|
private void watchdogLoop() {
|
||||||
|
while (running) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(WATCHDOG_INTERVAL_MS);
|
||||||
|
if (!running) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Thread cur = consumer;
|
||||||
|
if (cur == null || !cur.isAlive()) {
|
||||||
|
long n = consumerRestartCount.incrementAndGet();
|
||||||
|
LOG.error("[개인정보/#33] 접속기록 컨슈머 사망 감지 — 재기동(누적 " + n + "회). 큐 잔량="
|
||||||
|
+ queue.size() + ", 누적 포기=" + droppedCount.get());
|
||||||
|
startConsumer();
|
||||||
|
} else if (queue.size() >= BACKLOG_WARN_SIZE) {
|
||||||
|
LOG.warn("[개인정보/#33] 접속기록 큐 적체 경고 — size=" + queue.size() + "/" + CAPACITY
|
||||||
|
+ ". 이 상태가 지속되면 포화로 유실이 시작된다(누적 포기=" + droppedCount.get() + "건).");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 차단기 복구 — 잔량이 절반 이하로 빠졌으면 정상(대기 허용) 경로로 되돌린다.
|
||||||
|
if (saturated && queue.size() <= SATURATION_CLEAR_SIZE) {
|
||||||
|
saturated = false;
|
||||||
|
LOG.info("[개인정보/#33] 접속기록 큐 포화 차단기 OFF — 정상 경로 복귀(size=" + queue.size()
|
||||||
|
+ ", 누적 포기=" + droppedCount.get() + "건).");
|
||||||
|
}
|
||||||
|
} catch (InterruptedException ie) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
break;
|
||||||
|
} catch (Throwable t) {
|
||||||
|
LOG.error("[개인정보/#33] 접속기록 컨슈머 감시 중 예외(계속)", t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LOG.info("[개인정보/#33] 접속기록 컨슈머 감시 종료.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유실 1건을 집계하고 기록한다.
|
||||||
|
*
|
||||||
|
* <p>접속기록은 법정 보관 대상이라 조용히 버리지 않는다. 다만 포화가 지속되면 유실 로그가
|
||||||
|
* 그대로 로그 폭주가 되므로, 초기 {@link #DROP_LOG_HEAD}건은 전부 남기고 이후에는
|
||||||
|
* {@link #DROP_LOG_SAMPLE}건마다 표본으로 남긴다. 누적 건수는 항상 정확히 집계된다.</p>
|
||||||
|
*/
|
||||||
|
private void recordDrop(PersonalDataAccessLogVO vo) {
|
||||||
|
long dropped = droppedCount.incrementAndGet();
|
||||||
|
if (dropped <= DROP_LOG_HEAD || dropped % DROP_LOG_SAMPLE == 0L) {
|
||||||
|
LOG.error("[개인정보/#33] 접속기록 1건 포기(누적 " + dropped + "건, size=" + queue.size()
|
||||||
|
+ ") — 컨슈머 상태 점검 필요. 유실건: " + describe(vo));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 유실·실패 로그용 식별 정보. 개인정보 본문은 담지 않는다(#31 본문 미적재 원칙). */
|
||||||
|
private String describe(PersonalDataAccessLogVO vo) {
|
||||||
|
if (vo == null) {
|
||||||
|
return "(null)";
|
||||||
|
}
|
||||||
|
return "conectId=" + vo.getConectId() + ", conectDt=" + vo.getConectDt()
|
||||||
|
+ ", jobSeCode=" + vo.getJobSeCode() + ", trgetId=" + vo.getTrgetId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 큐 포화로 포기한 누적 건수(운영 점검·정기점검 리포트 참고용). */
|
||||||
|
public long getDroppedCount() {
|
||||||
|
return droppedCount.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 컨슈머 재기동 누적 횟수(운영 점검용). */
|
||||||
|
public long getConsumerRestartCount() {
|
||||||
|
return consumerRestartCount.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 현재 큐 잔량(운영 점검용). */
|
||||||
|
public int getQueueSize() {
|
||||||
|
return queue.size();
|
||||||
|
}
|
||||||
|
|
||||||
@PreDestroy
|
@PreDestroy
|
||||||
public void stop() {
|
public void stop() {
|
||||||
running = false;
|
running = false;
|
||||||
if (consumer != null) {
|
Thread w = watchdog;
|
||||||
consumer.interrupt();
|
if (w != null) {
|
||||||
|
w.interrupt();
|
||||||
|
}
|
||||||
|
Thread c = consumer;
|
||||||
|
if (c != null) {
|
||||||
|
c.interrupt();
|
||||||
try {
|
try {
|
||||||
consumer.join(SHUTDOWN_JOIN_MS); // 잔여 flush 대기
|
c.join(SHUTDOWN_JOIN_MS); // 잔여 flush 대기
|
||||||
} catch (InterruptedException ie) {
|
} catch (InterruptedException ie) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
}
|
}
|
||||||
@ -136,11 +313,15 @@ public class AccessLogQueue {
|
|||||||
try {
|
try {
|
||||||
personalDataLogService.persistNow(remaining.get(i));
|
personalDataLogService.persistNow(remaining.get(i));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.error("[개인정보/#33] 종료 flush 적재 실패", e);
|
LOG.error("[개인정보/#33] 종료 flush 적재 실패 — " + describe(remaining.get(i)), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!remaining.isEmpty()) {
|
if (!remaining.isEmpty()) {
|
||||||
LOG.info("[개인정보/#33] 종료 시 잔여 " + remaining.size() + "건 인라인 flush 완료.");
|
LOG.info("[개인정보/#33] 종료 시 잔여 " + remaining.size() + "건 인라인 flush 완료.");
|
||||||
}
|
}
|
||||||
|
if (droppedCount.get() > 0L) {
|
||||||
|
LOG.error("[개인정보/#33] 가동 중 큐 포화로 포기한 접속기록 누계 " + droppedCount.get()
|
||||||
|
+ "건 — 원인 점검 필요(컨슈머 처리량/DB 지연).");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,267 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -25,4 +25,54 @@ public class JobMappingDAO extends EgovComAbstractDAO {
|
|||||||
public List<JobMappingVO> selectJobMappingList() {
|
public List<JobMappingVO> selectJobMappingList() {
|
||||||
return list("JobMappingDAO.selectJobMappingList", null);
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,53 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@ -0,0 +1,181 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
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; }
|
||||||
|
}
|
||||||
@ -33,6 +33,16 @@ public class JobMappingVO implements Serializable {
|
|||||||
private String useAt;
|
private String useAt;
|
||||||
private String rm;
|
private String rm;
|
||||||
|
|
||||||
|
// ---- 변경 추적(#32-1 관리 화면) : 시드 적재 행은 등록자 NULL ----
|
||||||
|
/** 등록자 계정ID */
|
||||||
|
private String registerId;
|
||||||
|
/** 등록 일시 */
|
||||||
|
private String regDt;
|
||||||
|
/** 최종 변경자 계정ID */
|
||||||
|
private String updusrId;
|
||||||
|
/** 최종 변경 일시 */
|
||||||
|
private String updtDt;
|
||||||
|
|
||||||
public long getMappingId() { return mappingId; }
|
public long getMappingId() { return mappingId; }
|
||||||
public void setMappingId(long mappingId) { this.mappingId = mappingId; }
|
public void setMappingId(long mappingId) { this.mappingId = mappingId; }
|
||||||
|
|
||||||
@ -59,4 +69,16 @@ public class JobMappingVO implements Serializable {
|
|||||||
|
|
||||||
public String getRm() { return rm; }
|
public String getRm() { return rm; }
|
||||||
public void setRm(String rm) { this.rm = rm; }
|
public void setRm(String rm) { this.rm = rm; }
|
||||||
|
|
||||||
|
public String getRegisterId() { return registerId; }
|
||||||
|
public void setRegisterId(String registerId) { this.registerId = registerId; }
|
||||||
|
|
||||||
|
public String getRegDt() { return regDt; }
|
||||||
|
public void setRegDt(String regDt) { this.regDt = regDt; }
|
||||||
|
|
||||||
|
public String getUpdusrId() { return updusrId; }
|
||||||
|
public void setUpdusrId(String updusrId) { this.updusrId = updusrId; }
|
||||||
|
|
||||||
|
public String getUpdtDt() { return updtDt; }
|
||||||
|
public void setUpdtDt(String updtDt) { this.updtDt = updtDt; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,195 @@
|
|||||||
|
package egovframework.com.cmm.privacy.auditlog;
|
||||||
|
|
||||||
|
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.util.EgovUserDetailsHelper;
|
||||||
|
import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개인정보 접속기록 조회·다운로드 컨트롤러 — 개인정보보호 기능개선 사업(SFR-004 / SER-002 / #47).
|
||||||
|
*
|
||||||
|
* <p>{@code PERSONAL_DATA_ACCESS_LOG}(#30) 적재분을 접속자ID·업무유형·처리대상·IP·처리구분·
|
||||||
|
* 처리결과·기간으로 복합 검색한다(#41 백엔드 연계). 화면:
|
||||||
|
* {@code /WEB-INF/jsp/site/mnt/sec/accessLog/list.jsp}</p>
|
||||||
|
*
|
||||||
|
* <p><b>다운로드(#42):</b> 사유 입력이 필수이며 미입력 시 400으로 차단하고 파일을 열지 않는다.
|
||||||
|
* 다운로드자·일시·IP·사유·건수는 {@code PDAL_DOWNLOAD_HISTORY}에 적재된다
|
||||||
|
* ({@link AccessLogDownloadService}). 개인정보 본문은 이력에 남기지 않는다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>접근통제:</b> 인터셉터 3단 중 ①인증판정이 비활성이고 ②③은 fail-open 이라
|
||||||
|
* /mnt 하위라도 무인증 노출이 가능하므로, {@link #handlerUser()} 로 화면·엔드포인트
|
||||||
|
* 단에서 직접 판정한다(LoginFailHistoryController 와 동일 방식).</p>
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* << 개정이력(Modification Information) >>
|
||||||
|
* 수정일 수정자 수정내용
|
||||||
|
* ---------- ------ ---------------------------
|
||||||
|
* 2026.08.06 URP 최초 생성 (개인정보보호 SFR-004 #47)
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
@Controller
|
||||||
|
public class PersonalDataAccessLogController {
|
||||||
|
|
||||||
|
private static final String LOGIN_REDIRECT = "redirect:/uat/uia/egovLoginUsr.do";
|
||||||
|
|
||||||
|
@Resource(name = "personalDataAccessLogSearchService")
|
||||||
|
private PersonalDataAccessLogSearchService personalDataAccessLogSearchService;
|
||||||
|
|
||||||
|
@Resource(name = "accessLogDownloadService")
|
||||||
|
private AccessLogDownloadService accessLogDownloadService;
|
||||||
|
|
||||||
|
@Resource(name = "personalDataAccessLogSearchDAO")
|
||||||
|
private PersonalDataAccessLogSearchDAO personalDataAccessLogSearchDAO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개인정보 접속기록 조회 화면(서버 렌더링).
|
||||||
|
* 뷰: /WEB-INF/jsp/site/mnt/sec/accessLog/list.jsp
|
||||||
|
*/
|
||||||
|
@RequestMapping(value = "/mnt/sec/accessLog/listView.do")
|
||||||
|
public String listView(@ModelAttribute("personalDataAccessLogSearchVO") PersonalDataAccessLogSearchVO searchVO,
|
||||||
|
ModelMap model) throws Exception {
|
||||||
|
if (handlerUser() == null) {
|
||||||
|
return LOGIN_REDIRECT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectAccessLogList 내부에서 firstIndex/totalRecordCount 를 세팅한다.
|
||||||
|
List<PersonalDataAccessLogVO> list = personalDataAccessLogSearchService.selectAccessLogList(searchVO);
|
||||||
|
|
||||||
|
PaginationInfo paginationInfo = new PaginationInfo();
|
||||||
|
paginationInfo.setCurrentPageNo(searchVO.getPageIndex() < 1 ? 1 : searchVO.getPageIndex());
|
||||||
|
paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage());
|
||||||
|
paginationInfo.setPageSize(10);
|
||||||
|
paginationInfo.setTotalRecordCount(searchVO.getTotalRecordCount());
|
||||||
|
|
||||||
|
model.addAttribute("resultList", list);
|
||||||
|
model.addAttribute("paginationInfo", paginationInfo);
|
||||||
|
model.addAttribute("jobSeList", selectJobSeCodeList());
|
||||||
|
return "site/mnt/sec/accessLog/list";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 접속기록 다운로드(CSV) — 사유 필수 + 다운로드 이력 적재(#42).
|
||||||
|
* 사유 미입력·차단 시 파일을 열지 않고 400 으로 응답한다.
|
||||||
|
*/
|
||||||
|
@RequestMapping(value = "/mnt/sec/accessLog/download.do")
|
||||||
|
public void download(@ModelAttribute("personalDataAccessLogSearchVO") PersonalDataAccessLogSearchVO 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<PersonalDataAccessLogVO> list;
|
||||||
|
try {
|
||||||
|
list = accessLogDownloadService.downloadAccessLog(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,수행업무코드,수행업무명,처리구분,처리대상ID,처리대상범위,처리결과,무결성해시");
|
||||||
|
if (list != null) {
|
||||||
|
for (PersonalDataAccessLogVO log : list) {
|
||||||
|
pw.println(log.getLogId() + "," + csv(log.getConectDt()) + "," + csv(log.getConectId())
|
||||||
|
+ "," + csv(log.getUserSe()) + "," + csv(log.getConectIp())
|
||||||
|
+ "," + csv(log.getJobSeCode()) + "," + csv(log.getJobSeNm())
|
||||||
|
+ "," + csv(dmlLabel(log.getDmlGubun())) + "," + csv(log.getTrgetId())
|
||||||
|
+ "," + csv(log.getTrgetCn()) + "," + csv(resultLabel(log.getProcessResult()))
|
||||||
|
+ "," + csv(log.getHashVal()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pw.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 검색조건 드롭다운용 업무유형 목록.
|
||||||
|
*
|
||||||
|
* <p>2026-08-07 소스 변경: 종전에는 PDAL_JOB_MAPPING(서비스 클래스·메서드 매핑)을 읽었으나,
|
||||||
|
* 수집이 관리자 화면 메뉴 기반({@code MenuAccessLogInterceptor})으로 바뀌어 업무유형이
|
||||||
|
* 메뉴에서 나온다. 매핑 테이블을 보면 실제 적재값과 어긋난 선택지가 떠 검색 결과가
|
||||||
|
* 항상 0건이 되므로, 적재된 로그의 DISTINCT 업무유형을 소스로 쓴다.</p>
|
||||||
|
*
|
||||||
|
* <p>조회 실패해도 화면은 떠야 하므로 빈 목록으로 fail-safe.</p>
|
||||||
|
*/
|
||||||
|
private List<JobMappingVO> selectJobSeCodeList() {
|
||||||
|
try {
|
||||||
|
List<JobMappingVO> list = personalDataAccessLogSearchDAO.selectJobSeCodeList();
|
||||||
|
return (list == null) ? new ArrayList<JobMappingVO>() : list;
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 드롭다운 소스일 뿐이므로 조회 실패가 화면 전체를 막지 않도록 한다.
|
||||||
|
return new ArrayList<JobMappingVO>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CSV 셀 이스케이프(구분자·따옴표·개행 방어). */
|
||||||
|
private String csv(String v) {
|
||||||
|
if (v == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String s = v.replace("\"", "\"\"");
|
||||||
|
return "\"" + s.replace("\r", " ").replace("\n", " ") + "\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String dmlLabel(String code) {
|
||||||
|
if ("S".equals(code)) return "조회";
|
||||||
|
if ("I".equals(code)) return "등록";
|
||||||
|
if ("U".equals(code)) return "수정";
|
||||||
|
if ("D".equals(code)) return "삭제";
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resultLabel(String code) {
|
||||||
|
if ("S".equals(code)) return "성공";
|
||||||
|
if ("F".equals(code)) return "실패/차단";
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 취급자 영역(/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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -30,4 +30,16 @@ public class PersonalDataAccessLogSearchDAO extends EgovComAbstractDAO {
|
|||||||
Object cnt = selectByPk("PersonalDataAccessLogDAO.selectAccessLogListCount", searchVO);
|
Object cnt = selectByPk("PersonalDataAccessLogDAO.selectAccessLogListCount", searchVO);
|
||||||
return cnt == null ? 0 : ((Integer) cnt).intValue();
|
return cnt == null ? 0 : ((Integer) cnt).intValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조회화면 '수행업무' 드롭다운 소스 — 실제 적재된 (업무코드, 업무명) 목록.
|
||||||
|
*
|
||||||
|
* <p>수집이 관리자 화면 메뉴 기반으로 바뀌면서 업무유형은 메뉴에서 나온다(2026-08-07).
|
||||||
|
* PDAL_JOB_MAPPING 을 보면 적재값과 어긋난 선택지가 뜨므로 적재분에서 직접 뽑는다.
|
||||||
|
* 반환 타입은 (jobSeCode, jobSeNm) 두 프로퍼티만 쓰는 용도로 {@link JobMappingVO}를 재사용한다.</p>
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public List<JobMappingVO> selectJobSeCodeList() {
|
||||||
|
return list("PersonalDataAccessLogDAO.selectJobSeCodeList", null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,16 @@ import egovframework.com.cmm.LoginVO;
|
|||||||
/**
|
/**
|
||||||
* 개인정보 접속기록 수집 Aspect (개발항목 #31, SFR-004 / DBR-001 / SER-002).
|
* 개인정보 접속기록 수집 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* 전 서비스 대상 방식이 아니라,
|
* <p><b>한정 수집:</b> {@code COMTNSYSLOG}의 select* 전 서비스 대상 방식이 아니라,
|
||||||
* 개인정보 처리 서비스로 한정한 pointcut(스키마 기반 {@code <aop:config>}, context-syslogaop.xml)에만
|
* 개인정보 처리 서비스로 한정한 pointcut(스키마 기반 {@code <aop:config>}, context-syslogaop.xml)에만
|
||||||
* 적용된다. 가로챈 메서드는 업무유형 매핑 테이블({@link JobMappingResolver}, #32)로 업무코드를 얻고,
|
* 적용된다. 가로챈 메서드는 업무유형 매핑 테이블({@link JobMappingResolver}, #32)로 업무코드를 얻고,
|
||||||
@ -32,6 +42,16 @@ import egovframework.com.cmm.LoginVO;
|
|||||||
* <p><b>본문 미적재 원칙:</b> 처리 파라미터(개인정보 본문)는 로그에 담지 않는다. 식별자(TRGET_ID)만
|
* <p><b>본문 미적재 원칙:</b> 처리 파라미터(개인정보 본문)는 로그에 담지 않는다. 식별자(TRGET_ID)만
|
||||||
* 베스트에포트로 취득한다(#36).</p>
|
* 베스트에포트로 취득한다(#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개 지점 한정에서 전 개인정보 처리 서비스로 확장.
|
* <p><b>수집 범위(#36):</b> 회원·업체 2개 지점 한정에서 전 개인정보 처리 서비스로 확장.
|
||||||
* pointcut 대상은 {@code context-syslogaop.xml}에 개인정보 취급 서비스 impl만 열거(과수집 방지).
|
* pointcut 대상은 {@code context-syslogaop.xml}에 개인정보 취급 서비스 impl만 열거(과수집 방지).
|
||||||
* 신규 화면 추가 시에는 이 pointcut(또는 대상 서비스에 편입)과 업무유형 매핑({@link JobMappingResolver})만
|
* 신규 화면 추가 시에는 이 pointcut(또는 대상 서비스에 편입)과 업무유형 매핑({@link JobMappingResolver})만
|
||||||
@ -56,7 +76,6 @@ public class PersonalDataLogAspect {
|
|||||||
private static final Log LOG = LogFactory.getLog(PersonalDataLogAspect.class);
|
private static final Log LOG = LogFactory.getLog(PersonalDataLogAspect.class);
|
||||||
|
|
||||||
private static final String DT_FORMAT = "yyyy-MM-dd HH:mm:ss";
|
private static final String DT_FORMAT = "yyyy-MM-dd HH:mm:ss";
|
||||||
private static final String ANONYMOUS = "ANONYMOUS";
|
|
||||||
|
|
||||||
@Resource(name = "jobMappingResolver")
|
@Resource(name = "jobMappingResolver")
|
||||||
private JobMappingResolver jobMappingResolver;
|
private JobMappingResolver jobMappingResolver;
|
||||||
@ -95,6 +114,13 @@ public class PersonalDataLogAspect {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 로그인 세션이 없는 요청은 취급자 접속기록이 아니므로 수집하지 않는다(사유는 클래스 Javadoc).
|
||||||
|
// 매핑 조회보다 먼저 판정해 불필요한 작업을 하지 않는다.
|
||||||
|
LoginVO login = currentLogin(request);
|
||||||
|
if (login == null || !notBlank(login.getId())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
String targetSimpleName = pjp.getTarget().getClass().getSimpleName();
|
String targetSimpleName = pjp.getTarget().getClass().getSimpleName();
|
||||||
String methodName = pjp.getSignature().getName();
|
String methodName = pjp.getSignature().getName();
|
||||||
|
|
||||||
@ -116,10 +142,8 @@ public class PersonalDataLogAspect {
|
|||||||
dmlGubun = JobMappingResolver.deriveDmlGubun(methodName);
|
dmlGubun = JobMappingResolver.deriveDmlGubun(methodName);
|
||||||
}
|
}
|
||||||
|
|
||||||
LoginVO login = currentLogin(request);
|
|
||||||
|
|
||||||
PersonalDataAccessLogVO vo = new PersonalDataAccessLogVO();
|
PersonalDataAccessLogVO vo = new PersonalDataAccessLogVO();
|
||||||
vo.setConectId(login != null && notBlank(login.getId()) ? login.getId() : ANONYMOUS);
|
vo.setConectId(login.getId());
|
||||||
vo.setConectDt(new SimpleDateFormat(DT_FORMAT).format(new Date()));
|
vo.setConectDt(new SimpleDateFormat(DT_FORMAT).format(new Date()));
|
||||||
vo.setConectIp(safeIp(request));
|
vo.setConectIp(safeIp(request));
|
||||||
vo.setJobSeCode(jobSeCode);
|
vo.setJobSeCode(jobSeCode);
|
||||||
|
|||||||
@ -1,14 +1,20 @@
|
|||||||
package egovframework.com.cmm.privacy.author;
|
package egovframework.com.cmm.privacy.author;
|
||||||
|
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.io.OutputStreamWriter;
|
||||||
|
import java.io.PrintWriter;
|
||||||
|
import java.net.URLEncoder;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.ModelMap;
|
import org.springframework.ui.ModelMap;
|
||||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
||||||
import egovframework.com.cmm.LoginVO;
|
import egovframework.com.cmm.LoginVO;
|
||||||
import egovframework.com.cmm.privacy.auditlog.ClientIpResolver;
|
import egovframework.com.cmm.privacy.auditlog.ClientIpResolver;
|
||||||
@ -18,8 +24,8 @@ import egovframework.rte.ptl.mvc.tags.ui.pagination.PaginationInfo;
|
|||||||
* 권한 신청·승인 워크플로 + 권한이력 조회·다운로드 컨트롤러
|
* 권한 신청·승인 워크플로 + 권한이력 조회·다운로드 컨트롤러
|
||||||
* (개인정보보호 SFR-001 / #3·#6).
|
* (개인정보보호 SFR-001 / #3·#6).
|
||||||
*
|
*
|
||||||
* <p>취급자 영역(/mnt) 백엔드 엔드포인트. 응답은 jsonView(화면 JSP는 #46 후속).
|
* <p>취급자 영역(/mnt) 백엔드 엔드포인트. 처리자/신청자 식별자와 IP는
|
||||||
* 처리자/신청자 식별자와 IP는 세션 loginVO·ClientIpResolver(#34)에서 취득한다.</p>
|
* 세션 loginVO·ClientIpResolver(#34)에서 취득한다.</p>
|
||||||
*/
|
*/
|
||||||
@Controller
|
@Controller
|
||||||
public class AuthorWorkflowController {
|
public class AuthorWorkflowController {
|
||||||
@ -142,7 +148,7 @@ public class AuthorWorkflowController {
|
|||||||
return JSON;
|
return JSON;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 권한 변경 이력 조회(대상자·변경자 양방향 검색 + 페이징) */
|
/** 권한 변경 이력 조회(대상자·변경자 양방향 검색 + 페이징, AJAX/JSON) */
|
||||||
@RequestMapping(value = "/mnt/sec/authorHistory/list.do")
|
@RequestMapping(value = "/mnt/sec/authorHistory/list.do")
|
||||||
public String historyList(AuthorChangeHistoryVO searchVO, ModelMap model) throws Exception {
|
public String historyList(AuthorChangeHistoryVO searchVO, ModelMap model) throws Exception {
|
||||||
List<AuthorChangeHistoryVO> list = authorHistoryService.selectHistoryList(searchVO);
|
List<AuthorChangeHistoryVO> list = authorHistoryService.selectHistoryList(searchVO);
|
||||||
@ -151,11 +157,32 @@ public class AuthorWorkflowController {
|
|||||||
return JSON;
|
return JSON;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 권한 변경 이력 다운로드 — 사유 필수 + 다운로드 이력 적재 */
|
/**
|
||||||
|
* 권한 변경 이력 조회·검색 화면(서버 렌더링).
|
||||||
|
* 뷰: /WEB-INF/jsp/site/mnt/sec/authorHistory/list.jsp
|
||||||
|
*/
|
||||||
|
@RequestMapping(value = "/mnt/sec/authorHistory/listView.do")
|
||||||
|
public String historyListView(@ModelAttribute("authorChangeHistoryVO") AuthorChangeHistoryVO searchVO,
|
||||||
|
ModelMap model) throws Exception {
|
||||||
|
PaginationInfo paginationInfo = new PaginationInfo();
|
||||||
|
paginationInfo.setCurrentPageNo(searchVO.getPageIndex());
|
||||||
|
paginationInfo.setRecordCountPerPage(searchVO.getRecordCountPerPage() < 1 ? 10 : searchVO.getRecordCountPerPage());
|
||||||
|
paginationInfo.setPageSize(10);
|
||||||
|
|
||||||
|
// selectHistoryList 내부에서 firstIndex/totalRecordCount 를 세팅한다.
|
||||||
|
List<AuthorChangeHistoryVO> list = authorHistoryService.selectHistoryList(searchVO);
|
||||||
|
paginationInfo.setTotalRecordCount(searchVO.getTotalRecordCount());
|
||||||
|
|
||||||
|
model.addAttribute("resultList", list);
|
||||||
|
model.addAttribute("paginationInfo", paginationInfo);
|
||||||
|
return "site/mnt/sec/authorHistory/list";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 권한 변경 이력 다운로드 — 사유 필수 + 다운로드 이력 적재 + CSV 파일 스트리밍 */
|
||||||
@RequestMapping(value = "/mnt/sec/authorHistory/download.do")
|
@RequestMapping(value = "/mnt/sec/authorHistory/download.do")
|
||||||
public String historyDownload(AuthorChangeHistoryVO searchVO,
|
public void historyDownload(AuthorChangeHistoryVO searchVO,
|
||||||
@org.springframework.web.bind.annotation.RequestParam(value = "dwldResn", required = false) String dwldResn,
|
@RequestParam(value = "dwldResn", required = false) String dwldResn,
|
||||||
HttpServletRequest request, ModelMap model) throws Exception {
|
HttpServletRequest request, HttpServletResponse response) throws Exception {
|
||||||
LoginVO user = loginUser(request);
|
LoginVO user = loginUser(request);
|
||||||
AuthorHistDownloadVO dl = new AuthorHistDownloadVO();
|
AuthorHistDownloadVO dl = new AuthorHistDownloadVO();
|
||||||
dl.setDwldResn(dwldResn);
|
dl.setDwldResn(dwldResn);
|
||||||
@ -166,15 +193,46 @@ public class AuthorWorkflowController {
|
|||||||
dl.setDownloaderIp(ClientIpResolver.getClientIp(request));
|
dl.setDownloaderIp(ClientIpResolver.getClientIp(request));
|
||||||
dl.setSearchCond("trget=" + searchVO.getSearchTrgetId() + ",changer=" + searchVO.getSearchChangerId()
|
dl.setSearchCond("trget=" + searchVO.getSearchTrgetId() + ",changer=" + searchVO.getSearchChangerId()
|
||||||
+ ",se=" + searchVO.getSearchChgSeCode() + ",from=" + searchVO.getFromDt() + ",to=" + searchVO.getToDt());
|
+ ",se=" + searchVO.getSearchChgSeCode() + ",from=" + searchVO.getFromDt() + ",to=" + searchVO.getToDt());
|
||||||
|
|
||||||
|
List<AuthorChangeHistoryVO> list;
|
||||||
try {
|
try {
|
||||||
List<AuthorChangeHistoryVO> list = authorHistoryService.downloadHistory(searchVO, dl);
|
list = authorHistoryService.downloadHistory(searchVO, dl);
|
||||||
model.addAttribute("resultList", list);
|
|
||||||
model.addAttribute("result", "success");
|
|
||||||
// TODO: 화면 #46 — 실제 파일(CSV/XLS) 스트리밍은 프론트/뷰 계층에서 처리
|
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
model.addAttribute("result", "fail");
|
// 사유 미입력 등 차단 — 다운로드를 열지 않고 오류만 응답
|
||||||
model.addAttribute("message", e.getMessage()); // 사유 미입력 차단
|
response.sendError(HttpServletResponse.SC_BAD_REQUEST, e.getMessage());
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return JSON;
|
|
||||||
|
String fileName = URLEncoder.encode("권한변경이력_" + new java.text.SimpleDateFormat("yyyyMMddHHmmss").format(new java.util.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,변경구분,변경전권한,변경후권한,변경사유,승인자,변경자ID,변경자IP");
|
||||||
|
if (list != null) {
|
||||||
|
for (AuthorChangeHistoryVO h : list) {
|
||||||
|
pw.println(csv(h.getChgDt()) + "," + csv(h.getTrgetId()) + "," + csv(chgSeLabel(h.getChgSeCode()))
|
||||||
|
+ "," + csv(h.getBeforeAuthor()) + "," + csv(h.getAfterAuthor()) + "," + csv(h.getChgResn())
|
||||||
|
+ "," + csv(h.getAprovlId()) + "," + csv(h.getChangerId()) + "," + csv(h.getChangerIp()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pw.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String chgSeLabel(String code) {
|
||||||
|
if ("G".equals(code)) return "부여";
|
||||||
|
if ("R".equals(code)) return "회수";
|
||||||
|
if ("M".equals(code)) return "변경";
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CSV 필드 이스케이프(쉼표/따옴표/개행 포함 시 큰따옴표로 감싸고 내부 따옴표는 두 배로) */
|
||||||
|
private String csv(String value) {
|
||||||
|
if (value == null) return "";
|
||||||
|
String escaped = value.replace("\"", "\"\"");
|
||||||
|
return "\"" + escaped + "\"";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,8 +29,10 @@ import egovframework.com.cmm.util.EgovUserDetailsHelper;
|
|||||||
*
|
*
|
||||||
* <p><b>preHandle 판정 순서:</b>
|
* <p><b>preHandle 판정 순서:</b>
|
||||||
* <ol>
|
* <ol>
|
||||||
* <li>{@code /mnt/} 외 · 제외패턴 · 미인증(loginVO==null) → 통과(기존 유지)</li>
|
* <li>{@code /mnt/} 외 · 제외패턴 → 통과(기존 유지)</li>
|
||||||
* <li>명시 매핑({@code urlRoleMapping}) 우선 — 매칭 시 해당 롤 필요(신규 privacy 화면 보호 유지)</li>
|
* <li>명시 매핑({@code urlRoleMapping}) 판정을 인증 판정보다 <b>먼저</b> 수행. 매칭 경로는
|
||||||
|
* 미인증이면 로그인 유도(화면)/401(AJAX)로 차단하고, 인증 상태면 해당 롤을 요구한다.
|
||||||
|
* 매칭되지 않으면 미인증은 기존과 동일하게 통과(일반 arko 관리화면 동작 불변)</li>
|
||||||
* <li>바이패스 롤({@code bypassRoles}, 기본 ROLE_ADMIN/ROLE_ADMINS) 보유자 → 통과(관리자 잠금 방지)</li>
|
* <li>바이패스 롤({@code bypassRoles}, 기본 ROLE_ADMIN/ROLE_ADMINS) 보유자 → 통과(관리자 잠금 방지)</li>
|
||||||
* <li>메뉴권한 판정 — 요청 path(쿼리 제외):
|
* <li>메뉴권한 판정 — 요청 path(쿼리 제외):
|
||||||
* <ul>
|
* <ul>
|
||||||
@ -50,12 +52,20 @@ import egovframework.com.cmm.util.EgovUserDetailsHelper;
|
|||||||
* ---------- ------ ---------------------------
|
* ---------- ------ ---------------------------
|
||||||
* 2026.07.21 URP 최초 생성 (개인정보보호 기능개선 사업 SFR-001 / #2)
|
* 2026.07.21 URP 최초 생성 (개인정보보호 기능개선 사업 SFR-001 / #2)
|
||||||
* 2026.07.22 URP 기존 메뉴권한 DB 기반 URL 접근제어로 개편(요건4) — 캐시/TTL/바이패스/fail-open
|
* 2026.07.22 URP 기존 메뉴권한 DB 기반 URL 접근제어로 개편(요건4) — 캐시/TTL/바이패스/fail-open
|
||||||
|
* 2026.07.28 URP 명시 매핑 판정을 미인증 판정보다 선행 — privacy 화면 무인증 노출 차단
|
||||||
* </pre>
|
* </pre>
|
||||||
*/
|
*/
|
||||||
public class AuthorityInterceptor extends HandlerInterceptorAdapter {
|
public class AuthorityInterceptor extends HandlerInterceptorAdapter {
|
||||||
|
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(AuthorityInterceptor.class);
|
private static final Logger LOG = LoggerFactory.getLogger(AuthorityInterceptor.class);
|
||||||
|
|
||||||
|
/** 미인증 화면 진입 시 유도할 로그인 URL(기존 arko 관례). */
|
||||||
|
private static final String LOGIN_URL = "/uat/uia/egovLoginUsr.do";
|
||||||
|
|
||||||
|
/** 미인증 AJAX/JSON 액션 거부 본문(계정 존재 여부 등 부가정보 미노출 — AccountMngController 와 동일 문구). */
|
||||||
|
private static final String DENY_JSON =
|
||||||
|
"{\"result\":\"fail\",\"message\":\"접근 권한이 없습니다. 관리자 계정으로 로그인 후 이용하세요.\"}";
|
||||||
|
|
||||||
/** 적용 경로 접두(개인정보취급자 영역). 기본 /mnt/ */
|
/** 적용 경로 접두(개인정보취급자 영역). 기본 /mnt/ */
|
||||||
private String applyPathPrefix = "/mnt/";
|
private String applyPathPrefix = "/mnt/";
|
||||||
|
|
||||||
@ -109,17 +119,26 @@ public class AuthorityInterceptor extends HandlerInterceptorAdapter {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) 미인증은 통과(기존 인증판정 체계가 처리)
|
// 3) 명시 매핑 판정을 미인증 판정보다 먼저 수행한다.
|
||||||
|
// (authenticInterceptor 가 비활성이라 미인증을 걸러줄 다음 단계가 실제로는 없다.
|
||||||
|
// 매핑에 걸린 privacy 화면만 여기서 인증을 강제하고, 그 외 /mnt/ 는 기존 동작 유지.)
|
||||||
|
String requiredRole = resolveRequiredRole(path);
|
||||||
|
|
||||||
LoginVO loginVO = (LoginVO) EgovUserDetailsHelper.getAuthenticatedUser();
|
LoginVO loginVO = (LoginVO) EgovUserDetailsHelper.getAuthenticatedUser();
|
||||||
if (loginVO == null) {
|
if (loginVO == null) {
|
||||||
|
if (requiredRole == null) {
|
||||||
|
// 매핑 밖 경로 — 기존과 동일하게 통과(일반 arko 관리화면 동작 변경 없음)
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
LOG.warn("[개인정보/#2] 미인증 접근 차단: path={}, requiredRole={}", path, requiredRole);
|
||||||
|
denyUnauthenticated(request, response);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// 사용자 롤 목록(#1 getAuthorities 활용)
|
// 사용자 롤 목록(#1 getAuthorities 활용)
|
||||||
List<String> roles = EgovUserDetailsHelper.getAuthorities();
|
List<String> roles = EgovUserDetailsHelper.getAuthorities();
|
||||||
|
|
||||||
// 4) 명시 매핑 우선 — 신규 privacy 화면(/mnt/privacy/·/mnt/sec/ → ROLE_ADMIN) 보호
|
// 4) 명시 매핑 우선 — 신규 privacy 화면(/mnt/privacy/·/mnt/sec/ → ROLE_ADMIN) 보호
|
||||||
String requiredRole = resolveRequiredRole(path);
|
|
||||||
if (requiredRole != null) {
|
if (requiredRole != null) {
|
||||||
if (roles != null && roles.contains(requiredRole)) {
|
if (roles != null && roles.contains(requiredRole)) {
|
||||||
return true;
|
return true;
|
||||||
@ -178,6 +197,29 @@ public class AuthorityInterceptor extends HandlerInterceptorAdapter {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 미인증 + 명시 매핑 경로에 대한 거부 응답.
|
||||||
|
* 화면 진입(브라우저 내비게이션)은 로그인 화면으로 유도, AJAX/JSON 액션은 401 + jsonView 동형 본문.
|
||||||
|
*/
|
||||||
|
private void denyUnauthenticated(HttpServletRequest request, HttpServletResponse response) throws Exception {
|
||||||
|
if (isBrowserNavigation(request)) {
|
||||||
|
response.sendRedirect(request.getContextPath() + LOGIN_URL);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
|
response.setContentType("application/json; charset=UTF-8");
|
||||||
|
response.getWriter().write(DENY_JSON);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 브라우저 화면 진입 여부. jQuery AJAX(X-Requested-With) 우선 판정 후 Accept 헤더로 보완. */
|
||||||
|
private boolean isBrowserNavigation(HttpServletRequest request) {
|
||||||
|
if ("XMLHttpRequest".equalsIgnoreCase(request.getHeader("X-Requested-With"))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String accept = request.getHeader("Accept");
|
||||||
|
return accept == null || accept.indexOf("text/html") >= 0 || "*/*".equals(accept.trim());
|
||||||
|
}
|
||||||
|
|
||||||
/** 명시 매핑에서 필요 권한 조회. 매핑 없으면 null. */
|
/** 명시 매핑에서 필요 권한 조회. 매핑 없으면 null. */
|
||||||
private String resolveRequiredRole(String path) {
|
private String resolveRequiredRole(String path) {
|
||||||
if (urlRoleMapping == null || urlRoleMapping.isEmpty()) {
|
if (urlRoleMapping == null || urlRoleMapping.isEmpty()) {
|
||||||
|
|||||||
@ -0,0 +1,625 @@
|
|||||||
|
package egovframework.com.cmm.privacy.interceptor;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
|
||||||
|
|
||||||
|
import egovframework.com.cmm.LoginVO;
|
||||||
|
import egovframework.com.cmm.privacy.auditlog.AccessLogQueue;
|
||||||
|
import egovframework.com.cmm.privacy.auditlog.ClientIpResolver;
|
||||||
|
import egovframework.com.cmm.privacy.auditlog.PersonalDataAccessLogVO;
|
||||||
|
import egovframework.com.cmm.util.EgovUserDetailsHelper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 화면 메뉴 접속기록 인터셉터 — 개인정보보호 기능개선 사업(SFR-004 / DBR-001 / SER-002).
|
||||||
|
*
|
||||||
|
* <p><b>무엇을 남기는가:</b> "개인정보취급자가 관리자 화면에서 어떤 메뉴를 열어 무슨 처리를 했는지"
|
||||||
|
* 1요청 = 1행으로 {@code PERSONAL_DATA_ACCESS_LOG}에 남긴다. 수행업무(JOB_SE_NM)는
|
||||||
|
* <b>기존 arko 메뉴 테이블의 메뉴명</b>이다 — 별도 매핑 테이블을 관리하지 않아도 메뉴가 늘면
|
||||||
|
* 접속기록 업무명도 따라 늘어난다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>왜 서비스 AOP에서 인터셉터로 바꿨나(2026-08-07):</b> 종전
|
||||||
|
* {@code PersonalDataLogAspect}는 서비스 impl 메서드를 가로챘다. 서비스는 관리자 화면과
|
||||||
|
* 프론트 화면이 공유하므로 <u>어느 화면에서 호출됐는지 구분할 수 없었고</u>, 그 결과
|
||||||
|
* 무인증 프론트 방문이 접속자ID {@code ANONYMOUS}, 수행업무 "재능나눔 회원관리"로 적재됐다
|
||||||
|
* (예: 공개 재능나눔 상세 {@code /frt/biz/talent/bizTalentView.do} → {@code selectTalentMber}).
|
||||||
|
* 요청 단위 인터셉터는 URL을 보므로 관리자 화면 요청만 정확히 골라낼 수 있다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>기록 대상 판정(3중 조건, 모두 만족해야 기록):</b>
|
||||||
|
* <ol>
|
||||||
|
* <li>요청 경로가 {@code includePatterns}(개인정보 취급 화면 <b>화이트리스트</b>)에 걸릴 것.
|
||||||
|
* 관리자 화면 전체가 아니라 개인정보를 취급하는 화면만 남긴다 — SFR-004의 대상은
|
||||||
|
* "개인정보 처리" 기록이고, 통계·게시판·코드관리까지 730일 보관 테이블에 쌓으면
|
||||||
|
* 정기점검·이상탐지 신호가 묻힌다.
|
||||||
|
* <b>화이트리스트 산출 근거:</b> 개인정보 서비스(구 AOP pointcut 16종)를 실제로 주입·호출하는
|
||||||
|
* <u>관리자 컨트롤러를 역산</u>해 그 URL 대역을 열거했다. 접두 하드코딩이 아니라 정규식이라
|
||||||
|
* {@code /uss/umt/} 안에서 부서관리만 빼는 식의 예외도 표현된다.
|
||||||
|
* 미설정이면 아무것도 기록하지 않는다(설정 누락 → 전 화면 과수집 방지).</li>
|
||||||
|
* <li>{@code excludePatterns}에 걸리지 않을 것 — 화이트리스트 안에서 빼야 하는 것들이다.
|
||||||
|
* 프레임(left/top/footer)·공통 include 는 화면 1회 진입마다 동반 호출되므로 제외하지 않으면
|
||||||
|
* 같은 진입이 4~5행으로 부풀어 통계를 망친다. 부서관리({@code /uss/umt/dpt/})처럼
|
||||||
|
* 대역 안의 비개인정보 화면도 여기서 뺀다.</li>
|
||||||
|
* <li>인증된 <b>개인정보취급자</b> 세션 — {@code LoginVO.userSe}가 {@code handlerUserSes}
|
||||||
|
* (기본 {@code USR})에 속할 것. 비로그인 요청은 기록하지 않는다(SFR-004는 취급자 기록이며,
|
||||||
|
* 모든 익명 요청이 한 계정으로 뭉치면 계정공유·대량조회 탐지에 상시 오탐이 된다).
|
||||||
|
* {@code userSe}가 비어 있으면 취급자 기록 누락을 막기 위해 기록한다(fail-safe).</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p><b>화면이 늘면:</b> 개인정보 취급 화면을 새로 만들면 {@code includePatterns}에 한 줄을 더해야
|
||||||
|
* 수집된다(설정 파일 변경 → 컨텍스트 리로드). 자동 확장이 아닌 것은 의도된 선택이다 —
|
||||||
|
* 화이트리스트 없이 자동으로 늘리면 과수집을 통제할 지점이 사라진다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>메뉴명 해석 순서:</b>
|
||||||
|
* <ol>
|
||||||
|
* <li>요청 파라미터 {@code page} — arko 관리자 링크는 {@code ...listView.do?page=9100044}처럼
|
||||||
|
* 메뉴번호를 달고 다니며 좌측메뉴 활성화 판정에 쓴다. 가장 정확한 단서라 1순위.</li>
|
||||||
|
* <li>경로 정확 일치 — 메뉴 URL('?' 앞)과 요청 경로가 같을 때.</li>
|
||||||
|
* <li>같은 디렉터리 내 최장 공통접두 — 목록화면만 메뉴로 등록되고 상세·수정·삭제 액션은
|
||||||
|
* 메뉴에 없는 것이 arko의 일반형이다. 이들을 소관 메뉴로 귀속시킨다
|
||||||
|
* (예: {@code /mnt/biz/talent/bizTalentMberView.do} → "재능나눔 회원관리" 메뉴).</li>
|
||||||
|
* <li>폴백 — 메뉴로 환원되지 않는 경로는 업무코드 {@code MNT_ETC}, 업무명에 경로를 남긴다
|
||||||
|
* (누락 없이 기록. 어느 메뉴에 붙일지는 운영에서 메뉴 등록으로 해소).</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p><b>처리구분(DML_GUBUN):</b> URL 마지막 세그먼트(.do 파일명)로 유도한다. HTTP 메서드는
|
||||||
|
* 쓰지 않는다 — 이 레거시는 삭제·수정도 GET 링크로 부르는 곳이 있어 메서드가 실제 행위를
|
||||||
|
* 대변하지 못한다. {@code ~View}로 끝나면 등록/수정 <u>화면</u>이므로 조회(S)로 판정하는 것이
|
||||||
|
* 핵심이다({@code EgovMberInsertView} = 등록화면 진입이지 등록이 아니다).</p>
|
||||||
|
*
|
||||||
|
* <p><b>처리대상(TRGET_ID):</b> 요청 파라미터에서 <u>식별자만</u> 우선순위로 취득한다. 검색어·
|
||||||
|
* 개인정보 본문은 담지 않는다(본문 미적재 원칙). 다건 선택(체크박스)이면 첫 값 + 건수를
|
||||||
|
* TRGET_CN에 남긴다.</p>
|
||||||
|
*
|
||||||
|
* <p><b>업무 무영향 보장:</b> {@code afterHandle} 대신 {@code afterCompletion}에서 적재하며
|
||||||
|
* (처리결과 성공/실패를 알아야 한다), 큐 enqueue만 하고 즉시 반환한다(#33). 이 인터셉터에서
|
||||||
|
* 발생한 예외는 전부 삼키고 로그만 남긴다 — 접속기록 수집이 업무를 막아서는 안 된다.</p>
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* << 개정이력(Modification Information) >>
|
||||||
|
* 수정일 수정자 수정내용
|
||||||
|
* ---------- ------ ---------------------------
|
||||||
|
* 2026.08.07 URP 최초 생성 (SFR-004 — 서비스 AOP 수집 → 관리자 화면 메뉴 접속기록 전환)
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
public class MenuAccessLogInterceptor extends HandlerInterceptorAdapter {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(MenuAccessLogInterceptor.class);
|
||||||
|
|
||||||
|
/** 접속일시 형식 — 해시체인 재검증 시 DB 적재 문자열과 일치해야 한다(#39). */
|
||||||
|
private static final String DT_FORMAT = "yyyy-MM-dd HH:mm:ss";
|
||||||
|
|
||||||
|
/** 메뉴 미해석 경로의 업무코드. */
|
||||||
|
private static final String ETC_JOB_CODE = "MNT_ETC";
|
||||||
|
/** 메뉴 기반 업무코드 접두 — JOB_SE_CODE 는 VARCHAR(30). */
|
||||||
|
private static final String MENU_JOB_CODE_PREFIX = "MENU_";
|
||||||
|
|
||||||
|
/** JOB_SE_CODE 컬럼 길이 보호. */
|
||||||
|
private static final int JOB_SE_CODE_MAX = 30;
|
||||||
|
/** JOB_SE_NM 컬럼 길이 보호. */
|
||||||
|
private static final int JOB_SE_NM_MAX = 100;
|
||||||
|
/** TRGET_ID 컬럼 길이 보호. */
|
||||||
|
private static final int TRGET_ID_MAX = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 처리대상 식별자 파라미터 우선순위. 식별자로 확정 가능한 이름만 둔다
|
||||||
|
* (searchKeyword 등 검색어는 개인정보 본문일 수 있어 절대 넣지 않는다).
|
||||||
|
*/
|
||||||
|
private static final String[] TRGET_ID_PARAMS = {
|
||||||
|
"esntlId", "uniqId", "selectedId", "mberId", "emplyrId", "entrprsId",
|
||||||
|
"groupId", "userId", "checkedIdForDel", "checkedIdForUpdt", "sn"
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===================== 설정(주입) =====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기록 대상 화면 정규식 화이트리스트(개인정보 취급 관리자 화면). 여기에 걸리는 요청만 기록한다.
|
||||||
|
* 미설정이면 아무것도 기록하지 않는다(과수집 방지 기본값).
|
||||||
|
*/
|
||||||
|
private List<String> includePatterns;
|
||||||
|
|
||||||
|
/** 기록 제외 정규식 패턴(프레임/공통 include 등). contextPath 제외 경로 대상. */
|
||||||
|
private List<String> excludePatterns;
|
||||||
|
|
||||||
|
/** 개인정보취급자로 볼 사용자구분 코드. 기본 {USR}. */
|
||||||
|
private Set<String> handlerUserSes;
|
||||||
|
|
||||||
|
/** 메뉴 캐시 유효시간(초). 기본 600초. */
|
||||||
|
private int cacheTtlSeconds = 600;
|
||||||
|
|
||||||
|
/** 메뉴명 조회 DAO(루트 컨텍스트 @Repository, 자식→부모 가시성으로 주입). */
|
||||||
|
private PrivacyMenuAuthDAO menuAuthDAO;
|
||||||
|
|
||||||
|
/** 비동기 적재 큐(루트 컨텍스트 @Component, 자식→부모 가시성으로 주입). */
|
||||||
|
private AccessLogQueue accessLogQueue;
|
||||||
|
|
||||||
|
// ===================== 캐시 =====================
|
||||||
|
|
||||||
|
/** 메뉴번호 → 메뉴 */
|
||||||
|
private volatile Map<String, MenuNameVO> byMenuNo;
|
||||||
|
/** 경로('?' 앞) → 메뉴 */
|
||||||
|
private volatile Map<String, MenuNameVO> byPath;
|
||||||
|
/** 디렉터리('/' 로 끝남) → 그 디렉터리의 메뉴 목록 */
|
||||||
|
private volatile Map<String, List<MenuNameVO>> byDir;
|
||||||
|
/** 캐시 적재 시각(ms) */
|
||||||
|
private volatile long cacheLoadedAt = 0L;
|
||||||
|
|
||||||
|
public void setIncludePatterns(List<String> includePatterns) { this.includePatterns = includePatterns; }
|
||||||
|
public void setExcludePatterns(List<String> excludePatterns) { this.excludePatterns = excludePatterns; }
|
||||||
|
public void setHandlerUserSes(Set<String> handlerUserSes) { this.handlerUserSes = handlerUserSes; }
|
||||||
|
public void setCacheTtlSeconds(int cacheTtlSeconds) { this.cacheTtlSeconds = cacheTtlSeconds; }
|
||||||
|
public void setMenuAuthDAO(PrivacyMenuAuthDAO menuAuthDAO) { this.menuAuthDAO = menuAuthDAO; }
|
||||||
|
public void setAccessLogQueue(AccessLogQueue accessLogQueue) { this.accessLogQueue = accessLogQueue; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요청 종료 시점에 접속기록 1건을 남긴다.
|
||||||
|
* 처리결과(성공/실패)를 판정해야 하므로 preHandle 이 아닌 afterCompletion 이다.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
|
||||||
|
Object handler, Exception ex) {
|
||||||
|
try {
|
||||||
|
writeAccessLog(request, response, ex);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
// 접속기록 수집은 업무 흐름을 절대 방해하지 않는다.
|
||||||
|
LOG.error("[개인정보/SFR-004] 메뉴 접속기록 수집 중 예외(무시)", t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeAccessLog(HttpServletRequest request, HttpServletResponse response, Exception ex) {
|
||||||
|
String path = requestPath(request);
|
||||||
|
|
||||||
|
// 1) 개인정보 취급 관리자 화면인가(화이트리스트)
|
||||||
|
if (!isIncluded(path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 2) 화이트리스트 안이지만 빼야 하는 것인가(프레임·부서관리 등)
|
||||||
|
if (isExcluded(path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 3) 개인정보취급자 세션인가
|
||||||
|
LoginVO login = handlerLogin();
|
||||||
|
if (login == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MenuNameVO menu = resolveMenu(request, path);
|
||||||
|
|
||||||
|
PersonalDataAccessLogVO vo = new PersonalDataAccessLogVO();
|
||||||
|
vo.setConectId(login.getId());
|
||||||
|
vo.setConectDt(new SimpleDateFormat(DT_FORMAT).format(new Date()));
|
||||||
|
vo.setConectIp(safeIp(request));
|
||||||
|
if (menu != null) {
|
||||||
|
vo.setJobSeCode(cut(MENU_JOB_CODE_PREFIX + nvl(menu.getMenuNo()), JOB_SE_CODE_MAX));
|
||||||
|
vo.setJobSeNm(cut(menu.getMenuNm(), JOB_SE_NM_MAX));
|
||||||
|
} else {
|
||||||
|
vo.setJobSeCode(ETC_JOB_CODE);
|
||||||
|
vo.setJobSeNm(cut(path, JOB_SE_NM_MAX));
|
||||||
|
}
|
||||||
|
vo.setDmlGubun(deriveDmlGubun(path));
|
||||||
|
vo.setTrgetId(extractTrgetId(request));
|
||||||
|
vo.setTrgetCn(extractTrgetCn(request));
|
||||||
|
vo.setProcessResult(isFailure(response, ex) ? "F" : "S");
|
||||||
|
vo.setUserSe(login.getUserSe());
|
||||||
|
|
||||||
|
if (accessLogQueue == null) {
|
||||||
|
LOG.error("[개인정보/SFR-004] accessLogQueue 미주입 — 접속기록 적재 불가: path={}", path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 큐에 적재만 하고 즉시 반환 — 봉인+INSERT 는 단일 컨슈머가 순차 수행(해시체인 순번 보장, #33).
|
||||||
|
accessLogQueue.enqueue(vo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 대상 판정 =====================
|
||||||
|
|
||||||
|
/** contextPath 를 제외한 요청 경로(쿼리 없음). */
|
||||||
|
private String requestPath(HttpServletRequest request) {
|
||||||
|
String ctx = request.getContextPath();
|
||||||
|
String uri = request.getRequestURI();
|
||||||
|
if (uri == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return (ctx != null && ctx.length() > 0 && uri.startsWith(ctx)) ? uri.substring(ctx.length()) : uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개인정보 취급 화면 화이트리스트 판정.
|
||||||
|
* 미설정이면 아무것도 기록하지 않는다 — 설정 누락이 전 화면 과수집으로 이어지지 않게 하는 기본값이다.
|
||||||
|
*/
|
||||||
|
private boolean isIncluded(String path) {
|
||||||
|
if (includePatterns == null || includePatterns.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < includePatterns.size(); i++) {
|
||||||
|
String p = includePatterns.get(i);
|
||||||
|
if (p != null && p.length() > 0 && Pattern.matches(p, path)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isExcluded(String path) {
|
||||||
|
if (excludePatterns == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < excludePatterns.size(); i++) {
|
||||||
|
String p = excludePatterns.get(i);
|
||||||
|
if (p != null && p.length() > 0 && Pattern.matches(p, path)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인증된 개인정보취급자 로그인 정보. 취급자가 아니거나 미인증이면 null.
|
||||||
|
* userSe 가 비어 있으면 취급자 기록 누락을 막기 위해 통과시킨다(fail-safe).
|
||||||
|
*/
|
||||||
|
private LoginVO handlerLogin() {
|
||||||
|
if (!Boolean.TRUE.equals(EgovUserDetailsHelper.isAuthenticated())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Object o = EgovUserDetailsHelper.getAuthenticatedUser();
|
||||||
|
if (!(o instanceof LoginVO)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
LoginVO login = (LoginVO) o;
|
||||||
|
if (!notBlank(login.getId())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String userSe = login.getUserSe();
|
||||||
|
if (!notBlank(userSe)) {
|
||||||
|
return login;
|
||||||
|
}
|
||||||
|
if (handlerUserSes == null || handlerUserSes.isEmpty()) {
|
||||||
|
return login;
|
||||||
|
}
|
||||||
|
return handlerUserSes.contains(userSe) ? login : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 처리결과 실패 판정 — 예외 발생 또는 4xx/5xx 응답.
|
||||||
|
*
|
||||||
|
* <p>{@code HttpServletResponse.getStatus()}는 Servlet 3.0부터다. 이 프로젝트는
|
||||||
|
* Servlet 2.5 API 로 컴파일되므로 직접 호출하면 컴파일되지 않는다. 런타임 컨테이너는
|
||||||
|
* Tomcat 9(Servlet 4.0)이라 실제로는 존재하므로 리플렉션으로 부르고, 없으면 성공으로 둔다.</p>
|
||||||
|
*/
|
||||||
|
private boolean isFailure(HttpServletResponse response, Exception ex) {
|
||||||
|
if (ex != null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (response == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Method m = response.getClass().getMethod("getStatus");
|
||||||
|
Object status = m.invoke(response);
|
||||||
|
return (status instanceof Integer) && ((Integer) status).intValue() >= 400;
|
||||||
|
} catch (Throwable t) {
|
||||||
|
// Servlet 2.5 컨테이너/래퍼 등 미지원 — 판정 불가 시 성공으로 둔다.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safeIp(HttpServletRequest request) {
|
||||||
|
try {
|
||||||
|
String ip = ClientIpResolver.getClientIp(request);
|
||||||
|
return notBlank(ip) ? ip : "-";
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 메뉴명 해석 =====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요청을 메뉴로 해석한다. 해석 순서는 클래스 Javadoc 참조.
|
||||||
|
*
|
||||||
|
* @return 매칭된 메뉴. 해석 불가면 null(호출측이 경로 폴백)
|
||||||
|
*/
|
||||||
|
private MenuNameVO resolveMenu(HttpServletRequest request, String path) {
|
||||||
|
Map<String, MenuNameVO> noMap;
|
||||||
|
Map<String, MenuNameVO> pathMap;
|
||||||
|
Map<String, List<MenuNameVO>> dirMap;
|
||||||
|
try {
|
||||||
|
ensureCache();
|
||||||
|
noMap = this.byMenuNo;
|
||||||
|
pathMap = this.byPath;
|
||||||
|
dirMap = this.byDir;
|
||||||
|
} catch (Throwable t) {
|
||||||
|
LOG.error("[개인정보/SFR-004] 메뉴 캐시 로드 실패 — 경로 폴백으로 기록: path=" + path, t);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (noMap == null || pathMap == null || dirMap == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) page 파라미터(메뉴번호) — arko 관리자 링크의 표준 단서
|
||||||
|
String page = trim(request.getParameter("page"));
|
||||||
|
if (notBlank(page)) {
|
||||||
|
MenuNameVO byNo = noMap.get(page);
|
||||||
|
if (byNo != null) {
|
||||||
|
return byNo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 경로 정확 일치
|
||||||
|
MenuNameVO exact = pathMap.get(path);
|
||||||
|
if (exact != null) {
|
||||||
|
return exact;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 같은 디렉터리 내 최장 공통접두 — 메뉴에 없는 하위 액션을 소관 메뉴로 귀속
|
||||||
|
return resolveByDirectory(dirMap, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 같은 디렉터리의 메뉴 중 요청 경로와 공통접두가 가장 긴 것을 고른다.
|
||||||
|
* 동률이면 메뉴번호가 작은 쪽(상위/기본 화면일 확률이 높다)을 택해 결과를 결정적으로 만든다.
|
||||||
|
*/
|
||||||
|
private MenuNameVO resolveByDirectory(Map<String, List<MenuNameVO>> dirMap, String path) {
|
||||||
|
int slash = path.lastIndexOf('/');
|
||||||
|
if (slash < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<MenuNameVO> candidates = dirMap.get(path.substring(0, slash + 1));
|
||||||
|
if (candidates == null || candidates.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
MenuNameVO best = null;
|
||||||
|
int bestLen = -1;
|
||||||
|
for (int i = 0; i < candidates.size(); i++) {
|
||||||
|
MenuNameVO cand = candidates.get(i);
|
||||||
|
int len = commonPrefixLength(path, stripQuery(cand.getUrl()));
|
||||||
|
if (len > bestLen || (len == bestLen && best != null && compareMenuNo(cand, best) < 0)) {
|
||||||
|
best = cand;
|
||||||
|
bestLen = len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int commonPrefixLength(String a, String b) {
|
||||||
|
if (a == null || b == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
int max = Math.min(a.length(), b.length());
|
||||||
|
int i = 0;
|
||||||
|
while (i < max && a.charAt(i) == b.charAt(i)) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 메뉴번호 비교(숫자 우선, 파싱 불가 시 문자열). */
|
||||||
|
private int compareMenuNo(MenuNameVO a, MenuNameVO b) {
|
||||||
|
String x = nvl(a.getMenuNo());
|
||||||
|
String y = nvl(b.getMenuNo());
|
||||||
|
try {
|
||||||
|
return Long.valueOf(Long.parseLong(x)).compareTo(Long.valueOf(Long.parseLong(y)));
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return x.compareTo(y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 캐시가 없거나 TTL 경과 시 메뉴를 재로드한다(더블체크 락). */
|
||||||
|
private void ensureCache() {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
long ttlMs = (long) cacheTtlSeconds * 1000L;
|
||||||
|
if (byPath != null && (now - cacheLoadedAt) < ttlMs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
synchronized (this) {
|
||||||
|
now = System.currentTimeMillis();
|
||||||
|
if (byPath != null && (now - cacheLoadedAt) < ttlMs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 메뉴 전량 로드 후 캐시 갱신. 실패 시 기존 캐시 보존(로그만). */
|
||||||
|
private void loadCache() {
|
||||||
|
if (menuAuthDAO == null) {
|
||||||
|
LOG.error("[개인정보/SFR-004] menuAuthDAO 미주입 — 메뉴명 캐시 로드 불가(경로 폴백으로 기록)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
List<MenuNameVO> rows = menuAuthDAO.selectMenuNames();
|
||||||
|
Map<String, MenuNameVO> noMap = new HashMap<String, MenuNameVO>();
|
||||||
|
Map<String, MenuNameVO> pathMap = new HashMap<String, MenuNameVO>();
|
||||||
|
Map<String, List<MenuNameVO>> dirMap = new HashMap<String, List<MenuNameVO>>();
|
||||||
|
if (rows != null) {
|
||||||
|
for (int i = 0; i < rows.size(); i++) {
|
||||||
|
MenuNameVO row = rows.get(i);
|
||||||
|
if (row == null || !notBlank(row.getMenuNm())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (notBlank(row.getMenuNo())) {
|
||||||
|
noMap.put(trim(row.getMenuNo()), row);
|
||||||
|
}
|
||||||
|
String p = stripQuery(row.getUrl());
|
||||||
|
if (!notBlank(p)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 같은 경로에 메뉴가 둘 이상이면 먼저 온 것을 유지(ORDER 없는 조회라 결정성은
|
||||||
|
// 메뉴번호로 보정하지 않는다 — 실제 중복은 운영에서 메뉴 정리 대상).
|
||||||
|
if (!pathMap.containsKey(p)) {
|
||||||
|
pathMap.put(p, row);
|
||||||
|
}
|
||||||
|
int slash = p.lastIndexOf('/');
|
||||||
|
if (slash >= 0) {
|
||||||
|
String dir = p.substring(0, slash + 1);
|
||||||
|
List<MenuNameVO> bucket = dirMap.get(dir);
|
||||||
|
if (bucket == null) {
|
||||||
|
bucket = new ArrayList<MenuNameVO>();
|
||||||
|
dirMap.put(dir, bucket);
|
||||||
|
}
|
||||||
|
bucket.add(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.byMenuNo = noMap;
|
||||||
|
this.byPath = pathMap;
|
||||||
|
this.byDir = dirMap;
|
||||||
|
this.cacheLoadedAt = System.currentTimeMillis();
|
||||||
|
if (LOG.isInfoEnabled()) {
|
||||||
|
LOG.info("[개인정보/SFR-004] 메뉴명 캐시 로드 완료: menus={}, paths={}, dirs={}",
|
||||||
|
new Object[] { Integer.valueOf(noMap.size()), Integer.valueOf(pathMap.size()),
|
||||||
|
Integer.valueOf(dirMap.size()) });
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.error("[개인정보/SFR-004] 메뉴명 캐시 로드 실패(기존 캐시 유지)", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 처리구분·처리대상 =====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL 마지막 세그먼트(.do 파일명)로 처리구분을 유도한다.
|
||||||
|
*
|
||||||
|
* <p>판정 순서가 중요하다.
|
||||||
|
* <ol>
|
||||||
|
* <li>{@code ~View}·{@code ~Pop}·{@code ~Form}으로 끝나면 <b>화면 진입</b>이므로 조회(S).
|
||||||
|
* {@code EgovMberInsertView}(등록화면)를 등록(I)으로 세면 실제 등록 건수가 부풀려진다.</li>
|
||||||
|
* <li>{@code select~}로 시작하면 조회(S). 이 규칙이 아래 I/U/D 토큰보다 <b>먼저</b>여야 한다 —
|
||||||
|
* {@code selectProappActivityApprovalList}(승인대상 목록조회)가 'approv' 때문에
|
||||||
|
* 수정(U)으로 잡히는 것을 막는다.</li>
|
||||||
|
* <li>삭제 → 등록 → 수정 토큰 순.</li>
|
||||||
|
* </ol>
|
||||||
|
* 짧고 흔한 토큰({@code add}·{@code save}·{@code write}·{@code edit}·{@code chang})은
|
||||||
|
* 부분일치로 쓰면 오판정한다({@code EgovAddress~}의 'add', {@code exchange}의 'chang').
|
||||||
|
* 이들은 접두 일치로만 본다.</p>
|
||||||
|
*/
|
||||||
|
static String deriveDmlGubun(String path) {
|
||||||
|
String name = lastSegment(path);
|
||||||
|
if (name == null) {
|
||||||
|
return "S";
|
||||||
|
}
|
||||||
|
String n = name.toLowerCase();
|
||||||
|
if (n.endsWith(".do")) {
|
||||||
|
n = n.substring(0, n.length() - 3);
|
||||||
|
}
|
||||||
|
// 1) 등록/수정 '화면' 진입은 조회다.
|
||||||
|
if (n.endsWith("view") || n.endsWith("pop") || n.endsWith("popup") || n.endsWith("form")) {
|
||||||
|
return "S";
|
||||||
|
}
|
||||||
|
// 2) select~ 는 조회 확정 — 뒤의 행위 토큰보다 우선한다.
|
||||||
|
if (n.startsWith("select")) {
|
||||||
|
return "S";
|
||||||
|
}
|
||||||
|
// 3) 삭제
|
||||||
|
if (contains(n, "delete") || contains(n, "remov") || n.endsWith("del")) {
|
||||||
|
return "D";
|
||||||
|
}
|
||||||
|
// 4) 등록
|
||||||
|
if (contains(n, "insert") || contains(n, "regist") || contains(n, "creat")
|
||||||
|
|| n.startsWith("add") || n.startsWith("save") || n.startsWith("write")) {
|
||||||
|
return "I";
|
||||||
|
}
|
||||||
|
// 5) 수정
|
||||||
|
if (contains(n, "updt") || contains(n, "update") || contains(n, "modify")
|
||||||
|
|| contains(n, "approv") || n.startsWith("chang") || n.startsWith("edit")) {
|
||||||
|
return "U";
|
||||||
|
}
|
||||||
|
return "S";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요청 파라미터에서 처리대상 식별자를 취득한다(우선순위: {@link #TRGET_ID_PARAMS}).
|
||||||
|
* 검색어·개인정보 본문은 담지 않는다. 다건이면 첫 값만 담고 건수는 TRGET_CN 이 맡는다.
|
||||||
|
*/
|
||||||
|
private String extractTrgetId(HttpServletRequest request) {
|
||||||
|
for (int i = 0; i < TRGET_ID_PARAMS.length; i++) {
|
||||||
|
String[] values = request.getParameterValues(TRGET_ID_PARAMS[i]);
|
||||||
|
if (values == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (int j = 0; j < values.length; j++) {
|
||||||
|
String v = trim(values[j]);
|
||||||
|
if (notBlank(v)) {
|
||||||
|
return cut(v, TRGET_ID_MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 다건 선택(체크박스 등)일 때만 "N건"을 남긴다. 단건은 TRGET_ID 로 충분하다. */
|
||||||
|
private String extractTrgetCn(HttpServletRequest request) {
|
||||||
|
for (int i = 0; i < TRGET_ID_PARAMS.length; i++) {
|
||||||
|
String[] values = request.getParameterValues(TRGET_ID_PARAMS[i]);
|
||||||
|
if (values == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int count = 0;
|
||||||
|
for (int j = 0; j < values.length; j++) {
|
||||||
|
if (notBlank(values[j])) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count > 1) {
|
||||||
|
return count + "건";
|
||||||
|
}
|
||||||
|
if (count == 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 유틸 =====================
|
||||||
|
|
||||||
|
private static boolean contains(String s, String token) {
|
||||||
|
return s.indexOf(token) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String lastSegment(String path) {
|
||||||
|
if (path == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int slash = path.lastIndexOf('/');
|
||||||
|
return (slash >= 0) ? path.substring(slash + 1) : path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String stripQuery(String url) {
|
||||||
|
if (url == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int q = url.indexOf('?');
|
||||||
|
return (q >= 0) ? url.substring(0, q) : url;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String cut(String s, int max) {
|
||||||
|
if (s == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String t = s.trim();
|
||||||
|
return (t.length() > max) ? t.substring(0, max) : t;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trim(String s) {
|
||||||
|
return (s == null) ? null : s.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String nvl(String s) {
|
||||||
|
return (s == null) ? "" : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean notBlank(String s) {
|
||||||
|
return s != null && s.trim().length() > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
package egovframework.com.cmm.privacy.interceptor;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메뉴번호 → 메뉴명/URL 매핑 행(row) VO.
|
||||||
|
*
|
||||||
|
* <p>개인정보보호 기능개선 사업(SFR-004 접속기록) — {@link MenuAccessLogInterceptor}가
|
||||||
|
* "관리자 화면에서 어떤 메뉴를 열었는지"를 접속기록의 수행업무로 남기기 위해,
|
||||||
|
* 기존 arko 메뉴 2테이블(COMTNMENUINFO + COMTNPROGRMLIST)을 조인해 캐싱하는 단일 행 표현.</p>
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>menuNo : 메뉴번호(COMTNMENUINFO.MENU_NO). arko 관리자 화면은 URL 에 {@code ?page=<메뉴번호>}
|
||||||
|
* 를 붙여 좌측메뉴 활성화를 판정하므로, 요청 파라미터 {@code page} 로도 직접 매칭된다.</li>
|
||||||
|
* <li>menuNm : 메뉴명(COMTNMENUINFO.MENU_NM). 접속기록의 수행업무명(JOB_SE_NM)이 된다.</li>
|
||||||
|
* <li>url : 프로그램 URL(COMTNPROGRMLIST.URL). 쿼리스트링 포함 가능 →
|
||||||
|
* 인터셉터가 '?' 앞 경로만 취해 캐시에 적재</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* << 개정이력(Modification Information) >>
|
||||||
|
* 수정일 수정자 수정내용
|
||||||
|
* ---------- ------ ---------------------------
|
||||||
|
* 2026.08.07 URP 최초 생성 (개인정보보호 SFR-004 — 관리자 메뉴 접속기록 전환)
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
public class MenuNameVO implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/** 메뉴번호(MENU_NO) */
|
||||||
|
private String menuNo;
|
||||||
|
|
||||||
|
/** 메뉴명(MENU_NM) */
|
||||||
|
private String menuNm;
|
||||||
|
|
||||||
|
/** 프로그램 URL(쿼리스트링 포함 가능) */
|
||||||
|
private String url;
|
||||||
|
|
||||||
|
public String getMenuNo() {
|
||||||
|
return menuNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMenuNo(String menuNo) {
|
||||||
|
this.menuNo = menuNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMenuNm() {
|
||||||
|
return menuNm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMenuNm(String menuNm) {
|
||||||
|
this.menuNm = menuNm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUrl() {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUrl(String url) {
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -21,6 +21,7 @@ import egovframework.com.cmm.service.impl.EgovComAbstractDAO;
|
|||||||
* 수정일 수정자 수정내용
|
* 수정일 수정자 수정내용
|
||||||
* ---------- ------ ---------------------------
|
* ---------- ------ ---------------------------
|
||||||
* 2026.07.22 URP 최초 생성 (개인정보보호 기능개선 사업 SFR-001 요건4 / #2)
|
* 2026.07.22 URP 최초 생성 (개인정보보호 기능개선 사업 SFR-001 요건4 / #2)
|
||||||
|
* 2026.08.07 URP selectMenuNames 추가 (SFR-004 — 관리자 메뉴 접속기록 전환)
|
||||||
* </pre>
|
* </pre>
|
||||||
*/
|
*/
|
||||||
@Repository("privacyMenuAuthDAO")
|
@Repository("privacyMenuAuthDAO")
|
||||||
@ -37,4 +38,20 @@ public class PrivacyMenuAuthDAO extends EgovComAbstractDAO {
|
|||||||
public List<MenuAuthVO> selectMenuUrlRoles() throws Exception {
|
public List<MenuAuthVO> selectMenuUrlRoles() throws Exception {
|
||||||
return list("PrivacyMenuAuthDAO.selectMenuUrlRoles", null);
|
return list("PrivacyMenuAuthDAO.selectMenuUrlRoles", null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (메뉴번호, 메뉴명, 프로그램 URL) 전체 목록을 조회한다(파라미터 없음).
|
||||||
|
*
|
||||||
|
* <p>{@link MenuAccessLogInterceptor}가 "요청 URL·page 파라미터 → 메뉴명"을 해석하기 위해
|
||||||
|
* 최초 요청 시 1회 전량 로드해 캐싱한다. 권한 배정(COMTNMENUCREATDTLS)과 무관하게
|
||||||
|
* <b>메뉴에 연결된 프로그램 전체</b>를 대상으로 한다 — 롤에 배정되지 않은 메뉴로 진입한
|
||||||
|
* 요청도 기록해야 하기 때문이다(기록 대상 판정은 인터셉터의 경로 접두·취급자 구분이 담당).</p>
|
||||||
|
*
|
||||||
|
* @return (menuNo, menuNm, url) 목록. 없으면 빈 목록.
|
||||||
|
* @throws Exception 조회 오류
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public List<MenuNameVO> selectMenuNames() throws Exception {
|
||||||
|
return list("PrivacyMenuAuthDAO.selectMenuNames", null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -244,7 +244,11 @@ private FilterConfig config;
|
|||||||
|
|
||||||
if(loginVO != null && loginVO.getId() != null && !loginVO.getId().equals(""))
|
if(loginVO != null && loginVO.getId() != null && !loginVO.getId().equals(""))
|
||||||
{
|
{
|
||||||
System.out.println("1필터액션::[" + loginVO.getId()+"]["+password+"]");
|
// 2026-08-05 [개인정보/SER-003] 평문 비밀번호 로그 출력 제거.
|
||||||
|
// 종전: println("1필터액션::["+id+"]["+password+"]") — 로그인 성공 전건의
|
||||||
|
// 평문 비밀번호가 catalina.out 에 누적되어 로그 파일 자체가 유출원이 되었다.
|
||||||
|
// 인증정보(비밀번호/해시/OTP비밀키)는 어떤 형태로도 로그에 남기지 않는다.
|
||||||
|
System.out.println("1필터액션::[" + loginVO.getId()+"]");
|
||||||
|
|
||||||
// ip filtering
|
// ip filtering
|
||||||
String myIp = ((HttpServletRequest)request).getRemoteAddr();
|
String myIp = ((HttpServletRequest)request).getRemoteAddr();
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import javax.annotation.Resource;
|
|||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import egovframework.com.cmm.EgovWebUtil;
|
|
||||||
import egovframework.com.cmm.LoginVO;
|
import egovframework.com.cmm.LoginVO;
|
||||||
import egovframework.com.cop.ems.service.EgovSndngMailRegistService;
|
import egovframework.com.cop.ems.service.EgovSndngMailRegistService;
|
||||||
import egovframework.com.uat.uia.service.EgovLoginService;
|
import egovframework.com.uat.uia.service.EgovLoginService;
|
||||||
@ -80,7 +79,12 @@ public class EgovLoginServiceImpl extends AbstractServiceImpl implements
|
|||||||
{
|
{
|
||||||
System.out.println("로그인 아이디 체크입니다.");
|
System.out.println("로그인 아이디 체크입니다.");
|
||||||
// 1. 입력한 비밀번호를 암호화한다.
|
// 1. 입력한 비밀번호를 암호화한다.
|
||||||
String enpassword = EgovFileScrty.encryptPassword(vo.getPassword());
|
// [개인정보보호 #27 후속] 솔트 적용 계정(S1: 형식)은 저장값에 솔트가 들어 있으므로
|
||||||
|
// 먼저 저장값을 읽어 같은 솔트로 해시한다. 무솔트(기존) 계정·없는 아이디면
|
||||||
|
// stored 가 null 이 되어 encryptPasswordAs 가 종전과 동일한 무솔트 해시를 돌려준다.
|
||||||
|
// → 아래 loginDAO.actionLogin 의 'AND password = #password#' 조건은 그대로 둔다.
|
||||||
|
String stored = loginDAO.selectStoredPassword(vo);
|
||||||
|
String enpassword = EgovFileScrty.encryptPasswordAs(vo.getPassword(), stored);
|
||||||
vo.setPassword(enpassword);
|
vo.setPassword(enpassword);
|
||||||
|
|
||||||
// 2. 아이디와 암호화된 비밀번호가 DB와 일치하는지 확인한다.
|
// 2. 아이디와 암호화된 비밀번호가 DB와 일치하는지 확인한다.
|
||||||
@ -202,8 +206,9 @@ public class EgovLoginServiceImpl extends AbstractServiceImpl implements
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. 임시 비밀번호를 암호화하여 DB에 저장한다.
|
// 3. 임시 비밀번호를 암호화하여 DB에 저장한다.
|
||||||
|
// [개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 사용자별 랜덤 솔트를 적용한다.
|
||||||
LoginVO pwVO = new LoginVO();
|
LoginVO pwVO = new LoginVO();
|
||||||
String enpassword = EgovFileScrty.encryptPassword(newpassword);
|
String enpassword = EgovFileScrty.encryptPasswordWithSalt(newpassword);
|
||||||
pwVO.setId(vo.getId());
|
pwVO.setId(vo.getId());
|
||||||
pwVO.setPassword(enpassword);
|
pwVO.setPassword(enpassword);
|
||||||
pwVO.setUserSe(vo.getUserSe());
|
pwVO.setUserSe(vo.getUserSe());
|
||||||
@ -233,8 +238,9 @@ public class EgovLoginServiceImpl extends AbstractServiceImpl implements
|
|||||||
*/
|
*/
|
||||||
public void updatePassword(LoginVO vo) throws Exception {
|
public void updatePassword(LoginVO vo) throws Exception {
|
||||||
// 임시 비밀번호를 암호화하여 DB에 저장한다.
|
// 임시 비밀번호를 암호화하여 DB에 저장한다.
|
||||||
|
// [개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 사용자별 랜덤 솔트를 적용한다.
|
||||||
LoginVO pwVO = new LoginVO();
|
LoginVO pwVO = new LoginVO();
|
||||||
String enpassword = EgovFileScrty.encryptPassword(vo.getPassword());
|
String enpassword = EgovFileScrty.encryptPasswordWithSalt(vo.getPassword());
|
||||||
pwVO.setId(vo.getId());
|
pwVO.setId(vo.getId());
|
||||||
pwVO.setPassword(enpassword);
|
pwVO.setPassword(enpassword);
|
||||||
pwVO.setUserSe(vo.getUserSe());
|
pwVO.setUserSe(vo.getUserSe());
|
||||||
@ -289,38 +295,54 @@ public class EgovLoginServiceImpl extends AbstractServiceImpl implements
|
|||||||
// 휴먼고객 확인후 테이블 입력
|
// 휴먼고객 확인후 테이블 입력
|
||||||
@Override
|
@Override
|
||||||
public void insertDiapause(LoginVO loginVO) throws Exception {
|
public void insertDiapause(LoginVO loginVO) throws Exception {
|
||||||
EgovWebUtil egovUtil = new EgovWebUtil();
|
|
||||||
|
|
||||||
// 1) 일반회원(COMTNGNRLMBER) 휴면 대상 적재 + 유예/확정 처리
|
// 1) 일반회원(COMTNGNRLMBER) 휴면 대상 적재 + 유예/확정 처리
|
||||||
loginDAO.insertDiapause(loginVO);
|
loginDAO.insertDiapause(loginVO);
|
||||||
processDiapauseList(loginDAO.selectDiapauseList(loginVO), egovUtil);
|
processDiapauseList(loginDAO.selectDiapauseList(loginVO));
|
||||||
|
|
||||||
// 2) [개인정보보호 #7] 개인정보취급자(COMTNEMPLYRINFO) 휴면 대상 확장 — 동일 유예/확정 체인
|
// 2) [개인정보보호 #7] 개인정보취급자(COMTNEMPLYRINFO) 휴면 대상 확장 — 동일 유예/확정 체인
|
||||||
// COMTNDIAPAUSEMBER.MBER_TY_CODE='EMP' 로 분리되어 일반회원 흐름과 혼선 없음.
|
// COMTNDIAPAUSEMBER.MBER_TY_CODE='EMP' 로 분리되어 일반회원 흐름과 혼선 없음.
|
||||||
loginDAO.insertDiapauseEmplyr(loginVO);
|
loginDAO.insertDiapauseEmplyr(loginVO);
|
||||||
processDiapauseList(loginDAO.selectDiapauseListEmplyr(loginVO), egovUtil);
|
processDiapauseList(loginDAO.selectDiapauseListEmplyr(loginVO));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 휴면 유예(→'S')/확정(→'P') 공통 처리 — 일반회원·개인정보취급자 공용(#7)
|
// 휴면 유예(→'S')/확정(→'P') 공통 처리 — 일반회원·개인정보취급자 공용(#7)
|
||||||
private void processDiapauseList(List<LoginVO> list, EgovWebUtil egovUtil) throws Exception {
|
//
|
||||||
|
// 2026-07-30 최초상태 판정 수정: insertDiapause/insertDiapauseEmplyr 는 DIAPAUSE_STAT 에
|
||||||
|
// 빈 문자열('')을 넣는다. Oracle 은 ''를 NULL 로 저장해 기존 `== null` 검사가 통했지만
|
||||||
|
// MySQL/MariaDB 는 ''를 그대로 보관하므로 두 분기 모두 빠져나가 map 이 빈 채로
|
||||||
|
// updateDiapauseMber 가 호출됐다(→ WHERE MBER_ID = NULL, 0건 갱신). 그 결과 신규 휴면 행이
|
||||||
|
// ''에 머물러 'S'·'P' 로 진행되지 않고, 보존기간 경과 파기(#12)까지 도달하지 못했다.
|
||||||
|
// 07-28 수정은 selectDiapauseList 의 조회조건만 ''를 인식하게 고쳤고 이 판정은 누락돼 있었다.
|
||||||
|
//
|
||||||
|
// 2026-07-30 본인 메일 발송 제거: 유예('S')·확정('P') 전이 시 보내던 본인 안내메일 2종
|
||||||
|
// (sendIdDiapauseMail/sendIdDiapauseStandbyMail)을 걷어냈다. 이용자 통지는 #10 사전알림
|
||||||
|
// 배치(D-30/D-7/D-Day)로 일원화하며, 발주 요구사항은 '비활성화 전 관리자 알림'이다.
|
||||||
|
// 발송이 빠지면서 이 루프가 순수 상태전이(PK 단건 UPDATE)만 남아 일일 처리 상한을
|
||||||
|
// 200 → 5,000 으로 올릴 수 있게 됐다(sqlmap 주석 참조).
|
||||||
|
private void processDiapauseList(List<LoginVO> list) throws Exception {
|
||||||
if (list == null) {
|
if (list == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (int i = 0; i < list.size(); i++) {
|
for (int i = 0; i < list.size(); i++) {
|
||||||
LoginVO vo = list.get(i);
|
LoginVO vo = list.get(i);
|
||||||
HashMap<String, String> map = new HashMap<>();
|
HashMap<String, String> map = new HashMap<>();
|
||||||
|
String stat = vo.getDiapause_stat();
|
||||||
|
|
||||||
if (vo.getDiapause_stat() == null) {
|
if (stat == null || stat.trim().length() == 0) {
|
||||||
egovUtil.sendIdDiapauseMail(vo.getEmail(), vo.getCreatDt(), vo.getName(), vo.getId());
|
|
||||||
map.put("id", vo.getId());
|
map.put("id", vo.getId());
|
||||||
map.put("diapause_stat", "S");
|
map.put("diapause_stat", "S");
|
||||||
|
|
||||||
} else if (vo.getDiapause_stat().equals("S")) {
|
} else if ("S".equals(stat.trim())) {
|
||||||
egovUtil.sendIdDiapauseStandbyMail(vo.getEmail(), vo.getCreatDt(), vo.getName(), vo.getId());
|
|
||||||
map.put("id", vo.getId());
|
map.put("id", vo.getId());
|
||||||
map.put("diapause_stat", "P");
|
map.put("diapause_stat", "P");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 어느 분기에도 걸리지 않은 상태값은 갱신 대상이 아니다. 빈 map 으로 UPDATE 를 던지면
|
||||||
|
// WHERE MBER_ID = NULL 이 되어 무의미하므로 건너뛴다(위 주석의 사고 재발 방지).
|
||||||
|
if (map.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
loginDAO.updateDiapauseMber(map);
|
loginDAO.updateDiapauseMber(map);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,6 +54,22 @@ public class LoginDAO extends EgovComAbstractDAO {
|
|||||||
return (LoginVO)selectByPk("loginDAO.actionLogin", vo);
|
return (LoginVO)selectByPk("loginDAO.actionLogin", vo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [개인정보보호 #27 후속] 저장된 비밀번호 값을 아이디만으로 조회한다.
|
||||||
|
*
|
||||||
|
* <p>솔트 적용 계정(S1: 형식)은 저장값에 솔트가 들어 있으므로, 로그인 시
|
||||||
|
* 평문을 해시하기 전에 이 값을 먼저 읽어야 한다. 무솔트(기존) 계정이거나
|
||||||
|
* 존재하지 않는 아이디면 null 이 되고, 그 경우 종전과 동일한 무솔트 해시로
|
||||||
|
* 계산되어 actionLogin 이 0건을 돌려준다(동작·계정열거 노출 모두 종전과 동일).</p>
|
||||||
|
*
|
||||||
|
* @param vo LoginVO (id, userSe 사용)
|
||||||
|
* @return 저장된 비밀번호 값(없으면 null)
|
||||||
|
* @exception Exception
|
||||||
|
*/
|
||||||
|
public String selectStoredPassword(LoginVO vo) throws Exception {
|
||||||
|
return (String)selectByPk("loginDAO.selectStoredPassword", vo);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 인증서 로그인을 처리한다
|
* 인증서 로그인을 처리한다
|
||||||
* @param vo LoginVO
|
* @param vo LoginVO
|
||||||
|
|||||||
@ -75,7 +75,8 @@ public class EgovEntrprsManageServiceImpl extends AbstractServiceImpl implements
|
|||||||
String uniqId = idgenService.getNextStringId();
|
String uniqId = idgenService.getNextStringId();
|
||||||
entrprsManageVO.setUniqId(uniqId);
|
entrprsManageVO.setUniqId(uniqId);
|
||||||
//패스워드 암호화
|
//패스워드 암호화
|
||||||
String pass = EgovFileScrty.encryptPassword(entrprsManageVO.getEntrprsMberPassword());
|
//[개인정보보호 #27 후속] 신규 계정이므로 사용자별 랜덤 솔트를 적용한다(저장형식 'S1:솔트:해시').
|
||||||
|
String pass = EgovFileScrty.encryptPasswordWithSalt(entrprsManageVO.getEntrprsMberPassword());
|
||||||
entrprsManageVO.setEntrprsMberPassword(pass);
|
entrprsManageVO.setEntrprsMberPassword(pass);
|
||||||
|
|
||||||
String result = entrprsManageDAO.insertEntrprsmber(entrprsManageVO);
|
String result = entrprsManageDAO.insertEntrprsmber(entrprsManageVO);
|
||||||
@ -101,7 +102,8 @@ public class EgovEntrprsManageServiceImpl extends AbstractServiceImpl implements
|
|||||||
*/
|
*/
|
||||||
public void updateEntrprsmber(EntrprsManageVO entrprsManageVO) throws Exception {
|
public void updateEntrprsmber(EntrprsManageVO entrprsManageVO) throws Exception {
|
||||||
//패스워드 암호화
|
//패스워드 암호화
|
||||||
String pass = EgovFileScrty.encryptPassword(entrprsManageVO.getEntrprsMberPassword());
|
//[개인정보보호 #27 후속] 새로 저장되는 비밀번호이므로 솔트를 적용한다.
|
||||||
|
String pass = EgovFileScrty.encryptPasswordWithSalt(entrprsManageVO.getEntrprsMberPassword());
|
||||||
entrprsManageVO.setEntrprsMberPassword(pass);
|
entrprsManageVO.setEntrprsMberPassword(pass);
|
||||||
entrprsManageDAO.updateEntrprsmber(entrprsManageVO);
|
entrprsManageDAO.updateEntrprsmber(entrprsManageVO);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -78,7 +78,8 @@ public class EgovMberManageServiceImpl extends AbstractServiceImpl implements Eg
|
|||||||
String uniqId = idgenService.getNextStringId();
|
String uniqId = idgenService.getNextStringId();
|
||||||
mberManageVO.setUniqId(uniqId);
|
mberManageVO.setUniqId(uniqId);
|
||||||
//패스워드 암호화
|
//패스워드 암호화
|
||||||
String pass = EgovFileScrty.encryptPassword(mberManageVO.getPassword());
|
//[개인정보보호 #27 후속] 신규 계정이므로 사용자별 랜덤 솔트를 적용한다(저장형식 'S1:솔트:해시').
|
||||||
|
String pass = EgovFileScrty.encryptPasswordWithSalt(mberManageVO.getPassword());
|
||||||
mberManageVO.setPassword(pass);
|
mberManageVO.setPassword(pass);
|
||||||
String result = mberManageDAO.insertMber(mberManageVO);
|
String result = mberManageDAO.insertMber(mberManageVO);
|
||||||
return result;
|
return result;
|
||||||
@ -134,7 +135,8 @@ public class EgovMberManageServiceImpl extends AbstractServiceImpl implements Eg
|
|||||||
*/
|
*/
|
||||||
public void updateMber(MberManageVO mberManageVO) throws Exception {
|
public void updateMber(MberManageVO mberManageVO) throws Exception {
|
||||||
//패스워드 암호화
|
//패스워드 암호화
|
||||||
String pass = EgovFileScrty.encryptPassword(mberManageVO.getPassword());
|
//[개인정보보호 #27 후속] 새로 저장되는 비밀번호이므로 솔트를 적용한다.
|
||||||
|
String pass = EgovFileScrty.encryptPasswordWithSalt(mberManageVO.getPassword());
|
||||||
mberManageVO.setPassword(pass);
|
mberManageVO.setPassword(pass);
|
||||||
mberManageDAO.updateMber(mberManageVO);
|
mberManageDAO.updateMber(mberManageVO);
|
||||||
}
|
}
|
||||||
@ -237,7 +239,10 @@ public class EgovMberManageServiceImpl extends AbstractServiceImpl implements Eg
|
|||||||
}
|
}
|
||||||
|
|
||||||
public String searchMber(MberManageVO mberManageVO) throws Exception {
|
public String searchMber(MberManageVO mberManageVO) throws Exception {
|
||||||
String pass = EgovFileScrty.encryptPassword(mberManageVO.getPassword());
|
//[개인정보보호 #27 후속] 아이디+비밀번호를 WHERE 절에서 비교하는 경로.
|
||||||
|
//저장값을 먼저 읽어 같은 솔트로 해시한다(무솔트 계정이면 종전 동작 그대로).
|
||||||
|
String stored = mberManageDAO.selectStoredPasswordByMberId(mberManageVO);
|
||||||
|
String pass = EgovFileScrty.encryptPasswordAs(mberManageVO.getPassword(), stored);
|
||||||
mberManageVO.setPassword(pass);
|
mberManageVO.setPassword(pass);
|
||||||
return mberManageDAO.searchMber(mberManageVO);
|
return mberManageDAO.searchMber(mberManageVO);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -90,7 +90,8 @@ public class EgovUserManageServiceImpl extends AbstractServiceImpl implements Eg
|
|||||||
String uniqId = idgenService.getNextStringId();
|
String uniqId = idgenService.getNextStringId();
|
||||||
userManageVO.setUniqId(uniqId);
|
userManageVO.setUniqId(uniqId);
|
||||||
//패스워드 암호화
|
//패스워드 암호화
|
||||||
String pass = EgovFileScrty.encryptPassword(userManageVO.getPassword());
|
//[개인정보보호 #27 후속] 신규 계정이므로 사용자별 랜덤 솔트를 적용한다(저장형식 'S1:솔트:해시').
|
||||||
|
String pass = EgovFileScrty.encryptPasswordWithSalt(userManageVO.getPassword());
|
||||||
userManageVO.setPassword(pass);
|
userManageVO.setPassword(pass);
|
||||||
String result = userManageDAO.insertUser(userManageVO);
|
String result = userManageDAO.insertUser(userManageVO);
|
||||||
return result;
|
return result;
|
||||||
@ -135,7 +136,8 @@ public class EgovUserManageServiceImpl extends AbstractServiceImpl implements Eg
|
|||||||
*/
|
*/
|
||||||
public void updateUser(UserManageVO userManageVO) throws Exception {
|
public void updateUser(UserManageVO userManageVO) throws Exception {
|
||||||
//패스워드 암호화
|
//패스워드 암호화
|
||||||
String pass = EgovFileScrty.encryptPassword(userManageVO.getPassword());
|
//[개인정보보호 #27 후속] 새로 저장되는 비밀번호이므로 솔트를 적용한다.
|
||||||
|
String pass = EgovFileScrty.encryptPasswordWithSalt(userManageVO.getPassword());
|
||||||
userManageVO.setPassword(pass);
|
userManageVO.setPassword(pass);
|
||||||
|
|
||||||
userManageDAO.updateUser(userManageVO);
|
userManageDAO.updateUser(userManageVO);
|
||||||
|
|||||||
@ -175,6 +175,17 @@ public class MberManageDAO extends EgovComAbstractDAO{
|
|||||||
return (String)getSqlMapClientTemplate().queryForObject("mberManageDAO.searchMber_S", mberManageVO);
|
return (String)getSqlMapClientTemplate().queryForObject("mberManageDAO.searchMber_S", mberManageVO);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [개인정보보호 #27 후속] 회원아이디로 저장된 비밀번호 값을 조회한다.
|
||||||
|
*
|
||||||
|
* <p>searchMber_S 는 비밀번호를 WHERE 절에서 비교하므로, 솔트 적용 계정을 판별하려면
|
||||||
|
* 평문을 해시하기 전에 저장값(솔트 포함)을 먼저 읽어야 한다. selectPassword_S 는
|
||||||
|
* ESNTL_ID 기준이라 아이디만 아는 이 경로에서는 쓸 수 없어 별도 statement 를 둔다.</p>
|
||||||
|
*/
|
||||||
|
public String selectStoredPasswordByMberId(MberManageVO mberManageVO){
|
||||||
|
return (String)getSqlMapClientTemplate().queryForObject("mberManageDAO.selectStoredPasswordByMberId_S", mberManageVO);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public int selectMberTalentCnt(String mberId) {
|
public int selectMberTalentCnt(String mberId) {
|
||||||
return (Integer)getSqlMapClientTemplate().queryForObject("mberManageDAO.selectMberTalentCnt", mberId);
|
return (Integer)getSqlMapClientTemplate().queryForObject("mberManageDAO.selectMberTalentCnt", mberId);
|
||||||
|
|||||||
@ -852,7 +852,8 @@ public class EgovEntrprsManageController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isCorrectPassword){
|
if (isCorrectPassword){
|
||||||
entrprsManageVO.setEntrprsMberPassword(EgovFileScrty.encryptPassword(newPassword));
|
//[개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 솔트를 적용한다.
|
||||||
|
entrprsManageVO.setEntrprsMberPassword(EgovFileScrty.encryptPasswordWithSalt(newPassword));
|
||||||
entrprsManageService.updatePassword(entrprsManageVO);
|
entrprsManageService.updatePassword(entrprsManageVO);
|
||||||
model.addAttribute("entrprsManageVO", entrprsManageVO);
|
model.addAttribute("entrprsManageVO", entrprsManageVO);
|
||||||
resultMsg = "success.common.update";
|
resultMsg = "success.common.update";
|
||||||
|
|||||||
@ -928,10 +928,11 @@ public class EgovMberManageController {
|
|||||||
String resultMsg = "";
|
String resultMsg = "";
|
||||||
resultVO = mberManageService.selectPassword(mberManageVO);
|
resultVO = mberManageService.selectPassword(mberManageVO);
|
||||||
//패스워드 암호화
|
//패스워드 암호화
|
||||||
//String encryptPass = EgovFileScrty.encryptPassword(oldPassword);
|
// [개인정보/SER-001] 2026-08-05 현재 비밀번호 검증 복구.
|
||||||
String encryptPass = resultVO.getPassword();
|
// 종전: encryptPass 에 DB 값을 그대로 대입해 자기 자신과 비교 → 항상 true.
|
||||||
|
// 입력한 oldPassword 가 어디에도 쓰이지 않아, 현재 비밀번호를 몰라도 변경이 성립했다.
|
||||||
if (encryptPass.equals(resultVO.getPassword())){
|
// [개인정보보호 #27 후속] 2026-08-06 솔트 적용 계정(S1: 형식) 대응 — matches 로 일원화.
|
||||||
|
if (EgovFileScrty.matches(oldPassword, resultVO.getPassword())){
|
||||||
if (newPassword.equals(newPassword2)){
|
if (newPassword.equals(newPassword2)){
|
||||||
isCorrectPassword = true;
|
isCorrectPassword = true;
|
||||||
}else{
|
}else{
|
||||||
@ -944,7 +945,8 @@ public class EgovMberManageController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isCorrectPassword){
|
if (isCorrectPassword){
|
||||||
mberManageVO.setPassword(EgovFileScrty.encryptPassword(newPassword));
|
//[개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 솔트를 적용한다.
|
||||||
|
mberManageVO.setPassword(EgovFileScrty.encryptPasswordWithSalt(newPassword));
|
||||||
mberManageService.updatePassword(mberManageVO);
|
mberManageService.updatePassword(mberManageVO);
|
||||||
model.addAttribute("mberManageVO", mberManageVO);
|
model.addAttribute("mberManageVO", mberManageVO);
|
||||||
resultMsg = "success.common.update";
|
resultMsg = "success.common.update";
|
||||||
|
|||||||
@ -609,10 +609,12 @@ public class EgovUserManageController {
|
|||||||
resultVO = userManageService.selectPassword(userManageVO);
|
resultVO = userManageService.selectPassword(userManageVO);
|
||||||
//패스워드 암호화
|
//패스워드 암호화
|
||||||
|
|
||||||
//String encryptPass = EgovFileScrty.encryptPassword(oldPassword);
|
// [개인정보/SER-001] 2026-08-05 현재 비밀번호 검증 복구.
|
||||||
String encryptPass = resultVO.getPassword();
|
// 종전: encryptPass 에 DB 값을 그대로 대입해 자기 자신과 비교 → 항상 true.
|
||||||
|
// 입력한 oldPassword 가 어디에도 쓰이지 않아, 현재 비밀번호를 몰라도 변경이 성립했다
|
||||||
if (encryptPass.equals(resultVO.getPassword())){
|
// (fail.user.passwordUpdate1 분기는 도달 불가능한 죽은 코드였음).
|
||||||
|
// [개인정보보호 #27 후속] 2026-08-06 솔트 적용 계정(S1: 형식) 대응 — matches 로 일원화.
|
||||||
|
if (EgovFileScrty.matches(oldPassword, resultVO.getPassword())){
|
||||||
if (newPassword.equals(newPassword2)){
|
if (newPassword.equals(newPassword2)){
|
||||||
isCorrectPassword = true;
|
isCorrectPassword = true;
|
||||||
}else{
|
}else{
|
||||||
@ -625,7 +627,8 @@ public class EgovUserManageController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isCorrectPassword){
|
if (isCorrectPassword){
|
||||||
userManageVO.setPassword(EgovFileScrty.encryptPassword(newPassword));
|
//[개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 솔트를 적용한다.
|
||||||
|
userManageVO.setPassword(EgovFileScrty.encryptPasswordWithSalt(newPassword));
|
||||||
userManageService.updatePassword(userManageVO);
|
userManageService.updatePassword(userManageVO);
|
||||||
model.addAttribute("userManageVO", userManageVO);
|
model.addAttribute("userManageVO", userManageVO);
|
||||||
resultMsg = "success.common.update";
|
resultMsg = "success.common.update";
|
||||||
@ -693,10 +696,12 @@ public class EgovUserManageController {
|
|||||||
resultVO = userManageService.selectPassword(userManageVO);
|
resultVO = userManageService.selectPassword(userManageVO);
|
||||||
//패스워드 암호화
|
//패스워드 암호화
|
||||||
|
|
||||||
//String encryptPass = EgovFileScrty.encryptPassword(oldPassword);
|
// [개인정보/SER-001] 2026-08-05 현재 비밀번호 검증 복구.
|
||||||
String encryptPass = resultVO.getPassword();
|
// 종전: encryptPass 에 DB 값을 그대로 대입해 자기 자신과 비교 → 항상 true.
|
||||||
|
// 입력한 oldPassword 가 어디에도 쓰이지 않아, 현재 비밀번호를 몰라도 변경이 성립했다
|
||||||
if (encryptPass.equals(resultVO.getPassword())){
|
// (fail.user.passwordUpdate1 분기는 도달 불가능한 죽은 코드였음).
|
||||||
|
// [개인정보보호 #27 후속] 2026-08-06 솔트 적용 계정(S1: 형식) 대응 — matches 로 일원화.
|
||||||
|
if (EgovFileScrty.matches(oldPassword, resultVO.getPassword())){
|
||||||
if (newPassword.equals(newPassword2)){
|
if (newPassword.equals(newPassword2)){
|
||||||
isCorrectPassword = true;
|
isCorrectPassword = true;
|
||||||
}else{
|
}else{
|
||||||
@ -709,7 +714,8 @@ public class EgovUserManageController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isCorrectPassword){
|
if (isCorrectPassword){
|
||||||
userManageVO.setPassword(EgovFileScrty.encryptPassword(newPassword));
|
//[개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 솔트를 적용한다.
|
||||||
|
userManageVO.setPassword(EgovFileScrty.encryptPasswordWithSalt(newPassword));
|
||||||
userManageService.updatePassword(userManageVO);
|
userManageService.updatePassword(userManageVO);
|
||||||
model.addAttribute("userManageVO", userManageVO);
|
model.addAttribute("userManageVO", userManageVO);
|
||||||
resultMsg = "success.common.update";
|
resultMsg = "success.common.update";
|
||||||
|
|||||||
@ -280,6 +280,135 @@ public class EgovFileScrty {
|
|||||||
return MessageDigest.isEqual(hashValue, Base64.decodeBase64(encoded.getBytes()));
|
return MessageDigest.isEqual(hashValue, Base64.decodeBase64(encoded.getBytes()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// [개인정보보호 #27 후속] 사용자별 랜덤 솔트 적용 (SER-003 인증정보 보호)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// 점검노트(개인정보보호_비밀번호해시_점검노트.md) 권고 1의 이행분.
|
||||||
|
// 알고리즘은 종전과 동일한 SHA-256이며, 신규로 설정되는 비밀번호에만
|
||||||
|
// 사용자별 랜덤 솔트를 부여한다. 기존 비밀번호(무솔트)는 손대지 않는다.
|
||||||
|
//
|
||||||
|
// [저장형식] 솔트 저장용 컬럼을 새로 만들지 않고, 비밀번호 컬럼 안에
|
||||||
|
// 자기기술(self-describing) 형식으로 함께 담는다.
|
||||||
|
//
|
||||||
|
// S1:<Base64 솔트 24자>:<Base64 해시 44자> → 총 71자
|
||||||
|
// (기존 무솔트 값은 접두어 없는 Base64 해시 44자)
|
||||||
|
//
|
||||||
|
// · Base64 문자집합(A-Za-z0-9+/=)에 ':' 가 없어 구분자로 안전하다.
|
||||||
|
// · 대상 4개 컬럼(COMTNGNRLMBER.PASSWORD / COMTNEMPLYRINFO.PASSWORD /
|
||||||
|
// COMTNENTRPRSMBER.ENTRPRS_MBER_PASSWORD / BIZ_GROUP.GROUP_PASSWORD)은
|
||||||
|
// VARCHAR(200) 이므로 자릿수 여유가 충분하다(DDL 로 사전 확인).
|
||||||
|
// · 솔트 컬럼을 따로 두지 않으므로 회원 테이블 4종의 INSERT/UPDATE
|
||||||
|
// sqlmap(Mysql/Oracle 8본)과 VO 를 건드리지 않는다 → 변경 파급 최소화.
|
||||||
|
//
|
||||||
|
// [마이그레이션] 강제 일괄전환 없음. 아래 규칙으로 자연 전환된다.
|
||||||
|
// · 신규 가입 → 솔트 적용(S1 형식)
|
||||||
|
// · 기존 계정 로그인 → 저장값 그대로 무솔트 검증(변화 없음)
|
||||||
|
// · 기존 계정 비번변경 → 그 시점부터 솔트 적용
|
||||||
|
//
|
||||||
|
// [주의] 게시물·댓글·만족도 비밀번호는 계정 인증정보가 아니므로 이 경로를
|
||||||
|
// 적용하지 않는다(종전 encryptPassword(String) 유지).
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
/** 솔트 적용 비밀번호의 저장형식 접두어. 이 값으로 시작하면 솔트 방식이다. */
|
||||||
|
private static final String SALT_PREFIX = "S1:";
|
||||||
|
|
||||||
|
/** 솔트 길이(바이트). Base64 인코딩 시 24자가 된다. */
|
||||||
|
private static final int SALT_BYTE_LENGTH = 16;
|
||||||
|
|
||||||
|
/** 솔트 생성용 난수원. SecureRandom 은 thread-safe 하므로 공유한다. */
|
||||||
|
private static final java.security.SecureRandom SALT_RANDOM = new java.security.SecureRandom();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자별 랜덤 솔트를 생성한다(Base64 24자).
|
||||||
|
*
|
||||||
|
* @return Base64 인코딩된 솔트
|
||||||
|
*/
|
||||||
|
public static String createSalt() {
|
||||||
|
byte[] salt = new byte[SALT_BYTE_LENGTH];
|
||||||
|
SALT_RANDOM.nextBytes(salt);
|
||||||
|
return new String(Base64.encodeBase64(salt));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신규로 설정되는 비밀번호를 솔트 적용하여 암호화한다(가입·비밀번호 변경 경로 전용).
|
||||||
|
*
|
||||||
|
* @param data 평문 비밀번호
|
||||||
|
* @return "S1:솔트:해시" 형식의 저장값
|
||||||
|
* @exception Exception
|
||||||
|
*/
|
||||||
|
public static String encryptPasswordWithSalt(String data) throws Exception {
|
||||||
|
if (data == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String salt = createSalt();
|
||||||
|
return SALT_PREFIX + salt + ":" + encryptPassword(data, salt.getBytes("UTF-8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장값과 같은 방식으로 평문을 암호화한다.
|
||||||
|
*
|
||||||
|
* <p>저장값이 솔트 형식(S1:)이면 그 솔트를 재사용해 같은 형식의 문자열을,
|
||||||
|
* 아니면(기존 무솔트 계정) 종전 방식의 해시를 돌려준다. 결과를 저장값과
|
||||||
|
* 문자열 비교하면 검증이 되므로, <b>비밀번호를 WHERE 절에서 비교하는
|
||||||
|
* 기존 로그인 SQL을 수정하지 않고</b> 솔트를 도입할 수 있다.</p>
|
||||||
|
*
|
||||||
|
* @param data 평문 비밀번호
|
||||||
|
* @param stored DB에 저장된 비밀번호 값(없으면 null 허용)
|
||||||
|
* @return stored 와 같은 형식으로 암호화된 문자열
|
||||||
|
* @exception Exception
|
||||||
|
*/
|
||||||
|
public static String encryptPasswordAs(String data, String stored) throws Exception {
|
||||||
|
String salt = extractSalt(stored);
|
||||||
|
if (salt == null) {
|
||||||
|
// 기존(무솔트) 계정 또는 존재하지 않는 계정 → 종전 동작 그대로
|
||||||
|
return encryptPassword(data);
|
||||||
|
}
|
||||||
|
return SALT_PREFIX + salt + ":" + encryptPassword(data, salt.getBytes("UTF-8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 평문 비밀번호가 저장값과 일치하는지 검증한다(솔트·무솔트 양쪽 지원).
|
||||||
|
*
|
||||||
|
* <p>이미 저장값을 손에 쥐고 있는 경로(마이페이지 비밀번호 확인, 비밀번호
|
||||||
|
* 변경 시 현재 비밀번호 확인 등)에서는 이 메서드를 쓴다. 종전의
|
||||||
|
* {@code encryptPassword(raw).equals(stored)} 를 그대로 대체한다.</p>
|
||||||
|
*
|
||||||
|
* @param data 평문 비밀번호
|
||||||
|
* @param stored DB에 저장된 비밀번호 값
|
||||||
|
* @return 일치 여부
|
||||||
|
* @exception Exception
|
||||||
|
*/
|
||||||
|
public static boolean matches(String data, String stored) throws Exception {
|
||||||
|
if (data == null || stored == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String salt = extractSalt(stored);
|
||||||
|
if (salt == null) {
|
||||||
|
// 무솔트 저장값: 종전 방식으로 해시 후 상수시간 비교
|
||||||
|
return MessageDigest.isEqual(encryptPassword(data).getBytes("UTF-8"), stored.getBytes("UTF-8"));
|
||||||
|
}
|
||||||
|
String encoded = stored.substring(SALT_PREFIX.length() + salt.length() + 1);
|
||||||
|
return checkPassword(data, encoded, salt.getBytes("UTF-8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장값이 솔트 형식이면 솔트를 꺼내고, 아니면 null 을 돌려준다.
|
||||||
|
*
|
||||||
|
* @param stored DB에 저장된 비밀번호 값
|
||||||
|
* @return Base64 솔트 또는 null(무솔트·형식오류)
|
||||||
|
*/
|
||||||
|
private static String extractSalt(String stored) {
|
||||||
|
if (stored == null || !stored.startsWith(SALT_PREFIX)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int end = stored.indexOf(':', SALT_PREFIX.length());
|
||||||
|
if (end < 0 || end == SALT_PREFIX.length()) {
|
||||||
|
// 접두어만 있고 형식이 깨진 값은 무솔트로 취급(검증 실패로 귀결)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return stored.substring(SALT_PREFIX.length(), end);
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -42,7 +42,8 @@ public class BizMberMergeServiceImpl extends AbstractServiceImpl implements BizM
|
|||||||
|
|
||||||
public void updateLibMber(MberManageVO mberManageVO) throws Exception {
|
public void updateLibMber(MberManageVO mberManageVO) throws Exception {
|
||||||
//패스워드 암호화
|
//패스워드 암호화
|
||||||
String pass = EgovFileScrty.encryptPassword(mberManageVO.getPassword());
|
//[개인정보보호 #27 후속] 새로 저장되는 비밀번호이므로 솔트를 적용한다.
|
||||||
|
String pass = EgovFileScrty.encryptPasswordWithSalt(mberManageVO.getPassword());
|
||||||
mberManageVO.setPassword(pass);
|
mberManageVO.setPassword(pass);
|
||||||
bizMberMergeDAO.updateLibMber(mberManageVO);
|
bizMberMergeDAO.updateLibMber(mberManageVO);
|
||||||
}
|
}
|
||||||
@ -53,7 +54,8 @@ public class BizMberMergeServiceImpl extends AbstractServiceImpl implements BizM
|
|||||||
|
|
||||||
public void updateLibActivity(EntrprsManageVO entrprsManageVO) throws Exception {
|
public void updateLibActivity(EntrprsManageVO entrprsManageVO) throws Exception {
|
||||||
//패스워드 암호화
|
//패스워드 암호화
|
||||||
String pass = EgovFileScrty.encryptPassword(entrprsManageVO.getEntrprsMberPassword());
|
//[개인정보보호 #27 후속] 새로 저장되는 비밀번호이므로 솔트를 적용한다.
|
||||||
|
String pass = EgovFileScrty.encryptPasswordWithSalt(entrprsManageVO.getEntrprsMberPassword());
|
||||||
entrprsManageVO.setEntrprsMberPassword(pass);
|
entrprsManageVO.setEntrprsMberPassword(pass);
|
||||||
bizMberMergeDAO.updateLibActivity(entrprsManageVO);
|
bizMberMergeDAO.updateLibActivity(entrprsManageVO);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -647,10 +647,8 @@ public class BizEntrprsFrtManageController {
|
|||||||
String resultMsg = "";
|
String resultMsg = "";
|
||||||
|
|
||||||
resultVO = entrprsManageService.selectPassword(entrprsManageVO);
|
resultVO = entrprsManageService.selectPassword(entrprsManageVO);
|
||||||
//패스워드 암호화
|
//패스워드 확인 — [개인정보보호 #27 후속] 솔트·무솔트 저장값 모두 matches 로 검증
|
||||||
String encryptPass = EgovFileScrty.encryptPassword(entrprsManageVO.getOldPassword());
|
if (EgovFileScrty.matches(entrprsManageVO.getOldPassword(), resultVO.getEntrprsMberPassword())){
|
||||||
|
|
||||||
if (encryptPass.equals(resultVO.getEntrprsMberPassword())){
|
|
||||||
if (entrprsManageVO.getNewPassword().equals(entrprsManageVO.getNewPassword2())){
|
if (entrprsManageVO.getNewPassword().equals(entrprsManageVO.getNewPassword2())){
|
||||||
isCorrectPassword = true;
|
isCorrectPassword = true;
|
||||||
}else{
|
}else{
|
||||||
@ -663,7 +661,8 @@ public class BizEntrprsFrtManageController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isCorrectPassword){
|
if (isCorrectPassword){
|
||||||
entrprsManageVO.setEntrprsMberPassword(EgovFileScrty.encryptPassword(entrprsManageVO.getNewPassword()));
|
//[개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 솔트를 적용한다.
|
||||||
|
entrprsManageVO.setEntrprsMberPassword(EgovFileScrty.encryptPasswordWithSalt(entrprsManageVO.getNewPassword()));
|
||||||
entrprsManageService.updatePassword(entrprsManageVO);
|
entrprsManageService.updatePassword(entrprsManageVO);
|
||||||
model.addAttribute("entrprsManageVO", entrprsManageVO);
|
model.addAttribute("entrprsManageVO", entrprsManageVO);
|
||||||
resultMsg = "success.common.update";
|
resultMsg = "success.common.update";
|
||||||
|
|||||||
@ -533,10 +533,8 @@ public class BizGroupFrtManageController {
|
|||||||
|
|
||||||
String resultMsg = "";
|
String resultMsg = "";
|
||||||
resultVO = groupManageService.selectPassword(groupManageVO);
|
resultVO = groupManageService.selectPassword(groupManageVO);
|
||||||
//패스워드 암호화
|
//패스워드 확인 — [개인정보보호 #27 후속] 솔트·무솔트 저장값 모두 matches 로 검증
|
||||||
String encryptPass = EgovFileScrty.encryptPassword(groupManageVO.getGroupPassword());
|
if (EgovFileScrty.matches(groupManageVO.getGroupPassword(), resultVO.getGroupPassword())){
|
||||||
|
|
||||||
if (encryptPass.equals(resultVO.getGroupPassword())){
|
|
||||||
groupManageService.deleteGroupMberApp(loginVO.getUniqId());
|
groupManageService.deleteGroupMberApp(loginVO.getUniqId());
|
||||||
model.addAttribute("resultMsg", "success.request.msg");
|
model.addAttribute("resultMsg", "success.request.msg");
|
||||||
}else{
|
}else{
|
||||||
@ -578,10 +576,8 @@ public class BizGroupFrtManageController {
|
|||||||
String resultMsg = "";
|
String resultMsg = "";
|
||||||
resultVO = groupManageService.selectPassword(groupManageVO);
|
resultVO = groupManageService.selectPassword(groupManageVO);
|
||||||
|
|
||||||
//패스워드 암호화
|
//패스워드 확인 — [개인정보보호 #27 후속] 솔트·무솔트 저장값 모두 matches 로 검증
|
||||||
String encryptPass = EgovFileScrty.encryptPassword(oldPassword);
|
if (EgovFileScrty.matches(oldPassword, resultVO.getGroupPassword())){
|
||||||
|
|
||||||
if (encryptPass.equals(resultVO.getGroupPassword())){
|
|
||||||
if (newPassword.equals(newPassword2)){
|
if (newPassword.equals(newPassword2)){
|
||||||
isCorrectPassword = true;
|
isCorrectPassword = true;
|
||||||
}else{
|
}else{
|
||||||
@ -594,7 +590,8 @@ public class BizGroupFrtManageController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isCorrectPassword){
|
if (isCorrectPassword){
|
||||||
groupManageVO.setGroupPassword(EgovFileScrty.encryptPassword(newPassword));
|
//[개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 솔트를 적용한다.
|
||||||
|
groupManageVO.setGroupPassword(EgovFileScrty.encryptPasswordWithSalt(newPassword));
|
||||||
groupManageService.updatePassword(groupManageVO);
|
groupManageService.updatePassword(groupManageVO);
|
||||||
model.addAttribute("groupManageVO", groupManageVO);
|
model.addAttribute("groupManageVO", groupManageVO);
|
||||||
resultMsg = "success.common.update";
|
resultMsg = "success.common.update";
|
||||||
|
|||||||
@ -513,14 +513,14 @@ public class BizMberFrtManageController {
|
|||||||
String resultMsg = "";
|
String resultMsg = "";
|
||||||
String returnUrl = "";
|
String returnUrl = "";
|
||||||
System.out.println("타입체크::"+user.getUserSe());
|
System.out.println("타입체크::"+user.getUserSe());
|
||||||
System.out.println("1패스워드확인::"+mberManageVO.getPassword());
|
// 2026-08-05 [개인정보/SER-003] 평문 비밀번호 로그 출력 제거(종전 println 으로 평문 노출)
|
||||||
String encryptPass = "";
|
System.out.println("1패스워드확인::요청됨");
|
||||||
|
|
||||||
if(user.getUserSe().equals("ENT"))
|
if(user.getUserSe().equals("ENT"))
|
||||||
{
|
{
|
||||||
entVO = entrManageService.selectPassword(entVO);
|
entVO = entrManageService.selectPassword(entVO);
|
||||||
encryptPass = EgovFileScrty.encryptPassword(mberManageVO.getPassword());
|
// [개인정보보호 #27 후속] 솔트 적용 계정(S1: 형식)·기존 무솔트 계정 모두 matches 로 검증한다.
|
||||||
if (encryptPass.equals(entVO.getEntrprsMberPassword())){
|
if (EgovFileScrty.matches(mberManageVO.getPassword(), entVO.getEntrprsMberPassword())){
|
||||||
returnUrl = "redirect:/myp/ent/bizMberSelectUpdtView.do";
|
returnUrl = "redirect:/myp/ent/bizMberSelectUpdtView.do";
|
||||||
}else{
|
}else{
|
||||||
resultMsg="fail.user.passwordUpdate1";
|
resultMsg="fail.user.passwordUpdate1";
|
||||||
@ -531,11 +531,9 @@ public class BizMberFrtManageController {
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
resultVO = mberManageService.selectPassword(mberManageVO);
|
resultVO = mberManageService.selectPassword(mberManageVO);
|
||||||
encryptPass = EgovFileScrty.encryptPassword(mberManageVO.getPassword());
|
// [개인정보보호 #27 후속] 솔트 적용 계정(S1: 형식)·기존 무솔트 계정 모두 matches 로 검증한다.
|
||||||
|
// 2026-08-06 [개인정보/SER-003] 비밀번호 해시값 log.debug 출력 제거(인증정보 노출).
|
||||||
log.debug("encryptPass =>"+encryptPass);
|
if (EgovFileScrty.matches(mberManageVO.getPassword(), resultVO.getPassword()))
|
||||||
|
|
||||||
if (encryptPass.equals(resultVO.getPassword()))
|
|
||||||
{
|
{
|
||||||
returnUrl = "redirect:/myp/umt/bizMberSelectUpdtView.do";
|
returnUrl = "redirect:/myp/umt/bizMberSelectUpdtView.do";
|
||||||
}
|
}
|
||||||
@ -548,10 +546,8 @@ public class BizMberFrtManageController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
System.out.println("비밀번호 체크후이동"+returnUrl);
|
System.out.println("비밀번호 체크후이동"+returnUrl);
|
||||||
//패스워드 암호화
|
// 2026-08-06 [개인정보/SER-003] 비밀번호 해시값 표준출력 제거(인증정보 노출, 점검노트 권고 3).
|
||||||
|
// 종전: System.out.println("3패스워드확인::" + EgovFileScrty.encryptPassword(...));
|
||||||
System.out.println("3패스워드확인::"+EgovFileScrty.encryptPassword(mberManageVO.getPassword()));
|
|
||||||
|
|
||||||
|
|
||||||
return returnUrl;
|
return returnUrl;
|
||||||
}
|
}
|
||||||
@ -835,9 +831,8 @@ public class BizMberFrtManageController {
|
|||||||
|
|
||||||
String resultMsg = "";
|
String resultMsg = "";
|
||||||
resultVO = mberManageService.selectPassword(mberManageVO);
|
resultVO = mberManageService.selectPassword(mberManageVO);
|
||||||
//패스워드 암호화
|
//패스워드 확인 — [개인정보보호 #27 후속] 솔트·무솔트 저장값 모두 matches 로 검증
|
||||||
String encryptPass = EgovFileScrty.encryptPassword(oldPassword);
|
if (EgovFileScrty.matches(oldPassword, resultVO.getPassword())){
|
||||||
if (encryptPass.equals(resultVO.getPassword())){
|
|
||||||
if (newPassword.equals(newPassword2)){
|
if (newPassword.equals(newPassword2)){
|
||||||
isCorrectPassword = true;
|
isCorrectPassword = true;
|
||||||
}else{
|
}else{
|
||||||
@ -850,7 +845,8 @@ public class BizMberFrtManageController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isCorrectPassword){
|
if (isCorrectPassword){
|
||||||
mberManageVO.setPassword(EgovFileScrty.encryptPassword(newPassword));
|
//[개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 솔트를 적용한다.
|
||||||
|
mberManageVO.setPassword(EgovFileScrty.encryptPasswordWithSalt(newPassword));
|
||||||
mberManageService.updatePassword(mberManageVO);
|
mberManageService.updatePassword(mberManageVO);
|
||||||
model.addAttribute("userManageVO", mberManageVO);
|
model.addAttribute("userManageVO", mberManageVO);
|
||||||
resultMsg = "success.common.update";
|
resultMsg = "success.common.update";
|
||||||
@ -956,9 +952,8 @@ public class BizMberFrtManageController {
|
|||||||
String resultMsg = "";
|
String resultMsg = "";
|
||||||
String returnUrl = "";
|
String returnUrl = "";
|
||||||
resultVO = mberManageService.selectPassword(mberManageVO);
|
resultVO = mberManageService.selectPassword(mberManageVO);
|
||||||
//패스워드 암호화
|
//패스워드 확인 — [개인정보보호 #27 후속] 솔트·무솔트 저장값 모두 matches 로 검증
|
||||||
String encryptPass = EgovFileScrty.encryptPassword(mberManageVO.getPassword());
|
if (EgovFileScrty.matches(mberManageVO.getPassword(), resultVO.getPassword())){
|
||||||
if (encryptPass.equals(resultVO.getPassword())){
|
|
||||||
String userId = "USR01:"+user.getUniqId();
|
String userId = "USR01:"+user.getUniqId();
|
||||||
// 멤버 정보 삭제 (마일리지, 활동정보, 재능정보, 권한정보)
|
// 멤버 정보 삭제 (마일리지, 활동정보, 재능정보, 권한정보)
|
||||||
mberManageService.deleteMber(userId);
|
mberManageService.deleteMber(userId);
|
||||||
|
|||||||
@ -136,11 +136,11 @@ public class MblMypageController {
|
|||||||
String resultMsg = "";
|
String resultMsg = "";
|
||||||
String returnUrl = "";
|
String returnUrl = "";
|
||||||
System.out.println("타입체크::"+user.getUserSe());
|
System.out.println("타입체크::"+user.getUserSe());
|
||||||
System.out.println("1패스워드확인::"+mberManageVO.getPassword());
|
// 2026-08-05 [개인정보/SER-003] 평문 비밀번호 로그 출력 제거(종전 println 으로 평문 노출)
|
||||||
String encryptPass = "";
|
System.out.println("1패스워드확인::요청됨");
|
||||||
entVO = entrManageService.selectPassword(entVO);
|
entVO = entrManageService.selectPassword(entVO);
|
||||||
encryptPass = EgovFileScrty.encryptPassword(mberManageVO.getPassword());
|
// [개인정보보호 #27 후속] 솔트 적용 계정(S1: 형식)·기존 무솔트 계정 모두 matches 로 검증한다.
|
||||||
if (encryptPass.equals(entVO.getEntrprsMberPassword())){
|
if (EgovFileScrty.matches(mberManageVO.getPassword(), entVO.getEntrprsMberPassword())){
|
||||||
returnUrl = "redirect:/myp/ent/bizMberSelectUpdtView.do";
|
returnUrl = "redirect:/myp/ent/bizMberSelectUpdtView.do";
|
||||||
}else{
|
}else{
|
||||||
resultMsg="fail.user.passwordUpdate1";
|
resultMsg="fail.user.passwordUpdate1";
|
||||||
|
|||||||
@ -68,7 +68,8 @@ public class BizGroupManageServiceImpl extends AbstractServiceImpl implements Bi
|
|||||||
String uniqId = idgenService.getNextStringId();
|
String uniqId = idgenService.getNextStringId();
|
||||||
groupManageVO.setUniqId(uniqId);
|
groupManageVO.setUniqId(uniqId);
|
||||||
//패스워드 암호화
|
//패스워드 암호화
|
||||||
String pass = EgovFileScrty.encryptPassword(groupManageVO.getGroupPassword());
|
//[개인정보보호 #27 후속] 신규 계정이므로 사용자별 랜덤 솔트를 적용한다(저장형식 'S1:솔트:해시').
|
||||||
|
String pass = EgovFileScrty.encryptPasswordWithSalt(groupManageVO.getGroupPassword());
|
||||||
groupManageVO.setGroupPassword(pass);
|
groupManageVO.setGroupPassword(pass);
|
||||||
|
|
||||||
return groupManageDAO.insertGroupMber(groupManageVO);
|
return groupManageDAO.insertGroupMber(groupManageVO);
|
||||||
|
|||||||
@ -701,10 +701,11 @@ public class BizGroupManageController {
|
|||||||
String resultMsg = "";
|
String resultMsg = "";
|
||||||
resultVO = groupManageService.selectPassword(groupManageVO);
|
resultVO = groupManageService.selectPassword(groupManageVO);
|
||||||
//패스워드 암호화
|
//패스워드 암호화
|
||||||
//String encryptPass = EgovFileScrty.encryptPassword(oldPassword);
|
// [개인정보/SER-001] 2026-08-05 현재 비밀번호 검증 복구.
|
||||||
String encryptPass = resultVO.getGroupPassword();
|
// 종전: encryptPass 에 DB 값을 그대로 대입해 자기 자신과 비교 → 항상 true.
|
||||||
|
// 입력한 oldPassword 가 어디에도 쓰이지 않아, 현재 비밀번호를 몰라도 변경이 성립했다.
|
||||||
if (encryptPass.equals(resultVO.getGroupPassword())){
|
// [개인정보보호 #27 후속] 2026-08-06 솔트 적용 계정(S1: 형식) 대응 — matches 로 일원화.
|
||||||
|
if (EgovFileScrty.matches(oldPassword, resultVO.getGroupPassword())){
|
||||||
if (newPassword.equals(newPassword2)){
|
if (newPassword.equals(newPassword2)){
|
||||||
isCorrectPassword = true;
|
isCorrectPassword = true;
|
||||||
}else{
|
}else{
|
||||||
@ -717,7 +718,8 @@ public class BizGroupManageController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isCorrectPassword){
|
if (isCorrectPassword){
|
||||||
groupManageVO.setGroupPassword(EgovFileScrty.encryptPassword(newPassword));
|
//[개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 솔트를 적용한다.
|
||||||
|
groupManageVO.setGroupPassword(EgovFileScrty.encryptPasswordWithSalt(newPassword));
|
||||||
groupManageService.updatePassword(groupManageVO);
|
groupManageService.updatePassword(groupManageVO);
|
||||||
model.addAttribute("groupManageVO", groupManageVO);
|
model.addAttribute("groupManageVO", groupManageVO);
|
||||||
resultMsg = "success.common.update";
|
resultMsg = "success.common.update";
|
||||||
|
|||||||
@ -267,10 +267,8 @@ public class BizEntrprsMntController {
|
|||||||
String resultMsg = "";
|
String resultMsg = "";
|
||||||
|
|
||||||
resultVO = entrprsManageService.selectPassword(entrprsManageVO);
|
resultVO = entrprsManageService.selectPassword(entrprsManageVO);
|
||||||
//패스워드 암호화
|
//패스워드 확인 — [개인정보보호 #27 후속] 솔트·무솔트 저장값 모두 matches 로 검증
|
||||||
String encryptPass = EgovFileScrty.encryptPassword(oldPassword);
|
if (EgovFileScrty.matches(oldPassword, resultVO.getEntrprsMberPassword())){
|
||||||
|
|
||||||
if (encryptPass.equals(resultVO.getEntrprsMberPassword())){
|
|
||||||
if (newPassword.equals(newPassword2)){
|
if (newPassword.equals(newPassword2)){
|
||||||
isCorrectPassword = true;
|
isCorrectPassword = true;
|
||||||
}else{
|
}else{
|
||||||
@ -283,7 +281,8 @@ public class BizEntrprsMntController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isCorrectPassword){
|
if (isCorrectPassword){
|
||||||
entrprsManageVO.setEntrprsMberPassword(EgovFileScrty.encryptPassword(newPassword));
|
//[개인정보보호 #27 후속] 새로 설정되는 비밀번호이므로 솔트를 적용한다.
|
||||||
|
entrprsManageVO.setEntrprsMberPassword(EgovFileScrty.encryptPasswordWithSalt(newPassword));
|
||||||
entrprsManageService.updatePassword(entrprsManageVO);
|
entrprsManageService.updatePassword(entrprsManageVO);
|
||||||
model.addAttribute("entrprsManageVO", entrprsManageVO);
|
model.addAttribute("entrprsManageVO", entrprsManageVO);
|
||||||
resultMsg = "success.common.update";
|
resultMsg = "success.common.update";
|
||||||
|
|||||||
@ -18,8 +18,10 @@
|
|||||||
<!-- 스케쥴러파일이 존재하는 패키지 설정 -->
|
<!-- 스케쥴러파일이 존재하는 패키지 설정 -->
|
||||||
<context:component-scan base-package="kr.or.arko.cmm.batch" />
|
<context:component-scan base-package="kr.or.arko.cmm.batch" />
|
||||||
<!-- 해당 태그들은 크론탭을 설정에 필요한 태그들 -->
|
<!-- 해당 태그들은 크론탭을 설정에 필요한 태그들 -->
|
||||||
<!-- <task:scheduler id="jobScheduler" pool-size="10" />
|
<!-- 2026-07-28 활성화: BatchScheduler의 @Scheduled 전체 기동. OS crontab에서 동일 배치를
|
||||||
<task:annotation-driven scheduler="jobScheduler" /> -->
|
병행 운영 중이면 이중 실행이 되므로 운영과 경합 여부 확인 필요. -->
|
||||||
|
<task:scheduler id="jobScheduler" pool-size="10" />
|
||||||
|
<task:annotation-driven scheduler="jobScheduler" />
|
||||||
|
|
||||||
|
|
||||||
<bean id="messageSource"
|
<bean id="messageSource"
|
||||||
|
|||||||
@ -25,6 +25,9 @@
|
|||||||
</aop:config> -->
|
</aop:config> -->
|
||||||
|
|
||||||
<!-- =========================================================================
|
<!-- =========================================================================
|
||||||
|
[이력 / 2026-08-07 중단] 아래 설명과 <aop:config> 는 현재 비활성이다.
|
||||||
|
중단 사유와 대체 구현은 바로 다음 주석 블록 참조. 이 블록은 판단 근거 보존용으로 남긴다.
|
||||||
|
─────────────────────────────────────────────────────────────────────────
|
||||||
개인정보보호 기능개선 사업 (SFR-004 / DBR-001 / SER-002) — 접속기록 한정 수집(#31)
|
개인정보보호 기능개선 사업 (SFR-004 / DBR-001 / SER-002) — 접속기록 한정 수집(#31)
|
||||||
· 위 COMTNSYSLOG용 syslog AOP(전 서비스 select* 대상)는 주석 그대로 유지(활성화하지 않음).
|
· 위 COMTNSYSLOG용 syslog AOP(전 서비스 select* 대상)는 주석 그대로 유지(활성화하지 않음).
|
||||||
· 아래 personalDataLogAspect 는 '개인정보 처리 서비스'로 한정한 별도 체계다.
|
· 아래 personalDataLogAspect 는 '개인정보 처리 서비스'로 한정한 별도 체계다.
|
||||||
@ -35,7 +38,31 @@
|
|||||||
· 대상 확장 시: 이 pointcut 에 개인정보 처리 서비스 impl 만 추가(과수집 R2 방지). 게시판/공지 등 무관 조회 제외.
|
· 대상 확장 시: 이 pointcut 에 개인정보 처리 서비스 impl 만 추가(과수집 R2 방지). 게시판/공지 등 무관 조회 제외.
|
||||||
· #36(2026-07-21): 회원·업체 2개 지점 → 전 개인정보 처리 서비스(16종)로 확장. TRGET_ID 취득 규약은 PersonalDataLogAspect Javadoc 참조.
|
· #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 등 개인정보 미취급 서비스.
|
[제외] 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 메서드를 가로채 접속기록을 남겼다. 그러나 서비스는
|
||||||
|
관리자 화면과 프론트 화면이 함께 호출하므로 '어느 화면에서 불렀는지'를 구분할 수 없었고,
|
||||||
|
그 결과 무인증 프론트 방문이 접속자ID=ANONYMOUS, 수행업무="재능나눔 회원관리" 로 적재됐다
|
||||||
|
(공개 재능나눔 상세 /frt/biz/talent/bizTalentView.do → selectTalentMber).
|
||||||
|
selectJoinTalentMberList 를 pointcut 에서 빼는 식의 개별 예외로는 같은 문제가 계속 생긴다 —
|
||||||
|
프론트/관리자가 공유하는 서비스 메서드가 그것 하나가 아니기 때문이다.
|
||||||
|
|
||||||
|
대체 구현: egovframework.com.cmm.privacy.interceptor.MenuAccessLogInterceptor
|
||||||
|
(등록: WEB-INF/config/egovframework/springmvc/egov-com-servlet.xml)
|
||||||
|
요청 URL 로 관리자 화면만 골라내고, 기존 메뉴 테이블(COMTNMENUINFO)의 메뉴명을
|
||||||
|
수행업무로 남긴다. 적재 경로(AccessLogQueue → 해시체인 → PERSONAL_DATA_ACCESS_LOG)는 동일하다.
|
||||||
|
|
||||||
|
되돌리려면 아래 주석을 풀고 egov-com-servlet.xml 의 menuAccessLogInterceptor 등록을 제거할 것
|
||||||
|
(둘을 동시에 켜면 한 요청이 두 경로로 중복 적재된다).
|
||||||
|
========================================================================= -->
|
||||||
|
<!--
|
||||||
<aop:config>
|
<aop:config>
|
||||||
<aop:aspect id="personalDataLogAspect" ref="personalDataLogAspect">
|
<aop:aspect id="personalDataLogAspect" ref="personalDataLogAspect">
|
||||||
<aop:pointcut id="personalDataProcessPointcut"
|
<aop:pointcut id="personalDataProcessPointcut"
|
||||||
@ -48,7 +75,8 @@
|
|||||||
or execution(* egovframework.com.uss.umt.service.impl.EgovAccountUnlockManageServiceImpl.*(..))
|
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.note.service.impl.BizNoteServiceImpl.*(..))
|
||||||
or execution(* kr.or.arko.frt.biz.sms.service.impl.BizSmsServiceImpl.*(..))
|
or execution(* kr.or.arko.frt.biz.sms.service.impl.BizSmsServiceImpl.*(..))
|
||||||
or execution(* kr.or.arko.mnt.biz.talent.service.impl.BizTalentMberManageServiceImpl.*(..))
|
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.proapp.service.impl.BizProappServiceImpl.*(..))
|
||||||
or execution(* kr.or.arko.mnt.biz.appconfirm.service.impl.BizAppconfirmServiceImpl.*(..))
|
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.proresult.service.impl.BizProresultServiceImpl.*(..))
|
||||||
@ -58,5 +86,6 @@
|
|||||||
<aop:around pointcut-ref="personalDataProcessPointcut" method="around" />
|
<aop:around pointcut-ref="personalDataProcessPointcut" method="around" />
|
||||||
</aop:aspect>
|
</aop:aspect>
|
||||||
</aop:config>
|
</aop:config>
|
||||||
|
-->
|
||||||
|
|
||||||
</beans>
|
</beans>
|
||||||
@ -511,10 +511,17 @@
|
|||||||
FROM COMTNGNRLMBER
|
FROM COMTNGNRLMBER
|
||||||
]]>
|
]]>
|
||||||
WHERE mber_id = #id#
|
WHERE mber_id = #id#
|
||||||
|
AND password = #password#
|
||||||
AND mber_sttus = 'P'
|
AND mber_sttus = 'P'
|
||||||
</isEqual>
|
</isEqual>
|
||||||
<!-- 2025.11.05 나혁제 확인결과 아래부분은 운영에는 적용되어 있음 확인후 처리해야됨 -->
|
<!-- ★ 2026-08-05 [개인정보/SER-001] 일반회원(GNR) 비밀번호 검증 조건 복구.
|
||||||
<!-- AND password = #password# -->
|
· 종전: 이 조건이 주석 처리 + <isEqual> 블록 '바깥'에 위치해 있어(2025.11.05 나혁제 메모)
|
||||||
|
GNR 분기는 mber_id/mber_sttus 만으로 인증이 성립했다 → 아이디만 알면 임의 비밀번호로
|
||||||
|
로그인되는 인증 우회. GRO/ENT/USR 3개 분기는 정상, GNR 만 누락된 상태였다.
|
||||||
|
· 주석만 해제하면 안 된다: 블록 바깥의 텍스트는 userSe 와 무관하게 항상 렌더링되어
|
||||||
|
GRO/ENT/USR 로그인 시 SELECT 앞에 'AND password = #password#' 가 붙어 SQL 이 깨진다.
|
||||||
|
반드시 위와 같이 GNR <isEqual> 블록 안으로 넣어야 한다.
|
||||||
|
· EgovLoginUsr_SQL_Oracle.xml 은 처음부터 조건이 살아 있어 이제 두 파일이 동일하다. -->
|
||||||
<!-- 그룹회원 -->
|
<!-- 그룹회원 -->
|
||||||
<isEqual property="userSe" compareValue="GRO">
|
<isEqual property="userSe" compareValue="GRO">
|
||||||
<![CDATA[
|
<![CDATA[
|
||||||
@ -584,6 +591,49 @@
|
|||||||
</isEqual>
|
</isEqual>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!-- ★ 2026-08-06 [개인정보/SER-003] 저장 비밀번호 값 조회 (비밀번호 해시 솔트 적용 개발항목 27 후속).
|
||||||
|
· 솔트 적용 계정은 저장값이 'S1:솔트:해시' 형식이라, 평문을 해시하기 전에 그 솔트를 알아야 한다.
|
||||||
|
EgovLoginServiceImpl.actionLogin 이 이 값을 먼저 읽어 EgovFileScrty.encryptPasswordAs 로
|
||||||
|
같은 형식의 문자열을 만든 뒤, 아래 loginDAO.actionLogin 의 WHERE 비교에 그대로 넣는다.
|
||||||
|
· 덕분에 인증 조건(AND password = 파라미터)을 건드리지 않는다. 2026-08-05 GNR 인증우회
|
||||||
|
사고가 이 WHERE 조건 이동에서 비롯됐으므로, 해당 조건은 원형 그대로 보존한다.
|
||||||
|
· 계정 상태(mber_sttus 등)는 조건에 넣지 않는다. 없는 아이디/휴면 계정이면 null 이 되어
|
||||||
|
무솔트 해시로 계산되고 actionLogin 이 0건을 돌려주므로 결과는 종전과 동일하다. -->
|
||||||
|
<select id="loginDAO.selectStoredPassword" resultClass="java.lang.String">
|
||||||
|
<!-- 일반회원 -->
|
||||||
|
<isEqual property="userSe" compareValue="GNR">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT password
|
||||||
|
FROM COMTNGNRLMBER
|
||||||
|
WHERE mber_id = #id#
|
||||||
|
]]>
|
||||||
|
</isEqual>
|
||||||
|
<!-- 그룹회원 -->
|
||||||
|
<isEqual property="userSe" compareValue="GRO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT group_password
|
||||||
|
FROM BIZ_GROUP
|
||||||
|
WHERE group_id = #id#
|
||||||
|
]]>
|
||||||
|
</isEqual>
|
||||||
|
<!-- 기업회원 -->
|
||||||
|
<isEqual property="userSe" compareValue="ENT">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT entrprs_mber_password
|
||||||
|
FROM COMTNENTRPRSMBER
|
||||||
|
WHERE ENTRPRS_MBER_ID = #id#
|
||||||
|
]]>
|
||||||
|
</isEqual>
|
||||||
|
<!-- 업무사용자 -->
|
||||||
|
<isEqual property="userSe" compareValue="USR">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT password
|
||||||
|
FROM COMTNEMPLYRINFO
|
||||||
|
WHERE emplyr_id = #id#
|
||||||
|
]]>
|
||||||
|
</isEqual>
|
||||||
|
</select>
|
||||||
|
|
||||||
<!-- 인증서 로그인 -->
|
<!-- 인증서 로그인 -->
|
||||||
<select id="loginDAO.actionCrtfctLogin" resultMap="login">
|
<select id="loginDAO.actionCrtfctLogin" resultMap="login">
|
||||||
<![CDATA[
|
<![CDATA[
|
||||||
|
|||||||
@ -355,6 +355,43 @@
|
|||||||
</isEqual>
|
</isEqual>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!-- ★ 2026-08-06 [개인정보/SER-003] 저장 비밀번호 값 조회 (비밀번호 해시 솔트 적용 개발항목 27 후속).
|
||||||
|
Mysql 본과 동일 statement. 상세 설명은 EgovLoginUsr_SQL_Mysql.xml 주석 참조. -->
|
||||||
|
<select id="loginDAO.selectStoredPassword" resultClass="java.lang.String">
|
||||||
|
<!-- 일반회원 -->
|
||||||
|
<isEqual property="userSe" compareValue="GNR">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT password
|
||||||
|
FROM COMTNGNRLMBER
|
||||||
|
WHERE mber_id = #id#
|
||||||
|
]]>
|
||||||
|
</isEqual>
|
||||||
|
<!-- 그룹회원 -->
|
||||||
|
<isEqual property="userSe" compareValue="GRO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT group_password
|
||||||
|
FROM BIZ_GROUP
|
||||||
|
WHERE group_id = #id#
|
||||||
|
]]>
|
||||||
|
</isEqual>
|
||||||
|
<!-- 기업회원 -->
|
||||||
|
<isEqual property="userSe" compareValue="ENT">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT entrprs_mber_password
|
||||||
|
FROM COMTNENTRPRSMBER
|
||||||
|
WHERE ENTRPRS_MBER_ID = #id#
|
||||||
|
]]>
|
||||||
|
</isEqual>
|
||||||
|
<!-- 업무사용자 -->
|
||||||
|
<isEqual property="userSe" compareValue="USR">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT password
|
||||||
|
FROM COMTNEMPLYRINFO
|
||||||
|
WHERE emplyr_id = #id#
|
||||||
|
]]>
|
||||||
|
</isEqual>
|
||||||
|
</select>
|
||||||
|
|
||||||
<!-- 인증서 로그인 -->
|
<!-- 인증서 로그인 -->
|
||||||
<select id="loginDAO.actionCrtfctLogin" resultMap="login">
|
<select id="loginDAO.actionCrtfctLogin" resultMap="login">
|
||||||
<![CDATA[
|
<![CDATA[
|
||||||
|
|||||||
@ -807,6 +807,15 @@ FROM(
|
|||||||
WHERE ESNTL_ID=#uniqId#
|
WHERE ESNTL_ID=#uniqId#
|
||||||
]]>
|
]]>
|
||||||
</select>
|
</select>
|
||||||
|
<!-- 2026-08-06 [개인정보/SER-003] 아이디로 저장 비밀번호 값 조회 (비밀번호 해시 솔트 적용 개발항목 27 후속).
|
||||||
|
selectPassword_S 는 ESNTL_ID 기준이라 아이디만 아는 searchMber 경로에서 쓸 수 없어 별도로 둔다. -->
|
||||||
|
<select id="mberManageDAO.selectStoredPasswordByMberId_S" parameterClass="mberVO" resultClass="java.lang.String">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT PASSWORD
|
||||||
|
FROM COMTNGNRLMBER
|
||||||
|
WHERE MBER_ID = #mberId#
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
<select id="mberManageDAO.searchMber_S" parameterClass="mberVO" resultClass="String">
|
<select id="mberManageDAO.searchMber_S" parameterClass="mberVO" resultClass="String">
|
||||||
<![CDATA[
|
<![CDATA[
|
||||||
SELECT MBER_ID "mberId"
|
SELECT MBER_ID "mberId"
|
||||||
|
|||||||
@ -535,6 +535,15 @@ SELECT * FROM ( SELECT rownum rn, TB.* FROM (
|
|||||||
]]>
|
]]>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!-- 2026-08-06 [개인정보/SER-003] 아이디로 저장 비밀번호 값 조회 (비밀번호 해시 솔트 적용 개발항목 27 후속).
|
||||||
|
Mysql 본과 동일 statement. -->
|
||||||
|
<select id="mberManageDAO.selectStoredPasswordByMberId_S" parameterClass="mberVO" resultClass="java.lang.String">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT PASSWORD
|
||||||
|
FROM COMTNGNRLMBER
|
||||||
|
WHERE MBER_ID = #mberId#
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
<select id="mberManageDAO.searchMber_S" parameterClass="mberVO" resultClass="String">
|
<select id="mberManageDAO.searchMber_S" parameterClass="mberVO" resultClass="String">
|
||||||
<![CDATA[
|
<![CDATA[
|
||||||
SELECT MBER_ID "mberId"
|
SELECT MBER_ID "mberId"
|
||||||
|
|||||||
@ -134,6 +134,25 @@
|
|||||||
</dynamic>
|
</dynamic>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
[SFR-004 / 2026.08.07] 조회화면 '수행업무' 드롭다운 소스.
|
||||||
|
종전에는 PDAL_JOB_MAPPING(서비스 클래스·메서드 매핑)을 소스로 썼으나, 수집이
|
||||||
|
관리자 화면 메뉴 기반(MenuAccessLogInterceptor)으로 바뀌면서 업무유형이 메뉴에서 나온다.
|
||||||
|
매핑 테이블을 보면 실제 적재값과 어긋난 선택지가 뜨므로, 적재된 값에서 직접 뽑는다.
|
||||||
|
(드롭다운이라 건수가 많지 않고, JOB_SE_CODE 는 인덱스 대상이 아니어도 메뉴 수준의 카디널리티다)
|
||||||
|
-->
|
||||||
|
<select id="PersonalDataAccessLogDAO.selectJobSeCodeList"
|
||||||
|
resultClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT JOB_SE_CODE AS jobSeCode,
|
||||||
|
MAX(JOB_SE_NM) AS jobSeNm
|
||||||
|
FROM PERSONAL_DATA_ACCESS_LOG
|
||||||
|
WHERE JOB_SE_CODE IS NOT NULL
|
||||||
|
GROUP BY JOB_SE_CODE
|
||||||
|
ORDER BY MAX(JOB_SE_NM) ASC
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
<!-- ================================================================
|
<!-- ================================================================
|
||||||
#43 정기점검 리포트 집계·적재. 전월분 PERSONAL_DATA_ACCESS_LOG 집계(SELECT) +
|
#43 정기점검 리포트 집계·적재. 전월분 PERSONAL_DATA_ACCESS_LOG 집계(SELECT) +
|
||||||
PDAL_INSPECTION_REPORT 적재(INSERT). 파라미터는 fromDt/toDt(Map) 또는 InspectionReportVO.
|
PDAL_INSPECTION_REPORT 적재(INSERT). 파라미터는 fromDt/toDt(Map) 또는 InspectionReportVO.
|
||||||
|
|||||||
@ -120,6 +120,19 @@
|
|||||||
</dynamic>
|
</dynamic>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!-- [SFR-004 / 2026.08.07] 조회화면 '수행업무' 드롭다운 소스 — MySQL판과 동일 의미. -->
|
||||||
|
<select id="PersonalDataAccessLogDAO.selectJobSeCodeList"
|
||||||
|
resultClass="egovframework.com.cmm.privacy.auditlog.JobMappingVO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT JOB_SE_CODE AS jobSeCode,
|
||||||
|
MAX(JOB_SE_NM) AS jobSeNm
|
||||||
|
FROM PERSONAL_DATA_ACCESS_LOG
|
||||||
|
WHERE JOB_SE_CODE IS NOT NULL
|
||||||
|
GROUP BY JOB_SE_CODE
|
||||||
|
ORDER BY MAX(JOB_SE_NM) ASC
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
<!-- #43 정기점검 리포트 집계·적재 — Oracle 쌍(시퀀스 PDAL_INSPECTION_REPORT_SEQ 필요). -->
|
<!-- #43 정기점검 리포트 집계·적재 — Oracle 쌍(시퀀스 PDAL_INSPECTION_REPORT_SEQ 필요). -->
|
||||||
<select id="PersonalDataAccessLogDAO.summarizeAccessLogByPeriod"
|
<select id="PersonalDataAccessLogDAO.summarizeAccessLogByPeriod"
|
||||||
parameterClass="java.util.HashMap" resultClass="java.util.HashMap">
|
parameterClass="java.util.HashMap" resultClass="java.util.HashMap">
|
||||||
|
|||||||
@ -0,0 +1,101 @@
|
|||||||
|
<?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">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
장기 미사용 계정 개인정보 파기(#12) — SFR-002 개발항목 #12
|
||||||
|
보존기간(DELETE_PRESERVE_DAY)은 ACCOUNT_MNG_POLICY 정책값을 그때그때 조회해 사용(하드코딩 없음).
|
||||||
|
1회 처리건수는 LIMIT 으로 상한(백로그 최초가동 폭주 방지, #7과 동일 정책, 2026-07-28).
|
||||||
|
2026-07-30 상한 재산정 200 → 1,000(유형별): 휴면 적재 상한을 2,000/일로 올린 데 맞춰 파기도
|
||||||
|
올리되, 파기는 마스킹으로 되돌릴 수 없으므로 의도적으로 유입(2,000/일)보다 낮게 둔다.
|
||||||
|
이상 징후를 발견하면 PURGE_USE_YN='N' 으로 즉시 정지할 수 있는 관측 여유를 확보하기 위함.
|
||||||
|
-->
|
||||||
|
<sqlMap namespace="AccountDeleteDAO">
|
||||||
|
|
||||||
|
<typeAlias alias="accountDeleteTargetVO" type="egovframework.com.cmm.privacy.account.AccountDeleteTargetVO"/>
|
||||||
|
<typeAlias alias="accountDeleteHistoryVO" type="egovframework.com.cmm.privacy.account.AccountDeleteHistoryVO"/>
|
||||||
|
|
||||||
|
<!-- 파기 대상(일반회원): 휴면 확정('P') + 보존기간 경과 + 상한 1,000 -->
|
||||||
|
<select id="AccountDeleteDAO.selectExpiredGnrAccounts" resultClass="accountDeleteTargetVO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT A.MBER_ID AS mberId
|
||||||
|
, A.ESNTL_ID AS esntlId
|
||||||
|
, A.DIAPAUSE_DT AS diapauseDt
|
||||||
|
, (SELECT IFNULL(MAX(DELETE_PRESERVE_DAY),365) FROM ACCOUNT_MNG_POLICY) AS preserveDay
|
||||||
|
FROM COMTNDIAPAUSEMBER A
|
||||||
|
WHERE A.MBER_TY_CODE = 'GNR'
|
||||||
|
AND A.DIAPAUSE_STAT = 'P'
|
||||||
|
AND DATEDIFF(NOW(), A.DIAPAUSE_DT) > (SELECT IFNULL(MAX(DELETE_PRESERVE_DAY),365) FROM ACCOUNT_MNG_POLICY)
|
||||||
|
ORDER BY A.DIAPAUSE_DT
|
||||||
|
LIMIT 1000
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 파기 대상(개인정보취급자): 휴면 확정('P') + 보존기간 경과 + 상한 1,000 -->
|
||||||
|
<select id="AccountDeleteDAO.selectExpiredEmpAccounts" resultClass="accountDeleteTargetVO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT A.MBER_ID AS mberId
|
||||||
|
, A.ESNTL_ID AS esntlId
|
||||||
|
, A.DIAPAUSE_DT AS diapauseDt
|
||||||
|
, (SELECT IFNULL(MAX(DELETE_PRESERVE_DAY),365) FROM ACCOUNT_MNG_POLICY) AS preserveDay
|
||||||
|
FROM COMTNDIAPAUSEMBER A
|
||||||
|
WHERE A.MBER_TY_CODE = 'EMP'
|
||||||
|
AND A.DIAPAUSE_STAT = 'P'
|
||||||
|
AND DATEDIFF(NOW(), A.DIAPAUSE_DT) > (SELECT IFNULL(MAX(DELETE_PRESERVE_DAY),365) FROM ACCOUNT_MNG_POLICY)
|
||||||
|
ORDER BY A.DIAPAUSE_DT
|
||||||
|
LIMIT 1000
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 일반회원 개인정보 파기(마스킹) — 기존 회원탈퇴(MBER_STTUS='D') 컨벤션과 동일 규칙 재사용 -->
|
||||||
|
<update id="AccountDeleteDAO.anonymizeGnrMember" parameterClass="java.lang.String">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE COMTNGNRLMBER
|
||||||
|
SET MBER_NM = CONCAT(LEFT(MBER_NM,1), REPEAT('*', GREATEST(CHAR_LENGTH(MBER_NM)-1,0)))
|
||||||
|
, MBER_EMAIL_ADRES = NULL
|
||||||
|
, MBTLNUM = NULL
|
||||||
|
, MBER_FXNUM = NULL
|
||||||
|
, IHIDNUM = NULL
|
||||||
|
, ZIP = NULL
|
||||||
|
, ADRES = NULL
|
||||||
|
, DETAIL_ADRES = NULL
|
||||||
|
, MBER_STTUS = 'D'
|
||||||
|
, LOCK_YN = 'Y'
|
||||||
|
WHERE MBER_ID = #value#
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<!-- 개인정보취급자 개인정보 파기(마스킹) -->
|
||||||
|
<update id="AccountDeleteDAO.anonymizeEmplyr" parameterClass="java.lang.String">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE COMTNEMPLYRINFO
|
||||||
|
SET USER_NM = CONCAT(LEFT(USER_NM,1), REPEAT('*', GREATEST(CHAR_LENGTH(USER_NM)-1,0)))
|
||||||
|
, EMAIL_ADRES = NULL
|
||||||
|
, MBTLNUM = NULL
|
||||||
|
, FXNUM = NULL
|
||||||
|
, OFFM_TELNO = NULL
|
||||||
|
, IHIDNUM = NULL
|
||||||
|
, ZIP = NULL
|
||||||
|
, HOUSE_ADRES = NULL
|
||||||
|
, DETAIL_ADRES = NULL
|
||||||
|
, EMPLYR_STTUS_CODE = 'D'
|
||||||
|
, LOCK_YN = 'Y'
|
||||||
|
WHERE EMPLYR_ID = #value#
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<!-- 파기 완료 대상은 휴면 추적 대상에서 제거(재처리 방지) -->
|
||||||
|
<delete id="AccountDeleteDAO.deleteDiapauseRow" parameterClass="java.lang.String">
|
||||||
|
<![CDATA[
|
||||||
|
DELETE FROM COMTNDIAPAUSEMBER WHERE MBER_ID = #value#
|
||||||
|
]]>
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
<!-- 파기 이력 적재(#12) -->
|
||||||
|
<insert id="AccountDeleteDAO.insertDeleteHistory" parameterClass="accountDeleteHistoryVO">
|
||||||
|
<![CDATA[
|
||||||
|
INSERT INTO ACCOUNT_DELETE_HISTORY (MBER_ID, MBER_TY_CODE, ESNTL_ID, DIAPAUSE_DT, PRESERVE_DAY)
|
||||||
|
VALUES (#mberId#, #mberTyCode#, #esntlId#, #diapauseDt#, #preserveDay#)
|
||||||
|
]]>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
</sqlMap>
|
||||||
@ -0,0 +1,87 @@
|
|||||||
|
<?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">
|
||||||
|
|
||||||
|
<!-- 장기 미사용 계정 개인정보 파기(#12) — Mysql 쌍(PrivacyAccountDelete_SQL_Mysql.xml) 참조.
|
||||||
|
대상 운영 DB가 MySQL(csv)이라 sql-map-config 미등록(다른 Privacy* Oracle 파일과 동일 관례). -->
|
||||||
|
<sqlMap namespace="AccountDeleteDAO">
|
||||||
|
|
||||||
|
<typeAlias alias="accountDeleteTargetVO" type="egovframework.com.cmm.privacy.account.AccountDeleteTargetVO"/>
|
||||||
|
<typeAlias alias="accountDeleteHistoryVO" type="egovframework.com.cmm.privacy.account.AccountDeleteHistoryVO"/>
|
||||||
|
|
||||||
|
<select id="AccountDeleteDAO.selectExpiredGnrAccounts" resultClass="accountDeleteTargetVO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT A.MBER_ID AS mberId
|
||||||
|
, A.ESNTL_ID AS esntlId
|
||||||
|
, A.DIAPAUSE_DT AS diapauseDt
|
||||||
|
, (SELECT NVL(MAX(DELETE_PRESERVE_DAY),365) FROM ACCOUNT_MNG_POLICY) AS preserveDay
|
||||||
|
FROM COMTNDIAPAUSEMBER A
|
||||||
|
WHERE A.MBER_TY_CODE = 'GNR'
|
||||||
|
AND A.DIAPAUSE_STAT = 'P'
|
||||||
|
AND (SYSDATE - A.DIAPAUSE_DT) > (SELECT NVL(MAX(DELETE_PRESERVE_DAY),365) FROM ACCOUNT_MNG_POLICY)
|
||||||
|
AND ROWNUM <= 1000 /* 2026-07-30 상한 200→1,000 재산정(Mysql 쌍 주석 참조) */
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="AccountDeleteDAO.selectExpiredEmpAccounts" resultClass="accountDeleteTargetVO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT A.MBER_ID AS mberId
|
||||||
|
, A.ESNTL_ID AS esntlId
|
||||||
|
, A.DIAPAUSE_DT AS diapauseDt
|
||||||
|
, (SELECT NVL(MAX(DELETE_PRESERVE_DAY),365) FROM ACCOUNT_MNG_POLICY) AS preserveDay
|
||||||
|
FROM COMTNDIAPAUSEMBER A
|
||||||
|
WHERE A.MBER_TY_CODE = 'EMP'
|
||||||
|
AND A.DIAPAUSE_STAT = 'P'
|
||||||
|
AND (SYSDATE - A.DIAPAUSE_DT) > (SELECT NVL(MAX(DELETE_PRESERVE_DAY),365) FROM ACCOUNT_MNG_POLICY)
|
||||||
|
AND ROWNUM <= 1000 /* 2026-07-30 상한 200→1,000 재산정(Mysql 쌍 주석 참조) */
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<update id="AccountDeleteDAO.anonymizeGnrMember" parameterClass="java.lang.String">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE COMTNGNRLMBER
|
||||||
|
SET MBER_NM = SUBSTR(MBER_NM,1,1) || RPAD('*', GREATEST(LENGTH(MBER_NM)-1,0), '*')
|
||||||
|
, MBER_EMAIL_ADRES = NULL
|
||||||
|
, MBTLNUM = NULL
|
||||||
|
, MBER_FXNUM = NULL
|
||||||
|
, IHIDNUM = NULL
|
||||||
|
, ZIP = NULL
|
||||||
|
, ADRES = NULL
|
||||||
|
, DETAIL_ADRES = NULL
|
||||||
|
, MBER_STTUS = 'D'
|
||||||
|
, LOCK_YN = 'Y'
|
||||||
|
WHERE MBER_ID = #value#
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="AccountDeleteDAO.anonymizeEmplyr" parameterClass="java.lang.String">
|
||||||
|
<![CDATA[
|
||||||
|
UPDATE COMTNEMPLYRINFO
|
||||||
|
SET USER_NM = SUBSTR(USER_NM,1,1) || RPAD('*', GREATEST(LENGTH(USER_NM)-1,0), '*')
|
||||||
|
, EMAIL_ADRES = NULL
|
||||||
|
, MBTLNUM = NULL
|
||||||
|
, FXNUM = NULL
|
||||||
|
, OFFM_TELNO = NULL
|
||||||
|
, IHIDNUM = NULL
|
||||||
|
, ZIP = NULL
|
||||||
|
, HOUSE_ADRES = NULL
|
||||||
|
, DETAIL_ADRES = NULL
|
||||||
|
, EMPLYR_STTUS_CODE = 'D'
|
||||||
|
, LOCK_YN = 'Y'
|
||||||
|
WHERE EMPLYR_ID = #value#
|
||||||
|
]]>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<delete id="AccountDeleteDAO.deleteDiapauseRow" parameterClass="java.lang.String">
|
||||||
|
<![CDATA[
|
||||||
|
DELETE FROM COMTNDIAPAUSEMBER WHERE MBER_ID = #value#
|
||||||
|
]]>
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
<insert id="AccountDeleteDAO.insertDeleteHistory" parameterClass="accountDeleteHistoryVO">
|
||||||
|
<![CDATA[
|
||||||
|
INSERT INTO ACCOUNT_DELETE_HISTORY (MBER_ID, MBER_TY_CODE, ESNTL_ID, DIAPAUSE_DT, PRESERVE_DAY)
|
||||||
|
VALUES (#mberId#, #mberTyCode#, #esntlId#, #diapauseDt#, #preserveDay#)
|
||||||
|
]]>
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
</sqlMap>
|
||||||
@ -2,9 +2,13 @@
|
|||||||
<!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">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
개인정보보호 기능개선 사업 (SFR-004 / SER-002) — 업무유형 매핑 조회(#32).
|
개인정보보호 기능개선 사업 (SFR-004 / SER-002) — 업무유형 매핑(#32) + 관리 화면(#32-1).
|
||||||
PDAL_JOB_MAPPING 을 JobMappingResolver 가 기동 시 1회 로드해 메모리 캐시한다.
|
· selectJobMappingList : JobMappingResolver 가 캐시에 적재(USE_AT='Y' 만)
|
||||||
|
· selectJobMappingManage* 이하 : 관리 화면 CRUD (사용중지 행도 조회)
|
||||||
컬럼→프로퍼티 매핑을 위해 AS 별칭을 camelCase 로 부여.
|
컬럼→프로퍼티 매핑을 위해 AS 별칭을 camelCase 로 부여.
|
||||||
|
|
||||||
|
※ REGISTER_ID/REG_DT/UPDUSR_ID/UPDT_DT 는 privacy.job_mapping_audit_cols_mysql.sql 로
|
||||||
|
추가되는 컬럼이다. 이 sqlMap 을 배포하기 전에 해당 DDL 을 반드시 먼저 적용할 것.
|
||||||
-->
|
-->
|
||||||
<sqlMap namespace="JobMappingDAO">
|
<sqlMap namespace="JobMappingDAO">
|
||||||
|
|
||||||
@ -26,4 +30,136 @@
|
|||||||
]]>
|
]]>
|
||||||
</select>
|
</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>
|
</sqlMap>
|
||||||
|
|||||||
@ -2,8 +2,12 @@
|
|||||||
<!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">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
개인정보보호 기능개선 사업 — 업무유형 매핑 조회(#32) Oracle 쌍(이중관리 규약).
|
개인정보보호 기능개선 사업 — 업무유형 매핑(#32) + 관리 화면(#32-1) Oracle 쌍(이중관리 규약).
|
||||||
※ arko 런타임은 MySQL sqlmap 만 로드한다.
|
※ arko 런타임은 MySQL sqlmap 만 로드한다. Oracle 전환 시에는 아래 차이를 확인할 것.
|
||||||
|
· 페이징: LIMIT 미지원 → ROWNUM 인라인뷰
|
||||||
|
· 문자열 결합: CONCAT 2인자 제한 → '%' || v || '%'
|
||||||
|
· 현재시각: NOW() → SYSDATE
|
||||||
|
· MAPPING_ID: AUTO_INCREMENT 없음 → 시퀀스(PDAL_JOB_MAPPING_SEQ) 선행 생성 필요
|
||||||
-->
|
-->
|
||||||
<sqlMap namespace="JobMappingDAO">
|
<sqlMap namespace="JobMappingDAO">
|
||||||
|
|
||||||
@ -25,4 +29,135 @@
|
|||||||
]]>
|
]]>
|
||||||
</select>
|
</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>
|
</sqlMap>
|
||||||
|
|||||||
@ -24,4 +24,26 @@
|
|||||||
]]>
|
]]>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
[SFR-004 접속기록 / 2026.08.07] 관리자 화면 메뉴 접속기록 — 요청 URL·page 파라미터 → 메뉴명 해석용.
|
||||||
|
MenuAccessLogInterceptor 가 최초 요청 시 1회 전량 로드해 캐싱한다(요청마다 조인 금지).
|
||||||
|
- COMTNMENUINFO m : 메뉴정보 — MENU_NO, MENU_NM, PROGRM_FILE_NM
|
||||||
|
- COMTNPROGRMLIST p : 프로그램목록 — PROGRM_FILE_NM, URL
|
||||||
|
위 selectMenuUrlRoles 와 달리 COMTNMENUCREATDTLS(권한배정) 를 조인하지 않는다.
|
||||||
|
권한 배정 여부와 무관하게 메뉴 전체가 필요하고, 조인 시 롤 수만큼 행이 중복되기 때문이다.
|
||||||
|
URL 접두 제한도 두지 않는다 — 관리자 화면 판정은 인터셉터의 applyPathPrefixes 가 담당하며,
|
||||||
|
arko 관리자 화면 중 회원관리(/uss/umt/...)처럼 '/mnt/' 접두가 아닌 것이 있다.
|
||||||
|
MENU_NO 는 NUMERIC 이라 CHAR 로 캐스팅해 문자열 프로퍼티(menuNo)에 맞춘다.
|
||||||
|
-->
|
||||||
|
<select id="PrivacyMenuAuthDAO.selectMenuNames" resultClass="egovframework.com.cmm.privacy.interceptor.MenuNameVO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT CAST(m.MENU_NO AS CHAR) AS menuNo,
|
||||||
|
m.MENU_NM AS menuNm,
|
||||||
|
p.URL AS url
|
||||||
|
FROM COMTNMENUINFO m
|
||||||
|
JOIN COMTNPROGRMLIST p ON p.PROGRM_FILE_NM = m.PROGRM_FILE_NM
|
||||||
|
WHERE p.URL IS NOT NULL
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
</sqlMap>
|
</sqlMap>
|
||||||
|
|||||||
@ -19,4 +19,19 @@
|
|||||||
]]>
|
]]>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
[SFR-004 접속기록 / 2026.08.07] 관리자 화면 메뉴 접속기록 — MySQL판과 동일 의미.
|
||||||
|
MENU_NO 숫자→문자 변환만 Oracle 문법(TO_CHAR)으로 바꿨다.
|
||||||
|
-->
|
||||||
|
<select id="PrivacyMenuAuthDAO.selectMenuNames" resultClass="egovframework.com.cmm.privacy.interceptor.MenuNameVO">
|
||||||
|
<![CDATA[
|
||||||
|
SELECT TO_CHAR(m.MENU_NO) AS menuNo,
|
||||||
|
m.MENU_NM AS menuNm,
|
||||||
|
p.URL AS url
|
||||||
|
FROM COMTNMENUINFO m
|
||||||
|
JOIN COMTNPROGRMLIST p ON p.PROGRM_FILE_NM = m.PROGRM_FILE_NM
|
||||||
|
WHERE p.URL IS NOT NULL
|
||||||
|
]]>
|
||||||
|
</select>
|
||||||
|
|
||||||
</sqlMap>
|
</sqlMap>
|
||||||
|
|||||||
@ -77,6 +77,14 @@
|
|||||||
<!-- <ref local="authenticInterceptor" /> 로그인 체크 -->
|
<!-- <ref local="authenticInterceptor" /> 로그인 체크 -->
|
||||||
<ref local="IpObtainInterceptor" />
|
<ref local="IpObtainInterceptor" />
|
||||||
<ref local="siteURLInterceptor" />
|
<ref local="siteURLInterceptor" />
|
||||||
|
<!-- 관리자 화면 메뉴 접속기록(SFR-004) — 판정이 아니라 기록이며, preHandle 은
|
||||||
|
아무것도 하지 않고 afterCompletion 에서만 적재한다.
|
||||||
|
※ 위치가 중요하다: 반드시 ②③ 판정 인터셉터보다 '앞'이어야 한다.
|
||||||
|
Spring 은 preHandle 이 false 를 반환하면 그 '이전' 인터셉터들에 대해서만
|
||||||
|
afterCompletion 을 호출한다. 뒤에 두면 2단계인증 미완료·권한 미허용으로
|
||||||
|
차단된 요청이 접속기록에 아예 남지 않는다(차단 이력 유실).
|
||||||
|
앞에 두면 차단된 요청도 처리결과 'F'(응답 401/403)로 기록된다. -->
|
||||||
|
<ref local="menuAccessLogInterceptor" />
|
||||||
<!-- ② 2단계 인증 판정(SFR-003/#14) → ③ 권한 판정(SFR-001/#2) -->
|
<!-- ② 2단계 인증 판정(SFR-003/#14) → ③ 권한 판정(SFR-001/#2) -->
|
||||||
<ref local="secondFactorInterceptor" />
|
<ref local="secondFactorInterceptor" />
|
||||||
<ref local="authorityInterceptor" />
|
<ref local="authorityInterceptor" />
|
||||||
@ -206,6 +214,77 @@
|
|||||||
</property>
|
</property>
|
||||||
</bean>
|
</bean>
|
||||||
|
|
||||||
|
<!-- ============================================================
|
||||||
|
[개인정보보호 SFR-004 / 2026.08.07] 관리자 화면 메뉴 접속기록.
|
||||||
|
종전 서비스 AOP 수집(personalDataLogAspect, context-syslogaop.xml)을 대체한다.
|
||||||
|
서비스는 프론트/관리자가 공유해 호출 화면을 구분할 수 없었고, 그 탓에 무인증 프론트
|
||||||
|
방문이 접속자ID=ANONYMOUS 로 적재됐다. 요청 URL 을 보는 인터셉터로 전환해
|
||||||
|
'취급자가 관리자 화면에서 연 메뉴'만 남긴다. 상세 근거는 MenuAccessLogInterceptor Javadoc.
|
||||||
|
|
||||||
|
· includePatterns : 관리자 화면 전체가 아니라 '개인정보 취급 화면'만 담은 화이트리스트다.
|
||||||
|
SFR-004 의 대상은 개인정보 처리 기록이고, 통계·게시판·코드관리까지 730일 보관 테이블에
|
||||||
|
쌓으면 정기점검·이상탐지 신호가 묻힌다.
|
||||||
|
[산출 근거] 개인정보 서비스(구 AOP pointcut 16종: EgovMber/EgovEntrprs/EgovUser/
|
||||||
|
EgovDiapause/EgovAccountUnlock/BizTalentMber/BizProapp/BizAppconfirm/BizProresult/
|
||||||
|
BizGroupManage/BizMileage/BizProfile/BizNote/BizSms ManageService)를 실제로 주입·호출하는
|
||||||
|
'관리자 컨트롤러'를 역산해 그 URL 대역만 열거했다. 추정이 아니라 소스 실측이다.
|
||||||
|
※ 구 AOP 는 excel/provol 을 '개인정보 미취급'으로 분류했으나, 그건 서비스 기준이라
|
||||||
|
excelDown.do → EgovMberManageServiceImpl 호출이 서비스 쪽에서 잡혔기 때문이다.
|
||||||
|
URL 기준으로 바뀐 지금은 화면을 직접 넣지 않으면 '회원 명단 엑셀 다운로드'가
|
||||||
|
통째로 누락된다 — 대량 개인정보 반출이라 반드시 포함한다.
|
||||||
|
· excludePatterns : 화이트리스트 '안에서' 빼는 것. 프레임(left/top/footer)·공통 include 는
|
||||||
|
화면 1회 진입마다 동반 호출돼, 안 빼면 같은 진입이 4~5행으로 부풀어 통계가 왜곡된다.
|
||||||
|
/uss/umt/dpt/(부서관리)는 대역 안이지만 개인정보 취급이 아니라 제외.
|
||||||
|
· handlerUserSes : 개인정보취급자 판정(USR=업무사용자). 일반회원(GNR)·기업(ENT) 세션이
|
||||||
|
/uss/umt/ 계열을 호출해도 취급자 기록으로 섞이지 않는다.
|
||||||
|
· menuAuthDAO/accessLogQueue 는 루트 컨텍스트 빈 — 자식→부모 가시성으로 참조.
|
||||||
|
· 개인정보 취급 화면을 새로 만들면 includePatterns 에 한 줄을 더해야 수집된다
|
||||||
|
(자동 확장 아님 — 과수집 통제 지점을 남기기 위한 의도된 선택).
|
||||||
|
============================================================ -->
|
||||||
|
<bean id="menuAccessLogInterceptor" class="egovframework.com.cmm.privacy.interceptor.MenuAccessLogInterceptor">
|
||||||
|
<property name="includePatterns">
|
||||||
|
<list>
|
||||||
|
<!-- 회원·업체·업무사용자·휴면계정·계정잠금해제 관리 (표준프레임워크 사용자관리 모듈)
|
||||||
|
EgovMberManage / EgovEntrprsManage / EgovUserManage /
|
||||||
|
EgovDiapauseManage / EgovAccountUnlockManage Controller -->
|
||||||
|
<value>\A/uss/umt/.*\Z</value>
|
||||||
|
<!-- arko 관리자 개인정보 취급 화면
|
||||||
|
talent 재능나눔 회원관리 (BizTalentMberManageController)
|
||||||
|
group 그룹 회원관리 (BizGroupManageController)
|
||||||
|
mypage 그룹·업체 회원관리 (BizGroupMntManage/BizEntrprsMnt/BizActivityMntController)
|
||||||
|
proapp 신청자 관리 (BizProappMnt/BizAppconfirmMntController)
|
||||||
|
proresult 활동결과 관리 (BizProresultMntController)
|
||||||
|
profile 프로필 관리 (BizProfileMntController)
|
||||||
|
provol 모집·참가자 명단 (BizProvolMntController — excelEnterList 등)
|
||||||
|
excel 명단 엑셀 다운로드 (ExcelController — 대량 개인정보 반출)
|
||||||
|
mileage 마일리지 (BizMileageService 계열) -->
|
||||||
|
<value>\A/mnt/biz/(talent|group|mypage|proapp|appconfirm|proresult|profile|provol|excel|mileage|applymileage)/.*\Z</value>
|
||||||
|
<!-- 개인정보보호 관리화면 자체 — 접속기록·권한이력 조회 행위도 남겨야 한다(SER-002-3) -->
|
||||||
|
<value>\A/mnt/(sec|privacy)/.*\Z</value>
|
||||||
|
</list>
|
||||||
|
</property>
|
||||||
|
<property name="excludePatterns">
|
||||||
|
<list>
|
||||||
|
<!-- 관리자 프레임/레이아웃 — 화면 1회 진입에 동반 호출되는 부속 요청 -->
|
||||||
|
<value>\A/mnt/(main|left|top|right|footer|bottom)\.do.*\Z</value>
|
||||||
|
<value>\A/mnt/(include)/.*\.do.*\Z</value>
|
||||||
|
<value>\A/Egov(Left|Top|Bottom)\.do.*\Z</value>
|
||||||
|
<!-- 부서관리 — /uss/umt/ 대역이지만 개인정보 취급이 아니다(EgovDeptManageController) -->
|
||||||
|
<value>\A/uss/umt/dpt/.*\Z</value>
|
||||||
|
<!-- 로그인/2차인증 화면 자신 — 인증 이력은 #22 인증 실패 이력이 담당 -->
|
||||||
|
<value>\A/uat/uia/.*\Z</value>
|
||||||
|
</list>
|
||||||
|
</property>
|
||||||
|
<property name="handlerUserSes">
|
||||||
|
<set>
|
||||||
|
<value>USR</value>
|
||||||
|
</set>
|
||||||
|
</property>
|
||||||
|
<property name="menuAuthDAO" ref="privacyMenuAuthDAO" />
|
||||||
|
<property name="accessLogQueue" ref="accessLogQueue" />
|
||||||
|
<property name="cacheTtlSeconds" value="600" />
|
||||||
|
</bean>
|
||||||
|
|
||||||
<!-- <bean class="org.springframework.web.servlet.handler.SimpleMappingExceptionResolver">
|
<!-- <bean class="org.springframework.web.servlet.handler.SimpleMappingExceptionResolver">
|
||||||
<property name="defaultErrorView" value="egovframework/com/cmm/error/egovError"/>
|
<property name="defaultErrorView" value="egovframework/com/cmm/error/egovError"/>
|
||||||
<property name="exceptionMappings">
|
<property name="exceptionMappings">
|
||||||
|
|||||||
@ -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 ''}">class="on"</c:if>><a href="#none">시스템관리</a>
|
|| param.page eq '9100091' || param.page eq '9100092' || param.page eq '9100093' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100045' || param.page eq ''}">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 ''}">style="display: block;"</c:if>>
|
|| param.page eq '9100091' || param.page eq '9100092' || param.page eq '9100093' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100045' || param.page eq ''}">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'}">class="on"</c:if>><a href="#none">${menuUpperVo2.menuNm}</a>
|
<li <c:if test="${param.page eq '9100031' || param.page eq '9100034' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100045'}">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'}">style="display: block;"</c:if>>
|
<ul <c:if test="${param.page eq '9100031' || param.page eq '9100034' || param.page eq '9100035' || param.page eq '9100036' || param.page eq '9100037' || param.page eq '9100038' || param.page eq '9100039' || param.page eq '9100044' || param.page eq '9100045'}">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'}">
|
||||||
@ -122,6 +122,14 @@
|
|||||||
<c:when test="${menuUpperVo4.menuNo eq '9100039'}">
|
<c:when test="${menuUpperVo4.menuNo eq '9100039'}">
|
||||||
<li <c:if test="${param.page eq '9100039'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
|
<li <c:if test="${param.page eq '9100039'}">class="on"</c:if>><a href="${menuUpperVo4.chkURL}">${menuUpperVo4.menuNm}</a></li>
|
||||||
</c:when>
|
</c:when>
|
||||||
|
<%-- [개인정보/#47] 개인정보 접속기록 조회·다운로드 (9100040~43은 게시판관리가 선점 → 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>
|
||||||
|
</c:when>
|
||||||
|
<%-- [개인정보/#32-1] 업무유형 매핑 관리 (접속기록 수행업무 분류 기준) --%>
|
||||||
|
<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>
|
||||||
</c:choose>
|
</c:choose>
|
||||||
</c:forEach>
|
</c:forEach>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
206
src/main/webapp/WEB-INF/jsp/site/mnt/sec/accessLog/list.jsp
Normal file
206
src/main/webapp/WEB-INF/jsp/site/mnt/sec/accessLog/list.jsp
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
<%@ 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"%>
|
||||||
|
|
||||||
|
<%--
|
||||||
|
개인정보 접속기록 조회·다운로드 (개인정보보호 SFR-004 / SER-002 / #47)
|
||||||
|
· PERSONAL_DATA_ACCESS_LOG(#30) 적재분을 접속자ID·업무유형·대상·IP·처리구분·결과·기간으로 복합검색
|
||||||
|
· 다운로드는 사유 필수(#42) — 미입력 시 서버가 400으로 차단하고 파일을 열지 않음
|
||||||
|
· 이 테이블은 UPDATE/DELETE statement 미정의(위·변조 방지) — 화면에서도 조회/다운로드만 제공
|
||||||
|
--%>
|
||||||
|
<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/accessLog/listView.do' />";
|
||||||
|
document.frm.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 다운로드 : 사유 필수(#42). 사유·검색조건을 그대로 넘기고 이력을 남긴다. */
|
||||||
|
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/accessLog/download.do' />";
|
||||||
|
document.frm.submit();
|
||||||
|
/* 다음 검색이 다운로드로 가지 않도록 원복 */
|
||||||
|
document.frm.dlReason.value = "";
|
||||||
|
document.frm.action = "<c:url value='${PATH}/sec/accessLog/listView.do' />";
|
||||||
|
}
|
||||||
|
//-->
|
||||||
|
</script>
|
||||||
|
<style type="text/css">
|
||||||
|
.badge { display:inline-block; padding:2px 8px; border-radius:3px; font-size:12px; color:#fff; }
|
||||||
|
.badge.s { background:#4a7ebb; }
|
||||||
|
.badge.f { background:#c25b5b; }
|
||||||
|
.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; }
|
||||||
|
.ip { font-family:Consolas,monospace; }
|
||||||
|
.hash { font-family:Consolas,monospace; font-size:11px; color:#888; }
|
||||||
|
.guide { margin:6px 0 10px; padding:8px 10px; background:#f5f7fa; border:1px solid #dde3ea; font-size:12px; color:#555; }
|
||||||
|
.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="personalDataAccessLogSearchVO" 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="conectId" title="접속자ID" cssClass="tbox w4" />
|
||||||
|
<span>수행업무 :</span>
|
||||||
|
<select name="jobSeCode" id="jobSeCode" title="수행업무 유형">
|
||||||
|
<option value="" <c:if test="${empty personalDataAccessLogSearchVO.jobSeCode}">selected="selected"</c:if>>전체</option>
|
||||||
|
<c:forEach items="${jobSeList}" var="job">
|
||||||
|
<option value="<c:out value='${job.jobSeCode}'/>" <c:if test="${personalDataAccessLogSearchVO.jobSeCode == job.jobSeCode}">selected="selected"</c:if>><c:out value="${job.jobSeNm}" /></option>
|
||||||
|
</c:forEach>
|
||||||
|
</select>
|
||||||
|
<span>처리대상ID :</span>
|
||||||
|
<form:input path="trgetId" title="처리대상 식별자" cssClass="tbox w4" />
|
||||||
|
<span>접속IP :</span>
|
||||||
|
<form:input path="conectIp" title="접속IP" cssClass="tbox w4" />
|
||||||
|
<br />
|
||||||
|
<span>처리구분 :</span>
|
||||||
|
<select name="dmlGubun" id="dmlGubun" title="처리구분">
|
||||||
|
<option value="" <c:if test="${empty personalDataAccessLogSearchVO.dmlGubun}">selected="selected"</c:if>>전체</option>
|
||||||
|
<option value="S" <c:if test="${personalDataAccessLogSearchVO.dmlGubun == 'S'}">selected="selected"</c:if>>조회</option>
|
||||||
|
<option value="I" <c:if test="${personalDataAccessLogSearchVO.dmlGubun == 'I'}">selected="selected"</c:if>>등록</option>
|
||||||
|
<option value="U" <c:if test="${personalDataAccessLogSearchVO.dmlGubun == 'U'}">selected="selected"</c:if>>수정</option>
|
||||||
|
<option value="D" <c:if test="${personalDataAccessLogSearchVO.dmlGubun == 'D'}">selected="selected"</c:if>>삭제</option>
|
||||||
|
</select>
|
||||||
|
<span>처리결과 :</span>
|
||||||
|
<select name="processResult" id="processResult" title="처리결과">
|
||||||
|
<option value="" <c:if test="${empty personalDataAccessLogSearchVO.processResult}">selected="selected"</c:if>>전체</option>
|
||||||
|
<option value="S" <c:if test="${personalDataAccessLogSearchVO.processResult == 'S'}">selected="selected"</c:if>>성공</option>
|
||||||
|
<option value="F" <c:if test="${personalDataAccessLogSearchVO.processResult == 'F'}">selected="selected"</c:if>>실패/차단</option>
|
||||||
|
</select>
|
||||||
|
<span>기간 :</span>
|
||||||
|
<form:input path="fromDt" title="조회 시작일시" cssClass="tbox w4" maxlength="19" />
|
||||||
|
~
|
||||||
|
<form:input path="toDt" title="조회 종료일시" cssClass="tbox w4" maxlength="19" />
|
||||||
|
<input type="image" src="<c:url value='/images/site/mnt/btn_search.gif'/>" alt="검색" />
|
||||||
|
<a href="<c:url value='${PATH}/sec/accessLog/listView.do' />"><img src="<c:url value='/images/site/mnt/btn_all_list.gif'/>" alt="전체목록" /></a>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<!-- //검색창 -->
|
||||||
|
|
||||||
|
<div class="guide">
|
||||||
|
· 개인정보를 처리(조회·등록·수정·삭제)한 기록입니다. 기간은 <strong>yyyy-MM-dd</strong> 또는 <strong>yyyy-MM-dd HH:mm:ss</strong> 형식으로 입력하세요.<br />
|
||||||
|
· 보관기간은 <strong>730일</strong>이며, 파기는 개별 삭제가 아니라 <strong>월 단위 파티션 삭제</strong>로만 수행됩니다(파기 내역은 별도 이력으로 보존).<br />
|
||||||
|
· 각 기록은 <strong>SHA-256 해시체인</strong>으로 연결되어 있어 중간 변조 시 무결성 검증 배치가 탐지합니다. 이 화면에서는 수정·삭제를 제공하지 않습니다.<br />
|
||||||
|
· <strong>다운로드는 사유 입력이 필수</strong>이며, 다운로드자·일시·IP·사유·건수가 다운로드 이력에 기록됩니다.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn_area">
|
||||||
|
총 <strong><c:out value="${personalDataAccessLogSearchVO.totalRecordCount}" /></strong>건
|
||||||
|
<input type="button" class="btn_dl" value="CSV 다운로드(사유 필수)" onclick="fn_download();" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table summary="개인정보 접속기록 목록" class="list1">
|
||||||
|
<caption>개인정보 접속기록 목록</caption>
|
||||||
|
<colgroup>
|
||||||
|
<col width="5%" />
|
||||||
|
<col width="13%" />
|
||||||
|
<col width="10%" />
|
||||||
|
<col width="11%" />
|
||||||
|
<col width="*" />
|
||||||
|
<col width="7%" />
|
||||||
|
<col width="12%" />
|
||||||
|
<col width="8%" />
|
||||||
|
<col width="9%" />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>NO.</th>
|
||||||
|
<th>접속일시</th>
|
||||||
|
<th>접속자ID</th>
|
||||||
|
<th>접속IP</th>
|
||||||
|
<th>수행업무</th>
|
||||||
|
<th>처리구분</th>
|
||||||
|
<th>처리대상</th>
|
||||||
|
<th>처리결과</th>
|
||||||
|
<th>무결성해시</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<c:forEach items="${resultList}" var="result" varStatus="status">
|
||||||
|
<tr>
|
||||||
|
<td><c:out value="${personalDataAccessLogSearchVO.totalRecordCount - (status.index + (personalDataAccessLogSearchVO.pageIndex-1)*personalDataAccessLogSearchVO.recordCountPerPage)}" /></td>
|
||||||
|
<td><c:out value="${result.conectDt}" /></td>
|
||||||
|
<td>
|
||||||
|
<c:out value="${result.conectId}" />
|
||||||
|
<c:if test="${not empty result.userSe}">(<c:out value="${result.userSe}" />)</c:if>
|
||||||
|
</td>
|
||||||
|
<td class="ip"><c:out value="${result.conectIp}" /></td>
|
||||||
|
<td><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><c:out value="${result.dmlGubun}" /></c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<c:out value="${result.trgetId}" />
|
||||||
|
<c:if test="${not empty result.trgetCn}"><br /><span class="hash"><c:out value="${result.trgetCn}" /></span></c:if>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<c:choose>
|
||||||
|
<c:when test="${result.processResult == 'F'}"><span class="badge f">실패/차단</span></c:when>
|
||||||
|
<c:otherwise><span class="badge s">성공</span></c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
</td>
|
||||||
|
<td class="hash" title="<c:out value='${result.hashVal}'/>">
|
||||||
|
<c:out value="${fn:substring(result.hashVal, 0, 12)}" />…
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</c:forEach>
|
||||||
|
<c:if test="${fn:length(resultList) == 0}">
|
||||||
|
<tr>
|
||||||
|
<td colspan="9"><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>
|
||||||
157
src/main/webapp/WEB-INF/jsp/site/mnt/sec/authorHistory/list.jsp
Normal file
157
src/main/webapp/WEB-INF/jsp/site/mnt/sec/authorHistory/list.jsp
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
<%@ 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"%>
|
||||||
|
|
||||||
|
<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/authorHistory/listView.do' />";
|
||||||
|
document.frm.submit();
|
||||||
|
}
|
||||||
|
function fn_download() {
|
||||||
|
var resn = prompt("다운로드 사유를 입력하세요.(필수)", "");
|
||||||
|
if (resn === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resn = $.trim(resn);
|
||||||
|
if (resn.length === 0) {
|
||||||
|
alert("다운로드 사유는 필수입니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var params = {
|
||||||
|
searchTrgetId: $("#searchTrgetId").val(),
|
||||||
|
searchChangerId: $("#searchChangerId").val(),
|
||||||
|
searchChgSeCode: $("#searchChgSeCode").val(),
|
||||||
|
fromDt: $("#fromDt").val(),
|
||||||
|
toDt: $("#toDt").val(),
|
||||||
|
dwldResn: resn
|
||||||
|
};
|
||||||
|
var qs = $.param(params);
|
||||||
|
location.href = "<c:url value='${PATH}/sec/authorHistory/download.do' />?" + qs;
|
||||||
|
}
|
||||||
|
//-->
|
||||||
|
</script>
|
||||||
|
<style type="text/css">
|
||||||
|
.badge { display:inline-block; padding:2px 8px; border-radius:3px; font-size:12px; color:#fff; }
|
||||||
|
.badge.g { background:#3a9d5d; }
|
||||||
|
.badge.m { background:#5b7ec2; }
|
||||||
|
.badge.r { background:#c25b5b; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<!-- 타이틀 -->
|
||||||
|
<c:import url = "/mnt/include/navigation.do" />
|
||||||
|
<!-- //타이틀 -->
|
||||||
|
<!-- 컨텐츠 영역 -->
|
||||||
|
|
||||||
|
<form:form commandName="authorChangeHistoryVO" onsubmit="fn_list('1'); return false;" id="frm" name="frm" method="get" >
|
||||||
|
<form:hidden path="pageIndex" />
|
||||||
|
|
||||||
|
<!-- 검색창 -->
|
||||||
|
<div id="search_wrap">
|
||||||
|
<fieldset class="date_search">
|
||||||
|
<span>대상자ID :</span>
|
||||||
|
<form:input path="searchTrgetId" title="대상자ID" cssClass="tbox w4" />
|
||||||
|
<span>변경자ID :</span>
|
||||||
|
<form:input path="searchChangerId" title="변경자ID" cssClass="tbox w4" />
|
||||||
|
<span>변경구분 :</span>
|
||||||
|
<select name="searchChgSeCode" id="searchChgSeCode" title="변경구분">
|
||||||
|
<option value="" <c:if test="${empty authorChangeHistoryVO.searchChgSeCode}">selected="selected"</c:if>>전체</option>
|
||||||
|
<option value="G" <c:if test="${authorChangeHistoryVO.searchChgSeCode == 'G'}">selected="selected"</c:if>>부여</option>
|
||||||
|
<option value="M" <c:if test="${authorChangeHistoryVO.searchChgSeCode == 'M'}">selected="selected"</c:if>>변경</option>
|
||||||
|
<option value="R" <c:if test="${authorChangeHistoryVO.searchChgSeCode == 'R'}">selected="selected"</c:if>>회수</option>
|
||||||
|
</select>
|
||||||
|
<span>기간 :</span>
|
||||||
|
<form:input path="fromDt" title="검색시작일" cssClass="tbox w4" />
|
||||||
|
~
|
||||||
|
<form:input path="toDt" title="검색종료일" cssClass="tbox w4" />
|
||||||
|
<input type="image" src="<c:url value='/images/site/mnt/btn_search.gif'/>" alt="검색" />
|
||||||
|
<a href="<c:url value='${PATH}/sec/authorHistory/listView.do' />"><img src="<c:url value='/images/site/mnt/btn_all_list.gif'/>" alt="전체목록" /></a>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<!-- //검색창 -->
|
||||||
|
|
||||||
|
<!-- 버튼 -->
|
||||||
|
<div class="btn_zone">
|
||||||
|
<a href="javascript:fn_download();" class="btn2">다운로드(CSV)</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table summary="권한변경 이력 목록" class="list1">
|
||||||
|
<caption>권한변경 이력 목록</caption>
|
||||||
|
<colgroup>
|
||||||
|
<col width="5%" />
|
||||||
|
<col width="13%" />
|
||||||
|
<col width="10%" />
|
||||||
|
<col width="8%" />
|
||||||
|
<col width="14%" />
|
||||||
|
<col width="14%" />
|
||||||
|
<col width="*" />
|
||||||
|
<col width="9%" />
|
||||||
|
<col width="9%" />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>NO.</th>
|
||||||
|
<th>변경일시</th>
|
||||||
|
<th>대상자ID</th>
|
||||||
|
<th>변경구분</th>
|
||||||
|
<th>변경전 권한</th>
|
||||||
|
<th>변경후 권한</th>
|
||||||
|
<th>변경사유</th>
|
||||||
|
<th>승인자</th>
|
||||||
|
<th>변경자ID</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<c:forEach items="${resultList}" var="result" varStatus="status">
|
||||||
|
<tr>
|
||||||
|
<td><c:out value="${authorChangeHistoryVO.totalRecordCount - (status.index + (authorChangeHistoryVO.pageIndex-1)*authorChangeHistoryVO.recordCountPerPage)}" /></td>
|
||||||
|
<td><c:out value="${result.chgDt}" /></td>
|
||||||
|
<td><c:out value="${result.trgetId}" /></td>
|
||||||
|
<td>
|
||||||
|
<c:choose>
|
||||||
|
<c:when test="${result.chgSeCode == 'G'}"><span class="badge g">부여</span></c:when>
|
||||||
|
<c:when test="${result.chgSeCode == 'M'}"><span class="badge m">변경</span></c:when>
|
||||||
|
<c:when test="${result.chgSeCode == 'R'}"><span class="badge r">회수</span></c:when>
|
||||||
|
<c:otherwise><c:out value="${result.chgSeCode}" /></c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
</td>
|
||||||
|
<td><c:out value="${result.beforeAuthor}" /></td>
|
||||||
|
<td><c:out value="${result.afterAuthor}" /></td>
|
||||||
|
<td class="sbj"><c:out value="${result.chgResn}" /></td>
|
||||||
|
<td><c:out value="${result.aprovlId}" /></td>
|
||||||
|
<td><c:out value="${result.changerId}" /></td>
|
||||||
|
</tr>
|
||||||
|
</c:forEach>
|
||||||
|
<c:if test="${fn:length(resultList) == 0}">
|
||||||
|
<tr>
|
||||||
|
<td colspan="9"><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>
|
||||||
132
src/main/webapp/WEB-INF/jsp/site/mnt/sec/dormantStatus/list.jsp
Normal file
132
src/main/webapp/WEB-INF/jsp/site/mnt/sec/dormantStatus/list.jsp
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
<%@ 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"%>
|
||||||
|
|
||||||
|
<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/dormantStatus/listView.do' />";
|
||||||
|
document.frm.submit();
|
||||||
|
}
|
||||||
|
//-->
|
||||||
|
</script>
|
||||||
|
<style type="text/css">
|
||||||
|
.badge { display:inline-block; padding:2px 8px; border-radius:3px; font-size:12px; color:#fff; }
|
||||||
|
.badge.p { background:#3a9d5d; }
|
||||||
|
.badge.s { background:#c25b5b; }
|
||||||
|
.badge.a { background:#5b7ec2; }
|
||||||
|
.badge.d { background:#888; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<!-- 타이틀 -->
|
||||||
|
<c:import url = "/mnt/include/navigation.do" />
|
||||||
|
<!-- //타이틀 -->
|
||||||
|
<!-- 컨텐츠 영역 -->
|
||||||
|
|
||||||
|
<form:form commandName="dormantAccountStatusVO" onsubmit="fn_list('1'); return false;" id="frm" name="frm" method="get" >
|
||||||
|
<form:hidden path="pageIndex" />
|
||||||
|
|
||||||
|
<!-- 검색창 -->
|
||||||
|
<div id="search_wrap">
|
||||||
|
<fieldset class="date_search">
|
||||||
|
<span>계정ID :</span>
|
||||||
|
<form:input path="accountId" title="계정ID" cssClass="tbox w4" />
|
||||||
|
<span>성명 :</span>
|
||||||
|
<form:input path="userNm" title="성명" cssClass="tbox w4" />
|
||||||
|
<span>상태 :</span>
|
||||||
|
<select name="sttusCode" id="sttusCode" title="상태">
|
||||||
|
<option value="" <c:if test="${empty dormantAccountStatusVO.sttusCode}">selected="selected"</c:if>>전체</option>
|
||||||
|
<option value="P" <c:if test="${dormantAccountStatusVO.sttusCode == 'P'}">selected="selected"</c:if>>사용</option>
|
||||||
|
<option value="A" <c:if test="${dormantAccountStatusVO.sttusCode == 'A'}">selected="selected"</c:if>>승인대기</option>
|
||||||
|
<option value="S" <c:if test="${dormantAccountStatusVO.sttusCode == 'S'}">selected="selected"</c:if>>사용중지</option>
|
||||||
|
<option value="D" <c:if test="${dormantAccountStatusVO.sttusCode == 'D'}">selected="selected"</c:if>>말소</option>
|
||||||
|
</select>
|
||||||
|
<span>미접속일수(이상) :</span>
|
||||||
|
<form:input path="minNoConnectDays" title="미접속일수 하한" cssClass="tbox w4" />
|
||||||
|
<input type="image" src="<c:url value='/images/site/mnt/btn_search.gif'/>" alt="검색" />
|
||||||
|
<a href="<c:url value='${PATH}/sec/dormantStatus/listView.do' />"><img src="<c:url value='/images/site/mnt/btn_all_list.gif'/>" alt="전체목록" /></a>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<!-- //검색창 -->
|
||||||
|
|
||||||
|
<table summary="장기 미사용 계정 현황 목록" class="list1">
|
||||||
|
<caption>장기 미사용 계정 현황 목록</caption>
|
||||||
|
<colgroup>
|
||||||
|
<col width="5%" />
|
||||||
|
<col width="12%" />
|
||||||
|
<col width="10%" />
|
||||||
|
<col width="12%" />
|
||||||
|
<col width="10%" />
|
||||||
|
<col width="12%" />
|
||||||
|
<col width="9%" />
|
||||||
|
<col width="9%" />
|
||||||
|
<col width="*" />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>NO.</th>
|
||||||
|
<th>계정ID</th>
|
||||||
|
<th>성명</th>
|
||||||
|
<th>소속</th>
|
||||||
|
<th>직위</th>
|
||||||
|
<th>최종로그인일</th>
|
||||||
|
<th>미접속일수</th>
|
||||||
|
<th>상태</th>
|
||||||
|
<th>휴면예정일</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<c:forEach items="${resultList}" var="result" varStatus="status">
|
||||||
|
<tr>
|
||||||
|
<td><c:out value="${dormantAccountStatusVO.totalRecordCount - (status.index + (dormantAccountStatusVO.pageIndex-1)*dormantAccountStatusVO.recordCountPerPage)}" /></td>
|
||||||
|
<td><c:out value="${result.accountId}" /></td>
|
||||||
|
<td><c:out value="${result.userNm}" /></td>
|
||||||
|
<td><c:out value="${result.orgnztId}" /></td>
|
||||||
|
<td><c:out value="${result.ofcpsNm}" /></td>
|
||||||
|
<td><c:out value="${result.lastLoginDt}" /></td>
|
||||||
|
<td><c:out value="${result.noConnectDays}" /></td>
|
||||||
|
<td>
|
||||||
|
<c:choose>
|
||||||
|
<c:when test="${result.sttusCode == 'P'}"><span class="badge p">사용</span></c:when>
|
||||||
|
<c:when test="${result.sttusCode == 'A'}"><span class="badge a">승인대기</span></c:when>
|
||||||
|
<c:when test="${result.sttusCode == 'S'}"><span class="badge s">사용중지</span></c:when>
|
||||||
|
<c:when test="${result.sttusCode == 'D'}"><span class="badge d">말소</span></c:when>
|
||||||
|
<c:otherwise><c:out value="${result.sttusCode}" /></c:otherwise>
|
||||||
|
</c:choose>
|
||||||
|
</td>
|
||||||
|
<td><c:out value="${result.dormantExpectDt}" /></td>
|
||||||
|
</tr>
|
||||||
|
</c:forEach>
|
||||||
|
<c:if test="${fn:length(resultList) == 0}">
|
||||||
|
<tr>
|
||||||
|
<td colspan="9"><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>
|
||||||
253
src/main/webapp/WEB-INF/jsp/site/mnt/sec/jobMapping/list.jsp
Normal file
253
src/main/webapp/WEB-INF/jsp/site/mnt/sec/jobMapping/list.jsp
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
<%@ 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>
|
||||||
188
src/main/webapp/WEB-INF/jsp/site/mnt/sec/jobMapping/regist.jsp
Normal file
188
src/main/webapp/WEB-INF/jsp/site/mnt/sec/jobMapping/regist.jsp
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
<%@ 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>
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- 장기 미사용 계정 개인정보 파기 이력 : ACCOUNT_DELETE_HISTORY
|
||||||
|
-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-002, 개발항목 #12 계정 삭제 기능)
|
||||||
|
-- 작성일: 2026-07-28 DB: MySQL 8 / MariaDB
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- [목적] 휴면 확정(COMTNDIAPAUSEMBER.DIAPAUSE_STAT='P') 후 정책상 보존기간
|
||||||
|
-- (ACCOUNT_MNG_POLICY.DELETE_PRESERVE_DAY)이 경과한 계정의 개인정보를
|
||||||
|
-- 파기(마스킹)한 이력을 남긴다. 실제 파기 방식은 행 DELETE가 아니라
|
||||||
|
-- PII 컬럼 마스킹(기존 회원탈퇴 MBER_STTUS='D' 컨벤션과 동일) — 다른
|
||||||
|
-- 테이블(게시글·마일리지·활동확인 등)이 MBER_ID/EMPLYR_ID를 참조하는
|
||||||
|
-- 소프트 조인이 많아 행 자체를 지우면 그쪽이 깨지기 때문.
|
||||||
|
--
|
||||||
|
-- [보존기간] ACCOUNT_MNG_POLICY.DELETE_PRESERVE_DAY 값을 실행 시점에 그대로
|
||||||
|
-- 조회해 사용(하드코딩 없음). 이 테이블의 PRESERVE_DAY 컬럼은
|
||||||
|
-- 그 시점에 적용된 값의 스냅샷(정책이 나중에 바뀌어도 이력은 불변).
|
||||||
|
--
|
||||||
|
-- [처리량 제한] 배치가 오래 정지돼 있다가 재가동되는 날 백로그가 한꺼번에
|
||||||
|
-- 몰리는 것을 막기 위해 1회 처리건수를 상한(LIMIT 200, DAO 참조)
|
||||||
|
-- 으로 제한한다(#7/#10과 동일 정책, 2026-07-28).
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ACCOUNT_DELETE_HISTORY (
|
||||||
|
DEL_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '파기 일련번호(PK)',
|
||||||
|
MBER_ID VARCHAR(32) NOT NULL COMMENT '파기 대상 계정ID(MBER_ID/EMPLYR_ID)',
|
||||||
|
MBER_TY_CODE CHAR(3) NOT NULL COMMENT '회원유형 GNR:일반회원 EMP:개인정보취급자',
|
||||||
|
ESNTL_ID VARCHAR(20) NULL COMMENT '고유식별자(파기 시점 스냅샷, 참고용)',
|
||||||
|
DIAPAUSE_DT DATETIME NULL COMMENT '휴면 확정 기준일시(파기 판단 기준)',
|
||||||
|
PRESERVE_DAY INT NOT NULL COMMENT '파기 시점 적용된 보존기간(정책값 스냅샷, 일)',
|
||||||
|
DEL_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '파기 처리 일시',
|
||||||
|
PRIMARY KEY (DEL_ID),
|
||||||
|
KEY IX_ADH_MBER (MBER_ID),
|
||||||
|
KEY IX_ADH_DELDT (DEL_DT)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
COMMENT='장기 미사용 계정 개인정보 파기 이력(개인정보보호 SFR-002 #12)';
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- 계정 관리 정책 확장 : ACCOUNT_MNG_POLICY 에 파기 배치 사용여부 컬럼 추가
|
||||||
|
-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-002, 개발항목 #12 계정 삭제 기능)
|
||||||
|
-- 작성일: 2026-07-28 DB: MySQL 8 / MariaDB
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- [배경] #12 파기 배치(BatchScheduler.purgeExpiredAccountScheduler)는 되돌릴 수 없는
|
||||||
|
-- 처리라 그동안 @Scheduled 를 소스에서 주석 처리해 막아두었다. 그 방식은
|
||||||
|
-- 켜고 끌 때마다 재배포가 필요해 운영이 불편하다.
|
||||||
|
-- → 배치는 항상 등록해 두고, 실제 파기 수행 여부를 이 정책 플래그로 제어한다.
|
||||||
|
-- (플래그 'N'이면 서비스가 즉시 0건 반환하고 아무것도 파기하지 않음)
|
||||||
|
--
|
||||||
|
-- [안전] DEFAULT 'N' — 신규/기존 환경 모두 기본은 '파기 안 함'. 관리자가
|
||||||
|
-- 계정 관리 정책 화면(#13, /mnt/sec/accountPolicy/policyView.do)에서
|
||||||
|
-- 명시적으로 'Y'로 바꿔야 비로소 파기가 시작된다.
|
||||||
|
--
|
||||||
|
-- [관련] 파기 기준일은 별도 컬럼이 아니라 기존 DELETE_PRESERVE_DAY 를 그대로 사용한다
|
||||||
|
-- (같은 의미의 값을 두 곳에 두지 않기 위함).
|
||||||
|
--
|
||||||
|
-- [멱등] MySQL 8 은 ADD COLUMN IF NOT EXISTS 를 지원하지 않으므로
|
||||||
|
-- information_schema 로 컬럼 존재를 확인해 없을 때만 실행한다(재실행 안전).
|
||||||
|
-- MySQL 8 / MariaDB 10.x 공통 동작.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
SET @ddl := IF(
|
||||||
|
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'ACCOUNT_MNG_POLICY'
|
||||||
|
AND COLUMN_NAME = 'PURGE_USE_YN') > 0,
|
||||||
|
'SELECT ''SKIP: ACCOUNT_MNG_POLICY.PURGE_USE_YN already exists'' AS msg',
|
||||||
|
'ALTER TABLE ACCOUNT_MNG_POLICY
|
||||||
|
ADD COLUMN PURGE_USE_YN CHAR(1) NOT NULL DEFAULT ''N''
|
||||||
|
COMMENT ''장기 미사용 계정 파기 배치 사용여부 Y:사용 N:미사용(#12)'''
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- [검증] SELECT PURGE_USE_YN FROM ACCOUNT_MNG_POLICY WHERE POLICY_ID = 1;
|
||||||
|
-- [롤백] ALTER TABLE ACCOUNT_MNG_POLICY DROP COLUMN PURGE_USE_YN;
|
||||||
@ -9,7 +9,7 @@
|
|||||||
-- [무결성] 애플리케이션 계층에 UPDATE/DELETE statement 미정의(INSERT/SELECT 한정).
|
-- [무결성] 애플리케이션 계층에 UPDATE/DELETE statement 미정의(INSERT/SELECT 한정).
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
|
|
||||||
CREATE TABLE ACCOUNT_UNLOCK_HISTORY (
|
CREATE TABLE IF NOT EXISTS ACCOUNT_UNLOCK_HISTORY (
|
||||||
UNLOCK_HIST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '해제 이력 일련번호(PK)',
|
UNLOCK_HIST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '해제 이력 일련번호(PK)',
|
||||||
TRGET_ID VARCHAR(50) NULL COMMENT '해제 대상 계정 ID(전체해제 시 NULL 가능)',
|
TRGET_ID VARCHAR(50) NULL COMMENT '해제 대상 계정 ID(전체해제 시 NULL 가능)',
|
||||||
TRGET_SE VARCHAR(10) NULL COMMENT '대상 회원구분 GNR/GRO/ENT/USR',
|
TRGET_SE VARCHAR(10) NULL COMMENT '대상 회원구분 GNR/GRO/ENT/USR',
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
-- 애플리케이션 계층에 UPDATE statement 미정의(INSERT/SELECT/파기 DELETE 한정).
|
-- 애플리케이션 계층에 UPDATE statement 미정의(INSERT/SELECT/파기 DELETE 한정).
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
|
|
||||||
CREATE TABLE AUTHOR_CHANGE_HISTORY (
|
CREATE TABLE IF NOT EXISTS AUTHOR_CHANGE_HISTORY (
|
||||||
HIST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '이력 일련번호(PK)',
|
HIST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '이력 일련번호(PK)',
|
||||||
TRGET_UNIQ_ID VARCHAR(20) NOT NULL COMMENT '변경 대상자 고유식별자(ESNTL_ID)',
|
TRGET_UNIQ_ID VARCHAR(20) NOT NULL COMMENT '변경 대상자 고유식별자(ESNTL_ID)',
|
||||||
TRGET_ID VARCHAR(20) NULL COMMENT '변경 대상자 계정ID(참고용)',
|
TRGET_ID VARCHAR(20) NULL COMMENT '변경 대상자 계정ID(참고용)',
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
-- [#6] AUTHOR_HIST_DOWNLOAD_HISTORY : 권한이력 다운로드 증빙(사유 필수).
|
-- [#6] AUTHOR_HIST_DOWNLOAD_HISTORY : 권한이력 다운로드 증빙(사유 필수).
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
|
|
||||||
CREATE TABLE AUTHOR_CHANGE_REQUEST (
|
CREATE TABLE IF NOT EXISTS AUTHOR_CHANGE_REQUEST (
|
||||||
REQUEST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '신청 일련번호(PK)',
|
REQUEST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '신청 일련번호(PK)',
|
||||||
TRGET_UNIQ_ID VARCHAR(20) NOT NULL COMMENT '권한변경 대상자 고유식별자(ESNTL_ID)',
|
TRGET_UNIQ_ID VARCHAR(20) NOT NULL COMMENT '권한변경 대상자 고유식별자(ESNTL_ID)',
|
||||||
TRGET_ID VARCHAR(20) NULL COMMENT '권한변경 대상자 계정ID(참고용)',
|
TRGET_ID VARCHAR(20) NULL COMMENT '권한변경 대상자 계정ID(참고용)',
|
||||||
@ -32,7 +32,7 @@ CREATE TABLE AUTHOR_CHANGE_REQUEST (
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
COMMENT='권한 변경 신청·승인 워크플로(개인정보보호 SFR-001 #3)';
|
COMMENT='권한 변경 신청·승인 워크플로(개인정보보호 SFR-001 #3)';
|
||||||
|
|
||||||
CREATE TABLE AUTHOR_HIST_DOWNLOAD_HISTORY (
|
CREATE TABLE IF NOT EXISTS AUTHOR_HIST_DOWNLOAD_HISTORY (
|
||||||
DWLD_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '다운로드 일련번호(PK)',
|
DWLD_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '다운로드 일련번호(PK)',
|
||||||
DOWNLOADER_ID VARCHAR(20) NOT NULL COMMENT '다운로드자 계정ID',
|
DOWNLOADER_ID VARCHAR(20) NOT NULL COMMENT '다운로드자 계정ID',
|
||||||
DOWNLOADER_UNIQ_ID VARCHAR(20) NULL COMMENT '다운로드자 고유식별자',
|
DOWNLOADER_UNIQ_ID VARCHAR(20) NULL COMMENT '다운로드자 고유식별자',
|
||||||
|
|||||||
@ -13,13 +13,34 @@
|
|||||||
-- 기존 selectDiapauseList(일반회원 join COMTNGNRLMBER) 흐름 무영향.
|
-- 기존 selectDiapauseList(일반회원 join COMTNGNRLMBER) 흐름 무영향.
|
||||||
-- 취급자 휴면 행은 'EMP'로 적재하여 별도 select/notify 경로로 처리한다.
|
-- 취급자 휴면 행은 'EMP'로 적재하여 별도 select/notify 경로로 처리한다.
|
||||||
--
|
--
|
||||||
-- [멱등] 재실행 안전을 위해 컬럼 존재 시 무시(수동 확인 후 실행).
|
-- [멱등] MySQL 8 은 ADD COLUMN/INDEX IF NOT EXISTS 를 지원하지 않으므로
|
||||||
|
-- information_schema 로 존재를 확인해 없을 때만 실행한다(재실행 안전).
|
||||||
|
-- MySQL 8 / MariaDB 10.x 공통 동작.
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
|
|
||||||
ALTER TABLE COMTNDIAPAUSEMBER
|
SET @ddl := IF(
|
||||||
ADD COLUMN MBER_TY_CODE CHAR(3) NOT NULL DEFAULT 'GNR'
|
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
COMMENT '회원유형 GNR:일반회원 EMP:개인정보취급자';
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'COMTNDIAPAUSEMBER'
|
||||||
|
AND COLUMN_NAME = 'MBER_TY_CODE') > 0,
|
||||||
|
'SELECT ''SKIP: COMTNDIAPAUSEMBER.MBER_TY_CODE already exists'' AS msg',
|
||||||
|
'ALTER TABLE COMTNDIAPAUSEMBER
|
||||||
|
ADD COLUMN MBER_TY_CODE CHAR(3) NOT NULL DEFAULT ''GNR''
|
||||||
|
COMMENT ''회원유형 GNR:일반회원 EMP:개인정보취급자'''
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
-- 조회 성능(취급자/일반 분리 조회) 보조 인덱스
|
-- 조회 성능(취급자/일반 분리 조회) 보조 인덱스
|
||||||
ALTER TABLE COMTNDIAPAUSEMBER
|
SET @ddl := IF(
|
||||||
ADD INDEX IX_DIAPAUSE_MBERTY (MBER_TY_CODE, DIAPAUSE_STAT);
|
(SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'COMTNDIAPAUSEMBER'
|
||||||
|
AND INDEX_NAME = 'IX_DIAPAUSE_MBERTY') > 0,
|
||||||
|
'SELECT ''SKIP: IX_DIAPAUSE_MBERTY already exists'' AS msg',
|
||||||
|
'ALTER TABLE COMTNDIAPAUSEMBER
|
||||||
|
ADD INDEX IX_DIAPAUSE_MBERTY (MBER_TY_CODE, DIAPAUSE_STAT)'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- [검증] SHOW CREATE TABLE COMTNDIAPAUSEMBER;
|
||||||
|
-- [롤백] ALTER TABLE COMTNDIAPAUSEMBER DROP INDEX IX_DIAPAUSE_MBERTY, DROP COLUMN MBER_TY_CODE;
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
-- 이 테이블은 INSERT/SELECT 만 수행(위·변조 방지, UPDATE/DELETE 미정의).
|
-- 이 테이블은 INSERT/SELECT 만 수행(위·변조 방지, UPDATE/DELETE 미정의).
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
|
|
||||||
CREATE TABLE PDAL_DOWNLOAD_HISTORY (
|
CREATE TABLE IF NOT EXISTS PDAL_DOWNLOAD_HISTORY (
|
||||||
DL_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '다운로드 이력 일련번호(PK)',
|
DL_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '다운로드 이력 일련번호(PK)',
|
||||||
DOWNLOADER_ID VARCHAR(20) NOT NULL COMMENT '다운로드자 계정ID',
|
DOWNLOADER_ID VARCHAR(20) NOT NULL COMMENT '다운로드자 계정ID',
|
||||||
DOWNLOAD_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '다운로드 일시',
|
DOWNLOAD_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '다운로드 일시',
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
-- [화면입력] INSPECTOR_ID/INSPECT_RESULT/ACTN_MATTER 는 배치가 아닌 점검자가 화면(#48)에서 입력.
|
-- [화면입력] INSPECTOR_ID/INSPECT_RESULT/ACTN_MATTER 는 배치가 아닌 점검자가 화면(#48)에서 입력.
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
|
|
||||||
CREATE TABLE PDAL_INSPECTION_REPORT (
|
CREATE TABLE IF NOT EXISTS PDAL_INSPECTION_REPORT (
|
||||||
RPT_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '리포트 일련번호(PK)',
|
RPT_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '리포트 일련번호(PK)',
|
||||||
RPT_YM CHAR(6) NOT NULL COMMENT '점검 대상 연월(전월, yyyyMM)',
|
RPT_YM CHAR(6) NOT NULL COMMENT '점검 대상 연월(전월, yyyyMM)',
|
||||||
PERIOD_FROM DATETIME NOT NULL COMMENT '집계 시작일시',
|
PERIOD_FROM DATETIME NOT NULL COMMENT '집계 시작일시',
|
||||||
|
|||||||
@ -0,0 +1,72 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- 업무유형 매핑 테이블 확장 : 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;
|
||||||
@ -18,7 +18,7 @@
|
|||||||
-- ※ URL 패턴 기반 수집이 필요하면(서블릿 필터형) 동일 테이블에 URL_PTTRN 컬럼을 추가해 확장.
|
-- ※ URL 패턴 기반 수집이 필요하면(서블릿 필터형) 동일 테이블에 URL_PTTRN 컬럼을 추가해 확장.
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
|
|
||||||
CREATE TABLE PDAL_JOB_MAPPING (
|
CREATE TABLE IF NOT EXISTS PDAL_JOB_MAPPING (
|
||||||
MAPPING_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '매핑 일련번호(PK)',
|
MAPPING_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '매핑 일련번호(PK)',
|
||||||
TARGET_CLASS VARCHAR(200) NOT NULL COMMENT '대상 서비스 impl 단순 클래스명 또는 FQN',
|
TARGET_CLASS VARCHAR(200) NOT NULL COMMENT '대상 서비스 impl 단순 클래스명 또는 FQN',
|
||||||
METHOD_PTTRN VARCHAR(100) NOT NULL DEFAULT '*' COMMENT '메서드명 접두 매칭(또는 * 전체)',
|
METHOD_PTTRN VARCHAR(100) NOT NULL DEFAULT '*' COMMENT '메서드명 접두 매칭(또는 * 전체)',
|
||||||
@ -35,19 +35,34 @@ CREATE TABLE PDAL_JOB_MAPPING (
|
|||||||
|
|
||||||
-- ---------------------------------------------------------------------
|
-- ---------------------------------------------------------------------
|
||||||
-- 초기 매핑 시드 : 현행 개인정보 처리 서비스(#31 pointcut 대상과 일치)
|
-- 초기 매핑 시드 : 현행 개인정보 처리 서비스(#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) VALUES
|
INSERT INTO PDAL_JOB_MAPPING (TARGET_CLASS, METHOD_PTTRN, JOB_SE_CODE, JOB_SE_NM, DML_GUBUN, SORT_ORDR, RM)
|
||||||
('EgovMberManageServiceImpl', 'select', 'MBER_VIEW', '회원관리-조회', 'S', 10, '회원 개인정보 조회'),
|
SELECT s.TARGET_CLASS, s.METHOD_PTTRN, s.JOB_SE_CODE, s.JOB_SE_NM, s.DML_GUBUN, s.SORT_ORDR, s.RM
|
||||||
('EgovMberManageServiceImpl', 'insert', 'MBER_REG', '회원관리-등록', 'I', 10, NULL),
|
FROM (
|
||||||
('EgovMberManageServiceImpl', 'update', 'MBER_MOD', '회원관리-수정', 'U', 10, NULL),
|
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
|
||||||
('EgovMberManageServiceImpl', 'delete', 'MBER_DEL', '회원관리-삭제', 'D', 10, NULL),
|
UNION ALL SELECT 'EgovMberManageServiceImpl', 'insert', 'MBER_REG', '회원관리-등록', 'I', 10, NULL
|
||||||
('EgovMberManageServiceImpl', '*', 'MBER_ETC', '회원관리-기타', NULL, 90, '미분류 폴백'),
|
UNION ALL SELECT 'EgovMberManageServiceImpl', 'update', 'MBER_MOD', '회원관리-수정', 'U', 10, NULL
|
||||||
('EgovEntrprsManageServiceImpl', 'select', 'ENTRPRS_VIEW', '업체관리-조회', 'S', 10, '업체 담당자 개인정보 조회'),
|
UNION ALL SELECT 'EgovMberManageServiceImpl', 'delete', 'MBER_DEL', '회원관리-삭제', 'D', 10, NULL
|
||||||
('EgovEntrprsManageServiceImpl', 'insert', 'ENTRPRS_REG', '업체관리-등록', 'I', 10, NULL),
|
UNION ALL SELECT 'EgovMberManageServiceImpl', '*', 'MBER_ETC', '회원관리-기타', NULL, 90, '미분류 폴백'
|
||||||
('EgovEntrprsManageServiceImpl', 'update', 'ENTRPRS_MOD', '업체관리-수정', 'U', 10, NULL),
|
UNION ALL SELECT 'EgovEntrprsManageServiceImpl', 'select', 'ENTRPRS_VIEW', '업체관리-조회', 'S', 10, '업체 담당자 개인정보 조회'
|
||||||
('EgovEntrprsManageServiceImpl', 'delete', 'ENTRPRS_DEL', '업체관리-삭제', 'D', 10, NULL),
|
UNION ALL SELECT 'EgovEntrprsManageServiceImpl', 'insert', 'ENTRPRS_REG', '업체관리-등록', 'I', 10, NULL
|
||||||
('EgovEntrprsManageServiceImpl', '*', 'ENTRPRS_ETC', '업체관리-기타', NULL, 90, '미분류 폴백'),
|
UNION ALL SELECT 'EgovEntrprsManageServiceImpl', 'update', 'ENTRPRS_MOD', '업체관리-수정', 'U', 10, NULL
|
||||||
('MberSsnServiceImpl', '*', 'MBER_SSN', '회원 고유식별정보 처리', NULL, 20, '주민등록번호 등 고유식별정보'),
|
UNION ALL SELECT 'EgovEntrprsManageServiceImpl', 'delete', 'ENTRPRS_DEL', '업체관리-삭제', 'D', 10, NULL
|
||||||
('BizMberMergeServiceImpl', '*', 'MBER_MERGE', '회원 통합/이관', NULL, 30, NULL),
|
UNION ALL SELECT 'EgovEntrprsManageServiceImpl', '*', 'ENTRPRS_ETC', '업체관리-기타', NULL, 90, '미분류 폴백'
|
||||||
('BizTalentMberManageServiceImpl', '*', 'TALENT_MBER', '재능나눔 회원관리', NULL, 30, NULL);
|
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;
|
||||||
|
|||||||
@ -26,23 +26,62 @@
|
|||||||
-- 컬럼목록이 없어, 이 ALTER 후에는 컬럼 수 불일치(ERROR 1136)로 실패한다.
|
-- 컬럼목록이 없어, 이 ALTER 후에는 컬럼 수 불일치(ERROR 1136)로 실패한다.
|
||||||
-- 반드시 컬럼 명시 INSERT 로 교체된 sqlmap 과 함께 배포할 것.
|
-- 반드시 컬럼 명시 INSERT 로 교체된 sqlmap 과 함께 배포할 것.
|
||||||
--
|
--
|
||||||
-- [재실행] MySQL 8 은 ADD COLUMN IF NOT EXISTS 미지원 — 기존 환경 재실행 시
|
-- [재실행] MySQL 8 은 ADD COLUMN/KEY IF NOT EXISTS 미지원이라, 아래는 각 항목마다
|
||||||
-- 중복 오류(1060/1061)는 무시.
|
-- information_schema 로 존재를 확인해 없을 때만 실행한다(재실행 안전).
|
||||||
|
-- 컬럼/인덱스를 문장별로 분리했으므로 일부만 적용된 환경에서도 나머지가 채워진다.
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
|
|
||||||
ALTER TABLE LOGIN_HISTORY
|
SET @ddl := IF(
|
||||||
ADD COLUMN CONECT_IP VARCHAR(45) NULL COMMENT '접속 IP(#22-1, ClientIpResolver 취득)',
|
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
ADD COLUMN RESET_YN CHAR(1) NOT NULL DEFAULT 'N' COMMENT '잠금카운터 리셋여부 Y:성공으로 리셋됨 N:유효(#22-1)';
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'LOGIN_HISTORY' AND COLUMN_NAME = 'CONECT_IP') > 0,
|
||||||
|
'SELECT ''SKIP: LOGIN_HISTORY.CONECT_IP already exists'' AS msg',
|
||||||
|
'ALTER TABLE LOGIN_HISTORY
|
||||||
|
ADD COLUMN CONECT_IP VARCHAR(45) NULL COMMENT ''접속 IP(#22-1, ClientIpResolver 취득)'''
|
||||||
|
);
|
||||||
|
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 = 'LOGIN_HISTORY' AND COLUMN_NAME = 'RESET_YN') > 0,
|
||||||
|
'SELECT ''SKIP: LOGIN_HISTORY.RESET_YN already exists'' AS msg',
|
||||||
|
'ALTER TABLE LOGIN_HISTORY
|
||||||
|
ADD COLUMN RESET_YN CHAR(1) NOT NULL DEFAULT ''N''
|
||||||
|
COMMENT ''잠금카운터 리셋여부 Y:성공으로 리셋됨 N:유효(#22-1)'''
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
-- 조회화면 검색(계정·일자)·파기배치(일자) 대응 인덱스
|
-- 조회화면 검색(계정·일자)·파기배치(일자) 대응 인덱스
|
||||||
ALTER TABLE LOGIN_HISTORY
|
SET @ddl := IF(
|
||||||
ADD KEY IX_LOGIN_HISTORY_01 (CREATE_DATE),
|
(SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||||
ADD KEY IX_LOGIN_HISTORY_02 (ID, GUBUN, CREATE_DATE);
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'LOGIN_HISTORY' AND INDEX_NAME = 'IX_LOGIN_HISTORY_01') > 0,
|
||||||
|
'SELECT ''SKIP: IX_LOGIN_HISTORY_01 already exists'' AS msg',
|
||||||
|
'ALTER TABLE LOGIN_HISTORY ADD KEY IX_LOGIN_HISTORY_01 (CREATE_DATE)'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl := IF(
|
||||||
|
(SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'LOGIN_HISTORY' AND INDEX_NAME = 'IX_LOGIN_HISTORY_02') > 0,
|
||||||
|
'SELECT ''SKIP: IX_LOGIN_HISTORY_02 already exists'' AS msg',
|
||||||
|
'ALTER TABLE LOGIN_HISTORY ADD KEY IX_LOGIN_HISTORY_02 (ID, GUBUN, CREATE_DATE)'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
-- 보관기간 정책값(단일행 정책 테이블에 편입 — 하드코딩 금지 원칙)
|
-- 보관기간 정책값(단일행 정책 테이블에 편입 — 하드코딩 금지 원칙)
|
||||||
ALTER TABLE ACCOUNT_MNG_POLICY
|
SET @ddl := IF(
|
||||||
|
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'ACCOUNT_MNG_POLICY' AND COLUMN_NAME = 'LOGIN_HIST_KEEP_DAY') > 0,
|
||||||
|
'SELECT ''SKIP: ACCOUNT_MNG_POLICY.LOGIN_HIST_KEEP_DAY already exists'' AS msg',
|
||||||
|
'ALTER TABLE ACCOUNT_MNG_POLICY
|
||||||
ADD COLUMN LOGIN_HIST_KEEP_DAY INT NOT NULL DEFAULT 730
|
ADD COLUMN LOGIN_HIST_KEEP_DAY INT NOT NULL DEFAULT 730
|
||||||
COMMENT '인증 실패 이력 보관기간(일, 기본 730=2년, #22-1)';
|
COMMENT ''인증 실패 이력 보관기간(일, 기본 730=2년, #22-1)'''
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
-- [검증]
|
-- [검증]
|
||||||
-- SHOW CREATE TABLE LOGIN_HISTORY;
|
-- SHOW CREATE TABLE LOGIN_HISTORY;
|
||||||
|
|||||||
@ -27,7 +27,7 @@
|
|||||||
-- [분리 원칙] COMTNSYSLOG 의 7일 삭제정책은 본 테이블에 절대 적용하지 않는다.
|
-- [분리 원칙] COMTNSYSLOG 의 7일 삭제정책은 본 테이블에 절대 적용하지 않는다.
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
|
|
||||||
CREATE TABLE PERSONAL_DATA_ACCESS_LOG
|
CREATE TABLE IF NOT EXISTS PERSONAL_DATA_ACCESS_LOG
|
||||||
(
|
(
|
||||||
LOG_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '로그 일련번호',
|
LOG_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '로그 일련번호',
|
||||||
CONECT_ID VARCHAR(20) NOT NULL COMMENT '① 사용자 식별자(ID)',
|
CONECT_ID VARCHAR(20) NOT NULL COMMENT '① 사용자 식별자(ID)',
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
-- 파기는 개별 DELETE 가 아니라 파티션 단위 DROP 으로만 수행한다(개발항목 #37).
|
-- 파기는 개별 DELETE 가 아니라 파티션 단위 DROP 으로만 수행한다(개발항목 #37).
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
|
|
||||||
CREATE TABLE PDAL_PURGE_HISTORY
|
CREATE TABLE IF NOT EXISTS PDAL_PURGE_HISTORY
|
||||||
(
|
(
|
||||||
PURGE_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '이력 일련번호',
|
PURGE_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '이력 일련번호',
|
||||||
PARTITION_NAME VARCHAR(64) NOT NULL COMMENT '대상 파티션명(예: p202607)',
|
PARTITION_NAME VARCHAR(64) NOT NULL COMMENT '대상 파티션명(예: p202607)',
|
||||||
|
|||||||
@ -0,0 +1,64 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- 관리자 메뉴 등록 : 개인정보 접속기록 조회·다운로드 (MENU_NO 9100044)
|
||||||
|
-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-004 / SER-002, 개발항목 #47)
|
||||||
|
-- 작성일: 2026-08-06 DB: MySQL 8 / MariaDB (스키마 csv)
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- [배치] 시스템관리(9100000) > 권한관리(9100030) 하위 10번째(MENU_ORDR 10).
|
||||||
|
-- PERSONAL_DATA_ACCESS_LOG(#30) 조회 화면이라 인증 실패 이력(9100039) 다음에 둔다.
|
||||||
|
--
|
||||||
|
-- [메뉴번호 9100044를 쓰는 이유] 권한관리(9100030) 하위의 다음 순번은 9100040 이지만
|
||||||
|
-- 9100040 은 이미 '게시판관리' 2depth 그룹(하위 9100041~9100043)이 선점하고 있다.
|
||||||
|
-- 9100044 는 미사용 번호임을 확인했다(9100044~9100070 구간에 9100070 만 존재).
|
||||||
|
-- 메뉴 정렬은 MENU_NO 가 아니라 MENU_ORDR(=10) 을 따르므로 노출 순서에 문제 없다.
|
||||||
|
--
|
||||||
|
-- ★★ 이 DML만으로는 좌측메뉴에 안 뜬다 — left.jsp 수정 동반 배포 필수 ★★
|
||||||
|
-- src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp 는 메뉴번호 하드코딩 JSP라
|
||||||
|
-- ① 권한관리(9100030) 블록에 9100044 <c:when> 분기
|
||||||
|
-- ② 시스템관리/권한관리 상위 하이라이트 조건(param.page eq '9100044') 4곳
|
||||||
|
-- 이 함께 배포돼야 렌더된다. (소스에는 이미 반영 완료)
|
||||||
|
--
|
||||||
|
-- [재실행 안전] 전 구간 NOT EXISTS 가드 → 반복 실행 무해.
|
||||||
|
-- [노출권한] 기존 권한관리 메뉴와 동일하게 IS_AUTHENTICATED_FULLY, ROLE_ADMIN 2종.
|
||||||
|
-- [한글 리터럴 주의] git-bash + mysql.exe 로 이 파일을 stdin 파이프 실행하면 한글이
|
||||||
|
-- 이중 인코딩으로 손상된다(기존 실측). 적용 후 반드시 HEX(MENU_NM) 로 검증하고,
|
||||||
|
-- 손상 시 CONVERT(UNHEX('<UTF-8 hex>') USING utf8mb4) UPDATE 로 교정할 것.
|
||||||
|
-- [Oracle] 대상 운영 DB가 MySQL(csv)이라 Oracle 쌍 미작성.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- ── [1] 프로그램(URL) 등록 (COMTNMENUINFO.PROGRM_FILE_NM → COMTNPROGRMLIST FK, 메뉴보다 먼저) ──
|
||||||
|
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
|
||||||
|
SELECT * FROM (
|
||||||
|
SELECT 'AccessLogList' AS PROGRM_FILE_NM, '/mnt/sec/accessLog/' AS PROGRM_STRE_PATH,
|
||||||
|
'개인정보 접속기록' AS PROGRM_KOREAN_NM,
|
||||||
|
'개인정보 접속기록 조회·다운로드(SFR-004 #47)' AS PROGRM_DC,
|
||||||
|
'/mnt/sec/accessLog/listView.do?page=9100044' AS URL
|
||||||
|
) seed
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST p WHERE p.PROGRM_FILE_NM = seed.PROGRM_FILE_NM);
|
||||||
|
|
||||||
|
-- ── [2] 메뉴 등록 : 권한관리(9100030) 하위, MENU_ORDR 10 ──
|
||||||
|
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, 'AccessLogList' AS PROGRM_FILE_NM, 9100044 AS MENU_NO,
|
||||||
|
9100030 AS UPPER_MENU_NO, 10 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] 권한↔메뉴 노출 매핑 (2권한, FK 안전) ──
|
||||||
|
INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE)
|
||||||
|
SELECT 9100044, g.ac
|
||||||
|
FROM (SELECT 'IS_AUTHENTICATED_FULLY' AS ac UNION ALL SELECT 'ROLE_ADMIN') g
|
||||||
|
WHERE g.ac IN (SELECT AUTHOR_CODE FROM COMTNAUTHORINFO) -- FK 안전
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x
|
||||||
|
WHERE x.MENU_NO = 9100044 AND x.AUTHOR_CODE = g.ac);
|
||||||
|
|
||||||
|
-- ── [검증] 등록결과 + 한글 손상 여부(HEX 가 EA/EB/EC 대로 시작해야 정상) ──
|
||||||
|
-- SELECT m.MENU_NO, m.UPPER_MENU_NO, m.MENU_ORDR, m.MENU_NM, HEX(m.MENU_NM), 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 = 9100044;
|
||||||
|
|
||||||
|
-- ── [롤백] (역순) ──
|
||||||
|
-- DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO = 9100044;
|
||||||
|
-- DELETE FROM COMTNMENUINFO WHERE MENU_NO = 9100044;
|
||||||
|
-- DELETE FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM = 'AccessLogList';
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- 관리자 메뉴 등록 : 계정 관리 정책 설정 화면(#13)
|
||||||
|
-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-002 / 개발항목 #13)
|
||||||
|
-- 대상 DB : MySQL 5.7 (스키마 csv) 작성일: 2026-07-28
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- [목적] /mnt/sec/accountPolicy/policyView.do (계정 관리 정책 설정)를
|
||||||
|
-- 관리자 좌측/상단 메뉴에 노출.
|
||||||
|
--
|
||||||
|
-- [배치] privacy.menu_dormant_status_insert_mysql.sql(#11, 9100037)의
|
||||||
|
-- "[향후 확장]" 주석에서 예고한 대로 9100038 부여:
|
||||||
|
-- 통합관리시스템(9000000) > 시스템관리(9100000) > 권한관리(9100030)
|
||||||
|
-- - 권한생성 (9100031, EgovAuthorList)
|
||||||
|
-- - 롤관리 (9100034, EgovRoleList)
|
||||||
|
-- - 권한 신청·승인 (9100035, AuthorRequestList)
|
||||||
|
-- - 권한 변경 이력 (9100036, AuthorHistoryList)
|
||||||
|
-- - 미사용계정 현황 (9100037, DormantStatusList)
|
||||||
|
-- - 계정 관리 정책 (9100038, AccountPolicySet) <-- 신규
|
||||||
|
--
|
||||||
|
-- [메뉴번호 근거] 9100038 은 레포 전체 미사용(주석 예고만 존재) 확인 완료.
|
||||||
|
-- SFR-002 형제 화면(#11, 9100037)이 이미 권한관리(9100030) 하위에
|
||||||
|
-- 배치되어 있어, 신규 그룹 분리 대신 동일 그룹 유지를 택했다.
|
||||||
|
-- (신규 그룹 9100050 안은 SFR-002 화면 2개가 서로 다른 그룹으로
|
||||||
|
-- 쪼개지고 상위 메뉴 레코드까지 추가로 필요해 반려)
|
||||||
|
--
|
||||||
|
-- [노출권한] IS_AUTHENTICATED_FULLY, ROLE_ADMIN (형제 메뉴들과 동일)
|
||||||
|
--
|
||||||
|
-- [메뉴 렌더 경로] LayoutMntController.left() -> MainMenu.selectMainMenuLeft
|
||||||
|
-- : COMTNMENUCREATDTLS(권한/메뉴) 없으면 화면에 안 뜸.
|
||||||
|
--
|
||||||
|
-- ** 이 DML만으로는 좌측메뉴에 안 뜬다 - left.jsp 수정 필수(반영 완료) **
|
||||||
|
-- src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp 는 메뉴번호 하드코딩 JSP라
|
||||||
|
-- 권한관리(9100030) 블록에 9100038 <c:when> 분기를 추가해야 렌더된다.
|
||||||
|
-- (2026-07-28 반영 완료: 9100038 <c:when> + 시스템관리/권한관리 하이라이트 조건 추가)
|
||||||
|
--
|
||||||
|
-- [선행] privacy.account_mng_policy_create_mysql.sql (ACCOUNT_MNG_POLICY + 시드 1행)
|
||||||
|
-- 이 먼저 적용되어 있어야 화면 진입 시 기본값이 표시된다.
|
||||||
|
--
|
||||||
|
-- [재실행 안전] 모든 INSERT NOT EXISTS 가드 -> 반복 실행 무해.
|
||||||
|
-- [Oracle] 대상 운영 DB가 MySQL(csv)이라 Oracle 쌍 미작성.
|
||||||
|
-- [롤백] 맨 아래 주석 참조.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- -------------------------------------------------------------
|
||||||
|
-- [1] 프로그램(URL) 등록 ※ 메뉴보다 먼저(FK: COMTNMENUINFO.PROGRM_FILE_NM -> COMTNPROGRMLIST)
|
||||||
|
-- -------------------------------------------------------------
|
||||||
|
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
|
||||||
|
SELECT 'AccountPolicySet', '/mnt/sec/accountPolicy/', '계정 관리 정책 설정',
|
||||||
|
'장기 미사용 계정 관리 정책 설정(SFR-002 개발항목 13)', '/mnt/sec/accountPolicy/policyView.do?page=9100038'
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM = 'AccountPolicySet');
|
||||||
|
|
||||||
|
-- -------------------------------------------------------------
|
||||||
|
-- [2] 메뉴 등록 : 권한관리(9100030) 하위, 미사용계정 현황(ord 7) 다음(ord 8)
|
||||||
|
-- -------------------------------------------------------------
|
||||||
|
INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM)
|
||||||
|
SELECT '계정 관리 정책', 'AccountPolicySet', 9100038, 9100030, 8, '장기 미사용 계정 관리 정책 설정', '/', '/'
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO WHERE MENU_NO = 9100038);
|
||||||
|
|
||||||
|
-- -------------------------------------------------------------
|
||||||
|
-- [3] 권한/메뉴 노출 매핑 (형제 메뉴들과 동일한 2개 권한)
|
||||||
|
-- -------------------------------------------------------------
|
||||||
|
INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE)
|
||||||
|
SELECT 9100038, g.ac
|
||||||
|
FROM (SELECT 'IS_AUTHENTICATED_FULLY' AS ac UNION ALL SELECT 'ROLE_ADMIN') g
|
||||||
|
WHERE g.ac IN (SELECT AUTHOR_CODE FROM COMTNAUTHORINFO) -- FK 안전
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x
|
||||||
|
WHERE x.MENU_NO = 9100038 AND x.AUTHOR_CODE = g.ac);
|
||||||
|
|
||||||
|
-- -------------------------------------------------------------
|
||||||
|
-- [검증]
|
||||||
|
-- SELECT m.MENU_NO,m.UPPER_MENU_NO,m.MENU_NM,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=9100038;
|
||||||
|
--
|
||||||
|
-- [롤백]
|
||||||
|
-- DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO=9100038;
|
||||||
|
-- DELETE FROM COMTNMENUINFO WHERE MENU_NO=9100038;
|
||||||
|
-- DELETE FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM='AccountPolicySet';
|
||||||
|
-- =====================================================================
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- 관리자 메뉴 등록 : 권한 이력 조회·검색·다운로드 화면(#6 화면 구현)
|
||||||
|
-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-001 / 개발항목 #6)
|
||||||
|
-- 대상 DB : MySQL 5.7 (스키마 csv) 작성일: 2026-07-28
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- [목적] /mnt/sec/authorHistory/listView.do (권한 변경 이력 조회·검색·다운로드)를
|
||||||
|
-- 관리자 좌측/상단 메뉴에 노출.
|
||||||
|
--
|
||||||
|
-- [배치] privacy.menu_author_request_insert_mysql.sql(#3, 9100035)의
|
||||||
|
-- "[향후 확장]" 주석에서 예고한 대로 9100036 부여:
|
||||||
|
-- 통합관리시스템(9000000) > 시스템관리(9100000) > 권한관리(9100030)
|
||||||
|
-- ├ 권한생성 (9100031, EgovAuthorList)
|
||||||
|
-- ├ 롤관리 (9100034, EgovRoleList)
|
||||||
|
-- ├ 권한 신청·승인 (9100035, AuthorRequestList)
|
||||||
|
-- └ 권한 변경 이력 (9100036, AuthorHistoryList) ← 신규
|
||||||
|
--
|
||||||
|
-- [노출권한] IS_AUTHENTICATED_FULLY, ROLE_ADMIN (형제 메뉴들과 동일)
|
||||||
|
--
|
||||||
|
-- [메뉴 렌더 경로] LayoutMntController.left() → MainMenu.selectMainMenuLeft
|
||||||
|
-- : COMTNMENUCREATDTLS(권한↔메뉴) 없으면 화면에 안 뜸.
|
||||||
|
--
|
||||||
|
-- ★★ 이 DML만으로는 좌측메뉴에 안 뜬다 — left.jsp 수정 필수(반영 완료) ★★
|
||||||
|
-- src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp 는 메뉴번호 하드코딩 JSP라
|
||||||
|
-- 권한관리(9100030) 블록에 9100036 <c:when> 분기를 추가해야 렌더된다.
|
||||||
|
-- (2026-07-28 반영 완료: 9100036 <c:when> + 시스템관리/권한관리 하이라이트 조건 추가)
|
||||||
|
--
|
||||||
|
-- [재실행 안전] 모든 INSERT NOT EXISTS 가드 → 반복 실행 무해.
|
||||||
|
-- [Oracle] 대상 운영 DB가 MySQL(csv)이라 Oracle 쌍 미작성.
|
||||||
|
-- [롤백] 맨 아래 주석 참조.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- ─────────────────────────────────────────────────────────────
|
||||||
|
-- [1] 프로그램(URL) 등록 ※ 메뉴보다 먼저(FK: COMTNMENUINFO.PROGRM_FILE_NM → COMTNPROGRMLIST)
|
||||||
|
-- ─────────────────────────────────────────────────────────────
|
||||||
|
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
|
||||||
|
SELECT 'AuthorHistoryList', '/mnt/sec/authorHistory/', '권한 변경 이력',
|
||||||
|
'권한 변경 이력 조회·검색·다운로드(SFR-001 #6)', '/mnt/sec/authorHistory/listView.do?page=9100036'
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM = 'AuthorHistoryList');
|
||||||
|
|
||||||
|
-- ─────────────────────────────────────────────────────────────
|
||||||
|
-- [2] 메뉴 등록 : 권한관리(9100030) 하위, 권한 신청·승인(ord 5) 다음(ord 6)
|
||||||
|
-- ─────────────────────────────────────────────────────────────
|
||||||
|
INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM)
|
||||||
|
SELECT '권한 변경 이력', 'AuthorHistoryList', 9100036, 9100030, 6, '권한 변경 이력 조회·검색·다운로드', '/', '/'
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO WHERE MENU_NO = 9100036);
|
||||||
|
|
||||||
|
-- ─────────────────────────────────────────────────────────────
|
||||||
|
-- [3] 권한↔메뉴 노출 매핑 (형제 메뉴들과 동일한 2개 권한)
|
||||||
|
-- ─────────────────────────────────────────────────────────────
|
||||||
|
INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE)
|
||||||
|
SELECT 9100036, g.ac
|
||||||
|
FROM (SELECT 'IS_AUTHENTICATED_FULLY' AS ac UNION ALL SELECT 'ROLE_ADMIN') g
|
||||||
|
WHERE g.ac IN (SELECT AUTHOR_CODE FROM COMTNAUTHORINFO) -- FK 안전
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x
|
||||||
|
WHERE x.MENU_NO = 9100036 AND x.AUTHOR_CODE = g.ac);
|
||||||
|
|
||||||
|
-- ─────────────────────────────────────────────────────────────
|
||||||
|
-- [검증]
|
||||||
|
-- SELECT m.MENU_NO,m.UPPER_MENU_NO,m.MENU_NM,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=9100036;
|
||||||
|
--
|
||||||
|
-- [롤백]
|
||||||
|
-- DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO=9100036;
|
||||||
|
-- DELETE FROM COMTNMENUINFO WHERE MENU_NO=9100036;
|
||||||
|
-- DELETE FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM='AuthorHistoryList';
|
||||||
|
-- =====================================================================
|
||||||
@ -0,0 +1,74 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- 관리자 메뉴 등록 : 장기 미사용 계정 현황 조회 화면(#11)
|
||||||
|
-- 문화품앗이 개인정보보호 기능개선 사업 (SFR-002 / 개발항목 #11)
|
||||||
|
-- 대상 DB : MySQL 5.7 (스키마 csv) 작성일: 2026-07-28
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- [목적] /mnt/sec/dormantStatus/listView.do (장기 미사용 계정 현황 조회)를
|
||||||
|
-- 관리자 좌측/상단 메뉴에 노출.
|
||||||
|
--
|
||||||
|
-- [배치] privacy.menu_author_history_insert_mysql.sql(#6, 9100036)에 이어
|
||||||
|
-- 9100037 부여:
|
||||||
|
-- 통합관리시스템(9000000) > 시스템관리(9100000) > 권한관리(9100030)
|
||||||
|
-- ├ 권한생성 (9100031, EgovAuthorList)
|
||||||
|
-- ├ 롤관리 (9100034, EgovRoleList)
|
||||||
|
-- ├ 권한 신청·승인 (9100035, AuthorRequestList)
|
||||||
|
-- ├ 권한 변경 이력 (9100036, AuthorHistoryList)
|
||||||
|
-- └ 미사용계정 현황 (9100037, DormantStatusList) ← 신규
|
||||||
|
--
|
||||||
|
-- [노출권한] IS_AUTHENTICATED_FULLY, ROLE_ADMIN (형제 메뉴들과 동일)
|
||||||
|
--
|
||||||
|
-- [메뉴 렌더 경로] LayoutMntController.left() → MainMenu.selectMainMenuLeft
|
||||||
|
-- : COMTNMENUCREATDTLS(권한↔메뉴) 없으면 화면에 안 뜸.
|
||||||
|
--
|
||||||
|
-- ★★ 이 DML만으로는 좌측메뉴에 안 뜬다 — left.jsp 수정 필수(반영 완료) ★★
|
||||||
|
-- src/main/webapp/WEB-INF/jsp/site/mnt/left.jsp 는 메뉴번호 하드코딩 JSP라
|
||||||
|
-- 권한관리(9100030) 블록에 9100037 <c:when> 분기를 추가해야 렌더된다.
|
||||||
|
-- (2026-07-28 반영 완료: 9100037 <c:when> + 시스템관리/권한관리 하이라이트 조건 추가)
|
||||||
|
--
|
||||||
|
-- [재실행 안전] 모든 INSERT NOT EXISTS 가드 → 반복 실행 무해.
|
||||||
|
-- [Oracle] 대상 운영 DB가 MySQL(csv)이라 Oracle 쌍 미작성.
|
||||||
|
-- [롤백] 맨 아래 주석 참조.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- ─────────────────────────────────────────────────────────────
|
||||||
|
-- [1] 프로그램(URL) 등록 ※ 메뉴보다 먼저(FK: COMTNMENUINFO.PROGRM_FILE_NM → COMTNPROGRMLIST)
|
||||||
|
-- ─────────────────────────────────────────────────────────────
|
||||||
|
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
|
||||||
|
SELECT 'DormantStatusList', '/mnt/sec/dormantStatus/', '장기 미사용계정 현황',
|
||||||
|
'장기 미사용 계정 현황 조회(SFR-002 #11)', '/mnt/sec/dormantStatus/listView.do?page=9100037'
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM = 'DormantStatusList');
|
||||||
|
|
||||||
|
-- ─────────────────────────────────────────────────────────────
|
||||||
|
-- [2] 메뉴 등록 : 권한관리(9100030) 하위, 권한 변경 이력(ord 6) 다음(ord 7)
|
||||||
|
-- ─────────────────────────────────────────────────────────────
|
||||||
|
INSERT INTO COMTNMENUINFO (MENU_NM, PROGRM_FILE_NM, MENU_NO, UPPER_MENU_NO, MENU_ORDR, MENU_DC, RELATE_IMAGE_PATH, RELATE_IMAGE_NM)
|
||||||
|
SELECT '미사용계정 현황', 'DormantStatusList', 9100037, 9100030, 7, '장기 미사용 계정 현황 조회', '/', '/'
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM COMTNMENUINFO WHERE MENU_NO = 9100037);
|
||||||
|
|
||||||
|
-- ─────────────────────────────────────────────────────────────
|
||||||
|
-- [3] 권한↔메뉴 노출 매핑 (형제 메뉴들과 동일한 2개 권한)
|
||||||
|
-- ─────────────────────────────────────────────────────────────
|
||||||
|
INSERT INTO COMTNMENUCREATDTLS (MENU_NO, AUTHOR_CODE)
|
||||||
|
SELECT 9100037, g.ac
|
||||||
|
FROM (SELECT 'IS_AUTHENTICATED_FULLY' AS ac UNION ALL SELECT 'ROLE_ADMIN') g
|
||||||
|
WHERE g.ac IN (SELECT AUTHOR_CODE FROM COMTNAUTHORINFO) -- FK 안전
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x
|
||||||
|
WHERE x.MENU_NO = 9100037 AND x.AUTHOR_CODE = g.ac);
|
||||||
|
|
||||||
|
-- ─────────────────────────────────────────────────────────────
|
||||||
|
-- [검증]
|
||||||
|
-- SELECT m.MENU_NO,m.UPPER_MENU_NO,m.MENU_NM,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=9100037;
|
||||||
|
--
|
||||||
|
-- [롤백]
|
||||||
|
-- DELETE FROM COMTNMENUCREATDTLS WHERE MENU_NO=9100037;
|
||||||
|
-- DELETE FROM COMTNMENUINFO WHERE MENU_NO=9100037;
|
||||||
|
-- DELETE FROM COMTNPROGRMLIST WHERE PROGRM_FILE_NM='DormantStatusList';
|
||||||
|
--
|
||||||
|
-- [향후 확장] 계정 관리 정책(#13) 화면도 동일 패턴으로 9100038 부여하여
|
||||||
|
-- 같은 권한관리/시스템관리 그룹에 추가.
|
||||||
|
-- =====================================================================
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- 관리자 메뉴 등록 : 업무유형 매핑 관리 (개인정보보호 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';
|
||||||
@ -3,12 +3,14 @@
|
|||||||
-- 대상 DB : MySQL 5.7 / MariaDB (스키마 csv)
|
-- 대상 DB : MySQL 5.7 / MariaDB (스키마 csv)
|
||||||
-- 구성 : [PART 1] DDL(테이블 생성·변경) → [PART 2] DML(기준/시드 데이터)
|
-- 구성 : [PART 1] DDL(테이블 생성·변경) → [PART 2] DML(기준/시드 데이터)
|
||||||
-- → [PART 3] 관리자 메뉴 등록 DML(좌측메뉴 노출, COMTNPROGRMLIST/
|
-- → [PART 3] 관리자 메뉴 등록 DML(좌측메뉴 노출, COMTNPROGRMLIST/
|
||||||
-- COMTNMENUINFO/COMTNMENUCREATDTLS, 9100035~9100039)
|
-- COMTNMENUINFO/COMTNMENUCREATDTLS, 9100035~9100039 + 9100044 + 9100045)
|
||||||
-- 실행순서: 반드시 PART 1 → PART 2 → PART 3 순서로 실행
|
-- 실행순서: 반드시 PART 1 → PART 2 → PART 3 순서로 실행
|
||||||
-- (테이블 생성 → 시드 적재 → 메뉴 등록. 메뉴는 FK로 프로그램 등록을 먼저 요구)
|
-- (테이블 생성 → 시드 적재 → 메뉴 등록. 메뉴는 FK로 프로그램 등록을 먼저 요구)
|
||||||
-- 주의 : 신규 설치용. 기존 테이블이 있는 환경 재실행 시 CREATE 중복 오류 가능
|
-- 재실행 : **전 구간 재실행 안전(멱등)** — PART 1 은 CREATE TABLE IF NOT EXISTS 와
|
||||||
-- (IF NOT EXISTS 미지정 테이블). 그런 환경은 PART 2/PART 3(모두 NOT EXISTS
|
-- information_schema 존재 확인 후 실행되는 ALTER(컬럼/인덱스), PART 2·3 은
|
||||||
-- 가드로 재실행 안전)만 실행하세요.
|
-- NOT EXISTS 가드를 쓴다. 이미 적용된 항목은 'SKIP: ...' 메시지만 출력하고
|
||||||
|
-- 넘어가므로, 기존 환경에도 이 파일 하나만 그대로 실행하면 된다.
|
||||||
|
-- (2026-08-05 이전 버전은 재실행 시 1050/1060/1061 오류가 났다)
|
||||||
-- ★★ PART 3 적용만으로는 좌측메뉴에 안 뜬다 — left.jsp 하드코딩 <c:when> 분기가
|
-- ★★ PART 3 적용만으로는 좌측메뉴에 안 뜬다 — left.jsp 하드코딩 <c:when> 분기가
|
||||||
-- 반드시 함께 배포돼야 한다(소스에는 이미 반영됨). 상세는 PART 3 헤더 참조. ★★
|
-- 반드시 함께 배포돼야 한다(소스에는 이미 반영됨). 상세는 PART 3 헤더 참조. ★★
|
||||||
-- #####################################################################
|
-- #####################################################################
|
||||||
@ -66,7 +68,7 @@ CREATE TABLE IF NOT EXISTS ACCOUNT_MNG_POLICY (
|
|||||||
-- [무결성] 애플리케이션 계층에 UPDATE/DELETE statement 미정의(INSERT/SELECT 한정).
|
-- [무결성] 애플리케이션 계층에 UPDATE/DELETE statement 미정의(INSERT/SELECT 한정).
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
|
|
||||||
CREATE TABLE ACCOUNT_UNLOCK_HISTORY (
|
CREATE TABLE IF NOT EXISTS ACCOUNT_UNLOCK_HISTORY (
|
||||||
UNLOCK_HIST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '해제 이력 일련번호(PK)',
|
UNLOCK_HIST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '해제 이력 일련번호(PK)',
|
||||||
TRGET_ID VARCHAR(50) NULL COMMENT '해제 대상 계정 ID(전체해제 시 NULL 가능)',
|
TRGET_ID VARCHAR(50) NULL COMMENT '해제 대상 계정 ID(전체해제 시 NULL 가능)',
|
||||||
TRGET_SE VARCHAR(10) NULL COMMENT '대상 회원구분 GNR/GRO/ENT/USR',
|
TRGET_SE VARCHAR(10) NULL COMMENT '대상 회원구분 GNR/GRO/ENT/USR',
|
||||||
@ -115,7 +117,7 @@ UPDATE LOGIN_LOCK_COUNT SET LOCK_COUNT = 5;
|
|||||||
-- 애플리케이션 계층에 UPDATE statement 미정의(INSERT/SELECT/파기 DELETE 한정).
|
-- 애플리케이션 계층에 UPDATE statement 미정의(INSERT/SELECT/파기 DELETE 한정).
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
|
|
||||||
CREATE TABLE AUTHOR_CHANGE_HISTORY (
|
CREATE TABLE IF NOT EXISTS AUTHOR_CHANGE_HISTORY (
|
||||||
HIST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '이력 일련번호(PK)',
|
HIST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '이력 일련번호(PK)',
|
||||||
TRGET_UNIQ_ID VARCHAR(20) NOT NULL COMMENT '변경 대상자 고유식별자(ESNTL_ID)',
|
TRGET_UNIQ_ID VARCHAR(20) NOT NULL COMMENT '변경 대상자 고유식별자(ESNTL_ID)',
|
||||||
TRGET_ID VARCHAR(20) NULL COMMENT '변경 대상자 계정ID(참고용)',
|
TRGET_ID VARCHAR(20) NULL COMMENT '변경 대상자 계정ID(참고용)',
|
||||||
@ -150,7 +152,7 @@ CREATE TABLE AUTHOR_CHANGE_HISTORY (
|
|||||||
-- [#6] AUTHOR_HIST_DOWNLOAD_HISTORY : 권한이력 다운로드 증빙(사유 필수).
|
-- [#6] AUTHOR_HIST_DOWNLOAD_HISTORY : 권한이력 다운로드 증빙(사유 필수).
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
|
|
||||||
CREATE TABLE AUTHOR_CHANGE_REQUEST (
|
CREATE TABLE IF NOT EXISTS AUTHOR_CHANGE_REQUEST (
|
||||||
REQUEST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '신청 일련번호(PK)',
|
REQUEST_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '신청 일련번호(PK)',
|
||||||
TRGET_UNIQ_ID VARCHAR(20) NOT NULL COMMENT '권한변경 대상자 고유식별자(ESNTL_ID)',
|
TRGET_UNIQ_ID VARCHAR(20) NOT NULL COMMENT '권한변경 대상자 고유식별자(ESNTL_ID)',
|
||||||
TRGET_ID VARCHAR(20) NULL COMMENT '권한변경 대상자 계정ID(참고용)',
|
TRGET_ID VARCHAR(20) NULL COMMENT '권한변경 대상자 계정ID(참고용)',
|
||||||
@ -172,7 +174,7 @@ CREATE TABLE AUTHOR_CHANGE_REQUEST (
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
COMMENT='권한 변경 신청·승인 워크플로(개인정보보호 SFR-001 #3)';
|
COMMENT='권한 변경 신청·승인 워크플로(개인정보보호 SFR-001 #3)';
|
||||||
|
|
||||||
CREATE TABLE AUTHOR_HIST_DOWNLOAD_HISTORY (
|
CREATE TABLE IF NOT EXISTS AUTHOR_HIST_DOWNLOAD_HISTORY (
|
||||||
DWLD_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '다운로드 일련번호(PK)',
|
DWLD_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '다운로드 일련번호(PK)',
|
||||||
DOWNLOADER_ID VARCHAR(20) NOT NULL COMMENT '다운로드자 계정ID',
|
DOWNLOADER_ID VARCHAR(20) NOT NULL COMMENT '다운로드자 계정ID',
|
||||||
DOWNLOADER_UNIQ_ID VARCHAR(20) NULL COMMENT '다운로드자 고유식별자',
|
DOWNLOADER_UNIQ_ID VARCHAR(20) NULL COMMENT '다운로드자 고유식별자',
|
||||||
@ -209,7 +211,7 @@ CREATE TABLE AUTHOR_HIST_DOWNLOAD_HISTORY (
|
|||||||
-- ※ URL 패턴 기반 수집이 필요하면(서블릿 필터형) 동일 테이블에 URL_PTTRN 컬럼을 추가해 확장.
|
-- ※ URL 패턴 기반 수집이 필요하면(서블릿 필터형) 동일 테이블에 URL_PTTRN 컬럼을 추가해 확장.
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
|
|
||||||
CREATE TABLE PDAL_JOB_MAPPING (
|
CREATE TABLE IF NOT EXISTS PDAL_JOB_MAPPING (
|
||||||
MAPPING_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '매핑 일련번호(PK)',
|
MAPPING_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '매핑 일련번호(PK)',
|
||||||
TARGET_CLASS VARCHAR(200) NOT NULL COMMENT '대상 서비스 impl 단순 클래스명 또는 FQN',
|
TARGET_CLASS VARCHAR(200) NOT NULL COMMENT '대상 서비스 impl 단순 클래스명 또는 FQN',
|
||||||
METHOD_PTTRN VARCHAR(100) NOT NULL DEFAULT '*' COMMENT '메서드명 접두 매칭(또는 * 전체)',
|
METHOD_PTTRN VARCHAR(100) NOT NULL DEFAULT '*' COMMENT '메서드명 접두 매칭(또는 * 전체)',
|
||||||
@ -227,8 +229,64 @@ CREATE TABLE PDAL_JOB_MAPPING (
|
|||||||
-- ---------------------------------------------------------------------
|
-- ---------------------------------------------------------------------
|
||||||
-- 초기 매핑 시드 : 현행 개인정보 처리 서비스(#31 pointcut 대상과 일치)
|
-- 초기 매핑 시드 : 현행 개인정보 처리 서비스(#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]
|
||||||
-- ─────────────────────────────────────────────────────────────
|
-- ─────────────────────────────────────────────────────────────
|
||||||
@ -245,7 +303,7 @@ CREATE TABLE PDAL_JOB_MAPPING (
|
|||||||
-- 이 테이블은 INSERT/SELECT 만 수행(위·변조 방지, UPDATE/DELETE 미정의).
|
-- 이 테이블은 INSERT/SELECT 만 수행(위·변조 방지, UPDATE/DELETE 미정의).
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
|
|
||||||
CREATE TABLE PDAL_DOWNLOAD_HISTORY (
|
CREATE TABLE IF NOT EXISTS PDAL_DOWNLOAD_HISTORY (
|
||||||
DL_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '다운로드 이력 일련번호(PK)',
|
DL_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '다운로드 이력 일련번호(PK)',
|
||||||
DOWNLOADER_ID VARCHAR(20) NOT NULL COMMENT '다운로드자 계정ID',
|
DOWNLOADER_ID VARCHAR(20) NOT NULL COMMENT '다운로드자 계정ID',
|
||||||
DOWNLOAD_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '다운로드 일시',
|
DOWNLOAD_DT DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '다운로드 일시',
|
||||||
@ -280,7 +338,7 @@ CREATE TABLE PDAL_DOWNLOAD_HISTORY (
|
|||||||
-- [화면입력] INSPECTOR_ID/INSPECT_RESULT/ACTN_MATTER 는 배치가 아닌 점검자가 화면(#48)에서 입력.
|
-- [화면입력] INSPECTOR_ID/INSPECT_RESULT/ACTN_MATTER 는 배치가 아닌 점검자가 화면(#48)에서 입력.
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
|
|
||||||
CREATE TABLE PDAL_INSPECTION_REPORT (
|
CREATE TABLE IF NOT EXISTS PDAL_INSPECTION_REPORT (
|
||||||
RPT_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '리포트 일련번호(PK)',
|
RPT_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '리포트 일련번호(PK)',
|
||||||
RPT_YM CHAR(6) NOT NULL COMMENT '점검 대상 연월(전월, yyyyMM)',
|
RPT_YM CHAR(6) NOT NULL COMMENT '점검 대상 연월(전월, yyyyMM)',
|
||||||
PERIOD_FROM DATETIME NOT NULL COMMENT '집계 시작일시',
|
PERIOD_FROM DATETIME NOT NULL COMMENT '집계 시작일시',
|
||||||
@ -336,7 +394,7 @@ CREATE TABLE PDAL_INSPECTION_REPORT (
|
|||||||
-- [분리 원칙] COMTNSYSLOG 의 7일 삭제정책은 본 테이블에 절대 적용하지 않는다.
|
-- [분리 원칙] COMTNSYSLOG 의 7일 삭제정책은 본 테이블에 절대 적용하지 않는다.
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
|
|
||||||
CREATE TABLE PERSONAL_DATA_ACCESS_LOG
|
CREATE TABLE IF NOT EXISTS PERSONAL_DATA_ACCESS_LOG
|
||||||
(
|
(
|
||||||
LOG_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '로그 일련번호',
|
LOG_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '로그 일련번호',
|
||||||
CONECT_ID VARCHAR(20) NOT NULL COMMENT '① 사용자 식별자(ID)',
|
CONECT_ID VARCHAR(20) NOT NULL COMMENT '① 사용자 식별자(ID)',
|
||||||
@ -404,7 +462,7 @@ PARTITION BY RANGE COLUMNS(CONECT_DT)
|
|||||||
-- 파기는 개별 DELETE 가 아니라 파티션 단위 DROP 으로만 수행한다(개발항목 #37).
|
-- 파기는 개별 DELETE 가 아니라 파티션 단위 DROP 으로만 수행한다(개발항목 #37).
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
|
|
||||||
CREATE TABLE PDAL_PURGE_HISTORY
|
CREATE TABLE IF NOT EXISTS PDAL_PURGE_HISTORY
|
||||||
(
|
(
|
||||||
PURGE_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '이력 일련번호',
|
PURGE_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '이력 일련번호',
|
||||||
PARTITION_NAME VARCHAR(64) NOT NULL COMMENT '대상 파티션명(예: p202607)',
|
PARTITION_NAME VARCHAR(64) NOT NULL COMMENT '대상 파티션명(예: p202607)',
|
||||||
@ -465,16 +523,31 @@ CREATE TABLE IF NOT EXISTS OTP_DEVICE (
|
|||||||
-- 기존 selectDiapauseList(일반회원 join COMTNGNRLMBER) 흐름 무영향.
|
-- 기존 selectDiapauseList(일반회원 join COMTNGNRLMBER) 흐름 무영향.
|
||||||
-- 취급자 휴면 행은 'EMP'로 적재하여 별도 select/notify 경로로 처리한다.
|
-- 취급자 휴면 행은 'EMP'로 적재하여 별도 select/notify 경로로 처리한다.
|
||||||
--
|
--
|
||||||
-- [멱등] 재실행 안전을 위해 컬럼 존재 시 무시(수동 확인 후 실행).
|
-- [멱등] MySQL 8 은 ADD COLUMN/INDEX IF NOT EXISTS 미지원 → information_schema 로
|
||||||
|
-- 존재를 확인해 없을 때만 실행한다(재실행 안전, MySQL 8 / MariaDB 공통).
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
|
|
||||||
ALTER TABLE COMTNDIAPAUSEMBER
|
SET @ddl := IF(
|
||||||
ADD COLUMN MBER_TY_CODE CHAR(3) NOT NULL DEFAULT 'GNR'
|
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
COMMENT '회원유형 GNR:일반회원 EMP:개인정보취급자';
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'COMTNDIAPAUSEMBER' AND COLUMN_NAME = 'MBER_TY_CODE') > 0,
|
||||||
|
'SELECT ''SKIP: COMTNDIAPAUSEMBER.MBER_TY_CODE already exists'' AS msg',
|
||||||
|
'ALTER TABLE COMTNDIAPAUSEMBER
|
||||||
|
ADD COLUMN MBER_TY_CODE CHAR(3) NOT NULL DEFAULT ''GNR''
|
||||||
|
COMMENT ''회원유형 GNR:일반회원 EMP:개인정보취급자'''
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
-- 조회 성능(취급자/일반 분리 조회) 보조 인덱스
|
-- 조회 성능(취급자/일반 분리 조회) 보조 인덱스
|
||||||
ALTER TABLE COMTNDIAPAUSEMBER
|
SET @ddl := IF(
|
||||||
ADD INDEX IX_DIAPAUSE_MBERTY (MBER_TY_CODE, DIAPAUSE_STAT);
|
(SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'COMTNDIAPAUSEMBER' AND INDEX_NAME = 'IX_DIAPAUSE_MBERTY') > 0,
|
||||||
|
'SELECT ''SKIP: IX_DIAPAUSE_MBERTY already exists'' AS msg',
|
||||||
|
'ALTER TABLE COMTNDIAPAUSEMBER
|
||||||
|
ADD INDEX IX_DIAPAUSE_MBERTY (MBER_TY_CODE, DIAPAUSE_STAT)'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
|
||||||
-- ─────────────────────────────────────────────────────────────
|
-- ─────────────────────────────────────────────────────────────
|
||||||
@ -490,13 +563,20 @@ ALTER TABLE COMTNDIAPAUSEMBER
|
|||||||
-- 직후에는 아무것도 파기되지 않으며, 관리자가 계정 관리 정책 화면(#13)에서
|
-- 직후에는 아무것도 파기되지 않으며, 관리자가 계정 관리 정책 화면(#13)에서
|
||||||
-- '사용'으로 바꿔야 시작된다. 파기 기준일은 기존 DELETE_PRESERVE_DAY 를 사용.
|
-- '사용'으로 바꿔야 시작된다. 파기 기준일은 기존 DELETE_PRESERVE_DAY 를 사용.
|
||||||
--
|
--
|
||||||
-- ※ 신규 설치(위 PART 1 에서 ACCOUNT_MNG_POLICY 를 막 생성한 경우)에는 이미
|
-- ※ 신규 설치(위 PART 1 에서 ACCOUNT_MNG_POLICY 를 막 생성한 경우)에는 컬럼이
|
||||||
-- 컬럼이 없으므로 이 ALTER 가 필요하다. 기존 환경 재실행 시 중복 오류는 무시.
|
-- 없으므로 추가되고, 기존 환경 재실행 시에는 존재 확인 후 건너뛴다(재실행 안전).
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
|
|
||||||
ALTER TABLE ACCOUNT_MNG_POLICY
|
SET @ddl := IF(
|
||||||
ADD COLUMN PURGE_USE_YN CHAR(1) NOT NULL DEFAULT 'N'
|
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
COMMENT '장기 미사용 계정 파기 배치 사용여부 Y:사용 N:미사용(#12)';
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'ACCOUNT_MNG_POLICY' AND COLUMN_NAME = 'PURGE_USE_YN') > 0,
|
||||||
|
'SELECT ''SKIP: ACCOUNT_MNG_POLICY.PURGE_USE_YN already exists'' AS msg',
|
||||||
|
'ALTER TABLE ACCOUNT_MNG_POLICY
|
||||||
|
ADD COLUMN PURGE_USE_YN CHAR(1) NOT NULL DEFAULT ''N''
|
||||||
|
COMMENT ''장기 미사용 계정 파기 배치 사용여부 Y:사용 N:미사용(#12)'''
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
|
||||||
-- ─────────────────────────────────────────────────────────────
|
-- ─────────────────────────────────────────────────────────────
|
||||||
@ -525,7 +605,7 @@ ALTER TABLE ACCOUNT_MNG_POLICY
|
|||||||
-- 처리라 기본 비활성(@Scheduled 주석) 상태다. 보존기간 확정·운영협의 후 활성화.
|
-- 처리라 기본 비활성(@Scheduled 주석) 상태다. 보존기간 확정·운영협의 후 활성화.
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
|
|
||||||
CREATE TABLE ACCOUNT_DELETE_HISTORY (
|
CREATE TABLE IF NOT EXISTS ACCOUNT_DELETE_HISTORY (
|
||||||
DEL_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '파기 일련번호(PK)',
|
DEL_ID BIGINT NOT NULL AUTO_INCREMENT COMMENT '파기 일련번호(PK)',
|
||||||
MBER_ID VARCHAR(32) NOT NULL COMMENT '파기 대상 계정ID(MBER_ID/EMPLYR_ID)',
|
MBER_ID VARCHAR(32) NOT NULL COMMENT '파기 대상 계정ID(MBER_ID/EMPLYR_ID)',
|
||||||
MBER_TY_CODE CHAR(3) NOT NULL COMMENT '회원유형 GNR:일반회원 EMP:개인정보취급자',
|
MBER_TY_CODE CHAR(3) NOT NULL COMMENT '회원유형 GNR:일반회원 EMP:개인정보취급자',
|
||||||
@ -557,20 +637,59 @@ CREATE TABLE ACCOUNT_DELETE_HISTORY (
|
|||||||
-- 컬럼 추가 후 ERROR 1136(Column count doesn't match)로 실패한다.
|
-- 컬럼 추가 후 ERROR 1136(Column count doesn't match)로 실패한다.
|
||||||
-- 컬럼 명시 INSERT 로 교체된 EgovLoginUsr_SQL_Mysql.xml 과 함께 배포할 것.
|
-- 컬럼 명시 INSERT 로 교체된 EgovLoginUsr_SQL_Mysql.xml 과 함께 배포할 것.
|
||||||
--
|
--
|
||||||
-- [재실행] MySQL 8 은 ADD COLUMN IF NOT EXISTS 미지원 — 중복 오류(1060/1061) 무시.
|
-- [재실행] MySQL 8 은 ADD COLUMN/KEY IF NOT EXISTS 미지원 → 항목마다 information_schema
|
||||||
|
-- 로 존재를 확인해 없을 때만 실행한다(일부만 적용된 환경도 나머지가 채워짐).
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
|
|
||||||
ALTER TABLE LOGIN_HISTORY
|
SET @ddl := IF(
|
||||||
ADD COLUMN CONECT_IP VARCHAR(45) NULL COMMENT '접속 IP(#22-1, ClientIpResolver 취득)',
|
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
ADD COLUMN RESET_YN CHAR(1) NOT NULL DEFAULT 'N' COMMENT '잠금카운터 리셋여부 Y:성공으로 리셋됨 N:유효(#22-1)';
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'LOGIN_HISTORY' AND COLUMN_NAME = 'CONECT_IP') > 0,
|
||||||
|
'SELECT ''SKIP: LOGIN_HISTORY.CONECT_IP already exists'' AS msg',
|
||||||
|
'ALTER TABLE LOGIN_HISTORY
|
||||||
|
ADD COLUMN CONECT_IP VARCHAR(45) NULL COMMENT ''접속 IP(#22-1, ClientIpResolver 취득)'''
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
ALTER TABLE LOGIN_HISTORY
|
SET @ddl := IF(
|
||||||
ADD KEY IX_LOGIN_HISTORY_01 (CREATE_DATE),
|
(SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
ADD KEY IX_LOGIN_HISTORY_02 (ID, GUBUN, CREATE_DATE);
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'LOGIN_HISTORY' AND COLUMN_NAME = 'RESET_YN') > 0,
|
||||||
|
'SELECT ''SKIP: LOGIN_HISTORY.RESET_YN already exists'' AS msg',
|
||||||
|
'ALTER TABLE LOGIN_HISTORY
|
||||||
|
ADD COLUMN RESET_YN CHAR(1) NOT NULL DEFAULT ''N''
|
||||||
|
COMMENT ''잠금카운터 리셋여부 Y:성공으로 리셋됨 N:유효(#22-1)'''
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
ALTER TABLE ACCOUNT_MNG_POLICY
|
SET @ddl := IF(
|
||||||
|
(SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'LOGIN_HISTORY' AND INDEX_NAME = 'IX_LOGIN_HISTORY_01') > 0,
|
||||||
|
'SELECT ''SKIP: IX_LOGIN_HISTORY_01 already exists'' AS msg',
|
||||||
|
'ALTER TABLE LOGIN_HISTORY ADD KEY IX_LOGIN_HISTORY_01 (CREATE_DATE)'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @ddl := IF(
|
||||||
|
(SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'LOGIN_HISTORY' AND INDEX_NAME = 'IX_LOGIN_HISTORY_02') > 0,
|
||||||
|
'SELECT ''SKIP: IX_LOGIN_HISTORY_02 already exists'' AS msg',
|
||||||
|
'ALTER TABLE LOGIN_HISTORY ADD KEY IX_LOGIN_HISTORY_02 (ID, GUBUN, CREATE_DATE)'
|
||||||
|
);
|
||||||
|
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 = 'ACCOUNT_MNG_POLICY' AND COLUMN_NAME = 'LOGIN_HIST_KEEP_DAY') > 0,
|
||||||
|
'SELECT ''SKIP: ACCOUNT_MNG_POLICY.LOGIN_HIST_KEEP_DAY already exists'' AS msg',
|
||||||
|
'ALTER TABLE ACCOUNT_MNG_POLICY
|
||||||
ADD COLUMN LOGIN_HIST_KEEP_DAY INT NOT NULL DEFAULT 730
|
ADD COLUMN LOGIN_HIST_KEEP_DAY INT NOT NULL DEFAULT 730
|
||||||
COMMENT '인증 실패 이력 보관기간(일, 기본 730=2년, #22-1)';
|
COMMENT ''인증 실패 이력 보관기간(일, 기본 730=2년, #22-1)'''
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
@ -685,11 +804,65 @@ SELECT 9100039, g.ac
|
|||||||
AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x
|
AND NOT EXISTS (SELECT 1 FROM COMTNMENUCREATDTLS x
|
||||||
WHERE x.MENU_NO = 9100039 AND x.AUTHOR_CODE = g.ac);
|
WHERE x.MENU_NO = 9100039 AND x.AUTHOR_CODE = g.ac);
|
||||||
|
|
||||||
|
-- ── [5] 개인정보 접속기록 조회·다운로드 메뉴(9100044) — SFR-004 #47, 2026-08-06 추가 ──
|
||||||
|
-- 원본: src/script/mysql/dml/privacy.menu_access_log_insert_mysql.sql
|
||||||
|
-- ★ left.jsp 9100044 <c:when> 분기 + 하이라이트 조건 4곳 동반 배포 필수(위와 동일 제약)
|
||||||
|
-- ※ 9100040~9100043 은 '게시판관리' 그룹이 선점 → 미사용 번호 9100044 사용(MENU_ORDR 10)
|
||||||
|
INSERT INTO COMTNPROGRMLIST (PROGRM_FILE_NM, PROGRM_STRE_PATH, PROGRM_KOREAN_NM, PROGRM_DC, URL)
|
||||||
|
SELECT * FROM (
|
||||||
|
SELECT 'AccessLogList' AS PROGRM_FILE_NM, '/mnt/sec/accessLog/' AS PROGRM_STRE_PATH,
|
||||||
|
'개인정보 접속기록' AS PROGRM_KOREAN_NM, '개인정보 접속기록 조회·다운로드(SFR-004 #47)' AS PROGRM_DC,
|
||||||
|
'/mnt/sec/accessLog/listView.do?page=9100044' 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, 'AccessLogList' AS PROGRM_FILE_NM, 9100044 AS MENU_NO,
|
||||||
|
9100030 AS UPPER_MENU_NO, 10 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 9100044, 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 = 9100044 AND x.AUTHOR_CODE = g.ac);
|
||||||
|
|
||||||
|
-- ── [6] 업무유형 매핑 관리 메뉴(9100045) — SFR-004 #32-1, 2026-08-07 추가 ──
|
||||||
|
-- 원본: src/script/mysql/dml/privacy.menu_job_mapping_insert_mysql.sql
|
||||||
|
-- ★ left.jsp 9100045 <c:when> 분기 + 하이라이트 조건 4곳 동반 배포 필수(위와 동일 제약)
|
||||||
|
-- ※ 접속기록(9100044)의 '수행업무' 분류 기준을 편집하는 화면이라 바로 다음(MENU_ORDR 11)에 배치
|
||||||
|
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);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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 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 BETWEEN 9100035 AND 9100039 ORDER BY m.MENU_NO;
|
-- WHERE m.MENU_NO IN (9100035,9100036,9100037,9100038,9100039,9100044,9100045) 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;
|
||||||
|
|||||||
@ -102,7 +102,7 @@ fork 3개를 병렬로 투입해 **자체 완결적·파일 충돌 없는 기반
|
|||||||
| **A** | 휴면계정(umt·휴면 sqlmap 단독) | #8 MariaDB 휴면배치 statement 정비 | Oracle에만 있던 배치 statement MariaDB 추가 | ✅ 완료 |
|
| **A** | 휴면계정(umt·휴면 sqlmap 단독) | #8 MariaDB 휴면배치 statement 정비 | Oracle에만 있던 배치 statement MariaDB 추가 | ✅ 완료 |
|
||||||
| **B** | 접속기록조회·점검(자기sqlmap·스케줄러 단독) | #41 접속기록 조회·검색 개선 | 접속자ID 등 신규 검색조건(현행 LIKE+날짜뿐) | ✅ 완료 |
|
| **B** | 접속기록조회·점검(자기sqlmap·스케줄러 단독) | #41 접속기록 조회·검색 개선 | 접속자ID 등 신규 검색조건(현행 LIKE+날짜뿐) | ✅ 완료 |
|
||||||
| **B** | 접속기록조회·점검(자기sqlmap·스케줄러 단독) | #43 정기점검 리포트 생성 배치 | 월1회 이상징후·무결성·처리현황·다운로드 집계 | ✅ 완료 |
|
| **B** | 접속기록조회·점검(자기sqlmap·스케줄러 단독) | #43 정기점검 리포트 생성 배치 | 월1회 이상징후·무결성·처리현황·다운로드 집계 | ✅ 완료 |
|
||||||
| **C** | 접근통제마무리(로그인·servlet.xml 단독) | #23 계정 열거 방지 | 실패 메시지 존재여부 무관 일관화(#22 핸드오프 해소) | ✅ 완료 |
|
| **C** | 접근통제마무리(로그인·servlet.xml 단독) | #23 계정 열거 방지 | 실패 메시지 존재여부 무관 일관화(#22 핸드오프 해소) | ✅ 진행중 |
|
||||||
| **C** | 접근통제마무리(로그인·servlet.xml 단독) | #26 인터셉터 3단 순서 확정 | 인증→SecondFactor→Authority 순서 확정·검증 | ✅ 완료 |
|
| **C** | 접근통제마무리(로그인·servlet.xml 단독) | #26 인터셉터 3단 순서 확정 | 인증→SecondFactor→Authority 순서 확정·검증 | ✅ 완료 |
|
||||||
|
|
||||||
- 2026-07-21 5차 착수: Fork A/B/C 병렬 재투입, 대상 6개 🟡 진행중 전환.
|
- 2026-07-21 5차 착수: Fork A/B/C 병렬 재투입, 대상 6개 🟡 진행중 전환.
|
||||||
@ -135,7 +135,10 @@ fork 3개를 병렬로 투입해 **자체 완결적·파일 충돌 없는 기반
|
|||||||
| **B** | 처리기록확장(aspect·AOP context 단독) | #36 처리기록 적재 범위 확장 | 2개 Controller→전 처리지점, TRGET_ID 규약(sqlmap 무변경) | ✅ 완료 |
|
| **B** | 처리기록확장(aspect·AOP context 단독) | #36 처리기록 적재 범위 확장 | 2개 Controller→전 처리지점, TRGET_ID 규약(sqlmap 무변경) | ✅ 완료 |
|
||||||
| **C** | 점검결과보관(report·등록sqlmap 단독) | #44 점검결과 입력·보관(백엔드) | 점검자·일시·결과·조치사항 입력→PDAL_INSPECTION_REPORT | ✅ 완료 |
|
| **C** | 점검결과보관(report·등록sqlmap 단독) | #44 점검결과 입력·보관(백엔드) | 점검자·일시·결과·조치사항 입력→PDAL_INSPECTION_REPORT | ✅ 완료 |
|
||||||
|
|
||||||
- 2026-08-05 **#22-1 완료** (인증 실패 이력 IP 기록·보관기간·조회화면): #22 이설로 적재는 살아났으나 IP 미기록 + 성공 시 당일 이력 DELETE 로 흔적이 소멸하던 것을 보완. 신규 6파일(`privacy/lock/LoginFailHistory{VO,DAO,Service,ServiceImpl,Controller}`, `PrivacyLoginFailHistory_SQL_Mysql/Oracle`) + JSP 1 + DDL/메뉴DML 2 + 기존 8파일 수정. **핵심 함정: `insertFailHistory`가 컬럼목록 없는 `VALUES(3값)` 이라 DDL 단독 배포 시 ERROR 1136 으로 로그인 실패 적재가 깨진다 — sqlmap 동반 배포 필수.** 잠금 동작은 `selectFailCount`가 `RESET_YN='N'`만 세도록 해 종전과 동일. 후속: javac 미검증(사용자 요청으로 컴파일 생략), 배포 DB에 ALTER 적용 필요.
|
- 2026-08-05 **#22-1 완료** (인증 실패 이력 IP 기록·보관기간·조회화면): #22 이설로 적재는 살아났으나 IP 미기록 + 성공 시 당일 이력 DELETE 로 흔적이 소멸하던 것을 보완. 신규 6파일(`privacy/lock/LoginFailHistory{VO,DAO,Service,ServiceImpl,Controller}`, `PrivacyLoginFailHistory_SQL_Mysql/Oracle`) + JSP 1 + DDL/메뉴DML 2 + 기존 8파일 수정. **핵심 함정: `insertFailHistory`가 컬럼목록 없는 `VALUES(3값)` 이라 DDL 단독 배포 시 ERROR 1136 으로 로그인 실패 적재가 깨진다 — sqlmap 동반 배포 필수.** 잠금 동작은 `selectFailCount`가 `RESET_YN='N'`만 세도록 해 종전과 동일.
|
||||||
|
- 2026-08-05 **#22-1 로컬 활성화 완료**: JDK8 `mvn compile` **BUILD SUCCESS**(종전 "javac 미검증" 해소). 로컬 DB에 `privacy.install_mysql.sql` 전체 적용(privacy 7테이블 + `LOGIN_HISTORY.CONECT_IP`/`RESET_YN`+인덱스 2종 + `LOGIN_HIST_KEEP_DAY=730` + 메뉴 9100035~9100039, 한글 `HEX()` 무결성 확인). 파일 델타 19건 반영 후 Tomcat 재기동 → 컨텍스트 정상 기동(= iBatis sqlMap 등록·파싱 성공), `listView.do` 302→로그인(대조군 404로 매핑 실재 확인). 산출물: `_workspace/deploy/20260805_1800/{DELTA.md,APPLY_LOG.md,sql/}`.
|
||||||
|
- **함정 2건 실측:** ① 로컬 3307 도커(`urpai-mysql`)가 2026-07-30 재생성되며 `csv` DB 소실 → 실제 csv 는 MySQL80 **3306**, 배포본 `globals-live.properties` 포트 변경으로 복구. ② 배포본 `sql-map-config` 의 `BizApplyMileage`(소문자) ↔ 소스 `BizApplyMIleage`(대문자) drift 실재 — 통째 교체 시 전체 404, 신규 1줄만 병합해야 한다.
|
||||||
|
- **미완:** 개발서버 미반영(SSH 권한 차단). 관리자 계정이 없어 좌측메뉴 노출·목록 렌더·IP 적재·`RESET_YN` 소프트 리셋 실동작 검증은 미수행.
|
||||||
- 2026-07-21 7차 착수: Fork A/B/C 병렬 재투입, 대상 6개 🟡 진행중 전환.
|
- 2026-07-21 7차 착수: Fork A/B/C 병렬 재투입, 대상 6개 🟡 진행중 전환.
|
||||||
- 2026-07-21 **Fork B(7차) 완료** (#36): 처리기록 pointcut 5→16 서비스 확장 + TRGET_ID 식별자 추출 규약(본문 미적재, fail-safe). 실측 확인 후 ✅(XML well-formed·헬퍼 배선·sqlmap 미변경).
|
- 2026-07-21 **Fork B(7차) 완료** (#36): 처리기록 pointcut 5→16 서비스 확장 + TRGET_ID 식별자 추출 규약(본문 미적재, fail-safe). 실측 확인 후 ✅(XML well-formed·헬퍼 배선·sqlmap 미변경).
|
||||||
- 2026-07-21 **Fork C(7차) 완료** (#44): 점검결과 입력·보관(InspectionResult 3파일+INSPECT_DT+statement 3종). 실측 확인 후 ✅(미입력 차단·양쪽 XML well-formed·sql-map-config 미변경). **접속기록 영역(30~44) 15/15 완결.** 배포된 테이블엔 INSPECT_DT ALTER 필요(핸드오프).
|
- 2026-07-21 **Fork C(7차) 완료** (#44): 점검결과 입력·보관(InspectionResult 3파일+INSPECT_DT+statement 3종). 실측 확인 후 ✅(미입력 차단·양쪽 XML well-formed·sql-map-config 미변경). **접속기록 영역(30~44) 15/15 완결.** 배포된 테이블엔 INSPECT_DT ALTER 필요(핸드오프).
|
||||||
@ -228,20 +231,20 @@ fork 3개를 병렬로 투입해 **자체 완결적·파일 충돌 없는 기반
|
|||||||
|
|
||||||
| # | 개발항목 | 상태 | 소스 실측 / 비고 |
|
| # | 개발항목 | 상태 | 소스 실측 / 비고 |
|
||||||
|:--:|----------|:----:|------------------|
|
|:--:|----------|:----:|------------------|
|
||||||
| 22 | 인증 실패 이력 활성 경로 이설 | ✅ 진행중 | [ Fork A] AS-IS: 실패이력/잠금이 `EgovSpringSecurityLoginFilter:466`(조건부)에만 존재. `EgovLoginController.actionLogin` 재작성(잠금선확인→성공시 이력정리→실패시 조건부 적재/잠금), **기존 LOGIN_HISTORY/LOGIN_LOCK_COUNT·서비스 7메서드 재사용(신규 statement 0)**, 전구간 fail-safe. 서비스/VO 메서드 실재 검증 |
|
| 22 | 인증 실패 이력 활성 경로 이설 | ✅ 진짜 완료 | [ Fork A] AS-IS: 실패이력/잠금이 `EgovSpringSecurityLoginFilter:466`(조건부)에만 존재. `EgovLoginController.actionLogin` 재작성(잠금선확인→성공시 이력정리→실패시 조건부 적재/잠금), **기존 LOGIN_HISTORY/LOGIN_LOCK_COUNT·서비스 7메서드 재사용(신규 statement 0)**, 전구간 fail-safe. 서비스/VO 메서드 실재 검증 |
|
||||||
| 22-1 | 인증 실패 이력 IP 기록·보관기간·조회화면 | ✅ 완료 | AS-IS: `LOGIN_HISTORY`가 ID/CREATE_DATE/GUBUN 3컬럼뿐 → IP 미기록, 성공 시 당일 이력 DELETE 로 흔적 소멸. **DDL** `CONECT_IP`(VARCHAR45)+`RESET_YN`+인덱스 2종, `ACCOUNT_MNG_POLICY.LOGIN_HIST_KEEP_DAY`(기본 730). **적재** `insertFailHistory` 컬럼 명시 전환(★DDL 필수 동반 — 종전 무컬럼 VALUES 는 ERROR 1136)+`ClientIpResolver` IP 세팅, 성공 시 DELETE→`RESET_YN='Y'` 소프트 리셋(`selectFailCount` 가 'N'만 카운트 → 잠금 동작 불변). **화면** `/mnt/sec/loginFailHistory/listView.do`(계정·구분·IP·기간·리셋여부 검색+페이징), 메뉴 9100039. **배치** `purgeExpiredLoginFailHistoryScheduler` 매일 03:30, 1회 5,000건 상한 |
|
| 22-1 | 인증 실패 이력 IP 기록·보관기간·조회화면 | ✅ 진짜 완료 | AS-IS: `LOGIN_HISTORY`가 ID/CREATE_DATE/GUBUN 3컬럼뿐 → IP 미기록, 성공 시 당일 이력 DELETE 로 흔적 소멸. **DDL** `CONECT_IP`(VARCHAR45)+`RESET_YN`+인덱스 2종, `ACCOUNT_MNG_POLICY.LOGIN_HIST_KEEP_DAY`(기본 730). **적재** `insertFailHistory` 컬럼 명시 전환(★DDL 필수 동반 — 종전 무컬럼 VALUES 는 ERROR 1136)+`ClientIpResolver` IP 세팅, 성공 시 DELETE→`RESET_YN='Y'` 소프트 리셋(`selectFailCount` 가 'N'만 카운트 → 잠금 동작 불변). **화면** `/mnt/sec/loginFailHistory/listView.do`(계정·구분·IP·기간·리셋여부 검색+페이징), 메뉴 9100039. **배치** `purgeExpiredLoginFailHistoryScheduler` 매일 03:30, 1회 5,000건 상한 |
|
||||||
| 23 | 계정 열거 방지 처리 | ✅ 완료 | [Fork C] `EgovLoginController:238` 조건부 "현재 N회" 문구 제거→존재/미존재·실패횟수 무관 단일 `fail.common.login`. 실패이력·잠금은 내부 유지, 응답 노출 제거. 잠금안내만 의도적 예외 |
|
| 23 | 계정 열거 방지 처리 | ✅ 진짜 완료 | [Fork C] `EgovLoginController:238` 조건부 "현재 N회" 문구 제거→존재/미존재·실패횟수 무관 단일 `fail.common.login`. 실패이력·잠금은 내부 유지, 응답 노출 제거. 잠금안내만 의도적 예외 |
|
||||||
| 24 | 계정 잠금 정책 정비(10회→5회) | ✅ 완료 | [Fork A] 임계값 `LOGIN_LOCK_COUNT` UPDATE=5 + 소스 fallback `DEFAULT_LOCK_THRESHOLD=5`. 해제화면 기존확인. **신규 `ACCOUNT_UNLOCK_HISTORY`**(DDL+VO+DAO+sqlmap, sql-map-config 등록)에 해제이력 훅(해제자+ClientIpResolver IP, fail-safe). 사유입력은 화면 후속 |
|
| 24 | 계정 잠금 정책 정비(10회→5회) | ✅ 진짜 완료 | [Fork A] 임계값 `LOGIN_LOCK_COUNT` UPDATE=5 + 소스 fallback `DEFAULT_LOCK_THRESHOLD=5`. 해제화면 기존확인. **신규 `ACCOUNT_UNLOCK_HISTORY`**(DDL+VO+DAO+sqlmap, sql-map-config 등록)에 해제이력 훅(해제자+ClientIpResolver IP, fail-safe). 사유입력은 화면 후속 |
|
||||||
| 25 | 세션 타임아웃 조정(60→30분) | ✅ 완료 | [Fork C] `web.xml:218` `<session-timeout>30</session-timeout>`(60→30) + 근거주석. 실사용 webapp/web.xml 확정. 관리자 설정화면은 후속 |
|
| 25 | 세션 타임아웃 조정(60→30분) | ✅ 진짜 완료 | [Fork C] `web.xml:218` `<session-timeout>30</session-timeout>`(60→30) + 근거주석. 실사용 webapp/web.xml 확정. 관리자 설정화면은 후속 |
|
||||||
| 26 | 인터셉터 3단 판정 순서 확정 | ✅ 완료 | [Fork C] `egov-com-servlet.xml:66` 리스트 순서 확정 ①authenticInterceptor(비활성)→②secondFactor→③authority + 근거주석. ①재활성 시 위치 유지 핸드오프. xml well-formed |
|
| 26 | 인터셉터 3단 판정 순서 확정 | ✅ 진짜 완료 | [Fork C] `egov-com-servlet.xml:66` 리스트 순서 확정 ①authenticInterceptor(비활성)→②secondFactor→③authority + 근거주석. ①재활성 시 위치 유지 핸드오프. xml well-formed |
|
||||||
|
|
||||||
#### 6) 인증정보 보호 (SER-003)
|
#### 6) 인증정보 보호 (SER-003)
|
||||||
|
|
||||||
| # | 개발항목 | 상태 | 소스 실측 / 비고 |
|
| # | 개발항목 | 상태 | 소스 실측 / 비고 |
|
||||||
|:--:|----------|:----:|------------------|
|
|:--:|----------|:----:|------------------|
|
||||||
| 27 | 비밀번호 해시 알고리즘 점검 | ✅ 완료 | [Fork C] 점검노트 `개인정보보호_비밀번호해시_점검노트.md`. 알고리즘 **SHA-256(기준 충족)**, 단 운영경로 **무솔트·무반복** + `BizMberFrtManageController.java:553` 해시값 로그출력 3건 보완 권고 |
|
| 27 | 비밀번호 해시 알고리즘 점검 | ✅ 진짜 완료 | [Fork C] 점검노트 `개인정보보호_비밀번호해시_점검노트.md`. 알고리즘 **SHA-256(기준 충족)**, 단 운영경로 **무솔트·무반복** + `BizMberFrtManageController.java:553` 해시값 로그출력 3건 보완 권고 |
|
||||||
| 28 | 신규 인증정보 저장정책 적용 | ✅ 완료 | [Fork C→오케스트레이터] 점검노트 `개인정보보호_인증정보저장정책_점검노트.md`. OTP AES암호화저장(로그노출0)·SMS 미저장설계·CERT DN만 → **3종 준수, 위반 0건**. OTP 프로비저닝 URI 화면(#53) 취급 주의 1건 |
|
| 28 | 신규 인증정보 저장정책 적용 | ✅ 서버에서 적용해야함 | [Fork C→오케스트레이터] 점검노트 `개인정보보호_인증정보저장정책_점검노트.md`. OTP AES암호화저장(로그노출0)·SMS 미저장설계·CERT DN만 → **3종 준수, 위반 0건**. OTP 프로비저닝 URI 화면(#53) 취급 주의 1건, 지금 열쇠가 소스코드 안에 글자 그대로 적혀 있습니다 arko-otp-dev-2026 이렇게요 열쇠를 소스에서 빼서, 서버에만 두는 것 |
|
||||||
| 29 | 2차 인증 화면 개인정보 마스킹 | ✅ 완료 | [Fork C] `cmm/privacy/util/MaskingUtil.java`. maskPhone(010-****-1234)/maskEmail(ho**@)/maskName(홍*동)/범용 mask. null·형식 방어, javac+self-check 통과. #52 화면에서 사용 |
|
| 29 | 2차 인증 화면 개인정보 마스킹 | ✅ SMS인증 | [Fork C] `cmm/privacy/util/MaskingUtil.java`. maskPhone(010-****-1234)/maskEmail(ho**@)/maskName(홍*동)/범용 mask. null·형식 방어, javac+self-check 통과. #52 화면에서 사용 |
|
||||||
|
|
||||||
### Ⅲ. 접속 및 처리기록 관리 (Ⅲ-4, SFR-004·DBR-001·SER-002) — arko-privacy-auditlog
|
### Ⅲ. 접속 및 처리기록 관리 (Ⅲ-4, SFR-004·DBR-001·SER-002) — arko-privacy-auditlog
|
||||||
|
|
||||||
@ -249,9 +252,10 @@ fork 3개를 병렬로 투입해 **자체 완결적·파일 충돌 없는 기반
|
|||||||
|
|
||||||
| # | 개발항목 | 상태 | 소스 실측 / 비고 |
|
| # | 개발항목 | 상태 | 소스 실측 / 비고 |
|
||||||
|:--:|----------|:----:|------------------|
|
|:--:|----------|:----:|------------------|
|
||||||
| 30 | PERSONAL_DATA_ACCESS_LOG 신규 테이블 | ✅ 완료 | [Fork B] `src/script/mysql/ddl/privacy.pdal_create_mysql.sql`. 기존 PERSONAL_DATA_DML_HISTORY 실측 승격 + HASH_VAL/PREV_HASH_VAL, PK(LOG_ID,CONECT_DT), 월 RANGE COLUMNS 파티셔닝(접속자ID 검색 인덱스 포함). 파티션 자동증설/파기는 #37 연계 |
|
| 30 | PERSONAL_DATA_ACCESS_LOG 신규 테이블 | ✅ 진행중 | [Fork B] `src/script/mysql/ddl/privacy.pdal_create_mysql.sql`. 기존 PERSONAL_DATA_DML_HISTORY 실측 승격 + HASH_VAL/PREV_HASH_VAL, PK(LOG_ID,CONECT_DT), 월 RANGE COLUMNS 파티셔닝(접속자ID 검색 인덱스 포함). 파티션 자동증설/파기는 #37 연계 |
|
||||||
| 31 | AOP pointcut 한정 수집 설계 | ✅ 완료 | [Fork B] `PersonalDataLogAspect`+Service/Impl+DAO(10파일)+`context-syslogaop.xml`. pointcut **개인정보 서비스 impl 5개 한정**(과수집 방지), `ClientIpResolver`(#34)+`HashChainService.linkAndSeal`(#39) 적재, synchronized 순번직렬화. 본문 미적재. 비동기(#33)·TRGET_ID(#36) 후속 |
|
| 31 | ~~AOP pointcut 한정 수집 설계~~ → **관리자 화면 메뉴 접속기록** | ✅ 완료(2026-08-07 방식 전환) | **[전환]** 서비스 impl AOP 수집은 **중단**하고(`context-syslogaop.xml`의 `<aop:config>` 주석 처리), 요청 인터셉터 `egovframework.com.cmm.privacy.interceptor.MenuAccessLogInterceptor`(신규, `egov-com-servlet.xml` 등록)가 수집한다. **전환 사유:** 서비스는 관리자·프론트 화면이 공유해 호출 화면을 구분할 수 없어, 무인증 프론트 방문이 접속자ID `ANONYMOUS`·수행업무 "재능나눔 회원관리"로 적재됐다(공개 재능나눔 상세 `/frt/biz/talent/bizTalentView.do` → `selectTalentMber`). `selectJoinTalentMberList` 개별 제외로는 같은 문제가 반복된다 — 공유 서비스 메서드가 그것 하나가 아니다. **새 수집 규칙:** ①요청경로가 관리자 화면 접두(`/mnt/`·`/uss/umt/`·`/sec/`·`/sym/`) ②프레임·include 제외 ③취급자 세션(`userSe=USR`) 3중 조건. 수행업무=기존 메뉴명(`COMTNMENUINFO.MENU_NM`), 해석순서 `page`파라미터(메뉴번호)→경로정확→동일디렉터리 최장공통접두→경로폴백. 처리구분은 `.do` 파일명 유도(`~View`=조회 우선, `select~`=조회 우선 — 21케이스 검증 0불일치). 적재경로(AccessLogQueue→해시체인→`PERSONAL_DATA_ACCESS_LOG`)·스키마는 불변, **DDL 없음**. 델타: `_workspace/deploy/20260807_1440/DELTA.md`<br>─ 이하 전환 前 이력 ─<br>[Fork B] | [Fork B] `PersonalDataLogAspect`+Service/Impl+DAO(10파일)+`context-syslogaop.xml`. pointcut **개인정보 서비스 impl 5개 한정**(과수집 방지), `ClientIpResolver`(#34)+`HashChainService.linkAndSeal`(#39) 적재, synchronized 순번직렬화. 본문 미적재. 비동기(#33)·TRGET_ID(#36) 후속<br>**2026-08-07 과수집 1건 제외:** `BizTalentMberManageServiceImpl.selectJoinTalentMberList*` — 취급자 업무가 아니라 **무인증 공개 목록**(메인 `LayoutFrtController.main`, frt 재능나눔 목록) 경로라 방문 1회당 `ANONYMOUS` 로그 1건이 730일 보관 테이블에 적재됨(실측 41건). pointcut 에 `and not execution(...)` 추가. **검증:** 메인 3회+공개목록 1회 → 증가 0건 / 개인상세(`selectTalentMber`) → 증가 1건(취급자 경로 수집 유지), 해시체인 정상(누락 0) |
|
||||||
| 32 | 업무유형 매핑 테이블 구현 | ✅ 완료 | [Fork B] `privacy.job_mapping_create_mysql.sql`(`PDAL_JOB_MAPPING`+시드 13행) + `JobMappingResolver`(@Component 캐시)+DAO+sqlmap. (클래스,메서드)→업무코드/명, 신규화면은 행 등록만으로 수집(소스 무수정) |
|
| 32 | 업무유형 매핑 테이블 구현 | ⚠️ 수집경로에서 미사용(2026-08-07) | **[전환 영향]** #31이 메뉴 기반으로 바뀌어 수행업무가 `COMTNMENUINFO.MENU_NM`에서 나온다. `PDAL_JOB_MAPPING`/`JobMappingResolver`는 **수집에 더 이상 관여하지 않는다**(테이블·관리화면 #32-1은 기존 적재분 해석·복원 경로 보존용으로 존치). 접속기록 조회화면의 '수행업무' 드롭다운 소스도 이 테이블 → 적재분 DISTINCT(`PersonalDataAccessLogDAO.selectJobSeCodeList`)로 교체(매핑을 보면 실제 적재값과 어긋나 검색결과가 항상 0건이 된다).<br>─ 이하 전환 前 이력 ─<br>[Fork B] | [Fork B] `privacy.job_mapping_create_mysql.sql`(`PDAL_JOB_MAPPING`+시드 13행) + `JobMappingResolver`(@Component 캐시)+DAO+sqlmap. (클래스,메서드)→업무코드/명, 신규화면은 행 등록만으로 수집(소스 무수정). 관리 화면은 #32-1 로 분리 |
|
||||||
|
| 32-1 | 업무유형 매핑 **관리 화면** | ✅ 완료 | 2026-08-07 신설. #32 는 테이블+로더까지라 매핑 행을 **DB 직접 INSERT 로만** 운영했음 → 화면에서 등록·수정·사용중지·삭제. `JobMappingController`+`JobMappingManageService`/`Impl`+`JobMappingSearchVO`+DAO CRUD 7개+sqlmap(Mysql/Oracle 쌍, **config 등록 불요 — 기존 파일 확장**)+`site/mnt/sec/jobMapping/{list,regist}.jsp`+메뉴 9100045(권한관리 하위 `MENU_ORDR` 11)+`left.jsp` 5곳.<br>저장 즉시 `JobMappingResolver.refresh()` → **WAS 재기동 불요**. DB 직접수정 반영용 '캐시 재적재' 버튼 별도.<br>**감사 컬럼 신설**(`privacy.job_mapping_audit_cols_mysql.sql`: REGISTER_ID/REG_DT/UPDUSR_ID/UPDT_DT) — 분류 기준 변경 주체를 남긴다. 기존 13행은 등록자 NULL → 화면에 '시드' 표기.<br>보안: 상태변경 전 엔드포인트 **POST 한정** + Origin/Referer 동일출처 + `handlerUser()` USR 판정, 서버측 형식·중복(TARGET_CLASS+METHOD_PTTRN) 재검증, 업무명은 JS 인라인 보간 대신 `data-*`+`c:out`.<br>**검증:** JDK8 `mvn -o compile` 성공 / DDL·메뉴DML 로컬 3306 적용 + 멱등 재실행 확인 + `HEX()` 한글 정상 / CRUD SQL 실DB 실행 왕복 확인(13행 원복). **실화면 렌더는 Tomcat 미기동으로 미검증** |
|
||||||
| 33 | 비동기 적재 큐 구현 | ✅ 완료 | [Fork B] `AccessLogQueue`(LinkedBlockingQueue cap1만+단일 데몬컨슈머). Aspect가 enqueue만(업무 응답 무영향), 컨슈머가 persistNow 순차→**단일 라이터로 해시체인 순번 보장**. 백프레셔(offer 5s), @PreDestroy flush 유실방지 |
|
| 33 | 비동기 적재 큐 구현 | ✅ 완료 | [Fork B] `AccessLogQueue`(LinkedBlockingQueue cap1만+단일 데몬컨슈머). Aspect가 enqueue만(업무 응답 무영향), 컨슈머가 persistNow 순차→**단일 라이터로 해시체인 순번 보장**. 백프레셔(offer 5s), @PreDestroy flush 유실방지 |
|
||||||
| 34 | IP 취득 로직 보정 | ✅ 완료 | [Fork B] `cmm/privacy/auditlog/ClientIpResolver.java`. XFF→Proxy/WL-Proxy→HTTP_CLIENT_IP→getRemoteAddr 순 + **신뢰프록시 CIDR 검증(헤더위조 방지)** + IPv6 루프백 정규화. javac+단위검증 통과. AS-IS `IpObtainInterceptor`(getRemoteAddr+인증시만) 격차 확인. 신뢰대역 운영값=#57 후 확정 |
|
| 34 | IP 취득 로직 보정 | ✅ 완료 | [Fork B] `cmm/privacy/auditlog/ClientIpResolver.java`. XFF→Proxy/WL-Proxy→HTTP_CLIENT_IP→getRemoteAddr 순 + **신뢰프록시 CIDR 검증(헤더위조 방지)** + IPv6 루프백 정규화. javac+단위검증 통과. AS-IS `IpObtainInterceptor`(getRemoteAddr+인증시만) 격차 확인. 신뢰대역 운영값=#57 후 확정 |
|
||||||
|
|
||||||
@ -284,9 +288,9 @@ fork 3개를 병렬로 투입해 **자체 완결적·파일 충돌 없는 기반
|
|||||||
|
|
||||||
| # | 개발항목 | 구분 | 상태 |
|
| # | 개발항목 | 구분 | 상태 |
|
||||||
|:--:|----------|:----:|:----:|
|
|:--:|----------|:----:|:----:|
|
||||||
| 45 | 권한부여 신청/승인 관리 화면 | mnt | ⬜ 미착수 |
|
| 45 | 권한부여 신청/승인 관리 화면 | mnt | ✅ 완료 |
|
||||||
| 46 | 권한변경 이력 조회 화면 | mnt | ⬜ 미착수 |
|
| 46 | 권한변경 이력 조회 화면 | mnt | ✅ 완료 |
|
||||||
| 47 | 개인정보 접속기록 조회/다운로드 화면 | mnt | ⬜ 미착수 |
|
| 47 | 개인정보 접속기록 조회/다운로드 화면 | mnt | ✅ 완료 |
|
||||||
| 48 | 접속기록 정기점검 리포트 대시보드 | mnt | ⬜ 미착수 |
|
| 48 | 접속기록 정기점검 리포트 대시보드 | mnt | ⬜ 미착수 |
|
||||||
| 49 | 비정상접근 탐지 알림함 화면 | mnt | ⬜ 미착수 |
|
| 49 | 비정상접근 탐지 알림함 화면 | mnt | ⬜ 미착수 |
|
||||||
| 50 | 인증수단 관리 설정 화면 | mnt | ⬜ 미착수 |
|
| 50 | 인증수단 관리 설정 화면 | mnt | ⬜ 미착수 |
|
||||||
@ -295,6 +299,20 @@ fork 3개를 병렬로 투입해 **자체 완결적·파일 충돌 없는 기반
|
|||||||
| 53 | 마이페이지 인증수단 등록/관리 화면 | frt | ⬜ 미착수 |
|
| 53 | 마이페이지 인증수단 등록/관리 화면 | frt | ⬜ 미착수 |
|
||||||
| 54 | 계정 잠금 안내 화면 | frt | ⬜ 미착수 |
|
| 54 | 계정 잠금 안내 화면 | frt | ⬜ 미착수 |
|
||||||
|
|
||||||
|
> **표 갱신 이력**
|
||||||
|
> - 2026-08-06 정정: #45·#46 은 이미 구현·메뉴등록(9100035/9100036)까지 완료된 상태였는데 이 표만 ⬜ 로 뒤처져 있었다. 실측 반영.
|
||||||
|
> - 2026-08-06 #47 완료: `PersonalDataAccessLogController`(`/mnt/sec/accessLog/listView.do`·`download.do`) + `site/mnt/sec/accessLog/list.jsp` + 메뉴 9100044(권한관리 하위, `MENU_ORDR` 10) + `left.jsp` 분기 4곳.
|
||||||
|
> 검색 7조건(접속자ID·업무유형·처리대상·IP·처리구분·처리결과·기간)·페이징·CSV 다운로드(사유 필수 #42 연계, `PDAL_DOWNLOAD_HISTORY` 적재).
|
||||||
|
> 9100040~9100043 은 게시판관리 그룹이 선점하여 미사용 번호 9100044 사용.
|
||||||
|
> 로컬 3306 `csv` 메뉴 등록 + `HEX()` 한글 검증 완료 / 배포 ROOT 델타 반영 완료. **실화면 렌더는 Tomcat 미기동으로 미검증.**
|
||||||
|
> - 2026-08-07 #32-1 신설·완료: 업무유형 매핑 관리 화면(`/mnt/sec/jobMapping/`) + 메뉴 9100045(권한관리 하위, `MENU_ORDR` 11) + `left.jsp` 5곳.
|
||||||
|
> `PDAL_JOB_MAPPING` 감사 컬럼 4개 추가(`privacy.job_mapping_audit_cols_mysql.sql`). 저장 시 Resolver 캐시 즉시 무효화(재기동 불요).
|
||||||
|
> 신규 sqlMap 파일 없이 기존 `PrivacyJobMapping_SQL_Mysql.xml` 을 확장했으므로 **sql-map-config 등록 변경 불요**.
|
||||||
|
> 로컬 3306 DDL·메뉴 DML 적용 + 멱등 재실행 + `HEX()` 한글 검증 완료 / **배포 ROOT 델타는 미반영**(매니페스트 `_workspace/deploy/20260807_1500/DELTA.md`).
|
||||||
|
> **실화면 렌더는 Tomcat 미기동으로 미검증.**
|
||||||
|
> - 2026-08-07 대조 확인: `privacy.job_mapping_create_mysql.sql` 미커밋 변경분은 **멱등화 전용**(CREATE TABLE IF NOT EXISTS + 시드 NOT EXISTS 가드)이라
|
||||||
|
> 스키마·시드 내용이 커밋본과 동일하다 → 로컬 3306 적용본과 불일치 없음. 개발/운영 DB 는 privacy DDL 자체가 미적용 상태.
|
||||||
|
|
||||||
> UI 목업 브리프(스티치 AI 프롬프트 포함)는 `디자인.md`에 문서화 완료 — **설계 산출물은 준비됨**, 코드 구현은 미착수.
|
> UI 목업 브리프(스티치 AI 프롬프트 포함)는 `디자인.md`에 문서화 완료 — **설계 산출물은 준비됨**, 코드 구현은 미착수.
|
||||||
|
|
||||||
### Ⅴ. 착수 즉시 확인 필요 사항 — arko-privacy-analyst
|
### Ⅴ. 착수 즉시 확인 필요 사항 — arko-privacy-analyst
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user