Skip to content

Commit 7d752c0

Browse files
holly000claude
andcommitted
Merge branch 'main' into feature/post-crud
충돌 해소: - User.java: Auth 팀 구현(origin/main) 수용 - passwordHash, name, provider, deleted 필드 포함 - UserRole.java: origin/main 스타일 수용 (내용 동일) - UserRepository.java: origin/main 수용 - existsByEmail, existsByNickname 추가 ErrorCode 통합: - Auth 팀 추가분(DUPLICATE_EMAIL, DUPLICATE_NICKNAME) 유지 - Post CRUD 필요분(UNAUTHORIZED, FORBIDDEN, POST_NOT_FOUND) 추가 Post CRUD 파일 추가: - domain/post 전체 (PostType, Post, DTOs, Repository, Service, Controller) - global/security/SecurityUtil Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 parents 08c992e + 24ad8a9 commit 7d752c0

22 files changed

Lines changed: 785 additions & 30 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.bootsignal.domain.auth.controller;
2+
3+
import com.bootsignal.domain.auth.dto.SignupRequest;
4+
import com.bootsignal.domain.auth.dto.SignupResponse;
5+
import com.bootsignal.domain.auth.service.AuthService;
6+
import jakarta.validation.Valid;
7+
import lombok.RequiredArgsConstructor;
8+
import org.springframework.http.HttpStatus;
9+
import org.springframework.web.bind.annotation.PostMapping;
10+
import org.springframework.web.bind.annotation.RequestBody;
11+
import org.springframework.web.bind.annotation.RequestMapping;
12+
import org.springframework.web.bind.annotation.ResponseStatus;
13+
import org.springframework.web.bind.annotation.RestController;
14+
15+
@RestController
16+
@RequestMapping("/api/auth")
17+
@RequiredArgsConstructor
18+
public class AuthController {
19+
20+
private final AuthService authService;
21+
22+
@PostMapping("/signup")
23+
@ResponseStatus(HttpStatus.CREATED)
24+
public SignupResponse signup(@Valid @RequestBody SignupRequest request) {
25+
return authService.signup(request);
26+
}
27+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.bootsignal.domain.auth.dto;
2+
3+
import jakarta.validation.constraints.Email;
4+
import jakarta.validation.constraints.NotBlank;
5+
import jakarta.validation.constraints.Size;
6+
7+
public record SignupRequest(
8+
@NotBlank(message = "이메일은 필수입니다.")
9+
@Email(message = "이메일 형식이 올바르지 않습니다.")
10+
@Size(max = 255, message = "이메일은 255자 이하여야 합니다.")
11+
String email,
12+
13+
@NotBlank(message = "비밀번호는 필수입니다.")
14+
@Size(min = 8, max = 64, message = "비밀번호는 8자 이상 64자 이하이어야 합니다.")
15+
String password,
16+
17+
@NotBlank(message = "닉네임은 필수입니다.")
18+
@Size(max = 30, message = "닉네임은 30자 이하여야 합니다.")
19+
String nickname
20+
) {
21+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.bootsignal.domain.auth.dto;
2+
3+
import com.bootsignal.domain.user.entity.User;
4+
5+
public record SignupResponse(
6+
Long id,
7+
String email,
8+
String nickname
9+
) {
10+
11+
public static SignupResponse from(User user) {
12+
return new SignupResponse(user.getId(), user.getEmail(), user.getNickname());
13+
}
14+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package com.bootsignal.domain.auth.service;
2+
3+
import com.bootsignal.domain.auth.dto.SignupRequest;
4+
import com.bootsignal.domain.auth.dto.SignupResponse;
5+
import com.bootsignal.domain.user.entity.User;
6+
import com.bootsignal.domain.user.repository.UserRepository;
7+
import com.bootsignal.global.exception.BootSignalException;
8+
import com.bootsignal.global.exception.ErrorCode;
9+
import java.util.Locale;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.dao.DataIntegrityViolationException;
12+
import org.springframework.security.crypto.password.PasswordEncoder;
13+
import org.springframework.stereotype.Service;
14+
import org.springframework.transaction.annotation.Transactional;
15+
16+
@Service
17+
@RequiredArgsConstructor
18+
@Transactional(readOnly = true)
19+
public class AuthService {
20+
21+
private final UserRepository userRepository;
22+
private final PasswordEncoder passwordEncoder;
23+
24+
@Transactional
25+
public SignupResponse signup(SignupRequest request) {
26+
String email = normalizeEmail(request.email());
27+
String nickname = request.nickname().strip();
28+
if (userRepository.existsByEmail(email)) {
29+
throw new BootSignalException(ErrorCode.DUPLICATE_EMAIL);
30+
}
31+
if (userRepository.existsByNickname(nickname)) {
32+
throw new BootSignalException(ErrorCode.DUPLICATE_NICKNAME);
33+
}
34+
35+
User user = User.signupLocal(
36+
email,
37+
passwordEncoder.encode(request.password()),
38+
nickname
39+
);
40+
41+
try {
42+
return SignupResponse.from(userRepository.save(user));
43+
} catch (DataIntegrityViolationException exception) {
44+
throw new BootSignalException(resolveDuplicateErrorCode(exception));
45+
}
46+
}
47+
48+
private ErrorCode resolveDuplicateErrorCode(DataIntegrityViolationException exception) {
49+
String message = exception.getMostSpecificCause().getMessage();
50+
// 동시 요청으로 닉네임 유니크 제약이 발생한 경우를 분리한다.
51+
if (message != null && message.contains("uk_users_nickname")) {
52+
return ErrorCode.DUPLICATE_NICKNAME;
53+
}
54+
return ErrorCode.DUPLICATE_EMAIL;
55+
}
56+
57+
private String normalizeEmail(String email) {
58+
return email.strip().toLowerCase(Locale.ROOT);
59+
}
60+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package com.bootsignal.domain.post.controller;
2+
3+
import com.bootsignal.domain.post.dto.PostCreateRequest;
4+
import com.bootsignal.domain.post.dto.PostResponse;
5+
import com.bootsignal.domain.post.dto.PostUpdateRequest;
6+
import com.bootsignal.domain.post.entity.PostType;
7+
import com.bootsignal.domain.post.service.PostService;
8+
import jakarta.validation.Valid;
9+
import lombok.RequiredArgsConstructor;
10+
import org.springframework.data.domain.Page;
11+
import org.springframework.data.domain.Pageable;
12+
import org.springframework.data.domain.Sort;
13+
import org.springframework.data.web.PageableDefault;
14+
import org.springframework.http.HttpStatus;
15+
import org.springframework.web.bind.annotation.DeleteMapping;
16+
import org.springframework.web.bind.annotation.GetMapping;
17+
import org.springframework.web.bind.annotation.PatchMapping;
18+
import org.springframework.web.bind.annotation.PathVariable;
19+
import org.springframework.web.bind.annotation.PostMapping;
20+
import org.springframework.web.bind.annotation.RequestBody;
21+
import org.springframework.web.bind.annotation.RequestMapping;
22+
import org.springframework.web.bind.annotation.RequestParam;
23+
import org.springframework.web.bind.annotation.ResponseStatus;
24+
import org.springframework.web.bind.annotation.RestController;
25+
26+
@RestController
27+
@RequestMapping("/api/posts")
28+
@RequiredArgsConstructor
29+
public class PostController {
30+
31+
private final PostService postService;
32+
33+
@PostMapping
34+
@ResponseStatus(HttpStatus.CREATED)
35+
public PostResponse create(@RequestBody @Valid PostCreateRequest request) {
36+
return postService.create(request);
37+
}
38+
39+
@GetMapping
40+
public Page<PostResponse> getList(
41+
@RequestParam(required = false) PostType postType,
42+
@RequestParam(required = false) Long courseId,
43+
@RequestParam(required = false) String keyword,
44+
@PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable
45+
) {
46+
return postService.getList(postType, courseId, keyword, pageable);
47+
}
48+
49+
@GetMapping("/{postId}")
50+
public PostResponse get(@PathVariable Long postId) {
51+
return postService.get(postId);
52+
}
53+
54+
@PatchMapping("/{postId}")
55+
public PostResponse update(
56+
@PathVariable Long postId,
57+
@RequestBody @Valid PostUpdateRequest request
58+
) {
59+
return postService.update(postId, request);
60+
}
61+
62+
@DeleteMapping("/{postId}")
63+
@ResponseStatus(HttpStatus.NO_CONTENT)
64+
public void delete(@PathVariable Long postId) {
65+
postService.delete(postId);
66+
}
67+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.bootsignal.domain.post.dto;
2+
3+
import com.bootsignal.domain.post.entity.PostType;
4+
import jakarta.validation.constraints.NotBlank;
5+
import jakarta.validation.constraints.NotNull;
6+
7+
public record PostCreateRequest(
8+
Long courseId,
9+
@NotNull PostType postType,
10+
String category,
11+
@NotBlank String title,
12+
@NotBlank String content
13+
) {}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.bootsignal.domain.post.dto;
2+
3+
import com.bootsignal.domain.post.entity.Post;
4+
import com.bootsignal.domain.post.entity.PostType;
5+
import java.time.LocalDateTime;
6+
7+
public record PostResponse(
8+
Long postId,
9+
Long userId,
10+
String userNickname,
11+
Long courseId,
12+
PostType postType,
13+
String category,
14+
String title,
15+
String content,
16+
LocalDateTime createdAt,
17+
LocalDateTime updatedAt
18+
) {
19+
public static PostResponse from(Post post) {
20+
return new PostResponse(
21+
post.getId(),
22+
post.getUser().getId(),
23+
post.getUser().getNickname(),
24+
post.getCourse() != null ? post.getCourse().getId() : null,
25+
post.getPostType(),
26+
post.getCategory(),
27+
post.getTitle(),
28+
post.getContent(),
29+
post.getCreatedAt(),
30+
post.getUpdatedAt()
31+
);
32+
}
33+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.bootsignal.domain.post.dto;
2+
3+
public record PostUpdateRequest(
4+
String title,
5+
String content,
6+
String category
7+
) {}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package com.bootsignal.domain.post.entity;
2+
3+
import com.bootsignal.domain.course.entity.Course;
4+
import com.bootsignal.domain.user.entity.User;
5+
import com.bootsignal.global.entity.BaseEntity;
6+
import jakarta.persistence.Column;
7+
import jakarta.persistence.Entity;
8+
import jakarta.persistence.EnumType;
9+
import jakarta.persistence.Enumerated;
10+
import jakarta.persistence.FetchType;
11+
import jakarta.persistence.GeneratedValue;
12+
import jakarta.persistence.GenerationType;
13+
import jakarta.persistence.Id;
14+
import jakarta.persistence.JoinColumn;
15+
import jakarta.persistence.ManyToOne;
16+
import java.time.LocalDateTime;
17+
import lombok.AccessLevel;
18+
import lombok.Builder;
19+
import lombok.Getter;
20+
import lombok.NoArgsConstructor;
21+
22+
@Entity
23+
@Getter
24+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
25+
public class Post extends BaseEntity {
26+
27+
@Id
28+
@GeneratedValue(strategy = GenerationType.IDENTITY)
29+
private Long id;
30+
31+
@ManyToOne(fetch = FetchType.LAZY)
32+
@JoinColumn(name = "user_id", nullable = false)
33+
private User user;
34+
35+
@ManyToOne(fetch = FetchType.LAZY)
36+
@JoinColumn(name = "course_id")
37+
private Course course;
38+
39+
@Enumerated(EnumType.STRING)
40+
@Column(nullable = false)
41+
private PostType postType;
42+
43+
private String category;
44+
45+
@Column(nullable = false)
46+
private String title;
47+
48+
@Column(nullable = false, columnDefinition = "TEXT")
49+
private String content;
50+
51+
@Column(nullable = false)
52+
private boolean isValid = true;
53+
54+
private LocalDateTime deletedAt;
55+
56+
@Builder
57+
private Post(User user, Course course, PostType postType, String category, String title, String content) {
58+
this.user = user;
59+
this.course = course;
60+
this.postType = postType;
61+
this.category = category;
62+
this.title = title;
63+
this.content = content;
64+
}
65+
66+
public void update(String title, String content, String category) {
67+
if (title != null) this.title = title;
68+
if (content != null) this.content = content;
69+
if (category != null) this.category = category;
70+
}
71+
72+
public void softDelete() {
73+
this.deletedAt = LocalDateTime.now();
74+
this.isValid = false;
75+
}
76+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package com.bootsignal.domain.post.entity;
2+
3+
public enum PostType {
4+
PROJECT_RECRUIT, QNA, ARTICLE, BOARD
5+
}

0 commit comments

Comments
 (0)