diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1dbbfb9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +build/ +out/ +.gradle +.idea + +src/main/resources/application-db.yml +src/main/resources/application.properties +src/main/resources/application.yml diff --git a/src/main/java/kr/where/backend/BackendApplication.java b/src/main/java/kr/where/backend/BackendApplication.java new file mode 100644 index 0000000..54b0195 --- /dev/null +++ b/src/main/java/kr/where/backend/BackendApplication.java @@ -0,0 +1,18 @@ +package kr.where.backend; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.retry.annotation.EnableRetry; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@EnableRetry +@SpringBootApplication +public class BackendApplication { + + public static void main(String[] args) { + SpringApplication.run(BackendApplication.class, args); + } + +} diff --git a/src/main/java/kr/where/backend/admin/AdminController.java b/src/main/java/kr/where/backend/admin/AdminController.java new file mode 100644 index 0000000..64cbd85 --- /dev/null +++ b/src/main/java/kr/where/backend/admin/AdminController.java @@ -0,0 +1,251 @@ +//package kr.where.backend.admin; +// +//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.ExampleObject; +//import io.swagger.v3.oas.annotations.media.Schema; +//import io.swagger.v3.oas.annotations.responses.ApiResponse; +//import io.swagger.v3.oas.annotations.tags.Tag; +//import jakarta.servlet.http.HttpServletRequest; +//import jakarta.servlet.http.HttpSession; +//import kr.where.backend.admin.dto.AdminInfo; +//import kr.where.backend.admin.dto.KeyValueInfo; +//import kr.where.backend.member.exception.MemberException; +//import org.springframework.http.HttpStatus; +//import org.springframework.http.ResponseEntity; +//import org.springframework.web.bind.annotation.*; +// +//import java.util.Map; +// +//@RestController +//@RequestMapping("/v3/admin") +//@Tag(name = "admin", description = "admin API") +//public class AdminController { +// +// @Operation( +// summary = "admin login API", +// description = "관리자 로그인 및 세션 생성", +// parameters = { +// @Parameter(name = "session", description = "관리자 세션 생성용 세션", required = false), +// }, +// requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "관리자 로그인용 Id 및 PWD", required = true, content = @Content(schema = @Schema(implementation = AdminInfo.class))), +// responses = { +// @ApiResponse(responseCode = "200", description = "로그인 성공", content = @Content(schema = @Schema(implementation = ResponseWithData.class), examples = { +// @ExampleObject(value = "{\"statusCode\": 200, \"responseMsg\": \"관리자 로그인 성공\"}")})), +// @ApiResponse(responseCode = "401", description = "일치하는 관리자 없음", content = @Content(schema = @Schema(implementation = MemberException.class), examples = { +// @ExampleObject(value = "{\"statusCode\": 401, \"responseMsg\": \"관리자 로그인 실패\"}")})), +// }) +// @PostMapping("/login") +// public ResponseEntity adminLogin(HttpSession session, @RequestBody AdminInfo admin){ +//// adminService.adminLogin(admin.getName(), admin.getPasswd()); +//// session.setAttribute("name", admin.getName()); +//// session.setMaxInactiveInterval(30 * 60); +// return new ResponseEntity(Response.res(StatusCode.OK, ResponseMsg.ADMIN_LOGIN_SUCCESS), HttpStatus.OK); +// } +// +// @Operation( +// summary = "update secret_id for admin API", +// description = "관리자용 42api secret_id DB 갱신", +// requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( +// description = "갱신할 secret id", required = true, +// content = @Content(schema = @Schema(implementation = KeyValueInfo.class), +// examples = @ExampleObject(value = "{\"secret\":\"secret id\"}")) +// ), +// responses = { +// @ApiResponse(responseCode = "200", description = "갱신 성공", content = @Content(schema = @Schema(implementation = ResponseWithData.class), examples = { +// @ExampleObject(value = "{\"statusCode\": 200, \"responseMsg\": \"관리자 로그인 성공\"}")})), +// @ApiResponse(responseCode = "401", description = "세션이 만료된 경우 발생", content = @Content(schema = @Schema(implementation = MemberException.class), examples = { +// @ExampleObject(value = "{\"statusCode\": 401, \"responseMsg\": \"세션 없음\"}")})), +// }) +// @PostMapping("/secret-admin") +// public ResponseEntity updateAdminServerSecret(HttpServletRequest req, @RequestBody Map secret) { +//// if (!adminService.findAdminBySession(req)) +//// throw new SessionExpiredException(); +//// adminRepository.insertAdminSecret(secret.get("secret")); +// return new ResponseEntity(Response.res(StatusCode.OK, ResponseMsg.SECRET_UPDATE_SUCCESS), HttpStatus.OK); +// } +// +// @Operation( +// summary = "update secret_id for user API", +// description = "사용자용 42api secret_id DB 갱신", +// requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( +// description = "갱신할 secret id", required = true, +// content = @Content(schema = @Schema(implementation = KeyValueInfo.class), +// examples = @ExampleObject(value = "{\"secret\":\"secret id\"}")) +// ), +// responses = { +// @ApiResponse(responseCode = "200", description = "갱신 성공", content = @Content(schema = @Schema(implementation = ResponseWithData.class), examples = { +// @ExampleObject(value = "{\"statusCode\": 200, \"responseMsg\": \"시크릿 아이디 갱신 성공\"}")})), +// @ApiResponse(responseCode = "401", description = "세션이 만료된 경우 발생", content = @Content(schema = @Schema(implementation = MemberException.class), examples = { +// @ExampleObject(value = "{\"statusCode\": 401, \"responseMsg\": \"세션 없음\"}")})), +// }) +// @PostMapping("/secret-member") +// public ResponseEntity updateServerSecret(HttpServletRequest req, @RequestBody Map secret) { +//// if (!adminService.findAdminBySession(req)) +//// throw new SessionExpiredException(); +//// tokenRepository.insertSecret(secret.get("secret")); +// return new ResponseEntity(Response.res(StatusCode.OK, ResponseMsg.SECRET_UPDATE_SUCCESS), HttpStatus.OK); +// } +// +// @Operation( +// summary = "get 42api code API", +// description = "관리자 42api application code 획득용 주소", +// responses = { +// @ApiResponse(responseCode = "200", description = "code 획득", content = @Content(schema = @Schema(type = "string"))), +// }) +// @GetMapping("/auth/code") +// public String adminAuthLogin() { +// return "https://api.intra.42.fr/oauth/authorize?client_id=u-s4t2ud-b40ead3720ac3ae095283c426699403ad2949fd0c54785d38d6f9f360670ed2e&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fv2%2Fauth%2Fadmin%2Fcallback&response_type=codehttps://api.intra.42.fr/oauth/authorize?client_id=u-s4t2ud-b40ead3720ac3ae095283c426699403ad2949fd0c54785d38d6f9f360670ed2e&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fv2%2Fauth%2Fadmin%2Fcallback&response_type=code"; +// } +// +// @Operation( +// summary = "insert admin token API", +// description = "관리자 access token DB 저장", +// requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( +// description = "42api 요청용 code", required = true, +// content = @Content(schema = @Schema(implementation = KeyValueInfo.class), +// examples = @ExampleObject(value = "{\"code\":\"42api 요청용 code\"}")) +// ), +// responses = { +// @ApiResponse(responseCode = "200", description = "갱신 성공", content = @Content(schema = @Schema(implementation = ResponseWithData.class), examples = { +// @ExampleObject(value = "{\"statusCode\": 200, \"responseMsg\": \"관리자 토큰 갱신 성공\"}")})), +// @ApiResponse(responseCode = "401", description = "세션이 만료된 경우 발생", content = @Content(schema = @Schema(implementation = MemberException.class), examples = { +// @ExampleObject(value = "{\"statusCode\": 401, \"responseMsg\": \"세션 없음\"}")})), +// }) +// @PostMapping("/auth/token") +// public ResponseEntity insertAdminToken(HttpServletRequest req, @RequestBody Map code) { +//// log.info("[insertAdminToken] Admin Token을 주입합니다."); +//// if (!adminService.findAdminBySession(req)) +//// throw new SessionExpiredException(); +//// OAuthToken oAuthToken = adminApiService.getAdminOAuthToken(adminRepository.callAdminSecret(), code.get("code")); +//// adminRepository.saveAdmin("admin", oAuthToken); +// return new ResponseEntity(Response.res(StatusCode.OK, ResponseMsg.ADMIN_TOKEN_SUCCESS), HttpStatus.OK); +// } +// +// @Operation( +// summary = "insert 24hane token API", +// description = "24hane access token DB 저장. 토큰 만료 시 24hane 담당자(현재 joopark)에게 연락하여 갱신", +// requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( +// description = "갱신할 토큰", required = true, +// content = @Content(schema = @Schema(implementation = KeyValueInfo.class), +// examples = @ExampleObject(value = "{\"token\":\"갱신할 토큰\"}")) +// ), +// responses = { +// @ApiResponse(responseCode = "200", description = "갱신 성공", content = @Content(schema = @Schema(implementation = ResponseWithData.class), examples = { +// @ExampleObject(value = "{\"statusCode\": 200, \"responseMsg\": \"하네 토큰 갱신 성공\"}")})), +// @ApiResponse(responseCode = "401", description = "세션이 만료된 경우 발생", content = @Content(schema = @Schema(implementation = MemberException.class), examples = { +// @ExampleObject(value = "{\"statusCode\": 401, \"responseMsg\": \"세션 없음\"}")})), +// }) +// @PostMapping("/hane/token") +// public ResponseEntity insertHane(HttpServletRequest req, @RequestBody Map token) { +//// if (!adminService.findAdminBySession(req)) +//// throw new SessionExpiredException(); +//// adminRepository.insertHane(token.get("token")); +// return new ResponseEntity(Response.res(StatusCode.OK, ResponseMsg.HANE_SUCCESS), HttpStatus.OK); +// } +// +// @Operation( +// summary = "update all cadet in cluster API", +// description = "클러스터 아이맥에 로그인 해 있는 모든 카뎃들의 정보 갱신", +// responses = { +// @ApiResponse(responseCode = "200", description = "정보 저장 성공", content = @Content(schema = @Schema(implementation = ResponseWithData.class), examples = { +// @ExampleObject(value = "{\"statusCode\": 200, \"responseMsg\": \"클러스터 아이맥 로그인 카뎃 갱신 성공\"}")})), +// @ApiResponse(responseCode = "401", description = "세션이 만료된 경우 발생", content = @Content(schema = @Schema(implementation = MemberException.class), examples = { +// @ExampleObject(value = "{\"statusCode\": 401, \"responseMsg\": \"세션 없음\"}")})), +// }) +// @GetMapping("/incluster") +// public ResponseEntity findAllInClusterCadet(HttpServletRequest req) { +//// if (!adminService.findAdminBySession(req)) +//// throw new SessionExpiredException(); +//// backgroundService.updateAllInClusterCadet(); +// return new ResponseEntity(Response.res(StatusCode.OK, ResponseMsg.IN_CLUSTER), HttpStatus.OK); +// } +// +// @Operation( +// summary = "reset flash data API", +// description = "모든 플래시 데이터 초기화", +// responses = { +// @ApiResponse(responseCode = "200", description = "초기화 성공", content = @Content(schema = @Schema(implementation = ResponseWithData.class), examples = { +// @ExampleObject(value = "{\"statusCode\": 200, \"responseMsg\": \"플래시 디비 초기화 성공\"}")})), +// @ApiResponse(responseCode = "401", description = "세션이 만료된 경우 발생", content = @Content(schema = @Schema(implementation = MemberException.class), examples = { +// @ExampleObject(value = "{\"statusCode\": 401, \"responseMsg\": \"세션 없음\"}")})), +// }) +// @DeleteMapping("/flash") +// public ResponseEntity resetFlash(HttpServletRequest req) { +//// if (!adminService.findAdminBySession(req)) +//// throw new SessionExpiredException(); +//// flashDataRepository.resetFlash(); +//// log.info("[reset-flash] flash data 초기화를 완료하였습니다."); +// return new ResponseEntity(Response.res(StatusCode.OK, ResponseMsg.RESET_FLASH), HttpStatus.OK); +// } +// +// @Operation( +// summary = "reset all cadet's image API", +// description = "블랙홀 인원 제외 모든 카뎃들의 이미지 url 갱신 (새로운 기수 들어올 시 필수)", +// responses = { +// @ApiResponse(responseCode = "200", description = "이미지 갱신 성공", content = @Content(schema = @Schema(implementation = ResponseWithData.class), examples = { +// @ExampleObject(value = "{\"statusCode\": 200, \"responseMsg\": \"이미지 저장 성공\"}")})), +// @ApiResponse(responseCode = "401", description = "세션이 만료된 경우 발생", content = @Content(schema = @Schema(implementation = MemberException.class), examples = { +// @ExampleObject(value = "{\"statusCode\": 401, \"responseMsg\": \"세션 없음\"}")})), +// }) +// @PostMapping("/image") +// public ResponseEntity getAllCadetImages(HttpServletRequest req) { +//// if (!adminService.findAdminBySession(req)) +//// throw new SessionExpiredException(); +//// backgroundService.getAllCadetImages(); +// return new ResponseEntity(Response.res(StatusCode.OK, ResponseMsg.GET_IMAGE_SUCCESS), HttpStatus.OK); +// } +// +// @Operation( +// summary = "insert all cadet's piscine start date API", +// description = "모든 카뎃들의 피신 시작일 삽입", +// responses = { +// @ApiResponse(responseCode = "200", description = "피신 시작일 삽입 성공", content = @Content(schema = @Schema(implementation = ResponseWithData.class), examples = { +// @ExampleObject(value = "{\"statusCode\": 200, \"responseMsg\": \"피신 시작일 삽입 성공\"}")})), +// @ApiResponse(responseCode = "401", description = "세션이 만료된 경우 발생", content = @Content(schema = @Schema(implementation = MemberException.class), examples = { +// @ExampleObject(value = "{\"statusCode\": 401, \"responseMsg\": \"세션 없음\"}")})), +// }) +// @PostMapping("/createdAt") +// public ResponseEntity getAllCadetCreateAt(HttpServletRequest req) { +//// if (!adminService.findAdminBySession(req)) +//// throw new SessionExpiredException(); +//// adminService.getSignUpDate(); +// return null; +// } +// +// @Operation( +// summary = "delete member API", +// description = "멤버 삭제시 사용하며 그룹 친구 정보 등 모두 삭제", +// parameters = { +// @Parameter(name = "name", description = "삭제할 멤버 이름", required = true), +// }, +// responses = { +// @ApiResponse(responseCode = "200", description = "삭제 성공", content = @Content(schema = @Schema(implementation = ResponseWithData.class), examples = { +// @ExampleObject(value = "{\"statusCode\": 200, \"responseMsg\": \"멤버 삭제 성공\"}")})), +// @ApiResponse(responseCode = "401", description = "세션이 만료된 경우 발생", content = @Content(schema = @Schema(implementation = MemberException.class), examples = { +// @ExampleObject(value = "{\"statusCode\": 401, \"responseMsg\": \"세션 없음\"}")})), +// }) +// @DeleteMapping("/member") +// public ResponseEntity deleteMember(HttpServletRequest req, @RequestParam(name = "name") String name) { +//// if (!adminService.findAdminBySession(req)) +//// throw new SessionExpiredException(); +//// adminService.deleteMember(name); +// return new ResponseEntity(Response.res(StatusCode.OK, ResponseMsg.DELETE_MEMBER), HttpStatus.OK); +// } +// +// @Operation( +// summary = "admin logout API", +// description = "관리자 로그아웃(세션 삭제)", +// responses = { +// @ApiResponse(responseCode = "200", description = "로그아웃 성공", content = @Content(schema = @Schema(implementation = ResponseWithData.class), examples = { +// @ExampleObject(value = "{\"statusCode\": 200, \"responseMsg\": \"관리자 로그아웃 성공\"}")})), +// }) +// @GetMapping("/logout") +// public ResponseEntity adminLogout(HttpServletRequest req) { +//// HttpSession session = req.getSession(false); +//// if (session != null) +//// session.invalidate(); +// return new ResponseEntity(Response.res(StatusCode.OK, ResponseMsg.ADMIN_LOGOUT_SUCCESS), HttpStatus.OK); +// } +//} diff --git a/src/main/java/kr/where/backend/admin/dto/AdminInfo.java b/src/main/java/kr/where/backend/admin/dto/AdminInfo.java new file mode 100644 index 0000000..07bb9e5 --- /dev/null +++ b/src/main/java/kr/where/backend/admin/dto/AdminInfo.java @@ -0,0 +1,16 @@ +package kr.where.backend.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; +@Getter +@Setter +@Schema(description = "사용자 정보") +public class AdminInfo { + + @Schema(description = "이름", example = "홍길동") + private String name; + + @Schema(description = "비밀번호", example = "****") + private String passwd; +} diff --git a/src/main/java/kr/where/backend/admin/dto/KeyValueInfo.java b/src/main/java/kr/where/backend/admin/dto/KeyValueInfo.java new file mode 100644 index 0000000..a8f120a --- /dev/null +++ b/src/main/java/kr/where/backend/admin/dto/KeyValueInfo.java @@ -0,0 +1,14 @@ +package kr.where.backend.admin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Schema(description = "JSON 형식의 key:value") +public class KeyValueInfo { + + @Schema(description = "{'key':'value'}", example = "value") + private String key; +} diff --git a/src/main/java/kr/where/backend/api/HaneApiService.java b/src/main/java/kr/where/backend/api/HaneApiService.java new file mode 100644 index 0000000..3c89e8f --- /dev/null +++ b/src/main/java/kr/where/backend/api/HaneApiService.java @@ -0,0 +1,116 @@ +package kr.where.backend.api; + +import java.util.ArrayList; +import java.util.List; +import kr.where.backend.api.exception.RequestException; +import kr.where.backend.api.http.HttpHeader; +import kr.where.backend.api.http.HttpResponse; +import kr.where.backend.api.http.Uri; +import kr.where.backend.api.http.UriBuilder; +import kr.where.backend.api.json.hane.Hane; +import kr.where.backend.api.json.hane.HaneRequestDto; +import kr.where.backend.api.json.hane.HaneResponseDto; +import kr.where.backend.group.entity.Group; +import kr.where.backend.group.entity.GroupMember; +import kr.where.backend.member.Member; +import kr.where.backend.member.MemberRepository; +import kr.where.backend.member.exception.MemberException.NoMemberException; +import kr.where.backend.oauthtoken.OAuthTokenService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class HaneApiService { + private final OAuthTokenService oauthTokenService; + private final MemberRepository memberRepository; + private static final String HANE_TOKEN = "hane"; + + /** + * hane api 호출하여 in, out state 반환 + */ + public Hane getHaneInfo(final String name, final String token) { + try { + return JsonMapper.mapping(HttpResponse.getMethod(HttpHeader.requestHaneInfo(token), UriBuilder.hane(name)), + Hane.class); + } catch (final RequestException exception) { + log.warn("[hane] {} : {}", name, exception.toString()); + return new Hane(); + } + } + + @Transactional + public void updateInClusterForMainPage(final Member member) { + if (member.isAgree()) { + member.setInCluster(getHaneInfo(member.getIntraName(), oauthTokenService.findAccessToken(HANE_TOKEN))); + + log.info("[scheduling] : {}의 imacLocation이 변경되었습니다", member.getIntraName()); + } + } + + public List getHaneListInfo(final List haneRequestDto, final String token) { + try { + return JsonMapper.mappings(HttpResponse.postMethod(HttpHeader.requestHaneListInfo(haneRequestDto, token), + UriBuilder.hane(Uri.HANE_INFO_LIST.getValue())), HaneResponseDto[].class); + } catch (final RequestException exception) { + log.warn("[hane] : {}", exception.toString()); + return new ArrayList<>(); + } + } + + @Transactional + public void updateMemberInOrOutState(final Member member, final String state) { + member.setInCluster(Hane.create(state)); + } + + @Transactional + public void updateMyOwnMemberState(final List friends) { + log.info("[hane] : inCluster 업데이트 스케줄링을 시작합니다!"); + final List responses = getHaneListInfo( + friends + .stream() + .filter(m -> m.getMember().isPossibleToUpdateInCluster()) + .map(m -> new HaneRequestDto(m.getMember().getIntraName())) + .toList(), + oauthTokenService.findAccessToken(HANE_TOKEN)); + + responses.stream() + .filter(response -> response.getInoutState() != null) + .forEach(response -> { + this.updateMemberInOrOutState( + memberRepository.findByIntraName(response.getLogin()) + .orElseThrow(NoMemberException::new), + response.getInoutState()); + log.info("[hane] : {}의 inCluster가 변경되었습니다", response.getLogin()); + }); + log.info("[hane] : inCluster 업데이트 스케줄링을 끝냅니다!"); + } + + @Transactional + public void updateGroupMemberState(final Group group) { + log.info("[hane] : 메인 페이지 새로고침으로 인한 inCluster 업데이트를 시작합니다!"); + final List responses = getHaneListInfo( + group + .getGroupMembers() + .stream() + .filter(m -> m.getMember().isPossibleToUpdateInCluster()) + .map(m -> new HaneRequestDto(m.getMember().getIntraName())) + .toList(), + oauthTokenService.findAccessToken(HANE_TOKEN) + ); + responses.stream() + .filter(response -> response.getInoutState() != null) + .forEach(response -> { + updateMemberInOrOutState( + memberRepository.findByIntraName(response.getLogin()) + .orElseThrow(NoMemberException::new), + response.getInoutState()); + log.info("[hane] : {}의 inCluster가 변경되었습니다", response.getLogin()); + }); + log.info("[hane] : 메인 페이지 새로고침으로 인한 inCluster 업데이트를 끝냅니다!"); + } +} diff --git a/src/main/java/kr/where/backend/api/IntraApiService.java b/src/main/java/kr/where/backend/api/IntraApiService.java new file mode 100644 index 0000000..d6d7846 --- /dev/null +++ b/src/main/java/kr/where/backend/api/IntraApiService.java @@ -0,0 +1,110 @@ +package kr.where.backend.api; + +import java.util.List; +import kr.where.backend.api.exception.RequestException.TooManyRequestException; +import kr.where.backend.api.http.HttpHeader; +import kr.where.backend.api.http.HttpResponse; +import kr.where.backend.api.http.UriBuilder; +import kr.where.backend.api.json.CadetPrivacy; +import kr.where.backend.api.json.Cluster; +import kr.where.backend.exception.CustomException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class IntraApiService { + + private static final String END_DELIMITER = "z"; + + /** + * 특정 카텟의 정보 반환 + */ + @Retryable(retryFor = TooManyRequestException.class, maxAttempts = 3, backoff = @Backoff(delay = 1000)) + public CadetPrivacy getCadetPrivacy(final String token, final String name) { + return JsonMapper + .mapping( + HttpResponse.getMethod(HttpHeader.request42Info(token), UriBuilder.cadetInfo(name)), + CadetPrivacy.class); + } + + /** + * index page 별로 image 반환 + */ + @Retryable(retryFor = TooManyRequestException.class, maxAttempts = 3, backoff = @Backoff(delay = 1000)) + public List getCadetsImage(final String token, final int page) { + return JsonMapper + .mappings( + HttpResponse.getMethod(HttpHeader.request42Info(token), UriBuilder.image(page)), + CadetPrivacy[].class); + } + + /** + * 클러스터 아이맥에 로그인 한 카뎃 index page 별로 반환 + */ + @Retryable(retryFor = TooManyRequestException.class, maxAttempts = 3, backoff = @Backoff(delay = 1000)) + public List getCadetsInCluster(final String token, final int page) { + return JsonMapper + .mappings( + HttpResponse.getMethod(HttpHeader.request42Info(token), UriBuilder.loginCadet(page)), + Cluster[].class); + } + + /** + * 5분 이내 클러스터 아이맥에 로그아웃 한 카뎃 index page 별로 반환 + */ + @Retryable(retryFor = TooManyRequestException.class, maxAttempts = 3, backoff = @Backoff(delay = 1000)) + public List getLogoutCadetsLocation(final String token, final int page) { + return JsonMapper + .mappings(HttpResponse.getMethod(HttpHeader.request42Info(token), + UriBuilder.loginBeforeFiveMinute(page, false)), Cluster[].class); + } + + /** + * 5분 이내 클러스터 아이맥에 로그인 한 카뎃 index page 별로 반환 + */ + @Retryable(retryFor = TooManyRequestException.class, maxAttempts = 3, backoff = @Backoff(delay = 1000)) + public List getLoginCadetsLocation(final String token, final int page) { + return JsonMapper + .mappings(HttpResponse.getMethod(HttpHeader.request42Info(token), + UriBuilder.loginBeforeFiveMinute(page, true)), Cluster[].class); + } + + /** + * keyWord 부터 end 까지 intra id를 가진 카뎃 10명의 정보 반환 + */ + @Retryable(retryFor = TooManyRequestException.class, maxAttempts = 3, backoff = @Backoff(delay = 1000)) + public List getCadetsInRange(final String token, final String keyWord, final int page) { + return JsonMapper + .mappings(HttpResponse.getMethod( + HttpHeader.request42Info(token), + UriBuilder.searchCadets(keyWord, keyWord + END_DELIMITER, page)), + CadetPrivacy[].class); + } + + /** + * 요청 3번 실패 시 실행되는 메서드 + */ + @Recover + public CadetPrivacy fallbackCadetPrivacy(final CustomException exception) { + log.warn("[IntraApiService] CadetPrivacy method"); + throw exception; + } + + @Recover + public List fallbackCadetsPrivacy(final CustomException exception) { + log.warn("[IntraApiService] List method"); + throw exception; + } + + @Recover + public List fallbackClusterList(final CustomException exception) { + log.warn("[IntraApiService] List method"); + throw exception; + } +} diff --git a/src/main/java/kr/where/backend/api/JsonMapper.java b/src/main/java/kr/where/backend/api/JsonMapper.java new file mode 100644 index 0000000..b3df6c6 --- /dev/null +++ b/src/main/java/kr/where/backend/api/JsonMapper.java @@ -0,0 +1,38 @@ +package kr.where.backend.api; + + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Arrays; +import java.util.List; + +import kr.where.backend.api.exception.JsonException; + +public class JsonMapper { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + public static T mapping(final String jsonBody, final Class classType) { + try { + return OBJECT_MAPPER.readValue(jsonBody, classType); + } catch (JsonProcessingException e) { + throw new JsonException.DeserializeException(); + } + } + + public static List mappings(final String jsonBody, final Class classType) { + try { + return Arrays.asList(OBJECT_MAPPER.readValue(jsonBody, classType)); + } catch (JsonProcessingException e) { + System.out.println(e.getMessage()); + throw new JsonException.DeserializeException(); + } + } + + public static String convertJsonForm(final List requestBody) { + try { + return OBJECT_MAPPER.writeValueAsString(requestBody); + } catch(JsonProcessingException e) { + throw new JsonException.DeserializeException(); + } + } +} diff --git a/src/main/java/kr/where/backend/api/TokenApiService.java b/src/main/java/kr/where/backend/api/TokenApiService.java new file mode 100644 index 0000000..6709d25 --- /dev/null +++ b/src/main/java/kr/where/backend/api/TokenApiService.java @@ -0,0 +1,50 @@ +package kr.where.backend.api; + +import kr.where.backend.api.exception.RequestException.TooManyRequestException; +import kr.where.backend.api.http.HttpHeader; +import kr.where.backend.api.http.HttpResponse; +import kr.where.backend.api.http.UriBuilder; +import kr.where.backend.api.json.OAuthTokenDto; +import kr.where.backend.exception.CustomException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TokenApiService { + + /** + * intra 에 oAuth token 발급 요청 후 토큰을 반환 + */ + @Retryable(retryFor = TooManyRequestException.class, maxAttempts = 3, backoff = @Backoff(delay = 1000)) + public OAuthTokenDto getOAuthToken(final String code) { + return JsonMapper + .mapping(HttpResponse.postMethod(HttpHeader.requestToken(code), UriBuilder.token()), + OAuthTokenDto.class); + } + + /** + * refreshToken 으로 intra 에 oAuth token 발급 요청 후 토큰 반환 + */ + @Retryable(retryFor = TooManyRequestException.class, maxAttempts = 3, backoff = @Backoff(delay = 1000)) + public OAuthTokenDto getOAuthTokenWithRefreshToken(final String refreshToken) { + return JsonMapper + .mapping(HttpResponse.postMethod( + HttpHeader.requestAccessToken(refreshToken), UriBuilder.token()), + OAuthTokenDto.class); + } + + /** + * 요청 3번 실패 시 실행되는 메서드 + */ + @Recover + public OAuthTokenDto fallback(final CustomException exception) { + log.info("[TokenApiService] back token 발급에 실패하였습니다"); + throw exception; + } +} diff --git a/src/main/java/kr/where/backend/api/exception/JsonErrorCode.java b/src/main/java/kr/where/backend/api/exception/JsonErrorCode.java new file mode 100644 index 0000000..61f64b2 --- /dev/null +++ b/src/main/java/kr/where/backend/api/exception/JsonErrorCode.java @@ -0,0 +1,14 @@ +package kr.where.backend.api.exception; + +import kr.where.backend.exception.ErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum JsonErrorCode implements ErrorCode { + DESERIALIZE_FAIL(1200, "json 맵핑에 실패 했습니다."); + + private final int errorCode; + private final String errorMessage; +} diff --git a/src/main/java/kr/where/backend/api/exception/JsonException.java b/src/main/java/kr/where/backend/api/exception/JsonException.java new file mode 100644 index 0000000..fd2f4b4 --- /dev/null +++ b/src/main/java/kr/where/backend/api/exception/JsonException.java @@ -0,0 +1,15 @@ +package kr.where.backend.api.exception; + +import kr.where.backend.exception.CustomException; + +public class JsonException extends CustomException { + public JsonException(final JsonErrorCode jsonErrorCode) { + super(jsonErrorCode); + } + + public static class DeserializeException extends JsonException { + public DeserializeException() { + super(JsonErrorCode.DESERIALIZE_FAIL); + } + } +} diff --git a/src/main/java/kr/where/backend/api/exception/RequestErrorCode.java b/src/main/java/kr/where/backend/api/exception/RequestErrorCode.java new file mode 100644 index 0000000..48bbb53 --- /dev/null +++ b/src/main/java/kr/where/backend/api/exception/RequestErrorCode.java @@ -0,0 +1,17 @@ +package kr.where.backend.api.exception; + +import kr.where.backend.exception.ErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum RequestErrorCode implements ErrorCode { + UNAUTHORIZED(3000, "Unauthorized 권한이 없습니다."), + TOO_MANY_REQUEST(3001, "42API 요청 횟수를 초과하였습니다."), + SERVER_ERROR(3002, "외부 API 서버 에러입니다."), + BAD_REQUEST(3003, "잘못된 요청입니다"); + + private final int errorCode; + private final String errorMessage; +} diff --git a/src/main/java/kr/where/backend/api/exception/RequestException.java b/src/main/java/kr/where/backend/api/exception/RequestException.java new file mode 100644 index 0000000..fe130b0 --- /dev/null +++ b/src/main/java/kr/where/backend/api/exception/RequestException.java @@ -0,0 +1,34 @@ +package kr.where.backend.api.exception; + +import kr.where.backend.exception.CustomException; + +public class RequestException extends CustomException { + + public RequestException(final RequestErrorCode requestErrorCode) { + super(requestErrorCode); + } + + public static class ApiUnauthorizedException extends RequestException{ + public ApiUnauthorizedException() { + super(RequestErrorCode.UNAUTHORIZED); + } + } + + public static class TooManyRequestException extends RequestException{ + public TooManyRequestException() { + super(RequestErrorCode.TOO_MANY_REQUEST); + } + } + + public static class BadRequestException extends RequestException { + public BadRequestException() { + super(RequestErrorCode.BAD_REQUEST); + } + } + + public static class ApiServerErrorException extends RequestException{ + public ApiServerErrorException() { + super(RequestErrorCode.SERVER_ERROR); + } + } +} diff --git a/src/main/java/kr/where/backend/api/http/CustomResponseErrorHandler.java b/src/main/java/kr/where/backend/api/http/CustomResponseErrorHandler.java new file mode 100644 index 0000000..f8846c6 --- /dev/null +++ b/src/main/java/kr/where/backend/api/http/CustomResponseErrorHandler.java @@ -0,0 +1,41 @@ +package kr.where.backend.api.http; + +import java.io.IOException; +import kr.where.backend.api.exception.RequestException.ApiServerErrorException; +import kr.where.backend.api.exception.RequestException.ApiUnauthorizedException; +import kr.where.backend.api.exception.RequestException.BadRequestException; +import kr.where.backend.api.exception.RequestException.TooManyRequestException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.web.client.ResponseErrorHandler; + +@Slf4j +public class CustomResponseErrorHandler implements ResponseErrorHandler { + @Override + public boolean hasError(ClientHttpResponse response) throws IOException { + return response.getStatusCode().is4xxClientError(); + } + + /** + * 외부 api 요청 후 받은 응답의 exception + * + * 400 BAD_REQUEST : 잘못된 요청 + * 401 UNAUTHORIZED : 권한 없음 access_token 을 확인 + * 429 TOO_MANY_REQUESTS : 1초에 2번 / 10분에 1200번 요청 횟수 초과 시 + * 500 INTERNAL_SERVER_ERROR : 외부 서버의 에러 + */ + @Override + public void handleError(ClientHttpResponse response) throws IOException { + if (response.getStatusCode() == HttpStatus.UNAUTHORIZED) { + throw new ApiUnauthorizedException(); + } else if (response.getStatusCode() == HttpStatus.TOO_MANY_REQUESTS){ + throw new TooManyRequestException(); + } else if (response.getStatusCode() == HttpStatus.INTERNAL_SERVER_ERROR) { + throw new ApiServerErrorException(); + } else { + log.error("error code : {}", response.getStatusCode()); + throw new BadRequestException(); + } + } +} \ No newline at end of file diff --git a/src/main/java/kr/where/backend/api/http/HttpHeader.java b/src/main/java/kr/where/backend/api/http/HttpHeader.java new file mode 100644 index 0000000..072d5e0 --- /dev/null +++ b/src/main/java/kr/where/backend/api/http/HttpHeader.java @@ -0,0 +1,82 @@ +package kr.where.backend.api.http; + +import kr.where.backend.api.JsonMapper; +import kr.where.backend.api.json.hane.HaneRequestDto; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import java.util.List; + +public class HttpHeader { + private static final String BEARER = "Bearer "; + private static final String GRANT_TYPE_ACCESS = "authorization_code"; + private static final String GRANT_TYPE_REFRESH = "refresh_token"; + + public static HttpEntity> requestToken(final String code) { + final HttpHeaders headers = new HttpHeaders(); + + headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE); + + final MultiValueMap params = new LinkedMultiValueMap<>(); + + params.add("grant_type", GRANT_TYPE_ACCESS); + params.add("client_id", Utils.getClientId()); + params.add("client_secret", Utils.getSecret()); + params.add("code", code); + params.add("redirect_uri", Utils.getRedirectUri()); + + return new HttpEntity<>(params, headers); + } + + public static HttpEntity> requestAccessToken(final String refreshToken) { + final HttpHeaders headers = new HttpHeaders(); + + headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE); + + final MultiValueMap params = new LinkedMultiValueMap<>(); + + params.add("grant_type", GRANT_TYPE_REFRESH); + params.add("client_id", Utils.getClientId()); + params.add("client_secret", Utils.getSecret()); + params.add("refresh_token", refreshToken); + + return new HttpEntity<>(params, headers); + } + + public static HttpEntity> request42Info(final String token) { + final HttpHeaders headers = new HttpHeaders(); + + headers.add(HttpHeaders.AUTHORIZATION, BEARER + token); + headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE); + + final MultiValueMap params = new LinkedMultiValueMap<>(); + + return new HttpEntity<>(params, headers); + } + + public static HttpEntity> requestHaneInfo(final String token) { + final HttpHeaders headers = new HttpHeaders(); + + headers.add(HttpHeaders.AUTHORIZATION, BEARER + token); + headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); + + final MultiValueMap params = new LinkedMultiValueMap<>(); + + return new HttpEntity<>(params, headers); + } + + public static HttpEntity requestHaneListInfo( + final List requestBody, + final String token) + { + final HttpHeaders headers = new HttpHeaders(); + + headers.add(HttpHeaders.AUTHORIZATION, BEARER + token); + headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); + + return new HttpEntity<>(JsonMapper.convertJsonForm(requestBody), headers); + } +} diff --git a/src/main/java/kr/where/backend/api/http/HttpResponse.java b/src/main/java/kr/where/backend/api/http/HttpResponse.java new file mode 100644 index 0000000..f512fe6 --- /dev/null +++ b/src/main/java/kr/where/backend/api/http/HttpResponse.java @@ -0,0 +1,25 @@ +package kr.where.backend.api.http; + +import java.net.URI; + +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +public class HttpResponse { + + public static String getMethod(final HttpEntity> request, + final URI uri) { + final RestTemplate restTemplate = new RestTemplate(); + restTemplate.setErrorHandler(new CustomResponseErrorHandler()); + return restTemplate.exchange(uri.toString(), HttpMethod.GET, request, String.class).getBody(); + } + + public static String postMethod(final HttpEntity request, + final URI uri) { + final RestTemplate restTemplate = new RestTemplate(); + restTemplate.setErrorHandler(new CustomResponseErrorHandler()); + return restTemplate.exchange(uri.toString(), HttpMethod.POST, request, String.class).getBody(); + } +} diff --git a/src/main/java/kr/where/backend/api/http/Uri.java b/src/main/java/kr/where/backend/api/http/Uri.java new file mode 100644 index 0000000..f8db56b --- /dev/null +++ b/src/main/java/kr/where/backend/api/http/Uri.java @@ -0,0 +1,31 @@ +package kr.where.backend.api.http; + +public enum Uri { + HTTPS("https"), + HOST("api.intra.42.fr"), + TOKEN_PATH("oauth/token"), + ME_PATH("v2/me"), + CADET_PATH("v2/users/"), + USERS_PATH("v2/campus/29/users"), + LOCATIONS_PATH("v2/campus/29/locations"), + HANE_PATH("https://api.24hoursarenotenough.42seoul.kr/ext/where42/where42/"), + SORT("sort"), + FILTER("filter[kind]"), + PAGE_SIZE("page[size]"), + PAGE_NUMBER("page[number]"), + RANGE_BEGIN("range[begin_at]"), + RANGE_END("range[end_at]"), + RANGE_LOGIN("range[login]"), + DELIMITER(","), + HANE_INFO_LIST("where42All"); + + private final String value; + + Uri(final String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/src/main/java/kr/where/backend/api/http/UriBuilder.java b/src/main/java/kr/where/backend/api/http/UriBuilder.java new file mode 100644 index 0000000..8a5b62d --- /dev/null +++ b/src/main/java/kr/where/backend/api/http/UriBuilder.java @@ -0,0 +1,176 @@ +package kr.where.backend.api.http; + +import static kr.where.backend.api.http.Uri.CADET_PATH; +import static kr.where.backend.api.http.Uri.DELIMITER; +import static kr.where.backend.api.http.Uri.FILTER; +import static kr.where.backend.api.http.Uri.HANE_PATH; +import static kr.where.backend.api.http.Uri.HOST; +import static kr.where.backend.api.http.Uri.HTTPS; +import static kr.where.backend.api.http.Uri.LOCATIONS_PATH; +import static kr.where.backend.api.http.Uri.ME_PATH; +import static kr.where.backend.api.http.Uri.PAGE_NUMBER; +import static kr.where.backend.api.http.Uri.PAGE_SIZE; +import static kr.where.backend.api.http.Uri.RANGE_BEGIN; +import static kr.where.backend.api.http.Uri.RANGE_END; +import static kr.where.backend.api.http.Uri.RANGE_LOGIN; +import static kr.where.backend.api.http.Uri.SORT; +import static kr.where.backend.api.http.Uri.TOKEN_PATH; +import static kr.where.backend.api.http.Uri.USERS_PATH; + +import java.net.URI; +import java.text.SimpleDateFormat; +import java.time.ZoneId; +import java.util.Calendar; +import java.util.Date; +import java.util.TimeZone; +import org.springframework.web.util.UriComponentsBuilder; + +public class UriBuilder { + + private static final String DATE_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"; + private static final int TIME_DIFFERENCE = -5; + private static final int LOGIN_COUNT = 100; + private static final int SEARCH_COUNT = 10; + + /** + * oauth Token 요청 URI 반환 + */ + public static URI token() { + return UriComponentsBuilder + .newInstance() + .scheme(HTTPS.getValue()) + .host(HOST.getValue()) + .path(TOKEN_PATH.getValue()) + .build() + .toUri(); + } + + /** + * 나의 정보를 조회하는 URI 반환 + */ + public static URI me() { + return UriComponentsBuilder + .newInstance() + .scheme(HTTPS.getValue()) + .host(HOST.getValue()) + .path(ME_PATH.getValue()) + .build() + .toUri(); + } + + /** + * 특정 42서울 카뎃 정보 요청하는 URI 반환 + */ + public static URI cadetInfo(final String intraName) { + return UriComponentsBuilder + .newInstance() + .scheme(HTTPS.getValue()) + .host(HOST.getValue()) + .path(CADET_PATH.getValue() + intraName) + .build() + .toUri(); + } + + + /** + * 42서울 카뎃의 이미지를 요청하는 URI 반환 + */ + public static URI image(final int page) { + return UriComponentsBuilder + .newInstance() + .scheme(HTTPS.getValue()) + .host(HOST.getValue()) + .path(USERS_PATH.getValue()) + .queryParam(SORT.getValue(), "login") + .queryParam(FILTER.getValue(), "student") + .queryParam(PAGE_SIZE.getValue(), LOGIN_COUNT) + .queryParam(PAGE_NUMBER.getValue(), page) + .build() + .toUri(); + } + + /** + * 42서울 클러스터 MAC에 로그인하고 있는 카뎃 별 정보 요청 URI 반환 + */ + public static URI loginCadet(final int page) { + return UriComponentsBuilder + .newInstance() + .scheme(HTTPS.getValue()) + .host(HOST.getValue()) + .path(LOCATIONS_PATH.getValue()) + .queryParam(PAGE_SIZE.getValue(), LOGIN_COUNT) + .queryParam(PAGE_NUMBER.getValue(), page) + .queryParam(SORT.getValue(), "-end_at") + .build() + .toUri(); + } + + /** + * 5분 이내 클러스터 아이맥에 로그인 혹은 로그아웃 한 카뎃 요청 URI 반환 + */ + public static URI loginBeforeFiveMinute(final int page, final boolean login) { + final Date currentTime = new Date(); + + final UriComponentsBuilder builder = + UriComponentsBuilder + .newInstance() + .scheme(HTTPS.getValue()) + .host(HOST.getValue()) + .path(LOCATIONS_PATH.getValue()) + .queryParam(PAGE_SIZE.getValue(), LOGIN_COUNT) + .queryParam(PAGE_NUMBER.getValue(), page); + + if (login) { + builder.queryParam(RANGE_BEGIN.getValue(), + formatDate(calculateMinute(currentTime)) + DELIMITER.getValue() + formatDate(currentTime)); + + return builder.build().toUri(); + } + builder.queryParam(RANGE_END.getValue(), + formatDate(calculateMinute(currentTime)) + DELIMITER.getValue() + formatDate(currentTime)); + + return builder.build().toUri(); + } + + private static Date calculateMinute(final Date date) { + final Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone(ZoneId.of("UTC"))); + calendar.setTime(date); + calendar.add(Calendar.MINUTE, TIME_DIFFERENCE); + + return calendar.getTime(); + } + + private static String formatDate(final Date date) { + final SimpleDateFormat dateFormat = new SimpleDateFormat(DATE_PATTERN); + dateFormat.setTimeZone(TimeZone.getTimeZone("GMT")); + + return dateFormat.format(date); + } + + /** + * 조회를 시작하려는 첫 단어와 마지막 단어를 param으로 받아, 검색하는 URI 반환 + */ + public static URI searchCadets(final String begin, final String end, final int page) { + return UriComponentsBuilder + .newInstance() + .scheme(HTTPS.getValue()) + .host(HOST.getValue()) + .path(USERS_PATH.getValue()) + .queryParam(SORT.getValue(), "login") + .queryParam(RANGE_LOGIN.getValue(), begin + DELIMITER.getValue() + end) + .queryParam(PAGE_SIZE.getValue(), SEARCH_COUNT) + .queryParam(PAGE_NUMBER.getValue(), page) + .build() + .toUri(); + } + + /** + * hane 요청 URI 생성 + */ + public static URI hane(final String name) { + return UriComponentsBuilder + .fromHttpUrl(HANE_PATH.getValue() + name) + .build() + .toUri(); + } +} diff --git a/src/main/java/kr/where/backend/api/http/Utils.java b/src/main/java/kr/where/backend/api/http/Utils.java new file mode 100644 index 0000000..fa6c698 --- /dev/null +++ b/src/main/java/kr/where/backend/api/http/Utils.java @@ -0,0 +1,40 @@ +package kr.where.backend.api.http; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class Utils { + + private static String clientId; + private static String secret; + private static String redirectUri; + + // 환경변수를 static 함수에서 static 변수로 사용하기 위한 방법 + @Value("${back.client-id}") + private void setClientId(final String clientId) { + this.clientId = clientId; + } + + @Value("${back.secret}") + private void setSecret(final String secret) { + this.secret = secret; + } + + @Value("${back.redirect-uri}") + private void setRedirectUri(final String redirectUri) { + this.redirectUri = redirectUri; + } + + public static String getClientId() { + return clientId; + } + + public static String getSecret() { + return secret; + } + + public static String getRedirectUri() { + return redirectUri; + } +} diff --git a/src/main/java/kr/where/backend/api/json/CadetPrivacy.java b/src/main/java/kr/where/backend/api/json/CadetPrivacy.java new file mode 100644 index 0000000..730f19c --- /dev/null +++ b/src/main/java/kr/where/backend/api/json/CadetPrivacy.java @@ -0,0 +1,74 @@ +package kr.where.backend.api.json; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import java.util.Map; + +import lombok.*; + +@Getter +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema(description = "42seoul opnen API에 요청한 카뎃 정보") +@AllArgsConstructor +public class CadetPrivacy { + private final static Integer SEOUL_CAMPUS_ID = 29; + @Schema(description = "카뎃의 고유 intra id") + private Integer id; + @Schema(description = "카뎃 Intra 아이디") + private String login; + @Schema(description = "카뎃의 클러스터 위치") + private String location; + @Schema(description = "카뎃 이미지 URL") + private Image image; + @JsonProperty("active?") + @Schema(description = "카뎃의 블랙홀 상태") + private boolean active; + @Schema(description = "42서울 등록일") + private String created_at; + @Schema(description = "캠퍼스 소속") + private Integer campus; + protected CadetPrivacy() {} + + @Builder + public CadetPrivacy( + final Integer id, final String login, final String location, + final String small_image, final boolean active, final String created_at, + final Integer campusId + ) { + this.id = id; + this.login = login; + this.location = location; + this.image = Image.create(Versions.create(small_image)); + this.active = active; + this.created_at = created_at; + this.campus = campusId; + } + + public static CadetPrivacy of(final Map attributes) { + Map image = (Map) attributes.get("image"); + String smallUrl = ""; + if (image != null) { + Map versions = (Map) image.get("versions"); + if (versions != null) { + smallUrl = versions.get("small"); + } + } + List> campus = (List>) attributes.get("campus"); + + return CadetPrivacy.builder() + .id((Integer) attributes.get("id")) + .login((String) attributes.get("login")) + .location((String) attributes.get("location")) + .small_image(smallUrl) + .active(attributes.get("active") != null && (boolean) attributes.get("active")) + .created_at((String) attributes.get("created_at")) + .campusId((Integer) campus.get(0).get("id")) + .build(); + } + + public void setSeoulCampus() { + this.campus = SEOUL_CAMPUS_ID; + } +} diff --git a/src/main/java/kr/where/backend/api/json/Cluster.java b/src/main/java/kr/where/backend/api/json/Cluster.java new file mode 100644 index 0000000..8b2cc26 --- /dev/null +++ b/src/main/java/kr/where/backend/api/json/Cluster.java @@ -0,0 +1,13 @@ +package kr.where.backend.api.json; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Getter; + +@Getter +@JsonIgnoreProperties(ignoreUnknown = true) +public class Cluster { + private Integer id; + private String end_at; + private String begin_at; + private User user; +} diff --git a/src/main/java/kr/where/backend/api/json/Image.java b/src/main/java/kr/where/backend/api/json/Image.java new file mode 100644 index 0000000..6962992 --- /dev/null +++ b/src/main/java/kr/where/backend/api/json/Image.java @@ -0,0 +1,19 @@ +package kr.where.backend.api.json; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Getter; + +@Getter +@JsonIgnoreProperties(ignoreUnknown = true) +public class Image { + private Versions versions; + + //test + public static Image create(Versions versions) { + Image image = new Image(); + + image.versions = versions; + + return image; + } +} diff --git a/src/main/java/kr/where/backend/api/json/OAuthTokenDto.java b/src/main/java/kr/where/backend/api/json/OAuthTokenDto.java new file mode 100644 index 0000000..53dfb65 --- /dev/null +++ b/src/main/java/kr/where/backend/api/json/OAuthTokenDto.java @@ -0,0 +1,14 @@ +package kr.where.backend.api.json; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Getter; + +@Getter +@JsonIgnoreProperties(ignoreUnknown = true) +public class OAuthTokenDto { + private String access_token; + private String token_type; + private int expires_in; + private String refresh_token; + private int created_at; +} diff --git a/src/main/java/kr/where/backend/api/json/User.java b/src/main/java/kr/where/backend/api/json/User.java new file mode 100644 index 0000000..1e3269d --- /dev/null +++ b/src/main/java/kr/where/backend/api/json/User.java @@ -0,0 +1,13 @@ +package kr.where.backend.api.json; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Getter; + +@Getter +@JsonIgnoreProperties(ignoreUnknown = true) +public class User { + private Integer id; + private String login; + private Image image; + private String location; +} diff --git a/src/main/java/kr/where/backend/api/json/Versions.java b/src/main/java/kr/where/backend/api/json/Versions.java new file mode 100644 index 0000000..219d767 --- /dev/null +++ b/src/main/java/kr/where/backend/api/json/Versions.java @@ -0,0 +1,19 @@ +package kr.where.backend.api.json; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Getter; + +@Getter +@JsonIgnoreProperties(ignoreUnknown = true) +public class Versions { + private String small; + + //test + public static Versions create(String small) { + Versions versions = new Versions(); + + versions.small = small; + + return versions; + } +} diff --git a/src/main/java/kr/where/backend/api/json/hane/Hane.java b/src/main/java/kr/where/backend/api/json/hane/Hane.java new file mode 100644 index 0000000..7d0a84e --- /dev/null +++ b/src/main/java/kr/where/backend/api/json/hane/Hane.java @@ -0,0 +1,18 @@ +package kr.where.backend.api.json.hane; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Getter; + +@Getter +@JsonIgnoreProperties(ignoreUnknown = true) +public class Hane { + private String inoutState; + + public static Hane create(final String inoutState) { + Hane hane = new Hane(); + + hane.inoutState = inoutState; + + return hane; + } +} \ No newline at end of file diff --git a/src/main/java/kr/where/backend/api/json/hane/HaneRequestDto.java b/src/main/java/kr/where/backend/api/json/hane/HaneRequestDto.java new file mode 100644 index 0000000..8530c34 --- /dev/null +++ b/src/main/java/kr/where/backend/api/json/hane/HaneRequestDto.java @@ -0,0 +1,14 @@ +package kr.where.backend.api.json.hane; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class HaneRequestDto { + private String login; + + public HaneRequestDto(final String login) { + this.login = login; + } +} diff --git a/src/main/java/kr/where/backend/api/json/hane/HaneResponseDto.java b/src/main/java/kr/where/backend/api/json/hane/HaneResponseDto.java new file mode 100644 index 0000000..c3d68cc --- /dev/null +++ b/src/main/java/kr/where/backend/api/json/hane/HaneResponseDto.java @@ -0,0 +1,16 @@ +package kr.where.backend.api.json.hane; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Getter; + +@Getter +@JsonIgnoreProperties(ignoreUnknown = true) +public class HaneResponseDto { + private String login; + private String inoutState; + + @Override + public String toString() { + return "[hane] : login : " + this.login + ", inOrOutState : " + inoutState; + } +} diff --git a/src/main/java/kr/where/backend/auth/authUser/AuthUser.java b/src/main/java/kr/where/backend/auth/authUser/AuthUser.java new file mode 100644 index 0000000..1208dd6 --- /dev/null +++ b/src/main/java/kr/where/backend/auth/authUser/AuthUser.java @@ -0,0 +1,49 @@ +package kr.where.backend.auth.authUser; + +import kr.where.backend.auth.authUser.exception.AuthUserException; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.security.core.context.SecurityContextHolder; + +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AuthUser { + private Integer intraId; + private String intraName; + private Long defaultGroupId; + + @Builder + public AuthUser(final Integer intraId, final String intraName, final Long defaultGroupId) { + this.intraId = intraId; + this.intraName = intraName; + this.defaultGroupId = defaultGroupId; + } + + public Integer getIntraId() { + return intraId; + } + + public String getIntraName() { + return intraName; + } + + public Long getDefaultGroupId() { + if (defaultGroupId != null) { + return defaultGroupId; + } + throw new AuthUserException.ForbiddenUserException(); + } + + public void setDefaultGroupId(final Long groupId) { + this.defaultGroupId = groupId; + } + public static AuthUser of() { + final Object principle = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + if (principle.equals("anonymousUser")) { + throw new AuthUserException.AnonymousUserException(); + } + return (AuthUser) principle; + } +} diff --git a/src/main/java/kr/where/backend/auth/authUser/AuthUserInfo.java b/src/main/java/kr/where/backend/auth/authUser/AuthUserInfo.java new file mode 100644 index 0000000..e679954 --- /dev/null +++ b/src/main/java/kr/where/backend/auth/authUser/AuthUserInfo.java @@ -0,0 +1,13 @@ +package kr.where.backend.auth.authUser; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.PARAMETER) +@AuthenticationPrincipal +public @interface AuthUserInfo { +} diff --git a/src/main/java/kr/where/backend/auth/authUser/exception/AuthUserErrorCode.java b/src/main/java/kr/where/backend/auth/authUser/exception/AuthUserErrorCode.java new file mode 100644 index 0000000..453da18 --- /dev/null +++ b/src/main/java/kr/where/backend/auth/authUser/exception/AuthUserErrorCode.java @@ -0,0 +1,15 @@ +package kr.where.backend.auth.authUser.exception; + +import kr.where.backend.exception.ErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum AuthUserErrorCode implements ErrorCode { + FORBIDDEN_USER(1800, "Where42 서비스에 동의한 user가 아닙니다."), + ANONYMOUS_USER(1801, "인가 인증을 받지 않은 user 입니다"); + + private final int errorCode; + private final String errorMessage; +} diff --git a/src/main/java/kr/where/backend/auth/authUser/exception/AuthUserException.java b/src/main/java/kr/where/backend/auth/authUser/exception/AuthUserException.java new file mode 100644 index 0000000..38a08d8 --- /dev/null +++ b/src/main/java/kr/where/backend/auth/authUser/exception/AuthUserException.java @@ -0,0 +1,21 @@ +package kr.where.backend.auth.authUser.exception; + +import kr.where.backend.exception.CustomException; + +public class AuthUserException extends CustomException { + public AuthUserException(final AuthUserErrorCode authUserErrorCode) { + super(authUserErrorCode); + } + + public static class ForbiddenUserException extends AuthUserException { + public ForbiddenUserException() { + super(AuthUserErrorCode.FORBIDDEN_USER); + } + } + + public static class AnonymousUserException extends AuthUserException { + public AnonymousUserException() { + super(AuthUserErrorCode.ANONYMOUS_USER); + } + } +} diff --git a/src/main/java/kr/where/backend/auth/filter/JwtConstants.java b/src/main/java/kr/where/backend/auth/filter/JwtConstants.java new file mode 100644 index 0000000..431aff1 --- /dev/null +++ b/src/main/java/kr/where/backend/auth/filter/JwtConstants.java @@ -0,0 +1,24 @@ +package kr.where.backend.auth.filter; + +public enum JwtConstants { + HEADER_TYPE("Bearer"), + ACCESS("accessToken"), + REFRESH("refreshToken"), + USER_SUBJECTS("User"), + USER_ID("intraId"), + USER_NAME("intraName"), + USER_ROLE("Cadet"), + TOKEN_TYPE("type"), + ROLE_LEVEL("roles"), + REISSUE_URI("/v3/jwt/reissue"); + + private final String value; + + JwtConstants(final String value) { + this.value = value; + } + + public String getValue() { + return this.value; + } +} diff --git a/src/main/java/kr/where/backend/auth/filter/JwtExceptionFilter.java b/src/main/java/kr/where/backend/auth/filter/JwtExceptionFilter.java new file mode 100644 index 0000000..5d6fb69 --- /dev/null +++ b/src/main/java/kr/where/backend/auth/filter/JwtExceptionFilter.java @@ -0,0 +1,49 @@ +package kr.where.backend.auth.filter; + +import static com.fasterxml.jackson.core.JsonEncoding.UTF8; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import kr.where.backend.jwt.exception.JwtException; +import kr.where.backend.member.exception.MemberException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@RequiredArgsConstructor +public class JwtExceptionFilter extends OncePerRequestFilter { + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal( + final HttpServletRequest request, + final HttpServletResponse response, + final FilterChain filterChain + ) throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (final JwtException e) { + sendRequest(response, HttpServletResponse.SC_UNAUTHORIZED, e.toString()); + } catch (final MemberException e) { + sendRequest(response, HttpServletResponse.SC_NOT_FOUND, e.toString()); + } + } + + private void sendRequest( + final HttpServletResponse response, + final int errorCode, + final String error + ) throws IOException { + response.setStatus(errorCode); + response.setCharacterEncoding(UTF8.getJavaName()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + response.getWriter().write(objectMapper.writeValueAsString(error)); + } +} diff --git a/src/main/java/kr/where/backend/auth/filter/JwtFilter.java b/src/main/java/kr/where/backend/auth/filter/JwtFilter.java new file mode 100644 index 0000000..f04399c --- /dev/null +++ b/src/main/java/kr/where/backend/auth/filter/JwtFilter.java @@ -0,0 +1,37 @@ +package kr.where.backend.auth.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import kr.where.backend.jwt.JwtService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +@Slf4j +public class JwtFilter extends OncePerRequestFilter { + private final JwtService jwtService; + + @Override + protected void doFilterInternal( + final HttpServletRequest request, + final HttpServletResponse response, + final FilterChain filterChain) + throws ServletException, IOException { + + final String token = jwtService.extractToken(request).orElse(null); + if (token != null) { + final Authentication auth = jwtService.getAuthentication(request, token); + SecurityContextHolder.getContext().setAuthentication(auth); + } + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/kr/where/backend/auth/filter/exception/CustomAccessDeniedHandler.java b/src/main/java/kr/where/backend/auth/filter/exception/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..a530b1e --- /dev/null +++ b/src/main/java/kr/where/backend/auth/filter/exception/CustomAccessDeniedHandler.java @@ -0,0 +1,38 @@ +package kr.where.backend.auth.filter.exception; + +import static com.fasterxml.jackson.core.JsonEncoding.UTF8; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + private final ObjectMapper objectMapper; + + @Override + public void handle(final HttpServletRequest request, + final HttpServletResponse response, + final AccessDeniedException accessDeniedException) throws IOException { + //필요한 권한이 없이 접근하려 할때 403 + sendErrorResponse(response); +// response.sendError(HttpServletResponse.SC_FORBIDDEN); + } + + private void sendErrorResponse(final HttpServletResponse response) throws IOException{ + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setCharacterEncoding(UTF8.getJavaName()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + response.getWriter().write(objectMapper.writeValueAsString("접근 권한을 확인하세요.")); + } +} diff --git a/src/main/java/kr/where/backend/auth/filter/exception/JwtAuthenticationEntryPoint.java b/src/main/java/kr/where/backend/auth/filter/exception/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..85c29b8 --- /dev/null +++ b/src/main/java/kr/where/backend/auth/filter/exception/JwtAuthenticationEntryPoint.java @@ -0,0 +1,40 @@ +package kr.where.backend.auth.filter.exception; + +import static com.fasterxml.jackson.core.JsonEncoding.UTF8; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import kr.where.backend.exception.CustomException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerExceptionResolver; + +@Slf4j +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + private final HandlerExceptionResolver exceptionResolver; + + public JwtAuthenticationEntryPoint(@Qualifier("handlerExceptionResolver") final HandlerExceptionResolver exceptionResolver) { + this.exceptionResolver = exceptionResolver; + } + @Override + public void commence(final HttpServletRequest request, final HttpServletResponse response, + final AuthenticationException authException) throws IOException, ServletException { + String error = (String) request.getAttribute("exception"); + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + + response.setCharacterEncoding(UTF8.getJavaName()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + ObjectMapper objectMapper = new ObjectMapper(); + response.getWriter().write(objectMapper.writeValueAsString(error)); + } +} diff --git a/src/main/java/kr/where/backend/auth/oauth2login/CustomOauth2UserService.java b/src/main/java/kr/where/backend/auth/oauth2login/CustomOauth2UserService.java new file mode 100644 index 0000000..fb9254d --- /dev/null +++ b/src/main/java/kr/where/backend/auth/oauth2login/CustomOauth2UserService.java @@ -0,0 +1,35 @@ +package kr.where.backend.auth.oauth2login; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.Map; + +@RequiredArgsConstructor +@Service +@Slf4j +public class CustomOauth2UserService implements OAuth2UserService { + + @Override + public UserProfile loadUser(final OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + final OAuth2UserService oAuth2UserService = new DefaultOAuth2UserService(); + final OAuth2User oAuth2User = oAuth2UserService.loadUser(userRequest); + + final Map attributes = oAuth2User.getAttributes(); + final String registrationId = userRequest.getClientRegistration().getRegistrationId(); + + return new UserProfile( + registrationId, + Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")), + attributes + ); + } +} diff --git a/src/main/java/kr/where/backend/auth/oauth2login/OAuth2FailureHandler.java b/src/main/java/kr/where/backend/auth/oauth2login/OAuth2FailureHandler.java new file mode 100644 index 0000000..693b786 --- /dev/null +++ b/src/main/java/kr/where/backend/auth/oauth2login/OAuth2FailureHandler.java @@ -0,0 +1,23 @@ +package kr.where.backend.auth.oauth2login; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; +import java.rmi.RemoteException; + +@Component +public class OAuth2FailureHandler extends SimpleUrlAuthenticationFailureHandler { + + @Override + public void onAuthenticationFailure( + HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) + throws IOException, ServletException { + response.sendRedirect("https://where42.kr/login-fail"); + } +} diff --git a/src/main/java/kr/where/backend/auth/oauth2login/OAuth2SuccessHandler.java b/src/main/java/kr/where/backend/auth/oauth2login/OAuth2SuccessHandler.java new file mode 100644 index 0000000..45a9f8a --- /dev/null +++ b/src/main/java/kr/where/backend/auth/oauth2login/OAuth2SuccessHandler.java @@ -0,0 +1,74 @@ +package kr.where.backend.auth.oauth2login; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import kr.where.backend.api.json.CadetPrivacy; +import kr.where.backend.auth.filter.JwtConstants; +import kr.where.backend.auth.oauth2login.cookie.CookieShop; +import kr.where.backend.jwt.JwtService; +import kr.where.backend.member.Member; +import kr.where.backend.member.MemberService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +@Component +public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + private static final int ACCESS_EXPIRY = 30 * 60; + private static final int REFRESH_EXPIRY = 14 * 24 * 60 * 60; + private final MemberService memberService; + private final JwtService jwtService; + + @Override + public void onAuthenticationSuccess(final HttpServletRequest request, final HttpServletResponse response, + final Authentication authentication) throws IOException, ServletException { + + final UserProfile userProfile = (UserProfile) authentication.getPrincipal(); + + log.info("Principal에서 꺼낸 OAuth2User Name= {}", userProfile.getName()); + + final CadetPrivacy cadetPrivacy = CadetPrivacy.of(userProfile.getAttributes()); + final Member member = memberService.findOne(cadetPrivacy.getId()) + .orElseGet( + () -> memberService.createDisagreeMember(cadetPrivacy) + ); + + //jwt 발행 + log.info("JWT 토큰 발행 시작"); + + CookieShop.bakedCookie(response, + JwtConstants.ACCESS.getValue(), + ACCESS_EXPIRY, + jwtService.createAccessToken(cadetPrivacy.getId(), cadetPrivacy.getLogin()), + false + ); + + if (member.isAgree()) { + CookieShop.bakedCookie(response, + JwtConstants.REFRESH.getValue(), + REFRESH_EXPIRY, + jwtService.createRefreshToken(cadetPrivacy.getId(), cadetPrivacy.getLogin()), + true + ); + } + getRedirectStrategy() + .sendRedirect( + request, + response, + UriComponentsBuilder + .fromUriString("https://where42.kr") + .queryParam("intraId", member.getIntraId()) + .queryParam("agreement", member.isAgree()) + .build() + .toUriString() + ); + } +} diff --git a/src/main/java/kr/where/backend/auth/oauth2login/UserProfile.java b/src/main/java/kr/where/backend/auth/oauth2login/UserProfile.java new file mode 100644 index 0000000..e4f5b09 --- /dev/null +++ b/src/main/java/kr/where/backend/auth/oauth2login/UserProfile.java @@ -0,0 +1,34 @@ +package kr.where.backend.auth.oauth2login; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.Collection; +import java.util.Map; + +@Getter +@Builder +@AllArgsConstructor +public class UserProfile implements OAuth2User { + private String name; + private Collection authorities; + private Map attributes; + + @Override + public Map getAttributes() { + return this.attributes; + } + + @Override + public Collection getAuthorities() { + return this.authorities; + } + + @Override + public String getName() { + return this.name; + } +} diff --git a/src/main/java/kr/where/backend/auth/oauth2login/cookie/CookieShop.java b/src/main/java/kr/where/backend/auth/oauth2login/cookie/CookieShop.java new file mode 100644 index 0000000..e19563d --- /dev/null +++ b/src/main/java/kr/where/backend/auth/oauth2login/cookie/CookieShop.java @@ -0,0 +1,23 @@ +package kr.where.backend.auth.oauth2login.cookie; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; + +public class CookieShop { + public static void bakedCookie( + final HttpServletResponse response, + final String key, + final int expiry, + final String token, + final boolean http) { + final Cookie cookie = new Cookie(key, token); + + cookie.setDomain("where42.kr"); + cookie.setMaxAge(expiry); + cookie.setPath("/"); + cookie.setHttpOnly(http); + cookie.setSecure(true); + + response.addCookie(cookie); + } +} diff --git a/src/main/java/kr/where/backend/configuration/SecurityConfig.java b/src/main/java/kr/where/backend/configuration/SecurityConfig.java new file mode 100644 index 0000000..e8103dd --- /dev/null +++ b/src/main/java/kr/where/backend/configuration/SecurityConfig.java @@ -0,0 +1,140 @@ +package kr.where.backend.configuration; + +import java.util.List; +import kr.where.backend.auth.filter.exception.CustomAccessDeniedHandler; +import kr.where.backend.auth.filter.JwtExceptionFilter; +import kr.where.backend.auth.filter.JwtFilter; +import kr.where.backend.auth.oauth2login.CustomOauth2UserService; +import kr.where.backend.auth.oauth2login.OAuth2FailureHandler; +import kr.where.backend.auth.oauth2login.OAuth2SuccessHandler; +import kr.where.backend.jwt.JwtService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsUtils; +import org.springframework.web.servlet.handler.HandlerMappingIntrospector; + +import java.util.Collections; + +/** + * spring security 설정 config + * @author parksuhwan + * + * 42api를 통한 기존의 수동 로그인을 OAuth2을 통해 자동적으로 실행하는 oauth2 login 적용 + * session, cookie를 사용하지 않고 stateless하게 사용하기 위해 jwt 적용 + * flow : 모든 api 요청 -> security filter를 통한 인가, 인증 진행 -> http header에 jwt 가 있다면 + * custom filter 사용, 없다면 OAuth2 login 사용을 통한 token 발급 + */ + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +@Slf4j +public class SecurityConfig { + private final JwtService jwtService; + private final CustomOauth2UserService customOauth2UserService; + private final JwtExceptionFilter jwtExceptionFilter; + private final OAuth2SuccessHandler successHandler; + private final OAuth2FailureHandler failureHandler; + private final CustomAccessDeniedHandler accessDeniedHandler; + /** + * + * @param httpSecurity : security를 사용할 떄 옵션 적용. 인증인가 실행할때 예외 api 설정, filter 순서 설정을 위한 param + * @param introspector : mvc를 사용하기에 MvcRequestMatcher를 사용하기 위한 param + * @see #securityFilterChain(HttpSecurity, HandlerMappingIntrospector) + * .csrf(custom -> custom.configurationSource(request....)) + * cors Error에 대한 설정을 해준다. + * setAllowedOriginPatterns() Spring Security 6.x 이상 사용할 때는 setAllowedOrigin()은 사용하지 않는다. + * : 접근 허용한 Origins URL을 set 해준다. + * setAllowedMethods() : 허용 Method를 설정 + * setAllowCredentials() : 쿠키 사용한다면 true로 설정 + * setExposedHeaders() : clients에게 보여줄 header 설정 + * setAllowedHeaders() : 허용 header를 설정 + * setMaxAge() : preflight 요청 결과를 설정한 시간동안 캐시에 저장 (그 시간에는 prflight를 확인하지 않음) + * .formLogin(AbstractHttpConfigurer::disable) + * custom한 jwtFilter를 적용하기 위해 기존의 formLogin은 사용하지 않는다 + * .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + * session은 사용하지 않는다. + * .authorizeHttpRequests() + * authorizeHttpRequest ? -> http 요청에 대한 설정을 구성하는 것 이를 통해 다양한 인가 규칙 및 경로별 권한 설정 가능 + * requestmathcer : 특정 경로나 URL의 패턴에 대한 인가 규칙을 설정 + * permitAll : 특정 uri를 허용하는 함수, where42에서는 oAuth로그인 관련, swagger 관련만 열어 놓는다 + * anyRequest().authenticated() : 다른 접근은 다 인증을 필요로한다 + * .oauth2Login() + * OAuth 로그인을 실행 + * userInfoEndpoint을 실행 할때, 유저정보를 저장한다. + * successHandler 로그인을 성공했을 때, 실행하는 로직 + * failureHandler 로그인 실패 했을 때, 실행하는 로직 + * .logout(logout -> logout.clearAuthentication(true)) + * 로그아웃을 하면 security context holder의 정보를 지운다 + * .addFilterBefore(new JwtFilter(jwtService), UsernamePasswordAuthenticationFilter.class) + * oauth 로그인을 하기전에 where42 service에서 인가인증 받은 token이 있다면, token으로 인가인증 실행 하는 filter + * .addFilterBefore(jwtExceptionFilter, JwtFilter.class); + * jwtfilter를 실행할 때 token에 대한 예외 처리를 처리하는 filter + * + * @return HttpSecurity : custom한 security 설정 반환 + * @throws Exception : 예외 던짐 + */ + @Bean + public SecurityFilterChain securityFilterChain( + final HttpSecurity httpSecurity, + final HandlerMappingIntrospector introspector) throws Exception { + + final MvcRequestMatcher.Builder requestMatcher = new MvcRequestMatcher.Builder(introspector); + + return httpSecurity + .csrf(AbstractHttpConfigurer::disable) + .cors(custom -> custom.configurationSource(request -> { + final CorsConfiguration config = new CorsConfiguration(); + + config.setAllowedOriginPatterns(Collections.singletonList("*")); + config.setAllowedMethods(Collections.singletonList("*")); + config.setAllowCredentials(true); + config.setExposedHeaders(List.of("x-amz-server-side-encryption", "x-amz-request-id", "x-amz-id-2")); + config.setAllowedHeaders(Collections.singletonList("*")); + config.setMaxAge(3600L); + return config; + })) + .formLogin(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests( + authorize -> authorize + .requestMatchers(requestMatcher.pattern("/oauth2/**")) + .permitAll() + .requestMatchers(requestMatcher.pattern("/swagger-ui/**")) + .permitAll() + .requestMatchers(requestMatcher.pattern("/v3/api-docs/**")) + .permitAll() + .requestMatchers(requestMatcher.pattern("/v3/token")) + .permitAll() + .requestMatchers(requestMatcher.pattern("/actuator/prometheus")) + .permitAll() + .requestMatchers(requestMatcher.pattern("/v3/jwt/reissue")) + .permitAll() + .requestMatchers(CorsUtils::isPreFlightRequest) + .permitAll() + .anyRequest() + .authenticated() + ) + .oauth2Login(oauth -> oauth + .userInfoEndpoint(user -> user.userService(customOauth2UserService)) + .successHandler(successHandler) + .failureHandler(failureHandler) + ) + .logout(logout -> logout.clearAuthentication(true)) + .addFilterBefore(new JwtFilter(jwtService), UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(jwtExceptionFilter, JwtFilter.class) + .exceptionHandling(exception-> exception.accessDeniedHandler(accessDeniedHandler)) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/kr/where/backend/configuration/SwaggerConfig.java b/src/main/java/kr/where/backend/configuration/SwaggerConfig.java new file mode 100644 index 0000000..237eb17 --- /dev/null +++ b/src/main/java/kr/where/backend/configuration/SwaggerConfig.java @@ -0,0 +1,37 @@ +package kr.where.backend.configuration; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.Arrays; +import java.util.List; + +@OpenAPIDefinition( + info = @Info(title = "where42 API 명세서", + description = "어디있니 서비스 API 명세서", + version = "v2")) +@Configuration +public class SwaggerConfig { + @Bean + public OpenAPI openAPI(){ + SecurityScheme securityScheme = new SecurityScheme() + .type(SecurityScheme.Type.HTTP).scheme("bearer").bearerFormat("JWT") + .in(SecurityScheme.In.HEADER).name("Authorization"); + SecurityRequirement securityRequirement = new SecurityRequirement().addList("bearerAuth"); + + Server server = new Server(); + server.setUrl("https://api.where42.kr"); + + return new OpenAPI() + .components(new Components().addSecuritySchemes("bearerAuth", securityScheme)) + .security(List.of(securityRequirement)) + .servers(List.of(server)); + } +} \ No newline at end of file diff --git a/src/main/java/kr/where/backend/exception/CustomException.java b/src/main/java/kr/where/backend/exception/CustomException.java new file mode 100644 index 0000000..7fac23c --- /dev/null +++ b/src/main/java/kr/where/backend/exception/CustomException.java @@ -0,0 +1,16 @@ +package kr.where.backend.exception; + +import lombok.Getter; +import lombok.ToString; + +@Getter +@ToString +public class CustomException extends RuntimeException{ + private final int errorCode; + private final String errorMessage; + + public & ErrorCode> CustomException(final T errorType) { + errorCode = errorType.getErrorCode(); + errorMessage = errorType.getErrorMessage(); + } +} diff --git a/src/main/java/kr/where/backend/exception/ErrorCode.java b/src/main/java/kr/where/backend/exception/ErrorCode.java new file mode 100644 index 0000000..f086301 --- /dev/null +++ b/src/main/java/kr/where/backend/exception/ErrorCode.java @@ -0,0 +1,6 @@ +package kr.where.backend.exception; + +public interface ErrorCode { + int getErrorCode(); + String getErrorMessage(); +} diff --git a/src/main/java/kr/where/backend/exception/ExceptionHandleController.java b/src/main/java/kr/where/backend/exception/ExceptionHandleController.java new file mode 100644 index 0000000..68a191c --- /dev/null +++ b/src/main/java/kr/where/backend/exception/ExceptionHandleController.java @@ -0,0 +1,132 @@ +package kr.where.backend.exception; + +import kr.where.backend.auth.authUser.exception.AuthUserException; +import kr.where.backend.exception.httpError.HttpResourceErrorCode; +import kr.where.backend.exception.httpError.HttpResourceException; +import kr.where.backend.group.exception.GroupException; +import kr.where.backend.group.exception.GroupMemberException; +import kr.where.backend.join.exception.JoinException; +import kr.where.backend.jwt.exception.JwtException; +import kr.where.backend.member.exception.MemberException; +import kr.where.backend.api.exception.JsonException; +import kr.where.backend.api.exception.RequestException; +import kr.where.backend.oauthtoken.exception.OAuthTokenException; +import kr.where.backend.search.exception.SearchException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +@Slf4j +public class ExceptionHandleController { + + @ExceptionHandler({MemberException.NoMemberException.class, GroupException.NoGroupException.class}) + public ResponseEntity handleNoResultException(final CustomException e) { + log.info(e.toString()); + + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.toString()); + } + + @ExceptionHandler({MemberException.DuplicatedMemberException.class, GroupException.DuplicatedGroupNameException.class, + GroupMemberException.class}) + public ResponseEntity handleDuplicatedException(final CustomException e) { + log.info(e.toString()); + + return ResponseEntity.status(HttpStatus.CONFLICT).body(e.toString()); + } + + @ExceptionHandler(GroupException.CannotModifyGroupException.class) + public ResponseEntity handleCannotModifiedException(final CustomException e) { + log.info(e.toString()); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.toString()); + } + + @ExceptionHandler(OAuthTokenException.class) + public ResponseEntity handleOAuthTokenException(final CustomException e) { + log.info(e.toString()); + + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.toString()); + } + + @ExceptionHandler(JwtException.class) + public ResponseEntity handleJwtTokenException(final CustomException e) { + log.info(e.toString()); + + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.toString()); + } + + @ExceptionHandler(RequestException.class) + public ResponseEntity handleBadRequestException(final CustomException e) { + log.info(e.toString()); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.toString()); + } + + @ExceptionHandler(JsonException.class) + public ResponseEntity handleJsonException(final CustomException e) { + log.info(e.toString()); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.toString()); + } + + @ExceptionHandler(JoinException.class) + public ResponseEntity handleJoinException(final CustomException e) { + log.info(e.toString()); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.toString()); + } + + @ExceptionHandler(SearchException.class) + public ResponseEntity handleSearchException(final CustomException e) { + log.info(e.toString()); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.toString()); + } + + @ExceptionHandler(AuthUserException.class) + public ResponseEntity handleAuthUserException(final CustomException e) { + log.info(e.toString()); + + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.toString()); + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity handleMissingParameterException() { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(HttpResourceException.of(HttpResourceErrorCode.NO_PARAMETERS)); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleNoRequestBodyException() { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(HttpResourceException.of(HttpResourceErrorCode.NO_REQUEST_BODY)); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity handleUnsupportedMethodException() { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(HttpResourceException.of(HttpResourceErrorCode.NO_SUPPORTED_METHOD)); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException() { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(HttpResourceException.of(HttpResourceErrorCode.NOT_METHOD_VALID_ARGUMENT)); + } + + @ExceptionHandler(RuntimeException.class) + public ResponseEntity handleNoResourceException() { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("관리자에게 요청하세요."); + } +} diff --git a/src/main/java/kr/where/backend/exception/httpError/HttpResourceErrorCode.java b/src/main/java/kr/where/backend/exception/httpError/HttpResourceErrorCode.java new file mode 100644 index 0000000..a656ff2 --- /dev/null +++ b/src/main/java/kr/where/backend/exception/httpError/HttpResourceErrorCode.java @@ -0,0 +1,18 @@ +package kr.where.backend.exception.httpError; + +import kr.where.backend.exception.ErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum HttpResourceErrorCode implements ErrorCode { + + NO_PARAMETERS(1700, "파라미터가 없는 Api 요청입니다."), + NO_REQUEST_BODY(1701, "Request body가 없는 Api 요청입니다."), + NO_SUPPORTED_METHOD(1702, "지원하지 않는 Http Method 요청입니다."), + NOT_METHOD_VALID_ARGUMENT(1703, "Method에 맞는 arguments가 없습니다"); + + private final int errorCode; + private final String errorMessage; +} diff --git a/src/main/java/kr/where/backend/exception/httpError/HttpResourceException.java b/src/main/java/kr/where/backend/exception/httpError/HttpResourceException.java new file mode 100644 index 0000000..f815678 --- /dev/null +++ b/src/main/java/kr/where/backend/exception/httpError/HttpResourceException.java @@ -0,0 +1,11 @@ +package kr.where.backend.exception.httpError; + +import kr.where.backend.exception.ErrorCode; + +public class HttpResourceException { + public static String of(final ErrorCode errorCode) { + return "CustomException(errorCode=" + errorCode.getErrorCode() + + ", " + "errorMessage=" + + errorCode.getErrorMessage() + ")"; + } +} diff --git a/src/main/java/kr/where/backend/group/GroupController.java b/src/main/java/kr/where/backend/group/GroupController.java new file mode 100644 index 0000000..60d88ac --- /dev/null +++ b/src/main/java/kr/where/backend/group/GroupController.java @@ -0,0 +1,111 @@ +package kr.where.backend.group; + +import jakarta.validation.Valid; +import java.util.List; +import kr.where.backend.auth.authUser.AuthUser; +import kr.where.backend.auth.authUser.AuthUserInfo; +import kr.where.backend.group.dto.groupmember.*; +import kr.where.backend.group.dto.group.CreateGroupDTO; +import kr.where.backend.group.dto.group.ResponseGroupDTO; +import kr.where.backend.group.dto.group.UpdateGroupDTO; +import kr.where.backend.group.swagger.GroupApiDocs; +import lombok.AllArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/v3/group") +@AllArgsConstructor +public class GroupController implements GroupApiDocs { + + private final GroupService groupService; + private final GroupMemberService groupMemberService; + + @PostMapping("") + public ResponseEntity createGroup( + @RequestBody @Valid final CreateGroupDTO request, + @AuthUserInfo final AuthUser authUser) { + final ResponseGroupDTO dto = groupService.createGroup(request, authUser); + + return ResponseEntity.status(HttpStatus.CREATED).body(dto); + } + //완성 + + @GetMapping("") + public ResponseEntity> findAllGroups(@AuthUserInfo final AuthUser authUser) { + return ResponseEntity + .status(HttpStatus.OK) + .body(groupService.getGroupList(authUser)); + } + + @PostMapping("/name") + public ResponseEntity updateGroupName( + @RequestBody @Valid final UpdateGroupDTO dto, + @AuthUserInfo final AuthUser authUser) { + final ResponseGroupDTO responseGroupDto = groupService.updateGroup(dto, authUser); + + return ResponseEntity.status(HttpStatus.OK).body(responseGroupDto); + } + + @DeleteMapping("") + public ResponseEntity deleteGroup( + @RequestParam("groupId") final Long groupId, + @AuthUserInfo final AuthUser authUser) { + final ResponseGroupDTO responseGroupDto = groupService.deleteGroup(groupId, authUser); + + return ResponseEntity.status(HttpStatus.OK).body(responseGroupDto); + } + + @GetMapping("/info") + public ResponseEntity> findGroupNames(@AuthUserInfo final AuthUser authUser) { + final List dto = groupMemberService.findGroupsInfoByIntraId(authUser); + + return ResponseEntity.status(HttpStatus.OK).body(dto); + } + + @PostMapping("/groupmember") + public ResponseEntity createGroupMember( + @RequestParam("intraId") final Integer intraId, + @AuthUserInfo final AuthUser authUser) { + final ResponseGroupMemberDTO dto = groupMemberService.createDefaultGroupMember(intraId, false, authUser); + + return ResponseEntity.status(HttpStatus.CREATED).body(dto); + } + + @PostMapping("/groupmember/not-ingroup") + public ResponseEntity> findMemberListNotInGroup( + @RequestParam("groupId") final Long groupId, + @AuthUserInfo final AuthUser authUser) { + final List groupMemberDTOS = groupMemberService.findMemberNotInGroup(groupId, authUser); + + return ResponseEntity.status(HttpStatus.OK).body(groupMemberDTOS); + } + + @PostMapping("/groupmember/members") + public ResponseEntity> addFriendsToGroup( + @RequestBody @Valid final AddGroupMemberListDTO request, + @AuthUserInfo final AuthUser authUser) { + final List response = groupMemberService.addFriendsList(request, authUser); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping("/groupmember") + public ResponseEntity> findAllGroupFriends( + @RequestParam("groupId") final Long groupId, + @AuthUserInfo final AuthUser authUser) { + final List dto = groupMemberService.findGroupMemberByGroupId(groupId); + + return ResponseEntity.status(HttpStatus.CREATED).body(dto); + } + + @PutMapping ("/groupmember") + public ResponseEntity> removeIncludeGroupFriends( + @RequestBody @Valid final DeleteGroupMemberListDTO request, + @AuthUserInfo final AuthUser authUser) { + final List ResponseGroupMemberDTOs = groupMemberService.deleteFriendsList(request, authUser); + + return ResponseEntity.status(HttpStatus.OK).body(ResponseGroupMemberDTOs); + } +} diff --git a/src/main/java/kr/where/backend/group/GroupMemberRepository.java b/src/main/java/kr/where/backend/group/GroupMemberRepository.java new file mode 100644 index 0000000..033d2b8 --- /dev/null +++ b/src/main/java/kr/where/backend/group/GroupMemberRepository.java @@ -0,0 +1,28 @@ +package kr.where.backend.group; + +import java.util.List; +import kr.where.backend.group.entity.Group; +import kr.where.backend.group.entity.GroupMember; +import kr.where.backend.member.Member; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface GroupMemberRepository extends JpaRepository { + + List findGroupMembersByMemberAndIsOwner(Member member, Boolean isOwner); + + List findGroupMemberByGroup_GroupIdAndIsOwnerIsFalse(Long groupId); + + List findGroupMembersByGroup_GroupIdAndMember_IntraIdIn(Long groupId, List MemberIntraId); + + boolean existsByGroupAndMember(Group group, Member member); + + long countByGroup_GroupIdAndMemberIn(Long groupId, List members); + + List findGroupMembersByMember_IntraIdAndIsOwner(Integer intraId, boolean isOwner); + + List findGroupMembersByGroup_GroupIdInAndMember_IntraIdIn(List groupIds, List memberIds); + + List findGroupMemberByGroup_GroupId(Long defaultGroupId); +} diff --git a/src/main/java/kr/where/backend/group/GroupMemberService.java b/src/main/java/kr/where/backend/group/GroupMemberService.java new file mode 100644 index 0000000..2c69713 --- /dev/null +++ b/src/main/java/kr/where/backend/group/GroupMemberService.java @@ -0,0 +1,286 @@ +package kr.where.backend.group; + +import java.util.List; +import java.util.stream.Collectors; + +import kr.where.backend.api.HaneApiService; +import kr.where.backend.auth.authUser.AuthUser; +import kr.where.backend.group.dto.groupmember.*; +import kr.where.backend.group.entity.Group; +import kr.where.backend.group.entity.GroupMember; +import kr.where.backend.group.exception.GroupException; +import kr.where.backend.group.exception.GroupMemberException; +import kr.where.backend.member.MemberRepository; +import kr.where.backend.member.Member; +import kr.where.backend.member.exception.MemberException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class GroupMemberService { + + private final GroupMemberRepository groupMemberRepository; + private final MemberRepository memberRepository; + private final GroupRepository groupRepository; + private final HaneApiService haneApiService; + /** + * 그룹에 그룹 멤버를 추가 + * 인자로 들어온 groupId가 존재하지 않다면 Exception + * 인자로 들어온 memberId가 존재하지 않다면 Exception + * isOnwer가 true인 경우는 + * member가 처음 생성될 때, 기본그룹을 만들 때 && 그룹을 새로 생성 될때 + * 이므로 아직 그룹이 생성되지 않은 시기이므로 해당 그룹이 나의 그룹인지 검사X + * 이미 인자로 들어온 group에 member가 포함되어 있다면 Exception + * @param requestDTO : CreateGroupMemberDTO(그룹에 추가 할 intraId, 추가 할 groupId) + * @param isOwner : isOwner로 자신이 그룹을 생성할 경우, 그룹에 포함될 경우를 구별 + * @param authUser + * @return ResponseGroupMemberDTO + */ + @Transactional + public ResponseGroupMemberDTO createGroupMember(final CreateGroupMemberDTO requestDTO, final boolean isOwner, final AuthUser authUser){ + final Group group = groupRepository.findById(requestDTO.getGroupId()) + .orElseThrow(GroupException.NoGroupException::new); + final Member member = memberRepository.findByIntraId(requestDTO.getIntraId()) + .orElseThrow(MemberException.NoMemberException::new); + if (!isOwner) { + isMyGroup(requestDTO.getGroupId(), authUser); + } + if (groupMemberRepository.existsByGroupAndMember(group, member)) { + throw new GroupMemberException.DuplicatedGroupMemberException(); + } + + final GroupMember groupMember = new GroupMember(group, member, isOwner); + groupMemberRepository.save(groupMember); + + return ResponseGroupMemberDTO.builder() + .groupId(authUser.getDefaultGroupId()) + .groupName(group.getGroupName()) + .intraId(member.getIntraId()) + .memberIntraName(member.getIntraName()) + .location(member.getLocation().getLocation()) + .inCluster(member.isInCluster()) + .comment(member.getComment()) + .image(member.getImage()).build(); + } + + /** + * 기본 그룹에 멤버를 추가하는 메서드 + * 일반그룹멤버 생성메서드와 기능 유사(기본 그룹멤버 api에 dto를 사용하지 않기 위해 사용) + * @param intraId + * @param isOwner + * @param authUser + * @return + */ + @Transactional + public ResponseGroupMemberDTO createDefaultGroupMember(final Integer intraId, final boolean isOwner, final AuthUser authUser){ + final Group group = groupRepository.findById(authUser.getDefaultGroupId()) + .orElseThrow(GroupException.NoGroupException::new); + final Member member = memberRepository.findByIntraId(intraId) + .orElseThrow(MemberException.NoMemberException::new); + + if (groupMemberRepository.existsByGroupAndMember(group, member)) { + throw new GroupMemberException.DuplicatedGroupMemberException(); + } + final GroupMember groupMember = new GroupMember(group, member, isOwner); + group.addGroupMember(groupMember); + groupMemberRepository.save(groupMember); + + return ResponseGroupMemberDTO.builder() + .groupId(authUser.getDefaultGroupId()) + .groupName(group.getGroupName()) + .intraId(member.getIntraId()) + .memberIntraName(member.getIntraName()) + .location(member.getLocation().getLocation()) + .inCluster(member.isInCluster()) + .comment(member.getComment()) + .image(member.getImage()).build(); + } + + /** + * 해당 그룹이 authUser가 가지고 있는 그룹인지 확인 + * @param groupId + * @param authUser + */ + public void isMyGroup(final Long groupId, final AuthUser authUser) { + final List groups = findGroupsInfoByIntraId(authUser); + if (groups.stream().map(ResponseGroupMemberDTO::getGroupId).noneMatch(g -> g.equals(groupId))) { + throw new GroupException.CannotModifyGroupException(); + } + } + + /** + * 멤버가 가진 그룹의 정보를 반환 + * @param authUser + * @return List + */ + public List findGroupsInfoByIntraId(final AuthUser authUser) { + + return findGroupIdByIntraId(authUser.getIntraId()); + } + + /** + * intraId로 해당 멤버의 그룹 조회 + * intraId가 존재하지 않는다면 Exception + * 그룹멤버 중에서 IsOwner가 true이고, + * member가 매치가 되는 그룹멤버 리스트를 조회 후 필요한 정보 파싱 + * @param intraId + * @return List + */ + public List findGroupIdByIntraId(final Integer intraId) { + final Member owner = memberRepository.findByIntraId(intraId) + .orElseThrow(MemberException.NoMemberException::new); + final List groupMembers = groupMemberRepository.findGroupMembersByMemberAndIsOwner(owner, true); + + return groupMembers + .stream() + .map(m -> ResponseGroupMemberDTO + .builder() + .groupId(m.getGroup().getGroupId()) + .groupName(m.getGroup().getGroupName()) + .intraId(intraId) + .build() + ) + .toList(); + } + + /** + * groupId를 받아 해당 그룹의 모든 그룹멤버 리스트 반환 + * 해당 그룹이 존재 하지 않는다면 Exception + * @param groupId + * @return List + */ + public List findGroupMemberByGroupId(final Long groupId) { + groupRepository.findById(groupId).orElseThrow(GroupException.NoGroupException::new); + + final List groupMembers = groupMemberRepository.findGroupMemberByGroup_GroupIdAndIsOwnerIsFalse(groupId); + haneApiService.updateMyOwnMemberState(groupMembers); + + return groupMembers + .stream() + .map(m -> ResponseOneGroupMemberDTO.builder() + .intraId(m.getMember().getIntraId()) + .image(m.getMember().getImage()) + .comment(m.getMember().getComment()) + .intraName(m.getMember().getIntraName()) + .inCluster(m.getMember().isInCluster()) + .location(m.getMember().getLocation().getLocation()) + .build() + ) + .toList(); + } + + /** + * 인자로 들어온 member리스트가 중 이미 그룹에 저장되어 있는 멤버라면 Exception + * @param groupId + * @param members + */ + @Transactional + public void duplicateGroupMember(final Long groupId, final List members){ + final long count = groupMemberRepository.countByGroup_GroupIdAndMemberIn(groupId, members); + if (count > 0) + throw new GroupMemberException.DuplicatedGroupMemberException(); + } + + /** + * groupId에 해당하는 그룹에 멤버 리스트를 추가 + * groupId에 해당하는 그룹이 없다면 Exception + * groupId가 authUser가 소유햔 그룹에 포함되는지 확인 + * 이미 저장되어있는 멤버인지 중복확인 + * @param dto : AddGroupMemberListDTO (groupId, List) + * @param authUser + * @return List + */ + @Transactional + public List addFriendsList(final AddGroupMemberListDTO dto, final AuthUser authUser){ + final Group group = groupRepository.findById(dto.getGroupId()) + .orElseThrow(GroupException.NoGroupException::new); + isMyGroup(dto.getGroupId(), authUser); + final List members = memberRepository.findByIntraIdIn(dto.getMembers()) + .orElseThrow(MemberException.NoMemberException::new); + duplicateGroupMember(dto.getGroupId(), members); + + final List groupMembers = members.stream() + .map(member -> new GroupMember(group, member, false)) + .collect(Collectors.toList()); + groupMembers.forEach(group::addGroupMember); + groupMemberRepository.saveAll(groupMembers); + + return groupMembers.stream() + .map(m -> ResponseOneGroupMemberDTO.builder() + .intraId(m.getMember().getIntraId()) + .intraName(m.getMember().getIntraName()) + .location(m.getMember().getLocation().getLocation()) + .inCluster(m.getMember().isInCluster()) + .comment(m.getMember().getComment()) + .image(m.getMember().getImage()) + .build()).toList(); + } + + /** + * 그룹에 포함된 그룹멤버 삭제 + * 만약 지울 그룹이 멤버의 기본그룹이라면 멤버가 소유한 그룹에 모든 멤버 삭제 + * @param dto : DeleteGroupMemberListDTO (groupId, List) + * @param authUser + * @return List + */ + @Transactional + public List deleteFriendsList(final DeleteGroupMemberListDTO dto, final AuthUser authUser){ + + final List deleteGroupMember; + + groupRepository.findById(dto.getGroupId()).orElseThrow(GroupException.NoGroupException::new); + isMyGroup(dto.getGroupId(), authUser); + + if (authUser.getDefaultGroupId().equals(dto.getGroupId())) { + final List groupsOfOwner = groupMemberRepository + .findGroupMembersByMember_IntraIdAndIsOwner(authUser.getIntraId(), true); + final List groups = groupsOfOwner.stream() + .map(g -> g.getGroup().getGroupId()) + .toList(); + + deleteGroupMember = groupMemberRepository + .findGroupMembersByGroup_GroupIdInAndMember_IntraIdIn(groups,dto.getMembers()); + groupMemberRepository.deleteAll(deleteGroupMember); + } + else { + deleteGroupMember = groupMemberRepository + .findGroupMembersByGroup_GroupIdAndMember_IntraIdIn(dto.getGroupId(), dto.getMembers()); + groupMemberRepository.deleteAll(deleteGroupMember); + } + + return deleteGroupMember.stream() + .map(m -> ResponseGroupMemberDTO.builder() + .groupId(dto.getGroupId()) + .intraId(m.getMember().getIntraId()) + .memberIntraName(m.getMember().getIntraName()) + .location(m.getMember().getLocation().getLocation()) + .inCluster(m.getMember().isInCluster()) + .comment(m.getMember().getComment()) + .image(m.getMember().getImage()) + .build()).toList(); + } + + /** + * 기본그룹에 포함 되지 않은 멤버 리스트 반환 + * (다른 일반 그룹에 멤버 추가 시, 해당 그룹에 포함되지 않은 친구 리스트를 보여줘야 하기때문에) + * @param groupId + * @param authUser + * @return List + */ + public List findMemberNotInGroup(final Long groupId, final AuthUser authUser) { + groupRepository.findById(authUser.getDefaultGroupId()) + .orElseThrow(GroupException.NoGroupException::new); + groupRepository.findById(groupId) + .orElseThrow(GroupException.NoGroupException::new); + final List defaultMembers = findGroupMemberByGroupId(authUser.getDefaultGroupId()); + final List groupMembers = findGroupMemberByGroupId(groupId); + + return defaultMembers.stream() + .filter(defaultMember -> groupMembers.stream() + .noneMatch(groupMember -> defaultMember.getIntraId().equals(groupMember.getIntraId()))) + .toList(); + } +} diff --git a/src/main/java/kr/where/backend/group/GroupRepository.java b/src/main/java/kr/where/backend/group/GroupRepository.java new file mode 100644 index 0000000..6347344 --- /dev/null +++ b/src/main/java/kr/where/backend/group/GroupRepository.java @@ -0,0 +1,22 @@ +package kr.where.backend.group; + +import java.util.List; +import java.util.Optional; + +import kr.where.backend.group.entity.Group; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface GroupRepository extends JpaRepository { + + Optional findById(Long groupId); + + @Query("SELECT gm.group FROM GroupMember gm " + + "WHERE gm.member.intraId = :intraId " + + "AND gm.isOwner = true") + List findAllGroupByMember(@Param("intraId") final Integer intraId); + +} diff --git a/src/main/java/kr/where/backend/group/GroupService.java b/src/main/java/kr/where/backend/group/GroupService.java new file mode 100644 index 0000000..489987f --- /dev/null +++ b/src/main/java/kr/where/backend/group/GroupService.java @@ -0,0 +1,167 @@ +package kr.where.backend.group; + +import java.util.List; + +import java.util.Objects; +import kr.where.backend.api.HaneApiService; +import kr.where.backend.auth.authUser.AuthUser; +import kr.where.backend.group.dto.group.CreateGroupDTO; +import kr.where.backend.group.dto.groupmember.CreateGroupMemberDTO; +import kr.where.backend.group.dto.group.ResponseGroupDTO; +import kr.where.backend.group.dto.groupmember.ResponseGroupMemberDTO; +import kr.where.backend.group.dto.group.UpdateGroupDTO; +import kr.where.backend.group.dto.groupmember.ResponseGroupMemberListDTO; +import kr.where.backend.group.dto.groupmember.ResponseOneGroupMemberDTO; +import kr.where.backend.group.entity.Group; +import kr.where.backend.group.exception.GroupException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class GroupService { + private static final String DEFAULT = "default"; + private final GroupRepository groupRepository; + private final GroupMemberService groupMemberService; + private final HaneApiService haneApiService; + + /** + * 그룹 생성 + * @param dto : CreateGroupDTO (groupName) + * @param authUser : ContextHolder에 저장된 유저 정보 + * @return ResponeGroupDTO + */ + @Transactional + public ResponseGroupDTO createGroup(final CreateGroupDTO dto, final AuthUser authUser){ + Group group = new Group(dto.getGroupName()); + groupRepository.save(group); + + if (group.getGroupName().equals(DEFAULT)) { + authUser.setDefaultGroupId(group.getGroupId()); + } + CreateGroupMemberDTO createGroupMemberDTO = CreateGroupMemberDTO.builder() + .intraId(authUser.getIntraId()) + .groupId(group.getGroupId()).build(); + groupMemberService.createGroupMember(createGroupMemberDTO, true, authUser); + return ResponseGroupDTO.from(group); + } + + /** + * groupId로 group을 조회하는 메서드 + * 인자로 들어온 그룹이 존재하지 않을 경우 Exception + * @param groupId + * @return group + */ + public Group findOneGroupById(final Long groupId) { + Group group = groupRepository.findById(groupId) + .orElseThrow(GroupException.NoGroupException::new); + return group; + } + + /** + * 인자로 들어온 그룹의 이름을 반환 + * @param groupId + * @return groupName + */ + public final String findGroupNameById(final Long groupId){ + Group group = findOneGroupById(groupId); + return group.getGroupName(); + } + + /** + * 그룹 이름을 업데이트 + * 그룹이 존재하는지 먼저 확인 없으면 Exception + * 그룹을 수정할 수 있는 그룹인지 확인 아니면 Exception + * @param dto : UpdateGroupDTO(groupId) + * @param authUser + * @return ResponseGroupDTO + */ + @Transactional + public ResponseGroupDTO updateGroup(final UpdateGroupDTO dto, final AuthUser authUser) { + Group group = findOneGroupById(dto.getGroupId()); + updateGroupValidate(dto, authUser); + group.setGroupName(dto.getGroupName()); + return ResponseGroupDTO.from(group); + } + + /** + * 그룹 삭제 + * 삭제할 그룹이 본인의 그룹이라면 삭제 불가 + * @param groupId : 삭제할 그룹의 아이디 + * @param authUser + * @return ResponseGroupDTO + */ + @Transactional + public ResponseGroupDTO deleteGroup(final Long groupId, final AuthUser authUser) { + isMyGroup(groupId, authUser); + final Group group = findOneGroupById(groupId); + //validate 추가 + groupRepository.delete(group); + + return ResponseGroupDTO.from(group); + } + + /** + * 인자로 들어온 그룹ID가 authUser가 가지고 있는 그룹인지 아닌지 확인 + * 아니라면 Exception + * @param groupId + * @param authUser + */ + public final void isMyGroup(final Long groupId, final AuthUser authUser) { + List groups = groupMemberService.findGroupsInfoByIntraId(authUser); + if (groups.stream().map(ResponseGroupMemberDTO::getGroupId).noneMatch(g -> g.equals(groupId))) { + throw new GroupException.CannotModifyGroupException(); + } + } + + /** + * group이름 변경 전 변경 할 권한이 있는지 확인, + * 변경할 그룹이 기본그룹이라면 변경 불가 + * @param dto + * @param authUser + */ + public final void updateGroupValidate(final UpdateGroupDTO dto, final AuthUser authUser) { + if (dto.getGroupId().equals(authUser.getDefaultGroupId())) + throw new GroupException.CannotModifyGroupException(); + isMyGroup(dto.getGroupId(), authUser); + } + + @Transactional + public List getGroupList(final AuthUser authUser) { + final List ownGroups = groupRepository.findAllGroupByMember(authUser.getIntraId()); + + haneApiService.updateGroupMemberState(ownGroups.stream() + .filter(g -> Objects.equals(g.getGroupId(), authUser.getDefaultGroupId())) + .findFirst() + .orElseThrow(GroupException.NoGroupException::new) + ); + + return ownGroups + .stream() + .map(group -> { + List friends = group.getGroupMembers() + .stream() + .filter(friend -> !friend.getIsOwner()) + .map(friend -> ResponseOneGroupMemberDTO + .builder() + .intraId(friend.getMember().getIntraId()) + .image(friend.getMember().getImage()) + .comment(friend.getMember().getComment()) + .intraName(friend.getMember().getIntraName()) + .inCluster(friend.getMember().isInCluster()) + .location(friend.getMember().getLocation().getLocation()) + .build() + ) + .toList(); + return ResponseGroupMemberListDTO + .builder() + .groupId(group.getGroupId()) + .groupName(group.getGroupName()) + .count(friends.size()) + .members(friends) + .build(); + }).toList(); + } + +} diff --git a/src/main/java/kr/where/backend/group/dto/group/CreateGroupDTO.java b/src/main/java/kr/where/backend/group/dto/group/CreateGroupDTO.java new file mode 100644 index 0000000..3c139ed --- /dev/null +++ b/src/main/java/kr/where/backend/group/dto/group/CreateGroupDTO.java @@ -0,0 +1,15 @@ +package kr.where.backend.group.dto.group; +import jakarta.validation.constraints.NotBlank; +import lombok.*; + +@Getter +@NoArgsConstructor +public class CreateGroupDTO { + @NotBlank + private String groupName; + + @Builder + public CreateGroupDTO(final String groupName) { + this.groupName = groupName; + } +} \ No newline at end of file diff --git a/src/main/java/kr/where/backend/group/dto/group/FindGroupDTO.java b/src/main/java/kr/where/backend/group/dto/group/FindGroupDTO.java new file mode 100644 index 0000000..46b182d --- /dev/null +++ b/src/main/java/kr/where/backend/group/dto/group/FindGroupDTO.java @@ -0,0 +1,16 @@ +package kr.where.backend.group.dto.group; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class FindGroupDTO { + @NotNull + private Integer intraId; + + @Builder + public FindGroupDTO(final Integer intraId) { + this.intraId = intraId; + } +} diff --git a/src/main/java/kr/where/backend/group/dto/group/ResponseGroupDTO.java b/src/main/java/kr/where/backend/group/dto/group/ResponseGroupDTO.java new file mode 100644 index 0000000..df78805 --- /dev/null +++ b/src/main/java/kr/where/backend/group/dto/group/ResponseGroupDTO.java @@ -0,0 +1,19 @@ +package kr.where.backend.group.dto.group; + +import kr.where.backend.group.entity.Group; +import lombok.Getter; + +@Getter +public class ResponseGroupDTO { + private Long groupId; + private String groupName; + + private ResponseGroupDTO(final Long groupId, final String groupName) { + this.groupId = groupId; + this.groupName = groupName; + } + + public static final ResponseGroupDTO from(final Group group) { + return new ResponseGroupDTO(group.getGroupId(), group.getGroupName()); + } +} diff --git a/src/main/java/kr/where/backend/group/dto/group/UpdateGroupDTO.java b/src/main/java/kr/where/backend/group/dto/group/UpdateGroupDTO.java new file mode 100644 index 0000000..dcae5cf --- /dev/null +++ b/src/main/java/kr/where/backend/group/dto/group/UpdateGroupDTO.java @@ -0,0 +1,15 @@ +package kr.where.backend.group.dto.group; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class UpdateGroupDTO { + @NotNull + private Long groupId; + @NotBlank + private String groupName; +} diff --git a/src/main/java/kr/where/backend/group/dto/groupmember/AddGroupMemberListDTO.java b/src/main/java/kr/where/backend/group/dto/groupmember/AddGroupMemberListDTO.java new file mode 100644 index 0000000..a7139a9 --- /dev/null +++ b/src/main/java/kr/where/backend/group/dto/groupmember/AddGroupMemberListDTO.java @@ -0,0 +1,24 @@ +package kr.where.backend.group.dto.groupmember; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +public class AddGroupMemberListDTO { + @NotNull + private Long groupId; + @NotNull + private List members; + + @Builder + public AddGroupMemberListDTO(final Long groupId, final List members) { + this.groupId = groupId; + this.members = members; + } +} diff --git a/src/main/java/kr/where/backend/group/dto/groupmember/CreateGroupMemberDTO.java b/src/main/java/kr/where/backend/group/dto/groupmember/CreateGroupMemberDTO.java new file mode 100644 index 0000000..7756085 --- /dev/null +++ b/src/main/java/kr/where/backend/group/dto/groupmember/CreateGroupMemberDTO.java @@ -0,0 +1,22 @@ +package kr.where.backend.group.dto.groupmember; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.models.security.SecurityScheme.In; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +@Getter +@NoArgsConstructor +public class CreateGroupMemberDTO { + @NotNull + private Integer intraId; + @NotNull + private Long groupId; + + @Builder + public CreateGroupMemberDTO(final Integer intraId, final Long groupId) { + this.intraId = intraId; + this.groupId = groupId; + } +} diff --git a/src/main/java/kr/where/backend/group/dto/groupmember/DeleteGroupMemberListDTO.java b/src/main/java/kr/where/backend/group/dto/groupmember/DeleteGroupMemberListDTO.java new file mode 100644 index 0000000..37cc52b --- /dev/null +++ b/src/main/java/kr/where/backend/group/dto/groupmember/DeleteGroupMemberListDTO.java @@ -0,0 +1,24 @@ +package kr.where.backend.group.dto.groupmember; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +public class DeleteGroupMemberListDTO { + @NotNull + private Long groupId; + @NotNull + private List members; + + @Builder + public DeleteGroupMemberListDTO(final Long groupId, final List members) { + this.groupId = groupId; + this.members = members; + } +} diff --git a/src/main/java/kr/where/backend/group/dto/groupmember/RequestGroupMemberDTO.java b/src/main/java/kr/where/backend/group/dto/groupmember/RequestGroupMemberDTO.java new file mode 100644 index 0000000..138970e --- /dev/null +++ b/src/main/java/kr/where/backend/group/dto/groupmember/RequestGroupMemberDTO.java @@ -0,0 +1,22 @@ +package kr.where.backend.group.dto.groupmember; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class RequestGroupMemberDTO { + @NotNull + private Integer intraId; + @NotNull + private Long groupId; + + @Builder + public RequestGroupMemberDTO(final Integer intraId, final Long groupId) { + this.intraId = intraId; + this.groupId = groupId; + } +} diff --git a/src/main/java/kr/where/backend/group/dto/groupmember/ResponseGroupMemberDTO.java b/src/main/java/kr/where/backend/group/dto/groupmember/ResponseGroupMemberDTO.java new file mode 100644 index 0000000..62e9e15 --- /dev/null +++ b/src/main/java/kr/where/backend/group/dto/groupmember/ResponseGroupMemberDTO.java @@ -0,0 +1,43 @@ +package kr.where.backend.group.dto.groupmember; + +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Getter +@NoArgsConstructor +@ToString +public class ResponseGroupMemberDTO { + + private Long groupId; + private String groupName; + private Integer intraId; + private String comment; + private String memberIntraName; + private String location; + private boolean inCluster; + private String image; + + + @Builder + public ResponseGroupMemberDTO( + final @NotNull Long groupId, + final String groupName, + final @NotNull Integer intraId, + final String comment, + final String memberIntraName, + final String location, + final boolean inCluster, + final String image) { + this.groupId = groupId; + this.groupName = groupName; + this.intraId = intraId; + this.comment = comment; + this.memberIntraName = memberIntraName; + this.location = location; + this.inCluster = inCluster; + this.image = image; + } +} diff --git a/src/main/java/kr/where/backend/group/dto/groupmember/ResponseGroupMemberListDTO.java b/src/main/java/kr/where/backend/group/dto/groupmember/ResponseGroupMemberListDTO.java new file mode 100644 index 0000000..e5ab7c6 --- /dev/null +++ b/src/main/java/kr/where/backend/group/dto/groupmember/ResponseGroupMemberListDTO.java @@ -0,0 +1,30 @@ +package kr.where.backend.group.dto.groupmember; + +import java.util.List; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ResponseGroupMemberListDTO { + private Long groupId; + private String groupName; + private int count; + private List members; + + @Builder + public ResponseGroupMemberListDTO( + final Long groupId, + final String groupName, + int count, + final List members + ) + { + this.groupId = groupId; + this.groupName = groupName; + this.count = count; + this.members = members; + } +} diff --git a/src/main/java/kr/where/backend/group/dto/groupmember/ResponseOneGroupMemberDTO.java b/src/main/java/kr/where/backend/group/dto/groupmember/ResponseOneGroupMemberDTO.java new file mode 100644 index 0000000..95e04a4 --- /dev/null +++ b/src/main/java/kr/where/backend/group/dto/groupmember/ResponseOneGroupMemberDTO.java @@ -0,0 +1,42 @@ +package kr.where.backend.group.dto.groupmember; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ResponseOneGroupMemberDTO { + + private Integer intraId; + private String intraName; + private String grade; + private String image; + private String comment; + private boolean inCluster; + private boolean agree; + private Long defaultGroupId; + private String location; + + @Builder + public ResponseOneGroupMemberDTO( + final Integer intraId, + final String intraName, + final String grade, + final String image, + final String comment, + final boolean inCluster, + final boolean agree, + final Long defaultGroupId, + final String location) { + this.intraId = intraId; + this.intraName = intraName; + this.grade = grade; + this.image = image; + this.comment = comment; + this.inCluster = inCluster; + this.agree = agree; + this.defaultGroupId = defaultGroupId; + this.location = location; + } +} diff --git a/src/main/java/kr/where/backend/group/entity/Group.java b/src/main/java/kr/where/backend/group/entity/Group.java new file mode 100644 index 0000000..29fe302 --- /dev/null +++ b/src/main/java/kr/where/backend/group/entity/Group.java @@ -0,0 +1,47 @@ +package kr.where.backend.group.entity; + +import jakarta.persistence.*; + +import java.util.ArrayList; +import java.util.List; + +import kr.where.backend.member.Member; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "groups") +public class Group { + + public static final String DEFAULT_GROUP = "default"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "group_id", nullable = false) + private Long groupId; + + @Column(name = "group_name", length = 40) + private String groupName; + + @OneToMany(mappedBy = "group", fetch = FetchType.LAZY, cascade = CascadeType.ALL) + private List groupMembers = new ArrayList<>(); + + public Group(final String groupName) { + this.groupName = groupName; + } + + public void setGroupName(final String groupName) { + this.groupName = groupName; + } + + public boolean isInGroup(final Member member){ + return this.groupMembers.contains(member); + } + + public void addGroupMember(final GroupMember groupMember) { + this.groupMembers.add(groupMember); + } +} diff --git a/src/main/java/kr/where/backend/group/entity/GroupMember.java b/src/main/java/kr/where/backend/group/entity/GroupMember.java new file mode 100644 index 0000000..fc7bc94 --- /dev/null +++ b/src/main/java/kr/where/backend/group/entity/GroupMember.java @@ -0,0 +1,47 @@ +package kr.where.backend.group.entity; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import kr.where.backend.member.Member; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Getter@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "group_members") +public class GroupMember { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "table_id", nullable = false) + private Long tableId; + + @ManyToOne + @JoinColumn(name = "group_id") + private Group group; + + @ManyToOne + @JoinColumn(referencedColumnName = "intra_id") + private Member member; + + @JsonProperty(value = "isOwner") + @Column(name = "is_owner") + private Boolean isOwner; + + public GroupMember(final Group group, final Member member, final boolean isOwner){ + this.group = group; + this.member = member; + this.isOwner = isOwner; + } +} diff --git a/src/main/java/kr/where/backend/group/exception/GroupErrorCode.java b/src/main/java/kr/where/backend/group/exception/GroupErrorCode.java new file mode 100644 index 0000000..973384c --- /dev/null +++ b/src/main/java/kr/where/backend/group/exception/GroupErrorCode.java @@ -0,0 +1,16 @@ +package kr.where.backend.group.exception; + +import kr.where.backend.exception.ErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum GroupErrorCode implements ErrorCode { + NO_GROUP(1100, "그룹이 존재하지 않습니다."), + DUPLICATED_GROUP_NAME(1101, "이미 존재하는 그룹 이름입니다."), + CANNOT_MODIFY_GROUP(1102, "수정할 수 없는 그룹입니다."); + + private final int errorCode; + private final String errorMessage; +} diff --git a/src/main/java/kr/where/backend/group/exception/GroupException.java b/src/main/java/kr/where/backend/group/exception/GroupException.java new file mode 100644 index 0000000..805201e --- /dev/null +++ b/src/main/java/kr/where/backend/group/exception/GroupException.java @@ -0,0 +1,25 @@ +package kr.where.backend.group.exception; + +import kr.where.backend.exception.CustomException; + +public class GroupException extends CustomException { + public GroupException(final GroupErrorCode groupErrorCode) { + super(groupErrorCode); + } + + public static class NoGroupException extends GroupException { + public NoGroupException() { + super(GroupErrorCode.NO_GROUP); + } + } + + public static class DuplicatedGroupNameException extends GroupException { + public DuplicatedGroupNameException() { + super(GroupErrorCode.DUPLICATED_GROUP_NAME); + } + } + + public static class CannotModifyGroupException extends GroupException { + public CannotModifyGroupException() {super(GroupErrorCode.CANNOT_MODIFY_GROUP);} + } +} diff --git a/src/main/java/kr/where/backend/group/exception/GroupMemberErrorCode.java b/src/main/java/kr/where/backend/group/exception/GroupMemberErrorCode.java new file mode 100644 index 0000000..c1f449c --- /dev/null +++ b/src/main/java/kr/where/backend/group/exception/GroupMemberErrorCode.java @@ -0,0 +1,14 @@ +package kr.where.backend.group.exception; + +import kr.where.backend.exception.ErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum GroupMemberErrorCode implements ErrorCode { + DUPLICATED_GROUP_MEMBER(2002, "이미 등록된 맴버입니다."); + + private final int errorCode; + private final String errorMessage; +} diff --git a/src/main/java/kr/where/backend/group/exception/GroupMemberException.java b/src/main/java/kr/where/backend/group/exception/GroupMemberException.java new file mode 100644 index 0000000..cd84dec --- /dev/null +++ b/src/main/java/kr/where/backend/group/exception/GroupMemberException.java @@ -0,0 +1,15 @@ +package kr.where.backend.group.exception; + +import kr.where.backend.exception.CustomException; + +public class GroupMemberException extends CustomException { + public GroupMemberException(final GroupMemberErrorCode groupMemberErrorCode) { + super(groupMemberErrorCode); + } + + public static class DuplicatedGroupMemberException extends GroupMemberException{ + public DuplicatedGroupMemberException() { + super(GroupMemberErrorCode.DUPLICATED_GROUP_MEMBER); + } + } +} diff --git a/src/main/java/kr/where/backend/group/swagger/GroupApiDocs.java b/src/main/java/kr/where/backend/group/swagger/GroupApiDocs.java new file mode 100644 index 0000000..257b008 --- /dev/null +++ b/src/main/java/kr/where/backend/group/swagger/GroupApiDocs.java @@ -0,0 +1,185 @@ +package kr.where.backend.group.swagger; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.ErrorResponse; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import kr.where.backend.auth.authUser.AuthUser; +import kr.where.backend.auth.authUser.AuthUserInfo; +import kr.where.backend.group.dto.group.CreateGroupDTO; +import kr.where.backend.group.dto.group.ResponseGroupDTO; +import kr.where.backend.group.dto.group.UpdateGroupDTO; +import kr.where.backend.group.dto.groupmember.AddGroupMemberListDTO; +import kr.where.backend.group.dto.groupmember.DeleteGroupMemberListDTO; +import kr.where.backend.group.dto.groupmember.ResponseGroupMemberDTO; +import kr.where.backend.group.dto.groupmember.ResponseGroupMemberListDTO; +import kr.where.backend.group.dto.groupmember.ResponseOneGroupMemberDTO; + +@Tag(name = "group", description = "group API") +public interface GroupApiDocs { + + @Operation( + summary = "2.1 create new group API", + description = "그룹 생성", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "그룹 생성 요청", + required = true, content = @Content(schema = @Schema(implementation = CreateGroupDTO.class))), + responses = { + @ApiResponse(responseCode = "201", description = "그룹 생성 성공", content = @Content(schema = @Schema(implementation = ResponseGroupDTO.class))), + @ApiResponse(responseCode = "409", description = "그룹 이름 중복", content = @Content(schema = @Schema(implementation = ErrorResponse.class), examples = { + @ExampleObject(name = "example3", value = "{\"statusCode\": 2001, \"responseMsg\": \"그룹 이름 중복\"}"),})), + } + ) + @PostMapping("") + ResponseEntity createGroup( + @RequestBody @Valid final CreateGroupDTO request, + @AuthUserInfo final AuthUser authUser); + + @Operation( + summary = "2.2 그룹 목록 및 친구 API 조회", + description = "멤버가 만든 그룹 및 그룹 내의 친구 목록을 조회합니다 (메인 화면 용)", + responses = { + @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(schema = @Schema(implementation = ResponseGroupMemberListDTO.class))), + } + ) + @GetMapping("") + ResponseEntity> findAllGroups(@AuthUserInfo final AuthUser authUser); + + @Operation( + summary = "2.3 modify group name API", + description = "그룹 이름 수정", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "변경할 새로운 이름과 아이디", required = true, content = @Content(schema = @Schema(implementation = UpdateGroupDTO.class))), + responses = { + @ApiResponse(responseCode = "200", description = "그룹 이름 변경 성공", content = @Content(schema = @Schema(implementation = ResponseGroupDTO.class))), + @ApiResponse(responseCode = "400", description = "존재하지 않는 그룹", content = @Content(schema = @Schema(implementation = ErrorResponse.class), examples = { + @ExampleObject(name = "example1", value = "{\"statusCode\": 400, \"responseMsg\": \"존재하지 않는 그룹\"}"),})), + @ApiResponse(responseCode = "409", description = "그룹 이름 중복", content = @Content(schema = @Schema(implementation = ErrorResponse.class), examples = { + @ExampleObject(name = "example1", value = "{\"statusCode\": 409, \"responseMsg\": \"그룹 이름 중복\"}"),})), + } + ) + @PostMapping("/name") + ResponseEntity updateGroupName(@RequestBody @Valid final UpdateGroupDTO dto, @AuthUserInfo final AuthUser authUser); + + @Operation( + summary = "2.4 delete group API", + description = "그룹 삭제", + parameters = { + @Parameter(name = "groupId", description = "삭제할 그룹 ID", required = true, in = ParameterIn.QUERY) + }, + responses = { + @ApiResponse(responseCode = "200", description = "삭제 성공", content = @Content(schema = @Schema(implementation = ResponseGroupDTO.class), examples = { + @ExampleObject(name = "example1", value = "{\"statusCode\": 200, \"responseMsg\": \"그룹 삭제 성공\", \"data\": [ {\"groupId\": 3} ] }"),})), + @ApiResponse(responseCode = "400", description = "존재하지 않는 그룹", content = @Content(schema = @Schema(implementation = ErrorResponse.class), examples = { + @ExampleObject(name = "example1", value = "{\"statusCode\": 400, \"responseMsg\": \"데이터를 찾을 수 없음\"}"),})), + } + ) + @DeleteMapping("") + ResponseEntity deleteGroup(@RequestParam("groupId") final Long groupId, @AuthUserInfo final AuthUser authUser); + + @Operation( + summary = "2.5 get group list API", + description = "멤버가 소유한 그룹들의 id, 이름 반환 (그룹관리)", + responses = { + @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(schema = @Schema(implementation = ResponseGroupMemberDTO.class))), + @ApiResponse(responseCode = "401", description = "등록되지 않은 카뎃", content = @Content(schema = @Schema(implementation = ErrorResponse.class), examples = { + @ExampleObject(name = "example1", value = "{\"statusCode\": 401, \"responseMsg\": \"등록되지 않은 카뎃\"}"),})), + } + ) + @GetMapping("/info") + ResponseEntity> findGroupNames(@AuthUserInfo final AuthUser authUser); + + @Operation( + summary = "2.6 add friends to default group API", + description = "새로운 친구를 기본그룹에 추가 요청(멤버의 기본그룹 ID와, 추가할 멤버 ID)", + parameters = { + @Parameter(name = "intraId", description = "추가할 멤버 intraId", required = true, in = ParameterIn.QUERY) + }, + responses = { + @ApiResponse(responseCode = "201", description = "친구 추가 성공", content = @Content(schema = @Schema(implementation = ResponseGroupMemberDTO.class), examples = { + @ExampleObject(name = "example1", value = "{ \"statusCode\": 201, \"responseMsg\": \"그룹에 친구 추가 성공\"}"),})), + @ApiResponse(responseCode = "400", description = "존재하지 않는 그룹", content = @Content(schema = @Schema(implementation = ErrorResponse.class), examples = { + @ExampleObject(name = "example1", value = "{\"statusCode\": 400, \"responseMsg\": \"데이터를 찾을 수 없음\"}"),})), + @ApiResponse(responseCode = "401", description = "존재하지 않는 멤버", content = @Content(schema = @Schema(implementation = ErrorResponse.class), examples = { + @ExampleObject(name = "example1", value = "{\"statusCode\": 401, \"responseMsg\": \"데이터를 찾을 수 없음\"}"),})), + } + ) + @PostMapping("/groupmember") + ResponseEntity createGroupMember(@RequestParam("intraId") final Integer intraId, @AuthUserInfo final AuthUser authUser); + + @Operation( + summary = "2.7 get not included friends in group API", + description = "그룹에 포함되지 않은 친구 목록을 조회. 그룹에 기본 그룹의 친구를 추가하기 위함이다. 이때, 조회되는 친구들은 멤버가 친구로 등록하되 해당 그룹에 등록되지 않은 친구들이다.", + parameters = { + @Parameter(name = "groupId", description = "추가할 그룹 ID", required = true, in = ParameterIn.QUERY) + }, + responses = { + @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(schema = @Schema(implementation = ResponseOneGroupMemberDTO.class))), + @ApiResponse(responseCode = "400", description = "존재하지 않는 그룹", content = @Content(schema = @Schema(implementation = ErrorResponse.class), examples = { + @ExampleObject(name = "example1", value = "{\"statusCode\": 400, \"responseMsg\": \"존재하지 않는 그룹\"}")})), + } + ) + @PostMapping("/groupmember/not-ingroup") + ResponseEntity> findMemberListNotInGroup(@RequestParam("groupId") final Long groupId, @AuthUserInfo final AuthUser authUser); + + @Operation( + summary = "2.8 add friends to group API", + description = "친구 리스트를 받아서 해당 그룹에 일괄 추가", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "추가하려는 친구 ID 리스트와 친구를 추가할 그룹 ID", required = true, content = @Content(schema = @Schema(implementation = AddGroupMemberListDTO.class)) + ), + responses = { + @ApiResponse(responseCode = "201", description = "친구 일괄 추가 성공", content = @Content(schema = @Schema(implementation = ResponseOneGroupMemberDTO.class), examples = { + @ExampleObject(name = "example1", value = "{ \"statusCode\": 201, \"responseMsg\": \"그룹에 친구 추가 성공\"}"),})), + @ApiResponse(responseCode = "400", description = "존재하지 않는 그룹", content = @Content(schema = @Schema(implementation = ErrorResponse.class), examples = { + @ExampleObject(name = "example1", value = "{\"statusCode\": 400, \"responseMsg\": \"데이터를 찾을 수 없음\"}"),})), + } + ) + @PostMapping("/groupmember/members") + ResponseEntity> addFriendsToGroup(@RequestBody @Valid final AddGroupMemberListDTO request, @AuthUserInfo final AuthUser authUser); + + @Operation( + summary = "2.9 get group friend list API", + description = "그룹 내의 모든 친구 목록 조회", + parameters = { + @Parameter(name = "groupId", description = "조회를 원하는 그룹 ID", required = true, in = ParameterIn.QUERY) + }, + responses = { + @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(schema = @Schema(implementation = ResponseOneGroupMemberDTO.class))), + @ApiResponse(responseCode = "400", description = "존재하지 않는 그룹", content = @Content(schema = @Schema(implementation = ErrorResponse.class), examples = { + @ExampleObject(name = "example1", value = "{\"statusCode\": 400, \"responseMsg\": \"데이터를 찾을 수 없음\"}"),})), + } + ) + @GetMapping("/groupmember") + ResponseEntity> findAllGroupFriends(@RequestParam("groupId") final Long groupId, @AuthUserInfo final AuthUser authUser); + + @Operation( + summary = "2.10 delete group friend API", + description = "친구 리스트를 받아서 해당 그룹에서 일괄 삭제함. 이때, 친구 리스트는 모두 해당 그룹에 속해있어야 함", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "삭제하려는 친구 id 리스트", required = true, content = @Content(schema = @Schema(implementation = DeleteGroupMemberListDTO.class)) + ), + responses = { + @ApiResponse(responseCode = "200", description = "Ok", content = @Content(schema = @Schema(implementation = ResponseGroupMemberDTO.class), examples = { + @ExampleObject(name = "example1", value = "{\"statusCode\": 200, \"responseMsg\": \"그룹에서 친구 삭제 성공\"}"),})), + @ApiResponse(responseCode = "400", description = "존재하지 않는 그룹", content = @Content(schema = @Schema(implementation = ErrorResponse.class), examples = { + @ExampleObject(name = "example1", value = "{\"statusCode\": 400, \"responseMsg\": \"데이터를 찾을 수 없음\"}"),})), + } + ) + @PutMapping ("/groupmember") + ResponseEntity> removeIncludeGroupFriends(@RequestBody @Valid final DeleteGroupMemberListDTO request, @AuthUserInfo final AuthUser authUser); + + +} diff --git a/src/main/java/kr/where/backend/join/JoinController.java b/src/main/java/kr/where/backend/join/JoinController.java new file mode 100644 index 0000000..33d70c6 --- /dev/null +++ b/src/main/java/kr/where/backend/join/JoinController.java @@ -0,0 +1,29 @@ +package kr.where.backend.join; + +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.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import kr.where.backend.api.exception.JsonException; +import kr.where.backend.auth.authUser.AuthUser; +import kr.where.backend.auth.authUser.AuthUserInfo; +import kr.where.backend.join.swagger.JoinApiDocs; +import kr.where.backend.jwt.dto.ResponseRefreshTokenDTO; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v3/join") +public class JoinController implements JoinApiDocs { + private final JoinService joinService; + + @PostMapping("") + public ResponseEntity join(@AuthUserInfo final AuthUser authUser) { + + return ResponseEntity.ok(joinService.join(authUser)); + } +} diff --git a/src/main/java/kr/where/backend/join/JoinService.java b/src/main/java/kr/where/backend/join/JoinService.java new file mode 100644 index 0000000..d7cb70d --- /dev/null +++ b/src/main/java/kr/where/backend/join/JoinService.java @@ -0,0 +1,47 @@ +package kr.where.backend.join; + +import kr.where.backend.api.HaneApiService; +import kr.where.backend.api.json.CadetPrivacy; +import kr.where.backend.auth.authUser.AuthUser; +import kr.where.backend.jwt.dto.ResponseRefreshTokenDTO; +import kr.where.backend.join.exception.JoinException; +import kr.where.backend.jwt.JwtService; +import kr.where.backend.member.Member; +import kr.where.backend.member.MemberService; +import kr.where.backend.member.exception.MemberException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class JoinService { + private static final String TOKEN_HANE = "hane"; + private final MemberService memberService; + private final HaneApiService haneApiService; + private final JwtService jwtService; + + @Transactional + public ResponseRefreshTokenDTO join(final AuthUser authUser) { + final Member member = memberService.findOne(authUser.getIntraId()) + .orElseThrow(MemberException.NoMemberException::new); + if (member.isAgree()) { + throw new JoinException.DuplicatedJoinMember(); + } + memberService.createAgreeMember( + CadetPrivacy + .builder() + .build(), + haneApiService + .getHaneInfo(member.getIntraName(), TOKEN_HANE) + ); + + return ResponseRefreshTokenDTO + .builder() + .refreshToken( + jwtService.createRefreshToken(authUser.getIntraId(), authUser.getIntraName()) + ) + .build(); + } +} diff --git a/src/main/java/kr/where/backend/join/exception/JoinErrorCode.java b/src/main/java/kr/where/backend/join/exception/JoinErrorCode.java new file mode 100644 index 0000000..58ac573 --- /dev/null +++ b/src/main/java/kr/where/backend/join/exception/JoinErrorCode.java @@ -0,0 +1,14 @@ +package kr.where.backend.join.exception; + +import kr.where.backend.exception.ErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum JoinErrorCode implements ErrorCode { + DUPLICATED_JOIN(1700, "이미 동의한 유저입니다"); + + private final int errorCode; + private final String errorMessage; +} diff --git a/src/main/java/kr/where/backend/join/exception/JoinException.java b/src/main/java/kr/where/backend/join/exception/JoinException.java new file mode 100644 index 0000000..ba4bb4f --- /dev/null +++ b/src/main/java/kr/where/backend/join/exception/JoinException.java @@ -0,0 +1,15 @@ +package kr.where.backend.join.exception; + +import kr.where.backend.exception.CustomException; + +public class JoinException extends CustomException { + public JoinException(final JoinErrorCode joinErrorCode) { + super(joinErrorCode); + } + + public static class DuplicatedJoinMember extends JoinException { + public DuplicatedJoinMember() { + super(JoinErrorCode.DUPLICATED_JOIN); + } + } +} diff --git a/src/main/java/kr/where/backend/join/swagger/JoinApiDocs.java b/src/main/java/kr/where/backend/join/swagger/JoinApiDocs.java new file mode 100644 index 0000000..e67825a --- /dev/null +++ b/src/main/java/kr/where/backend/join/swagger/JoinApiDocs.java @@ -0,0 +1,28 @@ +package kr.where.backend.join.swagger; + +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.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.where.backend.api.exception.JsonException; +import kr.where.backend.auth.authUser.AuthUser; +import kr.where.backend.auth.authUser.AuthUserInfo; +import kr.where.backend.jwt.dto.ResponseRefreshTokenDTO; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; + +@Tag(name = "join", description = "join API") +public interface JoinApiDocs { + @Operation(summary = "3.1 JoinMember API", description = "동의 맴버 생성하는 Post API", + responses = { + @ApiResponse(responseCode = "200", description = "맴버 생성 성공", + content = @Content(schema = @Schema(implementation = ResponseRefreshTokenDTO.class))), + @ApiResponse(responseCode = "404", description = "맴버 생성 실패", + content = @Content(schema = @Schema(implementation = JsonException.class))) + } + + ) + @PostMapping("") + ResponseEntity join(@AuthUserInfo final AuthUser authUser); +} diff --git a/src/main/java/kr/where/backend/jwt/JwtController.java b/src/main/java/kr/where/backend/jwt/JwtController.java new file mode 100644 index 0000000..2d23714 --- /dev/null +++ b/src/main/java/kr/where/backend/jwt/JwtController.java @@ -0,0 +1,32 @@ +package kr.where.backend.jwt; + +import jakarta.servlet.http.HttpServletResponse; +import kr.where.backend.auth.authUser.AuthUser; +import kr.where.backend.auth.authUser.AuthUserInfo; +import kr.where.backend.jwt.dto.ResponseAccessTokenDTO; +import kr.where.backend.jwt.swagger.JwtApiDocs; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v3/jwt") +@RequiredArgsConstructor +public class JwtController implements JwtApiDocs { + private final JwtService jwtService; + + @PostMapping("/reissue") + public ResponseEntity reIssue( + final HttpServletResponse response, + @CookieValue(value = "refreshToken") final String refreshToken + ) + { + + return ResponseEntity.ok( + jwtService.reissueAccessToken(response, refreshToken) + ); + } +} diff --git a/src/main/java/kr/where/backend/jwt/JwtService.java b/src/main/java/kr/where/backend/jwt/JwtService.java new file mode 100644 index 0000000..f45e8e6 --- /dev/null +++ b/src/main/java/kr/where/backend/jwt/JwtService.java @@ -0,0 +1,240 @@ +package kr.where.backend.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SignatureException; +import jakarta.servlet.http.HttpServletRequest; +import java.security.Key; +import java.util.Base64; +import java.util.Collection; +import java.util.Date; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Stream; + +import jakarta.servlet.http.HttpServletResponse; +import kr.where.backend.auth.authUser.AuthUser; +import kr.where.backend.auth.filter.JwtConstants; +import kr.where.backend.auth.oauth2login.cookie.CookieShop; +import kr.where.backend.jwt.dto.ResponseAccessTokenDTO; +import kr.where.backend.jwt.exception.JwtException; +import kr.where.backend.member.Member; +import kr.where.backend.member.MemberService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.logging.log4j.util.Strings; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +public class JwtService { + private static final int ACCESS_EXPIRY = 30 * 60; + @Value("${accesstoken.expiration.time}") + private long accessTokenExpirationTime; + @Value("${refreshtoken.expiration.time}") + private long refreshTokenExpirationTime; + @Value("${jwt.token.secret}") + private String secret_code; + @Value("${issuer}") + private String issuer; + private final MemberService memberService; + /** + * 쿠키에 있는 refreshToken을 사용하여 재발급 + * @param refreshToken : 재발급을 위한 refreshToken을 httpOnly로 설정되어있기 때문에 값을 받아온다. + * @return accessToken + */ + public ResponseAccessTokenDTO reissueAccessToken( + final HttpServletResponse response, + final String refreshToken) { + final Claims claims = parseToken(refreshToken); + final String accessToken = createAccessToken( + (Integer) claims.get("intraId"), + (String) claims.get("intraName") + ); + + CookieShop.bakedCookie( + response, + JwtConstants.ACCESS.getValue(), + ACCESS_EXPIRY, + accessToken, + false + ); + + return ResponseAccessTokenDTO + .builder() + .accessToken(accessToken) + .build(); + } + + /** + * tokenProvider와 합칠 생각이여서, 가져옴 + * accessToken 만료 시간을 통해서, token 생성 + * @param intraId : token의 주인을 token payload에 저장 + * @return Token을 생성하는 메서드 호출 + */ + public String createAccessToken(final Integer intraId, final String intraName) { + return createToken( + intraId, + intraName, + accessTokenExpirationTime, + JwtConstants.ACCESS.getValue() + ); + } + + /** + * tokenProvider와 합칠 생각이여서, 가져옴 + * refreshToken 만료 시간을 통해서, token 생성 + * @param intraId : token의 주인을 token payload에 저장 + * @return Token을 생성하는 메서드 호출 + */ + public String createRefreshToken(final Integer intraId, final String intraName) { + return createToken( + intraId, + intraName, + refreshTokenExpirationTime, + JwtConstants.REFRESH.getValue() + ); + } + + /** + * token을 생성하는 메서드, jwt 안의 claim에 생성 시간, 생성자, 만료시간, secret Key 설정한 후 build + * @param intraId : token 주인을 payload에 설정하기 위함 + * @param validateTime : 토큰의 만료시간 설정하기 위함 + * @return token + */ + private String createToken( + final Integer intraId, final String intraName, final long validateTime, final String type + ) { + final Claims claims = Jwts.claims().setSubject(JwtConstants.USER_SUBJECTS.getValue()); + claims.put(JwtConstants.USER_ID.getValue(), intraId); + claims.put(JwtConstants.USER_NAME.getValue(), intraName); + claims.put(JwtConstants.TOKEN_TYPE.getValue(), type); + claims.put(JwtConstants.ROLE_LEVEL.getValue(), JwtConstants.USER_ROLE.getValue()); + final Date now = new Date(); + + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setIssuer(issuer) + .setExpiration(new Date(now.getTime() + validateTime)) + .signWith(generateSecretKey(secret_code), SignatureAlgorithm.HS256) + .compact(); + } + + /** + * jwt Token 설정 시 필요한 secret code 복호화 메서드 + * @param secretCode : 사용자 지정 secretcode + * @return 복호화된 code + */ + private Key generateSecretKey(final String secretCode) { + final String encodedSecretCode = Base64.getEncoder().encodeToString(secretCode.getBytes()); + return Keys.hmacShaKeyFor(encodedSecretCode.getBytes()); + } + + /** + * security의 권한과 관련된 메서드 + * token을 받아, 파싱한 후 권한 정보를 찾아온 후 + * intraId 값으로 member을 찾아 권한을 부여한다 + * @param token + * @return + */ + public Authentication getAuthentication(final HttpServletRequest request, final String token) { + // 토큰 복호화 + final Claims claims = parseToken(token); + log.info("[login] : 이름 {}, 토큰 {}", claims.get(JwtConstants.USER_NAME.getValue()), token); + + validateTypeAndClaims(request, claims); + + // 클레임에서 권한 정보 가져오기 + final Collection authorities = Stream.of( + claims.get(JwtConstants.ROLE_LEVEL.getValue()).toString()) + .map(SimpleGrantedAuthority::new) + .toList(); + + final Integer intraId = claims.get(JwtConstants.USER_ID.getValue(), Integer.class); + + //token 에 담긴 정보에 맵핑되는 User 정보 디비에서 조회 + final Member member = memberService.findOne(intraId) + .orElseThrow(JwtException.NotFoundJwtToken::new); + + //Authentication 객체 생성 + return new UsernamePasswordAuthenticationToken( + new AuthUser(member.getIntraId(), member.getIntraName(), member.getDefaultGroupId()), + "", + authorities); + } + + /** + * acessToken을 파싱하여, 유효한 token인지 판별 + * 1. 유효한 토큰인지 + * 2. 토큰 만료 시간이 다 되었는지 + * 3. 지원 되지 않은 토큰 인지 + * 4. 사용자 지정 유효한 토큰인지 + * @param accessToken : 판별할 token + * @return 파싱된 claim + */ + private Claims parseToken(final String accessToken) { + try { + return Jwts.parserBuilder() + .setSigningKey(generateSecretKey(secret_code)) + .build() + .parseClaimsJws(accessToken) + .getBody(); + } catch (MalformedJwtException e) { + throw new JwtException.InvalidJwtToken(); + } catch (ExpiredJwtException e) { + throw new JwtException.ExpiredJwtToken(); + } catch (UnsupportedJwtException e) { + throw new JwtException.UnsupportedJwtToken(); + } catch (IllegalArgumentException e) { + throw new JwtException.IllegalJwtToken(); + } catch (SignatureException e) { + throw new JwtException.WrongSignedJwtToken(); + } + } + + public Optional extractToken(final HttpServletRequest request) { + final String authorization = request.getHeader(HttpHeaders.AUTHORIZATION); + if (Strings.isEmpty(authorization)) { + return Optional.empty(); + } + return getToken(authorization.split(" ")); + } + + private Optional getToken(final String[] token) { + if (token.length != 2 || !token[0].equals(JwtConstants.HEADER_TYPE.getValue())) { + return Optional.empty(); + } + return Optional.ofNullable(token[1]); + } + + private void validateTypeAndClaims(final HttpServletRequest request, final Claims claims) { + if (!Objects.equals(request.getRequestURI(), JwtConstants.REISSUE_URI.getValue()) + && !Objects.equals(claims.get(JwtConstants.TOKEN_TYPE.getValue()), JwtConstants.ACCESS.getValue())) { + throw new JwtException.UnMatchedTypeJwtToken(); + } + + if (Objects.equals(request.getRequestURI(), JwtConstants.REISSUE_URI.getValue()) + && !Objects.equals(claims.get(JwtConstants.TOKEN_TYPE.getValue()), JwtConstants.REFRESH.getValue())) { + throw new JwtException.UnMatchedTypeJwtToken(); + } + + if (claims.get(JwtConstants.TOKEN_TYPE.getValue()) == null) { + throw new JwtException.IllegalJwtToken(); + } + } +} diff --git a/src/main/java/kr/where/backend/jwt/dto/ResponseAccessTokenDTO.java b/src/main/java/kr/where/backend/jwt/dto/ResponseAccessTokenDTO.java new file mode 100644 index 0000000..87f0b82 --- /dev/null +++ b/src/main/java/kr/where/backend/jwt/dto/ResponseAccessTokenDTO.java @@ -0,0 +1,17 @@ +package kr.where.backend.jwt.dto; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ResponseAccessTokenDTO { + private String accessToken; + + @Builder + public ResponseAccessTokenDTO(final String accessToken) { + this.accessToken = accessToken; + } +} diff --git a/src/main/java/kr/where/backend/jwt/dto/ResponseRefreshTokenDTO.java b/src/main/java/kr/where/backend/jwt/dto/ResponseRefreshTokenDTO.java new file mode 100644 index 0000000..2b7fcc3 --- /dev/null +++ b/src/main/java/kr/where/backend/jwt/dto/ResponseRefreshTokenDTO.java @@ -0,0 +1,17 @@ +package kr.where.backend.jwt.dto; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ResponseRefreshTokenDTO { + private String refreshToken; + + @Builder + public ResponseRefreshTokenDTO(final String refreshToken) { + this.refreshToken = refreshToken; + } +} diff --git a/src/main/java/kr/where/backend/jwt/exception/JwtErrorCode.java b/src/main/java/kr/where/backend/jwt/exception/JwtErrorCode.java new file mode 100644 index 0000000..67f014b --- /dev/null +++ b/src/main/java/kr/where/backend/jwt/exception/JwtErrorCode.java @@ -0,0 +1,20 @@ +package kr.where.backend.jwt.exception; + +import kr.where.backend.exception.ErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum JwtErrorCode implements ErrorCode { + INVALID_TOKEN(1500,"유효한 Jwt 토큰이 없습니다."), + WRONG_SIGNED_TOKEN(1501, "서명이 잘못된 Jwt 토큰입니다."), + EXPIRED_TOKEN_TIME_OUT(1502, "만료된 Jwt 토큰입니다."), + UNSUPPORTED_TOKEN(1503, "지원 되지 않는 Jwt 토큰입니다."), + ILLEGAL_TOKEN(1504, "잘못된 Jwt 토큰입니다."), + NOTFOUND_TOKEN(1505, "멤버에 대한 Jwt 토큰이 존재하지 않습니다."), + UNMATCHED_TYPE_TOKEN(1506, "Jwt 토큰의 type이 다릅니다."); + + private final int errorCode; + private final String errorMessage; +} diff --git a/src/main/java/kr/where/backend/jwt/exception/JwtException.java b/src/main/java/kr/where/backend/jwt/exception/JwtException.java new file mode 100644 index 0000000..fb30808 --- /dev/null +++ b/src/main/java/kr/where/backend/jwt/exception/JwtException.java @@ -0,0 +1,51 @@ +package kr.where.backend.jwt.exception; + +import kr.where.backend.exception.CustomException; + +public class JwtException extends CustomException { + + public JwtException(final JwtErrorCode errorCode) { + super(errorCode); + } + + public static class InvalidJwtToken extends JwtException { + public InvalidJwtToken() { + super(JwtErrorCode.INVALID_TOKEN); + } + } + + public static class WrongSignedJwtToken extends JwtException { + public WrongSignedJwtToken() { + super(JwtErrorCode.WRONG_SIGNED_TOKEN); + } + } + + public static class ExpiredJwtToken extends JwtException { + public ExpiredJwtToken() { + super(JwtErrorCode.EXPIRED_TOKEN_TIME_OUT); + } + } + + public static class UnsupportedJwtToken extends JwtException { + public UnsupportedJwtToken() { + super(JwtErrorCode.UNSUPPORTED_TOKEN); + } + } + + public static class IllegalJwtToken extends JwtException { + public IllegalJwtToken() { + super(JwtErrorCode.ILLEGAL_TOKEN); + } + } + + public static class UnMatchedTypeJwtToken extends JwtException { + public UnMatchedTypeJwtToken() { + super(JwtErrorCode.UNMATCHED_TYPE_TOKEN); + } + } + public static class NotFoundJwtToken extends JwtException { + public NotFoundJwtToken() { + super(JwtErrorCode.NOTFOUND_TOKEN); + } + } +} diff --git a/src/main/java/kr/where/backend/jwt/swagger/JwtApiDocs.java b/src/main/java/kr/where/backend/jwt/swagger/JwtApiDocs.java new file mode 100644 index 0000000..ee92165 --- /dev/null +++ b/src/main/java/kr/where/backend/jwt/swagger/JwtApiDocs.java @@ -0,0 +1,32 @@ +package kr.where.backend.jwt.swagger; + +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.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import kr.where.backend.auth.authUser.AuthUser; +import kr.where.backend.auth.authUser.AuthUserInfo; +import kr.where.backend.jwt.dto.ResponseAccessTokenDTO; +import kr.where.backend.jwt.dto.ResponseRefreshTokenDTO; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.PostMapping; + +@Tag(name = "jwt", description = "jwt API") +public interface JwtApiDocs { + @Operation( + summary = "reissue accessToken of Expired time API", + description = "accessToken을 재 발급", + responses = { + @ApiResponse(responseCode = "200", description = "토큰 재발급 성공", content = @Content(schema = @Schema(implementation = ResponseAccessTokenDTO.class))), + } + + ) + @PostMapping("/reissue") + ResponseEntity reIssue( + final HttpServletResponse response, + @CookieValue(value = "refreshToken") final String refreshToken + ); +} diff --git a/src/main/java/kr/where/backend/location/Location.java b/src/main/java/kr/where/backend/location/Location.java new file mode 100644 index 0000000..c7797dd --- /dev/null +++ b/src/main/java/kr/where/backend/location/Location.java @@ -0,0 +1,88 @@ +package kr.where.backend.location; + +import jakarta.persistence.*; +import kr.where.backend.member.Member; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Slf4j +public class Location { + + @Id + @Column(name = "location_id", nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long locationId; + + @OneToOne(mappedBy = "location", fetch = FetchType.LAZY) + private Member member; + + @Column(length = 30) + private String customLocation; + + @Column(length = 10) + private String imacLocation; + + private LocalDateTime customUpdatedAt; + + private LocalDateTime imacUpdatedAt; + + public Location(final Member member, final String imacLocation) { + this.member = member; + this.imacLocation = imacLocation; + this.imacUpdatedAt = LocalDateTime.now(); + } + + public Location(final String imacLocation) { + this.imacLocation = imacLocation; + this.imacUpdatedAt = LocalDateTime.now(); + } + + public void setMember(Member member) { + this.member = member; + } + + public void setCustomLocation(final String customLocation) { + this.customLocation = customLocation; + this.customUpdatedAt = LocalDateTime.now(); + } + + public void setImacLocation(final String imacLocation) { + this.imacLocation = imacLocation; + this.imacUpdatedAt = LocalDateTime.now(); + } + + public void initLocation() { + this.imacLocation = null; + this.imacUpdatedAt = LocalDateTime.now(); + this.customLocation = null; + this.customUpdatedAt = LocalDateTime.now(); + } + + public String getLocation() { + if (!this.member.isAgree()) { + return this.imacLocation; + } else { + if (customLocation == null && imacLocation == null) { + return null; + } else if (customLocation == null) { + return imacLocation; + } else if (imacLocation == null) { + return customLocation; + } else { + if (customUpdatedAt.isAfter(imacUpdatedAt)) { + return customLocation; + } else { + return imacLocation; + } + } + } + } + +} diff --git a/src/main/java/kr/where/backend/location/LocationController.java b/src/main/java/kr/where/backend/location/LocationController.java new file mode 100644 index 0000000..941250d --- /dev/null +++ b/src/main/java/kr/where/backend/location/LocationController.java @@ -0,0 +1,54 @@ +package kr.where.backend.location; + +import jakarta.validation.Valid; +import kr.where.backend.auth.authUser.AuthUser; +import kr.where.backend.location.dto.ResponseLocationDTO; +import kr.where.backend.location.dto.UpdateCustomLocationDTO; +import kr.where.backend.location.swagger.LocationApiDocs; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Slf4j +@RequestMapping("/v3/location") +@RequiredArgsConstructor +public class LocationController implements LocationApiDocs { + + private final LocationService locationService; + + /** + * 수동자리 업데이트 + * + * @param updateCustomLocation + * @return ResponseEntity(responseLocationDTO) + */ + @PostMapping("/custom") + public ResponseEntity updateCustomLocation(@RequestBody @Valid final UpdateCustomLocationDTO updateCustomLocation) { + final AuthUser authUser = AuthUser.of(); + final ResponseLocationDTO responseLocationDto = locationService.updateCustomLocation(updateCustomLocation, + authUser); + + return ResponseEntity.ok(responseLocationDto); + } + + /** + * 수동자리 초기화(삭제) + * + * @return ResponseEntity(responseLocationDTO) + */ + @DeleteMapping("/custom") + public ResponseEntity deleteCustomLocation() { + final AuthUser authUser = AuthUser.of(); + final ResponseLocationDTO responseLocationDto = locationService.deleteCustomLocation(authUser); + + return ResponseEntity.ok(responseLocationDto); + } + +} diff --git a/src/main/java/kr/where/backend/location/LocationRepository.java b/src/main/java/kr/where/backend/location/LocationRepository.java new file mode 100644 index 0000000..2b1b792 --- /dev/null +++ b/src/main/java/kr/where/backend/location/LocationRepository.java @@ -0,0 +1,11 @@ +package kr.where.backend.location; + +import kr.where.backend.member.Member; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface LocationRepository extends JpaRepository { + Location findByMember(Member member); +} diff --git a/src/main/java/kr/where/backend/location/LocationService.java b/src/main/java/kr/where/backend/location/LocationService.java new file mode 100644 index 0000000..e65a65b --- /dev/null +++ b/src/main/java/kr/where/backend/location/LocationService.java @@ -0,0 +1,76 @@ +package kr.where.backend.location; + +import kr.where.backend.auth.authUser.AuthUser; +import kr.where.backend.location.dto.ResponseLocationDTO; +import kr.where.backend.location.dto.UpdateCustomLocationDTO; +import kr.where.backend.member.Member; +import kr.where.backend.member.MemberRepository; +import kr.where.backend.member.exception.MemberException; +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class LocationService { + + private final LocationRepository locationRepository; + private final MemberRepository memberRepository; + + /** + * member의 imac 정보를 set + * member와 location은 one-to-one mappling 되어있음 + * + * @param member + * @param imac + */ + @Transactional + public void create(final Member member, final String imac) { + final Location location = new Location(member, imac); + + locationRepository.save(location); + + member.setLocation(location); + } + + /** + * 수동자리 업데이트 + * + * @param updateCustomLocationDto 수동자리정보 + * @param authUser accessToken 파싱한 정보 + * @return responseLocationDTO + * @throws MemberException.NoMemberException 존재하지 않는 멤버입니다 + */ + @Transactional + public ResponseLocationDTO updateCustomLocation( + final UpdateCustomLocationDTO updateCustomLocationDto, + final AuthUser authUser + ) { + final Member member = memberRepository.findByIntraId(authUser.getIntraId()) + .orElseThrow(MemberException.NoMemberException::new); + final Location location = locationRepository.findByMember(member); + + location.setCustomLocation(updateCustomLocationDto.getCustomLocation()); + + return ResponseLocationDTO.builder().location(location).build(); + } + + /** + * 수동자리 초기화(삭제) + * + * @param authUser accessToken 파싱한 정보 + * @return responseLocationDTO + * @throws MemberException.NoMemberException 존재하지 않는 멤버입니다 + */ + @Transactional + public ResponseLocationDTO deleteCustomLocation(final AuthUser authUser) { + final Member member = memberRepository.findByIntraId(authUser.getIntraId()) + .orElseThrow(MemberException.NoMemberException::new); + final Location location = locationRepository.findByMember(member); + + location.setCustomLocation(null); + + return ResponseLocationDTO.builder().location(location).build(); + } +} diff --git a/src/main/java/kr/where/backend/location/dto/ResponseLocationDTO.java b/src/main/java/kr/where/backend/location/dto/ResponseLocationDTO.java new file mode 100644 index 0000000..bf17235 --- /dev/null +++ b/src/main/java/kr/where/backend/location/dto/ResponseLocationDTO.java @@ -0,0 +1,27 @@ +package kr.where.backend.location.dto; + +import kr.where.backend.location.Location; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@RequiredArgsConstructor +public class ResponseLocationDTO { + private Integer intraId; + private String imacLocation; + private LocalDateTime imacUpdatedAt; + private String customLocation; + private LocalDateTime customUpdatedAt; + + @Builder + public ResponseLocationDTO(final Location location) { + this.intraId = location.getMember().getIntraId(); + this.imacLocation = location.getImacLocation(); + this.imacUpdatedAt = location.getImacUpdatedAt(); + this.customLocation = location.getCustomLocation(); + this.customUpdatedAt = location.getCustomUpdatedAt(); + } +} diff --git a/src/main/java/kr/where/backend/location/dto/UpdateCustomLocationDTO.java b/src/main/java/kr/where/backend/location/dto/UpdateCustomLocationDTO.java new file mode 100644 index 0000000..d0b0c51 --- /dev/null +++ b/src/main/java/kr/where/backend/location/dto/UpdateCustomLocationDTO.java @@ -0,0 +1,17 @@ +package kr.where.backend.location.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; + +@Getter +public class UpdateCustomLocationDTO { + private String customLocation; + + public static UpdateCustomLocationDTO createForTest(String customLocation) { + UpdateCustomLocationDTO updateCustomLocationDto = new UpdateCustomLocationDTO(); + + updateCustomLocationDto.customLocation = customLocation; + + return updateCustomLocationDto; + } +} diff --git a/src/main/java/kr/where/backend/location/swagger/LocationApiDocs.java b/src/main/java/kr/where/backend/location/swagger/LocationApiDocs.java new file mode 100644 index 0000000..b2f5214 --- /dev/null +++ b/src/main/java/kr/where/backend/location/swagger/LocationApiDocs.java @@ -0,0 +1,50 @@ +package kr.where.backend.location.swagger; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +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.tags.Tag; +import jakarta.validation.Valid; +import kr.where.backend.location.dto.ResponseLocationDTO; +import kr.where.backend.location.dto.UpdateCustomLocationDTO; +import kr.where.backend.member.exception.MemberException; + +@Tag(name = "location", description = "location API") +public interface LocationApiDocs { + @Operation(summary = "3.1 updateCustomLocation API", description = "수동 자리 업데이트", + parameters = { + @Parameter(name = "accessToken", description = "인증/인가 확인용 accessToken", in = ParameterIn.HEADER), + }, + requestBody = + @io.swagger.v3.oas.annotations.parameters.RequestBody( + content = @Content( + schema = @Schema(implementation = UpdateCustomLocationDTO.class)) + ), + responses = { + @ApiResponse(responseCode = "200", description = "수동자리 변경 성공", content = @Content(schema = @Schema(implementation = ResponseLocationDTO.class))), + @ApiResponse(responseCode = "1000", description = "존재하지 않는 맴버입니다.", content = @Content(schema = @Schema(implementation = MemberException.NoMemberException.class))) + } + ) + @PostMapping("/custom") + ResponseEntity updateCustomLocation(@RequestBody @Valid final UpdateCustomLocationDTO updateCustomLocation); + + @Operation(summary = "3.2 deleteCustomLocation API", description = "수동 자리 초기화(삭제)", + parameters = { + @Parameter(name = "accessToken", description = "인증/인가 확인용 accessToken", in = ParameterIn.HEADER), + }, + responses = { + @ApiResponse(responseCode = "200", description = "수동자리 초기화 성공", content = @Content(schema = @Schema(implementation = ResponseLocationDTO.class))), + @ApiResponse(responseCode = "1000", description = "존재하지 않는 맴버입니다.", content = @Content(schema = @Schema(implementation = MemberException.NoMemberException.class))) + } + ) + @DeleteMapping("/custom") + ResponseEntity deleteCustomLocation(); +} diff --git a/src/main/java/kr/where/backend/logger/Logger.java b/src/main/java/kr/where/backend/logger/Logger.java new file mode 100644 index 0000000..316e91f --- /dev/null +++ b/src/main/java/kr/where/backend/logger/Logger.java @@ -0,0 +1,47 @@ +package kr.where.backend.logger; + +public enum Logger { + LOGIN("[INFO] : '{}님이 로그인 하였습니다."), + LOGOUT("[INFO] : '{}님이 로그아웃 하였습니다."), + ADMIN_LOGIN("[ADMIN] : 관리자 '{}' 님이 로그인 하였습니다."), + ADMIN_LOGOUT("[ADMIN] : 관리자 '{}' 님이 로그아웃 하였습니다."), + CHECK_MAIN_PAGE("[INFO] : '{}'님이 메인페이지를 조회하였습니다."), + CREATE_AGREE_MEMBER("[INFO] : 새로운 동의-멤버'{}'님이 등록되었습니다."), + CREATE_DISAGREE_MEMBER("[INFO] : 새로운 비동의-멤버'{}'님이 등록되었습니다."), + UPDATE_MEMBER_COMMENT("[INFO] : '{}'님이 상태메세지를 {}로 변경하였습니다."), + UPDATE_MEMBER_LOCATION("[INFO] : '{}'님의 자리가 {}에서 {}로 변경되었습니다."), + UPDATE_MEMBER_CUSTOM_LOCATION("[INFO] : '{}'님이 {}로 수동 설정하였습니다."), + UPDATE_MEMBER_IMAC_LOCATION("[INFO] : '{}'님이 {}로 변경되었습니다."), + UPDATE_MEMBER_INCLUSTER("[INFO] : '{}'님이 {}하였습니다."), + UPDATE_GROUP_NAME("[INFO] : '{}'님이 그룹이름을 '{}로 변경하였습니다."), + CREATE_GROUP("[INFO] : '{}'님이 그룹 '[{}]'을 생성하였습니다."), + DELETE_GROUP("[INFO] : '{}'님이 그룹 '[{}]'을 삭제했습니다"), + UPDATE_GROUPMEMBER("[INFO] : '{}'님이 '{}님을 친구 추가 하였습니다."), + DELETE_GROUPMEMBER("[INFO] : '{}'번 그룹에서 {}님이 삭제 되었습니다."), + SEARCH_CADET("[INFO] : '{}' 검색어로 요청 되었습니다."), + UPDATE_IMG("[INFO] : 이미지 업데이트를 시작합니다."), + GET_OAUTH_TOKEN("[OAuth] : Intra OAuth Token이 발급되었습니다."), + FAIL_REQUSET_OAUTH_TOKEN("[OAuth] : Intra OAuth Token 발급 요청 횟수가 초과되었습니다."), + CREATE_TOKEN("[TOKEN] : {} 토큰이 생성되었습니다."), + DELETE_TOKEN("[TOKEN] : {} 토큰이 삭제되었습니다."), + UPDATE_TOKEN("[TOKEN] : {} 토큰이 갱신 되었습니다."), + EXPIRE_TOKEN("[TOKEN] : {} 토큰이 만료되었습니다."), + INTRA_API_FALLBACK("[INTRA API] : Cadet Privacy fallback {}"), + HANE_API_ERROR("[HANE API] : Hane Api 오류가 발생하였습니다."), + EXCEPTION("[EXCEPTION] : {}"); + + public final String msg; + + Logger(String msg) { + this.msg = msg; + } + + + //이런식으로 사용하면 돠지 않을까 싶습니다~ +// @Slf4j +// public static void main(String[] args) { +// log.info(Logger.LOGIN.msg, "suhwpark", "where42"); +// } +} + + diff --git a/src/main/java/kr/where/backend/logout/LogoutController.java b/src/main/java/kr/where/backend/logout/LogoutController.java new file mode 100644 index 0000000..320ddc6 --- /dev/null +++ b/src/main/java/kr/where/backend/logout/LogoutController.java @@ -0,0 +1,22 @@ +package kr.where.backend.logout; + +import kr.where.backend.auth.authUser.AuthUser; +import kr.where.backend.auth.authUser.AuthUserInfo; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v3") +@RequiredArgsConstructor +public class LogoutController { + + private final LogoutService logoutService; + + @PostMapping("/logout") + public ResponseEntity logout(@AuthUserInfo final AuthUser authUser) { + return ResponseEntity.ok(logoutService.logout(authUser)); + } +} diff --git a/src/main/java/kr/where/backend/logout/LogoutService.java b/src/main/java/kr/where/backend/logout/LogoutService.java new file mode 100644 index 0000000..c840c60 --- /dev/null +++ b/src/main/java/kr/where/backend/logout/LogoutService.java @@ -0,0 +1,14 @@ +package kr.where.backend.logout; + +import kr.where.backend.auth.authUser.AuthUser; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +@Service +public class LogoutService { + public String logout(final AuthUser authUser) { + SecurityContextHolder.clearContext(); + + return authUser.getIntraName(); + } +} diff --git a/src/main/java/kr/where/backend/member/Member.java b/src/main/java/kr/where/backend/member/Member.java new file mode 100644 index 0000000..049b0af --- /dev/null +++ b/src/main/java/kr/where/backend/member/Member.java @@ -0,0 +1,136 @@ +package kr.where.backend.member; + +import jakarta.persistence.*; +import kr.where.backend.api.json.CadetPrivacy; +import kr.where.backend.group.entity.GroupMember; +import kr.where.backend.location.Location; +import kr.where.backend.api.json.hane.Hane; +import lombok.*; +import lombok.extern.slf4j.Slf4j; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Slf4j +public class Member { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", unique = true, nullable = false) + private Long id; + + @Column(name = "intra_id", unique = true) + private Integer intraId; + + @Column(length = 15, unique = true, nullable = false) + private String intraName; + + @Column(length = 40) + private String comment; + + private String image; + + private boolean inCluster; + + private LocalDateTime inClusterUpdatedAt; + + @Column(nullable = false) + private boolean blackHole; + + @Column(nullable = false) + private String grade; + + @Column(nullable = false) + private boolean agree; + + private Long defaultGroupId; + + @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY) + @JoinColumn(name = "location_id") + private Location location; + + @Column(nullable = false) + @CreationTimestamp + private LocalDateTime createdAt = LocalDateTime.now(); + + @Column(nullable = false) + @UpdateTimestamp + private LocalDateTime updatedAt = LocalDateTime.now(); + + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + private List groupMembers = new ArrayList<>(); + + public Member(final CadetPrivacy cadetPrivacy, final Hane hane) { + this.intraId = cadetPrivacy.getId(); + this.intraName = cadetPrivacy.getLogin(); + this.grade = cadetPrivacy.getCreated_at(); + this.image = cadetPrivacy.getImage().getVersions().getSmall(); + this.inCluster = hane.getInoutState().equals("IN"); + this.inClusterUpdatedAt = LocalDateTime.now(); + this.agree = true; + this.blackHole = false; + } + + public Member(final CadetPrivacy cadetPrivacy) { + this.intraId = cadetPrivacy.getId(); + this.intraName = cadetPrivacy.getLogin(); + this.image = cadetPrivacy.getImage().getVersions().getSmall(); + this.grade = cadetPrivacy.getCreated_at(); + this.blackHole = false; + this.agree = false; + } + + public void setDisagreeToAgree(final Hane hane) { + this.inCluster = Objects.equals(hane.getInoutState(), "IN"); + this.agree = true; + } + + public void setComment(final String comment) { + this.comment = comment; + } + + public void setLocation(final Location location) { + this.location = location; + } + + public void setDefaultGroupId(final Long defaultGroupId) { + this.defaultGroupId = defaultGroupId; + } + + public void setBlackHole(final boolean active) { + this.blackHole = !active; + } + + public void setInCluster(final Hane hane) { + this.inCluster = Objects.equals(hane.getInoutState(), "IN"); + this.inClusterUpdatedAt = LocalDateTime.now(); + + if (this.inCluster == false) + this.location.initLocation(); + } + + // public void setInClusterUpdatedAtForTest() { + // this.inClusterUpdatedAt = inClusterUpdatedAt.minusMinutes(4); + // // this.inClusterUpdatedAt = null; + // } + + public boolean isPossibleToUpdateInCluster() { + if (inClusterUpdatedAt == null || LocalDateTime.now() + .minusMinutes(5) + .isAfter(inClusterUpdatedAt)) + return true; + return false; + } + + public void setImage(final String image) { + this.image = image; + } + +} diff --git a/src/main/java/kr/where/backend/member/MemberController.java b/src/main/java/kr/where/backend/member/MemberController.java new file mode 100644 index 0000000..4e40038 --- /dev/null +++ b/src/main/java/kr/where/backend/member/MemberController.java @@ -0,0 +1,100 @@ +package kr.where.backend.member; + +import jakarta.validation.Valid; +import kr.where.backend.auth.authUser.AuthUser; +import kr.where.backend.auth.authUser.AuthUserInfo; +import kr.where.backend.member.dto.*; +import kr.where.backend.member.swagger.MemberApiDocs; +import lombok.RequiredArgsConstructor; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/v3/member") +@RequiredArgsConstructor +public class MemberController implements MemberApiDocs { + + private final MemberService memberService; + + /** + * intraId에 해당하는 member 1명 조회 + * + * @param intraId + * @return ResponseEntity(ResponseMemberDTO) + */ + @GetMapping("/one") + public ResponseEntity findOneByIntraId(@RequestParam("intraId") final Integer intraId) { + final ResponseMemberDTO responseMemberDto = memberService.findOneByIntraId(intraId); + + return ResponseEntity.ok(responseMemberDto); + } + + /** + * 본인정보 조회 + * + * @return ResponseEntity(ResponseMemberDTO) + */ + @GetMapping("") + public ResponseEntity findOneByAccessToken(@AuthUserInfo final AuthUser authUser) { + final ResponseMemberDTO responseMemberDto = memberService.findOneByIntraId(authUser.getIntraId()); + + return ResponseEntity.ok(responseMemberDto); + } + + /** + * DB에 존재하는 모든 멤버 list 조회 + * + * @return ResponseEntity(ResponseMemberDTOList) + */ + @GetMapping("/all") + public ResponseEntity> findAll() { + final List responseMemberDTOList = memberService.findAll(); + + return ResponseEntity.ok(responseMemberDTOList); + } + + /** + * 멤버 탈퇴 + * accessToken을 받아서 claim에 존재하는 intraId에 해당하는 멤버 delete (본인 탈퇴) + * + * @return ResponseEntity(ResponseMemberDTO) + */ + @DeleteMapping("") + public ResponseEntity deleteMember(@AuthUserInfo final AuthUser authUser) { + final ResponseMemberDTO responseMemberDto = memberService.deleteMember(authUser); + + return ResponseEntity.ok(responseMemberDto); + } + + /** + * 상태메세지 변경 + * + * @param updateMemberCommentDto + * @return ResponseEntity(ResponseMemberDTO) + */ + @PostMapping("/comment") + public ResponseEntity updateComment( + @RequestBody @Valid final UpdateMemberCommentDTO updateMemberCommentDto, + @AuthUserInfo final AuthUser authUser) { + + final ResponseMemberDTO responseMemberDto = memberService.updateComment(updateMemberCommentDto, authUser); + + return ResponseEntity.ok(responseMemberDto); + } + + /** + * 상태메세지 삭제 + * + * @return ResponseEntity(ResponseMemberDTO) + */ + @DeleteMapping("/comment") + public ResponseEntity deleteComment(@AuthUserInfo final AuthUser authUser) { + + final ResponseMemberDTO responseMemberDto = memberService.deleteComment(authUser); + + return ResponseEntity.ok(responseMemberDto); + } +} diff --git a/src/main/java/kr/where/backend/member/MemberRepository.java b/src/main/java/kr/where/backend/member/MemberRepository.java new file mode 100644 index 0000000..7269073 --- /dev/null +++ b/src/main/java/kr/where/backend/member/MemberRepository.java @@ -0,0 +1,22 @@ +package kr.where.backend.member; + +import kr.where.backend.api.json.hane.HaneRequestDto; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.List; + +@Repository +public interface MemberRepository extends JpaRepository { + Optional findByIntraId(Integer intraId); + + Optional> findByIntraIdIn(List intraId); + + Optional findByIntraName(String intraName); + + @Query("select new kr.where.backend.api.json.hane.HaneRequestDto(m.intraName) " + + "from Member m where m.agree = true ") + Optional> findAllToUseHaneApi(); +} diff --git a/src/main/java/kr/where/backend/member/MemberService.java b/src/main/java/kr/where/backend/member/MemberService.java new file mode 100644 index 0000000..c6529a2 --- /dev/null +++ b/src/main/java/kr/where/backend/member/MemberService.java @@ -0,0 +1,207 @@ +package kr.where.backend.member; + +import kr.where.backend.api.HaneApiService; +import kr.where.backend.api.json.CadetPrivacy; +import kr.where.backend.api.json.hane.Hane; +import kr.where.backend.api.json.hane.HaneRequestDto; +import kr.where.backend.auth.authUser.AuthUser; +import kr.where.backend.group.GroupService; +import kr.where.backend.group.dto.group.CreateGroupDTO; +import kr.where.backend.group.dto.group.ResponseGroupDTO; +import kr.where.backend.location.LocationService; +import kr.where.backend.member.dto.ResponseMemberDTO; +import kr.where.backend.member.dto.UpdateMemberCommentDTO; +import kr.where.backend.group.entity.Group; +import kr.where.backend.member.exception.MemberException; +import lombok.RequiredArgsConstructor; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberService { + + private final MemberRepository memberRepository; + private final GroupService groupService; + private final LocationService locationService; + private final HaneApiService haneApiServiceService; + private final static Integer CAMPUS_ID = 29; + /** + * if (이미 존재하는 멤버) + * throw duplicate_exception + * else if (비동의 멤버) + * disagree -> agree + * else if (새로 생성해야 하는 상황) + * new member + * new location + * + * 멤버 생성후에는 new default group을 해준다 + * + * @param cadetPrivacy : 42api에게 받아온 cadet info + * @param hane : hane api에게 받아온 inCluster 여부 + * @return member + * @throws MemberException.DuplicatedMemberException 이미 존재하는 멤버입니다 + */ + @Transactional + public Member createAgreeMember(final CadetPrivacy cadetPrivacy, final Hane hane) { + final AuthUser authUser = AuthUser.of(); + + Member member = memberRepository.findByIntraId(authUser.getIntraId()).orElse(null); + + if (member != null && member.isAgree()) { + throw new MemberException.DuplicatedMemberException(); + } else if (member != null && !member.isAgree()) { + member.setDisagreeToAgree(hane); + } else { + member = new Member(cadetPrivacy, hane); + memberRepository.save(member); + locationService.create(member, cadetPrivacy.getLocation()); + } + ResponseGroupDTO responseGroupDto = groupService.createGroup(new CreateGroupDTO(Group.DEFAULT_GROUP), authUser); + member.setDefaultGroupId(responseGroupDto.getGroupId()); + + return member; + } + + /** + * hane의 inCluster 정보가 없는 disagree 멤버 생성 + * + * @param cadetPrivacy : 42api에게 받아온 cadet info + * @return member + */ + @Transactional + public Member createDisagreeMember(final CadetPrivacy cadetPrivacy) { + isSeoulCampus(cadetPrivacy); + final Member member = new Member(cadetPrivacy); + memberRepository.save(member); + locationService.create(member, cadetPrivacy.getLocation()); + + return member; + } + + /** + * find 전체 멤버 리스트 + * + * @return responseMemberDTOList : member entity와 동일한 모양의 responseMemberDTO의 list + */ + public List findAll() { + final List members = memberRepository.findAll(); + final List responseMemberDTOList = members.stream().map(member -> ResponseMemberDTO.builder() + .member(member).build()).toList(); + + return responseMemberDTOList; + } + + /** + * 멤버 탈퇴 + * accessToken에서 intraId를 얻어오므로 본인확인여부를 거치지 않는다 + * jwt token과 member entity를 삭제 + * + * @param authUser : accessToken 파싱한 정보 + * @return responseMemberDto + * @throws MemberException.NoMemberException 존재하지 않는 멤버입니다 + */ + @Transactional + public ResponseMemberDTO deleteMember(final AuthUser authUser) { + final Member member = memberRepository.findByIntraId(authUser.getIntraId()) + .orElseThrow(MemberException.NoMemberException::new); + final ResponseMemberDTO responseMemberDto = ResponseMemberDTO.builder().member(member).build(); + + memberRepository.delete(member); + + return responseMemberDto; + } + + /** + * 존재하는 멤버인지 검사 후, 본인의 comment를 변경함 + * + * @param updateMemberCommentDto : 변경할 comment + * @param authUser : accessToken 파싱한 정보 + * @return responseMemberDTO + * @throws MemberException.NoMemberException 존재하지 않는 멤버입니다 + */ + @Transactional + public ResponseMemberDTO updateComment( + final UpdateMemberCommentDTO updateMemberCommentDto, + final AuthUser authUser) { + final Member member = memberRepository.findByIntraId(authUser.getIntraId()) + .orElseThrow(MemberException.NoMemberException::new); + member.setComment(updateMemberCommentDto.getComment()); + + return ResponseMemberDTO.builder().member(member).build(); + } + + /** + * 존재하는 멤버인지 검사 후, 본인의 comment를 변경함 + * + * @param authUser : accessToken 파싱한 정보 + * @return responseMemberDTO + * @throws MemberException.NoMemberException 존재하지 않는 멤버입니다 + */ + @Transactional + public ResponseMemberDTO deleteComment(final AuthUser authUser) { + final Member member = memberRepository.findByIntraId(authUser.getIntraId()) + .orElseThrow(MemberException.NoMemberException::new); + member.setComment(null); + + return ResponseMemberDTO.builder().member(member).build(); + } + + /** + * intraId에 해당하는 member 한명을 찾음 + * findOne API(in member)을 위한 service + * + * @param intraId + * @return responseMemberDTO + * @throws MemberException.NoMemberException 존재하지 않는 멤버입니다 + */ + @Transactional + public ResponseMemberDTO findOneByIntraId(final Integer intraId) { + final Member member = memberRepository.findByIntraId(intraId) + .orElseThrow(MemberException.NoMemberException::new); + + if (member.isPossibleToUpdateInCluster()) + haneApiServiceService.updateInClusterForMainPage(member); + + return ResponseMemberDTO.builder().member(member).build(); + } + + /** + * intraId에 해당하는 member 한명을 찾음 + * search API를 위한 service + * + * @param intraId + * @return Optional + */ + public Optional findOne(final Integer intraId) { + return memberRepository.findByIntraId(intraId); + } + + public Optional> findAgreeMembers() { + return memberRepository.findAllToUseHaneApi(); + } + + /** + * 회원 가입하려는 사용자가 서울 캠퍼스 인지 판별 + * Oauth2SuccessHandler.class에서 사용 + * disagree member entity 만들때 사용 + * + * @param cadetPrivacy + * @throws MemberException + */ + public void isSeoulCampus(final CadetPrivacy cadetPrivacy) { + if (!cadetPrivacy.getCampus().equals(CAMPUS_ID)) { + throw new MemberException.NotFromSeoulCampus(); + } + } + + + public Optional findByIntraName(final String intraName) { + return memberRepository.findByIntraName(intraName); + } +} diff --git a/src/main/java/kr/where/backend/member/dto/ResponseMemberDTO.java b/src/main/java/kr/where/backend/member/dto/ResponseMemberDTO.java new file mode 100644 index 0000000..4825999 --- /dev/null +++ b/src/main/java/kr/where/backend/member/dto/ResponseMemberDTO.java @@ -0,0 +1,32 @@ +package kr.where.backend.member.dto; + +import kr.where.backend.member.Member; +import lombok.*; + +@Getter +@RequiredArgsConstructor +public class ResponseMemberDTO { + private Integer intraId; + private String intraName; + private String grade; + private String image; + private String comment; + private boolean inCluster; + private boolean agree; + private Long defaultGroupId; + private String location; + + @Builder + public ResponseMemberDTO(final Member member) { + this.intraId = member.getIntraId(); + this.intraName = member.getIntraName(); + this.grade = member.getGrade(); + this.image = member.getImage(); + this.comment = member.getComment(); + this.inCluster = member.isInCluster(); + this.agree = member.isAgree(); + this.defaultGroupId = member.getDefaultGroupId(); + this.location = member.getLocation().getLocation(); + } + +} diff --git a/src/main/java/kr/where/backend/member/dto/UpdateMemberCommentDTO.java b/src/main/java/kr/where/backend/member/dto/UpdateMemberCommentDTO.java new file mode 100644 index 0000000..5d98f53 --- /dev/null +++ b/src/main/java/kr/where/backend/member/dto/UpdateMemberCommentDTO.java @@ -0,0 +1,12 @@ +package kr.where.backend.member.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class UpdateMemberCommentDTO { + @NotBlank + private String comment; +} diff --git a/src/main/java/kr/where/backend/member/exception/MemberErrorCode.java b/src/main/java/kr/where/backend/member/exception/MemberErrorCode.java new file mode 100644 index 0000000..1593939 --- /dev/null +++ b/src/main/java/kr/where/backend/member/exception/MemberErrorCode.java @@ -0,0 +1,17 @@ +package kr.where.backend.member.exception; + +import kr.where.backend.exception.ErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum MemberErrorCode implements ErrorCode { + + NO_MEMBER(1000, "존재하지 않는 맴버입니다."), + DUPLICATED_MEMBER(1001, "이미 존재하는 맴버입니다."), + NOT_FROM_SEOUL_CADET(1002, "서울 캠퍼스 카뎃이 아닙니다."); + + private final int errorCode; + private final String errorMessage; +} diff --git a/src/main/java/kr/where/backend/member/exception/MemberException.java b/src/main/java/kr/where/backend/member/exception/MemberException.java new file mode 100644 index 0000000..787fd86 --- /dev/null +++ b/src/main/java/kr/where/backend/member/exception/MemberException.java @@ -0,0 +1,28 @@ +package kr.where.backend.member.exception; + +import kr.where.backend.exception.CustomException; + +public class MemberException extends CustomException { + + public MemberException(final MemberErrorCode memberErrorCode) { + super(memberErrorCode); + } + + public static class NoMemberException extends MemberException { + public NoMemberException() { + super(MemberErrorCode.NO_MEMBER); + } + } + + public static class DuplicatedMemberException extends MemberException { + public DuplicatedMemberException() { + super(MemberErrorCode.DUPLICATED_MEMBER); + } + } + + public static class NotFromSeoulCampus extends MemberException { + public NotFromSeoulCampus() { + super(MemberErrorCode.NOT_FROM_SEOUL_CADET); + } + } +} diff --git a/src/main/java/kr/where/backend/member/swagger/MemberApiDocs.java b/src/main/java/kr/where/backend/member/swagger/MemberApiDocs.java new file mode 100644 index 0000000..193ce5d --- /dev/null +++ b/src/main/java/kr/where/backend/member/swagger/MemberApiDocs.java @@ -0,0 +1,108 @@ +package kr.where.backend.member.swagger; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.ArraySchema; +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.tags.Tag; +import jakarta.validation.Valid; +import kr.where.backend.auth.authUser.AuthUser; +import kr.where.backend.auth.authUser.AuthUserInfo; +import kr.where.backend.member.dto.ResponseMemberDTO; +import kr.where.backend.member.dto.UpdateMemberCommentDTO; +import kr.where.backend.member.exception.MemberException; + +@Tag(name = "member", description = "member API") +public interface MemberApiDocs { + + @Operation(summary = "1.3 findOneByIntraId API", description = "맴버 1명 Dto 조회", + parameters = { + @Parameter(name = "accessToken", description = "인증/인가 확인용 accessToken", in = ParameterIn.HEADER), + @Parameter(name = "intraId", description = "5자리 intra 고유 id", in = ParameterIn.QUERY), + }, + responses = { + @ApiResponse(responseCode = "200", description = "맴버 조회 성공", content = @Content(schema = @Schema(implementation = ResponseMemberDTO.class))), + @ApiResponse(responseCode = "1000", description = "존재하지 않는 맴버입니다.", content = @Content(schema = @Schema(implementation = MemberException.NoMemberException.class))) + } + ) + @GetMapping("/one") + ResponseEntity findOneByIntraId(@RequestParam("intraId") final Integer intraId); + + @Operation(summary = "1.7 findOneByAccessToken API", description = "본인 Dto 조회", + parameters = { + @Parameter(name = "accessToken", description = "인증/인가 확인용 accessToken", in = ParameterIn.HEADER), + }, + responses = { + @ApiResponse(responseCode = "200", description = "맴버 조회 성공", content = @Content(schema = @Schema(implementation = ResponseMemberDTO.class))), + @ApiResponse(responseCode = "1000", description = "존재하지 않는 맴버입니다.", content = @Content(schema = @Schema(implementation = MemberException.NoMemberException.class))) + } + ) + @GetMapping("") + public ResponseEntity findOneByAccessToken(@AuthUserInfo final AuthUser authUser); + + @Operation(summary = "1.6 findAll API", description = "모든 멤버 list 조회", + parameters = { + @Parameter(name = "accessToken", description = "인증/인가 확인용 accessToken", in = ParameterIn.HEADER), + }, + responses = { + @ApiResponse(responseCode = "200", description = "맴버 조회 성공", content = @Content(schema = @Schema(implementation = ResponseMemberDTO.class))), + @ApiResponse(responseCode = "1000", description = "존재하지 않는 맴버입니다.", content = @Content(schema = @Schema(implementation = MemberException.NoMemberException.class))) + } + ) + @GetMapping("/all") + ResponseEntity> findAll(); + + @Operation(summary = "1.2 deleteMember API", description = "맴버 탈퇴", + parameters = { + @Parameter(name = "accessToken", description = "인증/인가 확인용 accessToken", in = ParameterIn.HEADER), + }, + responses = { + @ApiResponse(responseCode = "200", description = "맴버 삭제 성공", content = @Content(schema = @Schema(implementation = ResponseMemberDTO.class))), + @ApiResponse(responseCode = "1000", description = "존재하지 않는 맴버입니다.", content = @Content(schema = @Schema(implementation = MemberException.NoMemberException.class))) + } + ) + @DeleteMapping("") + ResponseEntity deleteMember(@AuthUserInfo final AuthUser authUser); + + @Operation(summary = "1.4 updatePersonalMessage API", description = "맴버 상태 메시지 변경", + parameters = { + @Parameter(name = "accessToken", description = "인증/인가 확인용 accessToken", in = ParameterIn.HEADER), + }, + requestBody = + @io.swagger.v3.oas.annotations.parameters.RequestBody( + content = @Content(schema = @Schema(implementation = UpdateMemberCommentDTO.class))) + , + responses = { + @ApiResponse(responseCode = "200", description = "맴버 상태 메시지 변경 성공", content = @Content(schema = @Schema(implementation = ResponseMemberDTO.class))), + @ApiResponse(responseCode = "1000", description = "존재하지 않는 맴버입니다.", content = @Content(schema = @Schema(implementation = MemberException.NoMemberException.class))) + } + ) + @PostMapping("/comment") + ResponseEntity updateComment( + @RequestBody @Valid final UpdateMemberCommentDTO updateMemberCommentDto, + @AuthUserInfo final AuthUser authUser); + + @Operation(summary = "1.8 deletePersonalMessage API", description = "맴버 상태 메시지 삭제", + parameters = { + @Parameter(name = "accessToken", description = "인증/인가 확인용 accessToken", in = ParameterIn.HEADER), + }, + responses = { + @ApiResponse(responseCode = "200", description = "맴버 상태 메시지 삭제 성공", content = @Content(schema = @Schema(implementation = ResponseMemberDTO.class))), + @ApiResponse(responseCode = "1000", description = "존재하지 않는 맴버입니다.", content = @Content(schema = @Schema(implementation = MemberException.NoMemberException.class))) + } + ) + @DeleteMapping("/comment") + ResponseEntity deleteComment(@AuthUserInfo final AuthUser authUser); +} diff --git a/src/main/java/kr/where/backend/oauthtoken/OAuthToken.java b/src/main/java/kr/where/backend/oauthtoken/OAuthToken.java new file mode 100644 index 0000000..e28f833 --- /dev/null +++ b/src/main/java/kr/where/backend/oauthtoken/OAuthToken.java @@ -0,0 +1,74 @@ +package kr.where.backend.oauthtoken; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.TimeZone; +import kr.where.backend.api.json.OAuthTokenDto; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Entity +@Getter +@Slf4j +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OAuthToken { + private static final int TOKEN_EXPIRATION_MINUTES = 60; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "token_id", unique = true, nullable = false) + private Long id; + + @Column(unique = true) + private String name; + + private String accessToken; + + private String refreshToken; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + public OAuthToken(final String name, final OAuthTokenDto oAuthTokenDto) { + this.name = name; + this.accessToken = oAuthTokenDto.getAccess_token(); + this.refreshToken = oAuthTokenDto.getRefresh_token(); + + final LocalDateTime createdAt = + LocalDateTime.ofInstant( + Instant.ofEpochSecond(oAuthTokenDto.getCreated_at()), + TimeZone.getDefault().toZoneId()); + this.createdAt = createdAt; + this.updatedAt = LocalDateTime.now(); + } + + public void updateToken(final OAuthTokenDto oAuthTokenDto) { + this.accessToken = oAuthTokenDto.getAccess_token(); + this.refreshToken = oAuthTokenDto.getRefresh_token(); + + final LocalDateTime createdAt = + LocalDateTime.ofInstant( + Instant.ofEpochSecond(oAuthTokenDto.getCreated_at()), + TimeZone.getDefault().toZoneId()); + this.createdAt = createdAt; + this.updatedAt = LocalDateTime.now(); + } + + public boolean isTimeOver() { + final LocalDateTime currentTime = LocalDateTime.now(TimeZone.getDefault().toZoneId()); + final Duration duration = Duration.between(currentTime, createdAt); + final Long minute = Math.abs(duration.toMinutes()); + log.info("[oAuthToken] : {} Token 이 발급된지 {}분 지났습니다.", name, minute); + + return minute > TOKEN_EXPIRATION_MINUTES; + } +} diff --git a/src/main/java/kr/where/backend/oauthtoken/OAuthTokenController.java b/src/main/java/kr/where/backend/oauthtoken/OAuthTokenController.java new file mode 100644 index 0000000..8666674 --- /dev/null +++ b/src/main/java/kr/where/backend/oauthtoken/OAuthTokenController.java @@ -0,0 +1,103 @@ +package kr.where.backend.oauthtoken; + +import io.swagger.v3.oas.annotations.Hidden; +import kr.where.backend.api.TokenApiService; +import kr.where.backend.api.json.OAuthTokenDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequestMapping("/v3/token") +@RequiredArgsConstructor +@Hidden +public class OAuthTokenController { + + private final OAuthTokenService oauthTokenService; + private final TokenApiService tokenApiService; + + /** + * OAuth 인증 후 해당 uri 로 code 반환됨 + */ + @GetMapping("") + public void createAccessToken(@RequestParam("code") String code) { + log.info("code : {}", code); + } + + /** + * code로 OAuth token 받아와서 생성 & 업데이트 + * @param name : 생성할 token 이름 + * @param code : log에 찍힌 code + */ + @PostMapping("") + public ResponseEntity createToken(@RequestParam("name") String name, @RequestParam("code") String code) { + final OAuthTokenDto oAuthToken = tokenApiService.getOAuthToken(code); + oauthTokenService.createToken(name, oAuthToken); + return new ResponseEntity(HttpStatus.CREATED); + } + + + /** + * api test + * OAuthToken 넣고 모든 api 호출 성공하면 삭제 + */ +// @GetMapping("/user") +// public CadetPrivacy getUserInfo() { +// final String accessToken = oauthTokenService.findAccessToken("test"); +// final CadetPrivacy cadetPrivacy = intraApiService.getCadetPrivacy(accessToken, "jonhan"); +// +// return cadetPrivacy; +// } +// +// @GetMapping("/image") +// public List get42Image() { +// final String accessToken = oauthTokenService.findAccessToken("test"); +// final List list = intraApiService.getCadetsImage(accessToken, 1); +// return list; +// } +// +// @GetMapping("/info") +// public List get42ClusterInfo() { +// final String accessToken = oauthTokenService.findAccessToken("test"); +// final List list = intraApiService.getCadetsInCluster(accessToken, 1); +// return list; +// } +// +// private static final String ADMIN_TOKEN = "admin"; +// +// @GetMapping("/logout") +// public List get42LocationEnd() { +// final String accessToken = oauthTokenService.findAccessToken(ADMIN_TOKEN); +// final List list = intraApiService.getLogoutCadetsLocation(accessToken, 1); +// return list; +// } +// +// @GetMapping("/login") +// public List get42LocationBegin() { +// final String accessToken = oauthTokenService.findAccessToken(ADMIN_TOKEN); +// final List list = intraApiService.getLoginCadetsLocation(accessToken, 1); +// return list; +// } +// +// @PostMapping("/hane") +// public ResponseEntity haneUpdate() { +// updateService.updateInCluster(); +// +// return ResponseEntity.ok("update complete"); +// } +// +// @GetMapping("/range/info") +// public List get42UsersInfoInRange() { +// final String accessToken = oauthTokenService.findAccessToken("test"); +// final List list = intraApiService.getCadetsInRange(accessToken, "jon", 1); +// +// return list; +// } +} diff --git a/src/main/java/kr/where/backend/oauthtoken/OAuthTokenRepository.java b/src/main/java/kr/where/backend/oauthtoken/OAuthTokenRepository.java new file mode 100644 index 0000000..e10679b --- /dev/null +++ b/src/main/java/kr/where/backend/oauthtoken/OAuthTokenRepository.java @@ -0,0 +1,10 @@ +package kr.where.backend.oauthtoken; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface OAuthTokenRepository extends JpaRepository { + Optional findByName(String name); +} diff --git a/src/main/java/kr/where/backend/oauthtoken/OAuthTokenService.java b/src/main/java/kr/where/backend/oauthtoken/OAuthTokenService.java new file mode 100644 index 0000000..be81b9f --- /dev/null +++ b/src/main/java/kr/where/backend/oauthtoken/OAuthTokenService.java @@ -0,0 +1,56 @@ +package kr.where.backend.oauthtoken; + +import kr.where.backend.api.TokenApiService; +import kr.where.backend.api.json.OAuthTokenDto; +import kr.where.backend.oauthtoken.exception.OAuthTokenException.InvalidTokenNameException; +import kr.where.backend.oauthtoken.exception.OAuthTokenException.InvalidOAuthTokenException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class OAuthTokenService { + private static final String EXCEPTION_TOKEN = "hane"; + private final OAuthTokenRepository oauthTokenRepository; + private final TokenApiService tokenApiService; + + @Transactional + public void createToken(final String name, final OAuthTokenDto oAuthTokenDto) { + validateName(name); + + OAuthToken oauthToken = oauthTokenRepository.findByName(name) + .map(existingToken -> { + existingToken.updateToken(oAuthTokenDto); + return existingToken; + }) + .orElseGet(() -> new OAuthToken(name, oAuthTokenDto)); + + oauthTokenRepository.save(oauthToken); + log.info("[oAuthToken] : {} Token 이 생성되었습니다.", name); + } + + private void validateName(final String name) { + if (name == null || name.isEmpty()) { + throw new InvalidTokenNameException(); + } + } + + @Transactional + public String findAccessToken(final String name) { + final OAuthToken oauthToken = oauthTokenRepository.findByName(name).orElseThrow(InvalidOAuthTokenException::new); + if (!name.equals(EXCEPTION_TOKEN) && oauthToken.isTimeOver()) { + updateToken(oauthToken); + } + return oauthToken.getAccessToken(); + } + + @Transactional + public void updateToken(final OAuthToken oauthToken) { + final OAuthTokenDto oAuthTokenDto = tokenApiService.getOAuthTokenWithRefreshToken(oauthToken.getRefreshToken()); + oauthToken.updateToken(oAuthTokenDto); + log.info("[oAuthToken] : {} Token 이 업데이트 되었습니다.", oauthToken.getName()); + } +} diff --git a/src/main/java/kr/where/backend/oauthtoken/exception/OAuthTokenErrorCode.java b/src/main/java/kr/where/backend/oauthtoken/exception/OAuthTokenErrorCode.java new file mode 100644 index 0000000..af74a95 --- /dev/null +++ b/src/main/java/kr/where/backend/oauthtoken/exception/OAuthTokenErrorCode.java @@ -0,0 +1,16 @@ +package kr.where.backend.oauthtoken.exception; + +import kr.where.backend.exception.ErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public enum OAuthTokenErrorCode implements ErrorCode { + INVALID_OAUTH_TOKEN(1400,"유효한 OAuth 토큰이 없습니다."), + INVALID_OAUTH_TOKEN_NAME(1401, "유요하지 않은 OAuth 토큰 이름입니다."), + DUPLICATED_OAUTH_TOKEN_NAME(1402, "이미 등록된 OAuth 토큰입니다."); + + private final int errorCode; + private final String errorMessage; +} diff --git a/src/main/java/kr/where/backend/oauthtoken/exception/OAuthTokenException.java b/src/main/java/kr/where/backend/oauthtoken/exception/OAuthTokenException.java new file mode 100644 index 0000000..08180a4 --- /dev/null +++ b/src/main/java/kr/where/backend/oauthtoken/exception/OAuthTokenException.java @@ -0,0 +1,27 @@ +package kr.where.backend.oauthtoken.exception; + +import kr.where.backend.exception.CustomException; + +public class OAuthTokenException extends CustomException { + + public OAuthTokenException(final OAuthTokenErrorCode errorCode) { + super(errorCode); + } + + public static class InvalidOAuthTokenException extends OAuthTokenException { + public InvalidOAuthTokenException() { + super(OAuthTokenErrorCode.INVALID_OAUTH_TOKEN); + } + } + + public static class InvalidTokenNameException extends OAuthTokenException { + public InvalidTokenNameException() { + super(OAuthTokenErrorCode.INVALID_OAUTH_TOKEN_NAME); + } + } + public static class DuplicatedTokenNameException extends OAuthTokenException { + public DuplicatedTokenNameException() { + super(OAuthTokenErrorCode.DUPLICATED_OAUTH_TOKEN_NAME); + } + } +} diff --git a/src/main/java/kr/where/backend/search/SearchController.java b/src/main/java/kr/where/backend/search/SearchController.java new file mode 100644 index 0000000..d33e0dc --- /dev/null +++ b/src/main/java/kr/where/backend/search/SearchController.java @@ -0,0 +1,27 @@ +package kr.where.backend.search; + +import kr.where.backend.auth.authUser.AuthUser; +import kr.where.backend.auth.authUser.AuthUserInfo; +import kr.where.backend.search.dto.ResponseSearchDTO; +import kr.where.backend.search.swagger.SearchApiDocs; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/v3/search") +@RequiredArgsConstructor +public class SearchController implements SearchApiDocs { + + private final SearchService searchService; + + @GetMapping("") + public ResponseEntity> search42UserResponse( + @RequestParam("keyWord") final String keyWord, + @AuthUserInfo final AuthUser authUser) { + + return ResponseEntity.ok(searchService.search(keyWord, authUser)); + } +} diff --git a/src/main/java/kr/where/backend/search/SearchService.java b/src/main/java/kr/where/backend/search/SearchService.java new file mode 100644 index 0000000..e13e4a3 --- /dev/null +++ b/src/main/java/kr/where/backend/search/SearchService.java @@ -0,0 +1,99 @@ +package kr.where.backend.search; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; +import kr.where.backend.api.IntraApiService; +import kr.where.backend.api.json.CadetPrivacy; +import kr.where.backend.auth.authUser.AuthUser; +import kr.where.backend.group.GroupRepository; +import kr.where.backend.group.entity.Group; +import kr.where.backend.group.exception.GroupException; +import kr.where.backend.member.Member; +import kr.where.backend.member.MemberService; +import kr.where.backend.member.exception.MemberException; +import kr.where.backend.search.dto.ResponseSearchDTO; +import kr.where.backend.search.exception.SearchException; +import kr.where.backend.oauthtoken.OAuthTokenService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class SearchService { + private static final String PATTERN = "^[0-9a-z-]*$"; + private static final String TOKEN_NAME = "search"; + private static final int MAXIMUM_SIZE = 10; + private static final int MINIMUM_LENGTH = 1; + private static final int MAXIMUM_LENGTH = 10; + private final MemberService memberService; + private final IntraApiService intraApiService; + private final OAuthTokenService oauthTokenService; + private final GroupRepository groupRepository; + + /** + * @param keyWord 찾을 검색 입력값 + * @return response로 변경하여 client 측에 전달 검색하고자 하는 입력값의 결과 10개를 반환 블랙홀에 빠지지 않은 카뎃을 필터로 걸러서 response DTO 생성 + * 입력 받은 값을 trim으로 공백을 없애주고, 대문자 영어가 들어와도 검색 가능하게 toLowerCase 적용 + */ + + public List search(final String keyWord, final AuthUser authUser) { + final String word = validateKeyWord(keyWord.trim().toLowerCase()); + final Member member = memberService.findOne(authUser.getIntraId()) + .orElseThrow(MemberException.NoMemberException::new); + + return responseOfSearch(member, findActiveCadets(word)); + } + + private String validateKeyWord(final String keyWord) { + if (keyWord.isEmpty() || !isContainOnlyEnglishAndDigit(keyWord)) { + throw new SearchException.InvalidContextException(); + } + if (!validateLength(keyWord)) { + throw new SearchException.InvalidLengthException(); + } + return keyWord; + } + + private boolean isContainOnlyEnglishAndDigit(final String keyWord) { + return Pattern.matches(PATTERN, keyWord); + } + + private boolean validateLength(final String keyWord) { + return keyWord.length() > MINIMUM_LENGTH && keyWord.length() < MAXIMUM_LENGTH; + } + + private List findActiveCadets(final String word) { + final List result = new ArrayList<>(); + + int page = 1; + while (true) { + final List searchApiResult = + intraApiService.getCadetsInRange(oauthTokenService.findAccessToken(TOKEN_NAME), word, page); + isActiveCadet(result, searchApiResult); + if (searchApiResult.size() < MAXIMUM_SIZE || result.size() > 14) { + break; + } + page += 1; + } + return result; + } + + private void isActiveCadet(final List result, final List cadetPrivacies) { + cadetPrivacies.stream().filter(CadetPrivacy::isActive).forEach(result::add); + } + + private List responseOfSearch(final Member member, final List cadetPrivacies) { + final Group group = groupRepository + .findById(member.getDefaultGroupId()) + .orElseThrow(GroupException.NoGroupException::new); + + return cadetPrivacies + .stream() + .peek(CadetPrivacy::setSeoulCampus) + .map(search -> memberService.findOne(search.getId()) + .orElseGet(() -> memberService.createDisagreeMember(search))) + .map(search -> new ResponseSearchDTO(group, search)) + .toList(); + } +} diff --git a/src/main/java/kr/where/backend/search/dto/ResponseSearchDTO.java b/src/main/java/kr/where/backend/search/dto/ResponseSearchDTO.java new file mode 100644 index 0000000..3919af5 --- /dev/null +++ b/src/main/java/kr/where/backend/search/dto/ResponseSearchDTO.java @@ -0,0 +1,57 @@ +package kr.where.backend.search.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.swagger.v3.oas.annotations.media.Schema; +import kr.where.backend.group.entity.Group; +import kr.where.backend.member.Member; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@JsonIgnoreProperties(ignoreUnknown = true) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Schema(description = "http response dto") +public class ResponseSearchDTO { + + @Schema(description = "카뎃의 고유 id") + private Integer intraId; + @Schema(description = "카뎃의 이름") + private String intraName; + @Schema(description = "카뎃의 이미지") + private String image; + @Schema(description = "카뎃의 상태메시지") + private String comment; + @Schema(description = "카뎃의 위치") + private String location; + @Schema(description = "카뎃의 incluster 상태") + private boolean inOrOut; + @Schema(description = "검색한 맴버와의 친구 여부") + private boolean isFriend; + @Schema(description = "서비스 동의 여부") + private boolean isAgree; + + /** + * @param searched 검색한 맴버에 대한 client 용 dto로 만듬 기본적인 고유 아이디, 이름, 이미지, 위치를 넣어주고, + * 만약 서비스 이용에 동의한 카뎃이라면, 나머지 목록들 첨부 동의 하지 않았다면, null로 내보내기. + * @param group 검색 맴버 결과가 나와 친구인지 판별하기 위한 entity + */ + @Builder + public ResponseSearchDTO(final Group group, final Member searched) { + this.intraId = searched.getIntraId(); + this.intraName = searched.getIntraName(); + this.image = searched.getImage(); + this.location = searched.getLocation().getLocation(); + this.isAgree = searched.isAgree(); + this.isFriend = false; + + if (this.isAgree) { + this.comment = searched.getComment(); + this.inOrOut = searched.isInCluster(); + this.isFriend = group.isInGroup(searched); + } + } +} \ No newline at end of file diff --git a/src/main/java/kr/where/backend/search/exception/SearchErrorCode.java b/src/main/java/kr/where/backend/search/exception/SearchErrorCode.java new file mode 100644 index 0000000..2062ce5 --- /dev/null +++ b/src/main/java/kr/where/backend/search/exception/SearchErrorCode.java @@ -0,0 +1,15 @@ +package kr.where.backend.search.exception; + +import kr.where.backend.exception.ErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum SearchErrorCode implements ErrorCode { + INVALID_CONTEXT(1300, "유효하지 않은 검색 입력 값입니다."), + INVALID_LENGTH(1301, "2자 이상 입력해주시길 바랍니다."); + + private final int errorCode; + private final String errorMessage; +} diff --git a/src/main/java/kr/where/backend/search/exception/SearchException.java b/src/main/java/kr/where/backend/search/exception/SearchException.java new file mode 100644 index 0000000..92e8dc3 --- /dev/null +++ b/src/main/java/kr/where/backend/search/exception/SearchException.java @@ -0,0 +1,21 @@ +package kr.where.backend.search.exception; + +import kr.where.backend.exception.CustomException; + +public class SearchException extends CustomException { + public SearchException(final SearchErrorCode searchErrorCode) { + super(searchErrorCode); + } + + public static class InvalidContextException extends SearchException { + public InvalidContextException() { + super(SearchErrorCode.INVALID_CONTEXT); + } + } + + public static class InvalidLengthException extends SearchException { + public InvalidLengthException() { + super(SearchErrorCode.INVALID_LENGTH); + } + } +} diff --git a/src/main/java/kr/where/backend/search/swagger/SearchApiDocs.java b/src/main/java/kr/where/backend/search/swagger/SearchApiDocs.java new file mode 100644 index 0000000..728bc88 --- /dev/null +++ b/src/main/java/kr/where/backend/search/swagger/SearchApiDocs.java @@ -0,0 +1,37 @@ +package kr.where.backend.search.swagger; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +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.tags.Tag; +import kr.where.backend.auth.authUser.AuthUser; +import kr.where.backend.auth.authUser.AuthUserInfo; +import kr.where.backend.search.dto.ResponseSearchDTO; +import kr.where.backend.search.exception.SearchException; + +@Tag(name="search", description = "search API group") +public interface SearchApiDocs { + + @Operation(summary = "search API", description = "멤버 검색 api", + parameters = { + @Parameter(name="intraId", description = "검색하려는 맴버 id 값", in= ParameterIn.QUERY), + @Parameter(name="keyWord", description = "검색하려는 입력값", in= ParameterIn.QUERY) + }, + responses = { + @ApiResponse(responseCode = "200", description = "카뎃 검색 성공", content=@Content(schema = @Schema(implementation = ResponseSearchDTO.class))), + @ApiResponse(responseCode = "401", description = "유효하지 않은 입력값 오류", content=@Content(schema = @Schema(implementation = SearchException.class))) + }) + @GetMapping("/") + ResponseEntity> search42UserResponse( + @RequestParam("keyWord") final String keyWord, + @AuthUserInfo final AuthUser authUser); +} \ No newline at end of file diff --git a/src/main/java/kr/where/backend/update/UpdateController.java b/src/main/java/kr/where/backend/update/UpdateController.java new file mode 100644 index 0000000..99089f6 --- /dev/null +++ b/src/main/java/kr/where/backend/update/UpdateController.java @@ -0,0 +1,25 @@ +package kr.where.backend.update; + +import kr.where.backend.auth.authUser.AuthUser; +import kr.where.backend.update.swagger.UpdateApiDocs; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequiredArgsConstructor +@RequestMapping("/v3/update") +public class UpdateController implements UpdateApiDocs { + + private final UpdateService updateService; + + @PostMapping("") + public ResponseEntity update(final AuthUser authUser) { + updateService.updateMemberLocations(); + + return ResponseEntity.ok("update complete"); + } +} diff --git a/src/main/java/kr/where/backend/update/UpdateService.java b/src/main/java/kr/where/backend/update/UpdateService.java new file mode 100644 index 0000000..2924bc2 --- /dev/null +++ b/src/main/java/kr/where/backend/update/UpdateService.java @@ -0,0 +1,200 @@ +package kr.where.backend.update; + +import java.util.ArrayList; +import java.util.List; +import kr.where.backend.api.HaneApiService; +import kr.where.backend.api.IntraApiService; +import kr.where.backend.api.json.CadetPrivacy; +import kr.where.backend.api.json.Cluster; +import kr.where.backend.api.json.hane.HaneResponseDto; +import kr.where.backend.member.MemberService; +import kr.where.backend.member.exception.MemberException.NoMemberException; +import kr.where.backend.oauthtoken.OAuthTokenService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.retry.annotation.Retryable; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@EnableScheduling +@Slf4j +@Transactional(readOnly = true) +public class UpdateService { + private static final String HANE_TOKEN = "hane"; + private static final String IMAGE_TOKEN = "image"; + private static final String ADMIN_TOKEN = "admin"; + private static final String UPDATE_TOKEN = "update"; + private final OAuthTokenService oauthTokenService; + private final IntraApiService intraApiService; + private final HaneApiService haneApiService; + private final MemberService memberService; + + //TODO + /** + * + * 1. 로그인한 모든 카뎃의 위치 업데이트 서비스 + * 2. 모든 3분마다 카뎃의 로그인 여부에 따른 위치 업데이트 서비스 + * 3. 새로운 기수 들어왔을 때, image 업데이트 서비스 + */ + + /** + * where42 서비스가 1시간 이상 다운 되었을때, 42 서울 로그인한 카뎃에 대한 위치 업데이트 42api를 호출하기 때문에 admin 토큰 호출(api 호출 제한) + */ + @Retryable + @Transactional + public void updateMemberLocations() { + log.info("[scheduling] : 로그인한 맴버의 imacLocation 업데이트를 시작합니다!!"); + final String token = oauthTokenService.findAccessToken(UPDATE_TOKEN); + + final List loginMember = getLoginMember(token); + + updateLocation(loginMember); + log.info("[scheduling] : 로그인한 맴버의 imacLocation 업데이트를 끝냅니다!!"); + } + + private List getLoginMember(final String token) { + int page = 1; + final List result = new ArrayList<>(); + + while (true) { + final List loginMember = intraApiService.getCadetsInCluster(token, page); + result.addAll(loginMember); + if (loginMember.get(99).getEnd_at() != null) { + break; + } + log.info("" + page); + page += 1; + } + + return result; + } + + private void updateLocation(final List cadets) { + final String haneToken = oauthTokenService.findAccessToken(HANE_TOKEN); + + cadets.forEach(cadet -> memberService.findOne(cadet.getUser().getId()) + .ifPresent(member -> { + member.getLocation().setImacLocation(cadet.getUser().getLocation()); + if (member.isAgree()) { + member.setInCluster( + haneApiService + .getHaneInfo(cadet.getUser().getLogin(), haneToken) + ); + } + log.info("[scheduling] : {}의 imacLocation가 변경되었습니다", member.getIntraName()); + })); + } + + /** + * 3분 동안 login, logout status 적용하는 메서드 + * Hane token도 적용 해야함! + */ + @Retryable + @Scheduled(cron = "0 0/3 * 1/1 * ?") + @Transactional + public void updateMemberStatus() { + final String token = oauthTokenService.findAccessToken(ADMIN_TOKEN); + + final List status = getStatus(token); + + updateLocation(status); + } + + + private List getStatus(final String token) { + int page = 1; + + final List statusResult = new ArrayList<>(); + + while(true) { + boolean loginFlag = false; + boolean logoutFlag = false; + + if (!logoutFlag) { + final List logoutStatus = intraApiService.getLogoutCadetsLocation(token, page); + statusResult.addAll(logoutStatus); + + if (logoutStatus.size() < 100) { + logoutFlag = true; + } + } + if (!loginFlag) { + final List loginStatus = intraApiService.getLoginCadetsLocation(token, page); + loginStatus.stream() + .filter(cluster -> cluster.getEnd_at() == null) + .forEach(statusResult::add); + + if (loginStatus.size() < 100) { + loginFlag = true; + } + } + + if (loginFlag && logoutFlag) { + break; + } + + page += 1; + } + + return statusResult; + } + + /** + * 새로운 기수에 대한 image 업데이트 + */ + @Transactional + public void updateMemberImage() { + final String token = oauthTokenService.findAccessToken(IMAGE_TOKEN); + + final List cadets = getCadetsInfo(token); + updateImage(cadets); + } + + private List getCadetsInfo(final String token) { + int page = 1; + + final List cadets = new ArrayList<>(); + while (true) { + List response = intraApiService.getCadetsImage(token, page); + cadets.addAll(response); + + if (response.size() < 100) { + break; + } + page += 1; + } + + return cadets; + } + + private void updateImage(final List cadets) { + cadets.forEach(cadet -> memberService.findOne(cadet.getId()) + .ifPresent(member -> member.setImage(cadet.getImage().getVersions().getSmall()))); + } + + @Transactional + @Scheduled(cron = "0 0 0/1 1/1 * ?") + public void updateInCluster() { + log.info("[hane] : inCluster 업데이트를 시작합니다!"); + final List haneResponse = haneApiService.getHaneListInfo( + memberService + .findAgreeMembers() + .orElseThrow(NoMemberException::new), + oauthTokenService.findAccessToken(HANE_TOKEN)); + + haneResponse.stream() + .filter(response -> response.getInoutState() != null) + .forEach(response -> { + haneApiService.updateMemberInOrOutState( + memberService.findByIntraName(response.getLogin()) + .orElseThrow(NoMemberException::new), + response.getInoutState()); + log.info("[hane] : {}의 inCluster가 변경되었습니다", response.getLogin()); + }); + log.info("[hane] : inCluster 업데이트를 끝냅니다!"); + } +} diff --git a/src/main/java/kr/where/backend/update/swagger/UpdateApiDocs.java b/src/main/java/kr/where/backend/update/swagger/UpdateApiDocs.java new file mode 100644 index 0000000..d904437 --- /dev/null +++ b/src/main/java/kr/where/backend/update/swagger/UpdateApiDocs.java @@ -0,0 +1,27 @@ +package kr.where.backend.update.swagger; + +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.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.where.backend.auth.authUser.AuthUser; +import kr.where.backend.auth.authUser.AuthUserInfo; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; + +@Tag(name = "update", description = "update API") + +public interface UpdateApiDocs { + @Operation(summary = "3.1 updateMember location API", description = "맴버 자리를 업데이트하는 api", + responses = { + @ApiResponse(responseCode = "200", description = "업데이트 성공", + content = @Content(schema = @Schema(implementation = String.class))), + @ApiResponse(responseCode = "404", description = "업데이트 실패", + content = @Content(schema = @Schema(implementation = String.class))) + } + + ) + @PostMapping("") + ResponseEntity update(@AuthUserInfo final AuthUser authUser); +} diff --git a/src/main/resources/templates/join.html b/src/main/resources/templates/join.html new file mode 100644 index 0000000..9e055fc --- /dev/null +++ b/src/main/resources/templates/join.html @@ -0,0 +1,11 @@ + + + + + Join Page + + +

Welcome to the Join Page!

+

This is the content of your Join page.

+ + \ No newline at end of file