diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 90f5d35..24fda22 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -72,7 +72,8 @@ jobs: client-secret: \${KAKAO_CLIENT_SECRET_KEY} client-name: kakao authorization-grant-type: authorization_code - redirect-uri: \${KAKAO_BASE_URL}/login/oauth2/code/kakao + # redirect-uri: \${KAKAO_BASE_URL}/login/oauth2/code/kakao + redirect-uri: http://localhost:3000/login scope: - profile_nickname - profile_image @@ -83,21 +84,26 @@ jobs: token-uri: https://kauth.kakao.com/oauth/token user-info-uri: https://kapi.kakao.com/v2/user/me user-name-attribute: id - # Uncomment Redis if needed - # data: - # redis: - # host: \${REDIS_HOST} - # port: \${REDIS_PORT} - # repositories: - # enabled: false + data: + redis: + host: \${REDIS_HOST} + port: \${REDIS_PORT} + repositories: + enabled: false logging: level: bst.bobsoolting: DEBUG - org.springframework.security: TRACE + org.springframework.security: INFO org.mybatis: DEBUG java.sql: DEBUG + org.springframework.web: INFO + org.springframework.boot.actuate: INFO mybatis: mapper-locations: classpath:/bst/bobsoolting/mapper/**/*.xml + configuration: + default-enum-type-handler: org.apache.ibatis.type.EnumTypeHandler + map-underscore-to-camel-case: true + type-handlers-package: bst.bobsoolting.util cors: allowedOrigins: \${CORS_ALLOWED_ORIGINS} allowedHeaders: "*" @@ -108,10 +114,12 @@ jobs: client-id: \${KAKAO_CLIENT_KEY} client-secret: \${KAKAO_CLIENT_SECRET_KEY} use-basic-authentication-with-access-code-grant: true - oauth2RedirectUrl: \${KAKAO_BASE_URL}/login/oauth2/code/kakao + # oauth2RedirectUrl: \${KAKAO_BASE_URL}/login/oauth2/code/kakao + oauth2RedirectUrl: http://localhost:3000/login jwt: secret: \${JWT_SECRET} - access-token-validity: 86400000 # 24시간 + access-token-validity: 1800000 # 30분 + refresh-token-validity: 604800000 # 7일 EOL - name: Set application.yml with Secrets @@ -125,11 +133,11 @@ jobs: DB_NAME: ${{ secrets.DB_NAME }} DB_USERNAME: ${{ secrets.DB_USERNAME }} DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + REDIS_HOST: ${{ secrets.REDIS_HOST }} + REDIS_PORT: ${{ secrets.REDIS_PORT }} KAKAO_CLIENT_KEY: ${{ secrets.KAKAO_CLIENT_KEY }} KAKAO_CLIENT_SECRET_KEY: ${{ secrets.KAKAO_CLIENT_SECRET_KEY }} KAKAO_BASE_URL: ${{ secrets.KAKAO_BASE_URL }} - # REDIS_HOST: ${{ secrets.REDIS_HOST }} - # REDIS_PORT: ${{ secrets.REDIS_PORT }} CORS_ALLOWED_ORIGINS: ${{ secrets.CORS_ALLOWED_ORIGINS }} JWT_SECRET: ${{ secrets.JWT_SECRET }} diff --git a/src/main/java/bst/bobsoolting/member/command/application/controller/MemberCommandController.java b/src/main/java/bst/bobsoolting/member/command/application/controller/MemberCommandController.java index 37b9aba..353083d 100644 --- a/src/main/java/bst/bobsoolting/member/command/application/controller/MemberCommandController.java +++ b/src/main/java/bst/bobsoolting/member/command/application/controller/MemberCommandController.java @@ -6,10 +6,14 @@ import bst.bobsoolting.member.command.application.mapper.MemberConverter; import bst.bobsoolting.member.command.application.service.MemberCommandService; import bst.bobsoolting.member.command.domain.vo.request.RequestAdditionalRegisterVO; +import bst.bobsoolting.member.command.domain.vo.request.RequestKakaoAuthVO; +import bst.bobsoolting.member.command.domain.vo.request.RequestRefreshTokenVO; import bst.bobsoolting.member.command.domain.vo.request.RequestUpdateProfileVO; import bst.bobsoolting.member.command.domain.vo.response.ResponseCreateMemberVO; import bst.bobsoolting.member.command.domain.vo.response.ResponseProfileVO; +import bst.bobsoolting.security.JwtTokenProvider; import bst.bobsoolting.util.SecurityUtil; +import io.jsonwebtoken.Claims; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; @@ -17,6 +21,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.HashMap; import java.util.Map; @RestController @@ -27,26 +32,70 @@ public class MemberCommandController implements MemberCommandControllerDocs { private final MemberCommandService memberCommandService; private final MemberConverter memberConverter; private final SecurityUtil securityUtil; + private final JwtTokenProvider jwtTokenProvider; @PostMapping("/auth/kakao") - public ResponseEntity kakaoLogin(@RequestBody Map request) { - String code = request.get("code"); + public ResponseEntity> kakaoLogin(@RequestBody RequestKakaoAuthVO request) { + String code = request.getCode(); + if (code == null || code.isEmpty()) { + log.error("인가 코드가 전달되지 않았습니다."); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of("error", "인가 코드가 없습니다.")); + } + log.info("카카오 로그인 요청. 인가 코드: {}", code); try { - String accessToken = memberCommandService.getKakaoAccessToken(code); - MemberDTO member = memberCommandService.getKakaoUserInfo(accessToken); - ResponseCreateMemberVO response = memberConverter.fromEntityToCreateVO(member); + String kakaoAccessToken = memberCommandService.getKakaoAccessToken(code); + MemberDTO member = memberCommandService.getKakaoUserInfo(kakaoAccessToken); - return ResponseEntity.ok(response); + String serverJwtToken = jwtTokenProvider.generateAccessToken(member.getKakaoId()); + String refreshToken = jwtTokenProvider.generateRefreshToken(member.getKakaoId()); + + memberCommandService.storeRefreshToken(member.getKakaoId(), refreshToken); + + Map responseMap = new HashMap<>(); + responseMap.put("access_token", serverJwtToken); + responseMap.put("refresh_token", refreshToken); + + return ResponseEntity.ok(responseMap); } catch (CommonException e) { log.error("카카오 로그인 오류: {}", e.getMessage()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of("error", e.getMessage())); } catch (Exception e) { log.error("예상치 못한 오류 발생", e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("카카오 로그인 처리 중 오류 발생"); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("error", "카카오 로그인 처리 중 오류 발생")); } } + @PostMapping("/auth/refresh") + public ResponseEntity refreshAccessToken(@RequestBody RequestRefreshTokenVO request) { + String refreshToken = request.getRefresh_token(); + + if (refreshToken == null || refreshToken.isEmpty() || jwtTokenProvider.isTokenExpired(refreshToken)) { + log.warn("유효하지 않은 Refresh Token 요청 감지"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("유효하지 않은 Refresh Token입니다."); + } + + Claims claims = jwtTokenProvider.parseToken(refreshToken); + String kakaoId = claims.getSubject(); + + String storedRefreshToken = memberCommandService.getRefreshToken(kakaoId); + + if (storedRefreshToken == null) { + log.warn("Redis에서 Refresh Token Not Found - Kakao ID: {}", kakaoId); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Refresh Token이 만료되었거나 존재하지 않습니다."); + } + if (!refreshToken.equals(storedRefreshToken)) { + log.warn("Refresh Token 불일치 - Kakao ID: {}", kakaoId); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Refresh Token이 일치하지 않습니다."); + } + + String newAccessToken = jwtTokenProvider.generateAccessToken(kakaoId); + Map response = new HashMap<>(); + response.put("access_token", newAccessToken); + + return ResponseEntity.ok(response); + } + @PatchMapping("/complete") public ResponseEntity completeRegistration(@RequestBody RequestAdditionalRegisterVO info, @RequestHeader(HttpHeaders.AUTHORIZATION) String token) { String kakaoId = securityUtil.getKakaoIdFromToken(token.replace("Bearer ", "")); diff --git a/src/main/java/bst/bobsoolting/member/command/application/controller/docs/MemberCommandControllerDocs.java b/src/main/java/bst/bobsoolting/member/command/application/controller/docs/MemberCommandControllerDocs.java index 2010a23..f84823a 100644 --- a/src/main/java/bst/bobsoolting/member/command/application/controller/docs/MemberCommandControllerDocs.java +++ b/src/main/java/bst/bobsoolting/member/command/application/controller/docs/MemberCommandControllerDocs.java @@ -1,31 +1,48 @@ package bst.bobsoolting.member.command.application.controller.docs; import bst.bobsoolting.member.command.domain.vo.request.RequestAdditionalRegisterVO; +import bst.bobsoolting.member.command.domain.vo.request.RequestKakaoAuthVO; +import bst.bobsoolting.member.command.domain.vo.request.RequestRefreshTokenVO; import bst.bobsoolting.member.command.domain.vo.request.RequestUpdateProfileVO; import bst.bobsoolting.member.command.domain.vo.response.ResponseCreateMemberVO; import bst.bobsoolting.member.command.domain.vo.response.ResponseProfileVO; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.Map; - @Tag(name = "회원 API", description = "회원 관리 관련 API 문서") @RequestMapping("api/member") public interface MemberCommandControllerDocs { - @Operation(summary = "카카오 로그인", description = "카카오 OAuth 로그인 및 회원 자동 등록 API (우선 redirect-uri 에서 인가 코드를 받아온 뒤 사용") + @Operation(summary = "카카오 로그인", description = "카카오 OAuth 로그인 및 회원 자동 등록 API (우선 redirect-uri 에서 인가 코드를 받아온 뒤 사용)") @ApiResponses({ @ApiResponse(responseCode = "200", description = "로그인 성공"), @ApiResponse(responseCode = "400", description = "잘못된 요청"), @ApiResponse(responseCode = "500", description = "서버 오류") }) @PostMapping("/auth/kakao") - ResponseEntity kakaoLogin(@RequestBody Map request); + ResponseEntity kakaoLogin( + @RequestBody @io.swagger.v3.oas.annotations.parameters.RequestBody( + content = @Content(schema = @Schema(implementation = RequestKakaoAuthVO.class)) + ) RequestKakaoAuthVO request); + + @Operation(summary = "액세스 토큰 갱신", description = "리프레시 토큰을 이용하여 새 액세스 토큰을 발급받음") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "토큰 갱신 성공"), + @ApiResponse(responseCode = "401", description = "유효하지 않은 리프레시 토큰"), + @ApiResponse(responseCode = "500", description = "서버 오류") + }) + @PostMapping("/auth/refresh") + ResponseEntity refreshAccessToken( + @RequestBody @io.swagger.v3.oas.annotations.parameters.RequestBody( + content = @Content(schema = @Schema(implementation = RequestRefreshTokenVO.class)) + ) RequestRefreshTokenVO request); @Operation(summary = "추가 회원가입 정보 등록", description = "추가 회원가입 정보를 등록하는 API (신규 회원 추가 정보 입력)") @ApiResponses({ @@ -34,7 +51,9 @@ public interface MemberCommandControllerDocs { @ApiResponse(responseCode = "500", description = "서버 오류") }) @PatchMapping("/complete") - ResponseEntity completeRegistration(@RequestBody RequestAdditionalRegisterVO info, @RequestHeader(HttpHeaders.AUTHORIZATION) String token); + ResponseEntity completeRegistration( + @RequestBody RequestAdditionalRegisterVO info, + @RequestHeader(HttpHeaders.AUTHORIZATION) String token); @Operation(summary = "회원 정보 수정", description = "회원 프로필을 수정하는 API") @ApiResponses({ @@ -43,5 +62,7 @@ public interface MemberCommandControllerDocs { @ApiResponse(responseCode = "500", description = "서버 오류") }) @PatchMapping("/profile") - ResponseEntity updateProfile(@RequestBody RequestUpdateProfileVO updateInfo, @RequestHeader(HttpHeaders.AUTHORIZATION) String token); -} + ResponseEntity updateProfile( + @RequestBody RequestUpdateProfileVO updateInfo, + @RequestHeader(HttpHeaders.AUTHORIZATION) String token); +} \ No newline at end of file diff --git a/src/main/java/bst/bobsoolting/member/command/application/service/MemberCommandService.java b/src/main/java/bst/bobsoolting/member/command/application/service/MemberCommandService.java index e27a5ed..7b50acd 100644 --- a/src/main/java/bst/bobsoolting/member/command/application/service/MemberCommandService.java +++ b/src/main/java/bst/bobsoolting/member/command/application/service/MemberCommandService.java @@ -15,7 +15,11 @@ public interface MemberCommandService { MemberDTO getKakaoUserInfo(String accessToken); @Transactional - MemberDTO createOrUpdateMember(String kakaoId, String nickname); + MemberDTO createOrUpdateMember(String kakaoId); MemberDTO updateMemberAdditionalInfo(String kakaoId, RequestAdditionalRegisterVO info); + + void storeRefreshToken(String kakaoId, String refreshToken); + + String getRefreshToken(String kakaoId); } diff --git a/src/main/java/bst/bobsoolting/member/command/application/service/MemberCommandServiceImpl.java b/src/main/java/bst/bobsoolting/member/command/application/service/MemberCommandServiceImpl.java index 455abdf..b6d310b 100644 --- a/src/main/java/bst/bobsoolting/member/command/application/service/MemberCommandServiceImpl.java +++ b/src/main/java/bst/bobsoolting/member/command/application/service/MemberCommandServiceImpl.java @@ -14,6 +14,8 @@ import bst.bobsoolting.member.query.repository.MemberMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; import org.springframework.http.*; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -22,11 +24,10 @@ import org.springframework.web.client.RestTemplate; import java.time.LocalDate; -import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; -import java.util.Date; import java.util.Map; import java.util.UUID; +import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; @Slf4j @@ -38,6 +39,7 @@ public class MemberCommandServiceImpl implements MemberCommandService { private final MemberMapper memberMapper; private final MemberConverter memberConverter; private final KakaoProperties kakaoProperties; + private final StringRedisTemplate redisTemplate; @Override @Transactional @@ -62,6 +64,16 @@ public MemberDTO updateMemberAdditionalInfo(String kakaoId, RequestAdditionalReg return memberConverter.fromEntityToDTO(existingMember); } + @Override + public void storeRefreshToken(String kakaoId, String refreshToken) { + ValueOperations ops = redisTemplate.opsForValue(); + + String key = "refresh_token:" + kakaoId; + ops.set(key, refreshToken, 7, TimeUnit.DAYS); + + log.info("✅ Refresh Token 저장 완료 - Kakao ID: {}, Token: {}", kakaoId, refreshToken); + } + @Override @Transactional public MemberDTO updateMemberProfile(String kakaoId, RequestUpdateProfileVO updateInfo) { @@ -111,6 +123,8 @@ public String getKakaoAccessToken(String code) { } String accessToken = (String) response.getBody().get("access_token"); + log.info("✅ 카카오에서 받은 Access Token: {}", accessToken); // <--- 로그 추가! + if (accessToken == null) { throw new CommonException(ErrorCode.INVALID_REQUEST_BODY); } @@ -131,21 +145,20 @@ public MemberDTO getKakaoUserInfo(String accessToken) { HttpEntity request = new HttpEntity<>(headers); ResponseEntity response = restTemplate.exchange(userInfoUrl, HttpMethod.GET, request, Map.class); - if (response.getStatusCode() != HttpStatus.OK || response.getBody() == null) { - throw new CommonException(ErrorCode.INTERNAL_SERVER_ERROR); - } + if (response.getStatusCode() != HttpStatus.OK || response.getBody() == null) throw new CommonException(ErrorCode.INTERNAL_SERVER_ERROR); Map responseBody = response.getBody(); - Map kakaoAccount = (Map) responseBody.get("kakao_account"); + log.info("✅ 카카오 사용자 정보: {}", responseBody); String kakaoId = String.valueOf(responseBody.get("id")); - String nickname = (String) ((Map) kakaoAccount.get("profile")).get("nickname"); - return createOrUpdateMember(kakaoId, nickname); + if (kakaoId == null) throw new CommonException(ErrorCode.INVALID_REQUEST_BODY); + + return createOrUpdateMember(kakaoId); } @Override - public MemberDTO createOrUpdateMember(String kakaoId, String nickname) { + public MemberDTO createOrUpdateMember(String kakaoId) { Member existingMember = memberMapper.findByKakaoId(kakaoId); if (existingMember == null) { @@ -153,46 +166,31 @@ public MemberDTO createOrUpdateMember(String kakaoId, String nickname) { Member newMember = Member.builder() .memberId(memberId) .kakaoId(kakaoId) - .nickname(nickname) + .nickname(null) .profileImage(null) - .gender(null) + .gender(MemberGender.DEFAULT) .birth(null) .university(null) .department(null) - .studentNumber(0) + .studentNumber(null) .rating(0.0f) - .memberRole(null) + .memberRole(MemberRole.ROLE_USER) .build(); memberRepository.save(newMember); log.info("신규 회원 생성: kakaoId={}, memberId={}", kakaoId, memberId); return memberConverter.fromEntityToDTO(newMember); } else { - existingMember.setNickname(nickname); - memberRepository.save(existingMember); log.info("기존 회원 정보 업데이트: kakaoId={}, memberId={}", kakaoId, existingMember.getMemberId()); return memberConverter.fromEntityToDTO(existingMember); } } - private Member createMember(String kakaoId) { - return Member.builder() - .memberId(generateMemberId()) - .kakaoId(kakaoId) - .nickname("신규 유저") - .phone("") - .profileImage("") - .gender(MemberGender.DEFAULT) - .birth(new Date()) - .university("미입력") - .department("미입력") - .studentNumber(0) - .rating(0.0f) - .memberRole(MemberRole.ROLE_USER) - .createdAt(LocalDateTime.now()) - .updatedAt(LocalDateTime.now()) - .build(); + @Override + public String getRefreshToken(String kakaoId) { + ValueOperations ops = redisTemplate.opsForValue(); + return ops.get("refresh_token:" + kakaoId); } private String generateMemberId() { @@ -215,7 +213,7 @@ private void validateInput(RequestAdditionalRegisterVO info) { if (!Pattern.matches("^(010-\\d{4}-\\d{4})$", info.getPhone())) { throw new CommonException(ErrorCode.INVALID_INPUT_VALUE); } - if (!info.getGender().name().matches("MAN|WOMAN")) { + if (!info.getGender().name().matches("MALE|FEMALE")) { throw new CommonException(ErrorCode.INVALID_INPUT_VALUE); } } diff --git a/src/main/java/bst/bobsoolting/member/command/domain/vo/request/RequestKakaoAuthVO.java b/src/main/java/bst/bobsoolting/member/command/domain/vo/request/RequestKakaoAuthVO.java new file mode 100644 index 0000000..c4c37d3 --- /dev/null +++ b/src/main/java/bst/bobsoolting/member/command/domain/vo/request/RequestKakaoAuthVO.java @@ -0,0 +1,12 @@ +package bst.bobsoolting.member.command.domain.vo.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class RequestKakaoAuthVO { + @Schema(description = "카카오에서 받은 인가 코드", example = "abcdef1234567890") + private String code; +} \ No newline at end of file diff --git a/src/main/java/bst/bobsoolting/member/command/domain/vo/request/RequestRefreshTokenVO.java b/src/main/java/bst/bobsoolting/member/command/domain/vo/request/RequestRefreshTokenVO.java new file mode 100644 index 0000000..8e3ffa0 --- /dev/null +++ b/src/main/java/bst/bobsoolting/member/command/domain/vo/request/RequestRefreshTokenVO.java @@ -0,0 +1,12 @@ +package bst.bobsoolting.member.command.domain.vo.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class RequestRefreshTokenVO { + @Schema(description = "리프레시 토큰", example = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI1MTM...") + private String refresh_token; +} \ No newline at end of file diff --git a/src/main/java/bst/bobsoolting/member/query/controller/MemberQueryController.java b/src/main/java/bst/bobsoolting/member/query/controller/MemberQueryController.java index cf20977..5009874 100644 --- a/src/main/java/bst/bobsoolting/member/query/controller/MemberQueryController.java +++ b/src/main/java/bst/bobsoolting/member/query/controller/MemberQueryController.java @@ -39,7 +39,7 @@ public ResponseEntity> loginSuccess(HttpServletRequest reque } log.info("✅ JWT 기반 로그인 성공: {}", kakaoId); - String token = jwtTokenProvider.generateToken(kakaoId); + String token = jwtTokenProvider.generateAccessToken(kakaoId); log.info("✅ 발급된 JWT: {}", token); Map response = new HashMap<>(); diff --git a/src/main/java/bst/bobsoolting/member/query/service/MemberQueryService.java b/src/main/java/bst/bobsoolting/member/query/service/MemberQueryService.java index b9ff645..d906490 100644 --- a/src/main/java/bst/bobsoolting/member/query/service/MemberQueryService.java +++ b/src/main/java/bst/bobsoolting/member/query/service/MemberQueryService.java @@ -7,4 +7,6 @@ public interface MemberQueryService { MemberDTO getMemberProfile(String kakaoId); MemberDTO getMemberDetail(String kakaoId); + + String getMemberIdByKakaoId(String kakaoId); } diff --git a/src/main/java/bst/bobsoolting/member/query/service/MemberQueryServiceImpl.java b/src/main/java/bst/bobsoolting/member/query/service/MemberQueryServiceImpl.java index 483293a..459617e 100644 --- a/src/main/java/bst/bobsoolting/member/query/service/MemberQueryServiceImpl.java +++ b/src/main/java/bst/bobsoolting/member/query/service/MemberQueryServiceImpl.java @@ -37,4 +37,10 @@ public MemberDTO getMemberProfile(String kakaoId) { public MemberDTO getMemberDetail(String kakaoId) { return getMemberProfile(kakaoId); } + + @Override + public String getMemberIdByKakaoId(String kakaoId) { + Member member = memberMapper.findByKakaoId(kakaoId); + return (member != null) ? member.getMemberId() : null; + } } diff --git a/src/main/java/bst/bobsoolting/post/command/application/controller/PostCommandController.java b/src/main/java/bst/bobsoolting/post/command/application/controller/PostCommandController.java index c0f0af0..7712e98 100644 --- a/src/main/java/bst/bobsoolting/post/command/application/controller/PostCommandController.java +++ b/src/main/java/bst/bobsoolting/post/command/application/controller/PostCommandController.java @@ -1,47 +1,64 @@ package bst.bobsoolting.post.command.application.controller; +import bst.bobsoolting.member.query.service.MemberQueryService; +import bst.bobsoolting.post.command.application.controller.docs.PostCommandControllerDocs; import bst.bobsoolting.post.command.application.dto.PostDTO; import bst.bobsoolting.post.command.application.service.PostCommandService; -import bst.bobsoolting.post.command.vo.request.RequestUpdatePostVO; +import bst.bobsoolting.post.command.domain.vo.request.RequestCreatePostVO; +import bst.bobsoolting.post.command.domain.vo.request.RequestUpdatePostVO; +import bst.bobsoolting.util.SecurityUtil; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.http.HttpHeaders; + @RestController -@RequestMapping("/api/post/command") @RequiredArgsConstructor -public class PostCommandController { +public class PostCommandController implements PostCommandControllerDocs { private final PostCommandService postCommandService; + private final MemberQueryService memberQueryService; + private final SecurityUtil securityUtil; + + @PostMapping + public ResponseEntity createPost(@RequestHeader(HttpHeaders.AUTHORIZATION) String token, @RequestBody RequestCreatePostVO request) { + String kakaoId = securityUtil.getKakaoIdFromToken(token.replace("Bearer ", "")); + String memberId = memberQueryService.getMemberIdByKakaoId(kakaoId); + if (memberId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null); - /** - * 게시글 생성 - */ - @PostMapping("/") - public ResponseEntity createPost(@RequestBody PostDTO postDTO) { - PostDTO created = postCommandService.createPost(postDTO); + PostDTO created = postCommandService.createPost(request, memberId); return ResponseEntity.ok(created); } - /** - * 게시글 수정 - */ @PutMapping("/{postId}") - public ResponseEntity updatePost(@PathVariable Long postId, - @RequestBody RequestUpdatePostVO postDTO) { - postDTO.setPostId(postId); - PostDTO updated = postCommandService.updatePost(postDTO); + public ResponseEntity updatePost(@RequestHeader(HttpHeaders.AUTHORIZATION) String token, @PathVariable Long postId, @RequestBody RequestUpdatePostVO request) { + String kakaoId = securityUtil.getKakaoIdFromToken(token.replace("Bearer ", "")); + String memberId = memberQueryService.getMemberIdByKakaoId(kakaoId); + if (memberId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null); + + PostDTO updated = postCommandService.updatePost(memberId, postId, request); return ResponseEntity.ok(updated); } - /** - * 게시글 삭제 (소프트 딜리트) - */ - @PatchMapping("/{postId}") - public ResponseEntity softDeletePost(@PathVariable("postId") Long postId) { - postCommandService.deletePost(postId); + @PatchMapping("/{postId}/status") + public ResponseEntity updateRecruitmentStatus(@RequestHeader(HttpHeaders.AUTHORIZATION) String token, @PathVariable("postId") Long postId) { + String kakaoId = securityUtil.getKakaoIdFromToken(token.replace("Bearer ", "")); + String memberId = memberQueryService.getMemberIdByKakaoId(kakaoId); + if (memberId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null); + + postCommandService.updateRecruitmentStatus(memberId, postId); return ResponseEntity.noContent().build(); } -} + @PatchMapping("/{postId}") + public ResponseEntity softDeletePost(@RequestHeader(HttpHeaders.AUTHORIZATION) String token, @PathVariable("postId") Long postId) { + String kakaoId = securityUtil.getKakaoIdFromToken(token.replace("Bearer ", "")); + String memberId = memberQueryService.getMemberIdByKakaoId(kakaoId); + if (memberId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null); + postCommandService.deletePost(memberId, postId); + return ResponseEntity.noContent().build(); + } +} \ No newline at end of file diff --git a/src/main/java/bst/bobsoolting/post/command/application/controller/docs/PostCommandControllerDocs.java b/src/main/java/bst/bobsoolting/post/command/application/controller/docs/PostCommandControllerDocs.java new file mode 100644 index 0000000..0caddc1 --- /dev/null +++ b/src/main/java/bst/bobsoolting/post/command/application/controller/docs/PostCommandControllerDocs.java @@ -0,0 +1,77 @@ +package bst.bobsoolting.post.command.application.controller.docs; + +import bst.bobsoolting.post.command.application.dto.PostDTO; +import bst.bobsoolting.post.command.domain.vo.request.RequestCreatePostVO; +import bst.bobsoolting.post.command.domain.vo.request.RequestUpdatePostVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "게시글 API", description = "게시글 생성, 수정, 삭제 관련 API") +@RequestMapping("/api/post") +public interface PostCommandControllerDocs { + + @Operation(summary = "게시글 생성", description = "새로운 게시글을 생성합니다. JWT에서 memberId를 자동 추출합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "게시글 생성 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청"), + @ApiResponse(responseCode = "500", description = "서버 오류") + }) + @PostMapping + ResponseEntity createPost( + @RequestHeader(HttpHeaders.AUTHORIZATION) String token, + @RequestBody @io.swagger.v3.oas.annotations.parameters.RequestBody( + content = @Content(schema = @Schema(implementation = RequestCreatePostVO.class)) + ) RequestCreatePostVO request + ); + + @Operation(summary = "게시글 수정", description = "기존 게시글을 수정합니다. JWT에서 memberId를 자동 추출하고 본인 게시글인지 검증합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "게시글 수정 성공"), + @ApiResponse(responseCode = "404", description = "해당 게시글이 존재하지 않음"), + @ApiResponse(responseCode = "403", description = "수정 권한 없음"), + @ApiResponse(responseCode = "500", description = "서버 오류") + }) + @PutMapping("/{postId}") + ResponseEntity updatePost( + @RequestHeader(HttpHeaders.AUTHORIZATION) String token, + @Parameter(description = "게시글 ID", required = true) @PathVariable Long postId, + @RequestBody @io.swagger.v3.oas.annotations.parameters.RequestBody( + content = @Content(schema = @Schema(implementation = RequestUpdatePostVO.class)) + ) RequestUpdatePostVO postDTO + ); + + @Operation(summary = "게시글 모집 상태 변경", description = "게시글의 모집 상태를 '모집 완료'로 변경합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "모집 상태 변경 성공"), + @ApiResponse(responseCode = "404", description = "해당 게시글이 존재하지 않음"), + @ApiResponse(responseCode = "403", description = "변경 권한 없음"), + @ApiResponse(responseCode = "500", description = "서버 오류") + }) + @PatchMapping("/{postId}/status") + ResponseEntity updateRecruitmentStatus( + @RequestHeader(HttpHeaders.AUTHORIZATION) String token, + @Parameter(description = "게시글 ID", required = true) @PathVariable("postId") Long postId + ); + + @Operation(summary = "게시글 삭제 (소프트 삭제)", description = "게시글을 소프트 딜리트합니다. JWT에서 memberId를 자동 추출하고 본인 게시글인지 검증합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "게시글 삭제 성공"), + @ApiResponse(responseCode = "404", description = "해당 게시글이 존재하지 않음"), + @ApiResponse(responseCode = "403", description = "삭제 권한 없음"), + @ApiResponse(responseCode = "500", description = "서버 오류") + }) + @PatchMapping("/{postId}") + ResponseEntity softDeletePost( + @RequestHeader(HttpHeaders.AUTHORIZATION) String token, // ✅ Authorization 헤더 추가 + @Parameter(description = "게시글 ID", required = true) @PathVariable("postId") Long postId + ); + +} diff --git a/src/main/java/bst/bobsoolting/post/command/application/dto/PostDTO.java b/src/main/java/bst/bobsoolting/post/command/application/dto/PostDTO.java index 6d399ac..f28c7e1 100644 --- a/src/main/java/bst/bobsoolting/post/command/application/dto/PostDTO.java +++ b/src/main/java/bst/bobsoolting/post/command/application/dto/PostDTO.java @@ -1,8 +1,11 @@ package bst.bobsoolting.post.command.application.dto; +import bst.bobsoolting.post.command.domain.aggregate.Category; +import bst.bobsoolting.post.command.domain.aggregate.RecruitmentStatus; import lombok.*; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.List; @Getter @Setter @@ -12,13 +15,12 @@ @ToString public class PostDTO { private Long postId; - private String category; // Enum을 문자열로 전달 (Converter에서 변환) + private Category category; private String title; private String content; - private String images; private Integer maxParticipants; - private String participants; - private String recruitmentStatus; + private List participants; + private RecruitmentStatus recruitmentStatus; private LocalDate date; private String location; private Boolean postStatus; diff --git a/src/main/java/bst/bobsoolting/post/command/application/mapper/PostConverter.java b/src/main/java/bst/bobsoolting/post/command/application/mapper/PostConverter.java index 33aff76..ba3fbf8 100644 --- a/src/main/java/bst/bobsoolting/post/command/application/mapper/PostConverter.java +++ b/src/main/java/bst/bobsoolting/post/command/application/mapper/PostConverter.java @@ -1,31 +1,55 @@ package bst.bobsoolting.post.command.application.mapper; import bst.bobsoolting.post.command.application.dto.PostDTO; -import bst.bobsoolting.post.command.domain.aggregate.Category; -import bst.bobsoolting.post.command.domain.aggregate.Post; +import bst.bobsoolting.post.command.domain.aggregate.RecruitmentStatus; +import bst.bobsoolting.post.command.domain.aggregate.entity.Post; +import bst.bobsoolting.post.command.domain.vo.request.RequestCreatePostVO; +import bst.bobsoolting.post.command.domain.vo.request.RequestUpdatePostVO; import org.springframework.stereotype.Component; +import java.time.LocalDateTime; +import java.util.List; + @Component public class PostConverter { // DTO -> Entity 변환 - public Post toEntity(PostDTO dto) { - if (dto == null) return null; + public Post fromCreateVOToEntity(RequestCreatePostVO request, String memberId) { + if (request == null) return null; + + return Post.builder() + .category(request.getCategory() != null ? request.getCategory() : null) + .title(request.getTitle()) + .content(request.getContent()) + .maxParticipants(request.getMaxParticipants()) + .participants(List.of()) + .recruitmentStatus(RecruitmentStatus.RECRUITING) + .date(request.getDate()) + .location(request.getLocation()) + .postStatus(true) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .memberId(memberId) + .build(); + } + + public Post fromUpdateVOToEntity(Post existingPost, RequestUpdatePostVO updateVO) { + if (existingPost == null) return null; + return Post.builder() - .postId(dto.getPostId()) - .category(dto.getCategory() != null ? Category.valueOf(dto.getCategory()) : null) - .title(dto.getTitle()) - .content(dto.getContent()) - .images(dto.getImages()) - .maxParticipants(dto.getMaxParticipants()) - .participants(dto.getParticipants()) - .recruitmentStatus(dto.getRecruitmentStatus()) - .date(dto.getDate()) - .location(dto.getLocation()) - .postStatus(dto.getPostStatus()) - .createdAt(dto.getCreatedAt()) - .updatedAt(dto.getUpdatedAt()) - .memberId(dto.getMemberId()) + .postId(existingPost.getPostId()) + .category(existingPost.getCategory()) + .title(updateVO.getTitle() != null ? updateVO.getTitle() : existingPost.getTitle()) + .content(updateVO.getContent() != null ? updateVO.getContent() : existingPost.getContent()) + .maxParticipants(updateVO.getMaxParticipants() != null ? updateVO.getMaxParticipants() : existingPost.getMaxParticipants()) + .participants(existingPost.getParticipants()) + .recruitmentStatus(updateVO.getRecruitmentStatus() != null ? updateVO.getRecruitmentStatus() : existingPost.getRecruitmentStatus()) + .date(updateVO.getDate() != null ? updateVO.getDate() : existingPost.getDate()) + .location(updateVO.getLocation() != null ? updateVO.getLocation() : existingPost.getLocation()) + .postStatus(existingPost.getPostStatus()) + .createdAt(existingPost.getCreatedAt()) + .updatedAt(LocalDateTime.now()) + .memberId(existingPost.getMemberId()) .build(); } @@ -34,10 +58,9 @@ public PostDTO toDTO(Post post) { if (post == null) return null; return PostDTO.builder() .postId(post.getPostId()) - .category(post.getCategory() != null ? post.getCategory().name() : null) + .category(post.getCategory() != null ? post.getCategory() : null) .title(post.getTitle()) .content(post.getContent()) - .images(post.getImages()) .maxParticipants(post.getMaxParticipants()) .participants(post.getParticipants()) .recruitmentStatus(post.getRecruitmentStatus()) diff --git a/src/main/java/bst/bobsoolting/post/command/application/service/PostCommandService.java b/src/main/java/bst/bobsoolting/post/command/application/service/PostCommandService.java index c9775b2..e5b1e85 100644 --- a/src/main/java/bst/bobsoolting/post/command/application/service/PostCommandService.java +++ b/src/main/java/bst/bobsoolting/post/command/application/service/PostCommandService.java @@ -1,13 +1,20 @@ package bst.bobsoolting.post.command.application.service; import bst.bobsoolting.post.command.application.dto.PostDTO; -import bst.bobsoolting.post.command.vo.request.RequestUpdatePostVO; +import bst.bobsoolting.post.command.domain.vo.request.RequestCreatePostVO; +import bst.bobsoolting.post.command.domain.vo.request.RequestUpdatePostVO; +import org.springframework.transaction.annotation.Transactional; public interface PostCommandService { - PostDTO createPost(PostDTO postDTO); + PostDTO createPost(RequestCreatePostVO request, String memberId); - PostDTO updatePost(RequestUpdatePostVO updateVO); + @Transactional + PostDTO updatePost(String memberId, Long postId, RequestUpdatePostVO updateVO); - void deletePost(Long postId); + @Transactional + void updateRecruitmentStatus(String memberId, Long postId); + + @Transactional + void deletePost(String memberId, Long postId); } \ No newline at end of file diff --git a/src/main/java/bst/bobsoolting/post/command/application/service/PostCommandServiceImpl.java b/src/main/java/bst/bobsoolting/post/command/application/service/PostCommandServiceImpl.java index 0ace00e..f76ac8e 100644 --- a/src/main/java/bst/bobsoolting/post/command/application/service/PostCommandServiceImpl.java +++ b/src/main/java/bst/bobsoolting/post/command/application/service/PostCommandServiceImpl.java @@ -1,15 +1,13 @@ package bst.bobsoolting.post.command.application.service; -import lombok.extern.java.Log; +import bst.bobsoolting.post.command.domain.aggregate.RecruitmentStatus; +import bst.bobsoolting.post.command.domain.vo.request.RequestCreatePostVO; import lombok.extern.slf4j.Slf4j; -import bst.bobsoolting.member.command.application.service.MemberCommandServiceImpl; -import bst.bobsoolting.member.command.domain.aggregate.entity.Member; -import bst.bobsoolting.member.query.repository.MemberMapper; import bst.bobsoolting.post.command.application.dto.PostDTO; import bst.bobsoolting.post.command.application.mapper.PostConverter; -import bst.bobsoolting.post.command.domain.aggregate.Post; +import bst.bobsoolting.post.command.domain.aggregate.entity.Post; import bst.bobsoolting.post.command.domain.repository.PostRepository; -import bst.bobsoolting.post.command.vo.request.RequestUpdatePostVO; +import bst.bobsoolting.post.command.domain.vo.request.RequestUpdatePostVO; import bst.bobsoolting.post.query.repository.PostMapper; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -23,17 +21,12 @@ public class PostCommandServiceImpl implements PostCommandService { private final PostRepository postRepository; private final PostMapper postMapper; - private final PostConverter postConverter; // + private final PostConverter postConverter; @Override @Transactional - public PostDTO createPost(PostDTO postDTO) { - postDTO.setPostStatus(true); - postDTO.setCreatedAt(LocalDateTime.now()); - postDTO.setUpdatedAt(LocalDateTime.now()); - - // static 메서드 대신 인스턴스 메서드 호출 - Post post = postConverter.toEntity(postDTO); + public PostDTO createPost(RequestCreatePostVO request, String memberId) { + Post post = postConverter.fromCreateVOToEntity(request, memberId); postRepository.save(post); return postConverter.toDTO(post); @@ -41,59 +34,40 @@ public PostDTO createPost(PostDTO postDTO) { @Override @Transactional - public PostDTO updatePost(RequestUpdatePostVO updateVO) { - Post existing = postMapper.findByPostId(updateVO.getPostId()); - if (existing == null) { - throw new RuntimeException("Post not found with id: " + updateVO.getPostId()); - } + public PostDTO updatePost(String memberId, Long postId, RequestUpdatePostVO updateVO) { + Post existingPost = postMapper.findByPostId(postId); + if (existingPost == null) throw new RuntimeException("Post not found with id: " + postId); + if (!existingPost.getMemberId().equals(memberId)) throw new RuntimeException("You are not authorized to update this post."); - Post updatedPost = Post.builder() - .postId(existing.getPostId()) - .category(existing.getCategory()) - .title(updateVO.getTitle() != null ? updateVO.getTitle() : existing.getTitle()) - .content(updateVO.getContent() != null ? updateVO.getContent() : existing.getContent()) - .images(updateVO.getImages() != null ? updateVO.getImages() : existing.getImages()) - .maxParticipants(updateVO.getMaxParticipants() != null ? updateVO.getMaxParticipants() : existing.getMaxParticipants()) - .participants(existing.getParticipants()) // 참여자 목록은 그대로 유지 - .recruitmentStatus(updateVO.getRecruitmentStatus() != null ? updateVO.getRecruitmentStatus() : existing.getRecruitmentStatus()) - .date(updateVO.getDate() != null ? updateVO.getDate() : existing.getDate()) - .location(updateVO.getLocation() != null ? updateVO.getLocation() : existing.getLocation()) - .postStatus(existing.getPostStatus()) // 이 값은 변경하지 않는다고 가정 - .createdAt(existing.getCreatedAt()) - .updatedAt(LocalDateTime.now()) - .memberId(existing.getMemberId()) - .build(); + Post updatedPost = postConverter.fromUpdateVOToEntity(existingPost, updateVO); postRepository.save(updatedPost); - return postConverter.toDTO(updatedPost); } @Override @Transactional - public void deletePost(Long postId) { + public void updateRecruitmentStatus(String memberId, Long postId) { Post existing = postMapper.findByPostId(postId); - if(existing == null) { - throw new RuntimeException("Post not found with id: " + postId); - } + if (existing == null) throw new RuntimeException("Post not found with id: " + postId); + if (!existing.getMemberId().equals(memberId)) throw new RuntimeException("You are not authorized to update this post."); - Post updatedPost = Post.builder() - .postId(existing.getPostId()) - .category(existing.getCategory()) - .title(existing.getTitle()) - .content(existing.getContent()) - .images(existing.getImages()) - .maxParticipants(existing.getMaxParticipants()) - .participants(existing.getParticipants()) - .recruitmentStatus(existing.getRecruitmentStatus()) - .date(existing.getDate()) - .location(existing.getLocation()) - .postStatus(false) // 소프트 딜리트를 위해 false로 설정 - .createdAt(existing.getCreatedAt()) - .updatedAt(LocalDateTime.now()) - .memberId(existing.getMemberId()) - .build(); + existing.setRecruitmentStatus(RecruitmentStatus.CLOSED); + existing.setUpdatedAt(LocalDateTime.now()); - postRepository.save(updatedPost); + postRepository.save(existing); + } + + @Override + @Transactional + public void deletePost(String memberId, Long postId) { + Post existing = postMapper.findByPostId(postId); + if (existing == null) throw new RuntimeException("Post not found with id: " + postId); + if (!existing.getMemberId().equals(memberId)) throw new RuntimeException("You are not authorized to delete this post."); + + existing.setPostStatus(false); + existing.setUpdatedAt(LocalDateTime.now()); + + postRepository.save(existing); } } diff --git a/src/main/java/bst/bobsoolting/post/command/domain/aggregate/Category.java b/src/main/java/bst/bobsoolting/post/command/domain/aggregate/Category.java index 5495531..ef1f7e0 100644 --- a/src/main/java/bst/bobsoolting/post/command/domain/aggregate/Category.java +++ b/src/main/java/bst/bobsoolting/post/command/domain/aggregate/Category.java @@ -1,7 +1,31 @@ package bst.bobsoolting.post.command.domain.aggregate; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + public enum Category { - BOB, - SOOL, - TING -} \ No newline at end of file + FOOD("FOOD"), + DRINK("DRINK"), + MEETING("MEETING"); + + private final String value; + + Category(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } + + @JsonCreator + public static Category fromValue(String value) { + for (Category category : Category.values()) { + if (category.value.equalsIgnoreCase(value)) { + return category; + } + } + throw new IllegalArgumentException("Invalid category: " + value); + } +} diff --git a/src/main/java/bst/bobsoolting/post/command/domain/aggregate/Post.java b/src/main/java/bst/bobsoolting/post/command/domain/aggregate/Post.java deleted file mode 100644 index 81c298a..0000000 --- a/src/main/java/bst/bobsoolting/post/command/domain/aggregate/Post.java +++ /dev/null @@ -1,41 +0,0 @@ -package bst.bobsoolting.post.command.domain.aggregate; - -import lombok.*; - -import java.time.LocalDate; -import java.time.LocalDateTime; - -import jakarta.persistence.*; - - -@Entity -@Table(name = "post") -@Getter -@AllArgsConstructor -@NoArgsConstructor -@ToString -@Builder -public class Post { - - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long postId; // PK (AUTO_INCREMENT) - - @Enumerated(EnumType.STRING) - private Category category; // Enum (예: STUDY, MEETUP 등) - - private String title; - private String content; - private String images; // JSON 컬럼 - private Integer maxParticipants; - private String participants; // JSON 컬럼 - private String recruitmentStatus; - private LocalDate date; - private String location; - private Boolean postStatus; - private LocalDateTime createdAt; - private LocalDateTime updatedAt; - private String memberId; // 외래키 - -} diff --git a/src/main/java/bst/bobsoolting/post/command/domain/aggregate/RecruitmentStatus.java b/src/main/java/bst/bobsoolting/post/command/domain/aggregate/RecruitmentStatus.java new file mode 100644 index 0000000..b547a90 --- /dev/null +++ b/src/main/java/bst/bobsoolting/post/command/domain/aggregate/RecruitmentStatus.java @@ -0,0 +1,30 @@ +package bst.bobsoolting.post.command.domain.aggregate; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public enum RecruitmentStatus { + RECRUITING("RECRUITING"), + CLOSED("CLOSED"); + + private final String value; + + RecruitmentStatus(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } + + @JsonCreator + public static RecruitmentStatus fromValue(String value) { + for (RecruitmentStatus status : RecruitmentStatus.values()) { + if (status.value.equalsIgnoreCase(value)) { + return status; + } + } + throw new IllegalArgumentException("Invalid recruitment status: " + value); + } +} diff --git a/src/main/java/bst/bobsoolting/post/command/domain/aggregate/entity/Post.java b/src/main/java/bst/bobsoolting/post/command/domain/aggregate/entity/Post.java new file mode 100644 index 0000000..deb484b --- /dev/null +++ b/src/main/java/bst/bobsoolting/post/command/domain/aggregate/entity/Post.java @@ -0,0 +1,76 @@ +package bst.bobsoolting.post.command.domain.aggregate.entity; + +import bst.bobsoolting.post.command.domain.aggregate.Category; +import bst.bobsoolting.post.command.domain.aggregate.RecruitmentStatus; +import bst.bobsoolting.util.StringListConverter; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@Entity +@Table(name = "post") +@Getter +@AllArgsConstructor +@NoArgsConstructor +@ToString +@Builder +public class Post { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long postId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Category category; + + @Column(nullable = false, length = 255, columnDefinition = "VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci") + private String title; + + @Column(nullable = false, length = 3000, columnDefinition = "VARCHAR(3000) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci") + private String content; + + @Column(nullable = false) + private Integer maxParticipants; + + @Convert(converter = StringListConverter.class) + @Column(columnDefinition = "JSON") + private List participants; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private RecruitmentStatus recruitmentStatus; + + @Column(nullable = false) + private LocalDate date; + + @Column(nullable = false, length = 255) + private String location; + + @Column(nullable = false) + private Boolean postStatus = true; + + @Column(nullable = false) + private LocalDateTime createdAt; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + @Column(nullable = false) + private String memberId; + + public void setRecruitmentStatus(RecruitmentStatus recruitmentStatus) { + this.recruitmentStatus = recruitmentStatus; + } + + public void setPostStatus(Boolean postStatus) { + this.postStatus = postStatus; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} \ No newline at end of file diff --git a/src/main/java/bst/bobsoolting/post/command/domain/repository/PostRepository.java b/src/main/java/bst/bobsoolting/post/command/domain/repository/PostRepository.java index ab68c45..f7e2ecc 100644 --- a/src/main/java/bst/bobsoolting/post/command/domain/repository/PostRepository.java +++ b/src/main/java/bst/bobsoolting/post/command/domain/repository/PostRepository.java @@ -1,12 +1,10 @@ package bst.bobsoolting.post.command.domain.repository; -import bst.bobsoolting.post.command.domain.aggregate.Post; +import bst.bobsoolting.post.command.domain.aggregate.entity.Post; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; - - @Repository public interface PostRepository extends JpaRepository { diff --git a/src/main/java/bst/bobsoolting/post/command/domain/vo/request/RequestCreatePostVO.java b/src/main/java/bst/bobsoolting/post/command/domain/vo/request/RequestCreatePostVO.java new file mode 100644 index 0000000..9a03c19 --- /dev/null +++ b/src/main/java/bst/bobsoolting/post/command/domain/vo/request/RequestCreatePostVO.java @@ -0,0 +1,37 @@ +package bst.bobsoolting.post.command.domain.vo.request; + +import bst.bobsoolting.post.command.domain.aggregate.Category; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +@ToString +public class RequestCreatePostVO { + @Schema(description = "카테고리 (FOOD, DRINK, MEETING)", example = "FOOD") + private Category category; + + @Schema(description = "제목", example = "저녁 드실 분!") + private String title; + + @Schema(description = "내용", example = "3월 15일에 저녁 드실 분 구합니다.") + private String content; + + @Schema(description = "최대 참가자 수", example = "5") + @JsonProperty("max_participants") + private Integer maxParticipants; + + @Schema(description = "활동 날짜", example = "2025-03-15") + private LocalDate date; + + @Schema(description = "장소", example = "서울 강남구") + private String location; +} diff --git a/src/main/java/bst/bobsoolting/post/command/domain/vo/request/RequestUpdatePostVO.java b/src/main/java/bst/bobsoolting/post/command/domain/vo/request/RequestUpdatePostVO.java new file mode 100644 index 0000000..4b4cbed --- /dev/null +++ b/src/main/java/bst/bobsoolting/post/command/domain/vo/request/RequestUpdatePostVO.java @@ -0,0 +1,33 @@ +package bst.bobsoolting.post.command.domain.vo.request; + +import bst.bobsoolting.post.command.domain.aggregate.RecruitmentStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +import java.time.LocalDate; +import java.util.List; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +public class RequestUpdatePostVO { + @Schema(description = "제목", example = "수정된 제목입니다.") + private String title; + + @Schema(description = "내용", example = "수정된 내용입니다.") + private String content; + + @Schema(description = "최대 참가자 수", example = "10") + private Integer maxParticipants; + + @Schema(description = "모집 상태", example = "CLOSED") + private RecruitmentStatus recruitmentStatus; + + @Schema(description = "활동 날짜", example = "2025-04-01") + private LocalDate date; + + @Schema(description = "장소", example = "서울 마포구") + private String location; +} diff --git a/src/main/java/bst/bobsoolting/post/command/vo/request/RequestDeletePostVO.java b/src/main/java/bst/bobsoolting/post/command/vo/request/RequestDeletePostVO.java deleted file mode 100644 index fa85dd0..0000000 --- a/src/main/java/bst/bobsoolting/post/command/vo/request/RequestDeletePostVO.java +++ /dev/null @@ -1,32 +0,0 @@ -package bst.bobsoolting.post.command.vo.request; - -import java.time.LocalDateTime; - -import com.fasterxml.jackson.annotation.JsonProperty; - - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; -import lombok.ToString; - -@Setter -@Getter -@AllArgsConstructor -@NoArgsConstructor -@Builder -@ToString -public class RequestDeletePostVO { - // 업데이트할 게시글의 식별자 (수정 대상 확인용) - @JsonProperty("postId") - private Long postId; - - // 사용자가 수정 가능한 필드들 - @JsonProperty("postStatus") - private Boolean postStatus; - - @JsonProperty("UpdatedAt") - private LocalDateTime UpdatedAt; -} diff --git a/src/main/java/bst/bobsoolting/post/command/vo/request/RequestUpdatePostVO.java b/src/main/java/bst/bobsoolting/post/command/vo/request/RequestUpdatePostVO.java deleted file mode 100644 index c6a00f1..0000000 --- a/src/main/java/bst/bobsoolting/post/command/vo/request/RequestUpdatePostVO.java +++ /dev/null @@ -1,48 +0,0 @@ -package bst.bobsoolting.post.command.vo.request; - -import java.time.LocalDate; - -import com.fasterxml.jackson.annotation.JsonProperty; - - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; -import lombok.ToString; - -@Setter -@Getter -@AllArgsConstructor -@NoArgsConstructor -@Builder -@ToString -public class RequestUpdatePostVO { - // 업데이트할 게시글의 식별자 (수정 대상 확인용) - @JsonProperty("postId") - private Long postId; - - // 사용자가 수정 가능한 필드들 - @JsonProperty("title") - private String title; - - @JsonProperty("content") - private String content; - - @JsonProperty("images") - private String images; // JSON 문자열 형태로 처리할 수도 있습니다. - - @JsonProperty("maxParticipants") - private Integer maxParticipants; // 수정할 최대 참여 인원 - - @JsonProperty("recruitmentStatus") - private String recruitmentStatus; - - @JsonProperty("date") - private LocalDate date; - - @JsonProperty("location") - private String location; - -} diff --git a/src/main/java/bst/bobsoolting/post/query/controller/PostQueryController.java b/src/main/java/bst/bobsoolting/post/query/controller/PostQueryController.java index b73fdd8..e70379c 100644 --- a/src/main/java/bst/bobsoolting/post/query/controller/PostQueryController.java +++ b/src/main/java/bst/bobsoolting/post/query/controller/PostQueryController.java @@ -1,18 +1,27 @@ package bst.bobsoolting.post.query.controller; +import bst.bobsoolting.member.query.service.MemberQueryService; import bst.bobsoolting.post.command.application.dto.PostDTO; +import bst.bobsoolting.post.query.controller.docs.PostQueryControllerDocs; import bst.bobsoolting.post.query.service.PostQueryService; +import bst.bobsoolting.util.SecurityUtil; +import com.github.pagehelper.PageHelper; +import com.github.pagehelper.PageInfo; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; +import org.springframework.http.HttpHeaders; + @RestController -@RequestMapping("/api/post/query") @RequiredArgsConstructor -public class PostQueryController { +public class PostQueryController implements PostQueryControllerDocs { private final PostQueryService postQueryService; + private final MemberQueryService memberQueryService; + private final SecurityUtil securityUtil; @GetMapping("/{postId}") public ResponseEntity getPostById(@PathVariable("postId") Long postId) { @@ -21,29 +30,32 @@ public ResponseEntity getPostById(@PathVariable("postId") Long postId) } @GetMapping - public ResponseEntity> getAllPosts() { + public ResponseEntity> getAllPosts(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size) { + PageHelper.startPage(page, size); List posts = postQueryService.getAllPosts(); - return ResponseEntity.ok(posts); + return ResponseEntity.ok(new PageInfo<>(posts)); } - - // 새로운 검색 엔드포인트: 키워드를 쿼리 파라미터로 전달 (예: ?keyword=버거킹) + + @GetMapping("/category") + public ResponseEntity> getPostsByCategory(@RequestParam("category") String category, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size) { + PageHelper.startPage(page, size); + List posts = postQueryService.getPostsByCategory(category); + return ResponseEntity.ok(new PageInfo<>(posts)); + } + @GetMapping("/search") public ResponseEntity> searchPosts(@RequestParam("keyword") String keyword) { List posts = postQueryService.searchPostsByKeyword(keyword); return ResponseEntity.ok(posts); } - - // Category별 조회 엔드포인트 (예: ?category=BOB) - @GetMapping("/category") - public ResponseEntity> getPostsByCategory(@RequestParam("category") String category) { - List posts = postQueryService.getPostsByCategory(category); - return ResponseEntity.ok(posts); - } - - // 작성자(memberId)별 조회 엔드포인트 (예: ?memberId=test-user) + @GetMapping("/member") - public ResponseEntity> getPostsByMemberId(@RequestParam("memberId") String memberId) { + public ResponseEntity> getMyPosts(@RequestHeader(HttpHeaders.AUTHORIZATION) String token) { + String kakaoId = securityUtil.getKakaoIdFromToken(token.replace("Bearer ", "")); + String memberId = memberQueryService.getMemberIdByKakaoId(kakaoId); + if (memberId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null); List posts = postQueryService.getPostsByMemberId(memberId); return ResponseEntity.ok(posts); } } + diff --git a/src/main/java/bst/bobsoolting/post/query/controller/docs/PostQueryControllerDocs.java b/src/main/java/bst/bobsoolting/post/query/controller/docs/PostQueryControllerDocs.java new file mode 100644 index 0000000..3e25216 --- /dev/null +++ b/src/main/java/bst/bobsoolting/post/query/controller/docs/PostQueryControllerDocs.java @@ -0,0 +1,60 @@ +package bst.bobsoolting.post.query.controller.docs; + +import bst.bobsoolting.post.command.application.dto.PostDTO; +import com.github.pagehelper.PageInfo; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "게시글 조회 API", description = "게시글 조회 및 검색 관련 API") +@RequestMapping("/api/post") +public interface PostQueryControllerDocs { + + @Operation(summary = "게시글 단건 조회", description = "특정 ID를 가진 게시글을 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(schema = @Schema(implementation = PostDTO.class))), + @ApiResponse(responseCode = "404", description = "해당 게시글이 존재하지 않음"), + @ApiResponse(responseCode = "500", description = "서버 오류") + }) + @GetMapping("/{postId}") + ResponseEntity getPostById( + @Parameter(description = "게시글 ID", required = true) @PathVariable("postId") Long postId + ); + + @Operation(summary = "전체 게시글 조회 (페이지네이션)", description = "페이지네이션 적용된 모든 게시글을 조회합니다.") + @GetMapping + ResponseEntity> getAllPosts(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size); + + @Operation(summary = "카테고리별 게시글 조회 (페이지네이션)", description = "특정 카테고리에 속한 게시글을 페이지네이션하여 조회합니다.") + @GetMapping("/category") + ResponseEntity> getPostsByCategory(@Parameter(description = "카테고리명", required = true, example = "FOOD") @RequestParam("category") String category, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size); + + @Operation(summary = "게시글 검색", description = "키워드를 이용해 게시글을 검색합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "검색 성공", content = @Content(schema = @Schema(implementation = PostDTO.class))), + @ApiResponse(responseCode = "500", description = "서버 오류") + }) + @GetMapping("/search") + ResponseEntity> searchPosts( + @Parameter(description = "검색 키워드", required = true, example = "스터디") @RequestParam("keyword") String keyword + ); + + @Operation(summary = "내가 작성한 게시글 조회", description = "JWT에서 로그인한 사용자의 memberId를 가져와 해당 사용자의 게시글을 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(schema = @Schema(implementation = PostDTO.class))), + @ApiResponse(responseCode = "500", description = "서버 오류") + }) + @GetMapping("/member") + ResponseEntity> getMyPosts( + @RequestHeader(HttpHeaders.AUTHORIZATION) String token + ); +} \ No newline at end of file diff --git a/src/main/java/bst/bobsoolting/post/query/repository/PostMapper.java b/src/main/java/bst/bobsoolting/post/query/repository/PostMapper.java index 7d19ff2..67425aa 100644 --- a/src/main/java/bst/bobsoolting/post/query/repository/PostMapper.java +++ b/src/main/java/bst/bobsoolting/post/query/repository/PostMapper.java @@ -5,7 +5,7 @@ import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; -import bst.bobsoolting.post.command.domain.aggregate.Post; +import bst.bobsoolting.post.command.domain.aggregate.entity.Post; @Mapper public interface PostMapper { diff --git a/src/main/java/bst/bobsoolting/post/query/service/PostQueryServiceImpl.java b/src/main/java/bst/bobsoolting/post/query/service/PostQueryServiceImpl.java index bfce60b..04fb4fa 100644 --- a/src/main/java/bst/bobsoolting/post/query/service/PostQueryServiceImpl.java +++ b/src/main/java/bst/bobsoolting/post/query/service/PostQueryServiceImpl.java @@ -2,7 +2,7 @@ import bst.bobsoolting.post.command.application.mapper.PostConverter; import bst.bobsoolting.post.command.application.dto.PostDTO; -import bst.bobsoolting.post.command.domain.aggregate.Post; +import bst.bobsoolting.post.command.domain.aggregate.entity.Post; import bst.bobsoolting.post.query.repository.PostMapper; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -13,43 +13,38 @@ @RequiredArgsConstructor public class PostQueryServiceImpl implements PostQueryService { - private final PostMapper postmapper; + private final PostMapper postMapper; private final PostConverter postConverter; @Override public PostDTO getPostById(Long postId) { - Post post = postmapper.findByPostId(postId); + Post post = postMapper.findByPostId(postId); return postConverter.toDTO(post); } @Override public List getAllPosts() { - List posts = postmapper.findAll(); - return posts.stream() - .map(postConverter::toDTO) - .collect(Collectors.toList()); + List posts = postMapper.findAll(); + return posts.stream().map(postConverter::toDTO).collect(Collectors.toList()); } - + @Override - public List searchPostsByKeyword(String keyword) { - List posts = postmapper.searchByKeyword(keyword); - return posts.stream() - .map(postConverter::toDTO) - .collect(Collectors.toList()); + public List getPostsByCategory(String category) { + List posts = postMapper.findByCategory(category); + return posts.stream().map(postConverter::toDTO).collect(Collectors.toList()); } - + @Override - public List getPostsByCategory(String category) { - // postmapper에서 category를 기준으로 조회한 후 DTO로 변환 - List posts = postmapper.findByCategory(category); + public List searchPostsByKeyword(String keyword) { + List posts = postMapper.searchByKeyword(keyword); return posts.stream() .map(postConverter::toDTO) .collect(Collectors.toList()); } - + @Override public List getPostsByMemberId(String memberId) { - List posts = postmapper.findByMemberId(memberId); + List posts = postMapper.findByMemberId(memberId); return posts.stream() .map(postConverter::toDTO) .collect(Collectors.toList()); diff --git a/src/main/java/bst/bobsoolting/security/CustomOAuth2UserService.java b/src/main/java/bst/bobsoolting/security/CustomOAuth2UserService.java index 922fd56..9a323e6 100644 --- a/src/main/java/bst/bobsoolting/security/CustomOAuth2UserService.java +++ b/src/main/java/bst/bobsoolting/security/CustomOAuth2UserService.java @@ -27,7 +27,7 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) { Long kakaoId = (Long) attributes.get("id"); log.info("✅ 카카오 로그인 성공 - ID: {}", kakaoId); - String token = jwtTokenProvider.generateToken(String.valueOf(kakaoId)); + String token = jwtTokenProvider.generateAccessToken(String.valueOf(kakaoId)); log.info("✅ 발급된 JWT: {}", token); return new DefaultOAuth2User( diff --git a/src/main/java/bst/bobsoolting/security/JwtAuthenticationFilter.java b/src/main/java/bst/bobsoolting/security/JwtAuthenticationFilter.java index fe4d10e..90800c3 100644 --- a/src/main/java/bst/bobsoolting/security/JwtAuthenticationFilter.java +++ b/src/main/java/bst/bobsoolting/security/JwtAuthenticationFilter.java @@ -1,8 +1,11 @@ package bst.bobsoolting.security; import io.jsonwebtoken.Claims; +import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; @@ -14,11 +17,17 @@ import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +@Slf4j @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; + @PostConstruct + public void init() { + log.info("✅ JwtAuthenticationFilter가 SecurityFilterChain에 추가됨"); + } + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { @@ -26,13 +35,24 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); + + if (jwtTokenProvider.isTokenExpired(token)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write("Access Token이 만료되었습니다. Refresh Token을 사용하세요."); + return; + } + Claims claims = jwtTokenProvider.parseToken(token); + log.info("Parsed claims: {}", claims); + String kakaoId = claims.getSubject(); UserDetails userDetails = User.withUsername(kakaoId).password("").roles("USER").build(); SecurityContextHolder.getContext().setAuthentication( new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()) ); + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + log.info("Current Authentication: {}", auth); } chain.doFilter(request, response); diff --git a/src/main/java/bst/bobsoolting/security/JwtTokenProvider.java b/src/main/java/bst/bobsoolting/security/JwtTokenProvider.java index 04f1ede..fda9b55 100644 --- a/src/main/java/bst/bobsoolting/security/JwtTokenProvider.java +++ b/src/main/java/bst/bobsoolting/security/JwtTokenProvider.java @@ -18,31 +18,64 @@ public class JwtTokenProvider { private final SecretKey secretKey; private final long accessTokenValidityInMilliseconds; + private final long refreshTokenValidityInMilliseconds; - public JwtTokenProvider(@Value("${jwt.secret}") String secret, @Value("${jwt.access-token-validity}") long accessTokenValidity) { + public JwtTokenProvider(@Value("${jwt.secret}") String secret, + @Value("${jwt.access-token-validity}") long accessTokenValidity, + @Value("${jwt.refresh-token-validity}") long refreshTokenValidity) { byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8); if (keyBytes.length < 32) throw new IllegalArgumentException("JWT Secret Key 길이가 32바이트 이상이어야 합니다."); this.secretKey = Keys.hmacShaKeyFor(keyBytes); this.accessTokenValidityInMilliseconds = accessTokenValidity; + this.refreshTokenValidityInMilliseconds = refreshTokenValidity; } - public String generateToken(String kakaoId) { + public String generateAccessToken(String kakaoId) { + return generateJwtToken(kakaoId, accessTokenValidityInMilliseconds); + } + + public String generateRefreshToken(String kakaoId) { + return generateJwtToken(kakaoId, refreshTokenValidityInMilliseconds); + } + + private String generateJwtToken(String kakaoId, long validity) { Date now = new Date(); - Date validity = new Date(now.getTime() + accessTokenValidityInMilliseconds); + Date expiration = new Date(now.getTime() + validity); return Jwts.builder() .setSubject(kakaoId) .setIssuedAt(now) - .setExpiration(validity) + .setExpiration(expiration) .signWith(secretKey, SignatureAlgorithm.HS256) .compact(); } + public Claims parseToken(String token) { - return Jwts.parserBuilder() - .setSigningKey(secretKey) - .build() - .parseClaimsJws(token) - .getBody(); + try { + Claims claims = Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + log.info("✅ JWT 파싱 성공 - Claims: {}", claims); + return claims; + } catch (Exception e) { + log.error("❌ JWT 파싱 오류 - 토큰: {}, 이유: {}", token, e.getMessage()); + throw e; + } + } + + public boolean isTokenExpired(String token) { + try { + Claims claims = parseToken(token); + boolean expired = claims.getExpiration().before(new Date()); + log.info("✅ JWT 만료 체크 - Expiration: {}, 현재 시간: {}, 만료 여부: {}", + claims.getExpiration(), new Date(), expired); + return expired; + } catch (Exception e) { + log.error("❌ JWT 파싱 실패 - 이유: {}", e.getMessage()); + return true; + } } } diff --git a/src/main/java/bst/bobsoolting/security/OAuth2LoginSuccessHandler.java b/src/main/java/bst/bobsoolting/security/OAuth2LoginSuccessHandler.java index f9c5996..dd16b02 100644 --- a/src/main/java/bst/bobsoolting/security/OAuth2LoginSuccessHandler.java +++ b/src/main/java/bst/bobsoolting/security/OAuth2LoginSuccessHandler.java @@ -3,7 +3,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; import org.springframework.stereotype.Component; @@ -12,22 +11,27 @@ import java.util.Map; @Component -@RequiredArgsConstructor public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { private final JwtTokenProvider jwtTokenProvider; private final ObjectMapper objectMapper = new ObjectMapper(); + public OAuth2LoginSuccessHandler(JwtTokenProvider jwtTokenProvider) { + this.jwtTokenProvider = jwtTokenProvider; + } + @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { String kakaoId = authentication.getName(); - String token = jwtTokenProvider.generateToken(kakaoId); + String accessToken = jwtTokenProvider.generateAccessToken(kakaoId); + String refreshToken = jwtTokenProvider.generateRefreshToken(kakaoId); response.setContentType("application/json"); response.setCharacterEncoding("UTF-8"); Map responseBody = new HashMap<>(); - responseBody.put("access_token", token); + responseBody.put("access_token", accessToken); + responseBody.put("refresh_token", refreshToken); responseBody.put("message", "로그인 성공"); response.getWriter().write(objectMapper.writeValueAsString(responseBody)); diff --git a/src/main/java/bst/bobsoolting/security/SecurityConfig.java b/src/main/java/bst/bobsoolting/security/SecurityConfig.java index 489a214..6a10180 100644 --- a/src/main/java/bst/bobsoolting/security/SecurityConfig.java +++ b/src/main/java/bst/bobsoolting/security/SecurityConfig.java @@ -40,15 +40,15 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // .requestMatchers("/api/**/admin/**").hasAuthority("ROLE_ADMIN") .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**", "/v3/api-docs/swagger-config").permitAll() - .requestMatchers("/api/member/basic-info").permitAll() - .requestMatchers("/api/member/complete").permitAll() + .requestMatchers("/api/member/auth/kakao").permitAll() + .requestMatchers("/api/member/complete").authenticated() .requestMatchers("/api/member/loginSuccess").permitAll() .requestMatchers("/api/member/loginFailure").permitAll() .requestMatchers("/api/member/**").authenticated() - .requestMatchers("/api/comment/**").authenticated() - .requestMatchers("/api/post/**").authenticated() - .requestMatchers("/api/reply/**").authenticated() - .requestMatchers("/api/like/**").authenticated() + .requestMatchers("/api/comment", "/api/comment/**").authenticated() + .requestMatchers("/api/post", "/api/post/**").authenticated() + .requestMatchers("/api/reply", "/api/reply/**").authenticated() + .requestMatchers("/api/like", "/api/like/**").authenticated() .requestMatchers("/", "/health").permitAll() @@ -63,15 +63,14 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http.build(); } - @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(Arrays.asList(allowedOrigins, "http://localhost:3000", "http://localhost:8080")); configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); configuration.setAllowCredentials(true); - configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "X-XSRF-TOKEN")); - configuration.setExposedHeaders(Collections.singletonList("Authorization")); + configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "X-XSRF-TOKEN", "Refresh-Token")); + configuration.setExposedHeaders(Arrays.asList("Authorization", "Refresh-Token")); configuration.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); diff --git a/src/main/java/bst/bobsoolting/util/JsonTypeHandler.java b/src/main/java/bst/bobsoolting/util/JsonTypeHandler.java new file mode 100644 index 0000000..04de925 --- /dev/null +++ b/src/main/java/bst/bobsoolting/util/JsonTypeHandler.java @@ -0,0 +1,49 @@ +package bst.bobsoolting.util; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.ibatis.type.BaseTypeHandler; +import org.apache.ibatis.type.JdbcType; + +import java.sql.*; +import java.util.Collections; +import java.util.List; + +public class JsonTypeHandler extends BaseTypeHandler> { + private static final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void setNonNullParameter(PreparedStatement ps, int i, List parameter, JdbcType jdbcType) throws SQLException { + try { + ps.setString(i, objectMapper.writeValueAsString(parameter)); + } catch (Exception e) { + throw new SQLException("JSON 변환 오류", e); + } + } + + @Override + public List getNullableResult(ResultSet rs, String columnName) throws SQLException { + return parseJson(rs.getString(columnName)); + } + + @Override + public List getNullableResult(ResultSet rs, int columnIndex) throws SQLException { + return parseJson(rs.getString(columnIndex)); + } + + @Override + public List getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { + return parseJson(cs.getString(columnIndex)); + } + + private List parseJson(String json) throws SQLException { + if (json == null || json.isEmpty()) { + return Collections.emptyList(); + } + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception e) { + throw new SQLException("JSON 파싱 오류", e); + } + } +} diff --git a/src/main/java/bst/bobsoolting/util/StringListConverter.java b/src/main/java/bst/bobsoolting/util/StringListConverter.java new file mode 100644 index 0000000..0f865c5 --- /dev/null +++ b/src/main/java/bst/bobsoolting/util/StringListConverter.java @@ -0,0 +1,30 @@ +package bst.bobsoolting.util; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import java.util.List; + +@Converter +public class StringListConverter implements AttributeConverter, String> { + private static final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(List attribute) { + try { + return objectMapper.writeValueAsString(attribute); + } catch (Exception e) { + throw new RuntimeException("List -> JSON 변환 오류", e); + } + } + + @Override + public List convertToEntityAttribute(String dbData) { + try { + return objectMapper.readValue(dbData, new TypeReference>() {}); + } catch (Exception e) { + throw new RuntimeException("JSON -> List 변환 오류", e); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9a24eac..f9ba59c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -39,6 +39,7 @@ spring: client-secret: ${KAKAO_CLIENT_SECRET_KEY} client-name: kakao authorization-grant-type: authorization_code +# redirect-uri: http://localhost:8080/test redirect-uri: http://localhost:3000/login # redirect-uri: ${KAKAO_BASE_URL}/login/oauth2/code/kakao scope: @@ -53,22 +54,28 @@ spring: user-info-uri: https://kapi.kakao.com/v2/user/me user-name-attribute: id -# data: -# redis: -# host: ${REDIS_HOST} -# port: ${REDIS_PORT} -# repositories: -# enabled: false + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + repositories: + enabled: false logging: level: bst.bobsoolting: DEBUG - org.springframework.security: TRACE + org.springframework.security: INFO org.mybatis: DEBUG java.sql: DEBUG + org.springframework.web: INFO + org.springframework.boot.actuate: INFO mybatis: mapper-locations: classpath:/bst/bobsoolting/mapper/**/*.xml + configuration: + default-enum-type-handler: org.apache.ibatis.type.EnumTypeHandler + map-underscore-to-camel-case: true + type-handlers-package: bst.bobsoolting.util cors: allowedOrigins: ${CORS_ALLOWED_ORIGINS} @@ -79,8 +86,11 @@ springdoc: client-id: ${KAKAO_CLIENT_KEY} client-secret: ${KAKAO_CLIENT_SECRET_KEY} use-basic-authentication-with-access-code-grant: true - oauth2RedirectUrl: ${KAKAO_BASE_URL}/login/oauth2/code/kakao + oauth2RedirectUrl: http://localhost:3000/login +# oauth2RedirectUrl: http://localhost:8080/test + # oauth2RedirectUrl: ${KAKAO_BASE_URL}/login/oauth2/code/kakao jwt: secret: ${JWT_SECRET} - access-token-validity: 86400000 # 24시간 \ No newline at end of file + access-token-validity: 1800000 + refresh-token-validity: 604800000 \ No newline at end of file diff --git a/src/main/resources/bst/bobsoolting/mapper/member/MemberMapper.xml b/src/main/resources/bst/bobsoolting/mapper/member/MemberMapper.xml index 2a7c80d..2ae86e4 100644 --- a/src/main/resources/bst/bobsoolting/mapper/member/MemberMapper.xml +++ b/src/main/resources/bst/bobsoolting/mapper/member/MemberMapper.xml @@ -8,7 +8,7 @@ - + diff --git a/src/main/resources/bst/bobsoolting/mapper/post/PostMapper.xml b/src/main/resources/bst/bobsoolting/mapper/post/PostMapper.xml index b2c5dd5..e116db3 100644 --- a/src/main/resources/bst/bobsoolting/mapper/post/PostMapper.xml +++ b/src/main/resources/bst/bobsoolting/mapper/post/PostMapper.xml @@ -1,36 +1,60 @@ + PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - - - - - + - \ No newline at end of file + + + + + + + + +