This commit is contained in:
lsh9672
2025-06-11 16:31:06 +09:00
commit f0fbb47c51
164 changed files with 8667 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
dependencies {
implementation project(':common')
// SMS Service (Optional)
implementation 'net.nurigo:sdk:4.3.0'
}
@@ -0,0 +1,19 @@
package com.ktds.hi.member;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 회원 관리 서비스 메인 애플리케이션 클래스
* 인증, 회원정보 관리, 취향 관리 기능을 제공
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
@SpringBootApplication(scanBasePackages = {"com.ktds.hi.member", "com.ktds.hi.common"})
public class MemberServiceApplication {
public static void main(String[] args) {
SpringApplication.run(MemberServiceApplication.class, args);
}
}
@@ -0,0 +1,12 @@
package com.ktds.hi.member.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
* JPA 설정 클래스
*/
@Configuration
@EnableJpaRepositories(basePackages = "com.ktds.hi.member.repository")
public class JpaConfig {
}
@@ -0,0 +1,51 @@
package com.ktds.hi.member.config;
import com.ktds.hi.member.service.JwtTokenProvider;
import com.ktds.hi.member.service.AuthService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* JWT 인증 필터 클래스
* 요청 헤더의 JWT 토큰을 검증하고 인증 정보를 설정
*/
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider tokenProvider;
private final AuthService authService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = resolveToken(request);
if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) {
Authentication authentication = tokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
/**
* 요청 헤더에서 JWT 토큰 추출
*/
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
@@ -0,0 +1,66 @@
package com.ktds.hi.member.config;
import com.ktds.hi.member.service.JwtTokenProvider;
import com.ktds.hi.member.service.AuthService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
/**
* Spring Security 설정 클래스
* JWT 기반 인증 및 권한 관리 설정
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
private final AuthService authService;
/**
* 보안 필터 체인 설정
* JWT 인증 방식을 사용하고 세션은 무상태로 관리
*/
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/auth/**", "/api/members/register").permitAll()
.requestMatchers("/swagger-ui/**", "/api-docs/**").permitAll()
.requestMatchers("/actuator/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, authService),
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
/**
* 비밀번호 암호화 빈
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 인증 매니저 빈
*/
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
@@ -0,0 +1,25 @@
package com.ktds.hi.member.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.servers.Server;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Swagger 설정 클래스
* API 문서화를 위한 OpenAPI 설정
*/
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.addServersItem(new Server().url("/"))
.info(new Info()
.title("하이오더 회원 관리 서비스 API")
.description("회원 가입, 로그인, 취향 관리 등 회원 관련 기능을 제공하는 API")
.version("1.0.0"));
}
}
@@ -0,0 +1,99 @@
package com.ktds.hi.member.controller;
import com.ktds.hi.member.dto.*;
import com.ktds.hi.member.service.AuthService;
import com.ktds.hi.common.dto.SuccessResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* 인증 컨트롤러 클래스
* 로그인, 로그아웃, 토큰 갱신 등 인증 관련 API를 제공
*/
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
@Tag(name = "인증 API", description = "로그인, 로그아웃, 토큰 관리 등 인증 관련 API")
public class AuthController {
private final AuthService authService;
/**
* 로그인 API
*/
@PostMapping("/login")
@Operation(summary = "로그인", description = "사용자명과 비밀번호로 로그인을 수행합니다.")
public ResponseEntity<TokenResponse> login(@Valid @RequestBody LoginRequest request) {
TokenResponse response = authService.login(request);
return ResponseEntity.ok(response);
}
/**
* 로그아웃 API
*/
@PostMapping("/logout")
@Operation(summary = "로그아웃", description = "현재 로그인된 사용자를 로그아웃 처리합니다.")
public ResponseEntity<SuccessResponse> logout(@Valid @RequestBody LogoutRequest request) {
authService.logout(request);
return ResponseEntity.ok(SuccessResponse.of("로그아웃이 완료되었습니다"));
}
/**
* 토큰 갱신 API
*/
@PostMapping("/refresh")
@Operation(summary = "토큰 갱신", description = "리프레시 토큰을 사용해 새로운 액세스 토큰을 발급받습니다.")
public ResponseEntity<TokenResponse> refreshToken(@RequestParam String refreshToken) {
TokenResponse response = authService.refreshToken(refreshToken);
return ResponseEntity.ok(response);
}
/**
* 아이디 찾기 API
*/
@PostMapping("/find-username")
@Operation(summary = "아이디 찾기", description = "전화번호를 사용해 아이디를 찾습니다.")
public ResponseEntity<SuccessResponse> findUsername(@Valid @RequestBody FindUserIdRequest request) {
String username = authService.findUserId(request);
return ResponseEntity.ok(SuccessResponse.of("아이디: " + username));
}
/**
* 비밀번호 찾기 API
*/
@PostMapping("/find-password")
@Operation(summary = "비밀번호 찾기", description = "전화번호로 임시 비밀번호를 SMS로 발송합니다.")
public ResponseEntity<SuccessResponse> findPassword(@Valid @RequestBody FindUserIdRequest request) {
authService.findPassword(request);
return ResponseEntity.ok(SuccessResponse.of("임시 비밀번호가 SMS로 발송되었습니다"));
}
/**
* SMS 인증번호 발송 API
*/
@PostMapping("/sms/send")
@Operation(summary = "SMS 인증번호 발송", description = "입력한 전화번호로 인증번호를 발송합니다.")
public ResponseEntity<SuccessResponse> sendSmsVerification(@RequestParam String phone) {
authService.sendSmsVerification(phone);
return ResponseEntity.ok(SuccessResponse.of("인증번호가 발송되었습니다"));
}
/**
* SMS 인증번호 확인 API
*/
@PostMapping("/sms/verify")
@Operation(summary = "SMS 인증번호 확인", description = "입력한 인증번호가 올바른지 확인합니다.")
public ResponseEntity<SuccessResponse> verifySmsCode(@RequestParam String phone, @RequestParam String code) {
boolean isValid = authService.verifySmsCode(phone, code);
if (isValid) {
return ResponseEntity.ok(SuccessResponse.of("인증이 완료되었습니다"));
} else {
return ResponseEntity.badRequest().body(SuccessResponse.of("인증번호가 올바르지 않습니다"));
}
}
}
@@ -0,0 +1,104 @@
package com.ktds.hi.member.controller;
import com.ktds.hi.member.dto.*;
import com.ktds.hi.member.service.MemberService;
import com.ktds.hi.common.dto.SuccessResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
/**
* 회원 컨트롤러 클래스
* 회원 가입, 정보 조회/수정 등 회원 관리 API를 제공
*/
@RestController
@RequestMapping("/api/members")
@RequiredArgsConstructor
@Tag(name = "회원 관리 API", description = "회원 가입, 정보 조회/수정 등 회원 관리 관련 API")
public class MemberController {
private final MemberService memberService;
/**
* 회원 가입 API
*/
@PostMapping("/register")
@Operation(summary = "회원 가입", description = "새로운 회원을 등록합니다.")
public ResponseEntity<SuccessResponse> registerMember(@Valid @RequestBody SignupRequest request) {
Long memberId = memberService.registerMember(request);
return ResponseEntity.ok(SuccessResponse.of("회원 가입이 완료되었습니다. 회원ID: " + memberId));
}
/**
* 마이페이지 정보 조회 API
*/
@GetMapping("/profile")
@Operation(summary = "마이페이지 조회", description = "현재 로그인한 회원의 정보를 조회합니다.")
public ResponseEntity<MyPageResponse> getMyPageInfo(Authentication authentication) {
Long memberId = Long.valueOf(authentication.getName());
MyPageResponse response = memberService.getMyPageInfo(memberId);
return ResponseEntity.ok(response);
}
/**
* 닉네임 변경 API
*/
@PutMapping("/nickname")
@Operation(summary = "닉네임 변경", description = "현재 로그인한 회원의 닉네임을 변경합니다.")
public ResponseEntity<SuccessResponse> updateNickname(Authentication authentication,
@Valid @RequestBody UpdateNicknameRequest request) {
Long memberId = Long.valueOf(authentication.getName());
memberService.updateNickname(memberId, request);
return ResponseEntity.ok(SuccessResponse.of("닉네임이 변경되었습니다"));
}
/**
* 아이디 변경 API
*/
@PutMapping("/username")
@Operation(summary = "아이디 변경", description = "현재 로그인한 회원의 아이디를 변경합니다.")
public ResponseEntity<SuccessResponse> updateUsername(Authentication authentication,
@RequestParam String username) {
Long memberId = Long.valueOf(authentication.getName());
memberService.updateUsername(memberId, username);
return ResponseEntity.ok(SuccessResponse.of("아이디가 변경되었습니다"));
}
/**
* 비밀번호 변경 API
*/
@PutMapping("/password")
@Operation(summary = "비밀번호 변경", description = "현재 로그인한 회원의 비밀번호를 변경합니다.")
public ResponseEntity<SuccessResponse> updatePassword(Authentication authentication,
@RequestParam String password) {
Long memberId = Long.valueOf(authentication.getName());
memberService.updatePassword(memberId, password);
return ResponseEntity.ok(SuccessResponse.of("비밀번호가 변경되었습니다"));
}
/**
* 아이디 중복 확인 API
*/
@GetMapping("/check-username")
@Operation(summary = "아이디 중복 확인", description = "사용 가능한 아이디인지 확인합니다.")
public ResponseEntity<SuccessResponse> checkUsernameAvailability(@RequestParam String username) {
boolean isAvailable = memberService.checkUsernameAvailability(username);
String message = isAvailable ? "사용 가능한 아이디입니다" : "이미 사용 중인 아이디입니다";
return ResponseEntity.ok(SuccessResponse.of(message));
}
/**
* 닉네임 중복 확인 API
*/
@GetMapping("/check-nickname")
@Operation(summary = "닉네임 중복 확인", description = "사용 가능한 닉네임인지 확인합니다.")
public ResponseEntity<SuccessResponse> checkNicknameAvailability(@RequestParam String nickname) {
boolean isAvailable = memberService.checkNicknameAvailability(nickname);
String message = isAvailable ? "사용 가능한 닉네임입니다" : "이미 사용 중인 닉네임입니다";
return ResponseEntity.ok(SuccessResponse.of(message));
}
}
@@ -0,0 +1,62 @@
package com.ktds.hi.member.controller;
import com.ktds.hi.member.dto.PreferenceRequest;
import com.ktds.hi.member.dto.TasteTagResponse;
import com.ktds.hi.member.domain.TagType;
import com.ktds.hi.member.service.PreferenceService;
import com.ktds.hi.common.dto.SuccessResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 취향 관리 컨트롤러 클래스
* 취향 정보 등록/수정 및 태그 관리 API를 제공
*/
@RestController
@RequestMapping("/api/members/preferences")
@RequiredArgsConstructor
@Tag(name = "취향 관리 API", description = "회원 취향 정보 등록/수정 및 태그 관리 관련 API")
public class PreferenceController {
private final PreferenceService preferenceService;
/**
* 취향 정보 등록/수정 API
*/
@PostMapping
@Operation(summary = "취향 정보 등록", description = "회원의 취향 정보를 등록하거나 수정합니다.")
public ResponseEntity<SuccessResponse> savePreference(Authentication authentication,
@Valid @RequestBody PreferenceRequest request) {
Long memberId = Long.valueOf(authentication.getName());
preferenceService.savePreference(memberId, request);
return ResponseEntity.ok(SuccessResponse.of("취향 정보가 저장되었습니다"));
}
/**
* 사용 가능한 취향 태그 목록 조회 API
*/
@GetMapping("/tags")
@Operation(summary = "취향 태그 목록 조회", description = "사용 가능한 모든 취향 태그 목록을 조회합니다.")
public ResponseEntity<List<TasteTagResponse>> getAvailableTags() {
List<TasteTagResponse> tags = preferenceService.getAvailableTags();
return ResponseEntity.ok(tags);
}
/**
* 태그 유형별 태그 목록 조회 API
*/
@GetMapping("/tags/by-type")
@Operation(summary = "유형별 태그 목록 조회", description = "특정 유형의 취향 태그 목록을 조회합니다.")
public ResponseEntity<List<TasteTagResponse>> getTagsByType(@RequestParam TagType tagType) {
List<TasteTagResponse> tags = preferenceService.getTagsByType(tagType);
return ResponseEntity.ok(tags);
}
}
@@ -0,0 +1,76 @@
package com.ktds.hi.member.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 회원 도메인 클래스
* 회원의 기본 정보를 담는 도메인 객체
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Member {
private Long id;
private String username;
private String password;
private String nickname;
private String phone;
private String role;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/**
* 닉네임 변경
*/
public Member updateNickname(String newNickname) {
return Member.builder()
.id(this.id)
.username(this.username)
.password(this.password)
.nickname(newNickname)
.phone(this.phone)
.role(this.role)
.createdAt(this.createdAt)
.updatedAt(LocalDateTime.now())
.build();
}
/**
* 아이디 변경
*/
public Member updateUsername(String newUsername) {
return Member.builder()
.id(this.id)
.username(newUsername)
.password(this.password)
.nickname(this.nickname)
.phone(this.phone)
.role(this.role)
.createdAt(this.createdAt)
.updatedAt(LocalDateTime.now())
.build();
}
/**
* 비밀번호 변경
*/
public Member updatePassword(String newPassword) {
return Member.builder()
.id(this.id)
.username(this.username)
.password(newPassword)
.nickname(this.nickname)
.phone(this.phone)
.role(this.role)
.createdAt(this.createdAt)
.updatedAt(LocalDateTime.now())
.build();
}
}
@@ -0,0 +1,43 @@
package com.ktds.hi.member.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 취향 정보 도메인 클래스
* 회원의 음식 취향 및 건강 정보를 담는 도메인 객체
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Preference {
private Long id;
private Long memberId;
private List<String> tags;
private String healthInfo;
private String spicyLevel;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/**
* 취향 정보 업데이트
*/
public Preference updatePreference(List<String> newTags, String newHealthInfo, String newSpicyLevel) {
return Preference.builder()
.id(this.id)
.memberId(this.memberId)
.tags(newTags)
.healthInfo(newHealthInfo)
.spicyLevel(newSpicyLevel)
.createdAt(this.createdAt)
.updatedAt(LocalDateTime.now())
.build();
}
}
@@ -0,0 +1,23 @@
package com.ktds.hi.member.domain;
/**
* 태그 유형 열거형
* 취향 태그의 카테고리를 정의
*/
public enum TagType {
CUISINE("음식 종류"),
FLAVOR(""),
DIETARY("식이 제한"),
ATMOSPHERE("분위기"),
PRICE("가격대");
private final String description;
TagType(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}
@@ -0,0 +1,23 @@
package com.ktds.hi.member.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 취향 태그 도메인 클래스
* 사용 가능한 취향 태그 정보를 담는 도메인 객체
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TasteTag {
private Long id;
private String tagName;
private TagType tagType;
private String description;
private Boolean isActive;
}
@@ -0,0 +1,23 @@
package com.ktds.hi.member.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 아이디 찾기 요청 DTO
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "아이디 찾기 요청")
public class FindUserIdRequest {
@NotBlank(message = "전화번호는 필수입니다")
@Pattern(regexp = "^010-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다")
@Schema(description = "전화번호", example = "010-1234-5678")
private String phone;
}
@@ -0,0 +1,26 @@
package com.ktds.hi.member.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 로그인 요청 DTO
* 사용자 로그인 시 필요한 정보를 담는 데이터 전송 객체
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "로그인 요청")
public class LoginRequest {
@NotBlank(message = "사용자명은 필수입니다")
@Schema(description = "사용자명", example = "test@example.com")
private String username;
@NotBlank(message = "비밀번호는 필수입니다")
@Schema(description = "비밀번호", example = "password123")
private String password;
}
@@ -0,0 +1,21 @@
package com.ktds.hi.member.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 로그아웃 요청 DTO
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "로그아웃 요청")
public class LogoutRequest {
@NotBlank(message = "리프레시 토큰은 필수입니다")
@Schema(description = "리프레시 토큰")
private String refreshToken;
}
@@ -0,0 +1,38 @@
package com.ktds.hi.member.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 마이페이지 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "마이페이지 정보")
public class MyPageResponse {
@Schema(description = "사용자명")
private String username;
@Schema(description = "닉네임")
private String nickname;
@Schema(description = "전화번호")
private String phone;
@Schema(description = "취향 태그 목록")
private List<String> preferences;
@Schema(description = "건강 정보")
private String healthInfo;
@Schema(description = "매운맛 선호도")
private String spicyLevel;
}
@@ -0,0 +1,29 @@
package com.ktds.hi.member.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 취향 정보 등록 요청 DTO
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "취향 정보 등록 요청")
public class PreferenceRequest {
@NotEmpty(message = "취향 태그는 최소 1개 이상 선택해야 합니다")
@Schema(description = "취향 태그 목록", example = "[\"한식\", \"매운맛\", \"저칼로리\"]")
private List<String> tags;
@Schema(description = "건강 정보", example = "당뇨 있음")
private String healthInfo;
@Schema(description = "매운맛 선호도", example = "보통")
private String spicyLevel;
}
@@ -0,0 +1,37 @@
package com.ktds.hi.member.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 회원가입 요청 DTO
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "회원가입 요청")
public class SignupRequest {
@NotBlank(message = "사용자명은 필수입니다")
@Schema(description = "사용자명", example = "test@example.com")
private String username;
@NotBlank(message = "비밀번호는 필수입니다")
@Size(min = 8, message = "비밀번호는 8자 이상이어야 합니다")
@Schema(description = "비밀번호", example = "password123")
private String password;
@NotBlank(message = "닉네임은 필수입니다")
@Size(min = 2, max = 20, message = "닉네임은 2-20자 사이여야 합니다")
@Schema(description = "닉네임", example = "홍길동")
private String nickname;
@Pattern(regexp = "^010-\\d{4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다")
@Schema(description = "전화번호", example = "010-1234-5678")
private String phone;
}
@@ -0,0 +1,31 @@
package com.ktds.hi.member.dto;
import com.ktds.hi.member.domain.TagType;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 취향 태그 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "취향 태그 정보")
public class TasteTagResponse {
@Schema(description = "태그 ID")
private Long id;
@Schema(description = "태그명")
private String tagName;
@Schema(description = "태그 유형")
private TagType tagType;
@Schema(description = "태그 설명")
private String description;
}
@@ -0,0 +1,31 @@
package com.ktds.hi.member.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 토큰 응답 DTO
* 로그인 성공 시 반환되는 JWT 토큰 정보
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "토큰 응답")
public class TokenResponse {
@Schema(description = "액세스 토큰")
private String accessToken;
@Schema(description = "리프레시 토큰")
private String refreshToken;
@Schema(description = "회원 ID")
private Long memberId;
@Schema(description = "사용자 역할")
private String role;
}
@@ -0,0 +1,23 @@
package com.ktds.hi.member.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 닉네임 변경 요청 DTO
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "닉네임 변경 요청")
public class UpdateNicknameRequest {
@NotBlank(message = "닉네임은 필수입니다")
@Size(min = 2, max = 20, message = "닉네임은 2-20자 사이여야 합니다")
@Schema(description = "새 닉네임", example = "새닉네임")
private String nickname;
}
@@ -0,0 +1,74 @@
package com.ktds.hi.member.repository.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* 회원 엔티티 클래스
* 데이터베이스 회원 테이블과 매핑되는 JPA 엔티티
*/
@Entity
@Table(name = "members")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class MemberEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false, length = 100)
private String username;
@Column(nullable = false)
private String password;
@Column(unique = true, nullable = false, length = 50)
private String nickname;
@Column(length = 20)
private String phone;
@Column(length = 20)
@Builder.Default
private String role = "USER";
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
/**
* 닉네임 변경
*/
public void updateNickname(String newNickname) {
this.nickname = newNickname;
}
/**
* 아이디 변경
*/
public void updateUsername(String newUsername) {
this.username = newUsername;
}
/**
* 비밀번호 변경
*/
public void updatePassword(String newPassword) {
this.password = newPassword;
}
}
@@ -0,0 +1,63 @@
package com.ktds.hi.member.repository.entity;
import com.ktds.hi.member.domain.TagType;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
import java.util.List;
/**
* 취향 정보 엔티티 클래스
* 데이터베이스 preferences 테이블과 매핑되는 JPA 엔티티
*/
@Entity
@Table(name = "preferences")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class PreferenceEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "member_id", nullable = false)
private Long memberId;
@ElementCollection
@CollectionTable(name = "preference_tags",
joinColumns = @JoinColumn(name = "preference_id"))
@Column(name = "tag")
private List<String> tags;
@Column(name = "health_info", length = 500)
private String healthInfo;
@Column(name = "spicy_level", length = 20)
private String spicyLevel;
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
/**
* 취향 정보 업데이트
*/
public void updatePreference(List<String> newTags, String newHealthInfo, String newSpicyLevel) {
this.tags = newTags;
this.healthInfo = newHealthInfo;
this.spicyLevel = newSpicyLevel;
}
}
@@ -0,0 +1,39 @@
package com.ktds.hi.member.repository.entity;
import com.ktds.hi.member.domain.TagType;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 취향 태그 엔티티 클래스
* 데이터베이스 taste_tags 테이블과 매핑되는 JPA 엔티티
*/
@Entity
@Table(name = "taste_tags")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TasteTagEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "tag_name", unique = true, nullable = false, length = 50)
private String tagName;
@Enumerated(EnumType.STRING)
@Column(name = "tag_type", nullable = false)
private TagType tagType;
@Column(length = 200)
private String description;
@Column(name = "is_active")
@Builder.Default
private Boolean isActive = true;
}
@@ -0,0 +1,40 @@
package com.ktds.hi.member.repository.jpa;
import com.ktds.hi.member.repository.entity.MemberEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* 회원 JPA 리포지토리 인터페이스
* 회원 데이터의 CRUD 작업을 담당
*/
@Repository
public interface MemberRepository extends JpaRepository<MemberEntity, Long> {
/**
* 사용자명으로 회원 조회
*/
Optional<MemberEntity> findByUsername(String username);
/**
* 닉네임으로 회원 조회
*/
Optional<MemberEntity> findByNickname(String nickname);
/**
* 전화번호로 회원 조회
*/
Optional<MemberEntity> findByPhone(String phone);
/**
* 사용자명 존재 여부 확인
*/
boolean existsByUsername(String username);
/**
* 닉네임 존재 여부 확인
*/
boolean existsByNickname(String nickname);
}
@@ -0,0 +1,30 @@
package com.ktds.hi.member.repository.jpa;
import com.ktds.hi.member.repository.entity.PreferenceEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* 취향 정보 JPA 리포지토리 인터페이스
* 취향 정보 데이터의 CRUD 작업을 담당
*/
@Repository
public interface PreferenceRepository extends JpaRepository<PreferenceEntity, Long> {
/**
* 회원 ID로 취향 정보 조회
*/
Optional<PreferenceEntity> findByMemberId(Long memberId);
/**
* 회원 ID로 취향 정보 존재 여부 확인
*/
boolean existsByMemberId(Long memberId);
/**
* 회원 ID로 취향 정보 삭제
*/
void deleteByMemberId(Long memberId);
}
@@ -0,0 +1,31 @@
package com.ktds.hi.member.repository.jpa;
import com.ktds.hi.member.domain.TagType;
import com.ktds.hi.member.repository.entity.TasteTagEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* 취향 태그 JPA 리포지토리 인터페이스
* 취향 태그 데이터의 CRUD 작업을 담당
*/
@Repository
public interface TasteTagRepository extends JpaRepository<TasteTagEntity, Long> {
/**
* 활성화된 태그 목록 조회
*/
List<TasteTagEntity> findByIsActiveTrue();
/**
* 태그 유형별 태그 목록 조회
*/
List<TasteTagEntity> findByTagTypeAndIsActiveTrue(TagType tagType);
/**
* 태그명으로 태그 조회
*/
List<TasteTagEntity> findByTagNameIn(List<String> tagNames);
}
@@ -0,0 +1,45 @@
package com.ktds.hi.member.service;
import com.ktds.hi.member.dto.*;
/**
* 인증 서비스 인터페이스
* 로그인, 로그아웃, 토큰 관리 등 인증 관련 기능을 정의
*/
public interface AuthService {
/**
* 로그인 처리
*/
TokenResponse login(LoginRequest request);
/**
* 로그아웃 처리
*/
void logout(LogoutRequest request);
/**
* 토큰 갱신
*/
TokenResponse refreshToken(String refreshToken);
/**
* 아이디 찾기
*/
String findUserId(FindUserIdRequest request);
/**
* 비밀번호 찾기 (SMS 발송)
*/
void findPassword(FindUserIdRequest request);
/**
* SMS 인증번호 발송
*/
void sendSmsVerification(String phone);
/**
* SMS 인증번호 확인
*/
boolean verifySmsCode(String phone, String code);
}
@@ -0,0 +1,170 @@
package com.ktds.hi.member.service;
import com.ktds.hi.member.dto.*;
import com.ktds.hi.member.repository.entity.MemberEntity;
import com.ktds.hi.member.repository.jpa.MemberRepository;
import com.ktds.hi.common.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
/**
* 인증 서비스 구현체
* 로그인, 로그아웃, 토큰 관리 등 인증 관련 기능을 구현
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class AuthServiceImpl implements AuthService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider;
private final SmsService smsService;
private final RedisTemplate<String, String> redisTemplate;
@Override
public TokenResponse login(LoginRequest request) {
// 회원 조회
MemberEntity member = memberRepository.findByUsername(request.getUsername())
.orElseThrow(() -> new BusinessException("존재하지 않는 사용자입니다"));
// 비밀번호 검증
if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) {
throw new BusinessException("비밀번호가 일치하지 않습니다");
}
// JWT 토큰 생성
String accessToken = jwtTokenProvider.generateAccessToken(member.getId(), member.getRole());
String refreshToken = jwtTokenProvider.generateRefreshToken(member.getId());
// 리프레시 토큰 Redis 저장
redisTemplate.opsForValue().set(
"refresh_token:" + member.getId(),
refreshToken,
7, TimeUnit.DAYS
);
return TokenResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.memberId(member.getId())
.role(member.getRole())
.build();
}
@Override
public void logout(LogoutRequest request) {
// 리프레시 토큰에서 사용자 ID 추출
Long memberId = jwtTokenProvider.getMemberIdFromToken(request.getRefreshToken());
// Redis에서 리프레시 토큰 삭제
redisTemplate.delete("refresh_token:" + memberId);
}
@Override
public TokenResponse refreshToken(String refreshToken) {
// 토큰 유효성 검증
if (!jwtTokenProvider.validateToken(refreshToken)) {
throw new BusinessException("유효하지 않은 리프레시 토큰입니다");
}
Long memberId = jwtTokenProvider.getMemberIdFromToken(refreshToken);
// Redis에서 리프레시 토큰 확인
String storedToken = redisTemplate.opsForValue().get("refresh_token:" + memberId);
if (!refreshToken.equals(storedToken)) {
throw new BusinessException("유효하지 않은 리프레시 토큰입니다");
}
// 회원 정보 조회
MemberEntity member = memberRepository.findById(memberId)
.orElseThrow(() -> new BusinessException("존재하지 않는 사용자입니다"));
// 새 토큰 생성
String newAccessToken = jwtTokenProvider.generateAccessToken(member.getId(), member.getRole());
String newRefreshToken = jwtTokenProvider.generateRefreshToken(member.getId());
// 새 리프레시 토큰 Redis 저장
redisTemplate.opsForValue().set(
"refresh_token:" + member.getId(),
newRefreshToken,
7, TimeUnit.DAYS
);
return TokenResponse.builder()
.accessToken(newAccessToken)
.refreshToken(newRefreshToken)
.memberId(member.getId())
.role(member.getRole())
.build();
}
@Override
public String findUserId(FindUserIdRequest request) {
MemberEntity member = memberRepository.findByPhone(request.getPhone())
.orElseThrow(() -> new BusinessException("해당 전화번호로 가입된 계정이 없습니다"));
return member.getUsername();
}
@Override
public void findPassword(FindUserIdRequest request) {
MemberEntity member = memberRepository.findByPhone(request.getPhone())
.orElseThrow(() -> new BusinessException("해당 전화번호로 가입된 계정이 없습니다"));
// 임시 비밀번호 생성 및 SMS 발송
String tempPassword = generateTempPassword();
smsService.sendTempPassword(request.getPhone(), tempPassword);
// 임시 비밀번호로 업데이트
member.updatePassword(passwordEncoder.encode(tempPassword));
memberRepository.save(member);
}
@Override
public void sendSmsVerification(String phone) {
String verificationCode = generateVerificationCode();
// SMS 발송
smsService.sendVerificationCode(phone, verificationCode);
// Redis에 인증코드 저장 (5분 만료)
redisTemplate.opsForValue().set(
"sms_code:" + phone,
verificationCode,
5, TimeUnit.MINUTES
);
}
@Override
public boolean verifySmsCode(String phone, String code) {
String storedCode = redisTemplate.opsForValue().get("sms_code:" + phone);
if (storedCode != null && storedCode.equals(code)) {
// 인증 성공 시 코드 삭제
redisTemplate.delete("sms_code:" + phone);
return true;
}
return false;
}
/**
* 임시 비밀번호 생성
*/
private String generateTempPassword() {
return "temp" + System.currentTimeMillis();
}
/**
* SMS 인증코드 생성
*/
private String generateVerificationCode() {
return String.valueOf((int)(Math.random() * 900000) + 100000);
}
}
@@ -0,0 +1,114 @@
package com.ktds.hi.member.service;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Collections;
import java.util.Date;
/**
* JWT 토큰 프로바이더 클래스
* JWT 토큰 생성, 검증, 파싱 기능을 제공
*/
@Component
@Slf4j
public class JwtTokenProvider {
private final SecretKey secretKey;
private final long accessTokenExpiration;
private final long refreshTokenExpiration;
public JwtTokenProvider(@Value("${jwt.secret}") String secret,
@Value("${jwt.access-token-expiration}") long accessTokenExpiration,
@Value("${jwt.refresh-token-expiration}") long refreshTokenExpiration) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes());
this.accessTokenExpiration = accessTokenExpiration;
this.refreshTokenExpiration = refreshTokenExpiration;
}
/**
* 액세스 토큰 생성
*/
public String generateAccessToken(Long memberId, String role) {
Date now = new Date();
Date expiration = new Date(now.getTime() + accessTokenExpiration);
return Jwts.builder()
.setSubject(memberId.toString())
.claim("role", role)
.setIssuedAt(now)
.setExpiration(expiration)
.signWith(secretKey)
.compact();
}
/**
* 리프레시 토큰 생성
*/
public String generateRefreshToken(Long memberId) {
Date now = new Date();
Date expiration = new Date(now.getTime() + refreshTokenExpiration);
return Jwts.builder()
.setSubject(memberId.toString())
.setIssuedAt(now)
.setExpiration(expiration)
.signWith(secretKey)
.compact();
}
/**
* 토큰에서 인증 정보 추출
*/
public Authentication getAuthentication(String token) {
Claims claims = parseClaims(token);
String memberId = claims.getSubject();
String role = claims.get("role", String.class);
return new UsernamePasswordAuthenticationToken(
memberId,
null,
Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + role))
);
}
/**
* 토큰에서 회원 ID 추출
*/
public Long getMemberIdFromToken(String token) {
Claims claims = parseClaims(token);
return Long.valueOf(claims.getSubject());
}
/**
* 토큰 유효성 검증
*/
public boolean validateToken(String token) {
try {
parseClaims(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
log.warn("Invalid JWT token: {}", e.getMessage());
return false;
}
}
/**
* 토큰 파싱
*/
private Claims parseClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
}
}
@@ -0,0 +1,45 @@
package com.ktds.hi.member.service;
import com.ktds.hi.member.dto.*;
/**
* 회원 서비스 인터페이스
* 회원 가입, 정보 조회/수정 등 회원 관리 기능을 정의
*/
public interface MemberService {
/**
* 회원 가입
*/
Long registerMember(SignupRequest request);
/**
* 마이페이지 정보 조회
*/
MyPageResponse getMyPageInfo(Long memberId);
/**
* 닉네임 변경
*/
void updateNickname(Long memberId, UpdateNicknameRequest request);
/**
* 아이디 변경
*/
void updateUsername(Long memberId, String newUsername);
/**
* 비밀번호 변경
*/
void updatePassword(Long memberId, String newPassword);
/**
* 아이디 중복 확인
*/
boolean checkUsernameAvailability(String username);
/**
* 닉네임 중복 확인
*/
boolean checkNicknameAvailability(String nickname);
}
@@ -0,0 +1,131 @@
package com.ktds.hi.member.service;
import com.ktds.hi.member.dto.*;
import com.ktds.hi.member.repository.entity.MemberEntity;
import com.ktds.hi.member.repository.entity.PreferenceEntity;
import com.ktds.hi.member.repository.jpa.MemberRepository;
import com.ktds.hi.member.repository.jpa.PreferenceRepository;
import com.ktds.hi.common.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collections;
/**
* 회원 서비스 구현체
* 회원 가입, 정보 조회/수정 등 회원 관리 기능을 구현
*/
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class MemberServiceImpl implements MemberService {
private final MemberRepository memberRepository;
private final PreferenceRepository preferenceRepository;
private final PasswordEncoder passwordEncoder;
@Override
public Long registerMember(SignupRequest request) {
// 중복 검사
if (memberRepository.existsByUsername(request.getUsername())) {
throw new BusinessException("이미 사용 중인 사용자명입니다");
}
if (memberRepository.existsByNickname(request.getNickname())) {
throw new BusinessException("이미 사용 중인 닉네임입니다");
}
// 회원 생성
MemberEntity member = MemberEntity.builder()
.username(request.getUsername())
.password(passwordEncoder.encode(request.getPassword()))
.nickname(request.getNickname())
.phone(request.getPhone())
.role("USER")
.build();
MemberEntity savedMember = memberRepository.save(member);
log.info("회원 가입 완료: memberId={}, username={}", savedMember.getId(), savedMember.getUsername());
return savedMember.getId();
}
@Override
@Transactional(readOnly = true)
public MyPageResponse getMyPageInfo(Long memberId) {
MemberEntity member = memberRepository.findById(memberId)
.orElseThrow(() -> new BusinessException("존재하지 않는 회원입니다"));
PreferenceEntity preference = preferenceRepository.findByMemberId(memberId)
.orElse(null);
return MyPageResponse.builder()
.username(member.getUsername())
.nickname(member.getNickname())
.phone(member.getPhone())
.preferences(preference != null ? preference.getTags() : Collections.emptyList())
.healthInfo(preference != null ? preference.getHealthInfo() : null)
.spicyLevel(preference != null ? preference.getSpicyLevel() : null)
.build();
}
@Override
public void updateNickname(Long memberId, UpdateNicknameRequest request) {
MemberEntity member = memberRepository.findById(memberId)
.orElseThrow(() -> new BusinessException("존재하지 않는 회원입니다"));
// 닉네임 중복 검사
if (memberRepository.existsByNickname(request.getNickname())) {
throw new BusinessException("이미 사용 중인 닉네임입니다");
}
member.updateNickname(request.getNickname());
memberRepository.save(member);
log.info("닉네임 변경 완료: memberId={}, newNickname={}", memberId, request.getNickname());
}
@Override
public void updateUsername(Long memberId, String newUsername) {
MemberEntity member = memberRepository.findById(memberId)
.orElseThrow(() -> new BusinessException("존재하지 않는 회원입니다"));
// 아이디 중복 검사
if (memberRepository.existsByUsername(newUsername)) {
throw new BusinessException("이미 사용 중인 사용자명입니다");
}
member.updateUsername(newUsername);
memberRepository.save(member);
log.info("아이디 변경 완료: memberId={}, newUsername={}", memberId, newUsername);
}
@Override
public void updatePassword(Long memberId, String newPassword) {
MemberEntity member = memberRepository.findById(memberId)
.orElseThrow(() -> new BusinessException("존재하지 않는 회원입니다"));
member.updatePassword(passwordEncoder.encode(newPassword));
memberRepository.save(member);
log.info("비밀번호 변경 완료: memberId={}", memberId);
}
@Override
@Transactional(readOnly = true)
public boolean checkUsernameAvailability(String username) {
return !memberRepository.existsByUsername(username);
}
@Override
@Transactional(readOnly = true)
public boolean checkNicknameAvailability(String nickname) {
return !memberRepository.existsByNickname(nickname);
}
}
@@ -0,0 +1,29 @@
package com.ktds.hi.member.service;
import com.ktds.hi.member.dto.PreferenceRequest;
import com.ktds.hi.member.dto.TasteTagResponse;
import com.ktds.hi.member.domain.TagType;
import java.util.List;
/**
* 취향 관리 서비스 인터페이스
* 취향 정보 등록/수정 및 태그 관리 기능을 정의
*/
public interface PreferenceService {
/**
* 취향 정보 등록/수정
*/
void savePreference(Long memberId, PreferenceRequest request);
/**
* 사용 가능한 취향 태그 목록 조회
*/
List<TasteTagResponse> getAvailableTags();
/**
* 태그 유형별 태그 목록 조회
*/
List<TasteTagResponse> getTagsByType(TagType tagType);
}
@@ -0,0 +1,91 @@
package com.ktds.hi.member.service;
import com.ktds.hi.member.dto.PreferenceRequest;
import com.ktds.hi.member.dto.TasteTagResponse;
import com.ktds.hi.member.domain.TagType;
import com.ktds.hi.member.repository.entity.PreferenceEntity;
import com.ktds.hi.member.repository.entity.TasteTagEntity;
import com.ktds.hi.member.repository.jpa.PreferenceRepository;
import com.ktds.hi.member.repository.jpa.TasteTagRepository;
import com.ktds.hi.common.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
/**
* 취향 관리 서비스 구현체
* 취향 정보 등록/수정 및 태그 관리 기능을 구현
*/
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class PreferenceServiceImpl implements PreferenceService {
private final PreferenceRepository preferenceRepository;
private final TasteTagRepository tasteTagRepository;
@Override
public void savePreference(Long memberId, PreferenceRequest request) {
// 태그 유효성 검증
List<TasteTagEntity> existingTags = tasteTagRepository.findByTagNameIn(request.getTags());
if (existingTags.size() != request.getTags().size()) {
throw new BusinessException("유효하지 않은 태그가 포함되어 있습니다");
}
// 기존 취향 정보 조회
PreferenceEntity preference = preferenceRepository.findByMemberId(memberId)
.orElse(null);
if (preference != null) {
// 기존 정보 업데이트
preference.updatePreference(request.getTags(), request.getHealthInfo(), request.getSpicyLevel());
} else {
// 새로운 취향 정보 생성
preference = PreferenceEntity.builder()
.memberId(memberId)
.tags(request.getTags())
.healthInfo(request.getHealthInfo())
.spicyLevel(request.getSpicyLevel())
.build();
}
preferenceRepository.save(preference);
log.info("취향 정보 저장 완료: memberId={}, tags={}", memberId, request.getTags());
}
@Override
@Transactional(readOnly = true)
public List<TasteTagResponse> getAvailableTags() {
List<TasteTagEntity> tags = tasteTagRepository.findByIsActiveTrue();
return tags.stream()
.map(tag -> TasteTagResponse.builder()
.id(tag.getId())
.tagName(tag.getTagName())
.tagType(tag.getTagType())
.description(tag.getDescription())
.build())
.collect(Collectors.toList());
}
@Override
@Transactional(readOnly = true)
public List<TasteTagResponse> getTagsByType(TagType tagType) {
List<TasteTagEntity> tags = tasteTagRepository.findByTagTypeAndIsActiveTrue(tagType);
return tags.stream()
.map(tag -> TasteTagResponse.builder()
.id(tag.getId())
.tagName(tag.getTagName())
.tagType(tag.getTagType())
.description(tag.getDescription())
.build())
.collect(Collectors.toList());
}
}
@@ -0,0 +1,43 @@
package com.ktds.hi.member.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
/**
* SMS 서비스 클래스
* SMS 발송 기능을 담당 (실제 구현은 외부 SMS API 연동 필요)
*/
@Service
@Slf4j
public class SmsService {
@Value("${sms.api-key:}")
private String apiKey;
@Value("${sms.api-secret:}")
private String apiSecret;
@Value("${sms.from-number:}")
private String fromNumber;
/**
* SMS 인증번호 발송
*/
public void sendVerificationCode(String phone, String code) {
String message = String.format("[하이오더] 인증번호는 %s입니다.", code);
// TODO: 실제 SMS API 연동 구현
log.info("SMS 발송: phone={}, message={}", phone, message);
}
/**
* 임시 비밀번호 SMS 발송
*/
public void sendTempPassword(String phone, String tempPassword) {
String message = String.format("[하이오더] 임시 비밀번호는 %s입니다. 로그인 후 비밀번호를 변경해주세요.", tempPassword);
// TODO: 실제 SMS API 연동 구현
log.info("임시 비밀번호 SMS 발송: phone={}, tempPassword={}", phone, tempPassword);
}
}
+55
View File
@@ -0,0 +1,55 @@
server:
port: ${MEMBER_SERVICE_PORT:8081}
spring:
application:
name: member-service
datasource:
url: ${MEMBER_DB_URL:jdbc:postgresql://localhost:5432/hiorder_member}
username: ${MEMBER_DB_USERNAME:hiorder_user}
password: ${MEMBER_DB_PASSWORD:hiorder_pass}
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: ${JPA_DDL_AUTO:validate}
show-sql: ${JPA_SHOW_SQL:false}
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.PostgreSQLDialect
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
timeout: 2000ms
lettuce:
pool:
max-active: 8
max-wait: -1ms
max-idle: 8
min-idle: 0
jwt:
secret: ${JWT_SECRET:hiorder-secret-key-for-jwt-token-generation-must-be-long-enough}
access-token-expiration: ${JWT_ACCESS_EXPIRATION:3600000} # 1시간
refresh-token-expiration: ${JWT_REFRESH_EXPIRATION:604800000} # 7일
sms:
api-key: ${SMS_API_KEY:}
api-secret: ${SMS_API_SECRET:}
from-number: ${SMS_FROM_NUMBER:}
springdoc:
api-docs:
path: /api-docs
swagger-ui:
path: /swagger-ui.html
management:
endpoints:
web:
exposure:
include: health,info,metrics