Compare commits

87 Commits

Author SHA1 Message Date
Rebwon
bb6971fd8a Downgrade version 2022-02-15 20:22:42 +09:00
Rebwon
8f9c5c4ce3 Refactor Module Configuration 2022-02-15 20:22:42 +09:00
Rebwon
896695d290 refactor paging query no offset 2021-11-02 21:11:34 +09:00
JiwonDev
ecb023f087 Refactor EmbeddedRedis
- 로컬 빌드는 성공하는데, CI 빌드가 실패해서 수정내역을 롤백했습니다.
2021-11-01 20:00:11 +09:00
JiwonDev
b4c9aebc36 ADD Readme, Refactor EmbeddedRedis
- 로컬 환경에서 테스트용 EmbeddedRedis 가 생성되지 않아 설정변경
  1. EmbeddedRedis maxheap size를 1gb로 고정
  2. 테스트 환경에서 포트번호가 충돌하지 않도록 포트번호 변경(6379->16379)
2021-11-01 20:00:11 +09:00
Rebwon
b3f389f827 Refactor Application DB Schema 2021-11-01 18:36:37 +09:00
Rebwon
9839df9b4c Refactor Sql Schema, Confirm Email Process 2021-11-01 11:38:23 +09:00
Rebwon
6df8cd3fcf Using Spring Text Context Caching 2021-11-01 11:38:23 +09:00
Rebwon
3178369fb8 Extract log4j2.xml profiling 2021-11-01 11:38:23 +09:00
JiwonDev
e1dcf7ad76 Refactor code
- 인터셉터 Url 패턴으로는 Query 와 Command 를 구분하지 못해서 핸들러 리졸버에 session null 확인을 추가하였습니다.
2021-10-28 11:15:03 +09:00
JiwonDev
e88bad95df Add comment query
게시글 댓글 조회 추가
테스트 추가
2021-10-28 11:15:03 +09:00
JiwonDev
3af4ef973f Add article query
게시글 단건 조회 추가
2021-10-28 11:15:03 +09:00
Rebwon
d5dd215dfd Refactor file path 2021-10-23 17:49:01 +09:00
Rebwon
dce2c22054 Spring Boot Admin Monitoring log files 2021-10-23 17:31:42 +09:00
Rebwon
4a3967aa97 Refactor Account domain implements Serializable 2021-10-21 17:31:04 +09:00
Rebwon
a28df24608 Refactor SessionManager using Authentication 2021-10-21 17:11:07 +09:00
Rebwon
5408213ab2 Add mail health check false 2021-10-21 16:48:16 +09:00
Rebwon
0800e79784 Refactor actuator health show details always 2021-10-21 16:48:16 +09:00
Rebwon
24e9a417a0 Feature hello api 2021-10-21 16:31:08 +09:00
Rebwon
71e1fb28fe Add actuator 2021-10-21 16:00:39 +09:00
Rebwon
40c6682b8a Refactor Exclude url pattern add actuator 2021-10-21 15:51:31 +09:00
Rebwon
83508038c4 Remove spring boot admin server url 2021-10-21 15:41:23 +09:00
Rebwon
1fb67badcd Add SpringBootAdmin Client Settings 2021-10-21 15:18:39 +09:00
Rebwon
5291eaae93 Add SpringBootAdmin Monitoring 2021-10-21 14:53:14 +09:00
Rebwon
81cb45d74a Refactor mapper query 2021-10-21 14:36:00 +09:00
Rebwon
6cf5df7ca7 refactor yml 2021-10-21 12:54:03 +09:00
Rebwon
ab56aa1a04 remove profile settings 2021-10-21 12:37:57 +09:00
Rebwon
357e2c61d9 Refactor spring session redis config 2021-10-21 12:24:03 +09:00
Rebwon
ec4f4323fc add server port 2021-10-21 12:09:51 +09:00
Rebwon
bae2788a6b Remove jenkinsfile 2021-10-21 11:14:47 +09:00
Rebwon
22271b6df4 Refactor query 2021-10-21 11:14:47 +09:00
Rebwon
bd4cdb5f97 Settings jenkins Ci 2021-10-20 17:17:57 +09:00
Rebwon
58c7e8922a Implements article pagination query HTTP API 2021-10-18 16:46:20 +09:00
Rebwon
f15ea65c06 Implements Article Pagination Query 2021-10-18 16:46:20 +09:00
Rebwon
00e454b20b Separate Application Profiles Using Local MySQL DB. 2021-10-18 16:46:20 +09:00
Rebwon
aed3b8805f Refactor Account Query API
- change to query with profile information when query account
  information.
2021-10-18 16:46:20 +09:00
Rebwon
a63d43ed66 Refactor mybatis data access cud operation excepted class. 2021-10-18 16:46:20 +09:00
Rebwon
865606ca68 Refactor code
- remove unnecessary test code
- remove comment domain service update assert statement
2021-10-11 19:34:22 +09:00
JiwonDev
a9d7523f10 Refactor Code
- Runtime Exception을 IllegalStateException으로 변경
- apache.commons.lang3 의존성 추가
- 문자열 테스트에서 apache.commons.lang3.RandomStringUtils 를 사용하도록 변경
2021-10-11 12:14:27 +09:00
JiwonDev
0d8d537ab5 ADD delete Comment HTTP API
- Test 이름, DisplayName 오타 수정 (update api:: "articleId" -> "commentId")
- 잘못된 Delete Comment 쿼리문 수정 (UPDATE COMMNET -> UPDATE COMMENT)
2021-10-11 12:14:27 +09:00
JiwonDev
938f5600bd ADD create & update Comment HTTP API, Refactor code
- LAST_INSERT_ID() 를 사용하기 위해 H2DB 설정에 mode=MYSQL 추가.
- save()가 Long commentId 를 반환하도록 댓글 도메인 수정.
2021-10-11 12:14:27 +09:00
JiwonDev
1537cb4694 Refactor Code
- Member 검증 추가
2021-10-09 13:16:22 +09:00
JiwonDev
feadb3f255 ADD Comment domain, ADD Article.existsById()
- comment 테스트용 DB DML 추가
- Article 모듈에 existsById() 추가
2021-10-09 13:16:22 +09:00
Rebwon
21e6162d11 Implements Wrtie Article HTTP API 2021-10-05 19:55:40 +09:00
Rebwon
1023c8cded Implements Article Query Mapper 2021-10-05 19:55:40 +09:00
Rebwon
7b4f275643 Implements Write Article UseCase 2021-10-05 19:55:40 +09:00
Rebwon
290c09b435 Add DB Schema 2021-10-01 12:34:24 +09:00
Rebwon
cb78015db1 Implements Update Account UseCase
- Implements update account usecase
- Add account, member domain event
2021-09-30 20:03:39 +09:00
Rebwon
fd5a1b53bc Add Update Account HTTP API
- Add Command API test
- Refactor Interceptor, SessionManager, ArgumentResolver
2021-09-30 20:03:39 +09:00
Rebwon
f91b823bac Refactor code 2021-09-30 12:08:11 +09:00
Rebwon
bf8d021588 Email Confirm API Media Type changed 2021-09-28 11:51:27 +09:00
Rebwon
9dc2d0418d Refactor code
- rename DomainEventTranslator method
- Add Authentication object
2021-09-28 11:51:27 +09:00
Rebwon
b8eecc0b67 Configures to add default member domain values only to email confirmed
users.

- using spring domain event
- add adapter layer
- add EventListener
- modulization account, member
2021-09-27 20:54:15 +09:00
Rebwon
989857c43d Using @Async annotation
- Changed synchronous mail transfer to asynchronous.
2021-09-27 11:17:46 +09:00
Rebwon
4aace5f571 Refactor code
- refactor test code
- refactor sessionManager
2021-09-27 11:08:03 +09:00
JiwonDev
f5bcfb438f Refactor code
- PR 리뷰 반영
2021-09-26 21:25:37 +09:00
JiwonDev
7f1434bf04 ADD logout, Refactor Test
- 필요없는 중복 테스트 삭제
- 401 인증 관련 테스트 Assert 를 Error URI Forward 로 수정
- LoginAccountMethodArgumentResolver 에서 세션 null 체크 삭제 ➡ SessionAuthInterceptor 에서 세션 null 체크 역할 담당.
2021-09-26 21:25:37 +09:00
JiwonDev
36d67b4e45 ADD Separate auth request url
- api uri 문자열을 AccountApiUri 클래스로 이동.
- AccountApiUri 를 interface 에서 final static 으로 변경
2021-09-26 21:25:37 +09:00
Rebwon
6533b75407 Refactor code
- Rename AppConfiguration -> AccountModuleConfiguration
- Refactor RegisterAccountProcessor using cqs principle
2021-09-25 14:54:54 +09:00
Rebwon
6c5ada53a7 Add member db schema
- add member db schema
- rename queryMapper
- extract ddl, dml script
2021-09-25 12:30:20 +09:00
Rebwon
09bf319778 Refactor code
- Exclude account domain property nickname
- Add account response property
2021-09-24 10:55:28 +09:00
Rebwon
107f3aa91d It manages the storage and inquiry of sessions using an external session
repository.

- Enable Redis Http Session
- Embedded Redis Settings
- Refactor integration tests
2021-09-22 18:37:59 +09:00
Rebwon
eaa84b2a50 Return Query Api resources in ApiResult 2021-09-22 14:21:37 +09:00
Rebwon
f20b50601b Add classes to handle exceptions that occur in the Presentation layer 2021-09-22 14:21:37 +09:00
Rebwon
8cb9d52d8b Refactor repository 2021-09-17 13:25:51 +09:00
Rebwon
6e62b56888 Refactor code
- Refactor to ensure consistency of code using CQS.
2021-09-17 13:25:51 +09:00
Rebwon
de943bb1a2 Refactor SessionManager 2021-09-15 22:11:53 +09:00
Rebwon
3f5bb61227 Refactor code
- remove unnecessary code
- moving package
2021-09-15 22:11:53 +09:00
Rebwon
b9e1e52de6 Refactor code
- Remove util class
- Add SessionManager
- Refactor test code
2021-09-14 12:05:44 +09:00
JiwonDev
d877744afd Refectory Session maintaining
- LoginSessionUtils 가 세션을 관리하도록 리펙토링
- @LoginAccount 가 AccountPrincipal 을 반환하도록 수정
- 테스트용 ExceptionHandler 삭제, ArgumentResolver 에서 예외 대신 null 을 반환하도록 수정.
- ArchUnitTests에서 프레젠테이션이 인프라 영역을 사용할 수 있도록 테스트 검증 수정
2021-09-14 09:16:46 +09:00
JiwonDev
363f2b5375 Refectory add-session, session maintaining
- 리뷰 반영, Test 수정
- J2EE CWE-579를 반영하여 세션에 저장할 객체를 Serializable 처리.
2021-09-14 09:16:46 +09:00
JiwonDev
d3ff00e167 ADD session maintaining
- @LoginAccount 파라메타를 command 객체로 바꾸는 ArgumentResolver 추가
- .editorconfig 에서 로그파일 (app.log)를 무시하도록 설정 추가
2021-09-14 09:16:46 +09:00
Rebwon
72c25e23ee Add settings to enable logging services 2021-09-10 21:42:42 +09:00
JiwonDev
41f50cea90 Refactor LoginAccountProcessor
- 이메일 검증 테스트 코드 추가
2021-09-10 19:35:58 +09:00
JiwonDev
fe963ec128 ADD SessionUtils
- LoginAccountProcessor 누락된 이메일 검증 추가
- build.grade 필요없는 주석 삭제
2021-09-10 19:35:58 +09:00
JiwonDev
85d777d5b6 ADD Code Coverage - Jacoco 2021-09-08 22:30:49 +09:00
Rebwon
7dff04cb89 Remove whitebox test 2021-09-08 22:14:44 +09:00
Rebwon
ca12815e42 Refactor code 2021-09-08 22:14:44 +09:00
JiwonDev
6841f40cc9 ADD AccountQueryApiTest, FIX LoginAccountProcessor
- 리뷰 반영, Session 과 관련된 주석 및 파라미터 제거
- LoginAccountProcessor 에서 암호문을 올바르게 검증하도록 수정.
2021-09-08 18:39:55 +09:00
JiwonDev
1f954f92ad ADD LoginAccountProcessor
- SessionUtils 삭제 - 이슈를 나눠 다른 PR에서 구현.
2021-09-08 18:39:55 +09:00
JiwonDev
a6f307becb ADD Login HTTP API 2021-09-08 18:39:55 +09:00
Rebwon
8448c62343 Add permissions for each account 2021-09-07 19:04:00 +09:00
Rebwon
6f21844d23 Refactor code
- Using CQRS Patterns in AccountReader and AccountRepository
- Refactoring MybatisAccountRepository and remove String + operators
- Modify multiple test codes.
2021-09-07 15:21:56 +09:00
Jiwon
9f8bd0c173 Merge pull request #29 from f-lab-edu/feature-verify-email-token 2021-09-07 13:52:58 +09:00
JiwonDev
207c54120c Refactor ConfirmRegisterAccountProcessor, API
- 기존 TokenVerifier에 토큰 검증과 회원 인증책임을 분리
- ConfirmRegisterAccountProcessor를 추가
- verify 메서드가 void를 반환하도록 변경
- 테스트에서 MailTokenVerifierStub 제거
2021-09-06 07:57:21 +09:00
JiwonDev
7cdc352d84 ADD TokenVerifier Domain Service
- Common 패키지에 StringUtils 추가
2021-09-04 17:27:55 +09:00
JiwonDev
b0fe8213fb ADD AccountRepository update & TokenVerifier HTTP API
- MybatisAccountRepository update 구현
- RegisterAccountApi -> AccountCommandApi 변경
- 테스트용 TestAccountRepositoryStub 분리
2021-09-04 17:17:49 +09:00
158 changed files with 5297 additions and 587 deletions

View File

@@ -8,6 +8,15 @@ 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,3 +35,8 @@ out/
### VS Code ###
.vscode/
/logs
/logs/*.log
/src/main/resources/application-prod.yml

View File

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

View File

@@ -1,7 +1,8 @@
import com.github.spotbugs.snom.SpotBugsTask
plugins {
id 'org.springframework.boot' version '2.5.4'
id 'jacoco'
id 'org.springframework.boot' version '2.5.3'
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'
@@ -18,6 +19,9 @@ configurations {
compileOnly {
extendsFrom annotationProcessor
}
all {
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging'
}
}
repositories {
@@ -38,30 +42,92 @@ dependencies {
annotationProcessor 'org.projectlombok:lombok'
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.0'
runtimeOnly 'com.h2database:h2'
testImplementation 'com.h2database:h2'
runtimeOnly 'mysql:mysql-connector-java'
implementation 'com.lmax:disruptor:3.4.4'
implementation 'org.springframework.boot:spring-boot-starter-log4j2'
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'
testImplementation 'io.github.javaunit:autoparams:0.2.12'
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 'com.tngtech.archunit:archunit-junit5:0.20.1'
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 {

1
lombok.config Normal file
View File

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

View File

@@ -0,0 +1,13 @@
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,9 +1,20 @@
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.RegisterAccountRequest;
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 org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -11,22 +22,59 @@ import org.springframework.transaction.annotation.Transactional;
@Service
public class AccountFacade {
private final RegisterAccountProcessor processor;
private final AccountTranslator translator;
private final RegisterAccountProcessor registerProcessor;
private final ApplicationEventPublisher publisher;
private final ConfirmRegisterAccountProcessor confirmRegisterProcessor;
private final LoginAccountProcessor loginProcessor;
private final AccountReader accountReader;
private final UpdateAccountProcessor updateProcessor;
public AccountFacade(RegisterAccountProcessor processor,
AccountTranslator translator,
ApplicationEventPublisher publisher) {
this.processor = processor;
this.translator = translator;
public AccountFacade(RegisterAccountProcessor registerProcessor,
ApplicationEventPublisher publisher,
ConfirmRegisterAccountProcessor confirmRegisterProcessor,
LoginAccountProcessor loginProcessor, AccountReader accountReader,
UpdateAccountProcessor updateProcessor) {
this.registerProcessor = registerProcessor;
this.publisher = publisher;
this.confirmRegisterProcessor = confirmRegisterProcessor;
this.loginProcessor = loginProcessor;
this.accountReader = accountReader;
this.updateProcessor = updateProcessor;
}
@Transactional
public AccountResponse register(RegisterAccountRequest request) {
var entity = processor.process(translator.toCommand(request));
public void register(RegisterAccountCommand command) {
registerProcessor.register(command.getEmail(), command.getPassword());
var entity = accountReader.findByEmail(command.getEmail())
.orElseThrow(() -> new AccountNotFoundException(command.getEmail()));
publisher.publishEvent(new RegisterAccountEvent(entity));
return translator.toResponse(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()));
}
}

View File

@@ -1,20 +0,0 @@
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

@@ -1,17 +0,0 @@
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,17 +1,22 @@
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")
public final class Account {
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public final class Account implements Serializable {
private Long id;
private Long memberId;
private String email;
private String nickname;
private String password;
private String emailCheckToken;
private LocalDateTime emailCheckTokenGeneratedAt;
@@ -20,15 +25,17 @@ public final class Account {
private LocalDateTime lastModifiedAt;
private LocalDateTime withdrawalAt;
private boolean withdraw = false;
private Role role;
private EntityStatus status = EntityStatus.ALIVE;
private Account(String email, String nickname, String password) {
private Account(String email, String password) {
this.email = email;
this.nickname = nickname;
this.password = password;
this.role = Role.DEFAULT;
}
public static Account of(String email, String nickname, String password) {
Account account = new Account(email, nickname, password);
public static Account of(String email, String password) {
Account account = new Account(email, password);
account.generateEmailCheckToken();
return account;
}
@@ -50,4 +57,12 @@ public final class Account {
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

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

View File

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

View File

@@ -0,0 +1,23 @@
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

@@ -0,0 +1,15 @@
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

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

View File

@@ -0,0 +1,19 @@
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

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

View File

@@ -0,0 +1,14 @@
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

@@ -0,0 +1,19 @@
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

@@ -0,0 +1,17 @@
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

@@ -0,0 +1,23 @@
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

@@ -0,0 +1,29 @@
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

@@ -0,0 +1,38 @@
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

@@ -0,0 +1,28 @@
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

@@ -1,49 +0,0 @@
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

@@ -0,0 +1,35 @@
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,13 +2,14 @@ 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;
@Component
final class MailManager {
class MailManager {
private final MailDispatcher mailDispatcher;
private final TemplateEngine templateEngine;
@@ -21,14 +22,16 @@ final class MailManager {
this.host = host;
}
@EventListener
@Async
@TransactionalEventListener
public void handle(RegisterAccountEvent event) {
var newAccount = event.getAccount();
var context = new Context();
context.setVariable("link",
"/api/check-email?token=" + newAccount.getEmailCheckToken()
"/api/accounts/authorize?token=" + newAccount.getEmailCheckToken()
+ "&email=" + newAccount.getEmail());
context.setVariable("nickname", newAccount.getNickname());
var username = newAccount.getEmail().split("@")[0];
context.setVariable("username", username);
context.setVariable("linkName", "이메일 인증하기");
context.setVariable("message", "YouAndMe 서비스를 사용하려면 링크를 클릭하세요.");
context.setVariable("host", host);

View File

@@ -3,14 +3,16 @@ 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 final SqlSessionTemplate template;
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 static final String COMMAND_NAMESPACE = "com.yam.app.account.domain.AccountRepository.";
private static final String READER_NAMESPACE = "com.yam.app.account.domain.AccountReader.";
private final SqlSessionTemplate template;
public MybatisAccountRepository(SqlSessionTemplate template) {
this.template = template;
@@ -18,28 +20,35 @@ public final class MybatisAccountRepository implements AccountRepository, Accoun
@Override
public boolean existsByEmail(String email) {
int result = template.selectOne(COMMAND_NAMESPACE + "existsByEmail", email);
return result != 0;
return template.getMapper(AccountReader.class).existsByEmail(email);
}
@Override
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);
public void update(Account entity) {
int result = template.update(UPDATE_FQCN, entity);
if (result != 1) {
throw new RuntimeException(
String.format("There was a problem saving the object : %s", entity));
throw new IllegalStateException(String.format(
"Unintentionally, more records were updated than expected. : %s", entity));
}
return findByEmail(entity.getEmail());
}
@Override
public Account findByEmail(String email) {
return template.selectOne(READER_NAMESPACE + "findByEmail", email);
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);
}
}

View File

@@ -0,0 +1,35 @@
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

@@ -0,0 +1,21 @@
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

@@ -0,0 +1,37 @@
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

@@ -0,0 +1,29 @@
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

@@ -0,0 +1,33 @@
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

@@ -0,0 +1,79 @@
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

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

View File

@@ -8,10 +8,12 @@ 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) {
public AccountResponse(Long id, String email, String nickname, String image) {
this.id = id;
this.email = email;
this.nickname = nickname;
this.image = image;
}
}

View File

@@ -0,0 +1,16 @@
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

@@ -0,0 +1,20 @@
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

@@ -0,0 +1,21 @@
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

@@ -1,21 +0,0 @@
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

@@ -0,0 +1,21 @@
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

@@ -0,0 +1,38 @@
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

@@ -0,0 +1,65 @@
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

@@ -0,0 +1,49 @@
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

@@ -0,0 +1,17 @@
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

@@ -0,0 +1,11 @@
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

@@ -0,0 +1,17 @@
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

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

View File

@@ -0,0 +1,25 @@
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

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

View File

@@ -0,0 +1,23 @@
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

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

View File

@@ -0,0 +1,39 @@
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

@@ -0,0 +1,45 @@
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

@@ -0,0 +1,50 @@
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

@@ -0,0 +1,25 @@
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

@@ -0,0 +1,30 @@
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

@@ -0,0 +1,35 @@
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

@@ -0,0 +1,30 @@
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

@@ -0,0 +1,38 @@
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

@@ -0,0 +1,34 @@
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

@@ -0,0 +1,20 @@
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

@@ -0,0 +1,19 @@
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

@@ -0,0 +1,81 @@
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

@@ -0,0 +1,60 @@
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

@@ -0,0 +1,11 @@
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

@@ -0,0 +1,62 @@
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

@@ -0,0 +1,13 @@
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

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

View File

@@ -0,0 +1,29 @@
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

@@ -0,0 +1,66 @@
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

@@ -0,0 +1,65 @@
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

@@ -0,0 +1,32 @@
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

@@ -0,0 +1,19 @@
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

@@ -0,0 +1,17 @@
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

@@ -0,0 +1,17 @@
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

@@ -0,0 +1,25 @@
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

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

View File

@@ -0,0 +1,12 @@
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

@@ -0,0 +1,15 @@
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

@@ -0,0 +1,14 @@
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

@@ -0,0 +1,14 @@
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

@@ -0,0 +1,89 @@
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

@@ -0,0 +1,12 @@
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

@@ -0,0 +1,15 @@
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

@@ -0,0 +1,28 @@
package com.yam.app.common.configuration;
import java.util.concurrent.Executor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@Slf4j
@EnableAsync
@Configuration
public class AsyncConfiguration implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
var executor = new ThreadPoolTaskExecutor();
int processors = Runtime.getRuntime().availableProcessors();
log.info("processors count {}", processors);
executor.setCorePoolSize(processors);
executor.setMaxPoolSize(processors * 2);
executor.setQueueCapacity(50);
executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix("AsyncExecutor-");
executor.initialize();
return executor;
}
}

View File

@@ -1,4 +1,4 @@
package com.yam.app.configuration;
package com.yam.app.common.configuration;
import javax.sql.DataSource;
import org.apache.ibatis.session.SqlSessionFactory;
@@ -6,20 +6,10 @@ 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

@@ -0,0 +1,36 @@
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

@@ -0,0 +1,35 @@
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

@@ -0,0 +1,39 @@
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

@@ -0,0 +1,15 @@
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

@@ -0,0 +1,28 @@
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

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

View File

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

View File

@@ -0,0 +1,13 @@
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

@@ -0,0 +1,17 @@
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

@@ -0,0 +1,49 @@
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