diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..56244a4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,135 @@ +# CLAUDE.md - alist API 프로젝트 + +## 프로젝트 개요 +- **프로젝트명**: alist API +- **그룹**: com.alist +- **포트**: 8106 +- **빌드 결과물**: `api.jar` + +## 기술 스택 +- **Java**: 21 +- **Framework**: Spring Boot 3.5.10 +- **빌드 도구**: Gradle +- **DB**: MariaDB +- **ORM**: MyBatis (mapper XML: `classpath:mapper/**/*.xml`) +- **인증**: JWT (jjwt 0.11.5) + Spring Security +- **API 문서**: Swagger (springdoc-openapi 2.6.0) +- **기타**: Lombok, Validation, Actuator, log4jdbc + +## 패키지 구조 +``` +com.alist.api +├── common +│ ├── response/ # ApiResponse, ApiResponseCode +│ └── utils/ # 공통 유틸리티 +├── config +│ ├── jwt/ # JWT 필터, 핸들러, Provider +│ ├── exception/ # GlobalExceptionHandler +│ ├── properties/ # JwtProperties 등 +│ ├── SecurityConfig.java +│ └── OpenApiConfig.java +└── modules + └── {도메인}/ # Controller, Service, Mapper, DTO +``` + +## 프로파일 +| 프로파일 | 설명 | +|---------|------| +| `local` | 로컬 개발 환경 | +| `pjt` | 프로젝트(개발) 환경 | + +## 보안 구조 +- **Swagger**: `/v3/api-docs/**`, `/swagger-ui/**` → HTTP Basic 인증 (InMemory) +- **API**: JWT Bearer 토큰 인증 (Stateless) +- **공개 경로**: `/`, `/actuator/health`, `/auth/**`, `/api/user/signup` + +## 응답 코드 규칙 + +`ApiResponseCode` enum으로 관리. 주요 코드: + +| 코드 | 메시지 | HTTP Status | 용도 | +|------|--------|-------------|------| +| `CODE_200` | 성공 | 200 OK | 일반 성공 | +| `CODE_400` | 잘못된 요청 | 400 Bad Request | 일반 클라이언트 오류 | +| `CODE_401` | 인증 필요 합니다. | 401 Unauthorized | 인증 없음 | +| `CODE_403` | 접근 권한 필요 합니다. | 403 Forbidden | 권한 없음 | +| `CODE_404` | 페이지를 찾을 수 없습니다. | 404 Not Found | 리소스 없음 | +| `CODE_405` | 잘못된 요청입니다. 요청 방식을 확인해 주세요. | 405 Method Not Allowed | 메서드 불일치 | +| `CODE_500` | 요청을 처리하는 중 오류가 발생했습니다. | 500 Internal Server Error | 서버 오류 | +| `CODE_2001` | {0} 정보 조회에 성공하였습니다. | 200 OK | 단건 조회 성공 | +| `CODE_2002` | {0} 등록 되었습니다. | 201 Created | 등록 성공 | +| `CODE_2003` | 조회된 정보가 없습니다. | 200 OK | 조회 결과 없음 | +| `CODE_2004` | 중복된 {0} 정보 입니다. | 409 Conflict | 중복 데이터 | +| `CODE_4001` | 입력값을 확인해주세요. | 400 Bad Request | `@Valid` / 바인딩 / 타입오류 / JSON 파싱 실패 | +| `CODE_4003` | 필수 요청 파라미터가 누락되었습니다. | 400 Bad Request | 필수 파라미터 누락 | + +- `{0}` 자리에 대상명 삽입 (예: `CODE_2001` → "회원 정보 조회에 성공하였습니다.") + +## 빌드 및 실행 +```bash +# 빌드 +./gradlew bootJar + +# 로컬 실행 +./gradlew bootRun --args='--spring.profiles.active=local' + +# JAR 실행 +java -jar build/libs/api.jar --spring.profiles.active=local +``` + +## Swagger 접속 +- URL: `http://localhost:8106/swagger-ui.html` +- 인증: `swagger.login.id` / `swagger.login.password` (환경별 yaml에 설정) + +## 코드 작성 규칙 +- 응답은 `ApiResponse` 래퍼 사용 +- 응답 코드는 `ApiResponseCode` enum 사용 +- MyBatis Mapper XML은 `src/main/resources/mapper/` 하위에 작성 +- 카멜케이스 자동 변환 활성화 (`map-underscore-to-camel-case: true`) +- 새 모듈 추가 시: `modules/{moduleName}/` 하위에 Controller, Service, Mapper, dto/, vo/ 구조로 생성 +- Mapper XML은 `resources/mapper/{moduleName}/` 에 위치 + +## MyBatis 규칙 +- Mapper XML 위치: `src/main/resources/mapper/**/*.xml` +- `map-underscore-to-camel-case: true` 설정 → DB 컬럼 `user_idx` → Java 필드 `userIdx` 자동 매핑 +- Mapper 인터페이스와 XML의 namespace, id 반드시 일치시킬 것 +- VO: DB 조회 결과 매핑용 / DTO: 서비스 레이어 간 데이터 전달용 / Form: 컨트롤러 입력 검증용 + +## CI/CD +- **Jenkins**: `Jenkinsfile.pjt` +- **Docker**: `Dockerfile` +- **배포 스크립트**: `deploy/` 디렉토리 + +## 개발 서버 (pjt) + +### 도메인 +- **API**: `api-alist.pjt.kr` +- **파일(업로드)**: `file-alist.pjt.kr` +- **Swagger**: `https://api-alist.pjt.kr/swagger-ui/index.html` + +### Docker +- **레지스트리**: `registry.pjt.kr` +- **이미지**: `registry.pjt.kr/alist/api` +- **컨테이너명**: `alist-api` +- **포트**: `127.0.0.1:8106->8106/tcp` + +### 서버 디렉토리 (`/srv/project/alist/`) +``` +/srv/project/alist/ +├── compose/ # docker-compose 파일 +├── data/ # 데이터 +├── env/ # 환경변수 파일 +├── logs/ # 로그 +├── scripts/ # 배포/운영 스크립트 +└── uploads/ # 업로드 파일 (file-alist.pjt.kr 루트) +``` + +### Nginx +- `file-alist.pjt.kr` → `/srv/project/alist/uploads` (정적 파일 서빙) +- HTTP(80) → HTTPS(301) 리다이렉트 +- SSL: Let's Encrypt +- 직접 접근 차단 (`allow 127.0.0.1; deny all;`) + +### 파일 업로드 +- 업로드 저장 경로: `/srv/project/alist/uploads/` +- 업로드 파일 접근 URL: `https://file-alist.pjt.kr/{파일경로}` diff --git a/Jenkinsfile.pjt b/Jenkinsfile.pjt index 7ec1c07..f3dd9cc 100644 --- a/Jenkinsfile.pjt +++ b/Jenkinsfile.pjt @@ -8,12 +8,27 @@ pipeline { environment { // ===== Registry ===== - DUMMY = "true" + REGISTRY_HOST = "registry.pjt.kr" + IMAGE_REPO = "alist/api" + DOCKER_CRED = "registry-pjt" + + // ===== api ssh ===== + SSH_HOST = "121.160.234.222" + SSH_PORT = "2001" + SSH_USER = "bigfuntnp" + SSH_COMPOSE_DIR = "/srv/project/alist/compose" + SSH_COMPOSE_FILE = "api-compose.pjt.yml" + + // ===== jenkins workspace path ===== + WORK_COMPOSE_DIR = "deploy/pjt/compose" + WORK_COMPOSE_FILE = "api-compose.pjt.yml" } stages { stage('1) Git check out') { steps { + echo "[1/1] Git check out" + checkout scm } } @@ -22,10 +37,224 @@ pipeline { steps { sh ''' set -e + + echo "[1/1] Gradle build" + chmod +x ./gradlew ./gradlew clean build -x test ''' } } + + stage('3) ImageTag create') { + steps { + script { + + echo "[1/1] ImageTag create" + + env.SHORT_SHA = sh(script: "git rev-parse --short HEAD", returnStdout: true).trim() + echo "SHORT_SHA = ${env.SHORT_SHA}" + env.IMAGE_TAG = "${env.BUILD_NUMBER}-${env.SHORT_SHA}" + echo "IMAGE_TAG = ${env.IMAGE_TAG}" + } + } + } + + stage('4) Docker Build') { + steps { + sh ''' + set -e + + echo "[1/1] docker build" + + docker build -t ${REGISTRY_HOST}/${IMAGE_REPO}:${IMAGE_TAG} . + ''' + } + } + + stage('5) Docker registry Login & Push') { + steps { + withCredentials([usernamePassword( + credentialsId: DOCKER_CRED, + usernameVariable: 'DOCKER_USER', + passwordVariable: 'DOCKER_PASS' + )]) { + sh ''' + set -e + + echo "[1/2] docker registry login" + echo "$DOCKER_PASS" | docker login ${REGISTRY_HOST} -u "$DOCKER_USER" --password-stdin + + echo "[2/2] docker image push" + docker push ${REGISTRY_HOST}/${IMAGE_REPO}:${IMAGE_TAG} + + docker logout ${REGISTRY_HOST} + ''' + } + } + } + + stage('6) Jenkins cleanup') { + steps { + sh ''' + set -e + + echo "[1/3] Remove pushed image from Jenkins" + docker rmi ${REGISTRY_HOST}/${IMAGE_REPO}:${IMAGE_TAG} 2>/dev/null || true + + echo "[2/3] Remove dangling images" + docker image prune -f + + echo "[3/3] Remove old build cache (safe-ish)" + docker builder prune -f || true + ''' + } + } + + stage('7) Copy compose') { + steps { + sshagent(['ssh-pjt']) { + sh ''' + set -e + + echo "[1/2] ssh login" + + SRC="${WORK_COMPOSE_DIR}/${WORK_COMPOSE_FILE}" + DST="${SSH_USER}@${SSH_HOST}:${SSH_COMPOSE_DIR}/${SSH_COMPOSE_FILE}" + + echo "[2/2] Copy compose $SRC -> $DST" + scp -P ${SSH_PORT} -o StrictHostKeyChecking=no "$SRC" "$DST" + ''' + } + } + } + + stage('8) Remote docker registry login') { + steps { + sshagent(['ssh-pjt']) { + withCredentials([usernamePassword( + credentialsId: DOCKER_CRED, + usernameVariable: 'DOCKER_USER', + passwordVariable: 'DOCKER_PASS' + )]) { + sh ''' + set -e + + echo "[1/1] docker registry login" + + ssh -p ${SSH_PORT} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${SSH_USER}@${SSH_HOST} ' + set -e + REGISTRY_HOST='"${REGISTRY_HOST}"' + DOCKER_USER='"${DOCKER_USER}"' + DOCKER_PASS='"${DOCKER_PASS}"' + + echo "$DOCKER_PASS" | docker login "$REGISTRY_HOST" -u "$DOCKER_USER" --password-stdin + ' + ''' + } + } + } + } + + stage('9) Remote docker pull') { + steps { + sshagent(['ssh-pjt']) { + sh ''' + set -e + ssh -p ${SSH_PORT} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + ${SSH_USER}@${SSH_HOST} \ + 'set -e + cd '"${SSH_COMPOSE_DIR}"' + export IMAGE_TAG='"${IMAGE_TAG}"' + + echo "[1/1] docker pull" + docker compose -p alist-api --env-file /srv/project/alist/env/api/.env -f '"${SSH_COMPOSE_FILE}"' pull + ' + ''' + } + } + } + + stage('10) Remote docker down & up') { + steps { + sshagent(['ssh-pjt']) { + sh ''' + set -e + ssh -p ${SSH_PORT} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + ${SSH_USER}@${SSH_HOST} \ + 'set -e + cd '"${SSH_COMPOSE_DIR}"' + export IMAGE_TAG='"${IMAGE_TAG}"' + + echo "[1/3] docker down (remove orphans)" + docker compose -p alist-api --env-file /srv/project/alist/env/api/.env -f "'"${SSH_COMPOSE_FILE}"'" down --remove-orphans + + echo "[2/3] docker up" + docker compose -p alist-api --env-file /srv/project/alist/env/api/.env -f '"${SSH_COMPOSE_FILE}"' up -d + + echo "[3/3] docker ps" + docker compose -p alist-api --env-file /srv/project/alist/env/api/.env -f '"${SSH_COMPOSE_FILE}"' ps + ' + ''' + } + } + } + + stage('11) Remote cleanup') { + steps { + sshagent(['ssh-pjt']) { + sh ''' + set -e + ssh -p ${SSH_PORT} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${SSH_USER}@${SSH_HOST} ' + set -e + + Repo='"${REGISTRY_HOST}/${IMAGE_REPO}"' + + echo "[1/3] Remove old image" + docker images "${Repo}" --format '{{.Repository}}:{{.Tag}}' | grep -v -F -f <(docker ps --format '{{.Image}}') | xargs -r docker rmi + + echo "[2/3] Remove dangling images" + docker image prune -f + + echo "[3/3] Remove old build cache (safe-ish)" + docker builder prune -f || true + ' + ''' + } + } + } + + stage('12) registry docker image cleanup') { + steps { + withCredentials([usernamePassword( + credentialsId: DOCKER_CRED, + usernameVariable: 'DOCKER_USER', + passwordVariable: 'DOCKER_PASS' + )]) { + sh ''' + set -e + + echo "[1/2] Remove registry old image tag list" + TAGS_TO_DELETE=$(curl -s -u "$DOCKER_USER:$DOCKER_PASS" https://${REGISTRY_HOST}/v2/${IMAGE_REPO}/tags/list | jq -r '.tags[]' | grep -E '^[0-9]+' | sort -t- -k1,1n | head -n -5) + + echo "[2/2] Remove registry old image sha list and delete registry" + for TAG in $TAGS_TO_DELETE; do + DIGEST=$(curl -s -u "$DOCKER_USER:$DOCKER_PASS" -H "Accept: application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json" -D - https://${REGISTRY_HOST}/v2/${IMAGE_REPO}/manifests/$TAG -o /dev/null | grep -i Docker-Content-Digest | awk '{print $2}' | tr -d $'\r') + + if [ -z "$DIGEST" ]; then + echo "[SKIP] $TAG digest not found" + continue + fi + + echo "[DRY] $TAG -> $DIGEST" + + curl -s -u "$DOCKER_USER:$DOCKER_PASS" -X DELETE "https://${REGISTRY_HOST}/v2/${IMAGE_REPO}/manifests/${DIGEST}" -o /dev/null -w "HTTP %{http_code}\n" + done + + ''' + } + } + } + } } diff --git a/build.gradle b/build.gradle index 8a9143f..b27da4a 100644 --- a/build.gradle +++ b/build.gradle @@ -25,8 +25,45 @@ dependencies { // Web 기본 implementation 'org.springframework.boot:spring-boot-starter-web' + + // security + implementation 'org.springframework.boot:spring-boot-starter-security' + + // jwt + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + + // Lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // jdbc + implementation 'org.bgee.log4jdbc-log4j2:log4jdbc-log4j2-jdbc4.1:1.16' + + // MariaDB + implementation 'org.mariadb.jdbc:mariadb-java-client' + + // swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.0' + + // MyBatis (Boot 3 전용) + implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3' + + // validation + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // health + implementation 'org.springframework.boot:spring-boot-starter-actuator' } tasks.named('test') { useJUnitPlatform() } +bootJar { + archiveFileName = "api.jar" +} +jar { + enabled = false +} \ No newline at end of file diff --git a/deploy/pjt/compose/api-compose.pjt.yml b/deploy/pjt/compose/api-compose.pjt.yml index cc18bfa..42ec4ca 100644 --- a/deploy/pjt/compose/api-compose.pjt.yml +++ b/deploy/pjt/compose/api-compose.pjt.yml @@ -1,15 +1,18 @@ services: app: + container_name: alist-api image: registry.pjt.kr/alist/api:${IMAGE_TAG} restart: always + extra_hosts: + - "host.docker.internal:host-gateway" + ports: - - "127.0.0.1:8006:8080" + - "127.0.0.1:8106:8106" env_file: - /srv/project/alist/env/api/.env - # 🔴 핵심: 로그 볼륨 연결 volumes: - /srv/project/alist/logs/api/app:/logs @@ -23,13 +26,12 @@ services: -Duser.timezone=Asia/Seoul healthcheck: - test: ["CMD-SHELL", "curl -fsS http://localhost:8080/actuator/health || exit 1"] + test: ["CMD-SHELL", "curl -fsS http://localhost:8106/actuator/health || exit 1"] interval: 10s timeout: 3s retries: 10 start_period: 30s - # 🟡 보험: 도커 로그 폭주 방지 logging: driver: json-file options: diff --git a/src/main/java/com/alist/api/ApiApplication.java b/src/main/java/com/alist/api/ApiApplication.java index 50a4ea8..222e71e 100644 --- a/src/main/java/com/alist/api/ApiApplication.java +++ b/src/main/java/com/alist/api/ApiApplication.java @@ -2,8 +2,10 @@ package com.alist.api; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; @SpringBootApplication +@ConfigurationPropertiesScan public class ApiApplication { public static void main(String[] args) { diff --git a/src/main/java/com/alist/api/common/response/ApiResponse.java b/src/main/java/com/alist/api/common/response/ApiResponse.java new file mode 100644 index 0000000..d0ff98a --- /dev/null +++ b/src/main/java/com/alist/api/common/response/ApiResponse.java @@ -0,0 +1,34 @@ +package com.alist.api.common.response; + +import lombok.Getter; +import org.springframework.http.ResponseEntity; + +@Getter +public class ApiResponse { + private T data; // 실제 데이터 + private String code; // 결과 코드 (SUCCESS, ERROR_001 등) + private String message; // 사용자 메시지 + + public ApiResponse(T data, String code, String message) { + this.data = data; + this.code = code; + this.message = message; + } + + public static ApiResponse body(ApiResponseCode code, Object... args) { + return new ApiResponse<>(null, code.code(), code.message(args)); + } + + public static ApiResponse body(T data, ApiResponseCode code, Object... args) { + return new ApiResponse<>(data, code.code(), code.message(args)); + } + + public static ResponseEntity> entity(ApiResponseCode code, Object... args) { + return ResponseEntity.status(code.httpStatus()).body(ApiResponse.body(code, args)); + } + + public static ResponseEntity> entity(T data, ApiResponseCode code, Object... args) { + return ResponseEntity.status(code.httpStatus()).body(ApiResponse.body(data, code, args)); + } + +} diff --git a/src/main/java/com/alist/api/common/response/ApiResponseCode.java b/src/main/java/com/alist/api/common/response/ApiResponseCode.java new file mode 100644 index 0000000..c515e84 --- /dev/null +++ b/src/main/java/com/alist/api/common/response/ApiResponseCode.java @@ -0,0 +1,53 @@ +package com.alist.api.common.response; + +import org.springframework.http.HttpStatus; + +import java.text.MessageFormat; + +public enum ApiResponseCode { + // ===== Common ===== + CODE_200 ("200", "성공", HttpStatus.OK), + + CODE_400 ("400", "잘못된 요청", HttpStatus.BAD_REQUEST), + CODE_401 ("401", "인증 필요 합니다.", HttpStatus.UNAUTHORIZED), + CODE_403 ("403", "접근 권한 필요 합니다.", HttpStatus.FORBIDDEN), + CODE_404 ("404", "페이지를 찾을 수 없습니다. 입력하신 주소가 올바른지 확인해주세요.", HttpStatus.NOT_FOUND), + CODE_405 ("405", "잘못된 요청입니다. 요청 방식을 확인해 주세요.", HttpStatus.METHOD_NOT_ALLOWED), + CODE_500 ("500", "요청을 처리하는 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + + // ===== Success detail ===== + CODE_2001("2001", "{0} 정보 조회에 성공하였습니다.", HttpStatus.OK), + CODE_2002("2002", "{0} 등록 되었습니다.", HttpStatus.CREATED), + CODE_2003("2003", "조회된 정보가 없습니다.", HttpStatus.OK), + CODE_2004("2004", "중복된 {0} 정보 입니다.", HttpStatus.CONFLICT), + + // ===== Client input errors ===== + // @Valid / 바인딩 / 타입미스매치 / JSON 파싱 실패 등은 다 여기로 + CODE_4001("4001", "입력값을 확인해주세요.", HttpStatus.BAD_REQUEST), + + // 필수 요청 파라미터 누락 + CODE_4003("4003", "필수 요청 파라미터가 누락되었습니다.", HttpStatus.BAD_REQUEST), + ; + + private final String code; + private final String message; + private final HttpStatus httpStatus; + + ApiResponseCode(String code, String message, HttpStatus httpStatus) { + this.code = code; + this.message = message; + this.httpStatus = httpStatus; + } + + public String code() { return code; } + + public String message() { return message; } + + public String message(Object... args) { + return MessageFormat.format(this.message, args); + } + + public HttpStatus httpStatus() { + return httpStatus; + } +} diff --git a/src/main/java/com/alist/api/common/utils/ApiKeyGenerator.java b/src/main/java/com/alist/api/common/utils/ApiKeyGenerator.java new file mode 100644 index 0000000..6b52442 --- /dev/null +++ b/src/main/java/com/alist/api/common/utils/ApiKeyGenerator.java @@ -0,0 +1,16 @@ +package com.alist.api.common.utils; + +import java.security.SecureRandom; +import java.util.Base64; + +public class ApiKeyGenerator { + + private static final SecureRandom secureRandom = new SecureRandom(); + private static final Base64.Encoder base64Encoder = Base64.getUrlEncoder().withoutPadding(); + + public static String userApiKeyProc() { + byte[] randomBytes = new byte[32]; // 256-bit + secureRandom.nextBytes(randomBytes); + return base64Encoder.encodeToString(randomBytes); + } +} diff --git a/src/main/java/com/alist/api/config/OpenApiConfig.java b/src/main/java/com/alist/api/config/OpenApiConfig.java new file mode 100644 index 0000000..484ff1c --- /dev/null +++ b/src/main/java/com/alist/api/config/OpenApiConfig.java @@ -0,0 +1,37 @@ +package com.alist.api.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@OpenAPIDefinition( + info = @Info( + title = "A*List Api App", + version = "v1", + description = "A*List Api App 입니다." + ) +) +public class OpenApiConfig { + private static final String SECURITY_SCHEME_NAME = "bearerAuth"; + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .addSecurityItem(new SecurityRequirement().addList(SECURITY_SCHEME_NAME)) + .components(new Components() + .addSecuritySchemes(SECURITY_SCHEME_NAME, + new SecurityScheme() + .name("Authorization") + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + ) + ); + } +} diff --git a/src/main/java/com/alist/api/config/SecurityConfig.java b/src/main/java/com/alist/api/config/SecurityConfig.java new file mode 100644 index 0000000..6f0fc9e --- /dev/null +++ b/src/main/java/com/alist/api/config/SecurityConfig.java @@ -0,0 +1,86 @@ +package com.alist.api.config; + +import com.alist.api.config.jwt.JwtAccessDeniedHandler; +import com.alist.api.config.jwt.JwtAuthenticationEntryPoint; +import com.alist.api.config.jwt.JwtAuthenticationFilter; +import com.alist.api.config.jwt.JwtTokenProvider; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +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.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Slf4j +@Configuration +@EnableWebSecurity +public class SecurityConfig { + @Value("${swagger.login.id}") + private String swaggerLoginId; + + @Value("${swagger.login.password}") + private String swaggerLoginPassword; + + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) { + return new JwtAuthenticationFilter(jwtTokenProvider); + } + + @Bean + @Order(1) + public SecurityFilterChain swaggerFilterChain(HttpSecurity http) throws Exception { + http + .securityMatcher("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html") + .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) + .httpBasic(Customizer.withDefaults()) + .csrf(csrf -> csrf.disable()); + + return http.build(); + } + + @Bean + @Order(2) + public SecurityFilterChain apiFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(new JwtAuthenticationEntryPoint()) + .accessDeniedHandler(new JwtAccessDeniedHandler()) + ) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/", "/actuator/health", "/auth/**", "/api/testUser/signup").permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public UserDetailsService userDetailsService() { + UserDetails alist = User.builder() + .username(swaggerLoginId) + .password(passwordEncoder().encode(swaggerLoginPassword)) + .roles("ADMIN") + .build(); + + return new InMemoryUserDetailsManager(alist); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/alist/api/config/exception/GlobalExceptionHandler.java b/src/main/java/com/alist/api/config/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..e3f6cc6 --- /dev/null +++ b/src/main/java/com/alist/api/config/exception/GlobalExceptionHandler.java @@ -0,0 +1,105 @@ +package com.alist.api.config.exception; + +import com.alist.api.common.response.ApiResponse; +import com.alist.api.common.response.ApiResponseCode; +import lombok.extern.slf4j.Slf4j; +import org.apache.ibatis.javassist.NotFoundException; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.BindException; +import org.springframework.validation.FieldError; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgument(IllegalArgumentException e) { + + log.warn("IllegalArgumentException: {}", e.getMessage()); + + return ApiResponse.entity(ApiResponseCode.CODE_400); + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity> handleMissingParam(MissingServletRequestParameterException e) { + + log.warn("Missing request parameter: {} (type={})", e.getParameterName(), e.getParameterType()); + + return ApiResponse.entity(ApiResponseCode.CODE_4003); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity> handleMethodNotSupported(HttpRequestMethodNotSupportedException e) { + + log.warn("Method not supported: {} (supported={})", e.getMethod(), e.getSupportedHttpMethods()); + + return ApiResponse.entity(ApiResponseCode.CODE_405); + } + + @ExceptionHandler(NotFoundException.class) + public ResponseEntity> handleNotFound(NotFoundException e) { + + log.warn("Not found: {}", e.getMessage()); + + return ApiResponse.entity(ApiResponseCode.CODE_404); + } + + // 입력오류 + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity>> handleMethodArgumentNotValid(MethodArgumentNotValidException e) { + Map fieldErrors = new LinkedHashMap<>(); + + for (FieldError fe : e.getBindingResult().getFieldErrors()) { + fieldErrors.putIfAbsent(fe.getField(), fe.getDefaultMessage()); + } + + log.warn("Validation failed: {}", e.getMessage()); + + return ApiResponse.entity(fieldErrors, ApiResponseCode.CODE_4001); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity> handleTypeMismatch(MethodArgumentTypeMismatchException e) { + + log.warn("Type mismatch: name={}, value={}", e.getName(), e.getValue()); + + return ApiResponse.entity(ApiResponseCode.CODE_4001); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity> handleNotReadable(HttpMessageNotReadableException e) { + + log.warn("Unreadable message (json parse?) : {}", e.getMessage()); + + return ApiResponse.entity(ApiResponseCode.CODE_4001); + } + + @ExceptionHandler(BindException.class) + public ResponseEntity>> handleBindException(BindException e) { + Map fieldErrors = new LinkedHashMap<>(); + for (FieldError fe : e.getBindingResult().getFieldErrors()) { + fieldErrors.putIfAbsent(fe.getField(), fe.getDefaultMessage()); + } + log.warn("Bind failed: {}", e.getMessage()); + + return ApiResponse.entity(fieldErrors, ApiResponseCode.CODE_4001); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException(Exception e) { + + log.error("Unhandled exception occurred", e); + + return ApiResponse.entity(ApiResponseCode.CODE_500); + } +} diff --git a/src/main/java/com/alist/api/config/jwt/JwtAccessDeniedHandler.java b/src/main/java/com/alist/api/config/jwt/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..2f4f115 --- /dev/null +++ b/src/main/java/com/alist/api/config/jwt/JwtAccessDeniedHandler.java @@ -0,0 +1,29 @@ +package com.alist.api.config.jwt; + +import com.alist.api.common.response.ApiResponse; +import com.alist.api.common.response.ApiResponseCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; + +import java.io.IOException; + +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void handle(HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException e) throws IOException { + + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + ApiResponse body = ApiResponse.body(ApiResponseCode.CODE_403); + objectMapper.writeValue(response.getOutputStream(), body); + } +} diff --git a/src/main/java/com/alist/api/config/jwt/JwtAuthenticationEntryPoint.java b/src/main/java/com/alist/api/config/jwt/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..478aaac --- /dev/null +++ b/src/main/java/com/alist/api/config/jwt/JwtAuthenticationEntryPoint.java @@ -0,0 +1,28 @@ +package com.alist.api.config.jwt; + +import com.alist.api.common.response.ApiResponse; +import com.alist.api.common.response.ApiResponseCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; + +import java.io.IOException; + +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void commence(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException e) throws IOException { + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + ApiResponse body = ApiResponse.body(ApiResponseCode.CODE_401); + objectMapper.writeValue(response.getOutputStream(), body); + } +} diff --git a/src/main/java/com/alist/api/config/jwt/JwtAuthenticationFilter.java b/src/main/java/com/alist/api/config/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..231cffb --- /dev/null +++ b/src/main/java/com/alist/api/config/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,60 @@ +package com.alist.api.config.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; + +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final JwtTokenProvider jwtTokenProvider; + + public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) { + this.jwtTokenProvider = jwtTokenProvider; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + try { + String token = resolveToken(request); + + if (token != null && jwtTokenProvider.validateToken(token)) { + String userId = jwtTokenProvider.getUserId(token); + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList()); + + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (Exception e) { + SecurityContextHolder.clearContext(); + // 로깅은 여기서 해도 됨 (SLF4J) + } + + filterChain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + String bearer = request.getHeader(HttpHeaders.AUTHORIZATION); + if (bearer == null) return null; + + // "Bearer " 뒤 토큰만 추출 + if (bearer.startsWith("Bearer ")) { + String token = bearer.substring(7).trim(); + return token.isEmpty() ? null : token; + } + return null; + } +} diff --git a/src/main/java/com/alist/api/config/jwt/JwtTokenProvider.java b/src/main/java/com/alist/api/config/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..a75701b --- /dev/null +++ b/src/main/java/com/alist/api/config/jwt/JwtTokenProvider.java @@ -0,0 +1,92 @@ +package com.alist.api.config.jwt; + +import com.alist.api.config.properties.JwtProperties; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; + +@Component +public class JwtTokenProvider { + private final JwtProperties jwtProperties; + private final SecretKey secretKey; + + public JwtTokenProvider(JwtProperties jwtProperties) { + this.jwtProperties = jwtProperties; + this.secretKey = Keys.hmacShaKeyFor( + jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8) + ); + } + + /** 토큰생성 **/ + public String createToken(String userId) { + Date now = new Date(); + Date expiry = new Date( + now.getTime() + jwtProperties.getAccessTokenValiditySeconds() * 1000 + ); + + return Jwts.builder() + .setSubject(userId) + .setIssuedAt(now) + .setExpiration(expiry) + .signWith(secretKey, SignatureAlgorithm.HS256) + .compact(); + } + + /* 엑세스 토큰 생성 */ + public String createAccessToken(long userTokenIdx, String role) { + Instant now = Instant.now(); + Instant expiry = now.plusSeconds(jwtProperties.getAccessTokenValiditySeconds()); + + return Jwts.builder() + .setSubject(String.valueOf(userTokenIdx)) + .claim("role", role) + .setIssuedAt(Date.from(now)) + .setExpiration(Date.from(expiry)) + .signWith(secretKey, SignatureAlgorithm.HS256) + .compact(); + } + + /* 리프레시 토큰 생성 */ + public String createRefreshToken(long userTokenIdx) { + Instant now = Instant.now(); + Instant expiry = now.plusSeconds(jwtProperties.getAccessTokenValiditySeconds()); + + return Jwts.builder() + .setSubject(String.valueOf(userTokenIdx)) + .setIssuedAt(Date.from(now)) + .setExpiration(Date.from(expiry)) + .signWith(secretKey, SignatureAlgorithm.HS256) + .compact(); + } + + /** 토큰에서 subject 추출 */ + public String getUserId(String token) { + return parseClaims(token).getSubject(); + } + + /** 토큰 검증 */ + public boolean validateToken(String token) { + try { + parseClaims(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + private Claims parseClaims(String token) { + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + } +} diff --git a/src/main/java/com/alist/api/config/properties/JwtProperties.java b/src/main/java/com/alist/api/config/properties/JwtProperties.java new file mode 100644 index 0000000..dbda00a --- /dev/null +++ b/src/main/java/com/alist/api/config/properties/JwtProperties.java @@ -0,0 +1,14 @@ +package com.alist.api.config.properties; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Setter +@ConfigurationProperties(prefix = "jwt") +public class JwtProperties { + String secret; + long accessTokenValiditySeconds; + long refreshTokenValiditySeconds; +} diff --git a/src/main/java/com/alist/api/modules/auth/AuthController.java b/src/main/java/com/alist/api/modules/auth/AuthController.java new file mode 100644 index 0000000..1fecc93 --- /dev/null +++ b/src/main/java/com/alist/api/modules/auth/AuthController.java @@ -0,0 +1,31 @@ +package com.alist.api.modules.auth; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.alist.api.common.response.ApiResponse; +import com.alist.api.common.response.ApiResponseCode; +import com.alist.api.config.jwt.JwtTokenProvider; +import com.alist.api.modules.auth.form.TokenForm; + +import jakarta.validation.Valid; + +@RestController +@RequestMapping("/auth") +public class AuthController { + private final JwtTokenProvider jwtTokenProvider; + + public AuthController(JwtTokenProvider jwtTokenProvider) { + this.jwtTokenProvider = jwtTokenProvider; + } + + @PostMapping("/token") + public ResponseEntity> token(@Valid @RequestBody TokenForm tokenForm) { + String accessToken = jwtTokenProvider.createToken(tokenForm.getUserId()); + + return ApiResponse.entity(accessToken, ApiResponseCode.CODE_2001, "엑세스 토큰"); + } +} diff --git a/src/main/java/com/alist/api/modules/auth/form/TokenForm.java b/src/main/java/com/alist/api/modules/auth/form/TokenForm.java new file mode 100644 index 0000000..a5573e7 --- /dev/null +++ b/src/main/java/com/alist/api/modules/auth/form/TokenForm.java @@ -0,0 +1,16 @@ +package com.alist.api.modules.auth.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Schema(description = "토큰 요청 폼") +public class TokenForm { + + @NotBlank(message = "사용자명은 필수입니다.") + @Schema(description = "사용자명", example = "user123") + private String userId; +} \ No newline at end of file diff --git a/src/main/java/com/alist/api/modules/main/MainController.java b/src/main/java/com/alist/api/modules/main/MainController.java new file mode 100644 index 0000000..8a69cd6 --- /dev/null +++ b/src/main/java/com/alist/api/modules/main/MainController.java @@ -0,0 +1,13 @@ +package com.alist.api.modules.main; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class MainController { + + @GetMapping(value = "/") + public String index() { + return "redirect:/swagger-ui/index.html"; + } +} diff --git a/src/main/java/com/alist/api/modules/testUser/TestUserController.java b/src/main/java/com/alist/api/modules/testUser/TestUserController.java new file mode 100644 index 0000000..d6e8937 --- /dev/null +++ b/src/main/java/com/alist/api/modules/testUser/TestUserController.java @@ -0,0 +1,39 @@ +package com.alist.api.modules.testUser; + +import com.alist.api.common.response.ApiResponse; +import com.alist.api.common.response.ApiResponseCode; +import com.alist.api.modules.testUser.dto.TestUserDto; +import com.alist.api.modules.testUser.form.TestUserSignupForm; +import com.alist.api.modules.testUser.service.TestUserService; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequestMapping("/api/testUser") +public class TestUserController { + + public final TestUserService testUserService; + + public TestUserController(TestUserService testUserService) { + this.testUserService = testUserService; + } + + @PostMapping("/signup") + public ResponseEntity> signup(@Valid @RequestBody TestUserSignupForm testUserSignupForm) { + + TestUserDto userDto = testUserService.insertTestUserProc(testUserSignupForm.testUserDto()); + + if (userDto.getResultCode() == 2004) { + return ApiResponse.entity(userDto, ApiResponseCode.CODE_2004, "아이디"); + } + + return ApiResponse.entity(userDto, ApiResponseCode.CODE_2002, "아이디"); + } + +} \ No newline at end of file diff --git a/src/main/java/com/alist/api/modules/testUser/dto/TestUserDto.java b/src/main/java/com/alist/api/modules/testUser/dto/TestUserDto.java new file mode 100644 index 0000000..8e58b54 --- /dev/null +++ b/src/main/java/com/alist/api/modules/testUser/dto/TestUserDto.java @@ -0,0 +1,19 @@ +package com.alist.api.modules.testUser.dto; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Getter; +import lombok.Setter; + +@Setter +@Getter +public class TestUserDto { + private Integer userIdx; + private String id; + + @JsonIgnore + private String newPassword; + @JsonIgnore + private String password; + @JsonIgnore + private int resultCode; +} diff --git a/src/main/java/com/alist/api/modules/testUser/dto/TestUserTokenDto.java b/src/main/java/com/alist/api/modules/testUser/dto/TestUserTokenDto.java new file mode 100644 index 0000000..2e89ec7 --- /dev/null +++ b/src/main/java/com/alist/api/modules/testUser/dto/TestUserTokenDto.java @@ -0,0 +1,14 @@ +package com.alist.api.modules.testUser.dto; + +import lombok.Getter; +import lombok.Setter; + +@Setter +@Getter +public class TestUserTokenDto { + private int userTokenIdx; + private int userIdx; + private String userApiKey; + private String userRole; + private int resultCode; +} diff --git a/src/main/java/com/alist/api/modules/testUser/form/TestUserSignupForm.java b/src/main/java/com/alist/api/modules/testUser/form/TestUserSignupForm.java new file mode 100644 index 0000000..9098cca --- /dev/null +++ b/src/main/java/com/alist/api/modules/testUser/form/TestUserSignupForm.java @@ -0,0 +1,40 @@ +package com.alist.api.modules.testUser.form; + +import com.alist.api.modules.testUser.dto.TestUserDto; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +@Setter +@Getter +@Schema(description = "회원 가입 요청 폼") +public class TestUserSignupForm { + @Schema( + description = "사용자 아이디 (공백 불가)", + example = "test" + ) + @NotBlank(message = "아이디를 입력해주세요.") + private String id; + + @Schema( + description = "비밀번호 (8~64자, 영문 + 숫자 조합, 공백 불가)", + example = "pass1234" + ) + @NotBlank(message = "비밀번호를 입력해주세요.") + @Size(min = 8, max = 64, message = "비밀번호는 8~64자여야 합니다.") + @Pattern( + regexp = "^(?=.*[A-Za-z])(?=.*\\d)\\S+$", + message = "비밀번호는 영문과 숫자를 포함하고 공백이 없어야 합니다." + ) + private String password; + + public TestUserDto testUserDto() { + TestUserDto testUserDto = new TestUserDto(); + testUserDto.setId(id.trim()); + testUserDto.setPassword(password); + return testUserDto; + } +} diff --git a/src/main/java/com/alist/api/modules/testUser/mapper/TestUserMapper.java b/src/main/java/com/alist/api/modules/testUser/mapper/TestUserMapper.java new file mode 100644 index 0000000..5ff3e0a --- /dev/null +++ b/src/main/java/com/alist/api/modules/testUser/mapper/TestUserMapper.java @@ -0,0 +1,14 @@ +package com.alist.api.modules.testUser.mapper; + +import com.alist.api.modules.testUser.dto.TestUserDto; +import com.alist.api.modules.testUser.dto.TestUserTokenDto; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface TestUserMapper { + int selectDuplicateTestUserCount(TestUserDto testUserDto); + + int insertTestUserSignup(TestUserDto testUserDto); + + int insertTestUserTokenSignup(TestUserTokenDto testUserTokenDto); +} diff --git a/src/main/java/com/alist/api/modules/testUser/service/TestUserService.java b/src/main/java/com/alist/api/modules/testUser/service/TestUserService.java new file mode 100644 index 0000000..6b4d4f6 --- /dev/null +++ b/src/main/java/com/alist/api/modules/testUser/service/TestUserService.java @@ -0,0 +1,65 @@ +package com.alist.api.modules.testUser.service; + +import com.alist.api.common.utils.ApiKeyGenerator; +import com.alist.api.modules.testUser.dto.TestUserDto; +import com.alist.api.modules.testUser.dto.TestUserTokenDto; +import com.alist.api.modules.testUser.mapper.TestUserMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +public class TestUserService { + private final TestUserMapper testUserMapper; + private final PasswordEncoder passwordEncoder; + + public TestUserService(TestUserMapper testUserMapper, PasswordEncoder passwordEncoder) { + this.testUserMapper = testUserMapper; + this.passwordEncoder = passwordEncoder; + } + + @Transactional + public TestUserDto insertTestUserProc(TestUserDto testUserDto) { + // 중복체크 + if (testUserMapper.selectDuplicateTestUserCount(testUserDto) > 0) { + testUserDto.setResultCode(2004); + return testUserDto; + } + + // 비밀번호 생성 + testUserDto.setNewPassword(passwordEncoder.encode(testUserDto.getPassword())); + + // 회원정보 입력 + int result = testUserMapper.insertTestUserSignup(testUserDto); + + if (result > 0) { + // user_api_key 생성 + TestUserTokenDto testUserTokenDto = new TestUserTokenDto(); + testUserTokenDto.setUserIdx(testUserDto.getUserIdx()); + + apiKeyWithRetry(testUserTokenDto); + } + + log.info("cnt : " + testUserDto.getUserIdx()); + log.info("userIdx : " + testUserDto.getUserIdx()); + + return testUserDto; + } + + @Transactional + public int apiKeyWithRetry(TestUserTokenDto testUserTokenDto) { + for (int i = 0; i < 5; i++) { + testUserTokenDto.setUserApiKey(ApiKeyGenerator.userApiKeyProc()); + try { + return testUserMapper.insertTestUserTokenSignup(testUserTokenDto); + } catch (DuplicateKeyException e) { + // 충돌이면 다시 생성 + log.warn("Duplicate API key. retrying... userId={}", testUserTokenDto.getUserIdx()); + } + } + throw new IllegalStateException("API key generation failed after retries."); + } +} diff --git a/src/main/java/com/alist/api/modules/testUser/vo/TestUserVo.java b/src/main/java/com/alist/api/modules/testUser/vo/TestUserVo.java new file mode 100644 index 0000000..9d08a7b --- /dev/null +++ b/src/main/java/com/alist/api/modules/testUser/vo/TestUserVo.java @@ -0,0 +1,8 @@ +package com.alist.api.modules.testUser.vo; + +import lombok.Getter; + +@Getter +public class TestUserVo { + private String id; +} diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index 8abea22..b9d418b 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -1,7 +1,27 @@ -server: - port: 8006 spring: application: name: api + datasource: + url: jdbc:log4jdbc:mariadb://121.160.234.222:3000/ALISTLMS?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Seoul&connectionCollation=utf8mb4_uca1400_ai_ci + username: alist_dev + password: 1qaz2wsx!@ + driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy + logging: config: classpath:logback-local.xml + +jwt: + secret: "F9k3s!29dkF#1lP0X9QZx8eW!2m@0AbD" + access-token-validity-seconds: 3600 + refresh-token-validity-seconds: 2592000 + +swagger: + login: + id: alist + password: "1qaz2wsx!@" + +springdoc: + api-docs: + enabled: true + swagger-ui: + enabled: true \ No newline at end of file diff --git a/src/main/resources/application-pjt.yaml b/src/main/resources/application-pjt.yaml index 792502f..43e9bca 100644 --- a/src/main/resources/application-pjt.yaml +++ b/src/main/resources/application-pjt.yaml @@ -1,5 +1,29 @@ spring: application: name: api + main: + banner-mode: log + datasource: + url: jdbc:log4jdbc:mariadb://${DB_HOST}:${DB_PORT}/${DB_NAME}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Seoul&connectionCollation=utf8mb4_uca1400_ai_ci + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy + logging: config: classpath:logback-pjt.xml + +jwt: + secret: ${JWT_SECRET} + access-token-validity-seconds: 3600 + refresh-token-validity-seconds: 2592000 + +swagger: + login: + id: ${SWAGGER_ID} + password: ${SWAGGER_PASSWORD} + +springdoc: + api-docs: + enabled: true + swagger-ui: + enabled: true \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 8a2082b..fce5e21 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,3 +1,29 @@ +server: + port: 8106 + forward-headers-strategy: framework + spring: application: name: api + +mybatis: + mapper-locations: classpath:mapper/**/*.xml + type-aliases-package: com.alist.api + configuration: + map-underscore-to-camel-case: true + log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl + +springdoc: + swagger-ui: + path: /swagger-ui.html + api-docs: + path: /v3/api-docs + +management: + endpoints: + web: + exposure: + include: health,info,metrics + endpoint: + health: + show-details: always \ No newline at end of file diff --git a/src/main/resources/log4jdbc.log4j2.properties b/src/main/resources/log4jdbc.log4j2.properties new file mode 100644 index 0000000..1b22fc5 --- /dev/null +++ b/src/main/resources/log4jdbc.log4j2.properties @@ -0,0 +1,3 @@ +log4jdbc.spylogdelegator.name=net.sf.log4jdbc.log.slf4j.Slf4jSpyLogDelegator +log4jdbc.drivers=org.mariadb.jdbc.Driver +log4jdbc.dump.sql.maxlinelength=0 \ No newline at end of file diff --git a/src/main/resources/logback-local.xml b/src/main/resources/logback-local.xml index eb719dd..17ddb53 100644 --- a/src/main/resources/logback-local.xml +++ b/src/main/resources/logback-local.xml @@ -4,19 +4,39 @@ + + + - %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + %clr(%d{HH:mm:ss.SSS}){faint} %clr(%5p) %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n + + + - + + + + + + + + + + + + + + + diff --git a/src/main/resources/logback-pjt.xml b/src/main/resources/logback-pjt.xml index 07198af..772bdd7 100644 --- a/src/main/resources/logback-pjt.xml +++ b/src/main/resources/logback-pjt.xml @@ -4,28 +4,20 @@ - - - - %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - + - ${LOG_PATH}/app.log + ${LOG_PATH}/api.log - ${LOG_PATH}/%d{yyyy/MM}/app.%d{yyyy-MM-dd}.%i.log.gz + ${LOG_PATH}/%d{yyyy/MM}/api.log-%d{yyyy-MM-dd}.%i - + 50MB @@ -34,7 +26,7 @@ - %d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger - %msg%n + %d{yyyy-MM-dd HH:mm:ss.SSS} | %-5level | %-15thread | %-40logger{40} | %msg%n @@ -44,7 +36,21 @@ - + + + + + + + + + + + + + + + diff --git a/src/main/resources/mapper/testUser/TestUserMapper.xml b/src/main/resources/mapper/testUser/TestUserMapper.xml new file mode 100644 index 0000000..a0d5b56 --- /dev/null +++ b/src/main/resources/mapper/testUser/TestUserMapper.xml @@ -0,0 +1,25 @@ + + + + + + /*UserSignupMapper.insertTestUserSignup*/ + insert into test_user (id, password, del_yn, create_at, update_at) + value (#{id}, #{newPassword}, 1, now(), now()) + + + /*UserSignupMapper.insertTestUserTokenSignup*/ + insert into test_user_token (user_idx, user_api_key, user_role, created_at, updated_at) + value (#{userIdx}, #{userApiKey}, 'user', now(), now()) + + + + + diff --git a/src/test/java/com/alist/api/ApiApplicationTests.java b/src/test/java/com/alist/api/ApiApplicationTests.java index 170e78d..bb6c87a 100644 --- a/src/test/java/com/alist/api/ApiApplicationTests.java +++ b/src/test/java/com/alist/api/ApiApplicationTests.java @@ -2,8 +2,10 @@ package com.alist.api; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("local") class ApiApplicationTests { @Test