Merge commit '5367465027cfd9d978e6e5f19182b2f38a7ecfb5'

This commit is contained in:
2026-02-24 17:14:28 +09:00
34 changed files with 1367 additions and 23 deletions
+135
View File
@@ -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<T>` 래퍼 사용
- 응답 코드는 `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/{파일경로}`
+230 -1
View File
@@ -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
'''
}
}
}
}
}
+37
View File
@@ -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
}
+6 -4
View File
@@ -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:
@@ -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) {
@@ -0,0 +1,34 @@
package com.alist.api.common.response;
import lombok.Getter;
import org.springframework.http.ResponseEntity;
@Getter
public class ApiResponse<T> {
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<Void> body(ApiResponseCode code, Object... args) {
return new ApiResponse<>(null, code.code(), code.message(args));
}
public static <T> ApiResponse<T> body(T data, ApiResponseCode code, Object... args) {
return new ApiResponse<>(data, code.code(), code.message(args));
}
public static ResponseEntity<ApiResponse<Void>> entity(ApiResponseCode code, Object... args) {
return ResponseEntity.status(code.httpStatus()).body(ApiResponse.body(code, args));
}
public static <T> ResponseEntity<ApiResponse<T>> entity(T data, ApiResponseCode code, Object... args) {
return ResponseEntity.status(code.httpStatus()).body(ApiResponse.body(data, code, args));
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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")
)
);
}
}
@@ -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();
}
}
@@ -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<ApiResponse<Void>> handleIllegalArgument(IllegalArgumentException e) {
log.warn("IllegalArgumentException: {}", e.getMessage());
return ApiResponse.entity(ApiResponseCode.CODE_400);
}
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<ApiResponse<Void>> handleMissingParam(MissingServletRequestParameterException e) {
log.warn("Missing request parameter: {} (type={})", e.getParameterName(), e.getParameterType());
return ApiResponse.entity(ApiResponseCode.CODE_4003);
}
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ApiResponse<Void>> handleMethodNotSupported(HttpRequestMethodNotSupportedException e) {
log.warn("Method not supported: {} (supported={})", e.getMethod(), e.getSupportedHttpMethods());
return ApiResponse.entity(ApiResponseCode.CODE_405);
}
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleNotFound(NotFoundException e) {
log.warn("Not found: {}", e.getMessage());
return ApiResponse.entity(ApiResponseCode.CODE_404);
}
// 입력오류
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Map<String, String>>> handleMethodArgumentNotValid(MethodArgumentNotValidException e) {
Map<String, String> 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<ApiResponse<Void>> handleTypeMismatch(MethodArgumentTypeMismatchException e) {
log.warn("Type mismatch: name={}, value={}", e.getName(), e.getValue());
return ApiResponse.entity(ApiResponseCode.CODE_4001);
}
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ApiResponse<Void>> handleNotReadable(HttpMessageNotReadableException e) {
log.warn("Unreadable message (json parse?) : {}", e.getMessage());
return ApiResponse.entity(ApiResponseCode.CODE_4001);
}
@ExceptionHandler(BindException.class)
public ResponseEntity<ApiResponse<Map<String, String>>> handleBindException(BindException e) {
Map<String, String> 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<ApiResponse<Void>> handleException(Exception e) {
log.error("Unhandled exception occurred", e);
return ApiResponse.entity(ApiResponseCode.CODE_500);
}
}
@@ -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<Void> body = ApiResponse.body(ApiResponseCode.CODE_403);
objectMapper.writeValue(response.getOutputStream(), body);
}
}
@@ -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<Void> body = ApiResponse.body(ApiResponseCode.CODE_401);
objectMapper.writeValue(response.getOutputStream(), body);
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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;
}
@@ -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<ApiResponse<String>> token(@Valid @RequestBody TokenForm tokenForm) {
String accessToken = jwtTokenProvider.createToken(tokenForm.getUserId());
return ApiResponse.entity(accessToken, ApiResponseCode.CODE_2001, "엑세스 토큰");
}
}
@@ -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;
}
@@ -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";
}
}
@@ -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<ApiResponse<TestUserDto>> 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, "아이디");
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
}
@@ -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);
}
@@ -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.");
}
}
@@ -0,0 +1,8 @@
package com.alist.api.modules.testUser.vo;
import lombok.Getter;
@Getter
public class TestUserVo {
private String id;
}
+22 -2
View File
@@ -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
+24
View File
@@ -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
+26
View File
@@ -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
@@ -0,0 +1,3 @@
log4jdbc.spylogdelegator.name=net.sf.log4jdbc.log.slf4j.Slf4jSpyLogDelegator
log4jdbc.drivers=org.mariadb.jdbc.Driver
log4jdbc.dump.sql.maxlinelength=0
+22 -2
View File
@@ -4,19 +4,39 @@
<!-- 로그 디렉토리 -->
<property name="LOG_PATH" value="/logs" />
<!-- Spring Boot가 제공하는 %clr / 색상 변환기 등록 -->
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<!-- 콘솔 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
<pattern>%clr(%d{HH:mm:ss.SSS}){faint} %clr(%5p) %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n</pattern>
</encoder>
</appender>
<!-- 콘솔 appender: Boot 기본 제공 -->
<include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
<!-- ==== 로컬(local) ==== -->
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
<!-- 우리 코드 -->
<!-- springframework -->
<logger name="org.springframework" level="INFO"/>
<!-- project -->
<logger name="com.alist.api" level="DEBUG"/>
<!-- MyBatis SQL/파라미터 보고 싶으면 DEBUG -->
<logger name="org.mybatis" level="DEBUG"/>
<logger name="org.apache.ibatis" level="DEBUG"/>
<!-- log4jdbc -->
<logger name="jdbc.sqltiming" level="DEBUG"/>
<logger name="jdbc.resultsettable" level="DEBUG"/>
<logger name="jdbc.audit" level="OFF"/>
<logger name="jdbc.resultset" level="OFF"/>
<logger name="jdbc.sqlonly" level="OFF"/>
</configuration>
+20 -14
View File
@@ -4,28 +4,20 @@
<!-- 로그 디렉토리 -->
<property name="LOG_PATH" value="/logs" />
<!-- 콘솔 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- 파일 (운영에서 사용) -->
<!-- 파일 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- 현재 로그는 고정 위치 (tail 편함) -->
<file>${LOG_PATH}/app.log</file>
<file>${LOG_PATH}/api.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 롤링 파일은 날짜 폴더(yyyy/MM) 하위로 저장 -->
<fileNamePattern>
${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
</fileNamePattern>
<timeBasedFileNamingAndTriggeringPolicy
class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>50MB</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
@@ -34,7 +26,7 @@
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger - %msg%n</pattern>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} | %-5level | %-15thread | %-40logger{40} | %msg%n</pattern>
</encoder>
</appender>
@@ -44,7 +36,21 @@
<appender-ref ref="FILE"/>
</root>
<!-- 우리 코드 -->
<!-- springframework -->
<logger name="org.springframework" level="WARN"/>
<!-- project -->
<logger name="com.alist.api" level="INFO"/>
<!-- MyBatis -->
<logger name="org.mybatis" level="WARN"/>
<logger name="org.apache.ibatis" level="WARN"/>
<!-- log4jdbc -->
<logger name="jdbc.sqltiming" level="INFO"/>
<logger name="jdbc.resultsettable" level="OFF"/>
<logger name="jdbc.audit" level="OFF"/>
<logger name="jdbc.resultset" level="OFF"/>
<logger name="jdbc.sqlonly" level="OFF"/>
</configuration>
@@ -0,0 +1,25 @@
<?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.alist.api.modules.testUser.mapper.TestUserMapper">
<insert id="insertTestUserSignup" useGeneratedKeys="true" keyProperty="userIdx">
/*UserSignupMapper.insertTestUserSignup*/
insert into test_user (id, password, del_yn, create_at, update_at)
value (#{id}, #{newPassword}, 1, now(), now())
</insert>
<insert id="insertTestUserTokenSignup" useGeneratedKeys="true" keyProperty="userTokenIdx">
/*UserSignupMapper.insertTestUserTokenSignup*/
insert into test_user_token (user_idx, user_api_key, user_role, created_at, updated_at)
value (#{userIdx}, #{userApiKey}, 'user', now(), now())
</insert>
<select id="selectDuplicateTestUserCount" resultType="int">
/*UserSignupMapper.selectDuplicateTestUserCount*/
select count(*)
from test_user
where id = #{id}
</select>
</mapper>
@@ -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