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/build.gradle b/build.gradle index 7c5316f..7c91ca6 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' //security implementation 'org.springframework.boot:spring-boot-starter-security' @@ -45,24 +46,23 @@ 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' // 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' testImplementation 'com.h2database:h2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - //lombok - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' + // sms + implementation 'com.solapi:sdk:1.0.3' } tasks.named('test') { 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 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..841811e --- /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.errorcode.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/domain/user/controller/UserController.java b/src/main/java/ita/tinybite/domain/user/controller/UserController.java new file mode 100644 index 0000000..5e5ca01 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/user/controller/UserController.java @@ -0,0 +1,44 @@ +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 jakarta.validation.Valid; +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(@Valid @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/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/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/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..5618914 --- /dev/null +++ b/src/main/java/ita/tinybite/global/exception/errorcode/AuthErrorCode.java @@ -0,0 +1,22 @@ +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", "유효하지 않은 번호입니다."), + INVALID_AUTHCODE(HttpStatus.UNAUTHORIZED, "INVALID_AUTHCODE", "인증코드가 만료되었거나, 일치하지 않습니다.") + ; + + 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/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; + } + +} 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/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/main/java/ita/tinybite/global/location/LocationController.java b/src/main/java/ita/tinybite/global/location/LocationController.java new file mode 100644 index 0000000..02d82bf --- /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..90cb6e3 --- /dev/null +++ b/src/main/java/ita/tinybite/global/location/LocationService.java @@ -0,0 +1,57 @@ +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.RestClientException; +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 final RestTemplate restTemplate = new RestTemplate(); + + 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(); + + 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; + 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(); + } +} 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..348f0f3 --- /dev/null +++ b/src/main/java/ita/tinybite/global/location/dto/res/GcResDto.java @@ -0,0 +1,45 @@ +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 +@JsonIgnoreProperties(ignoreUnknown = true) +public class GcResDto { + + public String getLocation() { + GcRegion region = results.get(1).region; + + 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 List results; + + @Getter + @JsonIgnoreProperties(ignoreUnknown = true) + static class GcResult { + 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..57d84c9 --- /dev/null +++ b/src/main/java/ita/tinybite/global/sms/controller/SmsAuthController.java @@ -0,0 +1,35 @@ +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; +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(AuthErrorCode.INVALID_AUTHCODE); + } +} 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..781ecc0 --- /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.replaceAll("-", ""), 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..b26a091 --- /dev/null +++ b/src/main/java/ita/tinybite/global/sms/service/SmsService.java @@ -0,0 +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; + } +} 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-local.yaml b/src/main/resources/application-local.yaml index 2e0b7b0..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 @@ -20,15 +19,24 @@ spring: show_sql: true dialect: org.hibernate.dialect.MySQLDialect -jwt: - secret: ${JWT_SECRET} - access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY} - refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY} + data: + redis: + host: localhost + port: 6379 kakao: client-id: ${KAKAO_CLIENT_ID} redirect-uri: ${KAKAO_REDIRECT_URI} +naver: + client-id: ${NAVER_CLIENT_ID} + secret: ${NAVER_CLIENT_SECRET} + +jwt: + secret: ${JWT_SECRET} + access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY} + refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY} + logging: level: org.hibernate.SQL: debug \ No newline at end of file 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 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
+
+ + + 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/response/ResponseTest.java b/src/test/java/ita/tinybite/global/response/ResponseTest.java similarity index 88% rename from src/test/java/ita/tinybite/response/ResponseTest.java rename to src/test/java/ita/tinybite/global/response/ResponseTest.java index 8fb4301..c2a8360 100644 --- a/src/test/java/ita/tinybite/response/ResponseTest.java +++ b/src/test/java/ita/tinybite/global/response/ResponseTest.java @@ -1,21 +1,25 @@ -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; 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 { @@ -27,7 +31,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 +43,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 +54,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()) 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); + } + } +} 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; + } + +}