From 851a85795bd781961789725534a5fc9f0a0e85d3 Mon Sep 17 00:00:00 2001 From: Wonjae Lim Date: Sun, 30 Nov 2025 22:20:48 +0900 Subject: [PATCH 01/12] =?UTF-8?q?fix=20:=20test=20api=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20(/api/v1=20=EB=B6=99=EC=9E=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/health/controller/HealthCheckController.java | 9 ++++----- src/test/java/ita/tinybite/response/ResponseTest.java | 6 +++--- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/main/java/ita/tinybite/global/health/controller/HealthCheckController.java b/src/main/java/ita/tinybite/global/health/controller/HealthCheckController.java index 1442ade..74f7233 100644 --- a/src/main/java/ita/tinybite/global/health/controller/HealthCheckController.java +++ b/src/main/java/ita/tinybite/global/health/controller/HealthCheckController.java @@ -9,25 +9,24 @@ import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/test") public class HealthCheckController { - @GetMapping("/health") + @GetMapping("/test/health") public ResponseEntity health() { return ResponseEntity.ok("UP"); } - @GetMapping("/health-check") + @GetMapping("/api/v1/test/health") public APIResponse test() { return APIResponse.success("test"); } - @GetMapping("/business-error") + @GetMapping("/api/v1/test/error/business") public APIResponse businessError() { throw BusinessException.of(BusinessErrorCode.MEMBER_NOT_FOUND); } - @GetMapping("/common-error") + @GetMapping("/api/v1/test/error/common") public APIResponse commonError() throws Exception { throw new Exception("INTERNAL_SERVER_ERROR"); } diff --git a/src/test/java/ita/tinybite/response/ResponseTest.java b/src/test/java/ita/tinybite/response/ResponseTest.java index 8fb4301..c543c95 100644 --- a/src/test/java/ita/tinybite/response/ResponseTest.java +++ b/src/test/java/ita/tinybite/response/ResponseTest.java @@ -27,7 +27,7 @@ public class ResponseTest { @Test @DisplayName("응답 성공 시, APIResponse.success()의 리턴형식을 준수합니다.") public void success_response() throws Exception { - mockMvc.perform(get("/health")) + mockMvc.perform(get("/api/v1/test/health")) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value(200)) .andExpect(jsonPath("$.code").doesNotExist()) @@ -39,7 +39,7 @@ public void success_response() throws Exception { @Test @DisplayName("Business 에러 발생 시, APIResponse.businessError()의 리턴형식을 준수합니다.") public void business_error_response() throws Exception { - mockMvc.perform(get("/business-error")) + mockMvc.perform(get("/api/v1/test/error/business")) .andExpect(result -> assertNotEquals(200, result.getResponse().getStatus())) .andExpect(jsonPath("$.status").value(BUSINESS_ERRORCODE.getHttpStatus().value())) .andExpect(jsonPath("$.code").value(BUSINESS_ERRORCODE.getCode())) @@ -50,7 +50,7 @@ public void business_error_response() throws Exception { @Test @DisplayName("Common 에러 발생 시, APIResponse.commonError()의 리턴형식을 준수합니다.") public void commonError() throws Exception { - mockMvc.perform(get("/common-error")) + mockMvc.perform(get("/api/v1/test/error/common")) .andExpect(status().is5xxServerError()) .andExpect(jsonPath("$.status").value(500)) .andExpect(jsonPath("$.code").doesNotExist()) From b40bc2ed0c989e5125d2201eb7222c0a349b30ce Mon Sep 17 00:00:00 2001 From: Wonjae Lim Date: Sun, 30 Nov 2025 22:21:31 +0900 Subject: [PATCH 02/12] =?UTF-8?q?feat=20:=20errorcode=20api=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(thymeleaf=EC=82=AC=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 +- .../errorcode/api/ErrorCodeController.java | 23 ++++++++ .../errorcode/api/ErrorCodeInfo.java | 5 ++ .../errorcode/api/ErrorCodeScanner.java | 51 ++++++++++++++++++ src/main/resources/templates/error-code.html | 52 +++++++++++++++++++ 5 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 src/main/java/ita/tinybite/global/exception/errorcode/api/ErrorCodeController.java create mode 100644 src/main/java/ita/tinybite/global/exception/errorcode/api/ErrorCodeInfo.java create mode 100644 src/main/java/ita/tinybite/global/exception/errorcode/api/ErrorCodeScanner.java create mode 100644 src/main/resources/templates/error-code.html diff --git a/build.gradle b/build.gradle index ff04421..7a75d9c 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' // util compileOnly 'org.projectlombok:lombok' @@ -48,9 +49,6 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-impl:0.13.0' implementation 'io.jsonwebtoken:jjwt-jackson:0.13.0' - // aws secrets manager - implementation 'io.awspring.cloud:spring-cloud-aws-starter-secrets-manager:3.1.0' - // test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' diff --git a/src/main/java/ita/tinybite/global/exception/errorcode/api/ErrorCodeController.java b/src/main/java/ita/tinybite/global/exception/errorcode/api/ErrorCodeController.java new file mode 100644 index 0000000..3a320fb --- /dev/null +++ b/src/main/java/ita/tinybite/global/exception/errorcode/api/ErrorCodeController.java @@ -0,0 +1,23 @@ +package ita.tinybite.global.exception.errorcode.api; + +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/api/v1") +public class ErrorCodeController { + + private final ErrorCodeScanner scanner; + + public ErrorCodeController(ErrorCodeScanner scanner) { + this.scanner = scanner; + } + + @GetMapping("/error-code") + public String errorCode(Model model) { + model.addAttribute("codes", scanner.scan()); + return "error-code"; + } +} diff --git a/src/main/java/ita/tinybite/global/exception/errorcode/api/ErrorCodeInfo.java b/src/main/java/ita/tinybite/global/exception/errorcode/api/ErrorCodeInfo.java new file mode 100644 index 0000000..f605a8b --- /dev/null +++ b/src/main/java/ita/tinybite/global/exception/errorcode/api/ErrorCodeInfo.java @@ -0,0 +1,5 @@ +package ita.tinybite.global.exception.errorcode.api; + +import org.springframework.http.HttpStatus; + +public record ErrorCodeInfo(HttpStatus httpStatus, String code, String message) {} diff --git a/src/main/java/ita/tinybite/global/exception/errorcode/api/ErrorCodeScanner.java b/src/main/java/ita/tinybite/global/exception/errorcode/api/ErrorCodeScanner.java new file mode 100644 index 0000000..42f3c98 --- /dev/null +++ b/src/main/java/ita/tinybite/global/exception/errorcode/api/ErrorCodeScanner.java @@ -0,0 +1,51 @@ +package ita.tinybite.global.exception.errorcode.api; + +import ita.tinybite.global.exception.errorcode.ErrorCode; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.core.type.filter.AssignableTypeFilter; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Component +public class ErrorCodeScanner { + + private static final String ERRORCODE_PACKAGE = "ita.tinybite.global.exception.errorcode"; + + public Map> scan() { + ClassPathScanningCandidateComponentProvider scanner = new ClassPathScanningCandidateComponentProvider(false); + + scanner.addIncludeFilter(new AssignableTypeFilter(ErrorCode.class)); + + Map> errorCodes = new LinkedHashMap<>(); + + for (BeanDefinition bd : scanner.findCandidateComponents(ERRORCODE_PACKAGE)) { + try { + Class clazz = Class.forName(bd.getBeanClassName()); + + if (!clazz.isEnum()) continue; + + List errorCodeInfos = new ArrayList<>(); + for (Object value : clazz.getEnumConstants()) { + ErrorCode ec = (ErrorCode) value; + + errorCodeInfos.add(new ErrorCodeInfo( + ec.getHttpStatus(), + ec.getCode(), + ec.getMessage())); + } + + errorCodes.put(bd.getBeanClassName(), errorCodeInfos); + + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + return errorCodes; + } +} diff --git a/src/main/resources/templates/error-code.html b/src/main/resources/templates/error-code.html new file mode 100644 index 0000000..2d9ae5d --- /dev/null +++ b/src/main/resources/templates/error-code.html @@ -0,0 +1,52 @@ + + + + + Error Codes + + + + + +

Error Code Documentation

+ +
+

+ + + + + + + + + + + + + + + + + +
HttpStatusCodeMessage
+
+ + + From a14aa2e04ff243f3b8c3561f360c8b39d5e4c700 Mon Sep 17 00:00:00 2001 From: Wonjae Lim Date: Tue, 2 Dec 2025 23:39:15 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat=20:=20sms=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9C=84=EC=B9=98=EC=84=A4=EC=A0=95=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(redis=20=EC=B6=94=EA=B0=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 + .../tinybite/global/config/RedisConfig.java | 44 ++++++++++++++ .../exception/errorcode/AuthErrorCode.java | 21 +++++++ .../global/location/LocationController.java | 26 ++++++++ .../global/location/LocationService.java | 50 ++++++++++++++++ .../global/location/dto/res/GcResDto.java | 59 +++++++++++++++++++ .../global/sms/AuthCodeGenerator.java | 19 ++++++ .../sms/controller/SmsAuthController.java | 34 +++++++++++ .../global/sms/dto/req/CheckReqDto.java | 3 + .../global/sms/dto/req/SendReqDto.java | 3 + .../global/sms/service/SmsAuthService.java | 57 ++++++++++++++++++ .../global/sms/service/SmsService.java | 11 ++++ src/main/resources/application-local.yaml | 10 ++++ .../{ => global}/response/ResponseTest.java | 2 +- .../global/sms/SmsAuthServiceTest.java | 38 ++++++++++++ 15 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 src/main/java/ita/tinybite/global/config/RedisConfig.java create mode 100644 src/main/java/ita/tinybite/global/exception/errorcode/AuthErrorCode.java create mode 100644 src/main/java/ita/tinybite/global/location/LocationController.java create mode 100644 src/main/java/ita/tinybite/global/location/LocationService.java create mode 100644 src/main/java/ita/tinybite/global/location/dto/res/GcResDto.java create mode 100644 src/main/java/ita/tinybite/global/sms/AuthCodeGenerator.java create mode 100644 src/main/java/ita/tinybite/global/sms/controller/SmsAuthController.java create mode 100644 src/main/java/ita/tinybite/global/sms/dto/req/CheckReqDto.java create mode 100644 src/main/java/ita/tinybite/global/sms/dto/req/SendReqDto.java create mode 100644 src/main/java/ita/tinybite/global/sms/service/SmsAuthService.java create mode 100644 src/main/java/ita/tinybite/global/sms/service/SmsService.java rename src/test/java/ita/tinybite/{ => global}/response/ResponseTest.java (98%) create mode 100644 src/test/java/ita/tinybite/global/sms/SmsAuthServiceTest.java diff --git a/build.gradle b/build.gradle index 7a75d9c..1e3db9a 100644 --- a/build.gradle +++ b/build.gradle @@ -38,6 +38,9 @@ dependencies { // database runtimeOnly 'com.mysql:mysql-connector-j' + // redis + implementation('org.springframework.boot:spring-boot-starter-data-redis') + // swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13' diff --git a/src/main/java/ita/tinybite/global/config/RedisConfig.java b/src/main/java/ita/tinybite/global/config/RedisConfig.java new file mode 100644 index 0000000..570ab96 --- /dev/null +++ b/src/main/java/ita/tinybite/global/config/RedisConfig.java @@ -0,0 +1,44 @@ +package ita.tinybite.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration conf = new RedisStandaloneConfiguration(); + conf.setHostName(host); + conf.setPort(port); + return new LettuceConnectionFactory(conf); + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory factory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(factory); + + // 문자열 직렬화 + StringRedisSerializer serializer = new StringRedisSerializer(); + redisTemplate.setKeySerializer(serializer); + redisTemplate.setValueSerializer(serializer); + redisTemplate.setHashKeySerializer(serializer); + redisTemplate.setHashValueSerializer(serializer); + + redisTemplate.afterPropertiesSet(); + return redisTemplate; + } +} diff --git a/src/main/java/ita/tinybite/global/exception/errorcode/AuthErrorCode.java b/src/main/java/ita/tinybite/global/exception/errorcode/AuthErrorCode.java new file mode 100644 index 0000000..cbe6043 --- /dev/null +++ b/src/main/java/ita/tinybite/global/exception/errorcode/AuthErrorCode.java @@ -0,0 +1,21 @@ +package ita.tinybite.global.exception.errorcode; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum AuthErrorCode implements ErrorCode { + + INVALID_PHONE_NUMBER(HttpStatus.BAD_REQUEST, "INVALID_PHONE_NUMBER", "유효하지 않은 번호입니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + AuthErrorCode(HttpStatus httpStatus, String code, String message) { + this.httpStatus = httpStatus; + this.code = code; + this.message = message; + } +} diff --git a/src/main/java/ita/tinybite/global/location/LocationController.java b/src/main/java/ita/tinybite/global/location/LocationController.java new file mode 100644 index 0000000..720d1e8 --- /dev/null +++ b/src/main/java/ita/tinybite/global/location/LocationController.java @@ -0,0 +1,26 @@ +package ita.tinybite.global.location; + +import ita.tinybite.global.response.APIResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import static ita.tinybite.global.response.APIResponse.*; + +@RestController +@RequestMapping("/api/v1/auth/location") +public class LocationController { + + private final LocationService locationService; + + public LocationController(LocationService locationService) { + this.locationService = locationService; + } + + @GetMapping() + public APIResponse location(@RequestParam(defaultValue = "37.3623504988728") String latitude, + @RequestParam(defaultValue = "127.117057453619") String longitude) { + return success(locationService.getLocation(latitude, longitude)); + } +} diff --git a/src/main/java/ita/tinybite/global/location/LocationService.java b/src/main/java/ita/tinybite/global/location/LocationService.java new file mode 100644 index 0000000..ae04389 --- /dev/null +++ b/src/main/java/ita/tinybite/global/location/LocationService.java @@ -0,0 +1,50 @@ +package ita.tinybite.global.location; + +import ita.tinybite.global.exception.BusinessException; +import ita.tinybite.global.exception.errorcode.CommonErrorCode; +import ita.tinybite.global.location.dto.res.GcResDto; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +@Component +public class LocationService { + + @Value("${naver.client-id}") + private String clientId; + + @Value("${naver.secret}") + private String secret; + + private static final String URL = "https://maps.apigw.ntruss.com/map-reversegeocode/v2"; + private static final String URI = "/gc"; + + public String getLocation(String latitude, String longitude) { + String url = UriComponentsBuilder + .fromUriString(URL + URI) + .queryParam("coords", longitude + "," + latitude) + .queryParam("output", "json") + .toUriString(); + + RestTemplate restTemplate = new RestTemplate(); + HttpHeaders headers = new HttpHeaders(); + headers.add("x-ncp-apigw-api-key-id", clientId); + headers.add("x-ncp-apigw-api-key", secret); + + HttpEntity entity = new HttpEntity<>(headers); + + GcResDto res = restTemplate.exchange( + url, + HttpMethod.GET, + entity, + GcResDto.class + ).getBody(); + + if(res == null) throw new BusinessException(CommonErrorCode.INTERNAL_SERVER_ERROR); + return res.getLocation(); + } +} diff --git a/src/main/java/ita/tinybite/global/location/dto/res/GcResDto.java b/src/main/java/ita/tinybite/global/location/dto/res/GcResDto.java new file mode 100644 index 0000000..328a18c --- /dev/null +++ b/src/main/java/ita/tinybite/global/location/dto/res/GcResDto.java @@ -0,0 +1,59 @@ +package ita.tinybite.global.location.dto.res; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Getter; + +import java.util.ArrayList; +import java.util.List; + +@Getter +public class GcResDto { + + + public String getLocation() { + List address = new ArrayList<>(); + + GcRegion region = results.get(0).region; + + if (region.area0 != null) address.add(region.area0.name); + if (region.area1 != null) address.add(region.area1.name); + if (region.area2 != null) address.add(region.area2.name); + if (region.area3 != null) address.add(region.area3.name); + if (region.area4 != null) address.add(region.area4.name); + + return address.get(address.size() - 3) + " " + address.get(address.size() - 2); + } + + private GcStatus status; + private List results; + + @Getter + static class GcStatus { + private int code; + private String name; + private String message; + } + + @Getter + @JsonIgnoreProperties(ignoreUnknown = true) + static class GcResult { + private String name; + private GcRegion region; + } + + @Getter + @JsonIgnoreProperties(ignoreUnknown = true) + static class GcRegion { + private GcArea area0; + private GcArea area1; + private GcArea area2; + private GcArea area3; + private GcArea area4; + } + + @Getter + @JsonIgnoreProperties(ignoreUnknown = true) + static class GcArea { + private String name; + } +} diff --git a/src/main/java/ita/tinybite/global/sms/AuthCodeGenerator.java b/src/main/java/ita/tinybite/global/sms/AuthCodeGenerator.java new file mode 100644 index 0000000..94b9413 --- /dev/null +++ b/src/main/java/ita/tinybite/global/sms/AuthCodeGenerator.java @@ -0,0 +1,19 @@ +package ita.tinybite.global.sms; + +import java.util.concurrent.ThreadLocalRandom; + +public class AuthCodeGenerator { + + private static final AuthCodeGenerator INSTANCE = new AuthCodeGenerator(); + + private AuthCodeGenerator() {} + + public static AuthCodeGenerator getInstance() { + return INSTANCE; + } + + + public String getAuthCode() { + return String.valueOf(ThreadLocalRandom.current().nextInt(100000, 1000000)); + } +} diff --git a/src/main/java/ita/tinybite/global/sms/controller/SmsAuthController.java b/src/main/java/ita/tinybite/global/sms/controller/SmsAuthController.java new file mode 100644 index 0000000..9a2c63b --- /dev/null +++ b/src/main/java/ita/tinybite/global/sms/controller/SmsAuthController.java @@ -0,0 +1,34 @@ +package ita.tinybite.global.sms.controller; + +import ita.tinybite.global.exception.errorcode.TaskErrorCode; +import ita.tinybite.global.response.APIResponse; +import ita.tinybite.global.sms.dto.req.CheckReqDto; +import ita.tinybite.global.sms.dto.req.SendReqDto; +import ita.tinybite.global.sms.service.SmsAuthService; +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 +@RequestMapping("/api/v1/auth/sms") +public class SmsAuthController { + + private final SmsAuthService smsAuthService; + + public SmsAuthController(SmsAuthService smsAuthService) { + this.smsAuthService = smsAuthService; + } + + @PostMapping("/send") + public APIResponse send(@RequestBody SendReqDto req) { + smsAuthService.send(req.phone()); + return APIResponse.success(); + } + + @PostMapping("/check") + public APIResponse check(@RequestBody CheckReqDto req) { + if(smsAuthService.check(req)) return APIResponse.success(); + return APIResponse.businessError(TaskErrorCode.TASK_NOT_FOUND); + } +} diff --git a/src/main/java/ita/tinybite/global/sms/dto/req/CheckReqDto.java b/src/main/java/ita/tinybite/global/sms/dto/req/CheckReqDto.java new file mode 100644 index 0000000..8a12860 --- /dev/null +++ b/src/main/java/ita/tinybite/global/sms/dto/req/CheckReqDto.java @@ -0,0 +1,3 @@ +package ita.tinybite.global.sms.dto.req; + +public record CheckReqDto(String phone, String authCode) {} diff --git a/src/main/java/ita/tinybite/global/sms/dto/req/SendReqDto.java b/src/main/java/ita/tinybite/global/sms/dto/req/SendReqDto.java new file mode 100644 index 0000000..7f7482a --- /dev/null +++ b/src/main/java/ita/tinybite/global/sms/dto/req/SendReqDto.java @@ -0,0 +1,3 @@ +package ita.tinybite.global.sms.dto.req; + +public record SendReqDto (String phone){} diff --git a/src/main/java/ita/tinybite/global/sms/service/SmsAuthService.java b/src/main/java/ita/tinybite/global/sms/service/SmsAuthService.java new file mode 100644 index 0000000..1d91727 --- /dev/null +++ b/src/main/java/ita/tinybite/global/sms/service/SmsAuthService.java @@ -0,0 +1,57 @@ +package ita.tinybite.global.sms.service; + +import ita.tinybite.global.exception.BusinessException; +import ita.tinybite.global.exception.errorcode.AuthErrorCode; +import ita.tinybite.global.sms.AuthCodeGenerator; +import ita.tinybite.global.sms.dto.req.CheckReqDto; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; + +@Service +public class SmsAuthService { + + private static final long EXPIRE_TIME = 60000L; // 1분 + private final SmsService smsService; + private final RedisTemplate redisTemplate; + private final AuthCodeGenerator authCodeGenerator; + + public SmsAuthService(SmsService smsService, RedisTemplate redisTemplate) { + this.smsService = smsService; + this.redisTemplate = redisTemplate; + this.authCodeGenerator = AuthCodeGenerator.getInstance(); + } + + /** + * 1. 인증코드 생성
+ * 2. 주어진 폰번호로 인증코드 전송
+ * 3. DB에 {번호, 인증코드}쌍으로 저장 or 메모리에 저장 (ttl 설정 고려하기)
+ */ + public void send(String phone) { + validatePhoneNumber(phone); + + String smsAuthCode = authCodeGenerator.getAuthCode(); + smsService.send(phone, smsAuthCode); + redisTemplate.opsForValue().set(phone, smsAuthCode, EXPIRE_TIME, TimeUnit.MILLISECONDS); + } + + /** + * req.phone으로 redis 조회
+ * 조회한 authCode와 요청받은 authcode를 비교
+ * 같으면 true, 다르면 false
+ */ + public boolean check(CheckReqDto req) { + validatePhoneNumber(req.phone()); + + String authCode = redisTemplate.opsForValue().get(req.phone()); + if(authCode == null) return false; + return authCode.equals(req.authCode()); + } + + private void validatePhoneNumber(String phone) { + if(!Pattern.matches("010-\\d{4}-\\d{4}", phone)) + throw new BusinessException(AuthErrorCode.INVALID_PHONE_NUMBER); + } +} diff --git a/src/main/java/ita/tinybite/global/sms/service/SmsService.java b/src/main/java/ita/tinybite/global/sms/service/SmsService.java new file mode 100644 index 0000000..15198e9 --- /dev/null +++ b/src/main/java/ita/tinybite/global/sms/service/SmsService.java @@ -0,0 +1,11 @@ +package ita.tinybite.global.sms.service; + +import org.springframework.stereotype.Service; + +@Service +public class SmsService { + + public void send(String phone, String smsAuthCode) { + + } +} diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index ea063f7..48d75db 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -14,3 +14,13 @@ spring: open-in-view: false hibernate: ddl-auto: create-drop + + + data: + redis: + host: localhost + port: 6379 + +naver: + client-id: ${NAVER_CLIENT_ID} + secret: ${NAVER_CLIENT_SECRET} \ No newline at end of file diff --git a/src/test/java/ita/tinybite/response/ResponseTest.java b/src/test/java/ita/tinybite/global/response/ResponseTest.java similarity index 98% rename from src/test/java/ita/tinybite/response/ResponseTest.java rename to src/test/java/ita/tinybite/global/response/ResponseTest.java index c543c95..058d984 100644 --- a/src/test/java/ita/tinybite/response/ResponseTest.java +++ b/src/test/java/ita/tinybite/global/response/ResponseTest.java @@ -1,4 +1,4 @@ -package ita.tinybite.response; +package ita.tinybite.global.response; import static org.junit.jupiter.api.Assertions.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; diff --git a/src/test/java/ita/tinybite/global/sms/SmsAuthServiceTest.java b/src/test/java/ita/tinybite/global/sms/SmsAuthServiceTest.java new file mode 100644 index 0000000..407877a --- /dev/null +++ b/src/test/java/ita/tinybite/global/sms/SmsAuthServiceTest.java @@ -0,0 +1,38 @@ +package ita.tinybite.global.sms; + +import ita.tinybite.global.exception.BusinessException; +import ita.tinybite.global.sms.fake.FakeRedisTemplate; +import ita.tinybite.global.sms.fake.FakeSmsService; +import ita.tinybite.global.sms.service.SmsAuthService; +import org.junit.jupiter.api.Test; + + +import static org.assertj.core.api.Assertions.*; + + +class SmsAuthServiceTest { + + // Fake 객체 + private final FakeSmsService fakeSmsService = new FakeSmsService(); + private final FakeRedisTemplate fakeRedisTemplate = new FakeRedisTemplate(); + + // 테스트 객체 + private final SmsAuthService smsAuthService = new SmsAuthService(fakeSmsService, fakeRedisTemplate); + + private static final String SUCCESS_PHONE_NUMBER = "010-1234-4321"; + private static final String[] FAIL_PHONE_NUMBER = {"010-1234-43211", "asdf", "010-12344-123", "123-1234-1234"}; + + @Test + void should_success_when_smsAuth_send() { + smsAuthService.send(SUCCESS_PHONE_NUMBER); + assertThat(fakeRedisTemplate.opsForValue().get(SUCCESS_PHONE_NUMBER)).isNotNull(); + } + + @Test + void should_fail_when_smsAuth_send_with_invalid_phone() { + for (String phone : FAIL_PHONE_NUMBER) { + assertThatThrownBy(() -> smsAuthService.send(phone)) + .isInstanceOf(BusinessException.class); + } + } +} From 16e1409a3aa86461c1e78b371d0fc97ebed33b5f Mon Sep 17 00:00:00 2001 From: Wonjae Lim Date: Wed, 3 Dec 2025 11:30:00 +0900 Subject: [PATCH 04/12] =?UTF-8?q?unused=20import=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 --- 1 file changed, 3 deletions(-) diff --git a/build.gradle b/build.gradle index babe0da..31e2437 100644 --- a/build.gradle +++ b/build.gradle @@ -55,9 +55,6 @@ dependencies { // OAuth implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' - // aws secrets manager - implementation 'io.awspring.cloud:spring-cloud-aws-starter-secrets-manager:3.1.0' - // test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' From 380561ea6ab0d4c2ad4df1661c8dafd8d6a8588a Mon Sep 17 00:00:00 2001 From: Wonjae Lim Date: Wed, 3 Dec 2025 11:30:06 +0900 Subject: [PATCH 05/12] =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=BF=BC=EB=A6=AC?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-local.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index 6be2710..16c9521 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -10,7 +10,6 @@ spring: password: ${LOCAL_DB_PASSWORD} jpa: - show-sql: true open-in-view: false hibernate: ddl-auto: create-drop From 4bfa55a207703562865c0573b57d0b54e7e25b7a Mon Sep 17 00:00:00 2001 From: Wonjae Lim Date: Wed, 3 Dec 2025 13:37:40 +0900 Subject: [PATCH 06/12] =?UTF-8?q?security=20whitelist=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20(swagger,=20auth=20API=20=EB=93=B1...)=20+=20SecurityProvide?= =?UTF-8?q?r=20=EC=83=9D=EC=84=B1=20(SecurityContextHolder=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=ED=98=84=EC=9E=AC=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=ED=95=9C=20=EC=9C=A0=EC=A0=80=20=EC=97=94=ED=8B=B0=ED=8B=B0?= =?UTF-8?q?=EB=A5=BC=20=EA=B0=80=EC=A0=B8=EC=98=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/service/SecurityProvider.java | 35 +++++++++++++++++++ .../domain/user/constant/InterestField.java | 5 --- .../domain/user/constant/Location.java | 4 --- .../tinybite/domain/user/constant/Target.java | 5 --- .../tinybite/domain/user/constant/Work.java | 5 --- .../global/config/SecurityConfig.java | 10 ++++++ src/main/resources/application-dev.yaml | 4 --- src/main/resources/application.yaml | 13 ++++++- 8 files changed, 57 insertions(+), 24 deletions(-) create mode 100644 src/main/java/ita/tinybite/domain/auth/service/SecurityProvider.java delete mode 100644 src/main/java/ita/tinybite/domain/user/constant/InterestField.java delete mode 100644 src/main/java/ita/tinybite/domain/user/constant/Location.java delete mode 100644 src/main/java/ita/tinybite/domain/user/constant/Target.java delete mode 100644 src/main/java/ita/tinybite/domain/user/constant/Work.java diff --git a/src/main/java/ita/tinybite/domain/auth/service/SecurityProvider.java b/src/main/java/ita/tinybite/domain/auth/service/SecurityProvider.java new file mode 100644 index 0000000..175e6d5 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/auth/service/SecurityProvider.java @@ -0,0 +1,35 @@ +package ita.tinybite.domain.auth.service; + +import ita.tinybite.domain.user.entity.User; +import ita.tinybite.domain.user.repository.UserRepository; +import ita.tinybite.global.exception.BusinessException; +import ita.tinybite.global.exception.UserErrorCode; +import ita.tinybite.global.exception.errorcode.CommonErrorCode; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +@Component +public class SecurityProvider { + + private final UserRepository userRepository; + + public SecurityProvider(UserRepository userRepository) { + this.userRepository = userRepository; + } + + public User getCurrentUser() { + return getUserFromContext(); + } + + private User getUserFromContext() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (!(authentication instanceof UsernamePasswordAuthenticationToken auth)) { + throw BusinessException.of(CommonErrorCode.UNAUTHORIZED); + } + + Long userId = (Long) auth.getPrincipal(); + return userRepository.findById(userId).orElseThrow(() -> BusinessException.of(UserErrorCode.USER_NOT_EXISTS)); + } +} diff --git a/src/main/java/ita/tinybite/domain/user/constant/InterestField.java b/src/main/java/ita/tinybite/domain/user/constant/InterestField.java deleted file mode 100644 index 4f6db1b..0000000 --- a/src/main/java/ita/tinybite/domain/user/constant/InterestField.java +++ /dev/null @@ -1,5 +0,0 @@ -package ita.tinybite.domain.user.constant; - -public enum InterestField { - ECONOMY_MANAGEMENT, IT, HUMAN_EDUCATION_PSYCHOLOGY, DESIGN_ART_MEDIA, NATURE_ENVIRONMENT, NONE -} diff --git a/src/main/java/ita/tinybite/domain/user/constant/Location.java b/src/main/java/ita/tinybite/domain/user/constant/Location.java deleted file mode 100644 index bacac66..0000000 --- a/src/main/java/ita/tinybite/domain/user/constant/Location.java +++ /dev/null @@ -1,4 +0,0 @@ -package ita.tinybite.domain.user.constant; - -public enum Location { -} diff --git a/src/main/java/ita/tinybite/domain/user/constant/Target.java b/src/main/java/ita/tinybite/domain/user/constant/Target.java deleted file mode 100644 index 7d2d285..0000000 --- a/src/main/java/ita/tinybite/domain/user/constant/Target.java +++ /dev/null @@ -1,5 +0,0 @@ -package ita.tinybite.domain.user.constant; - -public enum Target { - WORK_CAREER, MAJOR_CAREER_EXPLORATION, STARTUP, UNDECIDED -} diff --git a/src/main/java/ita/tinybite/domain/user/constant/Work.java b/src/main/java/ita/tinybite/domain/user/constant/Work.java deleted file mode 100644 index 19a3a65..0000000 --- a/src/main/java/ita/tinybite/domain/user/constant/Work.java +++ /dev/null @@ -1,5 +0,0 @@ -package ita.tinybite.domain.user.constant; - -public enum Work { - MIDDLE_TO_HIGH_STUDENT, GRAGUATE_STUDENT, OFFICE_WORKER, OTHER -} diff --git a/src/main/java/ita/tinybite/global/config/SecurityConfig.java b/src/main/java/ita/tinybite/global/config/SecurityConfig.java index 0ea0df2..cbfdd36 100644 --- a/src/main/java/ita/tinybite/global/config/SecurityConfig.java +++ b/src/main/java/ita/tinybite/global/config/SecurityConfig.java @@ -17,6 +17,15 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; + private static final String[] WHITELIST = { + "/api/v1/auth/**", + "/oauth2/callback/kakao", + "/v3/api-docs/**", + "/swagger-ui/**", + "/api/v1/test/**", + "/test/health", + "/api/v1/error-code" + }; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @@ -31,6 +40,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 요청 인증 설정 (최신 문법) .authorizeHttpRequests(auth -> auth + .requestMatchers(WHITELIST).permitAll() .requestMatchers("/api/v1/auth/**").permitAll() .requestMatchers("/oauth2/callback/kakao").permitAll() .anyRequest().authenticated() diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml index e4584f1..e57eb3c 100644 --- a/src/main/resources/application-dev.yaml +++ b/src/main/resources/application-dev.yaml @@ -14,10 +14,6 @@ spring: hibernate: ddl-auto: update # 개발: update, 운영: validate 또는 none -jwt: - secret: ${JWT_SECRET} - access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY} - refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY} kakao: client-id: ${KAKAO_CLIENT_ID} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 163592d..860b3b9 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -6,4 +6,15 @@ spring: group: local: "local" dev: "dev" - test: "test" \ No newline at end of file + test: "test" + + + +jwt: + secret: ${JWT_SECRET} + access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY} + refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY} + +sms: + api-key: ${SMS_API_KEY} + api-secret: ${SMS_API_SECRET} \ No newline at end of file From 2de77d5e1aead5e315179af1c0d239225055919a Mon Sep 17 00:00:00 2001 From: Wonjae Lim Date: Wed, 3 Dec 2025 13:40:57 +0900 Subject: [PATCH 07/12] =?UTF-8?q?feat=20:=20=EB=AC=B8=EC=9E=90=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 ++ .../domain/auth/service/SecurityProvider.java | 2 +- .../exception/errorcode/AuthErrorCode.java | 1 + .../sms/controller/SmsAuthController.java | 3 +- .../global/sms/service/SmsAuthService.java | 2 +- .../global/sms/service/SmsService.java | 39 +++++++++++++++++++ 6 files changed, 47 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index 31e2437..7c91ca6 100644 --- a/build.gradle +++ b/build.gradle @@ -60,6 +60,9 @@ dependencies { testImplementation 'org.springframework.security:spring-security-test' testImplementation 'com.h2database:h2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // sms + implementation 'com.solapi:sdk:1.0.3' } tasks.named('test') { diff --git a/src/main/java/ita/tinybite/domain/auth/service/SecurityProvider.java b/src/main/java/ita/tinybite/domain/auth/service/SecurityProvider.java index 175e6d5..841811e 100644 --- a/src/main/java/ita/tinybite/domain/auth/service/SecurityProvider.java +++ b/src/main/java/ita/tinybite/domain/auth/service/SecurityProvider.java @@ -3,7 +3,7 @@ import ita.tinybite.domain.user.entity.User; import ita.tinybite.domain.user.repository.UserRepository; import ita.tinybite.global.exception.BusinessException; -import ita.tinybite.global.exception.UserErrorCode; +import ita.tinybite.global.exception.errorcode.UserErrorCode; import ita.tinybite.global.exception.errorcode.CommonErrorCode; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; diff --git a/src/main/java/ita/tinybite/global/exception/errorcode/AuthErrorCode.java b/src/main/java/ita/tinybite/global/exception/errorcode/AuthErrorCode.java index cbe6043..5618914 100644 --- a/src/main/java/ita/tinybite/global/exception/errorcode/AuthErrorCode.java +++ b/src/main/java/ita/tinybite/global/exception/errorcode/AuthErrorCode.java @@ -7,6 +7,7 @@ public enum AuthErrorCode implements ErrorCode { INVALID_PHONE_NUMBER(HttpStatus.BAD_REQUEST, "INVALID_PHONE_NUMBER", "유효하지 않은 번호입니다."), + INVALID_AUTHCODE(HttpStatus.UNAUTHORIZED, "INVALID_AUTHCODE", "인증코드가 만료되었거나, 일치하지 않습니다.") ; private final HttpStatus httpStatus; diff --git a/src/main/java/ita/tinybite/global/sms/controller/SmsAuthController.java b/src/main/java/ita/tinybite/global/sms/controller/SmsAuthController.java index 9a2c63b..57d84c9 100644 --- a/src/main/java/ita/tinybite/global/sms/controller/SmsAuthController.java +++ b/src/main/java/ita/tinybite/global/sms/controller/SmsAuthController.java @@ -1,5 +1,6 @@ package ita.tinybite.global.sms.controller; +import ita.tinybite.global.exception.errorcode.AuthErrorCode; import ita.tinybite.global.exception.errorcode.TaskErrorCode; import ita.tinybite.global.response.APIResponse; import ita.tinybite.global.sms.dto.req.CheckReqDto; @@ -29,6 +30,6 @@ public APIResponse send(@RequestBody SendReqDto req) { @PostMapping("/check") public APIResponse check(@RequestBody CheckReqDto req) { if(smsAuthService.check(req)) return APIResponse.success(); - return APIResponse.businessError(TaskErrorCode.TASK_NOT_FOUND); + return APIResponse.businessError(AuthErrorCode.INVALID_AUTHCODE); } } diff --git a/src/main/java/ita/tinybite/global/sms/service/SmsAuthService.java b/src/main/java/ita/tinybite/global/sms/service/SmsAuthService.java index 1d91727..781ecc0 100644 --- a/src/main/java/ita/tinybite/global/sms/service/SmsAuthService.java +++ b/src/main/java/ita/tinybite/global/sms/service/SmsAuthService.java @@ -33,7 +33,7 @@ public void send(String phone) { validatePhoneNumber(phone); String smsAuthCode = authCodeGenerator.getAuthCode(); - smsService.send(phone, smsAuthCode); + smsService.send(phone.replaceAll("-", ""), smsAuthCode); redisTemplate.opsForValue().set(phone, smsAuthCode, EXPIRE_TIME, TimeUnit.MILLISECONDS); } diff --git a/src/main/java/ita/tinybite/global/sms/service/SmsService.java b/src/main/java/ita/tinybite/global/sms/service/SmsService.java index 15198e9..b26a091 100644 --- a/src/main/java/ita/tinybite/global/sms/service/SmsService.java +++ b/src/main/java/ita/tinybite/global/sms/service/SmsService.java @@ -1,11 +1,50 @@ package ita.tinybite.global.sms.service; +import com.solapi.sdk.SolapiClient; +import com.solapi.sdk.message.exception.SolapiEmptyResponseException; +import com.solapi.sdk.message.exception.SolapiMessageNotReceivedException; +import com.solapi.sdk.message.exception.SolapiUnknownException; +import com.solapi.sdk.message.model.Message; +import com.solapi.sdk.message.service.DefaultMessageService; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @Service public class SmsService { + private final String key; + private final String secret; + private static final String FROM = "01076029238"; + + private DefaultMessageService messageService; + + public SmsService(@Value("${sms.api-key}") String key, + @Value("${sms.api-secret}") String secret) { + this.key = key; + this.secret = secret; + } + + @PostConstruct + public void init() { + this.messageService = SolapiClient.INSTANCE.createInstance(key, secret); + } + public void send(String phone, String smsAuthCode) { + Message message = createMessage(phone, smsAuthCode); + + try { + messageService.send(message); + } catch (SolapiMessageNotReceivedException | SolapiEmptyResponseException | SolapiUnknownException e) { + throw new RuntimeException(e); + } + } + private Message createMessage(String phone, String smsAuthCode) { + Message message = new Message(); + message.setFrom(FROM); + message.setTo(phone); + message.setText(smsAuthCode); + return message; } } From 7a80575926a6d6e9cf5494311a263465b114bf28 Mon Sep 17 00:00:00 2001 From: Wonjae Lim Date: Thu, 4 Dec 2025 00:10:18 +0900 Subject: [PATCH 08/12] =?UTF-8?q?redis=20=EC=B6=94=EA=B0=80=20(docker=20co?= =?UTF-8?q?mpose)=20&=20unused=20workflow=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 5 --- .github/workflows/pr_upload_notify.yml | 49 -------------------------- db-compose.yml | 32 ----------------- docker/docker-compose.common.yml | 11 ++++++ 4 files changed, 11 insertions(+), 86 deletions(-) delete mode 100644 .github/workflows/pr_upload_notify.yml delete mode 100644 db-compose.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d11b61d..81657ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,11 +28,6 @@ jobs: java-version: "17" cache: gradle - - name: Copy application.yml into runner - run: | - mkdir -p src/main/resources - echo "${{ secrets.APPLICATION_YML }}" > src/main/resources/application.yml - - name: Build + Test(Gradle) if: ${{ hashFiles('**/build.gradle*') != '' }} run: | diff --git a/.github/workflows/pr_upload_notify.yml b/.github/workflows/pr_upload_notify.yml deleted file mode 100644 index 2cd1d6b..0000000 --- a/.github/workflows/pr_upload_notify.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Notify Slack on Pull Request Upload - -on: - pull_request: - types: [opened] - -jobs: - slack_notification: - runs-on: ubuntu-latest - steps: - - name: Send Slack notification - uses: slackapi/slack-github-action@v1.27.0 - with: - payload: | - { - "text": ":bell: *New Pull Request!*", - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": ":sparkles: *${{ github.actor }}* opened a PR" - } - }, - { - "type": "section", - "fields": [ - { - "type": "mrkdwn", - "text": "*Title:*\n${{ github.event.pull_request.title }}" - }, - { - "type": "mrkdwn", - "text": "*Base Branch:*\n${{ github.base_ref }}" - }, - { - "type": "mrkdwn", - "text": "*From Branch:*\n${{ github.head_ref }}" - }, - { - "type": "mrkdwn", - "text": "*Link:*\n<${{ github.event.pull_request.html_url }}|View PR>" - } - ] - } - ] - } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/db-compose.yml b/db-compose.yml deleted file mode 100644 index 5688bb6..0000000 --- a/db-compose.yml +++ /dev/null @@ -1,32 +0,0 @@ -version: '3.8' - -services: - mysql: - image: mysql:8.3 - container_name: hanipman-mysql - restart: always - environment: - MYSQL_ROOT_PASSWORD: 1234 - MYSQL_DATABASE: hanipman - MYSQL_USER: hanipman - MYSQL_PASSWORD: 1234 - TZ: Asia/Seoul - ports: - - "3306:3306" - volumes: - - mysql_data:/var/lib/mysql - - ./mysql.cnf:/etc/mysql/conf.d/my.cnf - - ./initdb/init.sql:/docker-entrypoint-initdb.d/init.sql:ro - command: - - --character-set-server=utf8mb4 - - --collation-server=utf8mb4_unicode_ci - platform: linux/x86_64 - entrypoint: ["/bin/sh", "-c", "chmod 644 /etc/mysql/conf.d/my.cnf && docker-entrypoint.sh mysqld"] - healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p1234"] - interval: 10s - timeout: 5s - retries: 5 - -volumes: - mysql_data: \ No newline at end of file diff --git a/docker/docker-compose.common.yml b/docker/docker-compose.common.yml index 6bf53d5..6295260 100644 --- a/docker/docker-compose.common.yml +++ b/docker/docker-compose.common.yml @@ -13,6 +13,17 @@ services: networks: - tinybite-network + redis: + container_name: redis + image: redis:7.2.4 + ports: + - "6379:6379" + volumes: + - redis_data:/data + restart: always + networks: + - tinybite-network + certbot: container_name: certbot image: certbot/certbot From 4462cb587f7d4203748160bd3ee63d8762d8dfba Mon Sep 17 00:00:00 2001 From: Wonjae Lim Date: Thu, 4 Dec 2025 00:10:45 +0900 Subject: [PATCH 09/12] =?UTF-8?q?feat=20:=20=EB=8F=99=EB=84=A4=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20API=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/location/LocationController.java | 2 +- .../global/location/dto/res/GcResDto.java | 26 +++++-------------- 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/src/main/java/ita/tinybite/global/location/LocationController.java b/src/main/java/ita/tinybite/global/location/LocationController.java index 720d1e8..02d82bf 100644 --- a/src/main/java/ita/tinybite/global/location/LocationController.java +++ b/src/main/java/ita/tinybite/global/location/LocationController.java @@ -18,7 +18,7 @@ public LocationController(LocationService locationService) { this.locationService = locationService; } - @GetMapping() + @GetMapping public APIResponse location(@RequestParam(defaultValue = "37.3623504988728") String latitude, @RequestParam(defaultValue = "127.117057453619") String longitude) { return success(locationService.getLocation(latitude, longitude)); diff --git a/src/main/java/ita/tinybite/global/location/dto/res/GcResDto.java b/src/main/java/ita/tinybite/global/location/dto/res/GcResDto.java index 328a18c..348f0f3 100644 --- a/src/main/java/ita/tinybite/global/location/dto/res/GcResDto.java +++ b/src/main/java/ita/tinybite/global/location/dto/res/GcResDto.java @@ -7,37 +7,23 @@ import java.util.List; @Getter +@JsonIgnoreProperties(ignoreUnknown = true) public class GcResDto { - public String getLocation() { - List address = new ArrayList<>(); - - GcRegion region = results.get(0).region; + GcRegion region = results.get(1).region; - if (region.area0 != null) address.add(region.area0.name); - if (region.area1 != null) address.add(region.area1.name); - if (region.area2 != null) address.add(region.area2.name); - if (region.area3 != null) address.add(region.area3.name); - if (region.area4 != null) address.add(region.area4.name); - - return address.get(address.size() - 3) + " " + address.get(address.size() - 2); + String[] tokens = region.area2.name.split(" "); + String large = tokens.length == 1 ? region.area2.name : tokens[tokens.length - 1]; + String small = region.area3 != null ? region.area3.name : ""; + return large + " " + small; } - private GcStatus status; private List results; - @Getter - static class GcStatus { - private int code; - private String name; - private String message; - } - @Getter @JsonIgnoreProperties(ignoreUnknown = true) static class GcResult { - private String name; private GcRegion region; } From 5f5ce52ddda2286a0e7eacda18812751cba5f773 Mon Sep 17 00:00:00 2001 From: Wonjae Lim Date: Thu, 4 Dec 2025 00:11:32 +0900 Subject: [PATCH 10/12] =?UTF-8?q?feat=20:=20=EC=9C=A0=EC=A0=80=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(=EC=B6=94=ED=9B=84=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81=20=EC=98=88=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserController.java | 43 +++++++++++++++++ .../domain/user/dto/req/UpdateUserReqDto.java | 6 +++ .../domain/user/dto/res/UserResDto.java | 14 ++++++ .../ita/tinybite/domain/user/entity/User.java | 11 ++++- .../user/repository/UserRepository.java | 7 ++- .../domain/user/service/UserService.java | 46 +++++++++++++++++++ .../exception/errorcode/UserErrorCode.java | 22 +++++++++ 7 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 src/main/java/ita/tinybite/domain/user/controller/UserController.java create mode 100644 src/main/java/ita/tinybite/domain/user/dto/req/UpdateUserReqDto.java create mode 100644 src/main/java/ita/tinybite/domain/user/dto/res/UserResDto.java create mode 100644 src/main/java/ita/tinybite/domain/user/service/UserService.java create mode 100644 src/main/java/ita/tinybite/global/exception/errorcode/UserErrorCode.java diff --git a/src/main/java/ita/tinybite/domain/user/controller/UserController.java b/src/main/java/ita/tinybite/domain/user/controller/UserController.java new file mode 100644 index 0000000..36c1b2f --- /dev/null +++ b/src/main/java/ita/tinybite/domain/user/controller/UserController.java @@ -0,0 +1,43 @@ +package ita.tinybite.domain.user.controller; + +import ita.tinybite.domain.user.dto.req.UpdateUserReqDto; +import ita.tinybite.domain.user.service.UserService; +import ita.tinybite.global.response.APIResponse; +import org.springframework.web.bind.annotation.*; + +import static ita.tinybite.global.response.APIResponse.success; + +@RestController +@RequestMapping("/api/v1/user/me") +public class UserController { + + private final UserService userService; + + public UserController(UserService userService) { + this.userService = userService; + } + + @GetMapping + public APIResponse getUser() { + return success(userService.getUser()); + } + + @PatchMapping + public APIResponse updateUser(@RequestBody UpdateUserReqDto req) { + userService.updateUser(req); + return success(); + } + + @PatchMapping("/location") + public APIResponse updateLocation(@RequestParam(defaultValue = "37.3623504988728") String latitude, + @RequestParam(defaultValue = "127.117057453619") String longitude) { + userService.updateLocation(latitude, longitude); + return success(); + } + + @DeleteMapping + public APIResponse deleteUser() { + userService.deleteUser(); + return success(); + } +} diff --git a/src/main/java/ita/tinybite/domain/user/dto/req/UpdateUserReqDto.java b/src/main/java/ita/tinybite/domain/user/dto/req/UpdateUserReqDto.java new file mode 100644 index 0000000..b996ee0 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/user/dto/req/UpdateUserReqDto.java @@ -0,0 +1,6 @@ +package ita.tinybite.domain.user.dto.req; + +public record UpdateUserReqDto( + String nickname +) { +} diff --git a/src/main/java/ita/tinybite/domain/user/dto/res/UserResDto.java b/src/main/java/ita/tinybite/domain/user/dto/res/UserResDto.java new file mode 100644 index 0000000..7d92af2 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/user/dto/res/UserResDto.java @@ -0,0 +1,14 @@ +package ita.tinybite.domain.user.dto.res; + +import ita.tinybite.domain.user.entity.User; + +public record UserResDto( + Long userId, + String name, + String location +) { + + public static UserResDto of(User user) { + return new UserResDto(user.getUserId(), user.getNickname(), user.getLocation()); + } +} diff --git a/src/main/java/ita/tinybite/domain/user/entity/User.java b/src/main/java/ita/tinybite/domain/user/entity/User.java index 9494890..f817069 100644 --- a/src/main/java/ita/tinybite/domain/user/entity/User.java +++ b/src/main/java/ita/tinybite/domain/user/entity/User.java @@ -2,6 +2,7 @@ import ita.tinybite.domain.user.constant.LoginType; import ita.tinybite.domain.user.constant.UserStatus; +import ita.tinybite.domain.user.dto.req.UpdateUserReqDto; import ita.tinybite.global.entity.BaseEntity; import jakarta.persistence.*; @@ -21,7 +22,7 @@ public class User extends BaseEntity { @Comment("uid") private Long userId; - @Column(nullable = false, length = 50) + @Column(nullable = false, length = 50, unique = true) private String email; @Column(length = 50) @@ -40,4 +41,12 @@ public class User extends BaseEntity { @Column(nullable = false, length = 100) private String location; + + public void update(UpdateUserReqDto req) { + this.nickname = req.nickname(); + } + + public void updateLocation(String location) { + this.location = location; + } } diff --git a/src/main/java/ita/tinybite/domain/user/repository/UserRepository.java b/src/main/java/ita/tinybite/domain/user/repository/UserRepository.java index 6466b27..ed5816e 100644 --- a/src/main/java/ita/tinybite/domain/user/repository/UserRepository.java +++ b/src/main/java/ita/tinybite/domain/user/repository/UserRepository.java @@ -2,11 +2,16 @@ import ita.tinybite.domain.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; import java.util.Optional; +@Repository public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); + boolean existsByNickname(String nickname); -} + User getUserByUserId(Long userId); +} diff --git a/src/main/java/ita/tinybite/domain/user/service/UserService.java b/src/main/java/ita/tinybite/domain/user/service/UserService.java new file mode 100644 index 0000000..5b04ee6 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/user/service/UserService.java @@ -0,0 +1,46 @@ +package ita.tinybite.domain.user.service; + +import ita.tinybite.domain.auth.service.SecurityProvider; +import ita.tinybite.domain.user.dto.req.UpdateUserReqDto; +import ita.tinybite.domain.user.dto.res.UserResDto; +import ita.tinybite.domain.user.entity.User; +import ita.tinybite.domain.user.repository.UserRepository; +import ita.tinybite.global.location.LocationService; +import org.springframework.stereotype.Service; + +@Service +public class UserService { + + private final SecurityProvider securityProvider; + private final UserRepository userRepository; + private final LocationService locationService; + + public UserService(SecurityProvider securityProvider, + UserRepository userRepository, + LocationService locationService) { + this.securityProvider = securityProvider; + this.userRepository = userRepository; + this.locationService = locationService; + } + + public UserResDto getUser() { + User user = securityProvider.getCurrentUser(); + return UserResDto.of(user); + } + + public void updateUser(UpdateUserReqDto req) { + User user = securityProvider.getCurrentUser(); + user.update(req); + } + + public void updateLocation(String latitude, String longitude) { + User user = securityProvider.getCurrentUser(); + String location = locationService.getLocation(latitude, longitude); + user.updateLocation(location); + } + + public void deleteUser() { + userRepository.delete(securityProvider.getCurrentUser()); + } + +} diff --git a/src/main/java/ita/tinybite/global/exception/errorcode/UserErrorCode.java b/src/main/java/ita/tinybite/global/exception/errorcode/UserErrorCode.java new file mode 100644 index 0000000..c13b750 --- /dev/null +++ b/src/main/java/ita/tinybite/global/exception/errorcode/UserErrorCode.java @@ -0,0 +1,22 @@ +package ita.tinybite.global.exception.errorcode; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum UserErrorCode implements ErrorCode { + + USER_NOT_EXISTS(HttpStatus.NOT_FOUND, "USER_NOT_EXISTS", "존재하지 않는 유저입니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + UserErrorCode(HttpStatus httpStatus, String code, String message) { + this.httpStatus = httpStatus; + this.code = code; + this.message = message; + } + +} From 404fb3c1602acf70990b6cd6783119876a238289 Mon Sep 17 00:00:00 2001 From: Wonjae Lim Date: Thu, 4 Dec 2025 00:20:18 +0900 Subject: [PATCH 11/12] =?UTF-8?q?test=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tinybite/TinyBiteApplicationTests.java | 2 + .../global/response/ResponseTest.java | 4 + .../global/sms/fake/FakeRedisTemplate.java | 14 ++ .../global/sms/fake/FakeSmsService.java | 15 ++ .../global/sms/fake/FakeValueOps.java | 174 ++++++++++++++++++ 5 files changed, 209 insertions(+) create mode 100644 src/test/java/ita/tinybite/global/sms/fake/FakeRedisTemplate.java create mode 100644 src/test/java/ita/tinybite/global/sms/fake/FakeSmsService.java create mode 100644 src/test/java/ita/tinybite/global/sms/fake/FakeValueOps.java diff --git a/src/test/java/ita/tinybite/TinyBiteApplicationTests.java b/src/test/java/ita/tinybite/TinyBiteApplicationTests.java index 6b55ed0..4030bf9 100644 --- a/src/test/java/ita/tinybite/TinyBiteApplicationTests.java +++ b/src/test/java/ita/tinybite/TinyBiteApplicationTests.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("test") class TinyBiteApplicationTests { @Test diff --git a/src/test/java/ita/tinybite/global/response/ResponseTest.java b/src/test/java/ita/tinybite/global/response/ResponseTest.java index 058d984..c2a8360 100644 --- a/src/test/java/ita/tinybite/global/response/ResponseTest.java +++ b/src/test/java/ita/tinybite/global/response/ResponseTest.java @@ -5,17 +5,21 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import ita.tinybite.domain.auth.kakao.KakaoApiClient; import ita.tinybite.global.exception.errorcode.BusinessErrorCode; import ita.tinybite.global.exception.errorcode.CommonErrorCode; import ita.tinybite.global.exception.errorcode.ErrorCode; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.mockito.Mock; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; @SpringBootTest +@ActiveProfiles("test") @AutoConfigureMockMvc(addFilters = false) public class ResponseTest { diff --git a/src/test/java/ita/tinybite/global/sms/fake/FakeRedisTemplate.java b/src/test/java/ita/tinybite/global/sms/fake/FakeRedisTemplate.java new file mode 100644 index 0000000..ce783f0 --- /dev/null +++ b/src/test/java/ita/tinybite/global/sms/fake/FakeRedisTemplate.java @@ -0,0 +1,14 @@ +package ita.tinybite.global.sms.fake; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +public class FakeRedisTemplate extends RedisTemplate { + + private final FakeValueOps ops = new FakeValueOps(); + + @Override + public ValueOperations opsForValue() { + return ops; + } +} diff --git a/src/test/java/ita/tinybite/global/sms/fake/FakeSmsService.java b/src/test/java/ita/tinybite/global/sms/fake/FakeSmsService.java new file mode 100644 index 0000000..1f6996b --- /dev/null +++ b/src/test/java/ita/tinybite/global/sms/fake/FakeSmsService.java @@ -0,0 +1,15 @@ +package ita.tinybite.global.sms.fake; + +import ita.tinybite.global.sms.service.SmsService; + +public class FakeSmsService extends SmsService { + + public FakeSmsService() { + super("key", "secret"); + } + + @Override + public void send(String phone, String smsAuthCode) { + + } +} diff --git a/src/test/java/ita/tinybite/global/sms/fake/FakeValueOps.java b/src/test/java/ita/tinybite/global/sms/fake/FakeValueOps.java new file mode 100644 index 0000000..c6b4424 --- /dev/null +++ b/src/test/java/ita/tinybite/global/sms/fake/FakeValueOps.java @@ -0,0 +1,174 @@ +package ita.tinybite.global.sms.fake; + +import org.springframework.data.redis.connection.BitFieldSubCommands; +import org.springframework.data.redis.core.RedisOperations; +import org.springframework.data.redis.core.ValueOperations; + +import java.time.Duration; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +class FakeValueOps implements ValueOperations { + + Map store = new HashMap<>(); + long lastTimeout; + TimeUnit lastTimeUnit; + + @Override + public void set(String key, String value) { + + } + + @Override + public String setGet(String key, String value, long timeout, TimeUnit unit) { + return ""; + } + + @Override + public String setGet(String key, String value, Duration duration) { + return ""; + } + + @Override + public void set(String key, String value, long timeout, TimeUnit unit) { + store.put(key, value); + lastTimeout = timeout; + lastTimeUnit = unit; + } + + // 나머지 메서드는 비워두고 필요할 때만 구현 + + @Override + public Boolean setIfAbsent(String key, String value) { + return null; + } + + @Override + public Boolean setIfAbsent(String key, String value, long timeout, TimeUnit unit) { + return null; + } + + @Override + public Boolean setIfPresent(String key, String value) { + return null; + } + + @Override + public Boolean setIfPresent(String key, String value, long timeout, TimeUnit unit) { + return null; + } + + @Override + public void multiSet(Map map) { + + } + + @Override + public Boolean multiSetIfAbsent(Map map) { + return null; + } + + @Override + public String get(Object key) { + return store.get(key); + } + + @Override + public String getAndDelete(String key) { + return ""; + } + + @Override + public String getAndExpire(String key, long timeout, TimeUnit unit) { + return ""; + } + + @Override + public String getAndExpire(String key, Duration timeout) { + return ""; + } + + @Override + public String getAndPersist(String key) { + return ""; + } + + @Override + public String getAndSet(String key, String value) { + return ""; + } + + @Override + public List multiGet(Collection keys) { + return List.of(); + } + + @Override + public Long increment(String key) { + return 0L; + } + + @Override + public Long increment(String key, long delta) { + return 0L; + } + + @Override + public Double increment(String key, double delta) { + return 0.0; + } + + @Override + public Long decrement(String key) { + return 0L; + } + + @Override + public Long decrement(String key, long delta) { + return 0L; + } + + @Override + public Integer append(String key, String value) { + return 0; + } + + @Override + public String get(String key, long start, long end) { + return ""; + } + + @Override + public void set(String key, String value, long offset) { + + } + + @Override + public Long size(String key) { + return 0L; + } + + @Override + public Boolean setBit(String key, long offset, boolean value) { + return null; + } + + @Override + public Boolean getBit(String key, long offset) { + return null; + } + + @Override + public List bitField(String key, BitFieldSubCommands subCommands) { + return List.of(); + } + + @Override + public RedisOperations getOperations() { + return null; + } + +} From a9924b332fc122aa6d8193f1911576b51f2914bb Mon Sep 17 00:00:00 2001 From: Wonjae Lim Date: Thu, 4 Dec 2025 00:59:09 +0900 Subject: [PATCH 12/12] =?UTF-8?q?fix=20:=20=EA=B2=80=EC=A6=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserController.java | 3 ++- .../global/location/LocationService.java | 21 ++++++++++++------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/main/java/ita/tinybite/domain/user/controller/UserController.java b/src/main/java/ita/tinybite/domain/user/controller/UserController.java index 36c1b2f..5e5ca01 100644 --- a/src/main/java/ita/tinybite/domain/user/controller/UserController.java +++ b/src/main/java/ita/tinybite/domain/user/controller/UserController.java @@ -3,6 +3,7 @@ import ita.tinybite.domain.user.dto.req.UpdateUserReqDto; import ita.tinybite.domain.user.service.UserService; import ita.tinybite.global.response.APIResponse; +import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*; import static ita.tinybite.global.response.APIResponse.success; @@ -23,7 +24,7 @@ public APIResponse getUser() { } @PatchMapping - public APIResponse updateUser(@RequestBody UpdateUserReqDto req) { + public APIResponse updateUser(@Valid @RequestBody UpdateUserReqDto req) { userService.updateUser(req); return success(); } diff --git a/src/main/java/ita/tinybite/global/location/LocationService.java b/src/main/java/ita/tinybite/global/location/LocationService.java index ae04389..90cb6e3 100644 --- a/src/main/java/ita/tinybite/global/location/LocationService.java +++ b/src/main/java/ita/tinybite/global/location/LocationService.java @@ -8,6 +8,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; @@ -20,6 +21,8 @@ public class LocationService { @Value("${naver.secret}") private String secret; + private final RestTemplate restTemplate = new RestTemplate(); + private static final String URL = "https://maps.apigw.ntruss.com/map-reversegeocode/v2"; private static final String URI = "/gc"; @@ -30,19 +33,23 @@ public String getLocation(String latitude, String longitude) { .queryParam("output", "json") .toUriString(); - RestTemplate restTemplate = new RestTemplate(); HttpHeaders headers = new HttpHeaders(); headers.add("x-ncp-apigw-api-key-id", clientId); headers.add("x-ncp-apigw-api-key", secret); HttpEntity entity = new HttpEntity<>(headers); - GcResDto res = restTemplate.exchange( - url, - HttpMethod.GET, - entity, - GcResDto.class - ).getBody(); + GcResDto res; + try { + res = restTemplate.exchange( + url, + HttpMethod.GET, + entity, + GcResDto.class + ).getBody(); + } catch (RestClientException e) { + throw new RuntimeException(e); + } if(res == null) throw new BusinessException(CommonErrorCode.INTERNAL_SERVER_ERROR); return res.getLocation();