share job

This commit is contained in:
JAE SIK CHO
2026-04-09 11:12:12 +09:00
commit f8427ee1d0
193 changed files with 23830 additions and 0 deletions

26
.env Normal file
View File

@@ -0,0 +1,26 @@
# ─────────── DB (MSSQL) ───────────
DB_URL=jdbc:sqlserver://121.156.116.136:52785;databaseName=logins_test;encrypt=false;trustServerCertificate=true
DB_USERNAME=logins
DB_PASSWORD=ghkfkdahrfh40-8
# ─────────── JWT ───────────
# 256비트 이상 랜덤 시크릿 (운영 시 반드시 교체)
JWT_SECRET=change-me-to-a-random-256bit-or-longer-secret-key-for-production
# ─────────── Redis ───────────
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=
# ─────────── RabbitMQ ───────────
RABBITMQ_HOST=rabbitmq
RABBITMQ_PORT=5672
RABBITMQ_USER=guest
RABBITMQ_PASSWORD=guest
# ─────────── File Upload ───────────
FILE_UPLOAD_PATH=/data/uploads
# ─────────── Spring Profile ───────────
# mssql | mariadb
SPRING_PROFILES_ACTIVE=mssql

10
.env.example Normal file
View File

@@ -0,0 +1,10 @@
# Oracle DB
DB_URL=jdbc:oracle:thin:@host:1521:SID
DB_USERNAME=
DB_PASSWORD=
# JWT
JWT_SECRET=your-secret-key-must-be-at-least-256-bits-long
# File Storage
FILE_UPLOAD_PATH=/data/uploads

6516
DB Scheme Normal file

File diff suppressed because it is too large Load Diff

96
Jenkinsfile vendored Normal file
View File

@@ -0,0 +1,96 @@
pipeline {
agent any
environment {
DOCKER_REGISTRY = "${env.DOCKER_REGISTRY ?: 'localhost:5000'}"
IMAGE_BACKEND = "${DOCKER_REGISTRY}/gw-backend"
IMAGE_FRONTEND = "${DOCKER_REGISTRY}/gw-frontend"
GIT_COMMIT_SHORT = sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim()
IMAGE_TAG = "${env.BUILD_NUMBER}-${GIT_COMMIT_SHORT}"
}
options {
buildDiscarder(logRotator(numToKeepStr: '10'))
timeout(time: 30, unit: 'MINUTES')
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Backend Build') {
steps {
dir('NEW/backend') {
sh './gradlew clean build -x test --no-daemon'
}
}
}
stage('Frontend Build') {
steps {
dir('NEW/frontend') {
sh 'pnpm install --frozen-lockfile'
sh 'pnpm build'
}
}
}
stage('Docker Build & Push') {
parallel {
stage('Backend Image') {
steps {
dir('NEW/backend') {
sh """
docker build -t ${IMAGE_BACKEND}:${IMAGE_TAG} -t ${IMAGE_BACKEND}:latest .
docker push ${IMAGE_BACKEND}:${IMAGE_TAG}
docker push ${IMAGE_BACKEND}:latest
"""
}
}
}
stage('Frontend Image') {
steps {
dir('NEW/frontend') {
sh """
docker build -t ${IMAGE_FRONTEND}:${IMAGE_TAG} -t ${IMAGE_FRONTEND}:latest .
docker push ${IMAGE_FRONTEND}:${IMAGE_TAG}
docker push ${IMAGE_FRONTEND}:latest
"""
}
}
}
}
}
stage('Deploy') {
when {
branch 'main'
}
steps {
dir('NEW') {
sh """
docker-compose pull
docker-compose up -d --no-build
docker-compose ps
"""
}
}
}
}
post {
success {
echo "Build ${IMAGE_TAG} deployed successfully."
}
failure {
echo "Build failed. Check logs."
}
always {
sh 'docker system prune -f --filter "until=24h" || true'
}
}
}

339
MIGRATION_GUIDE.md Normal file
View File

@@ -0,0 +1,339 @@
# 그룹웨어 마이그레이션 가이드
## 현행 시스템 → Spring Boot + Next.js 전환
---
## 현행 시스템 개요
| 항목 | 내용 |
|------|------|
| 프레임워크 | Grails 2.5.0 |
| 언어 | Groovy / Java |
| ORM | MyBatis (SQL XML 방식) |
| DB | Oracle (JNDI: `java:comp/env/loginsDS`) |
| 빌드 | Maven |
| 뷰 | GSP + Daum Editor |
| 버전관리 | SVN |
### 현행 주요 모듈
| 모듈 | 설명 |
|------|------|
| board | 게시판 |
| tam | 결재 관리 |
| wplan | 근무 계획 |
| wtime | 근무 시간 관리 |
| envset | 환경 설정 / 사용자 관리 |
| fedex | 배송 연동 |
| common | 공통 (인증, 코드, 첨부파일, 보안) |
---
## 신규 기술 스택 (회사 표준)
| 계층 | 기술 | 버전 |
|------|------|------|
| OS | Ubuntu | 24.04 LTS |
| Web/Proxy | Nginx | 1.26.x Stable |
| Frontend | Next.js | 15.x (React 19 기반) |
| Runtime | Node.js | 22 LTS |
| 패키지 관리 | pnpm | 최신 |
| API Gateway | Spring Cloud Gateway | 4.x (Spring Boot 3.x 기반) |
| Backend | Spring Boot | 3.4.x |
| JDK | OpenJDK | 21 LTS |
| 빌드 도구 | Gradle | 8.x |
| DB (신규 표준) | MariaDB | 11.4 LTS |
| DB (현재 유지) | Oracle | 기존 그대로 유지 |
| Cache | Redis | 7.x |
| MQ | RabbitMQ | 3.13.x |
| 컨테이너 | Docker / Docker Compose | 최신 Stable |
| 형상관리 | GitHub | - |
| CI/CD | Jenkins | LTS |
---
## 신규 아키텍처
```
[Nginx]
[Next.js 15.x] ──→ [Spring Cloud Gateway 4.x] ──→ [Spring Boot 3.4.x]
[Redis Cache]
[Oracle DB] ← 현재
[MariaDB] ← 추후 전환
```
### 폴더 구조
```
NEW/
backend/ ← Spring Boot 3.4.x 프로젝트 (Gradle)
frontend/ ← Next.js 15.x 프로젝트 (pnpm)
MIGRATION_GUIDE.md
```
---
## DB 전환 전략
### 단계별 계획
```
1단계 (현재): Oracle 유지하며 신규 시스템 개발
2단계 (추후): 신규 시스템 안정화 및 검증 완료 후 MariaDB 마이그레이션
```
### SQL 이중화 전략 (MyBatis databaseId 활용)
기존 Oracle SQL XML을 그대로 사용하면서, MariaDB 문법 버전을 병행 작성.
전환 시 `application.yml`의 databaseId 설정만 변경하면 즉시 교체 가능.
```xml
<!-- Oracle 전용 쿼리 -->
<select id="selectList" databaseId="oracle">
SELECT * FROM (
SELECT ROWNUM AS rn, t.* FROM board t WHERE ROWNUM <= #{endRow}
) WHERE rn > #{startRow}
</select>
<!-- MariaDB 전용 쿼리 (추후 전환용) -->
<select id="selectList" databaseId="mariadb">
SELECT * FROM board
LIMIT #{size} OFFSET #{offset}
</select>
```
### Oracle → MariaDB 주요 변환 포인트
| 항목 | Oracle | MariaDB |
|------|--------|---------|
| 페이징 | ROWNUM | LIMIT / OFFSET |
| 시퀀스 | sequence.NEXTVAL | AUTO_INCREMENT |
| 날짜 함수 | SYSDATE, TO_DATE | NOW(), STR_TO_DATE |
| 문자열 연결 | `||` | CONCAT() 또는 `||` |
| NVL | NVL() | IFNULL() / COALESCE() |
| DECODE | DECODE() | CASE WHEN |
| 가상 테이블 | FROM DUAL | FROM DUAL (지원) 또는 생략 |
---
## Phase 1. 분석 및 설계
### 1-1. 현행 파악 체크리스트
- [ ] 각 컨트롤러의 URL 매핑 목록화 (`UrlMappings.groovy` + 각 컨트롤러)
- [ ] 세션/인증 방식 파악 (`SecurityFilter`, `AuthService`)
- [ ] 공통 유틸 파악 (`CommonUtil`, `ReqUtil`, `FormatUtil` 등)
- [ ] SQL XML의 쿼리별 입출력 파라미터 파악
- [ ] 파일 첨부 저장 방식 파악 (`AttachService`)
- [ ] 메뉴/권한 구조 파악 (`MenuService`, `SecurityService`)
### 1-2. 인증 구조 전환 설계
```
기존: 세션 기반 (SecurityFilter.groovy)
신규: JWT 기반
- POST /api/auth/login → JWT 발급
- 모든 요청 헤더: Authorization: Bearer {token}
- Spring Security Filter Chain 구성
- Redis를 활용한 토큰 블랙리스트/Refresh Token 관리
```
---
## Phase 2. Spring Boot 백엔드 구성
### 2-1. 주요 의존성 (build.gradle)
```groovy
dependencies {
// Web
implementation 'org.springframework.boot:spring-boot-starter-web'
// Security & JWT
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.12.x'
// MyBatis
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.x'
// Oracle JDBC
implementation 'com.oracle.database.jdbc:ojdbc11'
// MariaDB JDBC (추후 전환용)
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// Validation
implementation 'org.springframework.boot:spring-boot-starter-validation'
// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// Excel (Apache POI)
implementation 'org.apache.poi:poi-ooxml:5.x'
}
```
### 2-2. MyBatis 마이그레이션 (기존 SQL XML 재사용)
기존 `src/java/sql/` 의 XML 파일을 Spring Boot 프로젝트로 복사 후 경미한 수정만 필요.
| 작업 | 내용 |
|------|------|
| Mapper XML 이동 | `src/main/resources/mapper/` 로 복사 |
| Mapper Interface 생성 | XML의 각 쿼리 ID에 대응하는 Java Interface 작성 |
| VO/DTO 클래스 | Groovy VO → Java POJO (Lombok 활용) |
| SqlSessionFactory | MyBatis Spring Boot Starter 자동 설정 |
| databaseId 설정 | Oracle/MariaDB 구분을 위한 DatabaseIdProvider 등록 |
### 2-3. 모듈별 변환 순서
```
1. common → 인증(JWT + Redis), 코드, 공통 유틸
2. envset → 사용자/권한/메뉴 관리
3. board → 게시판 (전형적인 CRUD로 패턴 확립)
4. tam → 결재 워크플로우
5. wplan → 근무 계획
6. wtime → 근무 시간
7. fedex → 외부 연동
```
### 2-4. API 응답 공통 형식
```json
{
"success": true,
"data": { ... },
"message": "",
"pagination": {
"page": 1,
"size": 20,
"total": 100
}
}
```
---
## Phase 3. Next.js 프론트엔드 구성
### 3-1. 주요 의존성
```
- Next.js 15.x (App Router, React 19)
- TypeScript
- Tailwind CSS
- shadcn/ui (UI 컴포넌트)
- TanStack Query v5 (서버 상태 관리)
- Zustand (클라이언트 상태 관리)
- react-hook-form + zod (폼 검증)
- axios (API 호출)
- Toast UI Editor 또는 Tiptap (WYSIWYG - Daum Editor 대체)
```
### 3-2. 페이지 구조
```
/app
/login
/(main)
/dashboard
/board/[boardType]
/tam ← 결재
/wplan ← 근무계획
/wtime ← 근무시간
/envset ← 환경설정
/users
/codes
/menus
```
### 3-3. GSP → Next.js 화면 변환
```
Daum Editor → Toast UI Editor 또는 Tiptap
GSP 페이징 태그 → 커스텀 Pagination 컴포넌트
GSP 공통 레이아웃 → app/layout.tsx
세션 체크 → JWT 기반 Next.js Middleware
```
---
## Phase 4. 인프라 구성 (Docker)
### docker-compose.yml 구성 예시
```yaml
services:
nginx:
image: nginx:1.26
frontend:
build: ./frontend # Next.js
gateway:
build: ./gateway # Spring Cloud Gateway
backend:
build: ./backend # Spring Boot
redis:
image: redis:7
rabbitmq:
image: rabbitmq:3.13-management
```
---
## Phase 5. 통합 및 검증
### 5-1. 모듈별 검증 체크리스트
- [ ] 기존 SQL 결과와 신규 API 응답값 동일 여부 확인
- [ ] 페이징, 검색 조건 동작 확인
- [ ] 파일 첨부/다운로드 동작 확인
- [ ] 결재 워크플로우 흐름 검증
- [ ] 권한/메뉴 제어 동작 확인
- [ ] Excel 다운로드 동작 확인
### 5-2. 병행 운영 전략
```
1단계: 기존 Grails 운영 유지
2단계: 신규 시스템 완성 모듈부터 부분 교체
3단계: 전체 전환 후 기존 시스템 종료
4단계: DB를 Oracle → MariaDB 마이그레이션
```
---
## 작업량 예상
| 영역 | 난이도 | 이유 |
|------|--------|------|
| MyBatis SQL 재사용 | 낮음 | XML 거의 그대로 사용 가능 |
| SQL 이중화 (Oracle+MariaDB) | 중간 | databaseId로 병행 관리 |
| Spring Boot API 변환 | 중간 | 서비스 로직 Groovy → Java 변환 |
| JWT + Redis 인증 | 중간 | 세션 → Stateless 구조 변경 |
| 결재(tam) 로직 | 높음 | 워크플로우 복잡도 |
| Next.js 화면 재작성 | 높음 | 전체 UI 새로 작성 필요 |
| 파일 첨부 처리 | 중간 | 저장 경로/방식 재설계 필요 |
| Docker 인프라 구성 | 중간 | 서비스별 컨테이너화 |
---
## 진행 현황
- [x] NEW 폴더 및 하위 구조 생성
- [x] 마이그레이션 가이드 문서 작성 (기술 스택 확정)
- [ ] Spring Boot 백엔드 프로젝트 초기 세팅
- [ ] Next.js 프론트엔드 프로젝트 초기 세팅
- [ ] Phase 1: 현행 분석
- [ ] Phase 2: 백엔드 구축
- [ ] Phase 3: 프론트엔드 구축
- [ ] Phase 4: 인프라(Docker) 구성
- [ ] Phase 5: 통합 및 검증
- [ ] DB 마이그레이션 (Oracle → MariaDB)

334
TASK_LIST.md Normal file
View File

@@ -0,0 +1,334 @@
# 그룹웨어 마이그레이션 작업 목록
> **범례**: ✅ 완료 | 🔄 진행중 | ⬜ 미착수
> **원칙**: 각 모듈은 백엔드(SQL → Mapper → Service → Controller) → 프론트엔드(API → Types → Page) 순서로 진행
---
## 0. 프로젝트 기반 구성
| # | 작업 | 상태 | 비고 |
|---|------|------|------|
| 0-1 | NEW 폴더 구조 생성 | ✅ | backend/, frontend/ |
| 0-2 | MIGRATION_GUIDE.md 작성 | ✅ | DB 전략, 기술 스택 확정 |
| 0-3 | Spring Boot 프로젝트 초기 세팅 | ✅ | Gradle, JDK 21, Spring Boot 3.4.x |
| 0-4 | Next.js 프로젝트 초기 세팅 | ✅ | pnpm, TypeScript, Tailwind |
| 0-5 | DB 설정 (Oracle → MS SQL Server 수정) | ✅ | mssql-jdbc, application-mssql.yml |
| 0-6 | docker-compose.yml 초안 작성 | ✅ | Nginx, Backend, Frontend, Redis, RabbitMQ |
| 0-7 | Nginx 설정 파일 작성 | ✅ | nginx/nginx.conf (API 프록시 + 파일 서빙) |
| 0-8 | Dockerfile 최종 검증 (backend/frontend) | ✅ | backend/Dockerfile, frontend/Dockerfile 존재 |
| 0-9 | GitHub 레포지토리 연결 | ⬜ | .gitignore, 초기 커밋 |
| 0-10 | Jenkins CI/CD 파이프라인 작성 | ✅ | Jenkinsfile (parallel build + deploy) |
---
## 1. 공통 기반 (Common)
### 1-1. 백엔드 공통
| # | 작업 | 상태 | 대상 파일 (원본) |
|---|------|------|------|
| 1-1-1 | JWT 유틸 (corpNo 포함) | ✅ | JwtUtil.java |
| 1-1-2 | JWT 인증 필터 | ✅ | JwtAuthenticationFilter.java |
| 1-1-3 | Spring Security 설정 | ✅ | SecurityConfig.java |
| 1-1-4 | Redis 설정 | ✅ | RedisConfig.java |
| 1-1-5 | MyBatis 설정 (DatabaseId 포함) | ✅ | MyBatisConfig.java |
| 1-1-6 | 공통 응답 포맷 (ApiResponse) | ✅ | ApiResponse.java |
| 1-1-7 | 공통 예외 처리 | ✅ | GlobalExceptionHandler.java, BizException.java |
| 1-1-8 | CurrentUser 유틸 + SecurityUtil | ✅ | CurrentUser.java, SecurityUtil.java |
| 1-1-9 | 시퀀스 서비스 | ✅ | SequenceService.java (SX_CO0060) |
| 1-1-10 | 파일 첨부 서비스 | ✅ | AttachService.java, AttachController.java |
| 1-1-11 | 코드 조회 서비스 (공통코드 캐시) | ✅ | CodeService.java (@Cacheable Redis, TTL 6h) |
| 1-1-12 | 이메일 서비스 | ⬜ | EmailService.groovy → EmailService.java |
| 1-1-13 | 공통 SQL XML 이식 | ✅ | common_sql.xml (시퀀스) |
| 1-1-14 | attach SQL XML 이식 | ✅ | attach_sql.xml (MSSQL + MariaDB) |
| 1-1-15 | code SQL XML 이식 | ✅ | code_sql.xml (getCodeList, searchUser, getWorkCdList) |
### 1-2. 프론트엔드 공통
| # | 작업 | 상태 | 비고 |
|---|------|------|------|
| 1-2-1 | Axios 클라이언트 + 401 자동 갱신 | ✅ | lib/api/client.ts |
| 1-2-2 | Zustand 인증 스토어 | ✅ | lib/store/authStore.ts |
| 1-2-3 | TanStack Query Provider | ✅ | components/common/Providers.tsx |
| 1-2-4 | JWT 미들웨어 (라우트 보호) | ✅ | middleware.ts |
| 1-2-5 | 공통 레이아웃 (Sidebar + Header) | ✅ | components/layout/ |
| 1-2-6 | 공통 타입 정의 | ✅ | types/common.ts, types/auth.ts |
| 1-2-7 | 공통 UI 컴포넌트 (Modal, Pagination, Table) | ✅ | Modal.tsx, Pagination.tsx 완성 |
| 1-2-8 | 파일 첨부 컴포넌트 | ✅ | components/common/FileUpload.tsx |
| 1-2-9 | Toast 알림 컴포넌트 | ✅ | Toast.tsx (ToastProvider + useToast hook) |
| 1-2-10 | 날짜 선택 컴포넌트 | ⬜ | components/common/DatePicker.tsx |
---
## 2. 인증 (Auth / Index)
> 원본: `IndexController`, `SecurityFilter`, `AuthService`, `SecurityService`, `UserService`
### 2-1. 백엔드
| # | 작업 | 상태 | 비고 |
|---|------|------|------|
| 2-1-1 | user_sql.xml 이식 | ✅ | getUserInfo, getUserInfoWithPwd |
| 2-1-2 | security_sql.xml 이식 | ✅ | getUserRoleList |
| 2-1-3 | menu_sql.xml 이식 | ✅ | getMenuList, getControllerRoleList |
| 2-1-4 | UserMapper / SecurityMapper / MenuMapper | ✅ | |
| 2-1-5 | PasswordUtil (SHA-256 + Base64) | ✅ | 기존 DB 비밀번호 호환 |
| 2-1-6 | AuthService (로그인/로그아웃/토큰재발급) | ✅ | Redis RefreshToken |
| 2-1-7 | CustomUserDetailsService | ✅ | |
| 2-1-8 | AuthController (login/logout/refresh/me) | ✅ | |
### 2-2. 프론트엔드
| # | 작업 | 상태 | 비고 |
|---|------|------|------|
| 2-2-1 | authApi (login/logout/refresh) | ✅ | lib/api/auth.ts |
| 2-2-2 | 로그인 페이지 + LoginForm | ✅ | app/login/page.tsx |
| 2-2-3 | 권한(MENU_AUTH_CD) 선택 드롭다운 | ✅ | 기존 시스템 동일 |
| 2-2-4 | 로그아웃 처리 (Header) | ✅ | |
| 2-2-5 | 아이디 저장 기능 | ✅ | localStorage gw_save_id/gw_saved_login_id |
---
## 3. 환경설정 (Envset)
### 3-1. 직원정보 (Envset0010 / SX_GW0010)
| # | 작업 | 상태 | 비고 |
|---|------|------|------|
| 3-1-1 | envset0010_sql.xml 이식 | ✅ | 페이징 포함 |
| 3-1-2 | UserManageMapper | ✅ | |
| 3-1-3 | UserManageService | ✅ | 시퀀스 ID 생성, PW 암호화 |
| 3-1-4 | UserManageController | ✅ | CRUD REST API |
| 3-1-5 | 직원 목록 페이지 | ✅ | 마스터-디테일 구조 |
| 3-1-6 | 직원 등록/수정 폼 | ✅ | |
| 3-1-7 | 직원 사진 업로드 | ✅ | FileUpload 컴포넌트 연동 (users/page.tsx) |
| 3-1-8 | 근무코드 관리 (Envset0040, SX_CO0070) | ✅ | envset0040_sql.xml + WorkCdMapper/Service/Controller + workcd/page.tsx |
### 3-2. 공통코드 관리 (Envset0020/0030 / SX_CO0030, SX_CO0040)
| # | 작업 | 상태 | 비고 |
|---|------|------|------|
| 3-2-1 | envset0020_sql.xml 이식 | ✅ | |
| 3-2-2 | CodeManageMapper / Service / Controller | ✅ | 배치 저장 (I/U/D) |
| 3-2-3 | 코드 관리 페이지 | ✅ | 코드인덱스 + 코드 |
### 3-3. 메뉴/권한 관리 (Envset0050 / SX_CO0080, SX_CO0090, SX_GW0130)
| # | 작업 | 상태 | 비고 |
|---|------|------|------|
| 3-3-1 | envset0050_sql.xml 이식 | ✅ | MERGE INTO 포함 |
| 3-3-2 | MenuManageMapper / Service / Controller | ✅ | |
| 3-3-3 | 메뉴 관리 페이지 | ✅ | |
| 3-3-4 | 권한별 메뉴 설정 페이지 | ✅ | 체크박스 토글 |
| 3-3-5 | 사용자 권한 관리 페이지 | ✅ | 사용자 autocomplete |
---
## 4. 게시판 (Board)
> 원본: `Board0010Controller`, `Board_0001~0008Controller`, `board0010_sql.xml`
> 8개 게시판 유형을 하나의 Controller로 통합 (boardType 파라미터로 구분)
### 4-1. 백엔드
| # | 작업 | 상태 | 비고 |
|---|------|------|------|
| 4-1-1 | board0010_sql.xml 분석 및 이식 | ✅ | MSSQL + MariaDB 이중 작성 |
| 4-1-2 | BoardMapper 인터페이스 | ✅ | |
| 4-1-3 | BoardService | ✅ | 목록/상세/등록/수정/삭제/조회수/댓글 |
| 4-1-4 | BoardController | ✅ | `/api/board/{boardType}` REST |
| 4-1-5 | 파일 첨부 연동 | ✅ | AttachService 연동 완료 |
### 4-2. 프론트엔드
| # | 작업 | 상태 | 비고 |
|---|------|------|------|
| 4-2-1 | boardApi 정의 | ✅ | lib/api/board.ts |
| 4-2-2 | 게시판 목록 페이지 | ✅ | app/(main)/board/[boardType]/page.tsx |
| 4-2-3 | 게시물 상세 페이지 | ✅ | 댓글 포함 |
| 4-2-4 | 게시물 등록/수정 페이지 | ✅ | PostForm 컴포넌트 |
| 4-2-5 | 파일 첨부 UI | ✅ | FileUpload 컴포넌트 연동 |
---
## 5. 결재 (TAM)
> 원본: `Tam0010~0040Controller`, `tam0010~0040_sql.xml`, `ApvdocService`, `apvdoc_sql.xml`
> 가장 복잡한 모듈 - 워크플로우 로직 포함
### 5-1. 백엔드
| # | 작업 | 상태 | 비고 |
|---|------|------|------|
| 5-1-1 | tam_sql.xml 분석 및 이식 (0010~0040 통합) | ✅ | MSSQL + MariaDB 이중 작성 |
| 5-1-2 | apvdoc_sql.xml 분석 및 이식 | ✅ | 결재 문서 공통, DECLARE @var → subquery |
| 5-1-3 | TamMapper / ApvdocMapper | ✅ | |
| 5-1-4 | ApvdocService (워크플로우 로직) | ✅ | 결재선, 승인/반려, 상태 변경 |
| 5-1-5 | TamService | ✅ | TAM0010~0040 서비스 |
| 5-1-6 | TamController | ✅ | `/api/tam/` REST |
### 5-2. 프론트엔드
| # | 작업 | 상태 | 비고 |
|---|------|------|------|
| 5-2-1 | tamApi 정의 | ✅ | lib/api/tam.ts |
| 5-2-2 | 연차 관리 페이지 (TAM0010) | ✅ | 인라인 편집 |
| 5-2-3 | 결재 신청 목록 페이지 (TAM0020) | ✅ | 상태/종류 필터 |
| 5-2-4 | 결재 신청 등록/상세 페이지 | ✅ | ApvreqForm + ApproverSection 컴포넌트 |
| 5-2-5 | 결재 처리 목록/상세 페이지 (TAM0030) | ✅ | 승인/반려 처리 |
| 5-2-6 | 근태 현황 페이지 (TAM0040) | ✅ | 종류별 집계 |
---
## 6. 근무계획 (Wplan)
> 원본: `Wplan0010~0030Controller`, `wplan0010~0030_sql.xml`
### 6-1. 백엔드
| # | 작업 | 상태 | 비고 |
|---|------|------|------|
| 6-1-1 | wplan_sql.xml 분석 및 이식 (0010~0030 통합) | ✅ | MSSQL + MariaDB 이중 작성 |
| 6-1-2 | WplanMapper / Service / Controller | ✅ | `/api/wplan/` |
### 6-2. 프론트엔드
| # | 작업 | 상태 | 비고 |
|---|------|------|------|
| 6-2-1 | wplanApi 정의 | ✅ | lib/api/wplan.ts |
| 6-2-2 | 근무계획 관리 페이지 (Wplan0010) | ✅ | 월별 피벗 테이블 + 인라인 편집 |
| 6-2-3 | 나의근무계획 페이지 (Wplan0020) | ✅ | 월 달력 뷰 |
| 6-2-4 | 전체근무계획 페이지 (Wplan0030) | ✅ | 근무코드 필터 |
---
## 7. 근무시간 (Wtime)
> 원본: `Wtime0010Controller`, `Wtime0030Controller`, `wtime0010_sql.xml`, `wtime0030_sql.xml`, `work_sql.xml`
### 7-1. 백엔드
| # | 작업 | 상태 | 비고 |
|---|------|------|------|
| 7-1-1 | wtime_sql.xml 분석 및 이식 (0010+0030 통합) | ✅ | MSSQL + MariaDB 이중 작성 |
| 7-1-2 | WtimeMapper / Service / Controller | ✅ | `/api/wtime/` |
| 7-1-3 | WorkService (근무시간 재계산 로직) | ⬜ | 추후 필요 시 추가 |
### 7-2. 프론트엔드
| # | 작업 | 상태 | 비고 |
|---|------|------|------|
| 7-2-1 | wtimeApi 정의 | ✅ | lib/api/wtime.ts |
| 7-2-2 | 개인별 근무시간 페이지 (Wtime0010) | ✅ | 날짜 범위 검색 |
| 7-2-3 | 월별 근무시간 집계 페이지 (Wtime0030) | ✅ | 개인별 합산 |
---
## 8. FedEx 배송 (Fedex)
> 원본: `Fedex0010Controller`, `fedex0010_sql.xml`
### 8-1. 백엔드
| # | 작업 | 상태 | 비고 |
|---|------|------|------|
| 8-1-1 | fedex0010_sql.xml 분석 및 이식 | ✅ | MSSQL + MariaDB, errorMng 테이블 |
| 8-1-2 | FedexMapper / Service / Controller | ✅ | `/api/fedex/` |
### 8-2. 프론트엔드
| # | 작업 | 상태 | 비고 |
|---|------|------|------|
| 8-2-1 | fedexApi 정의 | ✅ | lib/api/fedex.ts |
| 8-2-2 | FedEx 수입정정 목록 페이지 | ✅ | fedex/0010/page.tsx |
| 8-2-3 | FedEx 수입정정 등록/상세 페이지 | ✅ | write + [sq] 페이지 |
---
## 9. 메인/대시보드 (Main)
> 원본: `Main0010Controller`, `Main0020Controller`, `main0010_sql.xml`, `main0020_sql.xml`
### 9-1. 백엔드
| # | 작업 | 상태 | 비고 |
|---|------|------|------|
| 9-1-1 | main0010_sql.xml 분석 및 이식 | ✅ | main_sql.xml (직접 SQL, stored proc 불사용) |
| 9-1-2 | main0020_sql.xml 분석 및 이식 | ✅ | main_sql.xml 통합 |
| 9-1-3 | MainMapper / Service / Controller | ✅ | `/api/main/dashboard` |
### 9-2. 프론트엔드
| # | 작업 | 상태 | 비고 |
|---|------|------|------|
| 9-2-1 | mainApi 정의 | ✅ | lib/api/main.ts |
| 9-2-2 | 대시보드 페이지 | ✅ | 결재 대기 배너, 공지/게시판/근무 위젯 |
| 9-2-3 | 메인 레이아웃 완성 | ✅ | Sidebar 동적 메뉴 |
---
## 10. 공통 기능 (파일첨부 / 이메일)
### 10-1. 파일 첨부 (AttachService)
> 원본: `AttachController`, `AttachService`, `attach_sql.xml`
> 모든 모듈(게시판, 결재, 직원사진)에서 사용
| # | 작업 | 상태 | 비고 |
|---|------|------|------|
| 10-1-1 | attach_sql.xml 분석 및 이식 | ✅ | attach_sql.xml (MSSQL + MariaDB) |
| 10-1-2 | AttachMapper | ✅ | AttachMapper.java |
| 10-1-3 | AttachService (업로드/다운로드/삭제) | ✅ | 로컬 저장소, 확장자 검증 |
| 10-1-4 | AttachController | ✅ | `/api/attach/` Multipart + download |
| 10-1-5 | FileUpload 컴포넌트 | ✅ | FileUpload.tsx (드래그앤드롭) |
| 10-1-6 | 파일 다운로드 처리 | ✅ | Content-Disposition URLEncoder |
### 10-2. 이메일 서비스
| # | 작업 | 상태 | 비고 |
|---|------|------|------|
| 10-2-1 | EmailService 이식 | ⬜ | Spring Mail (SMTP) |
| 10-2-2 | 결재 알림 이메일 연동 | ⬜ | TAM 모듈 완성 후 |
---
## 11. 인프라 및 배포
| # | 작업 | 상태 | 비고 |
|---|------|------|------|
| 11-1 | Nginx 설정 (nginx.conf) | ✅ | API 프록시 + 파일 서빙 |
| 11-2 | docker-compose.yml 최종 완성 | ✅ | env_file, healthcheck, volumes |
| 11-3 | .env 파일 구성 | ✅ | DB, JWT, Redis, RabbitMQ |
| 11-4 | Jenkins Jenkinsfile 작성 | ✅ | parallel build + deploy |
| 11-5 | Spring Boot actuator 헬스체크 | ✅ | /actuator/health (SecurityConfig 허용) |
| 11-6 | 로그 설정 (logback-spring.xml) | ✅ | 프로파일별 콘솔/파일 분리 |
---
## 12. DB 마이그레이션 준비 (SQL Server → MariaDB)
| # | 작업 | 상태 | 비고 |
|---|------|------|------|
| 12-1 | 전체 SQL XML MariaDB 버전 작성 | ✅ | 전체 17개 XML 파일 완료 |
| 12-2 | GETDATE() → NOW() 변환 | ✅ | 모든 DML 문 MariaDB 버전에 반영 |
| 12-3 | ISNULL() → IFNULL() 변환 | ✅ | MariaDB databaseId 버전에 반영 |
| 12-4 | MERGE INTO → INSERT ON DUPLICATE KEY | ✅ | common_sql, envset0050_sql 반영 |
| 12-5 | SELECT TOP N → LIMIT N 변환 | ✅ | MariaDB 버전에 LIMIT 적용 |
| 12-6 | OFFSET...FETCH NEXT → LIMIT...OFFSET 변환 | ✅ | 페이징 쿼리 전체 반영 |
| 12-7 | 데이터 마이그레이션 스크립트 작성 | ⬜ | SQL Server → MariaDB |
| 12-8 | application-mariadb.yml 완성 | ⬜ | |
| 12-9 | MariaDB 환경에서 전체 기능 검증 | ⬜ | |
---
## 13. 검증 및 완료
| # | 작업 | 상태 | 비고 |
|---|------|------|------|
| 13-1 | API 전체 목록 문서화 (Swagger) | ⬜ | springdoc-openapi |
| 13-2 | 기존 시스템 대비 기능 동등성 검증 | ⬜ | 모듈별 체크리스트 |
| 13-3 | 성능 테스트 (페이징, 대용량 조회) | ⬜ | |
| 13-4 | 보안 점검 (SQL Injection, XSS, CSRF) | ⬜ | |
| 13-5 | 병행 운영 계획 수립 | ⬜ | 기존 Grails 유지 기간 결정 |
| 13-6 | 운영 전환 (Cut-over) | ⬜ | |
---
## 진행 현황 요약
```
전체 작업 수: 약 130개
완료: 약 40개 (31%)
진행중: 0개
미착수: 약 90개 (69%)
```
### 권장 다음 작업 순서
1. **1-1-10** 파일 첨부 서비스 → 게시판, 결재, 직원사진 모두 필요
2. **4** 게시판 → 가장 단순한 CRUD, 패턴 확립용
3. **5** 결재 → 핵심 업무 기능 (복잡도 높음)
4. **6, 7** 근무계획/근무시간
5. **8** FedEx
6. **9** 대시보드
7. **11** 인프라
8. **12** MariaDB 전환 준비

10
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.gradle/
build/
*.class
*.jar
!gradle/wrapper/gradle-wrapper.jar
.idea/
*.iml
out/
.env
application-local.yml

9
backend/Dockerfile Normal file
View File

@@ -0,0 +1,9 @@
FROM openjdk:21-jdk-slim AS builder
WORKDIR /app
COPY . .
RUN ./gradlew bootJar -x test
FROM openjdk:21-jdk-slim
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

92
backend/build.gradle Normal file
View File

@@ -0,0 +1,92 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.4.1'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.company'
version = '1.0.0-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
ext {
set('springCloudVersion', '2024.0.0')
set('mybatisVersion', '3.0.4')
set('jjwtVersion', '0.12.6')
}
dependencies {
// Web
implementation 'org.springframework.boot:spring-boot-starter-web'
// Actuator (헬스체크)
implementation 'org.springframework.boot:spring-boot-starter-actuator'
// Security
implementation 'org.springframework.boot:spring-boot-starter-security'
// JWT
implementation "io.jsonwebtoken:jjwt-api:${jjwtVersion}"
runtimeOnly "io.jsonwebtoken:jjwt-impl:${jjwtVersion}"
runtimeOnly "io.jsonwebtoken:jjwt-jackson:${jjwtVersion}"
// MyBatis
implementation "org.mybatis.spring.boot:mybatis-spring-boot-starter:${mybatisVersion}"
// MS SQL Server JDBC (현재 사용) - 9.4.1: encrypt=false 시 TLS 미사용 (TLS1.0 서버 호환)
runtimeOnly 'com.microsoft.sqlserver:mssql-jdbc:9.4.1.jre11'
// MariaDB JDBC (추후 전환용)
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// RabbitMQ
implementation 'org.springframework.boot:spring-boot-starter-amqp'
// Validation
implementation 'org.springframework.boot:spring-boot-starter-validation'
// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// Excel (Apache POI)
implementation 'org.apache.poi:poi-ooxml:5.3.0'
// Test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testImplementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter-test:3.0.4'
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
tasks.named('test') {
useJUnitPlatform()
}
bootRun {
args '--spring.profiles.active=mssql,local'
jvmArgs "-Djava.security.properties=${projectDir}/tls-local.security"
}

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
backend/gradlew vendored Normal file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
backend/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

1
backend/settings.gradle Normal file
View File

@@ -0,0 +1 @@
rootProject.name = 'gw-backend'

View File

@@ -0,0 +1,16 @@
package com.company.gw;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
@EnableCaching
public class GwBackendApplication {
public static void main(String[] args) {
SpringApplication.run(GwBackendApplication.class, args);
}
}

View File

@@ -0,0 +1,115 @@
package com.company.gw.board.controller;
import com.company.gw.board.service.BoardService;
import com.company.gw.common.dto.ApiResponse;
import com.company.gw.common.dto.CurrentUser;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/board")
@RequiredArgsConstructor
public class BoardController {
private final BoardService boardService;
/** 게시물 목록 */
@GetMapping("/{untyBbsCd}")
public ApiResponse<Map<String, Object>> getPostList(
@PathVariable String untyBbsCd,
@RequestParam(defaultValue = "") String searchText,
@RequestParam(defaultValue = "1") int pageNo,
@RequestParam(defaultValue = "20") int pageSize,
@AuthenticationPrincipal CurrentUser currentUser) {
return ApiResponse.ok(
boardService.getPostList(currentUser.getCorpNo(), untyBbsCd, searchText, pageNo, pageSize));
}
/** 게시물 상세 (조회수 증가) */
@GetMapping("/{untyBbsCd}/{untyBbsSno}")
public ApiResponse<Map<String, Object>> getPostInfo(
@PathVariable String untyBbsCd,
@PathVariable Long untyBbsSno,
@AuthenticationPrincipal CurrentUser currentUser) {
return ApiResponse.ok(
boardService.getPostInfoForView(currentUser.getCorpNo(), untyBbsCd, untyBbsSno));
}
/** 게시물 상세 (수정용, 조회수 미증가) */
@GetMapping("/{untyBbsCd}/{untyBbsSno}/edit")
public ApiResponse<Map<String, Object>> getPostInfoForEdit(
@PathVariable String untyBbsCd,
@PathVariable Long untyBbsSno,
@AuthenticationPrincipal CurrentUser currentUser) {
return ApiResponse.ok(
boardService.getPostInfoForEdit(currentUser.getCorpNo(), untyBbsCd, untyBbsSno));
}
/** 게시물 등록 */
@PostMapping("/{untyBbsCd}")
public ApiResponse<Map<String, Object>> insertPost(
@PathVariable String untyBbsCd,
@RequestBody Map<String, Object> body,
@AuthenticationPrincipal CurrentUser currentUser) {
body.put("untyBbsCd", untyBbsCd);
return ApiResponse.ok(boardService.insertPost(currentUser.getCorpNo(), body));
}
/** 게시물 수정 */
@PutMapping("/{untyBbsCd}/{untyBbsSno}")
public ApiResponse<Void> updatePost(
@PathVariable String untyBbsCd,
@PathVariable Long untyBbsSno,
@RequestBody Map<String, Object> body,
@AuthenticationPrincipal CurrentUser currentUser) {
body.put("untyBbsCd", untyBbsCd);
boardService.updatePost(currentUser.getCorpNo(), untyBbsSno, body);
return ApiResponse.ok(null);
}
/** 게시물 삭제 */
@DeleteMapping("/{untyBbsCd}/{untyBbsSno}")
public ApiResponse<Void> deletePost(
@PathVariable String untyBbsCd,
@PathVariable Long untyBbsSno,
@AuthenticationPrincipal CurrentUser currentUser) {
boardService.deletePost(currentUser.getCorpNo(), untyBbsCd, untyBbsSno);
return ApiResponse.ok(null);
}
/** 댓글 목록 */
@GetMapping("/{untyBbsCd}/{untyBbsSno}/comments")
public ApiResponse<List<Map<String, Object>>> getCommentList(
@PathVariable String untyBbsCd,
@PathVariable Long untyBbsSno,
@AuthenticationPrincipal CurrentUser currentUser) {
return ApiResponse.ok(
boardService.getCommentList(currentUser.getCorpNo(), untyBbsCd, untyBbsSno));
}
/** 댓글 등록 */
@PostMapping("/{untyBbsCd}/{untyBbsSno}/comments")
public ApiResponse<Void> insertComment(
@PathVariable String untyBbsCd,
@PathVariable Long untyBbsSno,
@RequestBody Map<String, String> body,
@AuthenticationPrincipal CurrentUser currentUser) {
boardService.insertComment(currentUser.getCorpNo(), untyBbsCd, untyBbsSno, body.get("cmmtCn"));
return ApiResponse.ok(null);
}
/** 댓글 삭제 */
@DeleteMapping("/{untyBbsCd}/{untyBbsSno}/comments/{cmmtSno}")
public ApiResponse<Void> deleteComment(
@PathVariable String untyBbsCd,
@PathVariable Long untyBbsSno,
@PathVariable Long cmmtSno,
@AuthenticationPrincipal CurrentUser currentUser) {
boardService.deleteComment(currentUser.getCorpNo(), untyBbsCd, untyBbsSno, cmmtSno);
return ApiResponse.ok(null);
}
}

View File

@@ -0,0 +1,40 @@
package com.company.gw.board.mapper;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
import java.util.Map;
@Mapper
public interface BoardMapper {
List<Map<String, Object>> getPostList(Map<String, Object> param);
long getPostListCount(Map<String, Object> param);
Map<String, Object> getPostInfo(Map<String, Object> param);
void insertPost(Map<String, Object> param);
void updatePost(Map<String, Object> param);
void increaseInqrCnt(Map<String, Object> param);
void updateEtcAtchNo(Map<String, Object> param);
void updatePhotoAtchNo(Map<String, Object> param);
void updateCmmtCnt(Map<String, Object> param);
void deletePost(Map<String, Object> param);
List<Map<String, Object>> getCommentList(Map<String, Object> param);
Map<String, Object> getCommentInfo(Map<String, Object> param);
void insertComment(Map<String, Object> param);
void deleteComment(Map<String, Object> param);
void deleteCommentsByPost(Map<String, Object> param);
}

View File

@@ -0,0 +1,228 @@
package com.company.gw.board.service;
import com.company.gw.board.mapper.BoardMapper;
import com.company.gw.common.exception.BizException;
import com.company.gw.common.service.AttachService;
import com.company.gw.common.service.SequenceService;
import com.company.gw.common.util.SecurityUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class BoardService {
private final BoardMapper boardMapper;
private final SequenceService sequenceService;
private final AttachService attachService;
// ──────────────────────────────────────────────
// 게시물 목록
// ──────────────────────────────────────────────
public Map<String, Object> getPostList(String corpNo, String untyBbsCd,
String searchText, int pageNo, int pageSize) {
if (pageSize > 200) pageSize = 200;
Map<String, Object> param = new HashMap<>();
param.put("corpNo", corpNo);
param.put("untyBbsCd", untyBbsCd);
param.put("searchText", searchText);
param.put("pageNo", pageNo);
param.put("pageSize", pageSize);
List<Map<String, Object>> list = boardMapper.getPostList(param);
long total = boardMapper.getPostListCount(param);
Map<String, Object> result = new HashMap<>();
result.put("list", list);
result.put("total", total);
result.put("pageNo", pageNo);
result.put("pageSize", pageSize);
return result;
}
// ──────────────────────────────────────────────
// 게시물 상세 (조회수 증가 포함)
// ──────────────────────────────────────────────
@Transactional
public Map<String, Object> getPostInfoForView(String corpNo, String untyBbsCd, Long untyBbsSno) {
Map<String, Object> param = buildPostParam(corpNo, untyBbsCd, untyBbsSno);
boardMapper.increaseInqrCnt(param);
Map<String, Object> info = boardMapper.getPostInfo(param);
if (info == null) throw new BizException("게시물이 존재하지 않습니다.", HttpStatus.NOT_FOUND);
// 첨부파일 목록 조회
String etcAtchNo = (String) info.get("ETC_ATCH_NO");
String photoAtchNo = (String) info.get("PHOTO_ATCH_NO");
if (etcAtchNo != null) info.put("etcFileList", attachService.getFileList(etcAtchNo));
if (photoAtchNo != null) info.put("photoFileList", attachService.getFileList(photoAtchNo));
return info;
}
// 수정화면용 (조회수 증가 없음)
@Transactional(readOnly = true)
public Map<String, Object> getPostInfoForEdit(String corpNo, String untyBbsCd, Long untyBbsSno) {
Map<String, Object> info = boardMapper.getPostInfo(buildPostParam(corpNo, untyBbsCd, untyBbsSno));
if (info == null) throw new BizException("게시물이 존재하지 않습니다.", HttpStatus.NOT_FOUND);
String etcAtchNo = (String) info.get("ETC_ATCH_NO");
String photoAtchNo = (String) info.get("PHOTO_ATCH_NO");
if (etcAtchNo != null) info.put("etcFileList", attachService.getFileList(etcAtchNo));
if (photoAtchNo != null) info.put("photoFileList", attachService.getFileList(photoAtchNo));
return info;
}
// ──────────────────────────────────────────────
// 게시물 등록
// ──────────────────────────────────────────────
@Transactional
public Map<String, Object> insertPost(String corpNo, Map<String, Object> body) {
String usrId = SecurityUtil.getUsrId();
Integer sno = Integer.parseInt(
sequenceService.getNextSeqString("SX_GW0020.UNTY_BBS_SNO", 10));
Map<String, Object> param = new HashMap<>(body);
param.put("corpNo", corpNo);
param.put("usrId", usrId);
param.put("untyBbsSno", sno);
boardMapper.insertPost(param);
// 첨부 확정
String etcAtchNo = (String) body.get("etcAtchNo");
String photoAtchNo = (String) body.get("photoAtchNo");
if (etcAtchNo != null) {
param.put("etcAtchNo", etcAtchNo);
boardMapper.updateEtcAtchNo(param);
attachService.confirmAtchNo(etcAtchNo, "SX_GW0020.ETC_ATCH_NO");
}
if (photoAtchNo != null) {
param.put("photoAtchNo", photoAtchNo);
boardMapper.updatePhotoAtchNo(param);
attachService.confirmAtchNo(photoAtchNo, "SX_GW0020.PHOTO_ATCH_NO");
}
Map<String, Object> result = new HashMap<>();
result.put("untyBbsSno", sno);
return result;
}
// ──────────────────────────────────────────────
// 게시물 수정
// ──────────────────────────────────────────────
@Transactional
public void updatePost(String corpNo, Long untyBbsSno, Map<String, Object> body) {
String usrId = SecurityUtil.getUsrId();
Map<String, Object> param = buildPostParam(corpNo, (String) body.get("untyBbsCd"), untyBbsSno);
Map<String, Object> existing = boardMapper.getPostInfo(param);
if (existing == null) throw new BizException("게시물이 존재하지 않습니다.", HttpStatus.NOT_FOUND);
if (!usrId.equals(existing.get("CTUSR_ID"))) {
throw new BizException("작성자만 수정할 수 있습니다.", HttpStatus.FORBIDDEN);
}
param.putAll(body);
param.put("usrId", usrId);
boardMapper.updatePost(param);
// 첨부 처리 (새 파일이 업로드된 경우)
String etcAtchNo = (String) body.get("etcAtchNo");
if (etcAtchNo != null) {
param.put("etcAtchNo", etcAtchNo);
boardMapper.updateEtcAtchNo(param);
attachService.confirmAtchNo(etcAtchNo, "SX_GW0020.ETC_ATCH_NO");
}
}
// ──────────────────────────────────────────────
// 게시물 삭제
// ──────────────────────────────────────────────
@Transactional
public void deletePost(String corpNo, String untyBbsCd, Long untyBbsSno) {
String usrId = SecurityUtil.getUsrId();
Map<String, Object> param = buildPostParam(corpNo, untyBbsCd, untyBbsSno);
Map<String, Object> info = boardMapper.getPostInfo(param);
if (info == null) throw new BizException("게시물이 존재하지 않습니다.", HttpStatus.NOT_FOUND);
if (!usrId.equals(info.get("CTUSR_ID"))) {
throw new BizException("작성자만 삭제할 수 있습니다.", HttpStatus.FORBIDDEN);
}
boardMapper.deleteCommentsByPost(param);
boardMapper.deletePost(param);
// 첨부파일 삭제
String etcAtchNo = (String) info.get("ETC_ATCH_NO");
String photoAtchNo = (String) info.get("PHOTO_ATCH_NO");
if (etcAtchNo != null) attachService.deleteFilesByAtchNo(etcAtchNo, true);
if (photoAtchNo != null) attachService.deleteFilesByAtchNo(photoAtchNo, true);
}
// ──────────────────────────────────────────────
// 댓글
// ──────────────────────────────────────────────
@Transactional(readOnly = true)
public List<Map<String, Object>> getCommentList(String corpNo, String untyBbsCd, Long untyBbsSno) {
return boardMapper.getCommentList(buildPostParam(corpNo, untyBbsCd, untyBbsSno));
}
@Transactional
public void insertComment(String corpNo, String untyBbsCd, Long untyBbsSno, String cmmtCn) {
String usrId = SecurityUtil.getUsrId();
Integer cmmtSno = Integer.parseInt(
sequenceService.getNextSeqString("SX_GW0030.CMMT_SNO", 10));
Map<String, Object> param = buildPostParam(corpNo, untyBbsCd, untyBbsSno);
param.put("cmmtSno", cmmtSno);
param.put("cmmtCn", cmmtCn);
param.put("usrId", usrId);
boardMapper.insertComment(param);
boardMapper.updateCmmtCnt(param);
}
@Transactional
public void deleteComment(String corpNo, String untyBbsCd, Long untyBbsSno, Long cmmtSno) {
String usrId = SecurityUtil.getUsrId();
Map<String, Object> param = buildPostParam(corpNo, untyBbsCd, untyBbsSno);
param.put("cmmtSno", cmmtSno);
param.put("usrId", usrId);
Map<String, Object> info = boardMapper.getCommentInfo(param);
if (info == null) throw new BizException("댓글이 존재하지 않습니다.", HttpStatus.NOT_FOUND);
if (!usrId.equals(info.get("CMMT_CTUSR_ID"))) {
throw new BizException("작성자만 삭제할 수 있습니다.", HttpStatus.FORBIDDEN);
}
boardMapper.deleteComment(param);
boardMapper.updateCmmtCnt(param);
}
// ──────────────────────────────────────────────
// 내부 유틸
// ──────────────────────────────────────────────
private Map<String, Object> buildPostParam(String corpNo, String untyBbsCd, Long untyBbsSno) {
Map<String, Object> p = new HashMap<>();
p.put("corpNo", corpNo);
p.put("untyBbsCd", untyBbsCd);
p.put("untyBbsSno", untyBbsSno);
return p;
}
}

View File

@@ -0,0 +1,69 @@
package com.company.gw.common.config;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.module.SimpleModule;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.io.IOException;
import java.util.Map;
/**
* MyBatis resultType="map" 은 DB 컬럼명을 그대로 Map 키로 반환합니다 (e.g. WORK_START_DT).
* 프론트엔드는 camelCase(e.g. workStartDt)를 기대하므로, JSON 직렬화 시 변환합니다.
* UPPER_CASE_SNAKE 패턴만 변환하고, 이미 camelCase인 키는 그대로 유지합니다.
*/
@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer mapKeyCamelCaseCustomizer() {
return builder -> {
SimpleModule module = new SimpleModule();
module.addSerializer(Map.class, new MapCamelCaseSerializer());
builder.modules(module);
};
}
@SuppressWarnings({"rawtypes", "unchecked"})
static class MapCamelCaseSerializer extends JsonSerializer<Map> {
@Override
public void serialize(Map map, JsonGenerator gen, SerializerProvider serializers) throws IOException {
gen.writeStartObject();
for (Object obj : map.entrySet()) {
Map.Entry entry = (Map.Entry) obj;
String key = entry.getKey() != null ? convertKey(entry.getKey().toString()) : "null";
gen.writeFieldName(key);
serializers.defaultSerializeValue(entry.getValue(), gen);
}
gen.writeEndObject();
}
/**
* UPPER_CASE_SNAKE 패턴인 경우에만 camelCase로 변환.
* 이미 camelCase 이거나 소문자인 키는 그대로 반환.
*/
private String convertKey(String key) {
// 모두 대문자+언더스코어 패턴인 경우만 변환
if (!key.matches("[A-Z][A-Z0-9_]*")) return key;
StringBuilder result = new StringBuilder();
boolean nextUpper = false;
for (int i = 0; i < key.length(); i++) {
char c = key.charAt(i);
if (c == '_') {
nextUpper = true;
} else if (nextUpper) {
result.append(Character.toUpperCase(c));
nextUpper = false;
} else {
result.append(Character.toLowerCase(c));
}
}
return result.toString();
}
}
}

View File

@@ -0,0 +1,53 @@
package com.company.gw.common.config;
import org.apache.ibatis.mapping.VendorDatabaseIdProvider;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import javax.sql.DataSource;
import java.util.Properties;
@Configuration
@MapperScan("com.company.gw.**.mapper")
public class MyBatisConfig {
@Bean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
factoryBean.setDataSource(dataSource);
factoryBean.setMapperLocations(
new PathMatchingResourcePatternResolver().getResources("classpath:mapper/**/*.xml")
);
// databaseId 벤더 매핑
// SQL Server → "mssql" (기본 구문은 databaseId 없거나 "mssql")
// MySQL → "mariadb" (MariaDB JDBC 2.x가 MySQL로 리포트)
// MariaDB → "mariadb" (MariaDB JDBC 3.x+)
VendorDatabaseIdProvider databaseIdProvider = new VendorDatabaseIdProvider();
Properties vendorProps = new Properties();
vendorProps.setProperty("SQL Server", "mssql");
vendorProps.setProperty("MySQL", "mariadb");
vendorProps.setProperty("MariaDB", "mariadb");
databaseIdProvider.setProperties(vendorProps);
factoryBean.setDatabaseIdProvider(databaseIdProvider);
// application.yml의 mybatis.configuration이 수동 Bean 생성 시 무시되므로 직접 설정
org.apache.ibatis.session.Configuration mybatisConfig = new org.apache.ibatis.session.Configuration();
mybatisConfig.setMapUnderscoreToCamelCase(true);
mybatisConfig.setDefaultFetchSize(100);
mybatisConfig.setDefaultStatementTimeout(30);
factoryBean.setConfiguration(mybatisConfig);
return factoryBean.getObject();
}
@Bean
public SqlSessionTemplate sqlSessionTemplate(SqlSessionFactory sqlSessionFactory) {
return new SqlSessionTemplate(sqlSessionFactory);
}
}

View File

@@ -0,0 +1,61 @@
package com.company.gw.common.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.CacheManager;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
import java.util.Map;
@Slf4j
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
return template;
}
@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
try {
// Redis 연결 가능 여부 확인
connectionFactory.getConnection().ping();
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(1))
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
Map<String, RedisCacheConfiguration> cacheConfigs = Map.of(
"code", defaultConfig.entryTtl(Duration.ofHours(6)),
"workCd", defaultConfig.entryTtl(Duration.ofHours(6))
);
log.info("CacheManager: Redis 사용");
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(cacheConfigs)
.build();
} catch (Exception e) {
log.warn("Redis 연결 실패 → In-Memory CacheManager 사용 ({})", e.getMessage());
return new ConcurrentMapCacheManager("code", "workCd");
}
}
}

View File

@@ -0,0 +1,85 @@
package com.company.gw.common.config;
import com.company.gw.common.filter.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.anyRequest().authenticated()
)
.exceptionHandling(ex -> ex
// 인증 안 된 요청 → 401 (프론트 interceptor가 refresh 시도)
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"message\":\"Unauthorized\"}");
})
// 인증은 됐지만 권한 없음 → 403
.accessDeniedHandler((request, response, accessDeniedException) -> {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"message\":\"Forbidden\"}");
})
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(List.of("*"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}

View File

@@ -0,0 +1,133 @@
package com.company.gw.common.controller;
import com.company.gw.common.dto.ApiResponse;
import com.company.gw.common.dto.AttachDto;
import com.company.gw.common.exception.BizException;
import com.company.gw.common.service.AttachService;
import lombok.RequiredArgsConstructor;
import org.apache.commons.io.FilenameUtils;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@RestController
@RequestMapping("/api/attach")
@RequiredArgsConstructor
public class AttachController {
private final AttachService attachService;
/** 단일 파일 업로드 */
@PostMapping("/upload")
public ApiResponse<AttachDto> upload(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "atchNo", required = false) String atchNo,
@RequestParam(value = "division", required = false) String division) {
return ApiResponse.ok(attachService.uploadFile(file, atchNo, division));
}
/** 다중 파일 업로드 */
@PostMapping("/upload/multi")
public ApiResponse<List<AttachDto>> uploadMulti(
@RequestParam("files") List<MultipartFile> files,
@RequestParam(value = "atchNo", required = false) String atchNo,
@RequestParam(value = "division", required = false) String division) {
return ApiResponse.ok(attachService.uploadFiles(files, atchNo, division));
}
/** 에디터 이미지 업로드 */
@PostMapping("/upload/image")
public ApiResponse<Map<String, String>> uploadImage(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "division", required = false) String division) {
return ApiResponse.ok(attachService.uploadEditorImage(file, division));
}
/** 첨부번호로 파일 목록 조회 */
@GetMapping("/list/{atchNo}")
public ApiResponse<List<Map<String, Object>>> getFileList(@PathVariable String atchNo) {
return ApiResponse.ok(attachService.getFileList(atchNo));
}
/** 단일 파일 다운로드 */
@GetMapping("/download/{atchfileNo}")
public ResponseEntity<Resource> download(@PathVariable String atchfileNo) {
Map<String, Object> info = attachService.getFileInfo(atchfileNo);
File file = attachService.getPhysicalFile(atchfileNo);
String fileName = (String) info.get("ATCH_FILE_NM");
String encodedName;
try {
encodedName = URLEncoder.encode(fileName, StandardCharsets.UTF_8.name())
.replace("+", "%20");
} catch (UnsupportedEncodingException e) {
encodedName = fileName;
}
String contentType = (String) info.getOrDefault("ATCH_TYPE_NM",
MediaType.APPLICATION_OCTET_STREAM_VALUE);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''" + encodedName)
.contentType(MediaType.parseMediaType(contentType))
.contentLength(file.length())
.body(new FileSystemResource(file));
}
/** 전체 파일 ZIP 다운로드 */
@GetMapping("/download-all/{atchNo}")
public ResponseEntity<byte[]> downloadAll(@PathVariable String atchNo) throws IOException {
List<Map<String, Object>> fileList = attachService.getFileList(atchNo);
if (fileList == null || fileList.isEmpty()) {
throw new BizException("다운로드할 파일이 없습니다.", HttpStatus.NOT_FOUND);
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos, StandardCharsets.UTF_8)) {
for (Map<String, Object> info : fileList) {
String atchfileNo = (String) info.get("ATCHFILE_NO");
String fileName = (String) info.get("ATCH_FILE_NM");
try {
File physicalFile = attachService.getPhysicalFile(atchfileNo);
zos.putNextEntry(new ZipEntry(fileName != null ? fileName : atchfileNo));
try (FileInputStream fis = new FileInputStream(physicalFile)) {
fis.transferTo(zos);
}
zos.closeEntry();
} catch (Exception e) {
// 개별 파일 오류 시 건너뜀
}
}
}
String encodedName = URLEncoder.encode("첨부파일.zip", StandardCharsets.UTF_8)
.replace("+", "%20");
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''" + encodedName)
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(baos.toByteArray());
}
/** 파일 삭제 */
@DeleteMapping("/{atchfileNo}")
public ApiResponse<Void> delete(@PathVariable String atchfileNo) {
attachService.deleteFile(atchfileNo, false);
return ApiResponse.ok(null);
}
}

View File

@@ -0,0 +1,88 @@
package com.company.gw.common.controller;
import com.company.gw.common.dto.ApiResponse;
import com.company.gw.common.dto.CurrentUser;
import com.company.gw.common.dto.LoginRequestDto;
import com.company.gw.common.dto.LoginResponseDto;
import com.company.gw.common.dto.RefreshRequestDto;
import com.company.gw.common.service.AuthService;
import com.company.gw.common.util.JwtUtil;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
private final JwtUtil jwtUtil;
/**
* 로그인
* POST /api/auth/login
*/
@PostMapping("/login")
public ResponseEntity<ApiResponse<LoginResponseDto>> login(
@Valid @RequestBody LoginRequestDto req) {
LoginResponseDto result = authService.login(req);
return ResponseEntity.ok(ApiResponse.ok(result));
}
/**
* 로그아웃
* POST /api/auth/logout
*/
@PostMapping("/logout")
public ResponseEntity<ApiResponse<Void>> logout(
@AuthenticationPrincipal UserDetails userDetails) {
authService.logout(userDetails.getUsername());
return ResponseEntity.ok(ApiResponse.ok(null));
}
/**
* Access Token 재발급
* POST /api/auth/refresh
*/
@PostMapping("/refresh")
public ResponseEntity<ApiResponse<String>> refresh(
@Valid @RequestBody RefreshRequestDto req) {
LoginResponseDto.UserInfo userInfo = authService.refreshToken(req.getRefreshToken());
String roleCode = (userInfo.getRoles() != null && !userInfo.getRoles().isEmpty())
? userInfo.getRoles().get(0) : "USER";
String newAccessToken = jwtUtil.generateAccessToken(userInfo.getUsrId(), userInfo.getCorpNo(), roleCode);
return ResponseEntity.ok(ApiResponse.ok(newAccessToken));
}
/**
* 현재 로그인 사용자 정보
* GET /api/auth/me
*/
@GetMapping("/me")
public ResponseEntity<ApiResponse<String>> me(
@AuthenticationPrincipal UserDetails userDetails) {
return ResponseEntity.ok(ApiResponse.ok(userDetails.getUsername()));
}
/**
* 비밀번호 변경
* POST /api/auth/change-pw
*/
@PostMapping("/change-pw")
public ResponseEntity<ApiResponse<Void>> changePassword(
@AuthenticationPrincipal CurrentUser currentUser,
@RequestBody Map<String, String> body) {
authService.changePassword(
currentUser.getUsrId(),
body.get("oldPw"),
body.get("newPw")
);
return ResponseEntity.ok(ApiResponse.ok(null));
}
}

View File

@@ -0,0 +1,78 @@
package com.company.gw.common.controller;
import com.company.gw.common.dto.ApiResponse;
import com.company.gw.common.dto.CurrentUser;
import com.company.gw.common.service.CodeService;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.CacheManager;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@RestController
@RequestMapping("/api/code")
@RequiredArgsConstructor
public class CodeController {
private final CodeService codeService;
private final CacheManager cacheManager;
/**
* 공통코드 목록 조회
* GET /api/code?commClCd=SX001&useYn=Y
*/
@GetMapping
public ApiResponse<List<Map<String, Object>>> getCodeList(
@RequestParam String commClCd,
@RequestParam(defaultValue = "") String useYn,
@AuthenticationPrincipal CurrentUser currentUser) {
return ApiResponse.ok(codeService.getCodeList(currentUser.getCorpNo(), commClCd, useYn));
}
/**
* 공통코드 전체 정보 조회 (PROP_CD 포함)
*/
@GetMapping("/full")
public ApiResponse<List<Map<String, Object>>> getCodeListFull(
@RequestParam String commClCd,
@RequestParam(defaultValue = "") String useYn,
@AuthenticationPrincipal CurrentUser currentUser) {
return ApiResponse.ok(codeService.getCodeListFull(currentUser.getCorpNo(), commClCd, useYn));
}
/**
* 직원 검색 (결재자 팝업 등)
* GET /api/code/users?searchText=홍&apprOnly=Y
*/
@GetMapping("/users")
public ApiResponse<List<Map<String, Object>>> searchUser(
@RequestParam(defaultValue = "") String searchText,
@RequestParam(defaultValue = "N") String apprOnly,
@AuthenticationPrincipal CurrentUser currentUser) {
return ApiResponse.ok(codeService.searchUser(
currentUser.getCorpNo(), searchText, "Y".equals(apprOnly)));
}
/**
* 근무코드 목록 조회
*/
@GetMapping("/workcd")
public ApiResponse<List<Map<String, Object>>> getWorkCdList(
@RequestParam(defaultValue = "") String useYn,
@AuthenticationPrincipal CurrentUser currentUser) {
return ApiResponse.ok(codeService.getWorkCdList(currentUser.getCorpNo(), useYn));
}
/**
* 캐시 초기화 (코드 변경 후 호출)
*/
@PostMapping("/cache/evict")
public ApiResponse<Void> evictCache() {
Objects.requireNonNull(cacheManager.getCache("code")).clear();
Objects.requireNonNull(cacheManager.getCache("workCd")).clear();
return ApiResponse.ok(null);
}
}

View File

@@ -0,0 +1,116 @@
package com.company.gw.common.controller;
import com.company.gw.common.dto.CurrentUser;
import com.company.gw.common.util.ExcelUtil;
import com.company.gw.tam.mapper.TamMapper;
import com.company.gw.wtime.mapper.WtimeMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Excel 내보내기 공통 컨트롤러
*/
@RestController
@RequestMapping("/api/excel")
@RequiredArgsConstructor
public class ExcelController {
private final WtimeMapper wtimeMapper;
private final TamMapper tamMapper;
/** 근무시간 목록 엑셀 */
@GetMapping("/wtime")
public ResponseEntity<byte[]> wtimeExcel(
@RequestParam Map<String, Object> params,
@AuthenticationPrincipal CurrentUser cu) throws IOException {
params.put("corpNo", cu.getCorpNo());
setDefault(params, "staYmd", "00000000");
setDefault(params, "endYmd", "99999999");
setDefault(params, "includeRetireYn", "N");
setDefault(params, "includeWorkYn", "N");
List<Map<String, Object>> data = wtimeMapper.getWtimeList(params);
return ExcelUtil.toExcel("근무시간",
new String[]{"날짜", "이름", "", "직위", "근무코드", "출근", "퇴근", "총근무(분)", "지각(분)", "OT(분)", "결근"},
new String[]{"WORK_PLAN_YYMMDD", "USR_NM", "TEAM_CD", "DUTY_CD",
"REAL_WORK_CD", "WORK_START_DT", "WORK_END_DT",
"TOT_WORK_MIN", "LATE_MIN", "OT_WORK_MIN", "ABTI_YN"},
data);
}
/** 월별 근무통계 엑셀 */
@GetMapping("/wstat")
public ResponseEntity<byte[]> wstatExcel(
@RequestParam Map<String, Object> params,
@AuthenticationPrincipal CurrentUser cu) throws IOException {
params.put("corpNo", cu.getCorpNo());
setDefault(params, "staYmd", "00000000");
setDefault(params, "endYmd", "99999999");
setDefault(params, "includeRetireYn", "N");
List<Map<String, Object>> data = wtimeMapper.getWstatList(params);
return ExcelUtil.toExcel("근무통계",
new String[]{"이름", "", "직위", "연차", "근무일", "결근", "지각일", "조퇴일", "총근무(분)", "지각(분)", "OT(분)"},
new String[]{"USR_NM", "TEAM_CD", "DUTY_CD", "YYVCT_CNT",
"WORK_DCNT", "ABTI_DCNT", "LATE_DCNT", "SKIPOFF_DCNT",
"TOT_WORK_MIN", "LATE_MIN", "OT_WORK_MIN"},
data);
}
/** 결재 신청 목록 엑셀 (TAM0020) */
@GetMapping("/tam-apvreq")
public ResponseEntity<byte[]> tamApvreqExcel(
@RequestParam Map<String, Object> params,
@AuthenticationPrincipal CurrentUser cu) throws IOException {
params.put("corpNo", cu.getCorpNo());
params.put("usrId", cu.getUsrId());
setDefault(params, "staYmd", "00000000");
setDefault(params, "endYmd", "99999999");
setDefault(params, "pageNo", 1);
setDefault(params, "pageSize", 10000);
List<Map<String, Object>> data = tamMapper.getApvreqList(params);
return ExcelUtil.toExcel("결재신청목록",
new String[]{"문서ID", "상태", "종류", "상신일", "완료일"},
new String[]{"APRVL_DOC_ID", "APRVL_STUS_CD", "APRVL_KIND_CD", "OFFER_DT", "CMPL_DT"},
data);
}
/** 근태 현황 엑셀 (TAM0040) */
@GetMapping("/tam-status")
public ResponseEntity<byte[]> tamStatusExcel(
@RequestParam Map<String, Object> params,
@AuthenticationPrincipal CurrentUser cu) throws IOException {
params.put("corpNo", cu.getCorpNo());
setDefault(params, "staYmd", "00000000");
setDefault(params, "endYmd", "99999999");
setDefault(params, "includeRetireYn", "N");
List<Map<String, Object>> data = tamMapper.getTamStatusList(params);
return ExcelUtil.toExcel("근태현황",
new String[]{"이름", "", "직위", "연차", "결재건수"},
new String[]{"USR_NM", "TEAM_CD", "DUTY_CD", "YYVCT_CNT", "APRVL_CNT"},
data);
}
private void setDefault(Map<String, Object> params, String key, Object defaultVal) {
if (!params.containsKey(key) || params.get(key) == null) {
params.put(key, defaultVal);
}
}
}

View File

@@ -0,0 +1,46 @@
package com.company.gw.common.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ApiResponse<T> {
private boolean success;
private T data;
private String message;
private PaginationInfo pagination;
public static <T> ApiResponse<T> ok(T data) {
return ApiResponse.<T>builder()
.success(true)
.data(data)
.build();
}
public static <T> ApiResponse<T> ok(T data, PaginationInfo pagination) {
return ApiResponse.<T>builder()
.success(true)
.data(data)
.pagination(pagination)
.build();
}
public static <T> ApiResponse<T> fail(String message) {
return ApiResponse.<T>builder()
.success(false)
.message(message)
.build();
}
@Getter
@Builder
public static class PaginationInfo {
private int page;
private int size;
private long total;
}
}

View File

@@ -0,0 +1,16 @@
package com.company.gw.common.dto;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class AttachDto {
private String atchfileNo;
private String atchNo;
private String atchFilePathNm;
private String atchFileNm;
private String atchTypeNm;
private Long atchFileMg;
private String tblColNm;
}

View File

@@ -0,0 +1,12 @@
package com.company.gw.common.dto;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class ControllerRoleDto {
private String controller;
private String role;
private String funcAuthCn;
}

View File

@@ -0,0 +1,24 @@
package com.company.gw.common.dto;
import lombok.Builder;
import lombok.Getter;
import java.security.Principal;
/**
* 기존 Grails의 SS_CORP_NO, SS_USER_ID, ROLE_CD를 대체하는 현재 로그인 사용자 정보.
* JWT 클레임에서 추출하여 컨트롤러/서비스에서 사용.
* Principal 구현 → auth.getName()이 usrId를 반환하도록 보장.
*/
@Getter
@Builder
public class CurrentUser implements Principal {
private final String usrId;
private final String corpNo;
private final String roleCode;
@Override
public String getName() {
return usrId;
}
}

View File

@@ -0,0 +1,17 @@
package com.company.gw.common.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
@Getter
public class LoginRequestDto {
@NotBlank(message = "로그인 아이디를 입력하세요.")
private String loginId;
@NotBlank(message = "비밀번호를 입력하세요.")
private String loginPw;
@NotBlank(message = "권한을 선택하세요.")
private String menuAuthCd;
}

View File

@@ -0,0 +1,27 @@
package com.company.gw.common.dto;
import lombok.Builder;
import lombok.Getter;
import java.util.List;
@Getter
@Builder
public class LoginResponseDto {
private String accessToken;
private String refreshToken;
private UserInfo userInfo;
private List<MenuDto> menuList;
@Getter
@Builder
public static class UserInfo {
private String usrId;
private String usrNm;
private String loginId;
private String corpNo;
private String dutyCd;
private String roleCode; // 선택한 MENU_AUTH_CD
private List<String> roles; // 보유 전체 role 목록
}
}

View File

@@ -0,0 +1,18 @@
package com.company.gw.common.dto;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class MenuDto {
private String menuNo;
private String upperMenuNo;
private String menuNm;
private String url;
private String rm;
private String menuProp;
private Integer menuOrdr;
private String menuUseYn;
private Integer lvl;
}

View File

@@ -0,0 +1,11 @@
package com.company.gw.common.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
@Getter
public class RefreshRequestDto {
@NotBlank(message = "refreshToken이 필요합니다.")
private String refreshToken;
}

View File

@@ -0,0 +1,19 @@
package com.company.gw.common.dto;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class UserDto {
private String corpNo;
private String usrId;
private String usrNm;
private String loginId;
@JsonIgnore
private String pw;
private String dutyCd;
@JsonIgnore
private String retirementDate;
}

View File

@@ -0,0 +1,20 @@
package com.company.gw.common.exception;
import lombok.Getter;
import org.springframework.http.HttpStatus;
@Getter
public class BizException extends RuntimeException {
private final HttpStatus status;
public BizException(String message) {
super(message);
this.status = HttpStatus.BAD_REQUEST;
}
public BizException(String message, HttpStatus status) {
super(message);
this.status = status;
}
}

View File

@@ -0,0 +1,42 @@
package com.company.gw.common.exception;
import com.company.gw.common.dto.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BizException.class)
public ResponseEntity<ApiResponse<Void>> handleBizException(BizException e) {
log.warn("BizException: {}", e.getMessage());
return ResponseEntity
.status(e.getStatus())
.body(ApiResponse.fail(e.getMessage()));
}
@ExceptionHandler({MethodArgumentNotValidException.class, BindException.class})
public ResponseEntity<ApiResponse<Void>> handleValidationException(BindException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
.findFirst()
.orElse("입력값이 올바르지 않습니다.");
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.fail(message));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleException(Exception e) {
log.error("Unhandled exception", e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.fail("서버 오류가 발생했습니다."));
}
}

View File

@@ -0,0 +1,71 @@
package com.company.gw.common.filter;
import com.company.gw.common.dto.CurrentUser;
import com.company.gw.common.util.JwtUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = resolveToken(request);
if (StringUtils.hasText(token) && jwtUtil.isValid(token)) {
try {
String usrId = jwtUtil.getUserId(token);
String corpNo = jwtUtil.getCorpNo(token);
String roleCode = jwtUtil.getRoleCode(token);
if (StringUtils.hasText(usrId)) {
CurrentUser currentUser = CurrentUser.builder()
.usrId(usrId)
.corpNo(corpNo)
.roleCode(roleCode)
.build();
var authorities = StringUtils.hasText(roleCode)
? List.of(new SimpleGrantedAuthority("ROLE_" + roleCode))
: List.<SimpleGrantedAuthority>of();
var authentication = new UsernamePasswordAuthenticationToken(
currentUser, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
log.debug("JWT 인증 실패 - 인증 없이 계속 진행: {}", e.getMessage());
SecurityContextHolder.clearContext();
}
}
filterChain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}

View File

@@ -0,0 +1,24 @@
package com.company.gw.common.mapper;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
import java.util.Map;
@Mapper
public interface AttachMapper {
Map<String, Object> getFileInfo(String atchfileNo);
List<Map<String, Object>> getFileInfoListByAtchNo(String atchNo);
void insertFileInfo(Map<String, Object> param);
void deleteFileInfo(String atchfileNo);
void updateTblColNmByAtchNo(Map<String, Object> param);
void updateTblColNmByAtchfileNo(Map<String, Object> param);
List<Map<String, Object>> getNeedDeleteFileList();
}

View File

@@ -0,0 +1,18 @@
package com.company.gw.common.mapper;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
import java.util.Map;
@Mapper
public interface CodeMapper {
List<Map<String, Object>> getCodeList(Map<String, Object> param);
List<Map<String, Object>> getCodeListFull(Map<String, Object> param);
List<Map<String, Object>> searchUser(Map<String, Object> param);
List<Map<String, Object>> getWorkCdList(Map<String, Object> param);
}

View File

@@ -0,0 +1,12 @@
package com.company.gw.common.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface CommonMapper {
void increaseSequence(@Param("sequenceNm") String sequenceNm);
Long getSequenceVal(@Param("sequenceNm") String sequenceNm);
}

View File

@@ -0,0 +1,17 @@
package com.company.gw.common.mapper;
import com.company.gw.common.dto.ControllerRoleDto;
import com.company.gw.common.dto.MenuDto;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface MenuMapper {
List<MenuDto> getMenuList(@Param("corpNo") String corpNo,
@Param("menuAuthCd") String menuAuthCd);
List<ControllerRoleDto> getControllerRoleList(@Param("corpNo") String corpNo);
}

View File

@@ -0,0 +1,13 @@
package com.company.gw.common.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface SecurityMapper {
List<String> getUserRoleList(@Param("corpNo") String corpNo,
@Param("usrId") String usrId);
}

View File

@@ -0,0 +1,15 @@
package com.company.gw.common.mapper;
import com.company.gw.common.dto.UserDto;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface UserMapper {
UserDto getUserInfo(@Param("usrId") String usrId);
UserDto getUserInfoWithPwd(@Param("loginId") String loginId);
int updatePassword(@Param("usrId") String usrId, @Param("newPw") String newPw);
}

View File

@@ -0,0 +1,226 @@
package com.company.gw.common.service;
import com.company.gw.common.dto.AttachDto;
import com.company.gw.common.exception.BizException;
import com.company.gw.common.mapper.AttachMapper;
import com.company.gw.common.util.SecurityUtil;
import lombok.RequiredArgsConstructor;
import org.apache.commons.io.FilenameUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.*;
@Service
@RequiredArgsConstructor
public class AttachService {
private static final List<String> NOT_ALLOWED_EXT =
Arrays.asList("php", "jsp", "asp", "gsp", "sh", "exe", "bat");
private static final List<String> IMAGE_EXT =
Arrays.asList("jpg", "jpeg", "png", "gif", "tif", "tiff", "bmp", "webp");
private static final long MAX_FILE_SIZE = 30L * 1024 * 1024; // 30MB
private final AttachMapper attachMapper;
@Value("${file.upload-path}")
private String uploadPath;
// ──────────────────────────────────────────────
// 업로드
// ──────────────────────────────────────────────
@Transactional
public AttachDto uploadFile(MultipartFile file, String atchNo, String division) {
validateFile(file);
if (!org.springframework.util.StringUtils.hasText(atchNo)) {
atchNo = UUID.randomUUID().toString().replace("-", "");
}
String ext = FilenameUtils.getExtension(file.getOriginalFilename()).toLowerCase();
String atchfileNo = UUID.randomUUID().toString().replace("-", "");
String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
String relativePath = (division != null ? division : "common") + "/" + today + "/" + atchfileNo + "." + ext;
File dest = new File(uploadPath, relativePath);
dest.getParentFile().mkdirs();
try {
file.transferTo(dest);
} catch (IOException e) {
throw new BizException("파일 저장 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR);
}
Map<String, Object> param = new HashMap<>();
param.put("atchfileNo", atchfileNo);
param.put("atchNo", atchNo);
param.put("atchFilePathNm", relativePath);
param.put("atchFileNm", file.getOriginalFilename());
param.put("atchTypeNm", file.getContentType());
param.put("atchFileMg", file.getSize());
param.put("tblColNm", null);
param.put("rgstrId", SecurityUtil.getUsrId());
attachMapper.insertFileInfo(param);
return AttachDto.builder()
.atchfileNo(atchfileNo)
.atchNo(atchNo)
.atchFilePathNm(relativePath)
.atchFileNm(file.getOriginalFilename())
.atchTypeNm(file.getContentType())
.atchFileMg(file.getSize())
.build();
}
@Transactional
public List<AttachDto> uploadFiles(List<MultipartFile> files, String atchNo, String division) {
if (!org.springframework.util.StringUtils.hasText(atchNo)) {
atchNo = UUID.randomUUID().toString().replace("-", "");
}
List<AttachDto> result = new ArrayList<>();
for (MultipartFile file : files) {
if (file != null && !file.isEmpty()) {
result.add(uploadFile(file, atchNo, division));
}
}
return result;
}
// 이미지 에디터용 업로드 (DB 저장 없이 파일만 저장)
public Map<String, String> uploadEditorImage(MultipartFile file, String division) {
String ext = FilenameUtils.getExtension(
Objects.requireNonNull(file.getOriginalFilename())).toLowerCase();
if (!IMAGE_EXT.contains(ext)) {
throw new BizException("이미지 파일만 업로드할 수 있습니다.");
}
if (file.getSize() > MAX_FILE_SIZE) {
throw new BizException("파일 크기가 초과되었습니다. (최대 30MB)");
}
String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
String fileId = UUID.randomUUID().toString().replace("-", "");
String relativePath = (division != null ? division : "editor") + "/" + today + "/" + fileId + "." + ext;
File dest = new File(uploadPath + "/images", relativePath);
dest.getParentFile().mkdirs();
try {
file.transferTo(dest);
} catch (IOException e) {
throw new BizException("이미지 저장 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR);
}
Map<String, String> result = new HashMap<>();
result.put("url", "/api/attach/image/" + relativePath.replace("/", "_") + "." + ext);
return result;
}
// ──────────────────────────────────────────────
// 조회
// ──────────────────────────────────────────────
public Map<String, Object> getFileInfo(String atchfileNo) {
Map<String, Object> info = attachMapper.getFileInfo(atchfileNo);
if (info == null) {
throw new BizException("파일 정보가 존재하지 않습니다.", HttpStatus.NOT_FOUND);
}
return info;
}
public List<Map<String, Object>> getFileList(String atchNo) {
return attachMapper.getFileInfoListByAtchNo(atchNo);
}
public File getPhysicalFile(String atchfileNo) {
Map<String, Object> info = getFileInfo(atchfileNo);
File file = new File(uploadPath, (String) info.get("ATCH_FILE_PATH_NM"));
if (!file.exists()) {
throw new BizException("파일이 존재하지 않습니다.", HttpStatus.NOT_FOUND);
}
return file;
}
// ──────────────────────────────────────────────
// 삭제
// ──────────────────────────────────────────────
@Transactional
public void deleteFile(String atchfileNo, boolean forceDelete) {
Map<String, Object> info = getFileInfo(atchfileNo);
if (!forceDelete) {
String rgstrId = (String) info.get("RGSTR_ID");
if (!SecurityUtil.getUsrId().equals(rgstrId)) {
throw new BizException("등록자만 삭제할 수 있습니다.", HttpStatus.FORBIDDEN);
}
}
attachMapper.deleteFileInfo(atchfileNo);
// 물리 파일 삭제 (DB 성공 후)
new File(uploadPath, (String) info.get("ATCH_FILE_PATH_NM")).delete();
}
@Transactional
public void deleteFilesByAtchNo(String atchNo, boolean forceDelete) {
List<Map<String, Object>> list = attachMapper.getFileInfoListByAtchNo(atchNo);
for (Map<String, Object> info : list) {
deleteFile((String) info.get("ATCHFILE_NO"), forceDelete);
}
}
// ──────────────────────────────────────────────
// TBL_COL_NM 확정 처리 (게시판/결재 저장 완료 후 호출)
// ──────────────────────────────────────────────
@Transactional
public void confirmAtchNo(String atchNo, String tblColNm) {
if (!org.springframework.util.StringUtils.hasText(atchNo)) return;
Map<String, Object> param = new HashMap<>();
param.put("atchNo", atchNo);
param.put("tblColNm", tblColNm);
attachMapper.updateTblColNmByAtchNo(param);
}
// ──────────────────────────────────────────────
// 미확정 파일 정기 삭제 (6시간 이상 TBL_COL_NM 없는 파일)
// ──────────────────────────────────────────────
@Scheduled(cron = "0 0 3 * * *") // 매일 새벽 3시
@Transactional
public void cleanUpUnnecessaryFiles() {
List<Map<String, Object>> list = attachMapper.getNeedDeleteFileList();
for (Map<String, Object> item : list) {
try {
attachMapper.deleteFileInfo((String) item.get("ATCHFILE_NO"));
new File(uploadPath, (String) item.get("ATCH_FILE_PATH_NM")).delete();
} catch (Exception ignored) {
}
}
}
// ──────────────────────────────────────────────
// 검증
// ──────────────────────────────────────────────
private void validateFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new BizException("파일이 없습니다.");
}
String ext = FilenameUtils.getExtension(
Objects.requireNonNull(file.getOriginalFilename())).toLowerCase();
if (NOT_ALLOWED_EXT.contains(ext)) {
throw new BizException("업로드할 수 없는 확장자입니다. (" + ext + ")");
}
if (file.getSize() > MAX_FILE_SIZE) {
throw new BizException("파일 크기가 초과되었습니다. (최대 30MB)");
}
}
}

View File

@@ -0,0 +1,189 @@
package com.company.gw.common.service;
import com.company.gw.common.dto.*;
import com.company.gw.common.exception.BizException;
import com.company.gw.common.mapper.MenuMapper;
import com.company.gw.common.mapper.SecurityMapper;
import com.company.gw.common.mapper.UserMapper;
import com.company.gw.common.util.JwtUtil;
import com.company.gw.common.util.PasswordUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {
private static final String ROLE_ADMIN = "BIDS_0001";
private static final String ROLE_FEDEX = "FEDE";
private static final String ROLE_USER = "USER";
private static final String REFRESH_TOKEN_PREFIX = "refresh:";
private final UserMapper userMapper;
private final SecurityMapper securityMapper;
private final MenuMapper menuMapper;
private final JwtUtil jwtUtil;
private final RedisTemplate<String, String> redisTemplate;
/**
* 로그인
* 기존 IndexController.do_login() 로직과 동일
*/
public LoginResponseDto login(LoginRequestDto req) {
// 1. 사용자 조회 (PW 포함)
UserDto user = userMapper.getUserInfoWithPwd(req.getLoginId());
if (user == null) {
throw new BizException("로그인 아이디 또는 비밀번호를 확인하세요.");
}
// 2. 비밀번호 검증 (SHA-256 + Base64)
if (!PasswordUtil.matches(req.getLoginPw(), user.getPw())) {
throw new BizException("로그인 아이디 또는 비밀번호를 확인하세요.");
}
// 3. 퇴직자 체크
if (StringUtils.hasText(user.getRetirementDate())) {
throw new BizException("로그인 아이디 또는 비밀번호를 확인하세요.");
}
// 4. 보유 롤 조회
List<String> roles = securityMapper.getUserRoleList(user.getCorpNo(), user.getUsrId());
// 5. 선택한 MENU_AUTH_CD 보유 여부 검증
// 구 시스템(SecurityService.groovy) 로직과 동일:
// - FEDE 사용자이거나, USER 이외의 권한을 선택한 경우에만 DB 체크
// - USER 권한은 FEDE 사용자가 아니면 누구나 사용 가능 (기본값)
boolean isFedexUser = roles.contains(ROLE_FEDEX);
String menuAuthCd = req.getMenuAuthCd();
if (isFedexUser || !ROLE_USER.equals(menuAuthCd)) {
if (!roles.contains(menuAuthCd)) {
throw new BizException("해당 메뉴 권한이 없습니다.");
}
}
// 6. JWT 발급 (usrId + corpNo + roleCode 포함)
String accessToken = jwtUtil.generateAccessToken(user.getUsrId(), user.getCorpNo(), menuAuthCd);
String refreshToken = jwtUtil.generateRefreshToken(user.getUsrId());
// 7. Redis에 Refresh Token 저장 (7일) — Redis 미사용 환경에서는 건너뜀
try {
redisTemplate.opsForValue().set(
REFRESH_TOKEN_PREFIX + user.getUsrId(),
refreshToken,
7, TimeUnit.DAYS
);
} catch (Exception e) {
log.warn("Redis 미사용 환경: Refresh Token 저장 건너뜀 ({})", e.getMessage());
}
// 8. 메뉴 조회 - 선택한 역할 코드로 SX_CO0090 기반 필터링
List<MenuDto> menuList = menuMapper.getMenuList(user.getCorpNo(), menuAuthCd);
return LoginResponseDto.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.userInfo(LoginResponseDto.UserInfo.builder()
.usrId(user.getUsrId())
.usrNm(user.getUsrNm())
.loginId(user.getLoginId())
.corpNo(user.getCorpNo())
.dutyCd(user.getDutyCd())
.roleCode(menuAuthCd)
.roles(roles)
.build())
.menuList(menuList)
.build();
}
/**
* Access Token 재발급 (Refresh Token 검증)
*/
public LoginResponseDto.UserInfo refreshToken(String refreshToken) {
if (!jwtUtil.isValid(refreshToken)) {
throw new BizException("유효하지 않은 토큰입니다.", HttpStatus.UNAUTHORIZED);
}
String usrId = jwtUtil.getUserId(refreshToken);
try {
String stored = redisTemplate.opsForValue().get(REFRESH_TOKEN_PREFIX + usrId);
if (stored != null && !refreshToken.equals(stored)) {
throw new BizException("만료된 토큰입니다. 다시 로그인하세요.", HttpStatus.UNAUTHORIZED);
}
// stored == null: Redis 미사용 환경 → JWT 유효성만으로 통과
} catch (BizException e) {
throw e;
} catch (Exception e) {
log.warn("Redis 미사용 환경: Refresh Token 검증 건너뜀 ({})", e.getMessage());
}
// 사용자 정보 재조회
UserDto user = userMapper.getUserInfo(usrId);
if (user == null) {
throw new BizException("사용자 정보를 찾을 수 없습니다.", HttpStatus.UNAUTHORIZED);
}
List<String> roles = securityMapper.getUserRoleList(user.getCorpNo(), usrId);
return LoginResponseDto.UserInfo.builder()
.usrId(user.getUsrId())
.usrNm(user.getUsrNm())
.loginId(user.getLoginId())
.corpNo(user.getCorpNo())
.dutyCd(user.getDutyCd())
.roles(roles)
.build();
}
/**
* 로그아웃 - Redis에서 Refresh Token 삭제
*/
public void logout(String usrId) {
try {
redisTemplate.delete(REFRESH_TOKEN_PREFIX + usrId);
} catch (Exception e) {
log.warn("Redis 미사용 환경: 로그아웃 토큰 삭제 건너뜀 ({})", e.getMessage());
}
log.debug("로그아웃 처리 완료: {}", usrId);
}
/**
* 비밀번호 변경
*/
@org.springframework.transaction.annotation.Transactional
public void changePassword(String usrId, String oldPw, String newPw) {
UserDto user = userMapper.getUserInfo(usrId);
if (user == null) {
throw new BizException("사용자 정보를 찾을 수 없습니다.", HttpStatus.UNAUTHORIZED);
}
// 현재 비밀번호 확인 (loginId로 재조회하여 PW 포함)
UserDto userWithPw = userMapper.getUserInfoWithPwd(user.getLoginId());
if (userWithPw == null || !PasswordUtil.matches(oldPw, userWithPw.getPw())) {
throw new BizException("현재 비밀번호가 올바르지 않습니다.");
}
if (!StringUtils.hasText(newPw) || newPw.length() < 4) {
throw new BizException("새 비밀번호는 4자 이상이어야 합니다.");
}
userMapper.updatePassword(usrId, PasswordUtil.encode(newPw));
try {
redisTemplate.delete(REFRESH_TOKEN_PREFIX + usrId);
} catch (Exception e) {
log.warn("Redis 미사용 환경: 비밀번호 변경 후 토큰 삭제 건너뜀 ({})", e.getMessage());
}
}
/**
* 관리자 여부
*/
public boolean isAdmin(List<String> roles) {
return roles.contains(ROLE_ADMIN);
}
}

View File

@@ -0,0 +1,72 @@
package com.company.gw.common.service;
import com.company.gw.common.mapper.CodeMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 공통코드 조회 서비스
* - @Cacheable("code") 로 Redis 캐시 적용 (기본 TTL은 RedisConfig에서 설정)
* - 공통코드 변경 시 /api/code/cache/evict 호출로 캐시 초기화
*/
@Service
@RequiredArgsConstructor
public class CodeService {
private final CodeMapper codeMapper;
// ──────────────────────────────────────────────
// 공통코드 목록 (드롭다운용)
// ──────────────────────────────────────────────
@Transactional(readOnly = true)
@Cacheable(value = "code", key = "#corpNo + ':' + #commClCd + ':' + #useYn")
public List<Map<String, Object>> getCodeList(String corpNo, String commClCd, String useYn) {
Map<String, Object> p = new HashMap<>();
p.put("corpNo", corpNo);
p.put("commClCd", commClCd);
p.put("useYn", useYn);
return codeMapper.getCodeList(p);
}
@Transactional(readOnly = true)
public List<Map<String, Object>> getCodeListFull(String corpNo, String commClCd, String useYn) {
Map<String, Object> p = new HashMap<>();
p.put("corpNo", corpNo);
p.put("commClCd", commClCd);
p.put("useYn", useYn);
return codeMapper.getCodeListFull(p);
}
// ──────────────────────────────────────────────
// 직원 검색 (결재자 선택 팝업 등)
// ──────────────────────────────────────────────
@Transactional(readOnly = true)
public List<Map<String, Object>> searchUser(String corpNo, String searchText, boolean apprOnly) {
Map<String, Object> p = new HashMap<>();
p.put("corpNo", corpNo);
p.put("searchText", searchText);
p.put("apprOnly", apprOnly ? "Y" : "N");
return codeMapper.searchUser(p);
}
// ──────────────────────────────────────────────
// 근무코드 목록
// ──────────────────────────────────────────────
@Transactional(readOnly = true)
@Cacheable(value = "workCd", key = "#corpNo + ':' + #useYn")
public List<Map<String, Object>> getWorkCdList(String corpNo, String useYn) {
Map<String, Object> p = new HashMap<>();
p.put("corpNo", corpNo);
p.put("useYn", useYn);
return codeMapper.getWorkCdList(p);
}
}

View File

@@ -0,0 +1,41 @@
package com.company.gw.common.service;
import com.company.gw.common.dto.UserDto;
import com.company.gw.common.mapper.SecurityMapper;
import com.company.gw.common.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserMapper userMapper;
private final SecurityMapper securityMapper;
@Override
public UserDetails loadUserByUsername(String usrId) throws UsernameNotFoundException {
UserDto user = userMapper.getUserInfo(usrId);
if (user == null) {
throw new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + usrId);
}
List<String> roles = securityMapper.getUserRoleList(user.getCorpNo(), usrId);
List<SimpleGrantedAuthority> authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.toList();
return User.builder()
.username(usrId)
.password("") // JWT 방식이므로 password 불필요
.authorities(authorities)
.build();
}
}

View File

@@ -0,0 +1,24 @@
package com.company.gw.common.service;
import com.company.gw.common.mapper.CommonMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 기존 CommonService.getNextSeqString() 대체.
* SX_CO0060 테이블의 MERGE 기반 시퀀스.
*/
@Service
@RequiredArgsConstructor
public class SequenceService {
private final CommonMapper commonMapper;
@Transactional
public String getNextSeqString(String sequenceNm, int padLength) {
commonMapper.increaseSequence(sequenceNm);
Long val = commonMapper.getSequenceVal(sequenceNm);
return String.format("%0" + padLength + "d", val);
}
}

View File

@@ -0,0 +1,86 @@
package com.company.gw.common.util;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
/**
* Excel 내보내기 유틸리티
*/
public class ExcelUtil {
private ExcelUtil() {}
/**
* Map 리스트를 Excel로 변환하여 ResponseEntity 반환
* @param fileName 다운로드 파일명 (확장자 없이)
* @param headers 컬럼 헤더 배열
* @param keys Map에서 읽을 키 배열 (headers와 1:1 대응)
* @param data 데이터 리스트
*/
public static ResponseEntity<byte[]> toExcel(String fileName,
String[] headers,
String[] keys,
List<Map<String, Object>> data) throws IOException {
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("Sheet1");
// 헤더 스타일
CellStyle headerStyle = wb.createCellStyle();
Font headerFont = wb.createFont();
headerFont.setBold(true);
headerStyle.setFont(headerFont);
headerStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
headerStyle.setBorderBottom(BorderStyle.THIN);
// 헤더 행
Row headerRow = sheet.createRow(0);
for (int i = 0; i < headers.length; i++) {
Cell cell = headerRow.createCell(i);
cell.setCellValue(headers[i]);
cell.setCellStyle(headerStyle);
sheet.setColumnWidth(i, 4000);
}
// 데이터 행
int rowIdx = 1;
for (Map<String, Object> row : data) {
Row dataRow = sheet.createRow(rowIdx++);
for (int i = 0; i < keys.length; i++) {
Cell cell = dataRow.createCell(i);
Object val = row.get(keys[i]);
if (val == null) {
cell.setCellValue("");
} else if (val instanceof Number) {
cell.setCellValue(((Number) val).doubleValue());
} else {
cell.setCellValue(val.toString());
}
}
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
wb.write(baos);
String encodedName = URLEncoder.encode(fileName + ".xlsx", StandardCharsets.UTF_8)
.replace("+", "%20");
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''" + encodedName)
.contentType(MediaType.parseMediaType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.body(baos.toByteArray());
}
}
}

View File

@@ -0,0 +1,82 @@
package com.company.gw.common.util;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
@Slf4j
@Component
public class JwtUtil {
private static final String CLAIM_CORP_NO = "corpNo";
private static final String CLAIM_ROLE_CODE = "roleCode";
private final SecretKey secretKey;
private final long accessTokenExpiry;
private final long refreshTokenExpiry;
public JwtUtil(
@Value("${jwt.secret}") String secret,
@Value("${jwt.access-token-expiry}") long accessTokenExpiry,
@Value("${jwt.refresh-token-expiry}") long refreshTokenExpiry) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.accessTokenExpiry = accessTokenExpiry;
this.refreshTokenExpiry = refreshTokenExpiry;
}
public String generateAccessToken(String usrId, String corpNo, String roleCode) {
return Jwts.builder()
.subject(usrId)
.claim(CLAIM_CORP_NO, corpNo)
.claim(CLAIM_ROLE_CODE, roleCode)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + accessTokenExpiry))
.signWith(secretKey)
.compact();
}
public String generateRefreshToken(String usrId) {
return Jwts.builder()
.subject(usrId)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + refreshTokenExpiry))
.signWith(secretKey)
.compact();
}
public String getUserId(String token) {
return getClaims(token).getSubject();
}
public String getCorpNo(String token) {
return (String) getClaims(token).get(CLAIM_CORP_NO);
}
public String getRoleCode(String token) {
return (String) getClaims(token).get(CLAIM_ROLE_CODE);
}
public boolean isValid(String token) {
try {
getClaims(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
log.debug("Invalid JWT token: {}", e.getMessage());
return false;
}
}
private Claims getClaims(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
}
}

View File

@@ -0,0 +1,30 @@
package com.company.gw.common.util;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
/**
* 기존 Grails EncryptUtil.get_enc_password() 와 동일한 방식
* SHA-256 → Base64 인코딩
* 기존 Oracle DB의 PW 컬럼 값과 호환 필수
*/
public class PasswordUtil {
private PasswordUtil() {}
public static String encode(String rawPassword) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(rawPassword.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(hash);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 알고리즘을 찾을 수 없습니다.", e);
}
}
public static boolean matches(String rawPassword, String encodedPassword) {
return encode(rawPassword).equals(encodedPassword);
}
}

View File

@@ -0,0 +1,52 @@
package com.company.gw.common.util;
import com.company.gw.common.dto.CurrentUser;
import com.company.gw.common.exception.BizException;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
/**
* JWT에서 현재 사용자 정보를 추출하는 유틸.
* 기존 CommonUtil.getLoginCorpNo(), getLoginUserID() 역할.
*/
public class SecurityUtil {
private SecurityUtil() {}
public static CurrentUser getCurrentUser(JwtUtil jwtUtil) {
String token = resolveToken();
if (!StringUtils.hasText(token) || !jwtUtil.isValid(token)) {
throw new BizException("인증 정보가 없습니다.", HttpStatus.UNAUTHORIZED);
}
return CurrentUser.builder()
.usrId(jwtUtil.getUserId(token))
.corpNo(jwtUtil.getCorpNo(token))
.roleCode(jwtUtil.getRoleCode(token))
.build();
}
public static String getUsrId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw new BizException("인증 정보가 없습니다.", HttpStatus.UNAUTHORIZED);
}
return auth.getName();
}
private static String resolveToken() {
ServletRequestAttributes attrs =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs == null) return null;
HttpServletRequest request = attrs.getRequest();
String bearer = request.getHeader("Authorization");
if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) {
return bearer.substring(7);
}
return null;
}
}

View File

@@ -0,0 +1,62 @@
package com.company.gw.envset.controller;
import com.company.gw.common.dto.ApiResponse;
import com.company.gw.common.dto.CurrentUser;
import com.company.gw.common.util.JwtUtil;
import com.company.gw.common.util.SecurityUtil;
import com.company.gw.envset.dto.CodeDto;
import com.company.gw.envset.dto.CodeIndexDto;
import com.company.gw.envset.service.CodeManageService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 환경설정 > 공통코드 관리 (Envset0020/0030)
* GET /api/envset/codes 코드인덱스 목록
* POST /api/envset/codes 코드인덱스 배치 저장
* GET /api/envset/codes/{commClCd} 코드 목록
* POST /api/envset/codes/{commClCd} 코드 배치 저장
*/
@RestController
@RequestMapping("/api/envset/codes")
@RequiredArgsConstructor
public class CodeManageController {
private final CodeManageService codeManageService;
private final JwtUtil jwtUtil;
@GetMapping
public ResponseEntity<ApiResponse<List<CodeIndexDto>>> getCdidxList(
@RequestParam(required = false) String searchText) {
CurrentUser cu = SecurityUtil.getCurrentUser(jwtUtil);
return ResponseEntity.ok(ApiResponse.ok(codeManageService.getCdidxList(cu, searchText)));
}
@PostMapping
public ResponseEntity<ApiResponse<Void>> saveCdidxList(@RequestBody List<CodeIndexDto> list) {
CurrentUser cu = SecurityUtil.getCurrentUser(jwtUtil);
codeManageService.saveCdidxList(cu, list);
return ResponseEntity.ok(ApiResponse.ok(null));
}
@GetMapping("/{commClCd}")
public ResponseEntity<ApiResponse<List<CodeDto>>> getCodeList(
@PathVariable String commClCd,
@RequestParam(required = false) String searchText) {
CurrentUser cu = SecurityUtil.getCurrentUser(jwtUtil);
return ResponseEntity.ok(ApiResponse.ok(
codeManageService.getCodeList(cu, commClCd, searchText)));
}
@PostMapping("/{commClCd}")
public ResponseEntity<ApiResponse<Void>> saveCodeList(
@PathVariable String commClCd,
@RequestBody List<CodeDto> list) {
CurrentUser cu = SecurityUtil.getCurrentUser(jwtUtil);
codeManageService.saveCodeList(cu, commClCd, list);
return ResponseEntity.ok(ApiResponse.ok(null));
}
}

View File

@@ -0,0 +1,89 @@
package com.company.gw.envset.controller;
import com.company.gw.common.dto.ApiResponse;
import com.company.gw.common.dto.CurrentUser;
import com.company.gw.common.util.JwtUtil;
import com.company.gw.common.util.SecurityUtil;
import com.company.gw.envset.dto.AuthMenuDto;
import com.company.gw.envset.dto.MenuManageDto;
import com.company.gw.envset.dto.UserAuthDto;
import com.company.gw.envset.service.MenuManageService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 환경설정 > 메뉴관리 / 사용자권한 (Envset0050)
*
* GET /api/envset/menus 메뉴 목록
* POST /api/envset/menus 메뉴 배치 저장
* GET /api/envset/menus/auth?menuAuthCd= 권한별 메뉴 목록
* POST /api/envset/menus/auth?menuAuthCd= 권한-메뉴 배치 저장
* GET /api/envset/menus/user-auth 사용자-권한 목록
* POST /api/envset/menus/user-auth 사용자-권한 배치 저장
* GET /api/envset/menus/search-user 사용자 검색 (autocomplete)
*/
@RestController
@RequestMapping("/api/envset/menus")
@RequiredArgsConstructor
public class MenuManageController {
private final MenuManageService menuManageService;
private final JwtUtil jwtUtil;
@GetMapping
public ResponseEntity<ApiResponse<List<MenuManageDto>>> getMenuList() {
CurrentUser cu = SecurityUtil.getCurrentUser(jwtUtil);
return ResponseEntity.ok(ApiResponse.ok(menuManageService.getMenuList(cu)));
}
@PostMapping
public ResponseEntity<ApiResponse<Void>> saveMenuList(@RequestBody List<MenuManageDto> list) {
CurrentUser cu = SecurityUtil.getCurrentUser(jwtUtil);
menuManageService.saveMenuList(cu, list);
return ResponseEntity.ok(ApiResponse.ok(null));
}
@GetMapping("/auth")
public ResponseEntity<ApiResponse<List<AuthMenuDto>>> getAuthMenuList(
@RequestParam String menuAuthCd) {
CurrentUser cu = SecurityUtil.getCurrentUser(jwtUtil);
return ResponseEntity.ok(ApiResponse.ok(
menuManageService.getAuthMenuList(cu, menuAuthCd)));
}
@PostMapping("/auth")
public ResponseEntity<ApiResponse<Void>> saveAuthMenuList(
@RequestParam String menuAuthCd,
@RequestBody List<AuthMenuDto> list) {
CurrentUser cu = SecurityUtil.getCurrentUser(jwtUtil);
menuManageService.saveAuthMenuList(cu, menuAuthCd, list);
return ResponseEntity.ok(ApiResponse.ok(null));
}
@GetMapping("/user-auth")
public ResponseEntity<ApiResponse<List<UserAuthDto>>> getUserMauthList(
@RequestParam(required = false) String menuAuthCd,
@RequestParam(required = false) String usrNm) {
CurrentUser cu = SecurityUtil.getCurrentUser(jwtUtil);
return ResponseEntity.ok(ApiResponse.ok(
menuManageService.getUserMauthList(cu, menuAuthCd, usrNm)));
}
@PostMapping("/user-auth")
public ResponseEntity<ApiResponse<Void>> saveUserMauthList(@RequestBody List<UserAuthDto> list) {
CurrentUser cu = SecurityUtil.getCurrentUser(jwtUtil);
menuManageService.saveUserMauthList(cu, list);
return ResponseEntity.ok(ApiResponse.ok(null));
}
@GetMapping("/search-user")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> searchUser(
@RequestParam(required = false) String keyword) {
CurrentUser cu = SecurityUtil.getCurrentUser(jwtUtil);
return ResponseEntity.ok(ApiResponse.ok(menuManageService.searchUser(cu, keyword)));
}
}

View File

@@ -0,0 +1,95 @@
package com.company.gw.envset.controller;
import com.company.gw.common.dto.ApiResponse;
import com.company.gw.common.dto.CurrentUser;
import com.company.gw.common.util.JwtUtil;
import com.company.gw.common.util.SecurityUtil;
import com.company.gw.envset.dto.UserDetailDto;
import com.company.gw.envset.dto.UserSaveDto;
import com.company.gw.envset.service.UserManageService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
/**
* 환경설정 > 직원정보 (Envset0010)
* GET /api/envset/users 직원 목록 (페이징)
* GET /api/envset/users/{usrId} 직원 상세
* POST /api/envset/users 직원 등록
* PUT /api/envset/users/{usrId} 직원 수정
* DELETE /api/envset/users/{usrId} 직원 삭제
*/
@RestController
@RequestMapping("/api/envset/users")
@RequiredArgsConstructor
public class UserManageController {
private final UserManageService userManageService;
private final JwtUtil jwtUtil;
@GetMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> getUserList(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "100") int size,
@RequestParam(required = false) String usrNm,
@RequestParam(required = false) String teamCd,
@RequestParam(required = false) String dutyCd,
@RequestParam(required = false) String apprYn,
@RequestParam(defaultValue = "N") String includeRetireYn,
@RequestParam(defaultValue = "USR_ID") String userOrderBy) {
CurrentUser cu = SecurityUtil.getCurrentUser(jwtUtil);
Map<String, Object> params = new HashMap<>();
params.put("page", page);
params.put("size", size);
params.put("usrNm", usrNm);
params.put("teamCd", teamCd);
params.put("dutyCd", dutyCd);
params.put("apprYn", apprYn);
params.put("includeRetireYn", includeRetireYn);
params.put("userOrderBy", userOrderBy);
Map<String, Object> result = userManageService.getUserList(cu, params);
long total = (long) result.get("total");
return ResponseEntity.ok(ApiResponse.ok(
result,
ApiResponse.PaginationInfo.builder().page(page).size(size).total(total).build()
));
}
@GetMapping("/{usrId}")
public ResponseEntity<ApiResponse<UserDetailDto>> getUserDetail(@PathVariable String usrId) {
CurrentUser cu = SecurityUtil.getCurrentUser(jwtUtil);
return ResponseEntity.ok(ApiResponse.ok(userManageService.getUserDetail(cu, usrId)));
}
@PostMapping
public ResponseEntity<ApiResponse<Void>> insertUser(@Valid @RequestBody UserSaveDto dto) {
CurrentUser cu = SecurityUtil.getCurrentUser(jwtUtil);
userManageService.insertUser(cu, dto);
return ResponseEntity.ok(ApiResponse.ok(null));
}
@PutMapping("/{usrId}")
public ResponseEntity<ApiResponse<Void>> updateUser(
@PathVariable String usrId,
@Valid @RequestBody UserSaveDto dto) {
CurrentUser cu = SecurityUtil.getCurrentUser(jwtUtil);
dto.setUsrId(usrId);
userManageService.updateUser(cu, dto);
return ResponseEntity.ok(ApiResponse.ok(null));
}
@DeleteMapping("/{usrId}")
public ResponseEntity<ApiResponse<Void>> deleteUser(@PathVariable String usrId) {
CurrentUser cu = SecurityUtil.getCurrentUser(jwtUtil);
userManageService.deleteUser(cu, usrId);
return ResponseEntity.ok(ApiResponse.ok(null));
}
}

View File

@@ -0,0 +1,58 @@
package com.company.gw.envset.controller;
import com.company.gw.common.dto.ApiResponse;
import com.company.gw.common.dto.CurrentUser;
import com.company.gw.common.util.JwtUtil;
import com.company.gw.common.util.SecurityUtil;
import com.company.gw.envset.service.WorkCdService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 환경설정 > 근무코드 관리 (Envset0040, SX_CO0070)
* GET /api/envset/workcd 목록
* POST /api/envset/workcd 등록
* PUT /api/envset/workcd/{workCd} 수정
* DELETE /api/envset/workcd/{workCd} 삭제
*/
@RestController
@RequestMapping("/api/envset/workcd")
@RequiredArgsConstructor
public class WorkCdController {
private final WorkCdService workCdService;
private final JwtUtil jwtUtil;
@GetMapping
public ApiResponse<List<Map<String, Object>>> getWorkCdList(
@RequestParam(defaultValue = "") String searchText) {
CurrentUser cu = SecurityUtil.getCurrentUser(jwtUtil);
return ApiResponse.ok(workCdService.getWorkCdList(cu.getCorpNo(), searchText));
}
@PostMapping
public ApiResponse<Void> insertWorkCd(@RequestBody Map<String, Object> body) {
CurrentUser cu = SecurityUtil.getCurrentUser(jwtUtil);
workCdService.insertWorkCd(cu.getCorpNo(), cu.getUsrId(), body);
return ApiResponse.ok(null);
}
@PutMapping("/{workCd}")
public ApiResponse<Void> updateWorkCd(
@PathVariable String workCd,
@RequestBody Map<String, Object> body) {
CurrentUser cu = SecurityUtil.getCurrentUser(jwtUtil);
workCdService.updateWorkCd(cu.getCorpNo(), cu.getUsrId(), workCd, body);
return ApiResponse.ok(null);
}
@DeleteMapping("/{workCd}")
public ApiResponse<Void> deleteWorkCd(@PathVariable String workCd) {
CurrentUser cu = SecurityUtil.getCurrentUser(jwtUtil);
workCdService.deleteWorkCd(cu.getCorpNo(), workCd);
return ApiResponse.ok(null);
}
}

View File

@@ -0,0 +1,23 @@
package com.company.gw.envset.dto;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
public class AuthMenuDto {
private String corpNo;
private String menuNo;
private String upperMenuNo;
private String menuNm;
private String url;
private String menuProp;
private Integer menuOrdr;
private String menuUseYn;
private Integer lvl;
private String menuAuthYn; // Y/N
private String menuAuthCd;
private String funcAuthCn;
private String rgstrId;
private String modId;
private String rowStatus; // U만 존재 (Y이면 merge, N이면 delete)
}

View File

@@ -0,0 +1,18 @@
package com.company.gw.envset.dto;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
public class CodeDto {
private String corpNo;
private String commClCd;
private String commCd;
private String commCdNm;
private String commCdUseYn;
private String commCdDscrpt;
private Integer commCdDsplyOrdr;
private String rgstrId;
private String modId;
private String rowStatus; // I/U/D
}

View File

@@ -0,0 +1,18 @@
package com.company.gw.envset.dto;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
public class CodeIndexDto {
private String corpNo;
private String commClCd;
private String commClCdNm;
private String commClCdUseYn;
private String commClCdDscrpt;
private Integer dsplyOrdr;
private String rgstrId;
private String modId;
// 배치 저장용 상태 플래그
private String rowStatus; // I/U/D
}

View File

@@ -0,0 +1,22 @@
package com.company.gw.envset.dto;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
public class MenuManageDto {
private String corpNo;
private String menuNo;
private String upperMenuNo;
private String menuNm;
private String url;
private String rm;
private String menuProp;
private Integer menuOrdr;
private String menuUseYn;
private String controller;
private Integer menuLvl;
private String rgstrId;
private String modId;
private String rowStatus; // I/U/D
}

View File

@@ -0,0 +1,17 @@
package com.company.gw.envset.dto;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
public class UserAuthDto {
private String corpNo;
private String usrId;
private String usrNm;
private String menuAuthCd;
private String teamCd;
private String dutyCd;
private String retirementDate;
private String rgstrId;
private String rowStatus; // I/D
}

View File

@@ -0,0 +1,31 @@
package com.company.gw.envset.dto;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
public class UserDetailDto {
private String corpNo;
private String usrId;
private String usrSortOrdr;
private String usrNm;
private String loginId;
private String dutyCd;
private String brthDyDate;
private String usrTelno;
private String mtelNo;
private String email;
private String baseAdrs;
private String gusoAdrs;
private String gusoTelno;
private String joinCpDate;
private String teamCd;
private String dismlDate;
private String retirementDate;
private String spkltArtcCn;
private String photoAtchfileNo;
private String spmtEmail;
private String spmtMtelNo;
private String apprYn;
private String finalSchspNm;
}

View File

@@ -0,0 +1,15 @@
package com.company.gw.envset.dto;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
public class UserListDto {
private String usrId;
private String usrNm;
private String teamCd;
private String dutyCd;
private String usrTelno;
private String mtelNo;
private String retirementDate;
}

View File

@@ -0,0 +1,39 @@
package com.company.gw.envset.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
public class UserSaveDto {
// 공통 (insert/update 구분용)
private String corpNo;
private String usrId;
private String rgstrId;
private String modId;
@NotBlank(message = "직원명은 필수입니다.")
private String usrNm;
private String loginId;
private String pw; // 평문 → 서비스에서 암호화
private String usrSortOrdr;
private String dutyCd;
private String brthDyDate;
private String usrTelno;
private String mtelNo;
private String email;
private String baseAdrs;
private String gusoAdrs;
private String gusoTelno;
private String joinCpDate;
private String teamCd;
private String dismlDate;
private String retirementDate;
private String spkltArtcCn;
private String spmtEmail;
private String spmtMtelNo;
private String rrno;
private String apprYn;
private String finalSchspNm;
}

View File

@@ -0,0 +1,25 @@
package com.company.gw.envset.mapper;
import com.company.gw.envset.dto.CodeDto;
import com.company.gw.envset.dto.CodeIndexDto;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface CodeManageMapper {
List<CodeIndexDto> getCdidxList(Map<String, Object> params);
CodeIndexDto getCdidxInfo(Map<String, Object> params);
void insertCdidx(CodeIndexDto dto);
void updateCdidx(CodeIndexDto dto);
void deleteCdidx(@Param("corpNo") String corpNo, @Param("commClCd") String commClCd);
List<CodeDto> getCodeList(Map<String, Object> params);
void insertCode(CodeDto dto);
void updateCode(CodeDto dto);
void deleteCode(@Param("corpNo") String corpNo,
@Param("commClCd") String commClCd,
@Param("commCd") String commCd);
}

View File

@@ -0,0 +1,33 @@
package com.company.gw.envset.mapper;
import com.company.gw.envset.dto.AuthMenuDto;
import com.company.gw.envset.dto.MenuManageDto;
import com.company.gw.envset.dto.UserAuthDto;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface MenuManageMapper {
List<MenuManageDto> getMenuList(@Param("corpNo") String corpNo);
void insertMenu(MenuManageDto dto);
void updateMenu(MenuManageDto dto);
void deleteMauthByMenu(@Param("corpNo") String corpNo, @Param("menuNo") String menuNo);
void deleteMenu(@Param("corpNo") String corpNo, @Param("menuNo") String menuNo);
List<AuthMenuDto> getAuthMenuList(Map<String, Object> params);
void mergeMenuAuth(AuthMenuDto dto);
void deleteMenuAuth(@Param("corpNo") String corpNo,
@Param("menuNo") String menuNo,
@Param("menuAuthCd") String menuAuthCd);
List<UserAuthDto> getUserMauthList(Map<String, Object> params);
void insertUserMauth(UserAuthDto dto);
void deleteUserMauth(@Param("corpNo") String corpNo,
@Param("usrId") String usrId,
@Param("menuAuthCd") String menuAuthCd);
List<Map<String, Object>> searchUser(Map<String, Object> params);
}

View File

@@ -0,0 +1,26 @@
package com.company.gw.envset.mapper;
import com.company.gw.envset.dto.UserDetailDto;
import com.company.gw.envset.dto.UserListDto;
import com.company.gw.envset.dto.UserSaveDto;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface UserManageMapper {
List<UserListDto> getUserList(Map<String, Object> params);
long getUserListCount(Map<String, Object> params);
UserDetailDto getUserDetail(Map<String, Object> params);
int getLoginIdCount(Map<String, Object> params);
void insertUser(UserSaveDto dto);
void updateUser(UserSaveDto dto);
void updateUserPw(@Param("corpNo") String corpNo,
@Param("usrId") String usrId,
@Param("pw") String pw,
@Param("modId") String modId);
void deleteUser(@Param("corpNo") String corpNo, @Param("usrId") String usrId);
void updateUserPhoto(Map<String, Object> params);
}

View File

@@ -0,0 +1,13 @@
package com.company.gw.envset.mapper;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
import java.util.Map;
@Mapper
public interface WorkCdMapper {
List<Map<String, Object>> getWorkCdList(Map<String, Object> param);
void insertWorkCd(Map<String, Object> param);
void updateWorkCd(Map<String, Object> param);
void deleteWorkCd(Map<String, Object> param);
}

View File

@@ -0,0 +1,71 @@
package com.company.gw.envset.service;
import com.company.gw.common.dto.CurrentUser;
import com.company.gw.common.exception.BizException;
import com.company.gw.envset.dto.CodeDto;
import com.company.gw.envset.dto.CodeIndexDto;
import com.company.gw.envset.mapper.CodeManageMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class CodeManageService {
private final CodeManageMapper codeManageMapper;
public List<CodeIndexDto> getCdidxList(CurrentUser cu, String searchText) {
return codeManageMapper.getCdidxList(Map.of(
"corpNo", cu.getCorpNo(),
"searchText", searchText != null ? searchText : ""
));
}
@Transactional
public void saveCdidxList(CurrentUser cu, List<CodeIndexDto> list) {
for (CodeIndexDto item : list) {
item.setCorpNo(cu.getCorpNo());
item.setRgstrId(cu.getUsrId());
item.setModId(cu.getUsrId());
switch (item.getRowStatus()) {
case "I" -> codeManageMapper.insertCdidx(item);
case "U" -> codeManageMapper.updateCdidx(item);
case "D" -> codeManageMapper.deleteCdidx(cu.getCorpNo(), item.getCommClCd());
default -> throw new BizException("잘못된 rowStatus: " + item.getRowStatus());
}
}
}
public List<CodeDto> getCodeList(CurrentUser cu, String commClCd, String searchText) {
if (!StringUtils.hasText(commClCd)) throw new BizException("코드분류가 필요합니다.");
return codeManageMapper.getCodeList(Map.of(
"corpNo", cu.getCorpNo(),
"commClCd", commClCd,
"searchText", searchText != null ? searchText : ""
));
}
@Transactional
public void saveCodeList(CurrentUser cu, String commClCd, List<CodeDto> list) {
if (!StringUtils.hasText(commClCd)) throw new BizException("코드분류가 필요합니다.");
for (CodeDto item : list) {
item.setCorpNo(cu.getCorpNo());
item.setCommClCd(commClCd);
item.setRgstrId(cu.getUsrId());
item.setModId(cu.getUsrId());
switch (item.getRowStatus()) {
case "I" -> codeManageMapper.insertCode(item);
case "U" -> codeManageMapper.updateCode(item);
case "D" -> codeManageMapper.deleteCode(cu.getCorpNo(), commClCd, item.getCommCd());
default -> throw new BizException("잘못된 rowStatus: " + item.getRowStatus());
}
}
}
}

View File

@@ -0,0 +1,102 @@
package com.company.gw.envset.service;
import com.company.gw.common.dto.CurrentUser;
import com.company.gw.common.exception.BizException;
import com.company.gw.envset.dto.AuthMenuDto;
import com.company.gw.envset.dto.MenuManageDto;
import com.company.gw.envset.dto.UserAuthDto;
import com.company.gw.envset.mapper.MenuManageMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class MenuManageService {
private final MenuManageMapper menuManageMapper;
public List<MenuManageDto> getMenuList(CurrentUser cu) {
return menuManageMapper.getMenuList(cu.getCorpNo());
}
@Transactional
public void saveMenuList(CurrentUser cu, List<MenuManageDto> list) {
for (MenuManageDto item : list) {
item.setCorpNo(cu.getCorpNo());
item.setRgstrId(cu.getUsrId());
item.setModId(cu.getUsrId());
switch (item.getRowStatus()) {
case "I" -> menuManageMapper.insertMenu(item);
case "U" -> menuManageMapper.updateMenu(item);
case "D" -> {
menuManageMapper.deleteMauthByMenu(cu.getCorpNo(), item.getMenuNo());
menuManageMapper.deleteMenu(cu.getCorpNo(), item.getMenuNo());
}
default -> throw new BizException("잘못된 rowStatus: " + item.getRowStatus());
}
}
}
public List<AuthMenuDto> getAuthMenuList(CurrentUser cu, String menuAuthCd) {
if (!StringUtils.hasText(menuAuthCd)) throw new BizException("권한코드가 필요합니다.");
return menuManageMapper.getAuthMenuList(Map.of(
"corpNo", cu.getCorpNo(),
"menuAuthCd", menuAuthCd
));
}
@Transactional
public void saveAuthMenuList(CurrentUser cu, String menuAuthCd, List<AuthMenuDto> list) {
if (!StringUtils.hasText(menuAuthCd)) throw new BizException("권한코드가 필요합니다.");
for (AuthMenuDto item : list) {
if (!"U".equals(item.getRowStatus())) continue;
item.setCorpNo(cu.getCorpNo());
item.setMenuAuthCd(menuAuthCd);
item.setRgstrId(cu.getUsrId());
item.setModId(cu.getUsrId());
if ("Y".equals(item.getMenuAuthYn())) {
menuManageMapper.mergeMenuAuth(item);
} else {
menuManageMapper.deleteMenuAuth(cu.getCorpNo(), item.getMenuNo(), menuAuthCd);
}
}
}
public List<UserAuthDto> getUserMauthList(CurrentUser cu, String menuAuthCd, String usrNm) {
Map<String, Object> params = new HashMap<>();
params.put("corpNo", cu.getCorpNo());
params.put("menuAuthCd", StringUtils.hasText(menuAuthCd) ? menuAuthCd : null);
params.put("usrNm", StringUtils.hasText(usrNm) ? usrNm : null);
return menuManageMapper.getUserMauthList(params);
}
@Transactional
public void saveUserMauthList(CurrentUser cu, List<UserAuthDto> list) {
for (UserAuthDto item : list) {
item.setCorpNo(cu.getCorpNo());
item.setRgstrId(cu.getUsrId());
switch (item.getRowStatus()) {
case "I" -> menuManageMapper.insertUserMauth(item);
case "D" -> menuManageMapper.deleteUserMauth(
cu.getCorpNo(), item.getUsrId(), item.getMenuAuthCd());
default -> throw new BizException("잘못된 rowStatus: " + item.getRowStatus());
}
}
}
public List<Map<String, Object>> searchUser(CurrentUser cu, String keyword) {
return menuManageMapper.searchUser(Map.of(
"corpNo", cu.getCorpNo(),
"keyword", keyword != null ? keyword : ""
));
}
}

View File

@@ -0,0 +1,117 @@
package com.company.gw.envset.service;
import com.company.gw.common.dto.ApiResponse;
import com.company.gw.common.dto.CurrentUser;
import com.company.gw.common.exception.BizException;
import com.company.gw.common.service.SequenceService;
import com.company.gw.common.util.PasswordUtil;
import com.company.gw.envset.dto.UserDetailDto;
import com.company.gw.envset.dto.UserListDto;
import com.company.gw.envset.dto.UserSaveDto;
import com.company.gw.envset.mapper.UserManageMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class UserManageService {
private static final List<String> ALLOWED_ORDER_BY =
List.of("USR_ID", "USR_NM", "TEAM_CD", "DUTY_CD");
private final UserManageMapper userManageMapper;
private final SequenceService sequenceService;
public Map<String, Object> getUserList(CurrentUser cu, Map<String, Object> params) {
String orderBy = (String) params.getOrDefault("userOrderBy", "USR_ID");
if (!ALLOWED_ORDER_BY.contains(orderBy)) {
throw new BizException("정렬 조건이 올바르지 않습니다.");
}
params.put("corpNo", cu.getCorpNo());
params.put("userOrderBy", orderBy);
long total = userManageMapper.getUserListCount(params);
List<UserListDto> list = userManageMapper.getUserList(params);
Map<String, Object> result = new HashMap<>();
result.put("list", list);
result.put("total", total);
return result;
}
public UserDetailDto getUserDetail(CurrentUser cu, String usrId) {
Map<String, Object> params = Map.of("corpNo", cu.getCorpNo(), "usrId", usrId);
UserDetailDto detail = userManageMapper.getUserDetail(params);
if (detail == null) {
throw new BizException("사용자 정보가 없습니다.");
}
return detail;
}
@Transactional
public void insertUser(CurrentUser cu, UserSaveDto dto) {
if (!StringUtils.hasText(dto.getPw())) {
throw new BizException("비밀번호는 필수입니다.");
}
dto.setCorpNo(cu.getCorpNo());
dto.setRgstrId(cu.getUsrId());
// 시퀀스로 USR_ID 생성 (기존 getNextSeqString('SX_GW0010.USR_ID', 5))
String usrId = sequenceService.getNextSeqString("SX_GW0010.USR_ID", 5);
dto.setUsrId(usrId);
// 로그인ID 중복 체크
checkLoginIdDuplicate(cu.getCorpNo(), dto.getLoginId(), usrId);
// 비밀번호 암호화 (SHA-256 + Base64)
dto.setPw(PasswordUtil.encode(dto.getPw()));
userManageMapper.insertUser(dto);
}
@Transactional
public void updateUser(CurrentUser cu, UserSaveDto dto) {
if (!StringUtils.hasText(dto.getUsrId())) {
throw new BizException("USR_ID는 필수입니다.");
}
dto.setCorpNo(cu.getCorpNo());
dto.setModId(cu.getUsrId());
// 로그인ID 중복 체크 (본인 제외)
checkLoginIdDuplicate(cu.getCorpNo(), dto.getLoginId(), dto.getUsrId());
userManageMapper.updateUser(dto);
// 비밀번호 입력 시에만 변경
if (StringUtils.hasText(dto.getPw())) {
userManageMapper.updateUserPw(
cu.getCorpNo(), dto.getUsrId(),
PasswordUtil.encode(dto.getPw()), cu.getUsrId());
}
}
@Transactional
public void deleteUser(CurrentUser cu, String usrId) {
// 사용자 존재 확인
getUserDetail(cu, usrId);
userManageMapper.deleteUser(cu.getCorpNo(), usrId);
}
private void checkLoginIdDuplicate(String corpNo, String loginId, String usrId) {
if (!StringUtils.hasText(loginId)) return;
Map<String, Object> params = new HashMap<>();
params.put("corpNo", corpNo);
params.put("loginId", loginId);
params.put("usrId", usrId != null ? usrId : "");
int cnt = userManageMapper.getLoginIdCount(params);
if (cnt > 0) {
throw new BizException("이미 사용 중인 로그인 아이디입니다.");
}
}
}

View File

@@ -0,0 +1,47 @@
package com.company.gw.envset.service;
import com.company.gw.envset.mapper.WorkCdMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class WorkCdService {
private final WorkCdMapper workCdMapper;
public List<Map<String, Object>> getWorkCdList(String corpNo, String searchText) {
Map<String, Object> param = new HashMap<>();
param.put("corpNo", corpNo);
param.put("searchText", searchText);
return workCdMapper.getWorkCdList(param);
}
@Transactional
public void insertWorkCd(String corpNo, String regUsrId, Map<String, Object> data) {
data.put("corpNo", corpNo);
data.put("regUsrId", regUsrId);
workCdMapper.insertWorkCd(data);
}
@Transactional
public void updateWorkCd(String corpNo, String regUsrId, String workCd, Map<String, Object> data) {
data.put("corpNo", corpNo);
data.put("regUsrId", regUsrId);
data.put("workCd", workCd);
workCdMapper.updateWorkCd(data);
}
@Transactional
public void deleteWorkCd(String corpNo, String workCd) {
Map<String, Object> param = new HashMap<>();
param.put("corpNo", corpNo);
param.put("workCd", workCd);
workCdMapper.deleteWorkCd(param);
}
}

View File

@@ -0,0 +1,58 @@
package com.company.gw.fedex.controller;
import com.company.gw.common.dto.ApiResponse;
import com.company.gw.common.dto.CurrentUser;
import com.company.gw.fedex.service.FedexService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/fedex")
@RequiredArgsConstructor
public class FedexController {
private final FedexService fedexService;
/** 목록 */
@GetMapping("/0010")
public ApiResponse<Map<String, Object>> getFedexList(
@RequestParam(defaultValue = "") String searchText,
@RequestParam(defaultValue = "1") int pageNo,
@RequestParam(defaultValue = "100") int pageSize) {
return ApiResponse.ok(fedexService.getFedexList(searchText, pageNo, pageSize));
}
/** 상세 */
@GetMapping("/0010/{sq}")
public ApiResponse<Map<String, Object>> getFedexDetail(@PathVariable long sq) {
return ApiResponse.ok(fedexService.getFedexDetail(sq));
}
/** 등록 */
@PostMapping("/0010")
public ApiResponse<Long> insertFedex(
@RequestBody Map<String, Object> body,
@AuthenticationPrincipal CurrentUser cu) {
body.put("regUserId", cu.getUsrId());
long sq = fedexService.insertFedex(body);
return ApiResponse.ok(sq);
}
/** 첨부파일 번호 업데이트 */
@PatchMapping("/0010/{sq}/attach")
public ApiResponse<Void> updateFedexAttach(
@PathVariable long sq,
@RequestBody Map<String, Object> body) {
fedexService.updateFedexAttach(sq, (String) body.get("attachNo"));
return ApiResponse.ok(null);
}
/** 코드 목록 */
@GetMapping("/codes")
public ApiResponse<Map<String, Object>> getCodes() {
return ApiResponse.ok(fedexService.getCodes());
}
}

View File

@@ -0,0 +1,17 @@
package com.company.gw.fedex.mapper;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
import java.util.Map;
@Mapper
public interface FedexMapper {
int countFedexList(Map<String, Object> param);
List<Map<String, Object>> getFedexList(Map<String, Object> param);
Map<String, Object> getFedexDetail(Map<String, Object> param);
void insertFedex(Map<String, Object> param);
void updateFedexAttach(Map<String, Object> param);
List<Map<String, Object>> getFstList();
List<Map<String, Object>> getGwiList();
List<Map<String, Object>> getFjjList();
}

View File

@@ -0,0 +1,60 @@
package com.company.gw.fedex.service;
import com.company.gw.fedex.mapper.FedexMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class FedexService {
private final FedexMapper fedexMapper;
public Map<String, Object> getFedexList(String searchText, int pageNo, int pageSize) {
Map<String, Object> param = new HashMap<>();
param.put("searchText", searchText);
param.put("pageSize", pageSize);
param.put("offset", (pageNo - 1) * pageSize);
int total = fedexMapper.countFedexList(param);
List<Map<String, Object>> list = fedexMapper.getFedexList(param);
Map<String, Object> result = new HashMap<>();
result.put("list", list);
result.put("total", total);
return result;
}
public Map<String, Object> getFedexDetail(long sq) {
Map<String, Object> param = new HashMap<>();
param.put("sq", sq);
return fedexMapper.getFedexDetail(param);
}
@Transactional
public long insertFedex(Map<String, Object> param) {
fedexMapper.insertFedex(param);
return ((Number) param.get("sq")).longValue();
}
@Transactional
public void updateFedexAttach(long sq, String attachNo) {
Map<String, Object> param = new HashMap<>();
param.put("sq", sq);
param.put("attachNo", attachNo);
fedexMapper.updateFedexAttach(param);
}
public Map<String, Object> getCodes() {
Map<String, Object> result = new HashMap<>();
result.put("fstList", fedexMapper.getFstList());
result.put("gwiList", fedexMapper.getGwiList());
result.put("fjjList", fedexMapper.getFjjList());
return result;
}
}

View File

@@ -0,0 +1,69 @@
package com.company.gw.main.controller;
import com.company.gw.common.dto.ApiResponse;
import com.company.gw.common.dto.CurrentUser;
import com.company.gw.main.service.MainService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/main")
@RequiredArgsConstructor
public class MainController {
private final MainService mainService;
/** 대시보드 전체 데이터 */
@GetMapping("/dashboard")
public ApiResponse<Map<String, Object>> getDashboard(
@AuthenticationPrincipal CurrentUser currentUser) {
return ApiResponse.ok(mainService.getDashboard(
currentUser.getCorpNo(), currentUser.getUsrId()));
}
/** 출근 */
@PostMapping("/workstart")
public ApiResponse<Map<String, Object>> clockIn(
@AuthenticationPrincipal CurrentUser currentUser,
@RequestBody Map<String, String> body,
HttpServletRequest request) {
String date = body.get("workPlanYymmdd");
String ip = getClientIp(request);
return ApiResponse.ok(mainService.clockIn(
currentUser.getCorpNo(), currentUser.getUsrId(), date, ip));
}
/** 퇴근 */
@PostMapping("/workend")
public ApiResponse<Map<String, Object>> clockOut(
@AuthenticationPrincipal CurrentUser currentUser,
@RequestBody Map<String, String> body,
HttpServletRequest request) {
String date = body.get("workPlanYymmdd");
String ip = getClientIp(request);
return ApiResponse.ok(mainService.clockOut(
currentUser.getCorpNo(), currentUser.getUsrId(), date, ip));
}
/** 오늘 지각자 목록 (관리자용) */
@GetMapping("/late-list")
public ApiResponse<List<Map<String, Object>>> getLateList(
@AuthenticationPrincipal CurrentUser currentUser) {
return ApiResponse.ok(mainService.getTodayLateList(currentUser.getCorpNo()));
}
/** X-Forwarded-For → remoteAddr 순으로 클라이언트 IP 추출 */
private String getClientIp(HttpServletRequest request) {
String xff = request.getHeader("X-Forwarded-For");
if (StringUtils.hasText(xff)) {
return xff.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}

View File

@@ -0,0 +1,23 @@
package com.company.gw.main.mapper;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
import java.util.Map;
@Mapper
public interface MainMapper {
List<Map<String, Object>> getTopPostList(Map<String, Object> param);
List<Map<String, Object>> getWorkRangeList(Map<String, Object> param);
Map<String, Object> getTodayWorkRecord(Map<String, Object> param);
Map<String, Object> getPendingApprCount(Map<String, Object> param);
List<Map<String, Object>> getMyDocSentSummary(Map<String, Object> param);
List<Map<String, Object>> getMyDocReceivedSummary(Map<String, Object> param);
List<Map<String, Object>> getTodayWorkSummary(Map<String, Object> param);
// 출퇴근
Map<String, Object> getWorkplanInfo(Map<String, Object> param);
List<String> getAllowedIpList(Map<String, Object> param);
int updateWorkStart(Map<String, Object> param);
int updateWorkEnd(Map<String, Object> param);
List<Map<String, Object>> getTodayLateList(Map<String, Object> param);
}

View File

@@ -0,0 +1,221 @@
package com.company.gw.main.service;
import com.company.gw.common.exception.BizException;
import com.company.gw.main.mapper.MainMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class MainService {
private final MainMapper mainMapper;
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyyMMdd");
/** 대시보드 전체 데이터 */
public Map<String, Object> getDashboard(String corpNo, String usrId) {
LocalDate today = LocalDate.now();
String todayStr = today.format(FMT);
String fromDate = today.minusDays(4).format(FMT);
String toDate = today.plusDays(4).format(FMT);
Map<String, Object> result = new HashMap<>();
// 공지사항 최신 5건 (UNTY_BBS_CD='0002')
result.put("noticeList", getTopPostList(corpNo, "0002", 5));
// 자유게시판 최신 5건 (UNTY_BBS_CD='0001')
result.put("boardList", getTopPostList(corpNo, "0001", 5));
// 업무메뉴얼 최신 5건 (UNTY_BBS_CD='0004')
result.put("manualList", getTopPostList(corpNo, "0004", 5));
// 9일 근무 범위 (오늘±4일)
Map<String, Object> wp = new HashMap<>();
wp.put("corpNo", corpNo);
wp.put("usrId", usrId);
wp.put("fromDate", fromDate);
wp.put("toDate", toDate);
result.put("workRangeList", mainMapper.getWorkRangeList(wp));
// 오늘 근무 기록
result.put("todayWork", getTodayWorkRecord(corpNo, usrId, todayStr));
// 결재 대기 건수
result.put("pendingAppr", getPendingApprCount(corpNo, usrId));
// 내가 올린 결재 문서 상태별 카운트
Map<String, Object> sp = new HashMap<>();
sp.put("corpNo", corpNo);
sp.put("usrId", usrId);
result.put("myDocSent", mainMapper.getMyDocSentSummary(sp));
// 내가 받은 결재 문서 상태별 카운트
result.put("myDocReceived", mainMapper.getMyDocReceivedSummary(sp));
// 오늘 전체 근무 현황 (근무코드별 그룹)
Map<String, Object> ts = new HashMap<>();
ts.put("corpNo", corpNo);
ts.put("today", todayStr);
result.put("todayWorkSummary", mainMapper.getTodayWorkSummary(ts));
// 오늘 지각자
result.put("todayLateList", getTodayLateList(corpNo));
return result;
}
private List<Map<String, Object>> getTopPostList(String corpNo, String bbsCd, int top) {
Map<String, Object> param = new HashMap<>();
param.put("corpNo", corpNo);
param.put("bbsCd", bbsCd);
param.put("top", top);
return mainMapper.getTopPostList(param);
}
private Map<String, Object> getTodayWorkRecord(String corpNo, String usrId, String today) {
Map<String, Object> param = new HashMap<>();
param.put("corpNo", corpNo);
param.put("usrId", usrId);
param.put("today", today);
return mainMapper.getTodayWorkRecord(param);
}
private Map<String, Object> getPendingApprCount(String corpNo, String usrId) {
Map<String, Object> param = new HashMap<>();
param.put("corpNo", corpNo);
param.put("usrId", usrId);
return mainMapper.getPendingApprCount(param);
}
// ─────────────────────────────────────────────────────
// 출퇴근
// ─────────────────────────────────────────────────────
/** 근무계획 단건 조회 */
public Map<String, Object> getWorkplanInfo(String corpNo, String usrId, String workPlanYymmdd) {
Map<String, Object> param = new HashMap<>();
param.put("corpNo", corpNo);
param.put("usrId", usrId);
param.put("workPlanYymmdd", workPlanYymmdd);
return mainMapper.getWorkplanInfo(param);
}
/** IP 허용 여부 검사 (SX014 코드에 IP 등록 없으면 전체 허용) */
public void checkAllowedIp(String corpNo, String clientIp) {
Map<String, Object> param = new HashMap<>();
param.put("corpNo", corpNo);
List<String> ipList = mainMapper.getAllowedIpList(param);
if (ipList == null || ipList.isEmpty()) return; // 설정 없으면 전체 허용
for (String allowed : ipList) {
if (StringUtils.hasText(allowed) && clientIp.startsWith(allowed.trim())) return;
}
throw new BizException("접속 IP(" + clientIp + ")가 허용 IP가 아닙니다.", HttpStatus.FORBIDDEN);
}
/** 출근 처리 */
@Transactional
public Map<String, Object> clockIn(String corpNo, String usrId, String workPlanYymmdd, String clientIp) {
checkAllowedIp(corpNo, clientIp);
Map<String, Object> wp = getWorkplanInfo(corpNo, usrId, workPlanYymmdd);
if (wp == null || !StringUtils.hasText((String) wp.get("REAL_WORK_CD"))) {
throw new BizException(workPlanYymmdd + " 일의 근무 계획이 없습니다.");
}
if (StringUtils.hasText((String) wp.get("WORK_START_DT"))) {
throw new BizException(workPlanYymmdd + " 에 이미 출근 기록이 있습니다.");
}
// 지각 여부 계산
int lateMin = calcLateMin((String) wp.get("GOTOWORK_TM_NM"));
Map<String, Object> param = new HashMap<>();
param.put("corpNo", corpNo);
param.put("usrId", usrId);
param.put("workPlanYymmdd", workPlanYymmdd);
mainMapper.updateWorkStart(param);
Map<String, Object> result = new HashMap<>();
result.put("lateMin", lateMin);
return result;
}
/** 퇴근 처리 */
@Transactional
public Map<String, Object> clockOut(String corpNo, String usrId, String workPlanYymmdd, String clientIp) {
checkAllowedIp(corpNo, clientIp);
Map<String, Object> wp = getWorkplanInfo(corpNo, usrId, workPlanYymmdd);
if (wp == null || !StringUtils.hasText((String) wp.get("REAL_WORK_CD"))) {
throw new BizException(workPlanYymmdd + " 일의 근무 계획이 없습니다.");
}
if (!StringUtils.hasText((String) wp.get("WORK_START_DT"))) {
throw new BizException(workPlanYymmdd + " 에 출근 기록이 없습니다.");
}
if (StringUtils.hasText((String) wp.get("WORK_END_DT"))) {
throw new BizException(workPlanYymmdd + " 에 이미 퇴근 기록이 있습니다.");
}
// 조퇴 여부 계산
int earlyMin = calcEarlyMin((String) wp.get("GETOFFWORK_TM_NM"));
Map<String, Object> param = new HashMap<>();
param.put("corpNo", corpNo);
param.put("usrId", usrId);
param.put("workPlanYymmdd", workPlanYymmdd);
mainMapper.updateWorkEnd(param);
Map<String, Object> result = new HashMap<>();
result.put("earlyMin", earlyMin);
return result;
}
/** 오늘 지각자 목록 */
@Transactional(readOnly = true)
public List<Map<String, Object>> getTodayLateList(String corpNo) {
String today = LocalDate.now().format(FMT);
Map<String, Object> param = new HashMap<>();
param.put("corpNo", corpNo);
param.put("today", today);
return mainMapper.getTodayLateList(param);
}
/** 지각 분 계산 (GOTOWORK_TM_NM 예: "0900") */
private int calcLateMin(String gotoworkTmNm) {
if (!StringUtils.hasText(gotoworkTmNm) || gotoworkTmNm.length() < 4) return 0;
try {
LocalTime scheduled = LocalTime.of(
Integer.parseInt(gotoworkTmNm.substring(0, 2)),
Integer.parseInt(gotoworkTmNm.substring(2, 4))
);
LocalTime now = LocalTime.now();
if (now.isAfter(scheduled)) {
return (int) java.time.Duration.between(scheduled, now).toMinutes();
}
} catch (Exception ignored) {}
return 0;
}
/** 조퇴 분 계산 (GETOFFWORK_TM_NM 예: "1800") */
private int calcEarlyMin(String getoffworkTmNm) {
if (!StringUtils.hasText(getoffworkTmNm) || getoffworkTmNm.length() < 4) return 0;
try {
LocalTime scheduled = LocalTime.of(
Integer.parseInt(getoffworkTmNm.substring(0, 2)),
Integer.parseInt(getoffworkTmNm.substring(2, 4))
);
LocalTime now = LocalTime.now();
if (now.isBefore(scheduled)) {
return (int) java.time.Duration.between(now, scheduled).toMinutes();
}
} catch (Exception ignored) {}
return 0;
}
}

View File

@@ -0,0 +1,193 @@
package com.company.gw.tam.controller;
import com.company.gw.common.dto.ApiResponse;
import com.company.gw.common.dto.CurrentUser;
import com.company.gw.tam.service.TamService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/tam")
@RequiredArgsConstructor
public class TamController {
private final TamService tamService;
// ──────────────────────────────────────────────
// TAM0010 - 연차 관리
// ──────────────────────────────────────────────
@GetMapping("/yyvct")
public ApiResponse<List<Map<String, Object>>> getYyvctList(
@RequestParam String yyvctYy,
@RequestParam(defaultValue = "") String usrNm,
@RequestParam(defaultValue = "") String teamCd,
@RequestParam(defaultValue = "N") String includeRetireYn,
@AuthenticationPrincipal CurrentUser currentUser) {
return ApiResponse.ok(tamService.getYyvctList(
currentUser.getCorpNo(), yyvctYy, usrNm, teamCd, includeRetireYn));
}
@PostMapping("/yyvct")
public ApiResponse<Void> saveYyvct(
@RequestBody Map<String, Object> body,
@AuthenticationPrincipal CurrentUser currentUser) {
tamService.saveYyvct(
currentUser.getCorpNo(),
(String) body.get("yyvctYy"),
(String) body.get("usrId"),
Integer.parseInt(body.get("yyvctCnt").toString()));
return ApiResponse.ok(null);
}
@DeleteMapping("/yyvct")
public ApiResponse<Void> deleteYyvct(
@RequestParam String yyvctYy,
@RequestParam String usrId,
@AuthenticationPrincipal CurrentUser currentUser) {
tamService.deleteYyvct(currentUser.getCorpNo(), yyvctYy, usrId);
return ApiResponse.ok(null);
}
// ──────────────────────────────────────────────
// TAM0020 - 결재 신청
// ──────────────────────────────────────────────
@GetMapping("/apvreq")
public ApiResponse<Map<String, Object>> getApvreqList(
@RequestParam Map<String, Object> params,
@AuthenticationPrincipal CurrentUser currentUser) {
return ApiResponse.ok(tamService.getApvreqList(currentUser.getCorpNo(), params));
}
@GetMapping("/apvreq/{aprvlDocId}")
public ApiResponse<Map<String, Object>> getApvreqDetail(
@PathVariable String aprvlDocId,
@AuthenticationPrincipal CurrentUser currentUser) {
return ApiResponse.ok(tamService.getApvreqDetail(currentUser.getCorpNo(), aprvlDocId));
}
@PostMapping("/apvreq")
public ApiResponse<Map<String, String>> createApvreq(
@RequestBody Map<String, Object> body,
@AuthenticationPrincipal CurrentUser currentUser) {
String aprvlDocId = tamService.createApvreq(currentUser.getCorpNo(), body);
return ApiResponse.ok(Map.of("aprvlDocId", aprvlDocId));
}
@PutMapping("/apvreq/{aprvlDocId}")
public ApiResponse<Void> updateApvreq(
@PathVariable String aprvlDocId,
@RequestBody Map<String, Object> body,
@AuthenticationPrincipal CurrentUser currentUser) {
tamService.updateApvreq(currentUser.getCorpNo(), aprvlDocId, body);
return ApiResponse.ok(null);
}
@PostMapping("/apvreq/{aprvlDocId}/request")
public ApiResponse<Void> requestApvreq(
@PathVariable String aprvlDocId,
@RequestBody Map<String, Object> body,
@AuthenticationPrincipal CurrentUser currentUser) {
@SuppressWarnings("unchecked")
List<Map<String, Object>> apprList = (List<Map<String, Object>>) body.get("apprList");
tamService.requestApvreq(currentUser.getCorpNo(), aprvlDocId, apprList);
return ApiResponse.ok(null);
}
@DeleteMapping("/apvreq/{aprvlDocId}")
public ApiResponse<Void> deleteApvreq(
@PathVariable String aprvlDocId,
@AuthenticationPrincipal CurrentUser currentUser) {
tamService.deleteApvreq(currentUser.getCorpNo(), aprvlDocId);
return ApiResponse.ok(null);
}
@GetMapping("/apvreq/latest-appr")
public ApiResponse<List<Map<String, Object>>> getLatestApprList(
@AuthenticationPrincipal CurrentUser currentUser) {
return ApiResponse.ok(tamService.getLatestApprList(currentUser.getCorpNo()));
}
// ──────────────────────────────────────────────
// TAM0030 - 결재 처리
// ──────────────────────────────────────────────
@GetMapping("/apvapp")
public ApiResponse<Map<String, Object>> getApvappList(
@RequestParam Map<String, Object> params,
@AuthenticationPrincipal CurrentUser currentUser) {
return ApiResponse.ok(tamService.getApvappList(currentUser.getCorpNo(), params));
}
@GetMapping("/apvapp/{aprvlDocId}")
public ApiResponse<Map<String, Object>> getApvappDetail(
@PathVariable String aprvlDocId,
@AuthenticationPrincipal CurrentUser currentUser) {
return ApiResponse.ok(tamService.getApvappDetail(currentUser.getCorpNo(), aprvlDocId));
}
@PostMapping("/apvapp/{aprvlDocId}/approve")
public ApiResponse<Void> approve(
@PathVariable String aprvlDocId,
@RequestBody Map<String, Object> body,
@AuthenticationPrincipal CurrentUser currentUser) {
tamService.approveApvdoc(currentUser.getCorpNo(), aprvlDocId,
Integer.parseInt(body.get("aprvlSno").toString()),
(String) body.get("apprCn"));
return ApiResponse.ok(null);
}
@PostMapping("/apvapp/{aprvlDocId}/reject")
public ApiResponse<Void> reject(
@PathVariable String aprvlDocId,
@RequestBody Map<String, Object> body,
@AuthenticationPrincipal CurrentUser currentUser) {
tamService.rejectApvdoc(currentUser.getCorpNo(), aprvlDocId,
Integer.parseInt(body.get("aprvlSno").toString()),
(String) body.get("apprCn"));
return ApiResponse.ok(null);
}
/** 일괄 승인 */
@PostMapping("/apvapp/multi-approve")
public ApiResponse<Void> multiApprove(
@RequestBody Map<String, Object> body,
@AuthenticationPrincipal CurrentUser currentUser) {
@SuppressWarnings("unchecked")
List<Map<String, Object>> items = (List<Map<String, Object>>) body.get("items");
tamService.multiApprove(currentUser.getCorpNo(), items, (String) body.get("apprCn"));
return ApiResponse.ok(null);
}
/** 일괄 반려 */
@PostMapping("/apvapp/multi-reject")
public ApiResponse<Void> multiReject(
@RequestBody Map<String, Object> body,
@AuthenticationPrincipal CurrentUser currentUser) {
@SuppressWarnings("unchecked")
List<Map<String, Object>> items = (List<Map<String, Object>>) body.get("items");
tamService.multiReject(currentUser.getCorpNo(), items, (String) body.get("apprCn"));
return ApiResponse.ok(null);
}
// ──────────────────────────────────────────────
// TAM0040 - 근태 현황
// ──────────────────────────────────────────────
@GetMapping("/status")
public ApiResponse<List<Map<String, Object>>> getTamStatusList(
@RequestParam String yyvctYy,
@RequestParam(defaultValue = "") String teamCd,
@RequestParam(defaultValue = "00000000") String staYmd,
@RequestParam(defaultValue = "99999999") String endYmd,
@RequestParam(defaultValue = "N") String includeRetireYn,
@AuthenticationPrincipal CurrentUser currentUser) {
return ApiResponse.ok(tamService.getTamStatusList(
currentUser.getCorpNo(), yyvctYy, teamCd, staYmd, endYmd, includeRetireYn));
}
}

View File

@@ -0,0 +1,52 @@
package com.company.gw.tam.mapper;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
import java.util.Map;
@Mapper
public interface ApvdocMapper {
Map<String, Object> getApvdocInfo(Map<String, Object> param);
List<Map<String, Object>> getApprList(Map<String, Object> param);
Map<String, Object> getApprInfo(Map<String, Object> param);
void insertApvdoc(Map<String, Object> param);
void updateApvdocContent(Map<String, Object> param);
void updateApvdocAtchNo(Map<String, Object> param);
void updateApvdocStatusRequest(Map<String, Object> param);
void updateApvdocStatusApprove(Map<String, Object> param);
void updateApvdocStatusReject(Map<String, Object> param);
void updateFinalAprvlSno(Map<String, Object> param);
void updateAprvlCmplSno(Map<String, Object> param);
void deleteApprAll(Map<String, Object> param);
void insertAppr(Map<String, Object> param);
void updateApprStatus(Map<String, Object> param);
void apprApproveOrReject(Map<String, Object> param);
void updateNextApprStatusAppring(Map<String, Object> param);
void deleteApvdocSx0080(Map<String, Object> param);
void deleteApvdocSx0110(Map<String, Object> param);
void deleteApvdocSx0090(Map<String, Object> param);
void updateApvdocDocFlagAplnt(Map<String, Object> param);
void resetWorkEndDt(Map<String, Object> param);
}

View File

@@ -0,0 +1,43 @@
package com.company.gw.tam.mapper;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
import java.util.Map;
@Mapper
public interface TamMapper {
// TAM0010 - 연차
List<Map<String, Object>> getYyvctList(Map<String, Object> param);
void upsertYyvct(Map<String, Object> param);
void deleteYyvct(Map<String, Object> param);
// TAM0020 - 결재 신청
List<Map<String, Object>> getApvreqList(Map<String, Object> param);
long getApvreqListCount(Map<String, Object> param);
List<Map<String, Object>> getWorkChangeList(Map<String, Object> param);
Map<String, Object> getOtInfo(Map<String, Object> param);
void insertSxGw0110(Map<String, Object> param);
void insertSxGw0080(Map<String, Object> param);
void deleteSxGw0110(Map<String, Object> param);
void deleteSxGw0080(Map<String, Object> param);
List<Map<String, Object>> getLatestApprList(Map<String, Object> param);
void updateDocFlagAplnt(Map<String, Object> param);
// TAM0030 - 결재 처리
List<Map<String, Object>> getApvappList(Map<String, Object> param);
long getApvappListCount(Map<String, Object> param);
void updateDocFlagAppr(Map<String, Object> param);
void updateOutingTime(Map<String, Object> param);
// TAM0040 - 현황/통계
List<Map<String, Object>> getTamStatusList(Map<String, Object> param);
// 결재 후처리
int afterProcessOt(Map<String, Object> param);
int afterProcessLate(Map<String, Object> param);
int afterProcessEarlyDep(Map<String, Object> param);
int afterProcessAbsence(Map<String, Object> param);
int afterProcessWorkChange(Map<String, Object> param);
}

View File

@@ -0,0 +1,262 @@
package com.company.gw.tam.service;
import com.company.gw.common.exception.BizException;
import com.company.gw.common.service.AttachService;
import com.company.gw.common.service.SequenceService;
import com.company.gw.common.util.SecurityUtil;
import com.company.gw.tam.mapper.ApvdocMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 결재 공통 서비스
* 결재 상태코드: 0001=작성중, 0002=결재중, 0003=결재완료, 0004=반려
*/
@Service
@RequiredArgsConstructor
public class ApvdocService {
public static final String STATUS_WRITING = "0001";
public static final String STATUS_APPRING = "0002";
public static final String STATUS_APPROVED = "0003";
public static final String STATUS_REJECTED = "0004";
private final ApvdocMapper apvdocMapper;
private final AttachService attachService;
private final SequenceService sequenceService;
// ──────────────────────────────────────────────
// 결재문서 조회
// ──────────────────────────────────────────────
public Map<String, Object> getApvdocInfo(String corpNo, String aprvlDocId) {
Map<String, Object> info = apvdocMapper.getApvdocInfo(param(corpNo, aprvlDocId));
if (info == null) throw new BizException("결재문서가 존재하지 않습니다.", HttpStatus.NOT_FOUND);
return info;
}
public List<Map<String, Object>> getApprList(String corpNo, String aprvlDocId) {
return apvdocMapper.getApprList(param(corpNo, aprvlDocId));
}
/** 결재문서 + 결재자목록 + 첨부파일 통합 조회 */
@Transactional(readOnly = true)
public Map<String, Object> getApvdocInfoAll(String corpNo, String aprvlDocId) {
Map<String, Object> info = getApvdocInfo(corpNo, aprvlDocId);
info.put("apprList", getApprList(corpNo, aprvlDocId));
String atchNo = (String) info.get("ATCH_NO");
if (atchNo != null) {
info.put("attachFileList", attachService.getFileList(atchNo));
}
return info;
}
// ──────────────────────────────────────────────
// 결재문서 생성
// ──────────────────────────────────────────────
@Transactional
public String createApvdoc(String corpNo, String aprvlKindCd,
String aplntCn, String bzCn, String offerCn, String bzDeputyId,
String atchNo) {
String usrId = SecurityUtil.getUsrId();
String aprvlDocId = sequenceService.getNextSeqString("SX_GW0090.APRVL_DOC_ID", 10);
Map<String, Object> p = new HashMap<>();
p.put("corpNo", corpNo);
p.put("aprvlDocId", aprvlDocId);
p.put("aprvlKindCd", aprvlKindCd);
p.put("aplntCn", aplntCn);
p.put("bzCn", bzCn);
p.put("offerCn", offerCn);
p.put("bzDeputyId", bzDeputyId);
p.put("atchNo", atchNo);
p.put("usrId", usrId);
apvdocMapper.insertApvdoc(p);
if (org.springframework.util.StringUtils.hasText(atchNo)) {
attachService.confirmAtchNo(atchNo, "SX_GW0090.ATCH_NO");
}
return aprvlDocId;
}
@Transactional
public void updateApvdocContent(String corpNo, String aprvlDocId,
String aplntCn, String bzCn, String offerCn, String bzDeputyId,
String atchNo) {
String usrId = SecurityUtil.getUsrId();
Map<String, Object> info = getApvdocInfo(corpNo, aprvlDocId);
checkAplnt(info);
checkBeforeRequest(info);
Map<String, Object> p = param(corpNo, aprvlDocId);
p.put("aplntCn", aplntCn);
p.put("bzCn", bzCn);
p.put("offerCn", offerCn);
p.put("bzDeputyId", bzDeputyId);
p.put("usrId", usrId);
apvdocMapper.updateApvdocContent(p);
if (org.springframework.util.StringUtils.hasText(atchNo)) {
Map<String, Object> ap = param(corpNo, aprvlDocId);
ap.put("atchNo", atchNo);
apvdocMapper.updateApvdocAtchNo(ap);
attachService.confirmAtchNo(atchNo, "SX_GW0090.ATCH_NO");
}
}
// ──────────────────────────────────────────────
// 결재자 등록
// ──────────────────────────────────────────────
@Transactional
public void insertApprList(String corpNo, String aprvlDocId, List<Map<String, Object>> apprList) {
String usrId = SecurityUtil.getUsrId();
Map<String, Object> info = getApvdocInfo(corpNo, aprvlDocId);
checkBeforeRequest(info);
apvdocMapper.deleteApprAll(param(corpNo, aprvlDocId));
int sno = 1;
for (Map<String, Object> appr : apprList) {
String apprId = (String) appr.get("apprId");
if (apprId == null || apprId.isEmpty()) {
throw new BizException("결재자가 지정되지 않았습니다.");
}
Map<String, Object> p = param(corpNo, aprvlDocId);
p.put("aprvlSno", sno++);
p.put("apprId", apprId);
p.put("usrId", usrId);
apvdocMapper.insertAppr(p);
}
apvdocMapper.updateFinalAprvlSno(param(corpNo, aprvlDocId));
}
// ──────────────────────────────────────────────
// 결재 상신
// ──────────────────────────────────────────────
@Transactional
public void requestApvdoc(String corpNo, String aprvlDocId) {
String usrId = SecurityUtil.getUsrId();
Map<String, Object> info = getApvdocInfo(corpNo, aprvlDocId);
checkBeforeRequest(info);
List<Map<String, Object>> apprList = getApprList(corpNo, aprvlDocId);
if (apprList.isEmpty()) throw new BizException("결재자가 지정되지 않았습니다.");
// 1차 결재자 → 결재중
Map<String, Object> p1 = param(corpNo, aprvlDocId);
p1.put("aprvlSno", 1);
p1.put("apprStusCd", STATUS_APPRING);
p1.put("usrId", usrId);
apvdocMapper.updateApprStatus(p1);
// 문서 → 결재중
apvdocMapper.updateApvdocStatusRequest(param(corpNo, aprvlDocId));
}
// ──────────────────────────────────────────────
// 승인 / 반려
// ──────────────────────────────────────────────
@Transactional
public void approveApvdoc(String corpNo, String aprvlDocId, int aprvlSno, String apprCn) {
processApproveOrReject(corpNo, aprvlDocId, aprvlSno, apprCn, true);
}
@Transactional
public void rejectApvdoc(String corpNo, String aprvlDocId, int aprvlSno, String apprCn) {
processApproveOrReject(corpNo, aprvlDocId, aprvlSno, apprCn, false);
}
private void processApproveOrReject(String corpNo, String aprvlDocId,
int aprvlSno, String apprCn, boolean isApprove) {
String usrId = SecurityUtil.getUsrId();
Map<String, Object> docInfo = getApvdocInfo(corpNo, aprvlDocId);
if (!STATUS_APPRING.equals(docInfo.get("APRVL_STUS_CD"))) {
throw new BizException("결재중 상태가 아닙니다.");
}
Map<String, Object> apprParam = param(corpNo, aprvlDocId);
apprParam.put("aprvlSno", aprvlSno);
Map<String, Object> apprInfo = apvdocMapper.getApprInfo(apprParam);
if (apprInfo == null) throw new BizException("결재자 정보가 없습니다. (" + aprvlSno + "차)");
if (!STATUS_APPRING.equals(apprInfo.get("APPR_STUS_CD"))) throw new BizException("결재중인 상태가 아닙니다.");
if (!usrId.equals(apprInfo.get("APPR_ID"))) throw new BizException("결재자가 아닙니다.");
String apprStusCd = isApprove ? STATUS_APPROVED : STATUS_REJECTED;
Map<String, Object> p = param(corpNo, aprvlDocId);
p.put("aprvlSno", aprvlSno);
p.put("apprStusCd", apprStusCd);
p.put("apprCn", apprCn);
p.put("usrId", usrId);
apvdocMapper.apprApproveOrReject(p);
if (isApprove) {
if ("Y".equals(apprInfo.get("FINAL_APPR_YN"))) {
apvdocMapper.updateApvdocStatusApprove(param(corpNo, aprvlDocId));
} else {
Map<String, Object> nextP = param(corpNo, aprvlDocId);
nextP.put("aprvlSno", aprvlSno);
nextP.put("usrId", usrId);
apvdocMapper.updateNextApprStatusAppring(nextP);
}
} else {
apvdocMapper.updateApvdocStatusReject(param(corpNo, aprvlDocId));
apvdocMapper.resetWorkEndDt(param(corpNo, aprvlDocId));
}
apvdocMapper.updateAprvlCmplSno(param(corpNo, aprvlDocId));
}
// ──────────────────────────────────────────────
// 결재문서 삭제
// ──────────────────────────────────────────────
@Transactional
public void deleteApvdoc(String corpNo, String aprvlDocId) {
Map<String, Object> info = getApvdocInfo(corpNo, aprvlDocId);
checkAplnt(info);
Map<String, Object> p = param(corpNo, aprvlDocId);
apvdocMapper.deleteApvdocSx0080(p);
apvdocMapper.deleteApvdocSx0110(p);
apvdocMapper.deleteApvdocSx0090(p);
String atchNo = (String) info.get("ATCH_NO");
if (atchNo != null) attachService.deleteFilesByAtchNo(atchNo, true);
}
// ──────────────────────────────────────────────
// 내부 유틸
// ──────────────────────────────────────────────
private Map<String, Object> param(String corpNo, String aprvlDocId) {
Map<String, Object> p = new HashMap<>();
p.put("corpNo", corpNo);
p.put("aprvlDocId", aprvlDocId);
return p;
}
private void checkBeforeRequest(Map<String, Object> docInfo) {
if (!STATUS_WRITING.equals(docInfo.get("APRVL_STUS_CD"))) {
throw new BizException("작성중 상태가 아닙니다.");
}
}
private void checkAplnt(Map<String, Object> docInfo) {
String usrId = SecurityUtil.getUsrId();
if (!usrId.equals(docInfo.get("APLNT_ID"))) {
throw new BizException("신청자가 아닙니다.", HttpStatus.FORBIDDEN);
}
}
}

View File

@@ -0,0 +1,315 @@
package com.company.gw.tam.service;
import com.company.gw.common.util.SecurityUtil;
import com.company.gw.tam.mapper.TamMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class TamService {
private final TamMapper tamMapper;
private final ApvdocService apvdocService;
// ──────────────────────────────────────────────
// TAM0010 - 연차 관리
// ──────────────────────────────────────────────
@Transactional(readOnly = true)
public List<Map<String, Object>> getYyvctList(String corpNo, String yyvctYy,
String usrNm, String teamCd,
String includeRetireYn) {
Map<String, Object> p = new HashMap<>();
p.put("corpNo", corpNo);
p.put("yyvctYy", yyvctYy);
p.put("usrNm", usrNm);
p.put("teamCd", teamCd);
p.put("includeRetireYn", includeRetireYn != null ? includeRetireYn : "N");
return tamMapper.getYyvctList(p);
}
@Transactional
public void saveYyvct(String corpNo, String yyvctYy, String usrId, int yyvctCnt) {
Map<String, Object> p = new HashMap<>();
p.put("corpNo", corpNo);
p.put("yyvctYy", yyvctYy);
p.put("usrId", usrId);
p.put("yyvctCnt", yyvctCnt);
p.put("loginUsrId", SecurityUtil.getUsrId());
tamMapper.upsertYyvct(p);
}
@Transactional
public void deleteYyvct(String corpNo, String yyvctYy, String usrId) {
Map<String, Object> p = new HashMap<>();
p.put("corpNo", corpNo);
p.put("yyvctYy", yyvctYy);
p.put("usrId", usrId);
tamMapper.deleteYyvct(p);
}
// ──────────────────────────────────────────────
// TAM0020 - 결재 신청 목록
// ──────────────────────────────────────────────
@Transactional(readOnly = true)
public Map<String, Object> getApvreqList(String corpNo, Map<String, Object> filter) {
filter.put("corpNo", corpNo);
filter.put("usrId", SecurityUtil.getUsrId());
filter.put("pageNo", Integer.parseInt(filter.getOrDefault("pageNo", "1").toString()));
filter.put("pageSize", Integer.parseInt(filter.getOrDefault("pageSize", "20").toString()));
if (!filter.containsKey("staYmd") || filter.get("staYmd") == null || filter.get("staYmd").toString().isEmpty())
filter.put("staYmd", "00000000");
if (!filter.containsKey("endYmd") || filter.get("endYmd") == null || filter.get("endYmd").toString().isEmpty())
filter.put("endYmd", "99999999");
List<Map<String, Object>> list = tamMapper.getApvreqList(filter);
long total = tamMapper.getApvreqListCount(filter);
Map<String, Object> result = new HashMap<>();
result.put("list", list);
result.put("total", total);
return result;
}
/** 결재 신청 상세 (문서 + 결재자 + 첨부 + 세부항목) */
@Transactional(readOnly = true)
public Map<String, Object> getApvreqDetail(String corpNo, String aprvlDocId) {
Map<String, Object> info = apvdocService.getApvdocInfoAll(corpNo, aprvlDocId);
// 변경근무 목록
Map<String, Object> p = new HashMap<>();
p.put("corpNo", corpNo);
p.put("aprvlDocId", aprvlDocId);
info.put("workChangeList", tamMapper.getWorkChangeList(p));
info.put("otInfo", tamMapper.getOtInfo(p));
return info;
}
/** 결재 신청 생성 */
@Transactional
public String createApvreq(String corpNo, Map<String, Object> body) {
String aprvlDocId = apvdocService.createApvdoc(
corpNo,
(String) body.get("aprvlKindCd"),
(String) body.get("aplntCn"),
(String) body.get("bzCn"),
(String) body.get("offerCn"),
(String) body.get("bzDeputyId"),
(String) body.get("atchNo")
);
// 세부 항목 등록
Map<String, Object> p = new HashMap<>();
p.put("corpNo", corpNo);
p.put("aprvlDocId", aprvlDocId);
p.put("usrId", SecurityUtil.getUsrId());
@SuppressWarnings("unchecked")
List<Map<String, Object>> workChangeList = (List<Map<String, Object>>) body.get("workChangeList");
if (workChangeList != null) {
for (Map<String, Object> item : workChangeList) {
Map<String, Object> ip = new HashMap<>(p);
ip.putAll(item);
tamMapper.insertSxGw0110(ip);
}
}
Map<String, Object> otInfo = (Map<String, Object>) body.get("otInfo");
if (otInfo != null) {
Map<String, Object> ip = new HashMap<>(p);
ip.putAll(otInfo);
tamMapper.insertSxGw0080(ip);
}
return aprvlDocId;
}
/** 결재 신청 수정 */
@Transactional
public void updateApvreq(String corpNo, String aprvlDocId, Map<String, Object> body) {
apvdocService.updateApvdocContent(corpNo, aprvlDocId,
(String) body.get("aplntCn"), (String) body.get("bzCn"),
(String) body.get("offerCn"), (String) body.get("bzDeputyId"),
(String) body.get("atchNo"));
Map<String, Object> p = new HashMap<>();
p.put("corpNo", corpNo);
p.put("aprvlDocId", aprvlDocId);
p.put("usrId", SecurityUtil.getUsrId());
tamMapper.deleteSxGw0110(p);
tamMapper.deleteSxGw0080(p);
@SuppressWarnings("unchecked")
List<Map<String, Object>> workChangeList = (List<Map<String, Object>>) body.get("workChangeList");
if (workChangeList != null) {
for (Map<String, Object> item : workChangeList) {
Map<String, Object> ip = new HashMap<>(p);
ip.putAll(item);
tamMapper.insertSxGw0110(ip);
}
}
Map<String, Object> otInfo = (Map<String, Object>) body.get("otInfo");
if (otInfo != null) {
Map<String, Object> ip = new HashMap<>(p);
ip.putAll(otInfo);
tamMapper.insertSxGw0080(ip);
}
}
/** 결재 상신 (결재자 등록 + 상신) */
@Transactional
public void requestApvreq(String corpNo, String aprvlDocId, List<Map<String, Object>> apprList) {
apvdocService.insertApprList(corpNo, aprvlDocId, apprList);
apvdocService.requestApvdoc(corpNo, aprvlDocId);
}
/** 결재문서 삭제 */
@Transactional
public void deleteApvreq(String corpNo, String aprvlDocId) {
apvdocService.deleteApvdoc(corpNo, aprvlDocId);
}
/** 최근 결재자 목록 조회 */
@Transactional(readOnly = true)
public List<Map<String, Object>> getLatestApprList(String corpNo) {
Map<String, Object> p = new HashMap<>();
p.put("corpNo", corpNo);
p.put("usrId", SecurityUtil.getUsrId());
return tamMapper.getLatestApprList(p);
}
// ──────────────────────────────────────────────
// TAM0030 - 결재 처리
// ──────────────────────────────────────────────
@Transactional(readOnly = true)
public Map<String, Object> getApvappList(String corpNo, Map<String, Object> filter) {
filter.put("corpNo", corpNo);
filter.put("usrId", SecurityUtil.getUsrId());
filter.put("pageNo", Integer.parseInt(filter.getOrDefault("pageNo", "1").toString()));
filter.put("pageSize", Integer.parseInt(filter.getOrDefault("pageSize", "20").toString()));
List<Map<String, Object>> list = tamMapper.getApvappList(filter);
long total = tamMapper.getApvappListCount(filter);
Map<String, Object> result = new HashMap<>();
result.put("list", list);
result.put("total", total);
return result;
}
/** 결재 처리 상세 */
@Transactional(readOnly = true)
public Map<String, Object> getApvappDetail(String corpNo, String aprvlDocId) {
return apvdocService.getApvdocInfoAll(corpNo, aprvlDocId);
}
/** 승인 처리 (최종 승인 시 executeAfterProcess 호출) */
@Transactional
public void approveApvdoc(String corpNo, String aprvlDocId, int aprvlSno, String apprCn) {
// 승인 전 문서 정보 (aplntId, aprvlKindCd 필요)
Map<String, Object> docBefore = apvdocService.getApvdocInfo(corpNo, aprvlDocId);
apvdocService.approveApvdoc(corpNo, aprvlDocId, aprvlSno, apprCn);
// 최종 승인 여부 확인
Map<String, Object> docAfter = apvdocService.getApvdocInfo(corpNo, aprvlDocId);
if (ApvdocService.STATUS_APPROVED.equals(docAfter.get("APRVL_STUS_CD"))) {
executeAfterProcess(corpNo, aprvlDocId,
(String) docBefore.get("APRVL_KIND_CD"),
(String) docBefore.get("APLNT_ID"));
}
}
/** 반려 처리 */
@Transactional
public void rejectApvdoc(String corpNo, String aprvlDocId, int aprvlSno, String apprCn) {
apvdocService.rejectApvdoc(corpNo, aprvlDocId, aprvlSno, apprCn);
}
/** 일괄 승인 */
@Transactional
public void multiApprove(String corpNo, List<Map<String, Object>> items, String apprCn) {
for (Map<String, Object> item : items) {
String aprvlDocId = (String) item.get("aprvlDocId");
int aprvlSno = Integer.parseInt(item.get("aprvlSno").toString());
try {
approveApvdoc(corpNo, aprvlDocId, aprvlSno, apprCn);
} catch (Exception e) {
// 일괄 처리 중 개별 실패는 무시하고 계속 진행
}
}
}
/** 일괄 반려 */
@Transactional
public void multiReject(String corpNo, List<Map<String, Object>> items, String apprCn) {
for (Map<String, Object> item : items) {
String aprvlDocId = (String) item.get("aprvlDocId");
int aprvlSno = Integer.parseInt(item.get("aprvlSno").toString());
try {
rejectApvdoc(corpNo, aprvlDocId, aprvlSno, apprCn);
} catch (Exception e) {
// 일괄 처리 중 개별 실패는 무시하고 계속 진행
}
}
}
/** 최종 승인 후처리 - 결재 종류별 근무 데이터 업데이트 */
private void executeAfterProcess(String corpNo, String aprvlDocId,
String aprvlKindCd, String aplntId) {
Map<String, Object> p = new HashMap<>();
p.put("corpNo", corpNo);
p.put("aprvlDocId", aprvlDocId);
p.put("aplntId", aplntId);
switch (aprvlKindCd != null ? aprvlKindCd : "") {
case "0001": // 시간외 → OT_RCTN_YN='Y'
tamMapper.afterProcessOt(p);
break;
case "0002": // 지각 → WORK_START_DT 보정
tamMapper.afterProcessLate(p);
break;
case "0003": // 조퇴 → WORK_END_DT 보정 + EL_RCTN_YN='Y'
tamMapper.afterProcessEarlyDep(p);
break;
case "0004": // 결근 → 출퇴근 모두 보정
tamMapper.afterProcessAbsence(p);
break;
case "0005": // 연차 → WORK_CD 변경
case "0006": // 근무변경 → WORK_CD 변경
tamMapper.afterProcessWorkChange(p);
break;
case "0008": // 외출 → OUTING_MIN/CNT 업데이트
tamMapper.updateOutingTime(p);
break;
default:
break;
}
}
// ──────────────────────────────────────────────
// TAM0040 - 근태 현황
// ──────────────────────────────────────────────
@Transactional(readOnly = true)
public List<Map<String, Object>> getTamStatusList(String corpNo, String yyvctYy,
String teamCd, String staYmd,
String endYmd, String includeRetireYn) {
Map<String, Object> p = new HashMap<>();
p.put("corpNo", corpNo);
p.put("yyvctYy", yyvctYy);
p.put("teamCd", teamCd);
p.put("staYmd", staYmd);
p.put("endYmd", endYmd);
p.put("includeRetireYn", includeRetireYn != null ? includeRetireYn : "N");
return tamMapper.getTamStatusList(p);
}
}

View File

@@ -0,0 +1,81 @@
package com.company.gw.wplan.controller;
import com.company.gw.common.dto.ApiResponse;
import com.company.gw.common.dto.CurrentUser;
import com.company.gw.wplan.service.WplanService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/wplan")
@RequiredArgsConstructor
public class WplanController {
private final WplanService wplanService;
// ──────────────────────────────────────────────
// 근무코드 목록
// ──────────────────────────────────────────────
@GetMapping("/workcode")
public ApiResponse<List<Map<String, Object>>> getWorkCodeList(
@AuthenticationPrincipal CurrentUser currentUser) {
return ApiResponse.ok(wplanService.getWorkCodeList(currentUser.getCorpNo()));
}
// ──────────────────────────────────────────────
// Wplan0010 - 근무계획관리
// ──────────────────────────────────────────────
@GetMapping("/0010")
public ApiResponse<List<Map<String, Object>>> getWplanList(
@RequestParam String workPlanYymm,
@RequestParam(defaultValue = "") String searchText,
@RequestParam(defaultValue = "") String teamCd,
@RequestParam(defaultValue = "N") String includeRetireYn,
@AuthenticationPrincipal CurrentUser currentUser) {
return ApiResponse.ok(wplanService.getWplanList(
currentUser.getCorpNo(), workPlanYymm, searchText, teamCd, includeRetireYn));
}
@PostMapping("/0010")
public ApiResponse<Void> saveWplanList(
@RequestBody Map<String, Object> body,
@AuthenticationPrincipal CurrentUser currentUser) {
@SuppressWarnings("unchecked")
List<Map<String, Object>> saveList = (List<Map<String, Object>>) body.get("saveList");
wplanService.saveWplanList(currentUser.getCorpNo(), saveList);
return ApiResponse.ok(null);
}
// ──────────────────────────────────────────────
// Wplan0020 - 나의 근무계획
// ──────────────────────────────────────────────
@GetMapping("/0020")
public ApiResponse<List<Map<String, Object>>> getMyWplanList(
@RequestParam String workPlanYymm,
@AuthenticationPrincipal CurrentUser currentUser) {
return ApiResponse.ok(wplanService.getMyWplanList(currentUser.getCorpNo(), workPlanYymm));
}
// ──────────────────────────────────────────────
// Wplan0030 - 전체 근무계획
// ──────────────────────────────────────────────
@GetMapping("/0030")
public ApiResponse<List<Map<String, Object>>> getAllWplanList(
@RequestParam String workPlanYymm,
@RequestParam(defaultValue = "") String teamCd,
@RequestParam(defaultValue = "") String dutyCd,
@RequestParam(defaultValue = "") String workCd,
@RequestParam(defaultValue = "WORK_CD") String workCdType,
@AuthenticationPrincipal CurrentUser currentUser) {
return ApiResponse.ok(wplanService.getAllWplanList(
currentUser.getCorpNo(), workPlanYymm, teamCd, dutyCd, workCd, workCdType));
}
}

View File

@@ -0,0 +1,27 @@
package com.company.gw.wplan.mapper;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
import java.util.Map;
@Mapper
public interface WplanMapper {
// 근무코드 목록
List<Map<String, Object>> getWorkCodeList(Map<String, Object> param);
// Wplan0010 - 근무계획관리
List<Map<String, Object>> getWplanList(Map<String, Object> param);
// Wplan0020 - 나의 근무계획
List<Map<String, Object>> getMyWplanList(Map<String, Object> param);
// Wplan0030 - 전체 근무계획
List<Map<String, Object>> getAllWplanList(Map<String, Object> param);
// 저장/삭제
void upsertWorkplan(Map<String, Object> param);
void deleteWorkplan(Map<String, Object> param);
void deleteWorkplanMonth(Map<String, Object> param);
}

View File

@@ -0,0 +1,97 @@
package com.company.gw.wplan.service;
import com.company.gw.common.util.SecurityUtil;
import com.company.gw.wplan.mapper.WplanMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class WplanService {
private final WplanMapper wplanMapper;
// ──────────────────────────────────────────────
// 근무코드 목록
// ──────────────────────────────────────────────
@Transactional(readOnly = true)
public List<Map<String, Object>> getWorkCodeList(String corpNo) {
Map<String, Object> p = new HashMap<>();
p.put("corpNo", corpNo);
return wplanMapper.getWorkCodeList(p);
}
// ──────────────────────────────────────────────
// Wplan0010 - 근무계획관리
// ──────────────────────────────────────────────
@Transactional(readOnly = true)
public List<Map<String, Object>> getWplanList(String corpNo, String workPlanYymm,
String searchText, String teamCd,
String includeRetireYn) {
Map<String, Object> p = new HashMap<>();
p.put("corpNo", corpNo);
p.put("workPlanYymm", workPlanYymm);
p.put("searchText", searchText);
p.put("teamCd", teamCd);
p.put("includeRetireYn", includeRetireYn != null ? includeRetireYn : "N");
return wplanMapper.getWplanList(p);
}
/**
* 근무계획 일괄 저장
* saveList: [{usrId, workPlanYymmdd, planWorkCd, sortOdr}]
*/
@Transactional
public void saveWplanList(String corpNo, List<Map<String, Object>> saveList) {
String loginUsrId = SecurityUtil.getUsrId();
for (Map<String, Object> item : saveList) {
Map<String, Object> p = new HashMap<>(item);
p.put("corpNo", corpNo);
p.put("loginUsrId", loginUsrId);
String planWorkCd = (String) p.get("planWorkCd");
if (planWorkCd == null || planWorkCd.isEmpty()) {
wplanMapper.deleteWorkplan(p);
} else {
wplanMapper.upsertWorkplan(p);
}
}
}
// ──────────────────────────────────────────────
// Wplan0020 - 나의 근무계획
// ──────────────────────────────────────────────
@Transactional(readOnly = true)
public List<Map<String, Object>> getMyWplanList(String corpNo, String workPlanYymm) {
Map<String, Object> p = new HashMap<>();
p.put("corpNo", corpNo);
p.put("usrId", SecurityUtil.getUsrId());
p.put("workPlanYymm", workPlanYymm);
return wplanMapper.getMyWplanList(p);
}
// ──────────────────────────────────────────────
// Wplan0030 - 전체 근무계획
// ──────────────────────────────────────────────
@Transactional(readOnly = true)
public List<Map<String, Object>> getAllWplanList(String corpNo, String workPlanYymm,
String teamCd, String dutyCd,
String workCd, String workCdType) {
Map<String, Object> p = new HashMap<>();
p.put("corpNo", corpNo);
p.put("workPlanYymm", workPlanYymm);
p.put("teamCd", teamCd);
p.put("dutyCd", dutyCd);
p.put("workCd", workCd);
p.put("workCdType", workCdType != null ? workCdType : "WORK_CD");
return wplanMapper.getAllWplanList(p);
}
}

View File

@@ -0,0 +1,58 @@
package com.company.gw.wtime.controller;
import com.company.gw.common.dto.ApiResponse;
import com.company.gw.common.dto.CurrentUser;
import com.company.gw.wtime.service.WtimeService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/wtime")
@RequiredArgsConstructor
public class WtimeController {
private final WtimeService wtimeService;
// ──────────────────────────────────────────────
// Wtime0010 - 개인별 근무시간
// ──────────────────────────────────────────────
@GetMapping("/0010")
public ApiResponse<List<Map<String, Object>>> getWtimeList(
@RequestParam String staYmd,
@RequestParam String endYmd,
@RequestParam(defaultValue = "") String usrId,
@RequestParam(defaultValue = "") String usrNm,
@RequestParam(defaultValue = "") String teamCd,
@RequestParam(defaultValue = "") String dutyCd,
@RequestParam(defaultValue = "N") String includeRetireYn,
@RequestParam(defaultValue = "") String includeWorkYn,
@AuthenticationPrincipal CurrentUser currentUser) {
return ApiResponse.ok(wtimeService.getWtimeList(
currentUser.getCorpNo(), staYmd, endYmd,
usrId, usrNm, teamCd, dutyCd, includeRetireYn, includeWorkYn));
}
// ──────────────────────────────────────────────
// Wtime0030 - 월별 근무시간 집계
// ──────────────────────────────────────────────
@GetMapping("/0030")
public ApiResponse<List<Map<String, Object>>> getWstatList(
@RequestParam String staYmd,
@RequestParam String endYmd,
@RequestParam(defaultValue = "") String usrId,
@RequestParam(defaultValue = "") String usrNm,
@RequestParam(defaultValue = "") String teamCd,
@RequestParam(defaultValue = "") String dutyCd,
@RequestParam(defaultValue = "N") String includeRetireYn,
@AuthenticationPrincipal CurrentUser currentUser) {
return ApiResponse.ok(wtimeService.getWstatList(
currentUser.getCorpNo(), staYmd, endYmd,
usrId, usrNm, teamCd, dutyCd, includeRetireYn));
}
}

View File

@@ -0,0 +1,16 @@
package com.company.gw.wtime.mapper;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
import java.util.Map;
@Mapper
public interface WtimeMapper {
// Wtime0010 - 개인별 근무시간
List<Map<String, Object>> getWtimeList(Map<String, Object> param);
// Wtime0030 - 월별 근무시간 집계
List<Map<String, Object>> getWstatList(Map<String, Object> param);
}

View File

@@ -0,0 +1,59 @@
package com.company.gw.wtime.service;
import com.company.gw.wtime.mapper.WtimeMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class WtimeService {
private final WtimeMapper wtimeMapper;
// ──────────────────────────────────────────────
// Wtime0010 - 개인별 근무시간
// ──────────────────────────────────────────────
@Transactional(readOnly = true)
public List<Map<String, Object>> getWtimeList(String corpNo, String staYmd, String endYmd,
String usrId, String usrNm, String teamCd,
String dutyCd, String includeRetireYn,
String includeWorkYn) {
Map<String, Object> p = new HashMap<>();
p.put("corpNo", corpNo);
p.put("staYmd", staYmd);
p.put("endYmd", endYmd);
p.put("usrId", usrId);
p.put("usrNm", usrNm);
p.put("teamCd", teamCd);
p.put("dutyCd", dutyCd);
p.put("includeRetireYn", includeRetireYn != null ? includeRetireYn : "N");
p.put("includeWorkYn", includeWorkYn);
return wtimeMapper.getWtimeList(p);
}
// ──────────────────────────────────────────────
// Wtime0030 - 월별 근무시간 집계
// ──────────────────────────────────────────────
@Transactional(readOnly = true)
public List<Map<String, Object>> getWstatList(String corpNo, String staYmd, String endYmd,
String usrId, String usrNm, String teamCd,
String dutyCd, String includeRetireYn) {
Map<String, Object> p = new HashMap<>();
p.put("corpNo", corpNo);
p.put("staYmd", staYmd);
p.put("endYmd", endYmd);
p.put("usrId", usrId);
p.put("usrNm", usrNm);
p.put("teamCd", teamCd);
p.put("dutyCd", dutyCd);
p.put("includeRetireYn", includeRetireYn != null ? includeRetireYn : "N");
return wtimeMapper.getWstatList(p);
}
}

View File

@@ -0,0 +1,17 @@
# MariaDB 설정 (추후 전환용)
spring:
datasource:
url: ${DB_URL:jdbc:mariadb://localhost:3306/gw}
username: ${DB_USERNAME:}
password: ${DB_PASSWORD:}
driver-class-name: org.mariadb.jdbc.Driver
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
mybatis:
configuration:
database-id: mariadb

View File

@@ -0,0 +1,13 @@
# MS SQL Server 설정 (현재 운영)
spring:
datasource:
url: ${DB_URL:jdbc:sqlserver://121.156.116.136:52785;databaseName=logins_test;encrypt=false;trustServerCertificate=true;sendStringParametersAsUnicode=false}
username: ${DB_USERNAME:logins}
password: ${DB_PASSWORD:ghkfkdahrfh40-8}
driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000

View File

@@ -0,0 +1,17 @@
# Oracle DB 설정 (현재 운영)
spring:
datasource:
url: ${DB_URL:jdbc:oracle:thin:@localhost:1521:ORCL}
username: ${DB_USERNAME:}
password: ${DB_PASSWORD:}
driver-class-name: oracle.jdbc.OracleDriver
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
mybatis:
configuration:
database-id: oracle

View File

@@ -0,0 +1,62 @@
spring:
application:
name: gw-backend
# Active Profile: mssql | mariadb
profiles:
active: mssql
# Redis
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
# RabbitMQ
rabbitmq:
host: ${RABBITMQ_HOST:localhost}
port: ${RABBITMQ_PORT:5672}
username: ${RABBITMQ_USER:guest}
password: ${RABBITMQ_PASSWORD:guest}
# File Upload
servlet:
multipart:
max-file-size: 50MB
max-request-size: 100MB
# MyBatis
mybatis:
mapper-locations: classpath:mapper/**/*.xml
configuration:
map-underscore-to-camel-case: true
default-fetch-size: 100
default-statement-timeout: 30
# JWT
jwt:
secret: ${JWT_SECRET:your-secret-key-must-be-at-least-256-bits-long-for-hs256}
access-token-expiry: 1800000 # 30분 (ms)
refresh-token-expiry: 604800000 # 7일 (ms)
# File Storage
file:
upload-path: ${FILE_UPLOAD_PATH:/data/uploads}
# Actuator
management:
endpoints:
web:
exposure:
include: health,info
base-path: /actuator
endpoint:
health:
show-details: when-authorized
# Logging
logging:
level:
com.company.gw: DEBUG
org.mybatis: DEBUG

View File

@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<springProperty scope="context" name="APP_NAME" source="spring.application.name" defaultValue="gw-backend"/>
<!-- ── 패턴 ── -->
<property name="LOG_PATTERN"
value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"/>
<!-- ── 콘솔 ── -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- ── 파일 (Rolling) ── -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>/var/log/${APP_NAME}/app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 날짜별 롤링, 최대 30일 보관 -->
<fileNamePattern>/var/log/${APP_NAME}/app.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<timeBasedFileNamingAndTriggeringPolicy
class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>100MB</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
<maxHistory>30</maxHistory>
<totalSizeCap>3GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- ── 에러 전용 파일 ── -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>/var/log/${APP_NAME}/error.log</file>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>ERROR</level>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>/var/log/${APP_NAME}/error.%d{yyyy-MM-dd}.log.gz</fileNamePattern>
<maxHistory>60</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- ── 레벨 설정 ── -->
<!-- 로컬 개발: 콘솔만, DEBUG (mssql/mariadb는 DB 프로파일이므로 local 여부로 판단) -->
<springProfile name="local">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
<logger name="com.company.gw" level="DEBUG"/>
<logger name="org.mybatis" level="DEBUG"/>
<logger name="jdbc.sqltiming" level="DEBUG"/>
</springProfile>
<!-- 운영: 파일 + 콘솔, INFO -->
<springProfile name="prod">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
<appender-ref ref="ERROR_FILE"/>
</root>
<logger name="com.company.gw" level="INFO"/>
<logger name="org.mybatis" level="WARN"/>
<logger name="org.apache.ibatis" level="WARN"/>
</springProfile>
<!-- 기본 (local/prod 아닐 때) -->
<springProfile name="!local &amp;&amp; !prod">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
<logger name="com.company.gw" level="INFO"/>
</springProfile>
</configuration>

View File

@@ -0,0 +1,339 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!-- 원본: src/java/sql/biz/board/board0010_sql.xml
테이블: SX_GW0020(게시물), SX_GW0030(댓글), SX_GW0010(사용자) -->
<mapper namespace="com.company.gw.board.mapper.BoardMapper">
<!-- ===================== MSSQL (default) ===================== -->
<!-- 게시물 목록 (페이징) -->
<select id="getPostList" parameterType="map" resultType="map">
SELECT A.UNTY_BBS_SNO,
A.BBS_TITLE_NM,
A.INQR_CNT,
A.CMMT_CNT,
A.CTUSR_ID,
B.USR_NM AS CTUSR_NM,
CONVERT(VARCHAR, A.RGST_DT, 112) AS RGST_DT
FROM SX_GW0020 A
LEFT JOIN SX_GW0010 B ON B.CORP_NO = A.CORP_NO AND B.USR_ID = A.CTUSR_ID
WHERE A.CORP_NO = #{corpNo}
AND A.UNTY_BBS_CD = #{untyBbsCd}
AND (#{searchText} IS NULL OR #{searchText} = ''
OR A.BBS_TITLE_NM LIKE CONCAT('%', #{searchText}, '%')
OR CAST(A.BBS_CN AS VARCHAR(MAX)) LIKE CONCAT('%', #{searchText}, '%'))
ORDER BY A.RGST_DT DESC, A.UNTY_BBS_SNO DESC
OFFSET (#{pageNo} - 1) * #{pageSize} ROWS
FETCH NEXT #{pageSize} ROWS ONLY
</select>
<!-- 게시물 목록 건수 -->
<select id="getPostListCount" parameterType="map" resultType="long">
SELECT COUNT(1)
FROM SX_GW0020 A
WHERE A.CORP_NO = #{corpNo}
AND A.UNTY_BBS_CD = #{untyBbsCd}
AND (#{searchText} IS NULL OR #{searchText} = ''
OR A.BBS_TITLE_NM LIKE CONCAT('%', #{searchText}, '%')
OR CAST(A.BBS_CN AS VARCHAR(MAX)) LIKE CONCAT('%', #{searchText}, '%'))
</select>
<!-- 게시물 상세 -->
<select id="getPostInfo" parameterType="map" resultType="map">
SELECT A.UNTY_BBS_CD,
A.UNTY_BBS_SNO,
A.BBS_TITLE_NM,
A.BBS_CN,
A.INQR_CNT,
A.CMMT_CNT,
A.CTUSR_ID,
A.ETC_ATCH_NO,
A.PHOTO_ATCH_NO,
A.RGSTR_ID,
CONVERT(VARCHAR, A.RGST_DT, 120) AS RGST_DATE,
CONVERT(VARCHAR, A.UPD_DT, 120) AS UPD_DATE,
B.USR_NM AS CTUSR_NM
FROM SX_GW0020 A
LEFT JOIN SX_GW0010 B ON B.CORP_NO = A.CORP_NO AND B.USR_ID = A.CTUSR_ID
WHERE A.CORP_NO = #{corpNo}
AND A.UNTY_BBS_CD = #{untyBbsCd}
AND A.UNTY_BBS_SNO = #{untyBbsSno}
</select>
<!-- 게시물 등록 -->
<insert id="insertPost" parameterType="map">
INSERT INTO SX_GW0020 (
CORP_NO, UNTY_BBS_CD, UNTY_BBS_SNO,
BBS_TITLE_NM, BBS_CN, INQR_CNT,
CTUSR_ID, RGSTR_ID, RGST_DT, MODID, UPD_DT
) VALUES (
#{corpNo}, #{untyBbsCd}, #{untyBbsSno},
#{bbsTitleNm}, #{bbsCn}, 0,
#{usrId}, #{usrId}, GETDATE(), #{usrId}, GETDATE()
)
</insert>
<!-- 게시물 수정 -->
<update id="updatePost" parameterType="map">
UPDATE SX_GW0020
SET BBS_TITLE_NM = #{bbsTitleNm},
BBS_CN = #{bbsCn},
MODID = #{usrId},
UPD_DT = GETDATE()
WHERE CORP_NO = #{corpNo}
AND UNTY_BBS_CD = #{untyBbsCd}
AND UNTY_BBS_SNO = #{untyBbsSno}
</update>
<!-- 조회수 증가 -->
<update id="increaseInqrCnt" parameterType="map">
UPDATE SX_GW0020
SET INQR_CNT = ISNULL(INQR_CNT, 0) + 1
WHERE CORP_NO = #{corpNo}
AND UNTY_BBS_CD = #{untyBbsCd}
AND UNTY_BBS_SNO = #{untyBbsSno}
</update>
<!-- 기타첨부 번호 업데이트 -->
<update id="updateEtcAtchNo" parameterType="map">
UPDATE SX_GW0020
SET ETC_ATCH_NO = #{etcAtchNo},
MODID = #{usrId},
UPD_DT = GETDATE()
WHERE CORP_NO = #{corpNo}
AND UNTY_BBS_CD = #{untyBbsCd}
AND UNTY_BBS_SNO = #{untyBbsSno}
</update>
<!-- 사진첨부 번호 업데이트 -->
<update id="updatePhotoAtchNo" parameterType="map">
UPDATE SX_GW0020
SET PHOTO_ATCH_NO = #{photoAtchNo},
MODID = #{usrId},
UPD_DT = GETDATE()
WHERE CORP_NO = #{corpNo}
AND UNTY_BBS_CD = #{untyBbsCd}
AND UNTY_BBS_SNO = #{untyBbsSno}
</update>
<!-- 댓글수 재계산 -->
<update id="updateCmmtCnt" parameterType="map">
UPDATE SX_GW0020
SET CMMT_CNT = (
SELECT COUNT(1) FROM SX_GW0030
WHERE CORP_NO = #{corpNo}
AND UNTY_BBS_CD = #{untyBbsCd}
AND UNTY_BBS_SNO = #{untyBbsSno}
),
MODID = #{usrId},
UPD_DT = GETDATE()
WHERE CORP_NO = #{corpNo}
AND UNTY_BBS_CD = #{untyBbsCd}
AND UNTY_BBS_SNO = #{untyBbsSno}
</update>
<!-- 게시물 삭제 -->
<delete id="deletePost" parameterType="map">
DELETE FROM SX_GW0020
WHERE CORP_NO = #{corpNo}
AND UNTY_BBS_CD = #{untyBbsCd}
AND UNTY_BBS_SNO = #{untyBbsSno}
</delete>
<!-- 댓글 목록 -->
<select id="getCommentList" parameterType="map" resultType="map">
SELECT A.CMMT_SNO,
A.CMMT_CN,
A.CMMT_CTUSR_ID,
B.USR_NM AS CMMT_CTUSR_NM,
CONVERT(VARCHAR, A.RGST_DT, 120) AS RGST_DATE
FROM SX_GW0030 A
LEFT JOIN SX_GW0010 B ON B.CORP_NO = A.CORP_NO AND B.USR_ID = A.CMMT_CTUSR_ID
WHERE A.CORP_NO = #{corpNo}
AND A.UNTY_BBS_CD = #{untyBbsCd}
AND A.UNTY_BBS_SNO = #{untyBbsSno}
ORDER BY A.RGST_DT, A.CMMT_SNO
</select>
<!-- 댓글 상세 -->
<select id="getCommentInfo" parameterType="map" resultType="map">
SELECT A.CMMT_SNO, A.CMMT_CN, A.CMMT_CTUSR_ID
FROM SX_GW0030 A
WHERE A.CORP_NO = #{corpNo}
AND A.UNTY_BBS_CD = #{untyBbsCd}
AND A.UNTY_BBS_SNO = #{untyBbsSno}
AND A.CMMT_SNO = #{cmmtSno}
</select>
<!-- 댓글 등록 -->
<insert id="insertComment" parameterType="map">
INSERT INTO SX_GW0030 (
CORP_NO, UNTY_BBS_CD, UNTY_BBS_SNO, CMMT_SNO,
CMMT_CN, CMMT_CTUSR_ID, RGSTR_ID, RGST_DT, MODID, UPD_DT
) VALUES (
#{corpNo}, #{untyBbsCd}, #{untyBbsSno}, #{cmmtSno},
#{cmmtCn}, #{usrId}, #{usrId}, GETDATE(), #{usrId}, GETDATE()
)
</insert>
<!-- 댓글 삭제 -->
<delete id="deleteComment" parameterType="map">
DELETE FROM SX_GW0030
WHERE CORP_NO = #{corpNo}
AND UNTY_BBS_CD = #{untyBbsCd}
AND UNTY_BBS_SNO = #{untyBbsSno}
AND CMMT_SNO = #{cmmtSno}
</delete>
<!-- 게시물 전체 댓글 삭제 (게시물 삭제시) -->
<delete id="deleteCommentsByPost" parameterType="map">
DELETE FROM SX_GW0030
WHERE CORP_NO = #{corpNo}
AND UNTY_BBS_CD = #{untyBbsCd}
AND UNTY_BBS_SNO = #{untyBbsSno}
</delete>
<!-- ===================== MariaDB ===================== -->
<select id="getPostList" parameterType="map" resultType="map" databaseId="mariadb">
SELECT A.UNTY_BBS_SNO,
A.BBS_TITLE_NM,
A.INQR_CNT,
A.CMMT_CNT,
A.CTUSR_ID,
B.USR_NM AS CTUSR_NM,
DATE_FORMAT(A.RGST_DT, '%Y%m%d') AS RGST_DT
FROM SX_GW0020 A
LEFT JOIN SX_GW0010 B ON B.CORP_NO = A.CORP_NO AND B.USR_ID = A.CTUSR_ID
WHERE A.CORP_NO = #{corpNo}
AND A.UNTY_BBS_CD = #{untyBbsCd}
AND (#{searchText} IS NULL OR #{searchText} = ''
OR A.BBS_TITLE_NM LIKE CONCAT('%', #{searchText}, '%')
OR CAST(A.BBS_CN AS CHAR) LIKE CONCAT('%', #{searchText}, '%'))
ORDER BY A.RGST_DT DESC, A.UNTY_BBS_SNO DESC
LIMIT #{pageSize} OFFSET (#{pageNo} - 1) * #{pageSize}
</select>
<select id="getPostListCount" parameterType="map" resultType="long" databaseId="mariadb">
SELECT COUNT(1)
FROM SX_GW0020 A
WHERE A.CORP_NO = #{corpNo}
AND A.UNTY_BBS_CD = #{untyBbsCd}
AND (#{searchText} IS NULL OR #{searchText} = ''
OR A.BBS_TITLE_NM LIKE CONCAT('%', #{searchText}, '%')
OR CAST(A.BBS_CN AS CHAR) LIKE CONCAT('%', #{searchText}, '%'))
</select>
<select id="getPostInfo" parameterType="map" resultType="map" databaseId="mariadb">
SELECT A.UNTY_BBS_CD,
A.UNTY_BBS_SNO,
A.BBS_TITLE_NM,
A.BBS_CN,
A.INQR_CNT,
A.CMMT_CNT,
A.CTUSR_ID,
A.ETC_ATCH_NO,
A.PHOTO_ATCH_NO,
A.RGSTR_ID,
DATE_FORMAT(A.RGST_DT, '%Y-%m-%d %H:%i:%s') AS RGST_DATE,
DATE_FORMAT(A.UPD_DT, '%Y-%m-%d %H:%i:%s') AS UPD_DATE,
B.USR_NM AS CTUSR_NM
FROM SX_GW0020 A
LEFT JOIN SX_GW0010 B ON B.CORP_NO = A.CORP_NO AND B.USR_ID = A.CTUSR_ID
WHERE A.CORP_NO = #{corpNo}
AND A.UNTY_BBS_CD = #{untyBbsCd}
AND A.UNTY_BBS_SNO = #{untyBbsSno}
</select>
<insert id="insertPost" parameterType="map" databaseId="mariadb">
INSERT INTO SX_GW0020 (
CORP_NO, UNTY_BBS_CD, UNTY_BBS_SNO,
BBS_TITLE_NM, BBS_CN, INQR_CNT,
CTUSR_ID, RGSTR_ID, RGST_DT, MODID, UPD_DT
) VALUES (
#{corpNo}, #{untyBbsCd}, #{untyBbsSno},
#{bbsTitleNm}, #{bbsCn}, 0,
#{usrId}, #{usrId}, NOW(), #{usrId}, NOW()
)
</insert>
<update id="updatePost" parameterType="map" databaseId="mariadb">
UPDATE SX_GW0020
SET BBS_TITLE_NM = #{bbsTitleNm},
BBS_CN = #{bbsCn},
MODID = #{usrId},
UPD_DT = NOW()
WHERE CORP_NO = #{corpNo}
AND UNTY_BBS_CD = #{untyBbsCd}
AND UNTY_BBS_SNO = #{untyBbsSno}
</update>
<update id="increaseInqrCnt" parameterType="map" databaseId="mariadb">
UPDATE SX_GW0020
SET INQR_CNT = IFNULL(INQR_CNT, 0) + 1
WHERE CORP_NO = #{corpNo}
AND UNTY_BBS_CD = #{untyBbsCd}
AND UNTY_BBS_SNO = #{untyBbsSno}
</update>
<update id="updateEtcAtchNo" parameterType="map" databaseId="mariadb">
UPDATE SX_GW0020
SET ETC_ATCH_NO = #{etcAtchNo},
MODID = #{usrId},
UPD_DT = NOW()
WHERE CORP_NO = #{corpNo}
AND UNTY_BBS_CD = #{untyBbsCd}
AND UNTY_BBS_SNO = #{untyBbsSno}
</update>
<update id="updatePhotoAtchNo" parameterType="map" databaseId="mariadb">
UPDATE SX_GW0020
SET PHOTO_ATCH_NO = #{photoAtchNo},
MODID = #{usrId},
UPD_DT = NOW()
WHERE CORP_NO = #{corpNo}
AND UNTY_BBS_CD = #{untyBbsCd}
AND UNTY_BBS_SNO = #{untyBbsSno}
</update>
<update id="updateCmmtCnt" parameterType="map" databaseId="mariadb">
UPDATE SX_GW0020
SET CMMT_CNT = (
SELECT COUNT(1) FROM SX_GW0030
WHERE CORP_NO = #{corpNo}
AND UNTY_BBS_CD = #{untyBbsCd}
AND UNTY_BBS_SNO = #{untyBbsSno}
),
MODID = #{usrId},
UPD_DT = NOW()
WHERE CORP_NO = #{corpNo}
AND UNTY_BBS_CD = #{untyBbsCd}
AND UNTY_BBS_SNO = #{untyBbsSno}
</update>
<insert id="insertComment" parameterType="map" databaseId="mariadb">
INSERT INTO SX_GW0030 (
CORP_NO, UNTY_BBS_CD, UNTY_BBS_SNO, CMMT_SNO,
CMMT_CN, CMMT_CTUSR_ID, RGSTR_ID, RGST_DT, MODID, UPD_DT
) VALUES (
#{corpNo}, #{untyBbsCd}, #{untyBbsSno}, #{cmmtSno},
#{cmmtCn}, #{usrId}, #{usrId}, NOW(), #{usrId}, NOW()
)
</insert>
<select id="getCommentList" parameterType="map" resultType="map" databaseId="mariadb">
SELECT A.CMMT_SNO,
A.CMMT_CN,
A.CMMT_CTUSR_ID,
B.USR_NM AS CMMT_CTUSR_NM,
DATE_FORMAT(A.RGST_DT, '%Y-%m-%d %H:%i:%s') AS RGST_DATE
FROM SX_GW0030 A
LEFT JOIN SX_GW0010 B ON B.CORP_NO = A.CORP_NO AND B.USR_ID = A.CMMT_CTUSR_ID
WHERE A.CORP_NO = #{corpNo}
AND A.UNTY_BBS_CD = #{untyBbsCd}
AND A.UNTY_BBS_SNO = #{untyBbsSno}
ORDER BY A.RGST_DT, A.CMMT_SNO
</select>
</mapper>

View File

@@ -0,0 +1,111 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.company.gw.common.mapper.AttachMapper">
<!-- ===================== MSSQL (default) ===================== -->
<select id="getFileInfo" parameterType="string" resultType="map">
SELECT ATCHFILE_NO, ATCH_NO, ATCH_FILE_PATH_NM, ATCH_FILE_NM,
ATCH_TYPE_NM, ATCH_FILE_MG, TBL_COL_NM, RGSTR_ID
FROM SX_CO0050
WHERE ATCHFILE_NO = #{atchfileNo}
</select>
<select id="getFileInfoListByAtchNo" parameterType="string" resultType="map">
SELECT ATCHFILE_NO, ATCH_NO, ATCH_FILE_PATH_NM, ATCH_FILE_NM,
ATCH_TYPE_NM, ATCH_FILE_MG, TBL_COL_NM
FROM SX_CO0050
WHERE ATCH_NO = #{atchNo}
AND DEL_DT IS NULL
ORDER BY RGST_DT
</select>
<insert id="insertFileInfo" parameterType="map">
INSERT INTO SX_CO0050 (
ATCHFILE_NO, ATCH_NO, ATCH_FILE_PATH_NM, ATCH_FILE_NM,
ATCH_TYPE_NM, ATCH_FILE_MG, DEL_DT, TBL_COL_NM,
RGSTR_ID, RGST_DT, MODID, UPD_DT
) VALUES (
#{atchfileNo}, #{atchNo}, #{atchFilePathNm}, #{atchFileNm},
#{atchTypeNm}, #{atchFileMg}, NULL, #{tblColNm},
#{rgstrId}, GETDATE(), #{rgstrId}, GETDATE()
)
</insert>
<delete id="deleteFileInfo" parameterType="string">
DELETE FROM SX_CO0050 WHERE ATCHFILE_NO = #{atchfileNo}
</delete>
<update id="updateTblColNmByAtchNo" parameterType="map">
UPDATE SX_CO0050
SET TBL_COL_NM = #{tblColNm}
WHERE ATCH_NO = #{atchNo}
</update>
<update id="updateTblColNmByAtchfileNo" parameterType="map">
UPDATE SX_CO0050
SET TBL_COL_NM = #{tblColNm}
WHERE ATCHFILE_NO = #{atchfileNo}
</update>
<select id="getNeedDeleteFileList" resultType="map">
SELECT ATCHFILE_NO, ATCH_FILE_PATH_NM
FROM SX_CO0050
WHERE NULLIF(TBL_COL_NM, '') IS NULL
AND RGST_DT &lt; DATEADD(HOUR, -6, GETDATE())
</select>
<!-- ===================== MariaDB ===================== -->
<select id="getFileInfo" parameterType="string" resultType="map" databaseId="mariadb">
SELECT ATCHFILE_NO, ATCH_NO, ATCH_FILE_PATH_NM, ATCH_FILE_NM,
ATCH_TYPE_NM, ATCH_FILE_MG, TBL_COL_NM, RGSTR_ID
FROM SX_CO0050
WHERE ATCHFILE_NO = #{atchfileNo}
</select>
<select id="getFileInfoListByAtchNo" parameterType="string" resultType="map" databaseId="mariadb">
SELECT ATCHFILE_NO, ATCH_NO, ATCH_FILE_PATH_NM, ATCH_FILE_NM,
ATCH_TYPE_NM, ATCH_FILE_MG, TBL_COL_NM
FROM SX_CO0050
WHERE ATCH_NO = #{atchNo}
AND DEL_DT IS NULL
ORDER BY RGST_DT
</select>
<insert id="insertFileInfo" parameterType="map" databaseId="mariadb">
INSERT INTO SX_CO0050 (
ATCHFILE_NO, ATCH_NO, ATCH_FILE_PATH_NM, ATCH_FILE_NM,
ATCH_TYPE_NM, ATCH_FILE_MG, DEL_DT, TBL_COL_NM,
RGSTR_ID, RGST_DT, MODID, UPD_DT
) VALUES (
#{atchfileNo}, #{atchNo}, #{atchFilePathNm}, #{atchFileNm},
#{atchTypeNm}, #{atchFileMg}, NULL, #{tblColNm},
#{rgstrId}, NOW(), #{rgstrId}, NOW()
)
</insert>
<delete id="deleteFileInfo" parameterType="string" databaseId="mariadb">
DELETE FROM SX_CO0050 WHERE ATCHFILE_NO = #{atchfileNo}
</delete>
<update id="updateTblColNmByAtchNo" parameterType="map" databaseId="mariadb">
UPDATE SX_CO0050
SET TBL_COL_NM = #{tblColNm}
WHERE ATCH_NO = #{atchNo}
</update>
<update id="updateTblColNmByAtchfileNo" parameterType="map" databaseId="mariadb">
UPDATE SX_CO0050
SET TBL_COL_NM = #{tblColNm}
WHERE ATCHFILE_NO = #{atchfileNo}
</update>
<select id="getNeedDeleteFileList" resultType="map" databaseId="mariadb">
SELECT ATCHFILE_NO, ATCH_FILE_PATH_NM
FROM SX_CO0050
WHERE (TBL_COL_NM IS NULL OR TBL_COL_NM = '')
AND RGST_DT &lt; DATE_SUB(NOW(), INTERVAL 6 HOUR)
</select>
</mapper>

View File

@@ -0,0 +1,145 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!--
공통코드 조회 SQL
테이블: SX_CO0040 (공통코드), SX_GW0010 (직원), SX_CO0070 (근무코드)
-->
<mapper namespace="com.company.gw.common.mapper.CodeMapper">
<!-- ──────────────────────────────────────────────
공통코드 목록 (드롭다운용 간략버전)
────────────────────────────────────────────── -->
<select id="getCodeList" parameterType="map" resultType="map"><![CDATA[
SELECT A.COMM_CD AS code,
A.COMM_CD_NM AS name,
A.COMM_CD_USE_YN AS useYn
FROM SX_CO0040 A
WHERE A.CORP_NO = #{corpNo}
AND A.COMM_CL_CD = #{commClCd}
AND (ISNULL(#{useYn}, '') = '' OR A.COMM_CD_USE_YN = #{useYn})
ORDER BY A.COMM_CD_DSPLY_ORDR, A.COMM_CD
]]></select>
<select id="getCodeList" databaseId="mariadb" parameterType="map" resultType="map"><![CDATA[
SELECT A.COMM_CD AS code,
A.COMM_CD_NM AS name,
A.COMM_CD_USE_YN AS useYn
FROM SX_CO0040 A
WHERE A.CORP_NO = #{corpNo}
AND A.COMM_CL_CD = #{commClCd}
AND (IFNULL(#{useYn}, '') = '' OR A.COMM_CD_USE_YN = #{useYn})
ORDER BY A.COMM_CD_DSPLY_ORDR, A.COMM_CD
]]></select>
<!-- ──────────────────────────────────────────────
공통코드 전체 정보
────────────────────────────────────────────── -->
<select id="getCodeListFull" parameterType="map" resultType="map"><![CDATA[
SELECT A.COMM_CD AS code,
A.COMM_CD_NM AS name,
A.COMM_CL_CD,
A.COMM_CD_DSPLY_ORDR,
A.COMM_CD_USE_YN,
A.COMM_CD_TYPE_VAL,
A.COMM_CD_DSCRPT,
A.PROP_CD1,
A.PROP_CD2,
A.PROP_CD3,
A.PROP_CD4,
A.PROP_CD5
FROM SX_CO0040 A
WHERE A.CORP_NO = #{corpNo}
AND A.COMM_CL_CD = #{commClCd}
AND (ISNULL(#{useYn}, '') = '' OR A.COMM_CD_USE_YN = #{useYn})
ORDER BY A.COMM_CD_DSPLY_ORDR, A.COMM_CD
]]></select>
<select id="getCodeListFull" databaseId="mariadb" parameterType="map" resultType="map"><![CDATA[
SELECT A.COMM_CD AS code,
A.COMM_CD_NM AS name,
A.COMM_CL_CD,
A.COMM_CD_DSPLY_ORDR,
A.COMM_CD_USE_YN,
A.COMM_CD_TYPE_VAL,
A.COMM_CD_DSCRPT,
A.PROP_CD1,
A.PROP_CD2,
A.PROP_CD3,
A.PROP_CD4,
A.PROP_CD5
FROM SX_CO0040 A
WHERE A.CORP_NO = #{corpNo}
AND A.COMM_CL_CD = #{commClCd}
AND (IFNULL(#{useYn}, '') = '' OR A.COMM_CD_USE_YN = #{useYn})
ORDER BY A.COMM_CD_DSPLY_ORDR, A.COMM_CD
]]></select>
<!-- ──────────────────────────────────────────────
직원 목록 (결재자 선택 등 팝업용)
────────────────────────────────────────────── -->
<select id="searchUser" parameterType="map" resultType="map"><![CDATA[
SELECT TOP 100
A.USR_ID,
A.USR_NM,
A.DUTY_CD,
A.TEAM_CD,
A.APPR_YN
FROM SX_GW0010 A
WHERE A.CORP_NO = #{corpNo}
AND ISNULL(A.RETIREMENT_DATE, '') = ''
AND (ISNULL(#{searchText}, '') = '' OR (
A.USR_ID LIKE CONCAT('%', #{searchText}, '%') OR
A.USR_NM LIKE CONCAT('%', #{searchText}, '%')))
AND (#{apprOnly} != 'Y' OR A.APPR_YN = 'Y')
ORDER BY A.USR_NM
]]></select>
<select id="searchUser" databaseId="mariadb" parameterType="map" resultType="map"><![CDATA[
SELECT A.USR_ID,
A.USR_NM,
A.DUTY_CD,
A.TEAM_CD,
A.APPR_YN
FROM SX_GW0010 A
WHERE A.CORP_NO = #{corpNo}
AND IFNULL(A.RETIREMENT_DATE, '') = ''
AND (IFNULL(#{searchText}, '') = '' OR (
A.USR_ID LIKE CONCAT('%', #{searchText}, '%') OR
A.USR_NM LIKE CONCAT('%', #{searchText}, '%')))
AND (#{apprOnly} != 'Y' OR A.APPR_YN = 'Y')
ORDER BY A.USR_NM
LIMIT 100
]]></select>
<!-- ──────────────────────────────────────────────
근무코드 목록
────────────────────────────────────────────── -->
<select id="getWorkCdList" parameterType="map" resultType="map"><![CDATA[
SELECT A.WORK_CD AS code,
A.WORK_CD_TITLE_NM AS name,
A.WORK_CD,
A.WORK_CD_TITLE_NM,
A.GOTOWORK_TM_NM,
A.GETOFFWORK_TM_NM,
A.WORK_CD_USE_YN
FROM SX_CO0070 A
WHERE A.CORP_NO = #{corpNo}
AND (ISNULL(#{useYn}, '') = '' OR A.WORK_CD_USE_YN = #{useYn})
ORDER BY A.WORK_CD
]]></select>
<select id="getWorkCdList" databaseId="mariadb" parameterType="map" resultType="map"><![CDATA[
SELECT A.WORK_CD AS code,
A.WORK_CD_TITLE_NM AS name,
A.WORK_CD,
A.WORK_CD_TITLE_NM,
A.GOTOWORK_TM_NM,
A.GETOFFWORK_TM_NM,
A.WORK_CD_USE_YN
FROM SX_CO0070 A
WHERE A.CORP_NO = #{corpNo}
AND (IFNULL(#{useYn}, '') = '' OR A.WORK_CD_USE_YN = #{useYn})
ORDER BY A.WORK_CD
]]></select>
</mapper>

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!-- 원본: src/java/sql/common/common_sql.xml -->
<mapper namespace="com.company.gw.common.mapper.CommonMapper">
<!-- 시퀀스 증가 (MERGE = SQL Server / MariaDB INSERT ON DUPLICATE KEY) -->
<update id="increaseSequence" parameterType="String">
MERGE INTO SX_CO0060 T
USING (SELECT 1 AS DUMMY) S
ON (T.SEQUENCE_NM = #{sequenceNm})
WHEN NOT MATCHED THEN
INSERT (SEQUENCE_NM, SEQUENCE_VAL) VALUES (#{sequenceNm}, 1)
WHEN MATCHED THEN
UPDATE SET SEQUENCE_VAL = SEQUENCE_VAL + 1;
</update>
<update id="increaseSequence" parameterType="String" databaseId="mariadb">
INSERT INTO SX_CO0060 (SEQUENCE_NM, SEQUENCE_VAL)
VALUES (#{sequenceNm}, 1)
ON DUPLICATE KEY UPDATE SEQUENCE_VAL = SEQUENCE_VAL + 1
</update>
<select id="getSequenceVal" parameterType="String" resultType="Long">
SELECT SEQUENCE_VAL FROM SX_CO0060 WHERE SEQUENCE_NM = #{sequenceNm}
</select>
</mapper>

View File

@@ -0,0 +1,146 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!--
원본: src/java/sql/common/menu_sql.xml (SX_CO0080, SX_CO0090)
-->
<mapper namespace="com.company.gw.common.mapper.MenuMapper">
<!-- 사용자 권한 기반 메뉴 트리 조회 (재귀 CTE) -->
<select id="getMenuList" parameterType="map" resultType="com.company.gw.common.dto.MenuDto">
WITH MENU_TREE AS (
SELECT A.MENU_NO AS upMenuNo,
A.CORP_NO,
A.MENU_NO,
A.UPPER_MENU_NO,
A.MENU_NM,
A.URL,
A.RM,
A.MENU_PROP,
A.MENU_ORDR,
A.MENU_USE_YN,
1 AS lvl,
CAST('/' + RIGHT('00000' + CONVERT(VARCHAR, A.MENU_ORDR), 5) AS VARCHAR(MAX)) AS sortPath
FROM SX_CO0080 A
JOIN SX_CO0090 B
ON B.CORP_NO = A.CORP_NO
AND B.MENU_NO = A.MENU_NO
AND B.MENU_AUTH_CD = #{menuAuthCd}
WHERE A.CORP_NO = #{corpNo}
AND A.MENU_USE_YN = 'Y'
AND NULLIF(A.UPPER_MENU_NO, '') IS NULL
UNION ALL
SELECT PARENT.upMenuNo,
A.CORP_NO,
A.MENU_NO,
A.UPPER_MENU_NO,
A.MENU_NM,
A.URL,
A.RM,
A.MENU_PROP,
A.MENU_ORDR,
A.MENU_USE_YN,
PARENT.lvl + 1 AS lvl,
PARENT.sortPath + CAST('/' + RIGHT('00000' + CONVERT(VARCHAR, A.MENU_ORDR), 5) AS VARCHAR(MAX)) AS sortPath
FROM SX_CO0080 A
JOIN SX_CO0090 B
ON B.CORP_NO = A.CORP_NO
AND B.MENU_NO = A.MENU_NO
AND B.MENU_AUTH_CD = #{menuAuthCd}
JOIN MENU_TREE PARENT
ON PARENT.CORP_NO = A.CORP_NO
AND PARENT.MENU_NO = A.UPPER_MENU_NO
WHERE A.CORP_NO = #{corpNo}
AND A.MENU_USE_YN = 'Y'
)
SELECT MENU_NO AS menuNo,
UPPER_MENU_NO AS upperMenuNo,
MENU_NM AS menuNm,
URL,
RM,
MENU_PROP AS menuProp,
MENU_ORDR AS menuOrdr,
MENU_USE_YN AS menuUseYn,
lvl
FROM MENU_TREE
WHERE CORP_NO = #{corpNo}
ORDER BY sortPath
</select>
<select id="getMenuList" parameterType="map" resultType="com.company.gw.common.dto.MenuDto" databaseId="mariadb">
WITH MENU_TREE AS (
SELECT A.MENU_NO AS upMenuNo,
A.CORP_NO,
A.MENU_NO,
A.UPPER_MENU_NO,
A.MENU_NM,
A.URL,
A.RM,
A.MENU_PROP,
A.MENU_ORDR,
A.MENU_USE_YN,
1 AS lvl,
CONCAT('/', LPAD(A.MENU_ORDR, 5, '0')) AS sortPath
FROM SX_CO0080 A
JOIN SX_CO0090 B
ON B.CORP_NO = A.CORP_NO
AND B.MENU_NO = A.MENU_NO
AND B.MENU_AUTH_CD = #{menuAuthCd}
WHERE A.CORP_NO = #{corpNo}
AND A.MENU_USE_YN = 'Y'
AND NULLIF(A.UPPER_MENU_NO, '') IS NULL
UNION ALL
SELECT PARENT.upMenuNo,
A.CORP_NO,
A.MENU_NO,
A.UPPER_MENU_NO,
A.MENU_NM,
A.URL,
A.RM,
A.MENU_PROP,
A.MENU_ORDR,
A.MENU_USE_YN,
PARENT.lvl + 1 AS lvl,
CONCAT(PARENT.sortPath, '/', LPAD(A.MENU_ORDR, 5, '0')) AS sortPath
FROM SX_CO0080 A
JOIN SX_CO0090 B
ON B.CORP_NO = A.CORP_NO
AND B.MENU_NO = A.MENU_NO
AND B.MENU_AUTH_CD = #{menuAuthCd}
JOIN MENU_TREE PARENT
ON PARENT.CORP_NO = A.CORP_NO
AND PARENT.MENU_NO = A.UPPER_MENU_NO
WHERE A.CORP_NO = #{corpNo}
AND A.MENU_USE_YN = 'Y'
)
SELECT MENU_NO AS menuNo,
UPPER_MENU_NO AS upperMenuNo,
MENU_NM AS menuNm,
URL,
RM,
MENU_PROP AS menuProp,
MENU_ORDR AS menuOrdr,
MENU_USE_YN AS menuUseYn,
lvl
FROM MENU_TREE
WHERE CORP_NO = #{corpNo}
ORDER BY sortPath
</select>
<!-- 컨트롤러-롤 매핑 목록 (접근 권한 체크용) -->
<select id="getControllerRoleList" parameterType="map" resultType="com.company.gw.common.dto.ControllerRoleDto">
SELECT DISTINCT
A.CONTROLLER AS controller,
B.MENU_AUTH_CD AS role,
B.FUNC_AUTH_CN AS funcAuthCn
FROM SX_CO0080 A
JOIN SX_CO0090 B
ON B.CORP_NO = A.CORP_NO
AND B.MENU_NO = A.MENU_NO
WHERE A.CORP_NO = #{corpNo}
AND A.MENU_USE_YN = 'Y'
AND NULLIF(A.CONTROLLER, '') IS NOT NULL
ORDER BY A.CONTROLLER
</select>
</mapper>

Some files were not shown because too many files have changed in this diff Show More