Skip to content

Commit d23e414

Browse files
authored
Merge pull request #36 from asowjdan/feat/23-member
Feat[member]: 기능 추가
2 parents 1a65b4a + 12a2dc6 commit d23e414

28 files changed

+1893
-77
lines changed

.github/workflows/CI-CD_Pipeline.yml

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,21 @@ jobs:
2626

2727
runs-on: ${{ matrix.os }}
2828
env:
29-
SPRING_PROFILES_ACTIVE: test
30-
JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }}
31-
REDIS_PORT: ${{ secrets.REDIS_PORT }}
32-
REDIS_HOST: ${{ secrets.REDIS_HOST }}
29+
SPRING_PROFILES_ACTIVE: test-ci
30+
31+
# ✅ Redis 서비스 추가
32+
services:
33+
redis:
34+
image: redis:7-alpine
35+
ports:
36+
- 6379:6379
37+
options: >-
38+
--health-cmd "redis-cli ping"
39+
--health-interval 10s
40+
--health-timeout 5s
41+
--health-retries 5
42+
env:
43+
REDIS_PASSWORD: ""
3344

3445
steps:
3546
- uses: actions/checkout@v4
@@ -45,6 +56,40 @@ jobs:
4556
- name: Grant execute permission for gradlew
4657
run: chmod +x backend/gradlew
4758

59+
# ✅ Redis 연결 테스트
60+
- name: Test Redis connection
61+
run: |
62+
echo "Testing Redis connection..."
63+
timeout 10s bash -c 'until printf "" 2>>/dev/null >>/dev/tcp/localhost/6379; do sleep 1; done'
64+
echo "Redis is ready!"
65+
66+
# ✅ application-test.yml에서 사용하는 모든 환경변수를 .env 파일에 생성
67+
- name: Create test .env file
68+
working-directory: backend
69+
run: |
70+
cat > .env << 'EOF'
71+
# Datasource 설정 (application-test.yml에서 참조)
72+
TEST_DATASOURCE_URL=jdbc:h2:mem:db_test;MODE=MySQL
73+
TEST_DATASOURCE_USERNAME=sa
74+
TEST_DATASOURCE_PASSWORD=
75+
TEST_DATASOURCE_DRIVER=org.h2.Driver
76+
77+
# JPA 설정 (application-test.yml에서 참조)
78+
TEST_JPA_HIBERNATE_DDL_AUTO=create-drop
79+
80+
# Redis 설정 (application-test.yml에서 참조, GitHub Actions 서비스 사용)
81+
TEST_REDIS_HOST=localhost
82+
TEST_REDIS_PORT=6379
83+
TEST_REDIS_PASSWORD=
84+
85+
# CI/CD 환경에서는 Embedded Redis 끄기
86+
SPRING_DATA_REDIS_EMBEDDED=false
87+
88+
# JWT 설정 (application-test.yml에서 참조)
89+
CUSTOM_JWT_SECRET_KEY=test-secret-key-for-testing-purposes-only-minimum-256-bits
90+
CUSTOM_JWT_ACCESS_TOKEN_EXPIRATION_SECONDS=3600
91+
EOF
92+
4893
- name: Run unit, and domain tests
4994
run: ${{ matrix.gradle_cmd }} clean test
5095
working-directory: backend
@@ -75,6 +120,16 @@ jobs:
75120
- name: Grant execute permission for gradlew
76121
run: chmod +x backend/gradlew
77122

123+
# ✅ 빌드용 .env 파일 생성 (Configuration Properties 바인딩용 최소 환경변수만)
124+
- name: Create build .env file
125+
working-directory: backend
126+
run: |
127+
cat > .env << 'EOF'
128+
# JWT Configuration Properties 바인딩용 (빌드 시 필요)
129+
CUSTOM_JWT_SECRET_KEY=build-dummy-key-for-configuration-properties-binding
130+
CUSTOM_JWT_ACCESS_TOKEN_EXPIRATION_SECONDS=3600
131+
EOF
132+
78133
- name: Gradle bootJar
79134
working-directory: backend
80135
run: ./gradlew --no-daemon clean bootJar -x test
@@ -123,7 +178,7 @@ jobs:
123178
file: backend/Dockerfile
124179
push: true
125180
tags: |
126-
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/back9-backend:${{ github.sha }}
127-
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/back9-backend:latest
181+
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/backend:${{ github.sha }}
182+
${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/backend:latest
128183
cache-from: type=gha
129184
cache-to: type=gha,mode=max

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
.idea
1+
.idea
2+
db_dev.mv.db

backend/.env.default

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,40 @@
1+
SPRING_PROFILES_ACTIVE=NEED_TO_SET
2+
3+
SPRING_JPA_HIBERNATE_DDL_AUTO=NEED_TO_SET
4+
15
SPRING__SECURITY__OAUTH2__CLIENT__REGISTRATION__KAKAO__CLIENT_ID=NEED_TO_SET
26
SPRING__SECURITY__OAUTH2__CLIENT__REGISTRATION__KAKAO__CLIENT_SECRET=NEED_TO_SET
37
SPRING__SECURITY__OAUTH2__CLIENT__REGISTRATION__NAVER__CLIENT_ID=NEED_TO_SET
48
SPRING__SECURITY__OAUTH2__CLIENT__REGISTRATION__NAVER__CLIENT_SECRET=NEED_TO_SET
59

610
CUSTOM__JWT__SECRET_KEY=NEED_TO_SET
11+
CUSTOM_JWT_ACCESS_TOKEN_EXPIRATION_SECONDS=NEED_TO_SET
12+
13+
PROD_DATASOURCE_URL=NEED_TO_SET
14+
PROD_DATASOURCE_DRIVER=NEED_TO_SET
15+
PROD_DATASOURCE_USERNAME=NEED_TO_SET
16+
PROD_DATASOURCE_PASSWORD=NEED_TO_SET
17+
PROD_JPA_HIBERNATE_DDL_AUTO=NEED_TO_SET
18+
PROD_REDIS_HOST=NEED_TO_SET
19+
PROD_REDIS_PORT=NEED_TO_SET
20+
PROD_REDIS_PASSWORD=NEED_TO_SET
21+
22+
DEV_DATASOURCE_URL=NEED_TO_SET
23+
DEV_DATASOURCE_USERNAME=NEED_TO_SET
24+
DEV_DATASOURCE_PASSWORD=NEED_TO_SET
25+
DEV_DATASOURCE_DRIVER=NEED_TO_SET
26+
DEV_JPA_HIBERNATE_DDL_AUTO=NEED_TO_SET
27+
28+
DEV_REDIS_HOST=NEED_TO_SET
29+
DEV_REDIS_PORT=NEED_TO_SET
30+
DEV_REDIS_PASSWORD=NEED_TO_SET
31+
32+
TEST_DATASOURCE_URL=NEED_TO_SET
33+
TEST_DATASOURCE_USERNAME=NEED_TO_SET
34+
TEST_DATASOURCE_PASSWORD=NEED_TO_SET
35+
TEST_DATASOURCE_DRIVER=NEED_TO_SET
36+
TEST_JPA_HIBERNATE_DDL_AUTO=NEED_TO_SET
737

8-
DB_HOST=NEED_TO_SET
9-
DB_PORT=NEED_TO_SET
10-
DB_NAME=NEED_TO_SET
11-
DB_USER=NEED_TO_SET
12-
DB_PASS=NEED_TO_SET
13-
DB_ROOT_PASS=NEED_TO_SET
38+
TEST_REDIS_HOST=NEED_TO_SET
39+
TEST_REDIS_PORT=NEED_TO_SET
40+
TEST_REDIS_PASSWORD=NEED_TO_SET

backend/build.gradle

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ dependencies {
3131
implementation 'org.springframework.boot:spring-boot-starter-security'
3232
implementation 'org.springframework.boot:spring-boot-starter-validation'
3333
implementation 'org.springframework.boot:spring-boot-starter-web'
34+
implementation 'org.springframework.boot:spring-boot-starter-actuator'
3435

3536
// API Documentation (문서화)
3637
implementation 'org.apache.commons:commons-lang3:3.18.0'
@@ -39,6 +40,10 @@ dependencies {
3940
// Database (데이터베이스)
4041
runtimeOnly 'com.h2database:h2'
4142
runtimeOnly 'com.mysql:mysql-connector-j'
43+
implementation 'com.google.guava:guava:32.1.2-jre'
44+
implementation 'commons-io:commons-io:2.15.1'
45+
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
46+
implementation 'org.springframework.session:spring-session-data-redis'
4247

4348
// Development Tools (개발 도구)
4449
compileOnly 'org.projectlombok:lombok'
@@ -51,6 +56,12 @@ dependencies {
5156
testImplementation 'org.springframework.boot:spring-boot-starter-test'
5257
testImplementation 'org.springframework.security:spring-security-test'
5358
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
59+
implementation("it.ozimov:embedded-redis:0.7.3") {
60+
exclude group: "org.slf4j", module: "slf4j-simple"
61+
exclude group: "com.google.guava", module: "guava"
62+
exclude group: "commons-io", module: "commons-io"
63+
exclude group: "commons-logging", module: "commons-logging"
64+
}
5465
}
5566

5667
tasks.named('test') {

backend/src/main/java/com/ai/lawyer/BackendApplication.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
import org.springframework.boot.SpringApplication;
44
import org.springframework.boot.autoconfigure.SpringBootApplication;
5+
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
56
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
67

78
@SpringBootApplication
89
@EnableJpaAuditing
10+
@ConfigurationPropertiesScan
911
public class BackendApplication {
1012

1113
public static void main(String[] args) {
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package com.ai.lawyer.domain.member.controller;
2+
3+
import com.ai.lawyer.domain.member.dto.MemberLoginRequest;
4+
import com.ai.lawyer.domain.member.dto.MemberResponse;
5+
import com.ai.lawyer.domain.member.dto.MemberSignupRequest;
6+
import com.ai.lawyer.domain.member.entity.Member;
7+
import com.ai.lawyer.domain.member.service.MemberService;
8+
import io.swagger.v3.oas.annotations.Operation;
9+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
10+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
11+
import io.swagger.v3.oas.annotations.tags.Tag;
12+
import jakarta.servlet.http.HttpServletRequest;
13+
import jakarta.servlet.http.HttpServletResponse;
14+
import jakarta.validation.Valid;
15+
import lombok.RequiredArgsConstructor;
16+
import lombok.extern.slf4j.Slf4j;
17+
import org.springframework.http.HttpStatus;
18+
import org.springframework.http.ResponseEntity;
19+
import org.springframework.security.core.Authentication;
20+
import org.springframework.web.bind.annotation.*;
21+
22+
@RestController
23+
@RequestMapping("/api/auth")
24+
@RequiredArgsConstructor
25+
@Slf4j
26+
@Tag(name = "Member", description = "회원 관리 API")
27+
public class MemberController {
28+
29+
private final MemberService memberService;
30+
31+
@PostMapping("/signup")
32+
@Operation(summary = "회원가입", description = "새로운 회원을 등록합니다.")
33+
@ApiResponses({
34+
@ApiResponse(responseCode = "201", description = "회원가입 성공"),
35+
@ApiResponse(responseCode = "400", description = "잘못된 요청 (중복 이메일/닉네임, 유효성 검증 실패)")
36+
})
37+
public ResponseEntity<MemberResponse> signup(@Valid @RequestBody MemberSignupRequest request) {
38+
log.info("회원가입 요청: email={}, nickname={}", request.getLoginId(), request.getNickname());
39+
40+
try {
41+
MemberResponse response = memberService.signup(request);
42+
log.info("회원가입 성공: memberId={}", response.getMemberId());
43+
return ResponseEntity.status(HttpStatus.CREATED).body(response);
44+
} catch (IllegalArgumentException e) {
45+
log.warn("회원가입 실패: {}", e.getMessage());
46+
return ResponseEntity.badRequest().build();
47+
}
48+
}
49+
50+
@PostMapping("/login")
51+
@Operation(summary = "로그인", description = "이메일과 비밀번호로 로그인합니다.")
52+
@ApiResponses({
53+
@ApiResponse(responseCode = "200", description = "로그인 성공"),
54+
@ApiResponse(responseCode = "401", description = "인증 실패 (존재하지 않는 회원, 비밀번호 불일치)")
55+
})
56+
public ResponseEntity<MemberResponse> login(@Valid @RequestBody MemberLoginRequest request,
57+
HttpServletResponse response) {
58+
log.info("로그인 요청: email={}", request.getLoginId());
59+
60+
try {
61+
MemberResponse memberResponse = memberService.login(request, response);
62+
log.info("로그인 성공: memberId={}", memberResponse.getMemberId());
63+
return ResponseEntity.ok(memberResponse);
64+
} catch (IllegalArgumentException e) {
65+
log.warn("로그인 실패: {}", e.getMessage());
66+
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
67+
}
68+
}
69+
70+
@PostMapping("/logout")
71+
@Operation(summary = "로그아웃", description = "현재 로그인된 사용자를 로그아웃합니다.")
72+
@ApiResponses({
73+
@ApiResponse(responseCode = "200", description = "로그아웃 성공")
74+
})
75+
public ResponseEntity<Void> logout(HttpServletResponse response) {
76+
log.info("로그아웃 요청");
77+
78+
memberService.logout(response);
79+
log.info("로그아웃 완료");
80+
return ResponseEntity.ok().build();
81+
}
82+
83+
@PostMapping("/refresh")
84+
@Operation(summary = "토큰 재발급", description = "리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받습니다.")
85+
@ApiResponses({
86+
@ApiResponse(responseCode = "200", description = "토큰 재발급 성공"),
87+
@ApiResponse(responseCode = "401", description = "유효하지 않은 리프레시 토큰")
88+
})
89+
public ResponseEntity<MemberResponse> refreshToken(HttpServletRequest request,
90+
HttpServletResponse response) {
91+
log.info("토큰 재발급 요청");
92+
93+
// 쿠키에서 리프레시 토큰 추출 (간단한 방법)
94+
String refreshToken = extractRefreshTokenFromCookies(request);
95+
96+
if (refreshToken == null) {
97+
log.warn("리프레시 토큰이 없음");
98+
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
99+
}
100+
101+
try {
102+
MemberResponse memberResponse = memberService.refreshToken(refreshToken, response);
103+
log.info("토큰 재발급 성공: memberId={}", memberResponse.getMemberId());
104+
return ResponseEntity.ok(memberResponse);
105+
} catch (IllegalArgumentException e) {
106+
log.warn("토큰 재발급 실패: {}", e.getMessage());
107+
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
108+
}
109+
}
110+
111+
@DeleteMapping("/withdraw")
112+
@Operation(summary = "회원탈퇴", description = "현재 로그인된 사용자의 계정을 삭제합니다.")
113+
@ApiResponses({
114+
@ApiResponse(responseCode = "200", description = "회원탈퇴 성공"),
115+
@ApiResponse(responseCode = "401", description = "인증되지 않은 사용자"),
116+
@ApiResponse(responseCode = "404", description = "존재하지 않는 회원")
117+
})
118+
public ResponseEntity<Void> withdraw(Authentication authentication, HttpServletResponse response) {
119+
if (authentication == null || authentication.getName() == null) {
120+
log.warn("인증되지 않은 회원탈퇴 요청");
121+
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
122+
}
123+
124+
String loginId = authentication.getName();
125+
log.info("회원탈퇴 요청: email={}", loginId);
126+
127+
try {
128+
// loginId로 Member를 조회하여 실제 memberId 사용
129+
Member member = memberService.findByLoginId(loginId);
130+
memberService.withdraw(member.getMemberId());
131+
memberService.logout(response); // 탈퇴 후 로그아웃 처리
132+
log.info("회원탈퇴 성공: email={}, memberId={}", loginId, member.getMemberId());
133+
return ResponseEntity.ok().build();
134+
} catch (IllegalArgumentException e) {
135+
log.warn("회원탈퇴 실패: {}", e.getMessage());
136+
return ResponseEntity.notFound().build();
137+
}
138+
}
139+
140+
@GetMapping("/me")
141+
@Operation(summary = "내 정보 조회", description = "현재 로그인된 사용자의 정보를 조회합니다.")
142+
@ApiResponses({
143+
@ApiResponse(responseCode = "200", description = "조회 성공"),
144+
@ApiResponse(responseCode = "401", description = "인증되지 않은 사용자")
145+
})
146+
public ResponseEntity<MemberResponse> getMyInfo(Authentication authentication) {
147+
if (authentication == null || authentication.getName() == null) {
148+
log.warn("인증되지 않은 정보 조회 요청");
149+
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
150+
}
151+
152+
String loginId = authentication.getName();
153+
log.info("내 정보 조회 요청: email={}", loginId);
154+
155+
try {
156+
// loginId로 Member를 조회하여 실제 memberId 사용
157+
Member member = memberService.findByLoginId(loginId);
158+
MemberResponse response = memberService.getMemberById(member.getMemberId());
159+
log.info("내 정보 조회 성공: memberId={}", response.getMemberId());
160+
return ResponseEntity.ok(response);
161+
} catch (IllegalArgumentException e) {
162+
log.warn("내 정보 조회 실패: {}", e.getMessage());
163+
return ResponseEntity.notFound().build();
164+
}
165+
}
166+
167+
private String extractRefreshTokenFromCookies(HttpServletRequest request) {
168+
if (request.getCookies() != null) {
169+
for (jakarta.servlet.http.Cookie cookie : request.getCookies()) {
170+
if ("refreshToken".equals(cookie.getName())) {
171+
return cookie.getValue();
172+
}
173+
}
174+
}
175+
return null;
176+
}
177+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.ai.lawyer.domain.member.dto;
2+
3+
import jakarta.validation.constraints.Email;
4+
import jakarta.validation.constraints.NotBlank;
5+
import lombok.*;
6+
7+
@Getter
8+
@Setter
9+
@NoArgsConstructor
10+
@AllArgsConstructor
11+
@Builder
12+
public class MemberLoginRequest {
13+
14+
@Email(message = "올바른 이메일 형식이 아닙니다")
15+
@NotBlank(message = "이메일(로그인 ID)은 필수입니다")
16+
private String loginId;
17+
18+
@NotBlank(message = "비밀번호는 필수입니다")
19+
private String password;
20+
}

0 commit comments

Comments
 (0)