feat: 일반 회원가입

This commit is contained in:
2025-12-15 13:27:33 +09:00
commit d43b8b8f9b
31 changed files with 997 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
package com.audio;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}

View File

@@ -0,0 +1,7 @@
package com.audio.common;
public record ApiResponse<T>(T data) {
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(data);
}
}

View File

@@ -0,0 +1,18 @@
package com.audio.common;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
public class GlobalExceptionResponse {
private int code;
private String message;
private String status;
private String instance;
}

View File

@@ -0,0 +1,16 @@
package com.audio.common;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/health-check")
public class HealthCheckController {
@GetMapping
public ResponseEntity<Boolean> healthCheck() {
return ResponseEntity.ok(true);
}
}

View File

@@ -0,0 +1,31 @@
package com.audio.config;
import com.audio.common.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
@Slf4j
@ControllerAdvice
public class GlobalApiResponseAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return returnType.getParameterType().equals(ResponseEntity.class);
}
@Override
public Object beforeBodyWrite(
Object body, MethodParameter returnType,
MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response) {
return ApiResponse.ok(body);
}
}

View File

@@ -0,0 +1,77 @@
package com.audio.config;
import com.audio.common.GlobalExceptionResponse;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.web.ErrorResponseException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler
implements AccessDeniedHandler, AuthenticationEntryPoint {
@Override
protected ResponseEntity<Object> handleErrorResponseException(
ErrorResponseException ex, HttpHeaders headers,
HttpStatusCode status, WebRequest request
) {
HttpStatus httpStatus = HttpStatus.valueOf(ex.getStatusCode().value());
String message = ex.getBody().getDetail() != null
? ex.getBody().getDetail()
: ex.getMessage();
String uri = null;
if (request instanceof ServletWebRequest servletWebRequest) {
HttpServletRequest httpReq = servletWebRequest.getRequest();
String queryString = httpReq.getQueryString();
uri = httpReq.getRequestURI();
if (queryString != null) {
uri += "?" + queryString;
}
}
GlobalExceptionResponse body = GlobalExceptionResponse.builder()
.code(httpStatus.value())
.message(message)
.status(httpStatus.name())
.instance(uri)
.build();
return ResponseEntity.status(httpStatus).body(body);
}
@Override
public void commence(
HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException
) throws IOException, ServletException {
}
@Override
public void handle(
HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException
) throws IOException, ServletException {
}
}

View File

@@ -0,0 +1,53 @@
package com.audio.config;
import jakarta.servlet.http.HttpServletResponse;
import java.util.ArrayList;
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
String[] allAuthorizedUrls = {
"/health-check",
"/api/v1/auth/**"
};
http.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// 허용 API
.requestMatchers(allAuthorizedUrls).permitAll()
// 그 외는 인증 필요
.anyRequest()
.authenticated())
// 이후 ex 핸들러로 변경
.exceptionHandling(ex -> ex.accessDeniedHandler((request, response, accessDeniedException) -> {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"message\":\"Forbidden\"}");
}));
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@@ -0,0 +1,42 @@
package com.audio.core.auth.api.controller;
import com.audio.core.auth.api.dto.req.SignUpRequest;
import com.audio.core.auth.api.dto.res.SignUpResponse;
import com.audio.core.auth.application.RegisterUseCase;
import com.audio.core.auth.application.dto.command.RegisterUseCaseCommand;
import com.audio.core.auth.application.dto.result.RegisterUseCaseResult;
import com.audio.core.auth.domain.service.AccountService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
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")
@RequiredArgsConstructor
public class AuthController {
private final AccountService accountService;
private final RegisterUseCase registerUseCase;
@PostMapping("/register")
public ResponseEntity<SignUpResponse> register(
@RequestBody SignUpRequest request
) {
RegisterUseCaseResult result = registerUseCase.execute(
RegisterUseCaseCommand.builder()
.loginId(request.getLoginId())
.password(request.getPassword())
.build()
);
SignUpResponse response = SignUpResponse.builder()
.accessToken(result.getAccessToken())
.refreshToken(result.getRefreshToken())
.build();
return ResponseEntity.ok(response);
}
}

View File

@@ -0,0 +1,16 @@
package com.audio.core.auth.api.dto.req;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
public class SignUpRequest {
private String loginId;
private String password;
}

View File

@@ -0,0 +1,16 @@
package com.audio.core.auth.api.dto.res;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Builder
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class SignUpResponse {
private String accessToken;
private String refreshToken;
}

View File

@@ -0,0 +1,36 @@
package com.audio.core.auth.application;
import com.audio.core.auth.application.dto.command.RegisterUseCaseCommand;
import com.audio.core.auth.application.dto.result.RegisterUseCaseResult;
import com.audio.core.auth.domain.service.AccountService;
import com.audio.core.exception.auth.ExistLoginIdException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@RequiredArgsConstructor
@Component
@Slf4j
public final class RegisterUseCase {
private final AccountService accountService;
public RegisterUseCaseResult execute(RegisterUseCaseCommand command) {
String loginId = command.getLoginId();
boolean isExistLoginId = accountService.isExistLoginId(loginId);
if (isExistLoginId) {
throw new ExistLoginIdException(loginId);
}
long accountId = accountService.register(loginId, command.getPassword());
return RegisterUseCaseResult.builder()
.accessToken("accessToken")
.refreshToken("refreshToken")
.build();
}
}

View File

@@ -0,0 +1,17 @@
package com.audio.core.auth.application.dto.command;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
public class RegisterUseCaseCommand {
private String loginId;
private String password;
}

View File

@@ -0,0 +1,17 @@
package com.audio.core.auth.application.dto.result;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
public class RegisterUseCaseResult {
private String accessToken;
private String refreshToken;
}

View File

@@ -0,0 +1,41 @@
package com.audio.core.auth.domain.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
import org.springframework.data.annotation.LastModifiedDate;
@Entity
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
@ToString
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String loginId;
private String passwordHash;
private String name;
@Builder.Default
private LocalDateTime createdAt = LocalDateTime.now();
@Builder.Default
@LastModifiedDate
private LocalDateTime updatedAt = LocalDateTime.now();
@Builder.Default
private Boolean deleted = false;
}

View File

@@ -0,0 +1,4 @@
package com.audio.core.auth.domain.repository;
public interface AccountQueryRepository {
}

View File

@@ -0,0 +1,12 @@
package com.audio.core.auth.domain.repository;
import com.audio.core.auth.domain.entity.Account;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface AccountRepository extends JpaRepository<Account, Long> {
Optional<Account> findByLoginId(String loginId);
}

View File

@@ -0,0 +1,44 @@
package com.audio.core.auth.domain.service;
import com.audio.core.auth.domain.entity.Account;
import com.audio.core.auth.domain.repository.AccountRepository;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Slf4j
public class AccountService {
private final AccountRepository accountRepository;
private final PasswordEncoder passwordEncoder;
@Transactional
public long register(String loginId, String password) {
log.info("회원 등록: {}", loginId);
String hashedPassword = passwordEncoder.encode(password);
Account account = Account.builder()
.loginId(loginId)
.name("임시 이름")
.passwordHash(hashedPassword)
.build();
log.info("account: {}", account);
accountRepository.save(account);
return account.getId();
}
public boolean isExistLoginId(String loginId) {
log.info("로그인 아이디 존재 여부 조회");
return accountRepository.findByLoginId(loginId).isPresent();
}
}

View File

@@ -0,0 +1,11 @@
package com.audio.core.auth.infra.repository;
import com.audio.core.auth.domain.repository.AccountQueryRepository;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public class AccountQueryDslRepository implements AccountQueryRepository {
private final JPAQueryFactory queryFactory;
}

View File

@@ -0,0 +1,19 @@
package com.audio.core.exception.auth;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.ErrorResponseException;
public class ExistLoginIdException extends ErrorResponseException {
public ExistLoginIdException(String loginId) {
super(
HttpStatus.CONFLICT,
ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT,
"이미 존재하는 loginId 입니다: " + loginId
),
null
);
}
}

View File

@@ -0,0 +1,3 @@
spring:
application:
name: audio_book

View File

@@ -0,0 +1,21 @@
spring:
application:
name: audio_book
jpa:
hibernate:
ddl-auto: validate
show-sql: false
properties:
hibernate:
format_sql: true
# dialect: org.hibernate.dialect.MySQL8Dialect
liquibase:
change-log: classpath:/db/master.yaml
enabled: true
#logging:
# level:
# org.hibernate.SQL: null
# org.hibernate.type: trace

View File

@@ -0,0 +1,3 @@
databaseChangeLog:
- include:
file: db/sql/account/251203/1_create_account.sql

View File

@@ -0,0 +1,3 @@
databaseChangeLog:
- include:
file: db/changelog/changelog.yaml

View File

@@ -0,0 +1,12 @@
-- liquibase formatted sql
-- changeset corpi:1_create_account
CREATE TABLE account
(
id BIGINT AUTO_INCREMENT PRIMARY KEY,
login_id VARCHAR(255) NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(255) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted TINYINT(1) NOT NULL DEFAULT 0
);

View File

@@ -0,0 +1,13 @@
package com.audio;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class ApplicationTests {
@Test
void contextLoads() {
}
}