1 Commits

Author SHA1 Message Date
Rebwon
a5c27bda82 Using @Async annotation and WebTestClient
Changed synchronous mail transfer to asynchronous. You also switched to
the WebTest Client without using MockMvc, which is dependent on Spring
ApplicationContext. As a result, the test runs 60% faster.
2021-09-02 11:47:46 +09:00
159 changed files with 637 additions and 5266 deletions

View File

@@ -8,15 +8,6 @@ charset = utf-8
# [newline-eof]
insert_final_newline = true
# Ignore
[*.log]
charset = unset
end_of_line = unset
insert_final_newline = unset
trim_trailing_whitespace = unset
indent_style = unset
indent_size = unset
[*.bat]
end_of_line = crlf

5
.gitignore vendored
View File

@@ -35,8 +35,3 @@ out/
### VS Code ###
.vscode/
/logs
/logs/*.log
/src/main/resources/application-prod.yml

View File

@@ -1,8 +1,2 @@
# YouAndMe
지역 사람들과 공감할 수 있는 지도 기반 블로깅 서비스
![main](https://i.imgur.com/1vm3DyZ.png)
/
## ✨프로젝트 구조
(2021.11.01 수정)
![Imgur](https://i.imgur.com/rE56Ain.png)

View File

@@ -1,8 +1,7 @@
import com.github.spotbugs.snom.SpotBugsTask
plugins {
id 'jacoco'
id 'org.springframework.boot' version '2.5.3'
id 'org.springframework.boot' version '2.5.4'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'org.asciidoctor.convert' version '1.5.8'
id 'org.ec4j.editorconfig' version '0.0.3'
@@ -19,9 +18,6 @@ configurations {
compileOnly {
extendsFrom annotationProcessor
}
all {
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging'
}
}
repositories {
@@ -42,92 +38,32 @@ dependencies {
annotationProcessor 'org.projectlombok:lombok'
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.0'
testImplementation 'com.h2database:h2'
runtimeOnly 'mysql:mysql-connector-java'
implementation 'com.lmax:disruptor:3.4.4'
implementation 'org.springframework.boot:spring-boot-starter-log4j2'
runtimeOnly 'com.h2database:h2'
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.security:spring-security-crypto:5.5.2'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'de.codecentric:spring-boot-admin-starter-client:2.4.3'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'it.ozimov:embedded-redis:0.7.2'
testImplementation 'it.ozimov:embedded-redis:0.7.2'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.session:spring-session-data-redis'
testImplementation 'io.github.javaunit:autoparams:0.3.0'
testImplementation 'io.github.javaunit:autoparams:0.2.12'
testImplementation 'com.tngtech.archunit:archunit-junit5:0.20.1'
testImplementation 'org.mockito:mockito-inline:3.9.0'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
implementation 'org.apache.commons:commons-lang3:3.12.0'
}
test {
outputs.dir snippetsDir
useJUnitPlatform()
finalizedBy('jacocoTestReport')
}
editorconfig {
excludes = ["build"]
}
jacoco {
toolVersion = '0.8.7'
}
jacocoTestReport {
reports {
xml.enabled true
html.enabled true
csv.enabled false
}
finalizedBy 'jacocoTestCoverageVerification'
}
jacocoTestCoverageVerification {
violationRules {
rule {
enabled = true
element = 'CLASS'
excludes = [
"*.YouAndMeApplication",
"*.*Request",
"*.*Response",
"*.*Repository",
"*.*Reader",
"*.Role",
"*.configuration.*",
"*.SmtpMail*",
"*.RoleTypeHandler",
]
limit {
counter = 'BRANCH'
value = 'COVEREDRATIO'
minimum = 0.80
}
limit {
counter = 'INSTRUCTION'
value = 'COVEREDRATIO'
minimum = 0.50
}
}
}
}
check.dependsOn editorconfigCheck
checkstyle {

View File

@@ -1 +0,0 @@
lombok.addLombokGeneratedAnnotation = true

View File

@@ -1,13 +0,0 @@
package com.yam.app;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public final class ActuatorApi {
@GetMapping("/")
public void hello() {
}
}

View File

@@ -1,20 +1,9 @@
package com.yam.app.account.application;
import com.yam.app.account.domain.AccountNotFoundException;
import com.yam.app.account.domain.AccountReader;
import com.yam.app.account.domain.ConfirmRegisterAccountProcessor;
import com.yam.app.account.domain.LoginAccountProcessor;
import com.yam.app.account.domain.RegisterAccountConfirmEvent;
import com.yam.app.account.domain.RegisterAccountEvent;
import com.yam.app.account.domain.RegisterAccountProcessor;
import com.yam.app.account.domain.UpdateAccountEvent;
import com.yam.app.account.domain.UpdateAccountProcessor;
import com.yam.app.account.presentation.AccountResponse;
import com.yam.app.account.presentation.ConfirmRegisterAccountCommand;
import com.yam.app.account.presentation.LoginAccountCommand;
import com.yam.app.account.presentation.RegisterAccountCommand;
import com.yam.app.account.presentation.UpdateAccountCommand;
import com.yam.app.common.Authentication;
import com.yam.app.account.presentation.RegisterAccountRequest;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -22,59 +11,22 @@ import org.springframework.transaction.annotation.Transactional;
@Service
public class AccountFacade {
private final RegisterAccountProcessor registerProcessor;
private final RegisterAccountProcessor processor;
private final AccountTranslator translator;
private final ApplicationEventPublisher publisher;
private final ConfirmRegisterAccountProcessor confirmRegisterProcessor;
private final LoginAccountProcessor loginProcessor;
private final AccountReader accountReader;
private final UpdateAccountProcessor updateProcessor;
public AccountFacade(RegisterAccountProcessor registerProcessor,
ApplicationEventPublisher publisher,
ConfirmRegisterAccountProcessor confirmRegisterProcessor,
LoginAccountProcessor loginProcessor, AccountReader accountReader,
UpdateAccountProcessor updateProcessor) {
this.registerProcessor = registerProcessor;
public AccountFacade(RegisterAccountProcessor processor,
AccountTranslator translator,
ApplicationEventPublisher publisher) {
this.processor = processor;
this.translator = translator;
this.publisher = publisher;
this.confirmRegisterProcessor = confirmRegisterProcessor;
this.loginProcessor = loginProcessor;
this.accountReader = accountReader;
this.updateProcessor = updateProcessor;
}
@Transactional
public void register(RegisterAccountCommand command) {
registerProcessor.register(command.getEmail(), command.getPassword());
var entity = accountReader.findByEmail(command.getEmail())
.orElseThrow(() -> new AccountNotFoundException(command.getEmail()));
public AccountResponse register(RegisterAccountRequest request) {
var entity = processor.process(translator.toCommand(request));
publisher.publishEvent(new RegisterAccountEvent(entity));
}
@Transactional
public void registerConfirm(ConfirmRegisterAccountCommand command) {
confirmRegisterProcessor.registerConfirm(command.getToken(), command.getEmail());
publisher.publishEvent(new RegisterAccountConfirmEvent(command.getEmail()));
}
@Transactional(readOnly = true)
public void login(LoginAccountCommand command) {
loginProcessor.login(command.getEmail(), command.getPassword());
}
@Transactional(readOnly = true)
public AccountResponse findInfo(Authentication authentication) {
var memberAccount = accountReader.findByEmailAndMemberId(
authentication.getCredentials(), authentication.getMemberId());
return new AccountResponse(memberAccount.getId(), memberAccount.getEmail(),
memberAccount.getNickname(), memberAccount.getImage());
}
@Transactional
public void update(Authentication authentication, UpdateAccountCommand command) {
updateProcessor.update(authentication.getCredentials(), command.getPassword());
publisher.publishEvent(new UpdateAccountEvent(
authentication.getMemberId(),
command.getNickname(),
command.getImage()));
return translator.toResponse(entity);
}
}

View File

@@ -0,0 +1,20 @@
package com.yam.app.account.application;
import com.yam.app.account.domain.Account;
import com.yam.app.account.presentation.AccountResponse;
import com.yam.app.account.presentation.RegisterAccountRequest;
import org.springframework.stereotype.Component;
@Component
final class AccountTranslator {
public RegisterAccountCommand toCommand(RegisterAccountRequest request) {
return new RegisterAccountCommand(request.getEmail(), request.getNickname(),
request.getPassword());
}
public AccountResponse toResponse(Account entity) {
return new AccountResponse(entity.getId(), entity.getEmail(),
entity.getNickname());
}
}

View File

@@ -0,0 +1,17 @@
package com.yam.app.account.application;
import lombok.Getter;
@Getter
public final class RegisterAccountCommand {
private final String email;
private final String nickname;
private final String password;
public RegisterAccountCommand(String email, String nickname, String password) {
this.email = email;
this.nickname = nickname;
this.password = password;
}
}

View File

@@ -1,22 +1,17 @@
package com.yam.app.account.domain;
import com.yam.app.common.EntityStatus;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.UUID;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Getter
@ToString(exclude = "password")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public final class Account implements Serializable {
public final class Account {
private Long id;
private Long memberId;
private String email;
private String nickname;
private String password;
private String emailCheckToken;
private LocalDateTime emailCheckTokenGeneratedAt;
@@ -25,17 +20,15 @@ public final class Account implements Serializable {
private LocalDateTime lastModifiedAt;
private LocalDateTime withdrawalAt;
private boolean withdraw = false;
private Role role;
private EntityStatus status = EntityStatus.ALIVE;
private Account(String email, String password) {
private Account(String email, String nickname, String password) {
this.email = email;
this.nickname = nickname;
this.password = password;
this.role = Role.DEFAULT;
}
public static Account of(String email, String password) {
Account account = new Account(email, password);
public static Account of(String email, String nickname, String password) {
Account account = new Account(email, nickname, password);
account.generateEmailCheckToken();
return account;
}
@@ -57,12 +50,4 @@ public final class Account implements Serializable {
public boolean isValidToken(String token) {
return this.emailCheckToken.equals(token);
}
public void addMember(Long memberId) {
this.memberId = memberId;
}
public void changePassword(String password) {
this.password = password;
}
}

View File

@@ -1,10 +0,0 @@
package com.yam.app.account.domain;
import com.yam.app.common.EntityNotFoundException;
public final class AccountNotFoundException extends EntityNotFoundException {
public AccountNotFoundException(String email) {
super("Account could not be found, (email : %s)", email);
}
}

View File

@@ -1,14 +1,6 @@
package com.yam.app.account.domain;
import java.util.Optional;
import org.apache.ibatis.annotations.Param;
public interface AccountReader {
boolean existsByEmail(String email);
Optional<Account> findByEmail(String email);
MemberAccount findByEmailAndMemberId(@Param("email") String email,
@Param("memberId") Long memberId);
Account findByEmail(String email);
}

View File

@@ -2,8 +2,9 @@ package com.yam.app.account.domain;
public interface AccountRepository {
void save(Account entity);
boolean existsByEmail(String email);
void update(Account entity);
boolean existsByNickname(String nickname);
Account save(Account entity);
}

View File

@@ -1,23 +0,0 @@
package com.yam.app.account.domain;
public final class ConfirmRegisterAccountProcessor {
private final AccountReader accountReader;
private final AccountRepository accountRepository;
private final TokenVerifier tokenVerifier;
public ConfirmRegisterAccountProcessor(AccountReader accountReader,
AccountRepository accountRepository, TokenVerifier tokenVerifier) {
this.accountReader = accountReader;
this.accountRepository = accountRepository;
this.tokenVerifier = tokenVerifier;
}
public void registerConfirm(String token, String email) {
tokenVerifier.verify(token, email);
var account = accountReader.findByEmail(email)
.orElseThrow(() -> new AccountNotFoundException(email));
account.completeRegister();
accountRepository.update(account);
}
}

View File

@@ -1,15 +0,0 @@
package com.yam.app.account.domain;
import lombok.Getter;
@Getter
public final class GenerateMemberEvent {
private final Long memberId;
private final String email;
public GenerateMemberEvent(Long memberId, String email) {
this.memberId = memberId;
this.email = email;
}
}

View File

@@ -1,7 +0,0 @@
package com.yam.app.account.domain;
public interface LoginAccountProcessor {
void login(String email, String password);
}

View File

@@ -1,19 +0,0 @@
package com.yam.app.account.domain;
import lombok.Getter;
@Getter
public final class MemberAccount {
private final Long id;
private final String email;
private final String nickname;
private final String image;
public MemberAccount(Long id, String email, String nickname, String image) {
this.id = id;
this.email = email;
this.nickname = nickname;
this.image = image;
}
}

View File

@@ -1,13 +0,0 @@
package com.yam.app.account.domain;
import lombok.Getter;
@Getter
public final class RegisterAccountConfirmEvent {
private final String email;
public RegisterAccountConfirmEvent(String email) {
this.email = email;
}
}

View File

@@ -1,27 +1,29 @@
package com.yam.app.account.domain;
import com.yam.app.common.DuplicateValueException;
import com.yam.app.account.application.RegisterAccountCommand;
public final class RegisterAccountProcessor {
private final AccountRepository accountRepository;
private final AccountReader accountReader;
private final PasswordEncrypter passwordEncrypter;
public RegisterAccountProcessor(AccountRepository accountRepository,
AccountReader accountReader, PasswordEncrypter passwordEncrypter) {
PasswordEncrypter passwordEncrypter) {
this.accountRepository = accountRepository;
this.accountReader = accountReader;
this.passwordEncrypter = passwordEncrypter;
}
public void register(String email, String password) {
if (accountReader.existsByEmail(email)) {
throw new DuplicateValueException(email);
public Account process(RegisterAccountCommand command) {
if (accountRepository.existsByEmail(command.getEmail())) {
throw new IllegalStateException();
}
if (accountRepository.existsByNickname(command.getNickname())) {
throw new IllegalStateException();
}
String encodedPassword = passwordEncrypter.encode(password);
String encodedPassword = passwordEncrypter.encode(command.getPassword());
accountRepository.save(Account.of(email, encodedPassword));
return accountRepository.save(
Account.of(command.getEmail(), command.getNickname(), encodedPassword));
}
}

View File

@@ -1,14 +0,0 @@
package com.yam.app.account.domain;
import java.util.Arrays;
public enum Role {
DEFAULT, LOCALIZED, ADMIN;
public static Role findRole(String role) {
return Arrays.stream(Role.values())
.filter(r -> r.name().equals(role))
.findFirst()
.orElseThrow(IllegalArgumentException::new);
}
}

View File

@@ -1,19 +0,0 @@
package com.yam.app.account.domain;
public final class TokenVerifier {
private final AccountReader accountReader;
public TokenVerifier(AccountReader accountReader) {
this.accountReader = accountReader;
}
public void verify(String token, String email) {
var account = accountReader.findByEmail(email)
.orElseThrow(() -> new AccountNotFoundException(email));
if (!account.isValidToken(token)) {
throw new IllegalStateException("Invalid token");
}
}
}

View File

@@ -1,17 +0,0 @@
package com.yam.app.account.domain;
import lombok.Getter;
@Getter
public final class UpdateAccountEvent {
private final Long memberId;
private final String nickname;
private final String image;
public UpdateAccountEvent(Long memberId, String nickname, String image) {
this.memberId = memberId;
this.nickname = nickname;
this.image = image;
}
}

View File

@@ -1,23 +0,0 @@
package com.yam.app.account.domain;
public final class UpdateAccountProcessor {
private final AccountReader accountReader;
private final AccountRepository accountRepository;
private final PasswordEncrypter passwordEncrypter;
public UpdateAccountProcessor(AccountReader accountReader,
AccountRepository accountRepository,
PasswordEncrypter passwordEncrypter) {
this.accountReader = accountReader;
this.accountRepository = accountRepository;
this.passwordEncrypter = passwordEncrypter;
}
public void update(String email, String password) {
var account = accountReader.findByEmail(email)
.orElseThrow(() -> new AccountNotFoundException(email));
account.changePassword(passwordEncrypter.encode(password));
accountRepository.update(account);
}
}

View File

@@ -1,29 +0,0 @@
package com.yam.app.account.infrastructure;
import com.yam.app.account.domain.AccountNotFoundException;
import com.yam.app.account.domain.AccountReader;
import com.yam.app.account.domain.AccountRepository;
import com.yam.app.account.domain.GenerateMemberEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
public class AccountEventListener {
private final AccountReader accountReader;
private final AccountRepository accountRepository;
public AccountEventListener(AccountReader accountReader,
AccountRepository accountRepository) {
this.accountReader = accountReader;
this.accountRepository = accountRepository;
}
@EventListener
public void handle(GenerateMemberEvent event) {
var account = accountReader.findByEmail(event.getEmail())
.orElseThrow(() -> new AccountNotFoundException(event.getEmail()));
account.addMember(event.getMemberId());
accountRepository.update(account);
}
}

View File

@@ -1,38 +0,0 @@
package com.yam.app.account.infrastructure;
import com.yam.app.account.domain.ConfirmRegisterAccountProcessor;
import com.yam.app.account.domain.RegisterAccountProcessor;
import com.yam.app.account.domain.TokenVerifier;
import com.yam.app.account.domain.UpdateAccountProcessor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Profile;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Import(value = {
MybatisAccountRepository.class,
DelegatePasswordEncrypter.class,
RegisterAccountProcessor.class,
TokenVerifier.class,
ConfirmRegisterAccountProcessor.class,
SessionBasedLoginAccountProcessor.class,
UpdateAccountProcessor.class,
SessionManager.class
})
@Configuration
public class AccountModuleConfiguration {
@Bean
@Profile(value = {"local", "prod"})
public MailDispatcher mailDispatcher(JavaMailSender javaMailSender) {
return new SmtpMailDispatcher(javaMailSender);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@@ -1,28 +0,0 @@
package com.yam.app.account.infrastructure;
import com.yam.app.account.domain.Account;
import com.yam.app.common.Authentication;
public final class AccountPrincipal implements Authentication {
private final Account account;
public AccountPrincipal(Account account) {
this.account = account;
}
@Override
public String getCredentials() {
return account.getEmail();
}
@Override
public String getRole() {
return account.getRole().name();
}
@Override
public Long getMemberId() {
return account.getMemberId();
}
}

View File

@@ -0,0 +1,49 @@
package com.yam.app.account.infrastructure;
import com.yam.app.account.domain.AccountReader;
import com.yam.app.account.domain.AccountRepository;
import com.yam.app.account.domain.PasswordEncrypter;
import com.yam.app.account.domain.RegisterAccountProcessor;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class AppConfiguration {
@Bean
@Profile("prod")
public MailDispatcher mailDispatcher(JavaMailSender javaMailSender) {
return new SmtpMailDispatcher(javaMailSender);
}
@Bean
public PasswordEncrypter passwordEncrypter(PasswordEncoder passwordEncoder) {
return new DelegatePasswordEncrypter(passwordEncoder);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AccountRepository accountRepository(SqlSessionTemplate sqlSessionTemplate) {
return new MybatisAccountRepository(sqlSessionTemplate);
}
@Bean
public AccountReader accountReader(SqlSessionTemplate sqlSessionTemplate) {
return new MybatisAccountRepository(sqlSessionTemplate);
}
@Bean
public RegisterAccountProcessor registerAccountProcessor(AccountRepository accountRepository,
PasswordEncrypter passwordEncrypter) {
return new RegisterAccountProcessor(accountRepository, passwordEncrypter);
}
}

View File

@@ -1,4 +1,4 @@
package com.yam.app.common.configuration;
package com.yam.app.account.infrastructure;
import java.util.concurrent.Executor;
import lombok.extern.slf4j.Slf4j;

View File

@@ -1,35 +0,0 @@
package com.yam.app.account.infrastructure;
import com.yam.app.common.AuthenticationPrincipal;
import com.yam.app.common.UnauthorizedRequestException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
public final class AuthenticationPrincipalArgumentResolver
implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(AuthenticationPrincipal.class);
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
HttpSession session = ((HttpServletRequest) webRequest.getNativeRequest())
.getSession(false);
if (session == null) {
throw new UnauthorizedRequestException("Unauthorized request");
}
var sessionManager = new SessionManager(session);
return sessionManager.fetchPrincipal()
.orElseThrow(() -> new UnauthorizedRequestException("Failed fetch principal"));
}
}

View File

@@ -2,9 +2,9 @@ package com.yam.app.account.infrastructure;
import com.yam.app.account.domain.RegisterAccountEvent;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionalEventListener;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
@@ -23,15 +23,14 @@ class MailManager {
}
@Async
@TransactionalEventListener
@EventListener
public void handle(RegisterAccountEvent event) {
var newAccount = event.getAccount();
var context = new Context();
context.setVariable("link",
"/api/accounts/authorize?token=" + newAccount.getEmailCheckToken()
"/api/check-email?token=" + newAccount.getEmailCheckToken()
+ "&email=" + newAccount.getEmail());
var username = newAccount.getEmail().split("@")[0];
context.setVariable("username", username);
context.setVariable("nickname", newAccount.getNickname());
context.setVariable("linkName", "이메일 인증하기");
context.setVariable("message", "YouAndMe 서비스를 사용하려면 링크를 클릭하세요.");
context.setVariable("host", host);

View File

@@ -3,52 +3,43 @@ package com.yam.app.account.infrastructure;
import com.yam.app.account.domain.Account;
import com.yam.app.account.domain.AccountReader;
import com.yam.app.account.domain.AccountRepository;
import com.yam.app.account.domain.MemberAccount;
import java.util.Optional;
import org.mybatis.spring.SqlSessionTemplate;
public final class MybatisAccountRepository implements AccountRepository, AccountReader {
private static final String SAVE_FQCN = "com.yam.app.account.domain.AccountRepository.save";
private static final String UPDATE_FQCN = "com.yam.app.account.domain.AccountRepository.update";
private final SqlSessionTemplate template;
private static final String COMMAND_NAMESPACE = "com.yam.app.account.domain.AccountRepository.";
private static final String READER_NAMESPACE = "com.yam.app.account.domain.AccountReader.";
public MybatisAccountRepository(SqlSessionTemplate template) {
this.template = template;
}
@Override
public boolean existsByEmail(String email) {
return template.getMapper(AccountReader.class).existsByEmail(email);
int result = template.selectOne(COMMAND_NAMESPACE + "existsByEmail", email);
return result != 0;
}
@Override
public void update(Account entity) {
int result = template.update(UPDATE_FQCN, entity);
public boolean existsByNickname(String nickname) {
int result = template.selectOne(COMMAND_NAMESPACE + "existsByNickname", nickname);
return result != 0;
}
@Override
public Account save(Account entity) {
int result = template.insert(COMMAND_NAMESPACE + "save", entity);
if (result != 1) {
throw new IllegalStateException(String.format(
"Unintentionally, more records were updated than expected. : %s", entity));
throw new RuntimeException(
String.format("There was a problem saving the object : %s", entity));
}
return findByEmail(entity.getEmail());
}
@Override
public void save(Account entity) {
int result = template.insert(SAVE_FQCN, entity);
if (result != 1) {
throw new IllegalStateException(String.format(
"Unintentionally, more records were saved than expected. : %s", entity));
}
}
@Override
public Optional<Account> findByEmail(String email) {
return template.getMapper(AccountReader.class).findByEmail(email);
}
@Override
public MemberAccount findByEmailAndMemberId(String email,
Long memberId) {
return template.getMapper(AccountReader.class).findByEmailAndMemberId(email, memberId);
public Account findByEmail(String email) {
return template.selectOne(READER_NAMESPACE + "findByEmail", email);
}
}

View File

@@ -1,35 +0,0 @@
package com.yam.app.account.infrastructure;
import com.yam.app.account.domain.Role;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedTypes;
import org.apache.ibatis.type.TypeHandler;
@MappedTypes(RoleTypeHandler.class)
public final class RoleTypeHandler implements TypeHandler<Role> {
@Override
public void setParameter(PreparedStatement ps, int i,
Role parameter, JdbcType jdbcType) throws SQLException {
ps.setString(i, parameter.name());
}
@Override
public Role getResult(ResultSet rs, String columnName) throws SQLException {
return Role.findRole(rs.getString(columnName));
}
@Override
public Role getResult(ResultSet rs, int columnIndex) throws SQLException {
return Role.findRole(rs.getString(columnIndex));
}
@Override
public Role getResult(CallableStatement cs, int columnIndex) throws SQLException {
return Role.findRole(cs.getString(columnIndex));
}
}

View File

@@ -1,21 +0,0 @@
package com.yam.app.account.infrastructure;
import com.yam.app.common.UnauthorizedRequestException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.web.servlet.HandlerInterceptor;
public final class SessionAuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) throws Exception {
var session = request.getSession(false);
if (session == null) {
throw new UnauthorizedRequestException("Unauthorized request");
}
return true;
}
}

View File

@@ -1,37 +0,0 @@
package com.yam.app.account.infrastructure;
import com.yam.app.account.domain.AccountNotFoundException;
import com.yam.app.account.domain.AccountReader;
import com.yam.app.account.domain.LoginAccountProcessor;
import com.yam.app.account.domain.PasswordEncrypter;
public final class SessionBasedLoginAccountProcessor implements LoginAccountProcessor {
private final AccountReader accountReader;
private final PasswordEncrypter passwordEncrypter;
private final SessionManager sessionManager;
public SessionBasedLoginAccountProcessor(AccountReader accountReader,
PasswordEncrypter passwordEncrypter,
SessionManager sessionManager) {
this.accountReader = accountReader;
this.passwordEncrypter = passwordEncrypter;
this.sessionManager = sessionManager;
}
@Override
public void login(String email, String password) {
var account = accountReader.findByEmail(email)
.orElseThrow(() -> new AccountNotFoundException(email));
if (!account.isEmailVerified()) {
throw new IllegalStateException("Email not verified");
}
if (!passwordEncrypter.matches(password, account.getPassword())) {
throw new IllegalStateException("Password mismatched");
}
sessionManager.setPrincipal(new AccountPrincipal(account));
}
}

View File

@@ -1,29 +0,0 @@
package com.yam.app.account.infrastructure;
import com.yam.app.common.Authentication;
import java.util.Optional;
import javax.servlet.http.HttpSession;
public final class SessionManager {
private static final String LOGIN_ACCOUNT = "LOGIN_ACCOUNT";
private final HttpSession httpSession;
public SessionManager(HttpSession httpSession) {
this.httpSession = httpSession;
}
public void setPrincipal(Authentication principal) {
this.httpSession.setAttribute(LOGIN_ACCOUNT, principal);
}
public Optional<Authentication> fetchPrincipal() {
return Optional.ofNullable((Authentication) httpSession.getAttribute(LOGIN_ACCOUNT));
}
public void removePrincipal() {
this.httpSession.removeAttribute(LOGIN_ACCOUNT);
this.httpSession.invalidate();
}
}

View File

@@ -1,33 +0,0 @@
package com.yam.app.account.infrastructure;
import java.util.List;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
private static final String[] EXCLUDE_PATHS = {
"/api/accounts/login",
"/api/accounts/authorize",
"/api/accounts",
"/api/articles/all",
"/api/articles/{articleId:[0-9]+}",
"/api/comments/{articleId:[0-9]+}",
"/actuator/**"
};
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new AuthenticationPrincipalArgumentResolver());
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SessionAuthInterceptor())
.addPathPatterns("/api/**")
.excludePathPatterns(EXCLUDE_PATHS);
}
}

View File

@@ -1,79 +0,0 @@
package com.yam.app.account.presentation;
import com.yam.app.account.application.AccountFacade;
import com.yam.app.account.infrastructure.SessionManager;
import com.yam.app.common.Authentication;
import com.yam.app.common.AuthenticationPrincipal;
import java.net.URI;
import javax.servlet.http.HttpSession;
import javax.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PatchMapping;
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(
produces = MediaType.APPLICATION_JSON_VALUE,
consumes = MediaType.APPLICATION_JSON_VALUE
)
public final class AccountCommandApi {
private final AccountFacade accountFacade;
public AccountCommandApi(AccountFacade accountFacade) {
this.accountFacade = accountFacade;
}
@PostMapping("/api/accounts")
public ResponseEntity<Void> register(
@RequestBody @Valid RegisterAccountCommand command) {
accountFacade.register(command);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
/**
* 임시로 "http://localhost:3000/login"로 리다이렉트 되도록 설정.
*/
@GetMapping(value = "/api/accounts/authorize",
produces = MediaType.ALL_VALUE,
consumes = MediaType.ALL_VALUE)
public ResponseEntity<Void> registerConfirm(
@ModelAttribute @Valid ConfirmRegisterAccountCommand command) throws Exception {
accountFacade.registerConfirm(command);
var uri = new URI("http://localhost:3000/login");
var header = new HttpHeaders();
header.setLocation(uri);
return new ResponseEntity<>(header, HttpStatus.SEE_OTHER);
}
@PostMapping("/api/accounts/login")
public ResponseEntity<Void> login(
@RequestBody @Valid LoginAccountCommand request) {
accountFacade.login(request);
return ResponseEntity.ok().build();
}
@PostMapping("/api/accounts/logout")
public ResponseEntity<Void> logout(HttpSession httpSession) {
var session = new SessionManager(httpSession);
session.removePrincipal();
return ResponseEntity.ok().build();
}
@PatchMapping("/api/accounts/update")
public ResponseEntity<Void> update(
@RequestBody @Valid UpdateAccountCommand command,
@AuthenticationPrincipal Authentication authentication) {
accountFacade.update(authentication, command);
return ResponseEntity.ok().build();
}
}

View File

@@ -8,12 +8,10 @@ public final class AccountResponse {
private final Long id;
private final String email;
private final String nickname;
private final String image;
public AccountResponse(Long id, String email, String nickname, String image) {
public AccountResponse(Long id, String email, String nickname) {
this.id = id;
this.email = email;
this.nickname = nickname;
this.image = image;
}
}

View File

@@ -1,16 +0,0 @@
package com.yam.app.account.presentation;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import lombok.Data;
@Data
public final class ConfirmRegisterAccountCommand {
@NotBlank
private String token;
@Email
@NotBlank
private String email;
}

View File

@@ -1,20 +0,0 @@
package com.yam.app.account.presentation;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
import lombok.Data;
@Data
public final class LoginAccountCommand {
@NotBlank
@Email
private String email;
@NotBlank
@Pattern(regexp = "^[A-Za-z1-9~!@#$%^&*()+|=]{8,12}$",
message = "Please enter the password in English, numbers, "
+ "and special characters within 8-12 digits.")
private String password;
}

View File

@@ -1,12 +1,11 @@
package com.yam.app.account.presentation;
import com.yam.app.account.application.AccountFacade;
import com.yam.app.common.ApiResult;
import com.yam.app.common.Authentication;
import com.yam.app.common.AuthenticationPrincipal;
import javax.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
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;
@@ -15,19 +14,17 @@ import org.springframework.web.bind.annotation.RestController;
produces = MediaType.APPLICATION_JSON_VALUE,
consumes = MediaType.APPLICATION_JSON_VALUE
)
public final class AccountQueryApi {
public final class RegisterAccountApi {
private final AccountFacade accountFacade;
public AccountQueryApi(AccountFacade accountFacade) {
public RegisterAccountApi(AccountFacade accountFacade) {
this.accountFacade = accountFacade;
}
@GetMapping("/api/accounts/me")
public ResponseEntity<ApiResult<?>> findInfo(
@AuthenticationPrincipal Authentication authentication) {
return ResponseEntity.ok(
ApiResult.success(accountFacade.findInfo(authentication)));
@PostMapping("/api/accounts")
public ResponseEntity<AccountResponse> register(
@RequestBody @Valid RegisterAccountRequest request) {
return ResponseEntity.ok(accountFacade.register(request));
}
}

View File

@@ -1,21 +0,0 @@
package com.yam.app.account.presentation;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
import lombok.Data;
@Data
public final class RegisterAccountCommand {
@Email
@NotBlank
private String email;
@NotBlank
@Pattern(regexp = "^[A-Za-z1-9~!@#$%^&*()+|=]{8,12}$",
message = "Please enter the password in English, numbers, "
+ "and special characters within 8-12 digits.")
private String password;
}

View File

@@ -0,0 +1,21 @@
package com.yam.app.account.presentation;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
import lombok.Data;
@Data
public final class RegisterAccountRequest {
@Email
@NotBlank
private String email;
@NotBlank
private String nickname;
@Size(min = 8, max = 12, message = "비밀번호는 최소 8자, 최대 12자까지 허용됩니다.")
@NotBlank
private String password;
}

View File

@@ -1,21 +0,0 @@
package com.yam.app.account.presentation;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
import lombok.Data;
@Data
public final class UpdateAccountCommand {
@NotBlank
private String nickname;
@NotBlank
private String image;
@NotBlank
@Pattern(regexp = "^[A-Za-z1-9~!@#$%^&*()+|=]{8,12}$",
message = "Please enter the password in English, numbers, "
+ "and special characters within 8-12 digits.")
private String password;
}

View File

@@ -1,38 +0,0 @@
package com.yam.app.adapter;
import com.yam.app.account.domain.RegisterAccountConfirmEvent;
import com.yam.app.account.domain.UpdateAccountEvent;
import com.yam.app.member.domain.GenerateMemberEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
class DomainEventTranslator {
private final ApplicationEventPublisher publisher;
public DomainEventTranslator(ApplicationEventPublisher publisher) {
this.publisher = publisher;
}
@EventListener
public void translate(RegisterAccountConfirmEvent event) {
publisher.publishEvent(
new com.yam.app.member.domain.RegisterAccountConfirmEvent(event.getEmail()));
}
@EventListener
public void translate(GenerateMemberEvent event) {
publisher.publishEvent(
new com.yam.app.account.domain.GenerateMemberEvent(event.getMemberId(),
event.getEmail()));
}
@EventListener
public void translate(UpdateAccountEvent event) {
publisher.publishEvent(
new com.yam.app.member.domain.UpdateAccountEvent(
event.getMemberId(), event.getNickname(), event.getImage()));
}
}

View File

@@ -1,65 +0,0 @@
package com.yam.app.article.application;
import com.yam.app.article.domain.ArticleNotFoundException;
import com.yam.app.article.domain.ArticleReader;
import com.yam.app.article.domain.WriteArticleProcessor;
import com.yam.app.article.presentation.ArticlePreviewResponse;
import com.yam.app.article.presentation.ArticleResponse;
import com.yam.app.article.presentation.TagResponse;
import com.yam.app.article.presentation.WriteArticleCommand;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ArticleFacade {
private final WriteArticleProcessor writeArticleProcessor;
private final ArticleReader articleReader;
public ArticleFacade(WriteArticleProcessor writeArticleProcessor,
ArticleReader articleReader) {
this.writeArticleProcessor = writeArticleProcessor;
this.articleReader = articleReader;
}
@Transactional
public void write(Long memberId, WriteArticleCommand command) {
writeArticleProcessor.write(memberId,
command.getTitle(), command.getContent(),
command.getImage(), command.getTags());
}
@Transactional(readOnly = true)
public List<ArticlePreviewResponse> findAll(Long articleId, int pageSize) {
return articleReader.findAll(articleId, pageSize)
.stream()
.map(dto -> new ArticlePreviewResponse(dto.getId(), dto.getAuthorId(), dto.getTitle(),
dto.getNickname(), dto.getImage(), dto.getCreatedAt(), dto.getModifiedAt(),
dto.getStatus())
).collect(Collectors.toList());
}
@Transactional(readOnly = true)
public ArticleResponse findById(Long articleId) {
var article = articleReader.findById(articleId).orElseThrow(
() -> new ArticleNotFoundException(articleId));
return ArticleResponse.builder()
.id(article.getId())
.authorId(article.getAuthorId())
.title(article.getTitle())
.content(article.getContent())
.image(article.getImage())
.createdAt(article.getCreatedAt())
.modifiedAt(article.getModifiedAt())
.tags(
article.getTags().stream()
.map(a -> TagResponse.of(a.getTag().getId(), a.getTag().getName()))
.collect(Collectors.toList())
)
.build();
}
}

View File

@@ -1,49 +0,0 @@
package com.yam.app.article.domain;
import static java.time.LocalDateTime.now;
import com.yam.app.common.EntityStatus;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import lombok.AccessLevel;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Getter
@ToString
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode(of = "id")
public final class Article {
private Long id;
private Long authorId;
private String title;
private String content;
private String image;
private LocalDateTime createdAt;
private LocalDateTime modifiedAt;
private EntityStatus status = EntityStatus.ALIVE;
private List<ArticleTag> tags = new ArrayList<>();
private Article(Long authorId, String title, String content, String image,
LocalDateTime createdAt, LocalDateTime modifiedAt) {
this.authorId = authorId;
this.title = title;
this.content = content;
this.image = image;
this.createdAt = createdAt;
this.modifiedAt = modifiedAt;
}
void setId(Long id) {
this.id = id;
}
public static Article write(Long authorId, String title, String content,
String image) {
return new Article(authorId, title, content, image, now(), now());
}
}

View File

@@ -1,17 +0,0 @@
package com.yam.app.article.domain;
import java.time.LocalDateTime;
import lombok.Data;
@Data
public final class ArticleDto {
private Long id;
private Long authorId;
private String title;
private String status;
private LocalDateTime createdAt;
private LocalDateTime modifiedAt;
private String nickname;
private String image;
}

View File

@@ -1,11 +0,0 @@
package com.yam.app.article.domain;
import com.yam.app.common.EntityNotFoundException;
public final class ArticleNotFoundException extends EntityNotFoundException {
public ArticleNotFoundException(Long id) {
super("Article could not be found, (id : %s)", id);
}
}

View File

@@ -1,17 +0,0 @@
package com.yam.app.article.domain;
import java.util.List;
import java.util.Optional;
import org.apache.ibatis.annotations.Param;
public interface ArticleReader {
Article findByTitle(String title);
Optional<Article> findById(Long articleId);
boolean existsById(Long articleId);
List<ArticleDto> findAll(@Param("articleId") Long articleId,
@Param("pageSize") int pageSize);
}

View File

@@ -1,6 +0,0 @@
package com.yam.app.article.domain;
public interface ArticleRepository {
void save(Article entity);
}

View File

@@ -1,25 +0,0 @@
package com.yam.app.article.domain;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Getter
@ToString
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public final class ArticleTag {
private Long id;
private Long articleId;
private Tag tag;
public ArticleTag(Long articleId, Tag tag) {
this.articleId = articleId;
this.tag = tag;
}
void setId(Long id) {
this.id = id;
}
}

View File

@@ -1,6 +0,0 @@
package com.yam.app.article.domain;
public interface ArticleTagRepository {
void save(ArticleTag entity);
}

View File

@@ -1,23 +0,0 @@
package com.yam.app.article.domain;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Getter
@ToString
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public final class Tag {
private Long id;
private String name;
public Tag(String name) {
this.name = name;
}
void setId(Long id) {
this.id = id;
}
}

View File

@@ -1,8 +0,0 @@
package com.yam.app.article.domain;
public interface TagRepository {
void save(Tag entity);
Tag findByName(String name);
}

View File

@@ -1,39 +0,0 @@
package com.yam.app.article.domain;
import java.util.List;
import java.util.Optional;
public final class WriteArticleProcessor {
private final ArticleRepository articleRepository;
private final ArticleReader articleReader;
private final TagRepository tagRepository;
private final ArticleTagRepository articleTagRepository;
public WriteArticleProcessor(ArticleRepository articleRepository,
ArticleReader articleReader, TagRepository tagRepository,
ArticleTagRepository articleTagRepository) {
this.articleRepository = articleRepository;
this.articleReader = articleReader;
this.tagRepository = tagRepository;
this.articleTagRepository = articleTagRepository;
}
public void write(Long authorId, String title, String content,
String image, List<String> rawTags) {
if (articleReader.findByTitle(title) != null) {
throw new IllegalStateException("Duplicated title");
}
articleRepository.save(Article.write(authorId, title, content, image));
var newArticle = articleReader.findByTitle(title);
for (String rawTag : rawTags) {
Tag tag = Optional.ofNullable(tagRepository.findByName(rawTag))
.orElseGet(() -> {
tagRepository.save(new Tag(rawTag));
return tagRepository.findByName(rawTag);
});
articleTagRepository.save(new ArticleTag(newArticle.getId(), tag));
}
}
}

View File

@@ -1,45 +0,0 @@
package com.yam.app.article.infrastructure;
import com.yam.app.article.domain.ArticleReader;
import com.yam.app.article.domain.ArticleRepository;
import com.yam.app.article.domain.ArticleTagRepository;
import com.yam.app.article.domain.TagRepository;
import com.yam.app.article.domain.WriteArticleProcessor;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ArticleModuleConfiguration {
@Bean
public ArticleReader articleReader(SqlSessionTemplate template) {
return new MybatisArticleRepository(template);
}
@Bean
public ArticleRepository articleRepository(SqlSessionTemplate template) {
return new MybatisArticleRepository(template);
}
@Bean
public ArticleTagRepository articleTagRepository(SqlSessionTemplate template) {
return new MybatisArticleTagRepository(template);
}
@Bean
public TagRepository tagRepository(SqlSessionTemplate template) {
return new MybatisTagRepository(template);
}
@Bean
public WriteArticleProcessor writeArticleProcessor(
TagRepository tagRepository,
ArticleRepository articleRepository,
ArticleReader articleReader,
ArticleTagRepository articleTagRepository
) {
return new WriteArticleProcessor(articleRepository, articleReader, tagRepository,
articleTagRepository);
}
}

View File

@@ -1,50 +0,0 @@
package com.yam.app.article.infrastructure;
import com.yam.app.article.domain.Article;
import com.yam.app.article.domain.ArticleDto;
import com.yam.app.article.domain.ArticleReader;
import com.yam.app.article.domain.ArticleRepository;
import java.util.List;
import java.util.Optional;
import org.mybatis.spring.SqlSessionTemplate;
public final class MybatisArticleRepository implements ArticleReader, ArticleRepository {
private static final String SAVE_FQCN = "com.yam.app.article.domain.ArticleRepository.save";
private final SqlSessionTemplate template;
public MybatisArticleRepository(SqlSessionTemplate template) {
this.template = template;
}
@Override
public void save(Article entity) {
int result = template.insert(SAVE_FQCN, entity);
if (result != 1) {
throw new IllegalStateException(String.format(
"Unintentionally, more records were saved than expected. : %s", entity));
}
}
@Override
public Article findByTitle(String title) {
return template.getMapper(ArticleReader.class).findByTitle(title);
}
@Override
public Optional<Article> findById(Long articleId) {
return template.getMapper(ArticleReader.class).findById(articleId);
}
@Override
public boolean existsById(Long articleId) {
return template.getMapper(ArticleReader.class).existsById(articleId);
}
@Override
public List<ArticleDto> findAll(Long articleId, int pageSize) {
return template.getMapper(ArticleReader.class).findAll(articleId, pageSize);
}
}

View File

@@ -1,25 +0,0 @@
package com.yam.app.article.infrastructure;
import com.yam.app.article.domain.ArticleTag;
import com.yam.app.article.domain.ArticleTagRepository;
import org.mybatis.spring.SqlSessionTemplate;
public final class MybatisArticleTagRepository implements ArticleTagRepository {
private static final String SAVE_FQCN = "com.yam.app.article.domain.ArticleTagRepository.save";
private final SqlSessionTemplate template;
public MybatisArticleTagRepository(SqlSessionTemplate template) {
this.template = template;
}
@Override
public void save(ArticleTag entity) {
int result = template.insert(SAVE_FQCN, entity);
if (result != 1) {
throw new IllegalStateException(String.format(
"Unintentionally, more records were saved than expected. : %s", entity));
}
}
}

View File

@@ -1,30 +0,0 @@
package com.yam.app.article.infrastructure;
import com.yam.app.article.domain.Tag;
import com.yam.app.article.domain.TagRepository;
import org.mybatis.spring.SqlSessionTemplate;
public final class MybatisTagRepository implements TagRepository {
private static final String SAVE_FQCN = "com.yam.app.article.domain.TagRepository.save";
private final SqlSessionTemplate template;
public MybatisTagRepository(SqlSessionTemplate template) {
this.template = template;
}
@Override
public void save(Tag entity) {
int result = template.insert(SAVE_FQCN, entity);
if (result != 1) {
throw new IllegalStateException(String.format(
"Unintentionally, more records were saved than expected. : %s", entity));
}
}
@Override
public Tag findByName(String name) {
return template.getMapper(TagRepository.class).findByName(name);
}
}

View File

@@ -1,35 +0,0 @@
package com.yam.app.article.presentation;
import com.yam.app.article.application.ArticleFacade;
import com.yam.app.common.Authentication;
import com.yam.app.common.AuthenticationPrincipal;
import javax.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
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(
produces = MediaType.APPLICATION_JSON_VALUE,
consumes = MediaType.APPLICATION_JSON_VALUE
)
public final class ArticleCommandApi {
private final ArticleFacade articleFacade;
public ArticleCommandApi(ArticleFacade articleFacade) {
this.articleFacade = articleFacade;
}
@PostMapping("/api/articles/write")
public ResponseEntity<Void> writeArticle(
@RequestBody @Valid WriteArticleCommand command,
@AuthenticationPrincipal Authentication authentication) {
articleFacade.write(authentication.getMemberId(), command);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
}

View File

@@ -1,30 +0,0 @@
package com.yam.app.article.presentation;
import java.time.LocalDateTime;
import lombok.Getter;
@Getter
public final class ArticlePreviewResponse {
private final Long id;
private final Long authorId;
private final String title;
private final String nickname;
private final String memberImage;
private final LocalDateTime createdAt;
private final LocalDateTime modifiedAt;
private final String status;
public ArticlePreviewResponse(Long id, Long authorId, String title,
String nickname, String memberImage, LocalDateTime createdAt,
LocalDateTime modifiedAt, String status) {
this.id = id;
this.authorId = authorId;
this.title = title;
this.nickname = nickname;
this.memberImage = memberImage;
this.createdAt = createdAt;
this.modifiedAt = modifiedAt;
this.status = status;
}
}

View File

@@ -1,38 +0,0 @@
package com.yam.app.article.presentation;
import com.yam.app.article.application.ArticleFacade;
import com.yam.app.common.ApiResult;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping(
produces = MediaType.APPLICATION_JSON_VALUE,
consumes = MediaType.APPLICATION_JSON_VALUE
)
public final class ArticleQueryApi {
private final ArticleFacade articleFacade;
public ArticleQueryApi(ArticleFacade articleFacade) {
this.articleFacade = articleFacade;
}
@GetMapping("/api/articles/all")
public ResponseEntity<?> findAll(
@RequestParam(value = "articleId", defaultValue = "0") Long articleId,
@RequestParam(value = "pageSize", defaultValue = "20") int pageSize) {
return ResponseEntity.ok(
ApiResult.success(articleFacade.findAll(articleId, pageSize)));
}
@GetMapping("/api/articles/{articleId}")
public ResponseEntity<ArticleResponse> findArticle(@PathVariable Long articleId) {
return ResponseEntity.ok(articleFacade.findById(articleId));
}
}

View File

@@ -1,34 +0,0 @@
package com.yam.app.article.presentation;
import java.time.LocalDateTime;
import java.util.List;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public final class ArticleResponse {
private final Long id;
private final Long authorId;
private final String title;
private final String content;
private final String image;
private final LocalDateTime createdAt;
private final LocalDateTime modifiedAt;
private final List<TagResponse> tags;
private ArticleResponse(Long id, Long authorId, String title, String content, String image,
LocalDateTime createdAt, LocalDateTime modifiedAt,
List<TagResponse> tags) {
this.id = id;
this.authorId = authorId;
this.title = title;
this.content = content;
this.image = image;
this.createdAt = createdAt;
this.modifiedAt = modifiedAt;
this.tags = tags;
}
}

View File

@@ -1,20 +0,0 @@
package com.yam.app.article.presentation;
import lombok.Getter;
@Getter
public final class TagResponse {
private final Long id;
private final String name;
private TagResponse(Long id, String name) {
this.id = id;
this.name = name;
}
public static TagResponse of(Long id, String name) {
return new TagResponse(id, name);
}
}

View File

@@ -1,19 +0,0 @@
package com.yam.app.article.presentation;
import java.util.List;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
import lombok.Data;
@Data
public final class WriteArticleCommand {
@NotBlank
private String title;
@NotBlank
private String content;
@NotBlank
private String image;
@Size(max = 3)
private List<String> tags;
}

View File

@@ -1,81 +0,0 @@
package com.yam.app.comment.application;
import com.yam.app.article.domain.ArticleNotFoundException;
import com.yam.app.article.domain.ArticleReader;
import com.yam.app.comment.domain.Comment;
import com.yam.app.comment.domain.CommentProcessor;
import com.yam.app.comment.domain.CommentReader;
import com.yam.app.comment.presentation.CommentResponse;
import com.yam.app.comment.presentation.CreateCommentCommand;
import com.yam.app.comment.presentation.UpdateCommentCommand;
import com.yam.app.member.domain.MemberReader;
import com.yam.app.member.presentation.MemberResponse;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CommentFacade {
private final CommentProcessor commentProcessor;
private final ArticleReader articleReader;
private final CommentReader commentReader;
private final MemberReader memberReader;
public CommentFacade(CommentProcessor commentProcessor,
ArticleReader articleReader, CommentReader commentReader,
MemberReader memberReader) {
this.commentProcessor = commentProcessor;
this.articleReader = articleReader;
this.commentReader = commentReader;
this.memberReader = memberReader;
}
@Transactional
public Long create(CreateCommentCommand request, Long memberId) {
return commentProcessor.create(request.getContent(), request.getArticleId(), memberId);
}
@Transactional
public void update(UpdateCommentCommand request, Long commentId, Long memberId) {
commentProcessor.update(request.getContent(), commentId, memberId);
}
@Transactional
public void delete(Long commentId, Long memberId) {
commentProcessor.delete(commentId, memberId);
}
@Transactional(readOnly = true)
public List<CommentResponse> findByArticleId(Long articleId) {
if (!articleReader.existsById(articleId)) {
throw new ArticleNotFoundException(articleId);
}
return commentReader.findByArticleId(articleId)
.stream()
.filter(Comment::isAlive)
.map(this::toCommentResponse)
.collect(Collectors.toList());
}
private CommentResponse toCommentResponse(Comment comment) {
var member = memberReader.findById(comment.getMemberId())
.orElseThrow(IllegalStateException::new);
return CommentResponse.builder()
.id(comment.getId())
.articleId(comment.getArticleId())
.content(comment.getContent())
.createAt(comment.getCreatedAt())
.modifiedAt(comment.getModifiedAt())
.author(
MemberResponse.builder()
.id(member.getId())
.image(member.getImage())
.nickname(member.getNickname())
.build())
.build();
}
}

View File

@@ -1,60 +0,0 @@
package com.yam.app.comment.domain;
import static java.time.LocalDateTime.now;
import com.yam.app.common.EntityStatus;
import java.time.LocalDateTime;
import lombok.AccessLevel;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Getter
@ToString
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode(of = "id")
public final class Comment {
private Long id;
private String content;
private LocalDateTime createdAt;
private LocalDateTime modifiedAt;
private EntityStatus status = EntityStatus.ALIVE;
private Long articleId;
private Long memberId;
private Comment(String content, LocalDateTime createdAt, LocalDateTime modifiedAt,
Long articleId, Long memberId) {
this.content = content;
this.createdAt = createdAt;
this.modifiedAt = modifiedAt;
this.articleId = articleId;
this.memberId = memberId;
}
public static Comment of(String content, Long articleId, Long memberId) {
return new Comment(content, now(), now(), articleId, memberId);
}
public void update(String content) {
this.content = content;
this.modifiedAt = now();
}
public void delete() {
this.status = EntityStatus.DELETED;
}
public boolean isAlive() {
return this.status == EntityStatus.ALIVE;
}
public boolean isAuthor(Long memberId) {
return this.memberId.equals(memberId);
}
void setId(Long id) {
this.id = id;
}
}

View File

@@ -1,11 +0,0 @@
package com.yam.app.comment.domain;
import com.yam.app.common.EntityNotFoundException;
public final class CommentNotFoundException extends EntityNotFoundException {
public CommentNotFoundException(Long id) {
super("Comment could not be found, It may have been deleted.(id : %s)", id);
}
}

View File

@@ -1,62 +0,0 @@
package com.yam.app.comment.domain;
import com.yam.app.article.domain.ArticleNotFoundException;
import com.yam.app.article.domain.ArticleReader;
import com.yam.app.common.UnauthorizedRequestException;
public final class CommentProcessor {
private final CommentReader commentReader;
private final CommentRepository commentRepository;
private final ArticleReader articleReader;
public CommentProcessor(CommentReader commentReader,
CommentRepository commentRepository, ArticleReader articleReader) {
this.commentReader = commentReader;
this.commentRepository = commentRepository;
this.articleReader = articleReader;
}
public Long create(String content, Long articleId, Long memberId) {
if (!articleReader.existsById(articleId)) {
throw new ArticleNotFoundException(articleId);
}
return commentRepository.save(Comment.of(content, articleId, memberId));
}
public void update(String content, Long commentId, Long memberId) {
var comment = this.getComment(commentId, memberId);
if (!comment.isAlive()) {
throw new CommentNotFoundException(commentId);
}
comment.update(content);
commentRepository.update(comment);
}
public void delete(Long commentId, Long memberId) {
var comment = this.getComment(commentId, memberId);
comment.delete();
commentRepository.delete(comment);
}
private Comment getComment(Long commentId, Long memberId) {
var comment = commentReader.findById(commentId)
.orElseThrow(() -> new CommentNotFoundException(commentId));
if (!articleReader.existsById(comment.getArticleId())) {
throw new ArticleNotFoundException(comment.getArticleId());
}
if (!comment.isAuthor(memberId)) {
throw new UnauthorizedRequestException(
"Member is not the author of this comment, id: " + memberId);
}
return comment;
}
}

View File

@@ -1,13 +0,0 @@
package com.yam.app.comment.domain;
import java.util.List;
import java.util.Optional;
public interface CommentReader {
Optional<Comment> findById(Long commentId);
List<Comment> findByArticleId(Long articleId);
boolean existsById(Long commentId);
}

View File

@@ -1,11 +0,0 @@
package com.yam.app.comment.domain;
public interface CommentRepository {
Long save(Comment entity);
void update(Comment entity);
void delete(Comment entity);
}

View File

@@ -1,29 +0,0 @@
package com.yam.app.comment.infrastructure;
import com.yam.app.article.domain.ArticleReader;
import com.yam.app.comment.domain.CommentProcessor;
import com.yam.app.comment.domain.CommentReader;
import com.yam.app.comment.domain.CommentRepository;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class CommentModuleConfiguration {
@Bean
public CommentReader commentReader(SqlSessionTemplate template) {
return new MybatisCommentRepository(template);
}
@Bean
public CommentRepository commentRepository(SqlSessionTemplate template) {
return new MybatisCommentRepository(template);
}
@Bean
public CommentProcessor commentProcessor(CommentReader commentReader,
CommentRepository commentRepository, ArticleReader articleReader) {
return new CommentProcessor(commentReader, commentRepository, articleReader);
}
}

View File

@@ -1,66 +0,0 @@
package com.yam.app.comment.infrastructure;
import com.yam.app.comment.domain.Comment;
import com.yam.app.comment.domain.CommentReader;
import com.yam.app.comment.domain.CommentRepository;
import java.util.List;
import java.util.Optional;
import org.mybatis.spring.SqlSessionTemplate;
public final class MybatisCommentRepository implements CommentReader, CommentRepository {
private static final String SAVE_FQCN = "com.yam.app.comment.domain.CommentRepository.save";
private static final String UPDATE_FQCN = "com.yam.app.comment.domain.CommentRepository.update";
private static final String DELETE_FQCN = "com.yam.app.comment.domain.CommentRepository.delete";
private final SqlSessionTemplate template;
public MybatisCommentRepository(SqlSessionTemplate template) {
this.template = template;
}
@Override
public Long save(Comment entity) {
int result = template.insert(SAVE_FQCN, entity);
if (result != 1) {
throw new IllegalStateException(String.format(
"Unintentionally, more records were saved than expected. : %s", entity));
}
return entity.getId();
}
@Override
public void update(Comment entity) {
int result = template.update(UPDATE_FQCN, entity);
if (result != 1) {
throw new IllegalStateException(String.format(
"Unintentionally, more records were updated than expected. : %s", entity));
}
}
@Override
public void delete(Comment entity) {
int result = template.update(DELETE_FQCN, entity);
if (result != 1) {
throw new IllegalStateException(String.format(
"Unintentionally, more records were soft-deleted than expected. : %s", entity));
}
}
@Override
public Optional<Comment> findById(Long commentId) {
return template.getMapper(CommentReader.class).findById(commentId);
}
@Override
public List<Comment> findByArticleId(Long articleId) {
return template.getMapper(CommentReader.class).findByArticleId(articleId);
}
@Override
public boolean existsById(Long commentId) {
return template.getMapper(CommentReader.class).existsById(commentId);
}
}

View File

@@ -1,65 +0,0 @@
package com.yam.app.comment.presentation;
import com.yam.app.comment.application.CommentFacade;
import com.yam.app.common.Authentication;
import com.yam.app.common.AuthenticationPrincipal;
import java.net.URI;
import javax.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
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(
produces = MediaType.APPLICATION_JSON_VALUE,
consumes = MediaType.APPLICATION_JSON_VALUE
)
public final class CommentCommandApi {
private final CommentFacade commentFacade;
public CommentCommandApi(CommentFacade commentFacade) {
this.commentFacade = commentFacade;
}
@PostMapping("/api/comments/")
public ResponseEntity<Void> createComment(
@RequestBody @Valid CreateCommentCommand request,
@AuthenticationPrincipal Authentication authentication) {
final Long commentId = commentFacade.create(request, authentication.getMemberId());
return ResponseEntity
.created(URI.create("/comments/" + commentId))
.build();
}
@PatchMapping("api/comments/{commentId}")
public ResponseEntity<Void> updateComment(
@PathVariable Long commentId,
@RequestBody @Valid UpdateCommentCommand request,
@AuthenticationPrincipal Authentication authentication) {
commentFacade.update(request, commentId, authentication.getMemberId());
return ResponseEntity.ok().build();
}
@DeleteMapping("api/comments/{commentId}")
public ResponseEntity<Void> deleteComment(
@PathVariable Long commentId,
@AuthenticationPrincipal Authentication authentication) {
commentFacade.delete(commentId, authentication.getMemberId());
return ResponseEntity.ok().build();
}
}

View File

@@ -1,32 +0,0 @@
package com.yam.app.comment.presentation;
import com.yam.app.comment.application.CommentFacade;
import com.yam.app.common.ApiResult;
import java.util.List;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping(
produces = MediaType.APPLICATION_JSON_VALUE,
consumes = MediaType.APPLICATION_JSON_VALUE
)
public final class CommentQueryApi {
private final CommentFacade commentFacade;
public CommentQueryApi(CommentFacade commentFacade) {
this.commentFacade = commentFacade;
}
@GetMapping("/api/comments/{articleId}")
public ResponseEntity<ApiResult<List<CommentResponse>>> getComments(
@PathVariable Long articleId) {
return ResponseEntity.ok(
ApiResult.success(commentFacade.findByArticleId(articleId)));
}
}

View File

@@ -1,19 +0,0 @@
package com.yam.app.comment.presentation;
import com.yam.app.member.presentation.MemberResponse;
import java.time.LocalDateTime;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public final class CommentResponse {
private final Long id;
private final String content;
private final LocalDateTime createAt;
private final LocalDateTime modifiedAt;
private final Long articleId;
private final MemberResponse author;
}

View File

@@ -1,17 +0,0 @@
package com.yam.app.comment.presentation;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
@Data
public final class CreateCommentCommand {
@NotNull
private Long articleId;
@Length(max = 120)
@NotBlank(message = "댓글 내용은 비어 있을 수 없습니다.")
private String content;
}

View File

@@ -1,17 +0,0 @@
package com.yam.app.comment.presentation;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
@Data
public final class UpdateCommentCommand {
@NotNull
private Long commentId;
@Length(max = 120)
@NotBlank(message = "댓글 내용은 비어 있을 수 없습니다.")
private String content;
}

View File

@@ -1,25 +0,0 @@
package com.yam.app.common;
import lombok.Getter;
@Getter
public final class ApiResult<T> {
private final boolean success;
private final T data;
private final String message;
private ApiResult(boolean success, T data, String message) {
this.success = success;
this.data = data;
this.message = message;
}
public static <T> ApiResult<T> success(T data) {
return new ApiResult<>(true, data, null);
}
public static ApiResult<?> error(String message) {
return new ApiResult<>(false, null, message);
}
}

View File

@@ -1,12 +0,0 @@
package com.yam.app.common;
import java.io.Serializable;
public interface Authentication extends Serializable {
String getCredentials();
String getRole();
Long getMemberId();
}

View File

@@ -1,12 +0,0 @@
package com.yam.app.common;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthenticationPrincipal {
}

View File

@@ -1,15 +0,0 @@
package com.yam.app.common;
import org.springframework.http.HttpStatus;
public final class DuplicateValueException extends SystemException {
public DuplicateValueException(String value) {
super("These are duplicated value, (value : %s)", value);
}
@Override
public HttpStatus getStatus() {
return HttpStatus.CONFLICT;
}
}

View File

@@ -1,14 +0,0 @@
package com.yam.app.common;
import org.springframework.http.HttpStatus;
public class EntityNotFoundException extends SystemException {
public EntityNotFoundException(String format, Object... args) {
super(format, args);
}
public HttpStatus getStatus() {
return HttpStatus.NOT_FOUND;
}
}

View File

@@ -1,14 +0,0 @@
package com.yam.app.common;
import java.util.Arrays;
public enum EntityStatus {
ALIVE, DELETED;
public static EntityStatus findStatus(String status) {
return Arrays.stream(EntityStatus.values())
.filter(s -> s.name().equals(status))
.findFirst()
.orElseThrow(IllegalArgumentException::new);
}
}

View File

@@ -1,89 +0,0 @@
package com.yam.app.common;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindException;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
@Slf4j
@ControllerAdvice
public final class GlobalApiExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
private ResponseEntity<ApiResult<?>> handleMethod(MethodArgumentNotValidException e) {
log.error("MethodArgumentNotValidException", e);
return ResponseEntity
.badRequest()
.body(ApiResult.error("Invalid argument"));
}
@ExceptionHandler(BindException.class)
private ResponseEntity<ApiResult<?>> handleBind(BindException e) {
log.error("BindException", e);
return ResponseEntity
.badRequest()
.body(ApiResult.error("Invalid argument"));
}
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
private ResponseEntity<ApiResult<?>> handleNotSupported(HttpMediaTypeNotSupportedException e) {
log.error("HttpMediaTypeNotSupportedException", e);
return ResponseEntity
.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
.body(ApiResult.error("Http media type not supported"));
}
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
private ResponseEntity<ApiResult<?>> handleNotSupported(
HttpRequestMethodNotSupportedException e) {
log.error("HttpRequestMethodNotSupportedException", e);
return ResponseEntity
.status(HttpStatus.METHOD_NOT_ALLOWED)
.body(ApiResult.error("Http request method not supported"));
}
@ExceptionHandler(Exception.class)
private ResponseEntity<ApiResult<?>> handleException(Exception e) {
log.error("Exception", e);
return ResponseEntity
.internalServerError()
.body(ApiResult.error("Internal server error"));
}
@ExceptionHandler(EntityNotFoundException.class)
private ResponseEntity<ApiResult<?>> handleNotFound(EntityNotFoundException e) {
log.error("EntityNotFoundException", e);
return ResponseEntity
.status(e.getStatus())
.body(ApiResult.error("Not found resources"));
}
@ExceptionHandler(DuplicateValueException.class)
private ResponseEntity<ApiResult<?>> handleDuplicate(DuplicateValueException e) {
log.error("DuplicateValueException", e);
return ResponseEntity
.status(e.getStatus())
.body(ApiResult.error("Duplicated value"));
}
@ExceptionHandler(IllegalStateException.class)
private ResponseEntity<ApiResult<?>> handleIllegalState(IllegalStateException e) {
log.error("IllegalStateException", e);
return ResponseEntity
.badRequest()
.body(ApiResult.error(e.getMessage()));
}
@ExceptionHandler(UnauthorizedRequestException.class)
private ResponseEntity<ApiResult<?>> handleUnauthorized(UnauthorizedRequestException e) {
log.error("UnauthorizedRequestException", e);
return ResponseEntity
.status(e.getStatus())
.body(ApiResult.error(e.getMessage()));
}
}

View File

@@ -1,12 +0,0 @@
package com.yam.app.common;
import org.springframework.http.HttpStatus;
public abstract class SystemException extends RuntimeException {
public SystemException(String format, Object... args) {
super(String.format(format, args));
}
public abstract HttpStatus getStatus();
}

View File

@@ -1,15 +0,0 @@
package com.yam.app.common;
import org.springframework.http.HttpStatus;
public final class UnauthorizedRequestException extends SystemException {
public UnauthorizedRequestException(String message) {
super(message);
}
@Override
public HttpStatus getStatus() {
return HttpStatus.UNAUTHORIZED;
}
}

View File

@@ -1,36 +0,0 @@
package com.yam.app.common.configuration;
import java.io.IOException;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import redis.embedded.RedisServer;
@Slf4j
@Profile({"test", "local"})
@Configuration
public class EmbeddedRedisConfiguration {
@Value("${spring.redis.port}")
private int redisPort;
private RedisServer redisServer;
@PostConstruct
public void redisServer() throws IOException {
redisServer = RedisServer.builder()
.port(redisPort)
.build();
redisServer.start();
}
@PreDestroy
public void stopRedis() {
if (redisServer != null) {
redisServer.stop();
}
}
}

View File

@@ -1,35 +0,0 @@
package com.yam.app.common.configuration;
import com.yam.app.common.EntityStatus;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedTypes;
import org.apache.ibatis.type.TypeHandler;
@MappedTypes(EntityStatusTypeHandler.class)
public final class EntityStatusTypeHandler implements TypeHandler<EntityStatus> {
@Override
public void setParameter(PreparedStatement ps, int i,
EntityStatus parameter, JdbcType jdbcType) throws SQLException {
ps.setString(i, parameter.name());
}
@Override
public EntityStatus getResult(ResultSet rs, String columnName) throws SQLException {
return EntityStatus.findStatus(rs.getString(columnName));
}
@Override
public EntityStatus getResult(ResultSet rs, int columnIndex) throws SQLException {
return EntityStatus.findStatus(rs.getString(columnIndex));
}
@Override
public EntityStatus getResult(CallableStatement cs, int columnIndex) throws SQLException {
return EntityStatus.findStatus(cs.getString(columnIndex));
}
}

View File

@@ -1,39 +0,0 @@
package com.yam.app.common.configuration;
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.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.session.data.redis.config.ConfigureRedisAction;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
@Configuration
@EnableRedisHttpSession
public class RedisConfiguration {
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
private int redisPort;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(redisHost, redisPort);
}
@Bean
public RedisTemplate<byte[], byte[]> redisTemplate() {
RedisTemplate<byte[], byte[]> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
return redisTemplate;
}
@Bean
public ConfigureRedisAction configureRedisAction() {
return ConfigureRedisAction.NO_OP;
}
}

View File

@@ -1,4 +1,4 @@
package com.yam.app.common.configuration;
package com.yam.app.configuration;
import javax.sql.DataSource;
import org.apache.ibatis.session.SqlSessionFactory;
@@ -6,10 +6,20 @@ import org.mybatis.spring.SqlSessionFactoryBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
@Configuration
public class DatabaseConfiguration {
@Bean
public DataSource dataSource() {
return new EmbeddedDatabaseBuilder()
.setType(EmbeddedDatabaseType.H2)
.addScript("classpath:sql/ddl.sql")
.build();
}
@Bean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
var factoryBean = new SqlSessionFactoryBean();

View File

@@ -1,15 +0,0 @@
package com.yam.app.member.domain;
import lombok.Getter;
@Getter
public final class GenerateMemberEvent {
private final Long memberId;
private final String email;
public GenerateMemberEvent(Long memberId, String email) {
this.memberId = memberId;
this.email = email;
}
}

View File

@@ -1,28 +0,0 @@
package com.yam.app.member.domain;
import com.yam.app.common.EntityStatus;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Getter
@ToString
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public final class Member {
private Long id;
private String nickname;
private String image;
private EntityStatus status = EntityStatus.ALIVE;
public Member(String nickname, String image) {
this.nickname = nickname;
this.image = image;
}
public void changeProfile(String nickname, String image) {
this.nickname = nickname;
this.image = image;
}
}

View File

@@ -1,10 +0,0 @@
package com.yam.app.member.domain;
import java.util.Optional;
public interface MemberReader {
boolean existsByNickname(String nickname);
Optional<Member> findById(Long memberId);
}

View File

@@ -1,8 +0,0 @@
package com.yam.app.member.domain;
public interface MemberRepository {
Long save(Member entity);
void update(Member entity);
}

View File

@@ -1,13 +0,0 @@
package com.yam.app.member.domain;
import lombok.Getter;
@Getter
public final class RegisterAccountConfirmEvent {
private final String email;
public RegisterAccountConfirmEvent(String email) {
this.email = email;
}
}

View File

@@ -1,17 +0,0 @@
package com.yam.app.member.domain;
import lombok.Getter;
@Getter
public final class UpdateAccountEvent {
private final Long memberId;
private final String nickname;
private final String image;
public UpdateAccountEvent(Long memberId, String nickname, String image) {
this.memberId = memberId;
this.nickname = nickname;
this.image = image;
}
}

View File

@@ -1,49 +0,0 @@
package com.yam.app.member.infrastructure;
import com.yam.app.member.domain.GenerateMemberEvent;
import com.yam.app.member.domain.Member;
import com.yam.app.member.domain.MemberReader;
import com.yam.app.member.domain.MemberRepository;
import com.yam.app.member.domain.RegisterAccountConfirmEvent;
import com.yam.app.member.domain.UpdateAccountEvent;
import java.util.UUID;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
public class MemberEventListener {
private final MemberReader memberReader;
private final MemberRepository memberRepository;
private final ApplicationEventPublisher publisher;
public MemberEventListener(MemberReader memberReader,
MemberRepository memberRepository,
ApplicationEventPublisher publisher) {
this.memberReader = memberReader;
this.memberRepository = memberRepository;
this.publisher = publisher;
}
@EventListener
public void handle(RegisterAccountConfirmEvent event) {
var nickname = event.getEmail().split("@")[0];
Long savedEntityId;
if (memberReader.existsByNickname(nickname)) {
savedEntityId = memberRepository.save(
new Member(UUID.randomUUID().toString(), "temp.png"));
} else {
savedEntityId = memberRepository.save(new Member(nickname, "temp.png"));
}
publisher.publishEvent(new GenerateMemberEvent(savedEntityId, event.getEmail()));
}
@EventListener
public void handle(UpdateAccountEvent event) {
var member = memberReader.findById(event.getMemberId())
.orElseThrow(IllegalArgumentException::new);
member.changeProfile(event.getNickname(), event.getImage());
memberRepository.update(member);
}
}

Some files were not shown because too many files have changed in this diff Show More