Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/backend-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ on:
branches:
- 'feature/**'

permissions:
contents: read
checks: write
pull-requests: write

jobs:
build-and-test:
runs-on: ubuntu-latest
Expand Down
2 changes: 2 additions & 0 deletions account-service/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ dependencies {
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
//H2 Database
implementation 'com.h2database:h2'
// Validation
implementation 'org.springframework.boot:spring-boot-starter-validation'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.synapse.account_service.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.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 {
http.csrf(csrf -> csrf.disable())
.formLogin(form -> form.disable())
.httpBasic(basic -> basic.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/accounts/signup").permitAll()
.anyRequest().authenticated()
);

return http.build();
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.synapse.account_service.controller;

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;

import com.synapse.account_service.dto.request.SignUpRequest;
import com.synapse.account_service.dto.response.SignUpResponse;
import com.synapse.account_service.service.AccountService;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

import static org.springframework.http.HttpStatus.CREATED;

@RestController
@RequestMapping("/api/accounts")
@RequiredArgsConstructor
public class AccountController {
private final AccountService accountService;

@PostMapping("/signup")
public ResponseEntity<SignUpResponse> signUp(@Valid @RequestBody SignUpRequest request) {
return ResponseEntity.status(CREATED).body(accountService.registerMember(request));
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.synapse.account_service.domain;

import org.springframework.security.crypto.password.PasswordEncoder;

import com.synapse.account_service.common.BaseEntity;
import com.synapse.account_service.domain.enums.MemberRole;

Expand All @@ -19,10 +21,10 @@ public class Member extends BaseEntity {
@Column(name = "member_id")
private Long id;

@Column(name = "username")
@Column(name = "username", nullable = false)
private String username;

@Column(name = "password")
@Column(name = "password", nullable = false)
private String password;

@Column(name = "email", nullable = false, unique = true, length = 50)
Expand Down Expand Up @@ -61,4 +63,8 @@ public void setSubscription(Subscription subscription) {
subscription.setMemberInternal(this); // 무한 루프 방지를 위해 내부 메서드 호출
}
}

public void encodePassword(PasswordEncoder passwordEncoder) {
this.password = passwordEncoder.encode(this.password);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.synapse.account_service.dto.request;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record SignUpRequest(
@NotBlank(message = "이메일은 필수 입력 항목입니다.")
@Email(message = "유효한 이메일 형식이 아닙니다.")
String email,

@NotBlank(message = "사용자 이름은 필수 입력 항목입니다.")
String username,

@NotBlank(message = "비밀번호는 필수 입력 항목입니다.")
@Size(min = 8, message = "비밀번호는 최소 8자 이상이어야 합니다.")
String password
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.synapse.account_service.dto.response;

import com.synapse.account_service.domain.Member;

public record SignUpResponse(
Long id,
String email,
String username,
String role
) {
public static SignUpResponse from(Member member) {
return new SignUpResponse(member.getId(), member.getEmail(), member.getUsername(), member.getRole().name());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.synapse.account_service.exception;

import lombok.Getter;

@Getter
public abstract class AccountServiceException extends RuntimeException {
private final ExceptionType exceptionType;

protected AccountServiceException(final ExceptionType exceptionType) {
super(exceptionType.getMessage());
this.exceptionType = exceptionType;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.synapse.account_service.exception;

public class DuplicatedException extends AccountServiceException {
public DuplicatedException(ExceptionType exceptionType) {
super(exceptionType);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.synapse.account_service.exception;

import org.springframework.http.HttpStatus;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;

import static org.springframework.http.HttpStatus.CONFLICT;
import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;

@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public enum ExceptionType {
DUPLICATED_EMAIL(CONFLICT, "001", "이미 존재하는 이메일입니다."),
DUPLICATED_USERNAME(CONFLICT, "002", "이미 존재하는 사용자 이름입니다."),
EXCEPTION(INTERNAL_SERVER_ERROR, "003", "예상치 못한 오류가 발생했습니다.")
;

private final HttpStatus status;
private final String code;
private final String message;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.synapse.account_service.exception;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

import com.synapse.account_service.exception.dto.ExceptionResponse;

import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;

@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
private static final Logger log = LoggerFactory.getLogger("ErrorLogger");

private static final String LOG_FORMAT_INFO_PATTERN = "[🔵INFO] - (%s %s)\nExceptionType: %s\n %s: %s";
private static final String LOG_FORMAT_WARN_PATTERN = "[🟠WARN] - (%s %s)\nExceptionType: %s\n %s: %s";
private static final String LOG_FORMAT_ERROR_PATTERN = "[🔴ERROR] - (%s %s)\nExceptionType: %s\n %s: %s";

@ExceptionHandler(AccountServiceException.class)
public ResponseEntity<ExceptionResponse> handleAccountServiceException(AccountServiceException e, HttpServletRequest request) {
logInfo(e, request);
return ResponseEntity.status(e.getExceptionType().getStatus()).body(ExceptionResponse.from(e));
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ExceptionResponse> handleException(Exception e, HttpServletRequest request) {
logError(e, request);
return ResponseEntity
.status(ExceptionType.EXCEPTION.getStatus())
.body(new ExceptionResponse(ExceptionType.EXCEPTION.getCode(), ExceptionType.EXCEPTION.getMessage()));
}

private void logInfo(AccountServiceException e, HttpServletRequest request) {
log.info(String.format(LOG_FORMAT_INFO_PATTERN,
request.getMethod(),
request.getRequestURI(),
e.getExceptionType(),
e.getClass().getName(),
e.getMessage()));
}

private void logWarn(AccountServiceException e, HttpServletRequest request) {
log.warn(String.format(LOG_FORMAT_WARN_PATTERN,
request.getMethod(),
request.getRequestURI(),
e.getExceptionType(),
e.getClass().getName(),
e.getMessage()));
}

private void logError(Exception e, HttpServletRequest request) {
log.error(String.format(LOG_FORMAT_ERROR_PATTERN,
request.getMethod(),
request.getRequestURI(),
e.getClass().getName(),
e.getMessage()));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.synapse.account_service.exception.dto;

import com.synapse.account_service.exception.AccountServiceException;
import com.synapse.account_service.exception.ExceptionType;

public record ExceptionResponse(
String code,
String message
) {
public static ExceptionResponse from(AccountServiceException exception) {
return ExceptionResponse.from(exception.getExceptionType());
}

public static ExceptionResponse from(ExceptionType exceptionType) {
return new ExceptionResponse(exceptionType.getCode(), exceptionType.getMessage());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@

public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByEmail(String email);
Optional<Member> findByUsername(String username);
Optional<Member> findByProviderAndRegistrationId(String provider, String registrationId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.synapse.account_service.service;

import java.time.LocalTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Optional;

import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.synapse.account_service.domain.Member;
import com.synapse.account_service.domain.Subscription;
import com.synapse.account_service.domain.enums.MemberRole;
import com.synapse.account_service.domain.enums.SubscriptionTier;
import com.synapse.account_service.dto.request.SignUpRequest;
import com.synapse.account_service.dto.response.SignUpResponse;
import com.synapse.account_service.exception.ExceptionType;
import com.synapse.account_service.exception.DuplicatedException;
import com.synapse.account_service.repository.MemberRepository;

import lombok.RequiredArgsConstructor;

@Transactional(readOnly = true)
@Service
@RequiredArgsConstructor
public class AccountService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;

public Optional<Member> findMemberByEmail(String email) {
return memberRepository.findByEmail(email);
}

public Optional<Member> findMemberByUsername(String username) {
return memberRepository.findByUsername(username);
}

public SignUpResponse registerMember(SignUpRequest request) {
findMemberByEmail(request.email()).ifPresent(m -> {
throw new DuplicatedException(ExceptionType.DUPLICATED_EMAIL);
});

findMemberByUsername(request.username()).ifPresent(m -> {
throw new DuplicatedException(ExceptionType.DUPLICATED_USERNAME);
});

Member member = Member.builder()
.email(request.email())
.password(request.password())
.username(request.username())
.role(MemberRole.USER) // 기본 역할은 USER
.provider("local") // 일반 회원가입이므로 "local"로 지정
.build();

member.encodePassword(passwordEncoder);

createAndSetDefaultSubscription(member);

memberRepository.save(member);

return SignUpResponse.from(member);
}

private void createAndSetDefaultSubscription(Member member) {
ZonedDateTime nextRenewalDate = ZonedDateTime.now(ZoneId.systemDefault()).plusDays(1).with(LocalTime.MIDNIGHT); // 무료 사용자는 자정 초기화

Subscription freeSubscription = Subscription.builder()
.tier(SubscriptionTier.FREE)
.nextRenewalDate(nextRenewalDate)
.build();

member.setSubscription(freeSubscription);
}
}
Loading
Loading