【Spring Boot】コメント機能を実装する|簡単な掲示板アプリの作成
Spring Bootを使い、掲示板アプリにコメント機能を追加する方法を解説します。投稿とコメント、ユーザーのデータを関連付け、ログインしている本人だけが自分の投稿やコメントを削除できるようにする、基本的な権限管理の仕組みも実装します。
開発環境
- OS: Windows10
- Visual Studio Code: 1.73.0
- Java: OpenJDK 23
- Spring Boot: 3.4.0
- Lombok: 1.18.30
- Thymeleaf Layout Dialect: 3.3.0
- Spring Security: 6.1.1
- Thymeleaf Extras Spring Security: 3.1.2.RELEASE
- maven-compiler-plugin: 3.8.1
サンプルコード
/pom.xml
/pom.xml1 <artifactId>maven-compiler-plugin</artifactId> 2 <version>3.8.1</version> 3 <configuration> 4+ <compilerArgument>-parameters</compilerArgument> 5 <annotationProcessorPaths> 6 <path> 7 <groupId>org.projectlombok</groupId> 8
/src/main/java/com/example/bbs/controller/CommentController.java
/src/main/java/com/example/bbs/controller/CommentController.java1+package com.example.bbs.controller; 2+ 3+import com.example.bbs.model.Comment; 4+import com.example.bbs.model.Post; 5+import com.example.bbs.model.User; 6+import com.example.bbs.service.CommentService; 7+import com.example.bbs.service.PostService; 8+import com.example.bbs.service.UserService; 9+import org.springframework.stereotype.Controller; 10+import org.springframework.web.bind.annotation.*; 11+ 12+@Controller 13+@RequestMapping("/comments") 14+public class CommentController { 15+ private final CommentService commentService; 16+ private final PostService postService; 17+ private final UserService userService; // ユーザーサービスを追加 18+ 19+ public CommentController(CommentService commentService, PostService postService, UserService userService) { 20+ this.commentService = commentService; 21+ this.postService = postService; 22+ this.userService = userService; 23+ } 24+ 25+ // コメントの追加 26+ @PostMapping("/add") 27+ public String addComment(@RequestParam Long postId, @RequestParam String content) { 28+ Post post = postService.findById(postId).orElseThrow(); 29+ User user = userService.getCurrentUser(); // 現在のログイン中ユーザーを取得 30+ 31+ Comment comment = new Comment(); 32+ comment.setPost(post); 33+ comment.setUser(user); // ユーザーを設定 34+ comment.setContent(content); 35+ 36+ commentService.save(comment); 37+ return "redirect:/posts/" + postId; 38+ } 39+ 40+ // コメント削除 41+ @PostMapping("/{id}/delete") 42+ public String deleteComment(@PathVariable Long id, @RequestParam Long postId) { 43+ // 現在のログインユーザーを取得 44+ User loggedInUser = userService.getCurrentUser(); 45+ 46+ // コメントを取得 47+ Comment comment = commentService.findById(id).orElseThrow(() -> new RuntimeException("Comment not found")); 48+ 49+ // 投稿の所有者を確認 50+ if (!commentService.verifyOwnership(comment, loggedInUser)) { 51+ // 所有者でない場合はエラーをスローまたはリダイレクト 52+ return "redirect:/posts/" + postId + "?error=notAuthorized"; 53+ } 54+ 55+ // コメントを削除 56+ commentService.deleteById(id); 57+ return "redirect:/posts/" + postId; 58+ } 59+} 60
/src/main/java/com/example/bbs/controller/PostController.java
/src/main/java/com/example/bbs/controller/PostController.java1 package com.example.bbs.controller; 2 3 import com.example.bbs.model.Post; 4+import com.example.bbs.model.User; 5 import com.example.bbs.service.PostService; 6+import com.example.bbs.service.UserService; 7+import com.example.bbs.service.CommentService; 8 import org.springframework.stereotype.Controller; 9 import org.springframework.ui.Model; 10 import org.springframework.web.bind.annotation.*; 11 @RequestMapping("/posts") 12 public class PostController { 13 private final PostService postService; 14+ private final CommentService commentService; 15+ private final UserService userService; // ユーザーサービスを追加 16 17- public PostController(PostService postService) { 18+ public PostController(PostService postService, CommentService commentService, UserService userService) { 19 this.postService = postService; 20+ this.commentService = commentService; 21+ this.userService = userService; 22 } 23 24 // 一覧表示 25 @GetMapping 26 public String listPosts(Model model) { 27+ // ログインユーザーを取得 28+ User loggedInUser = userService.getCurrentUser(); 29+ 30+ // モデルに渡す 31+ model.addAttribute("loggedInUserId", loggedInUser.getId()); 32+ 33 model.addAttribute("posts", postService.findAll()); 34 return "posts/list"; 35 } 36 // 詳細表示 37 @GetMapping("/{id}") 38 public String viewPost(@PathVariable Long id, Model model) { 39+ // ログインユーザーを取得 40+ User loggedInUser = userService.getCurrentUser(); 41+ 42+ // モデルに渡す 43+ model.addAttribute("loggedInUserId", loggedInUser.getId()); 44+ 45 model.addAttribute("post", postService.findById(id).orElseThrow()); 46+ model.addAttribute("comments", commentService.findByPostId(id)); 47 return "posts/detail"; 48 } 49 50 // 編集フォーム 51 @GetMapping("{id}/edit") 52 public String editPostFrom(@PathVariable Long id, Model model) { 53- model.addAttribute("post", postService.findById(id).orElseThrow()); 54+ // 現在のログインユーザーを取得 55+ User loggedInUser = userService.getCurrentUser(); 56+ 57+ // 投稿を取得 58+ Post post = postService.findById(id).orElseThrow(() -> new RuntimeException("Post not found")); 59+ 60+ // 投稿の所有者を確認 61+ if (!postService.verifyOwnership(post, loggedInUser)) { 62+ // 所有者でない場合はエラーをスローまたはリダイレクト 63+ return "redirect:/posts?error=notAuthorized"; 64+ } 65+ 66+ // 投稿をセット 67+ model.addAttribute("post", post); 68 return "posts/edit"; 69 } 70 71 // 投稿更新 72 @PostMapping("/{id}") 73 public String updatePost(@PathVariable Long id, @ModelAttribute Post post) { 74- System.out.println("test"); 75- Post existingPost = postService.findById(id).orElseThrow(); 76+ // 現在のログインユーザーを取得 77+ User loggedInUser = userService.getCurrentUser(); 78+ 79+ // 投稿を取得 80+ Post existingPost = postService.findById(id).orElseThrow(() -> new RuntimeException("Post not found")); 81+ 82+ // 投稿の所有者を確認 83+ if (!postService.verifyOwnership(existingPost, loggedInUser)) { 84+ // 所有者でない場合はエラーをスローまたはリダイレクト 85+ return "redirect:/posts?error=notAuthorized"; 86+ } 87+ 88+ // 投稿を更新 89 existingPost.setTitle(post.getTitle()); 90 existingPost.setContent(post.getContent()); 91 postService.save(existingPost); 92 // 投稿作成 93 @PostMapping 94 public String createPost(@ModelAttribute Post post) { 95+ // 現在のログイン中のユーザーを取得 96+ User user = userService.getCurrentUser(); // ユーザー取得メソッド 97+ 98+ // ユーザーをPostに設定 99+ post.setUser(user); 100+ 101 postService.save(post); 102- System.out.println(post); 103 return "redirect:/posts"; 104 } 105 106 // 投稿削除 107 @PostMapping("{id}/delete") 108 public String deletePost(@PathVariable Long id) { 109+ // 現在のログインユーザーを取得 110+ User loggedInUser = userService.getCurrentUser(); 111+ 112+ // 投稿を取得 113+ Post post = postService.findById(id).orElseThrow(() -> new RuntimeException("Post not found")); 114+ 115+ // 投稿の所有者を確認 116+ if (!postService.verifyOwnership(post, loggedInUser)) { 117+ // 所有者でない場合はエラーをスローまたはリダイレクト 118+ return "redirect:/posts?error=notAuthorized"; 119+ } 120+ 121+ // 投稿を削除 122 postService.deleteById(id); 123 return "redirect:/posts"; 124 } 125
/src/main/java/com/example/bbs/model/Comment.java
/src/main/java/com/example/bbs/model/Comment.java1+package com.example.bbs.model; 2+ 3+import java.time.LocalDateTime; 4+ 5+import jakarta.persistence.*; 6+import lombok.*; 7+import lombok.Getter; 8+import lombok.Setter; 9+ 10+@Entity 11+@Getter 12+@Setter 13+public class Comment { 14+ @Id 15+ @GeneratedValue(strategy = GenerationType.IDENTITY) 16+ private Long id; 17+ 18+ private String content; 19+ 20+ private LocalDateTime createdAt = LocalDateTime.now(); 21+ private LocalDateTime updatedAt = LocalDateTime.now(); 22+ 23+ @ManyToOne 24+ @JoinColumn(name = "post_id", nullable = true) 25+ private Post post; 26+ 27+ @ManyToOne 28+ @JoinColumn(name = "user_id", nullable = true) 29+ private User user; 30+ 31+ @PrePersist 32+ public void onCreate() { 33+ createdAt = LocalDateTime.now(); 34+ } 35+ 36+ @PreUpdate 37+ public void onUpdate() { 38+ updatedAt = LocalDateTime.now(); 39+ } 40+} 41
/src/main/java/com/example/bbs/model/Post.java
/src/main/java/com/example/bbs/model/Post.java1 package com.example.bbs.model; 2 3 import jakarta.persistence.*; 4-import lombok.Data; 5+import lombok.*; 6+ 7+import java.util.ArrayList; 8+import java.util.List; 9 10 import java.time.LocalDateTime; 11 12 @Entity 13-@Data 14+@Getter 15+@Setter 16 public class Post { 17 @Id 18 @GeneratedValue(strategy = GenerationType.IDENTITY) 19 private String title; 20 private String content; 21 22+ @ManyToOne 23+ @JoinColumn(name = "user_id", nullable = true) 24+ private User user; 25+ 26+ @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) 27+ private List<Comment> comments = new ArrayList<>(); 28+ 29 private LocalDateTime createdAt = LocalDateTime.now(); 30 private LocalDateTime updatedAt = LocalDateTime.now(); 31 32
/src/main/java/com/example/bbs/model/User.java
/src/main/java/com/example/bbs/model/User.java1 package com.example.bbs.model; 2 3 import jakarta.persistence.*; 4-import lombok.Data; 5+import lombok.*; 6+import lombok.Getter; 7+ 8+import java.util.List; 9 10 @Entity 11-@Data 12+@Getter 13+@Setter 14 @Table(name = "users") 15 public class User { 16 17 18 @Column(nullable = false) 19 private String password; 20+ 21+ @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) 22+ private List<Post> posts; 23+ 24+ @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) 25+ private List<Comment> comments; 26 } 27
/src/main/java/com/example/bbs/repository/CommentRepository.java
/src/main/java/com/example/bbs/repository/CommentRepository.java1+package com.example.bbs.repository; 2+ 3+import com.example.bbs.model.Comment; 4+import org.springframework.data.jpa.repository.JpaRepository; 5+ 6+import java.util.List; 7+ 8+public interface CommentRepository extends JpaRepository<Comment, Long> { 9+ List<Comment> findByPostId(Long postId); 10+} 11
/src/main/java/com/example/bbs/service/CommentService.java
/src/main/java/com/example/bbs/service/CommentService.java1+package com.example.bbs.service; 2+ 3+import com.example.bbs.model.Comment; 4+import com.example.bbs.model.User; 5+import com.example.bbs.repository.CommentRepository; 6+import org.springframework.stereotype.Service; 7+ 8+import java.util.List; 9+import java.util.Optional; 10+ 11+@Service 12+public class CommentService { 13+ private final CommentRepository commentRepository; 14+ 15+ public CommentService(CommentRepository commentRepository) { 16+ this.commentRepository = commentRepository; 17+ } 18+ 19+ public Optional<Comment> findById(Long id) { 20+ return commentRepository.findById(id); 21+ } 22+ 23+ public List<Comment> findByPostId(Long postId) { 24+ return commentRepository.findByPostId(postId); 25+ } 26+ 27+ public Comment save(Comment comment) { 28+ return commentRepository.save(comment); 29+ } 30+ 31+ public void deleteById(Long id) { 32+ commentRepository.deleteById(id); 33+ } 34+ 35+ public boolean verifyOwnership(Comment comment, User user) { 36+ if (comment.getUser() == null) { 37+ return false; 38+ } 39+ 40+ if (!comment.getUser().getId().equals(user.getId())) { 41+ return false; 42+ } 43+ 44+ return true; 45+ } 46+} 47
/src/main/java/com/example/bbs/service/PostService.java
/src/main/java/com/example/bbs/service/PostService.java1 package com.example.bbs.service; 2 3 import com.example.bbs.model.Post; 4+import com.example.bbs.model.User; 5 import com.example.bbs.repository.PostRepository; 6 import org.springframework.stereotype.Service; 7 8 public void deleteById(Long id) { 9 postRepository.deleteById(id); 10 } 11+ 12+ public boolean verifyOwnership(Post post, User user) { 13+ if (post.getUser() == null) { 14+ return false; 15+ } 16+ 17+ if (!post.getUser().getId().equals(user.getId())) { 18+ return false; 19+ } 20+ 21+ return true; 22+ } 23 } 24
/src/main/java/com/example/bbs/service/UserService.java
/src/main/java/com/example/bbs/service/UserService.java1+package com.example.bbs.service; 2+ 3+import com.example.bbs.model.User; 4+import com.example.bbs.repository.UserRepository; 5+import org.springframework.security.core.context.SecurityContextHolder; 6+import org.springframework.security.core.userdetails.UserDetails; 7+import org.springframework.stereotype.Service; 8+ 9+@Service 10+public class UserService { 11+ private final UserRepository userRepository; 12+ 13+ public UserService(UserRepository userRepository) { 14+ this.userRepository = userRepository; 15+ } 16+ 17+ public User getCurrentUser() { 18+ Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); 19+ if (principal instanceof UserDetails) { 20+ String username = ((UserDetails) principal).getUsername(); 21+ return userRepository.findByUsername(username).orElseThrow(); 22+ } 23+ throw new IllegalStateException("User not logged in"); 24+ } 25+} 26
/src/main/resources/templates/posts/detail.html
/src/main/resources/templates/posts/detail.html1 <h1 th:text="${post.title}"></h1> 2 <p th:text="${post.content}"></p> 3 <a th:href="@{/posts}" class="btn btn-secondary">Back To List</a> 4- <a th:href="@{/posts/{id}/edit(id=${post.id})}" class="btn btn-primary">Edit</a> 5- <form th:action="@{/posts/{id}/delete(id=${post.id})}" method="post" style="display: inline;"> 6- <button type="submit" class="btn btn-danger">Delete</button> 7+ <span th:if="${post.user != null and loggedInUserId == post.user.id}"> 8+ <a th:href="@{/posts/{id}/edit(id=${post.id})}" class="btn btn-primary">Edit</a> 9+ <form th:action="@{/posts/{id}/delete(id=${post.id})}" method="post" style="display: inline;"> 10+ <button type="submit" class="btn btn-danger">Delete</button> 11+ </form> 12+ </span> 13+ 14+ <h2>Comments</h2> 15+ <div class="mb-3"> 16+ <div th:each="comment : ${post.comments}"> 17+ <!-- コメントカード --> 18+ <div class="card mb-3"> 19+ <!-- コメントのヘッダー (ユーザー名と投稿日時) --> 20+ <div class="card-header"> 21+ <strong th:text="${comment.user.username}">Username</strong> 22+ <small class="text-muted ml-2" th:text="${#temporals.format(comment.createdAt, 'yyyy/MM/dd HH:mm:ss')}">Date</small> 23+ </div> 24+ 25+ <!-- コメントのボディ (コメント内容) --> 26+ <div class="card-body"> 27+ <p th:text="${comment.content}">Comment content</p> 28+ </div> 29+ 30+ <!-- コメントのフッター (削除ボタン) --> 31+ <div class="card-footer text-right"> 32+ <span th:if="${comment.user != null and loggedInUserId == comment.user.id}"> 33+ <form th:action="@{/comments/{id}/delete(id=${comment.id})}" method="post" style="display: inline;"> 34+ <input type="hidden" name="postId" th:value="${post.id}" /> 35+ <button type="submit" class="btn btn-danger btn-sm">Delete</button> 36+ </form> 37+ </span> 38+ </div> 39+ </div> 40+ </div> 41+ </div> 42+ 43+ <!-- コメント追加フォーム --> 44+ <h3>Add a Comment</h3> 45+ <form th:action="@{/comments/add}" method="post"> 46+ <input type="hidden" name="postId" th:value="${post.id}"/> 47+ <div class="mb-3"> 48+ <textarea name="content" class="form-control" rows="3" placeholder="Write a comment..." required></textarea> 49+ </div> 50+ <button type="submit" class="btn btn-success">Add Comment</button> 51 </form> 52 </main> 53
/src/main/resources/templates/posts/list.html
/src/main/resources/templates/posts/list.html1 <span th:text="${post.content}"></span> 2 </td> 3 <td class="text-end"> 4- <a th:href="@{/posts/{id}/edit(id=${post.id})}" class="btn btn-primary btn-sm">Edit</a> 5- <form th:action="@{/posts/{id}/delete(id=${post.id})}" method="post" style="display: inline;"> 6- <button type="submit" class="btn btn-danger btn-sm">Delete</button> 7- </form> 8+ <div th:if="${post.user != null and loggedInUserId == post.user.id}"> 9+ <a th:href="@{/posts/{id}/edit(id=${post.id})}" class="btn btn-primary btn-sm">Edit</a> 10+ <form th:action="@{/posts/{id}/delete(id=${post.id})}" method="post" style="display: inline;"> 11+ <button type="submit" class="btn btn-danger btn-sm">Delete</button> 12+ </form> 13+ </div> 14 </td> 15 </tr> 16 </tbody> 17
コード解説
変更点: pom.xmlにコンパイラ引数を追加
/pom.xml1 <artifactId>maven-compiler-plugin</artifactId> 2 <version>3.8.1</version> 3 <configuration> 4+ <compilerArgument>-parameters</compilerArgument> 5 <annotationProcessorPaths> 6 <path> 7 <groupId>org.projectlombok</groupId> 8
Javaのソースコードをコンパイルする際の設定ファイルである pom.xml に、コンパイラの引数を追加しました。
-parameters というオプションを追加することで、コンパイル後のファイルにメソッドの引数名が保持されるようになります。これにより、Springがリクエストのパラメータ名とコントローラーのメソッド引数名を自動で一致させてくれるなど、開発がより便利になります。
変更点: Comment(コメント)モデルの作成
/src/main/java/com/example/bbs/model/Comment.java1+package com.example.bbs.model; 2+ 3+import java.time.LocalDateTime; 4+ 5+import jakarta.persistence.*; 6+import lombok.*; 7+import lombok.Getter; 8+import lombok.Setter; 9+ 10+@Entity 11+@Getter 12+@Setter 13+public class Comment { 14+ @Id 15+ @GeneratedValue(strategy = GenerationType.IDENTITY) 16+ private Long id; 17+ 18+ private String content; 19+ 20+ private LocalDateTime createdAt = LocalDateTime.now(); 21+ private LocalDateTime updatedAt = LocalDateTime.now(); 22+ 23+ @ManyToOne 24+ @JoinColumn(name = "post_id", nullable = true) 25+ private Post post; 26+ 27+ @ManyToOne 28+ @JoinColumn(name = "user_id", nullable = true) 29+ private User user; 30+}
新しく Comment.java ファイルを作成し、コメントの情報をデータベースに保存するための設計図(モデル)を定義しました。
@Entity: このクラスがデータベースのテーブルに対応することを示すJPAのアノテーションです。id,content,createdAt,updatedAt: コメントが持つデータ(ID、内容、作成日時、更新日時)を定義しています。@ManyToOne: コメントと投稿(Post)、コメントとユーザー(User)の関係を定義しています。これは「多対一」の関係を表し、「多くのコメントが、一つの投稿に属する」「多くのコメントが、一人のユーザーによって書かれる」という意味になります。@JoinColumn: データベース上で、どのカラムを外部キーとして関連付けるかを指定しています。例えばpost_idカラムで、どの投稿へのコメントなのかを管理します。
変更点: Post(投稿)モデルにUserとCommentの関連を追加
/src/main/java/com/example/bbs/model/Post.java1-import lombok.Data; 2+import lombok.*; 3+ 4+import java.util.ArrayList; 5+import java.util.List; 6 7-@Data 8+@Getter 9+@Setter 10 public class Post { 11 private String title; 12 private String content; 13 14+ @ManyToOne 15+ @JoinColumn(name = "user_id", nullable = true) 16+ private User user; 17+ 18+ @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) 19+ private List<Comment> comments = new ArrayList<>(); 20+ 21 private LocalDateTime createdAt = LocalDateTime.now(); 22 private LocalDateTime updatedAt = LocalDateTime.now();
Post.java ファイルを修正し、投稿とユーザー、投稿とコメントの関連性を定義しました。
@ManyToOneと@JoinColumn: 投稿とユーザーの関係を定義しています。「多くの投稿が、一人のユーザーによって書かれる」という「多対一」の関係です。@OneToMany: 投稿とコメントの関係を定義しています。「一つの投稿に、多くのコメントが付く」という「一対多」の関係です。mappedBy = "post": この関連付けがCommentモデルのpostフィールドによって管理されていることを示します。cascade = CascadeType.ALL: 投稿が削除されたときに、関連するコメントも一緒に削除されるように設定しています。
変更点: User(ユーザー)モデルにPostとCommentの関連を追加
/src/main/java/com/example/bbs/model/User.java1-import lombok.Data; 2+import lombok.*; 3+import lombok.Getter; 4+ 5+import java.util.List; 6 7-@Data 8+@Getter 9+@Setter 10 @Table(name = "users") 11 public class User { 12 13 @Column(nullable = false) 14 private String password; 15+ 16+ @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) 17+ private List<Post> posts; 18+ 19+ @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) 20+ private List<Comment> comments; 21 }
User.java ファイルを修正し、ユーザーと投稿、ユーザーとコメントの関連性を定義しました。
@OneToMany: ユーザーと投稿・コメントの関係を定義しています。「一人のユーザーが、多くの投稿・コメントを持つ」という「一対多」の関係です。mappedBy = "user": この関連付けがPostモデルやCommentモデルのuserフィールドによって管理されていることを示しています。
変更点: CommentRepository(コメント用リポジトリ)の作成
/src/main/java/com/example/bbs/repository/CommentRepository.java1+package com.example.bbs.repository; 2+ 3+import com.example.bbs.model.Comment; 4+import org.springframework.data.jpa.repository.JpaRepository; 5+ 6+import java.util.List; 7+ 8+public interface CommentRepository extends JpaRepository<Comment, Long> { 9+ List<Comment> findByPostId(Long postId); 10+}
新しく CommentRepository.java を作成しました。これは、Comment モデルに対応するデータベース操作を行うためのインターフェースです。
JpaRepository<Comment, Long>を継承することで、データの保存(save)、削除(delete)、一件取得(findById)といった基本的なデータベース操作のメソッドが自動的に使えるようになります。List<Comment> findByPostId(Long postId);: Spring Data JPA の命名規則に従ってメソッドを定義するだけで、postIdをキーにしてコメントを検索する処理を自動で実装してくれます。
変更点: UserService(ユーザー用サービス)の作成
/src/main/java/com/example/bbs/service/UserService.java1+package com.example.bbs.service; 2+ 3+import com.example.bbs.model.User; 4+import com.example.bbs.repository.UserRepository; 5+import org.springframework.security.core.context.SecurityContextHolder; 6+import org.springframework.security.core.userdetails.UserDetails; 7+import org.springframework.stereotype.Service; 8+ 9+@Service 10+public class UserService { 11+ private final UserRepository userRepository; 12+ 13+ public UserService(UserRepository userRepository) { 14+ this.userRepository = userRepository; 15+ } 16+ 17+ public User getCurrentUser() { 18+ Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); 19+ if (principal instanceof UserDetails) { 20+ String username = ((UserDetails) principal).getUsername(); 21+ return userRepository.findByUsername(username).orElseThrow(); 22+ } 23+ throw new IllegalStateException("User not logged in"); 24+ } 25+}
新しく UserService.java を作成しました。ユーザーに関するビジネスロジックを担当するクラスです。
getCurrentUser メソッドは、現在ログインしているユーザーの情報を取得する重要な役割を担います。Spring Securityが管理している認証情報(SecurityContextHolder)からログイン中のユーザー名を取り出し、そのユーザー名を元にデータベースから完全な User オブジェクトを取得して返します。これにより、「今誰が操作しているのか」を正確に把握できるようになります。
変更点: CommentService(コメント用サービス)の作成
/src/main/java/com/example/bbs/service/CommentService.java1+package com.example.bbs.service; 2+ 3+import com.example.bbs.model.Comment; 4+import com.example.bbs.model.User; 5+import com.example.bbs.repository.CommentRepository; 6+import org.springframework.stereotype.Service; 7+ 8+import java.util.List; 9+import java.util.Optional; 10+ 11+@Service 12+public class CommentService { 13+ private final CommentRepository commentRepository; 14+ 15+ public CommentService(CommentRepository commentRepository) { 16+ this.commentRepository = commentRepository; 17+ } 18+ 19+ // ... (findById, findByPostId, save, deleteById メソッド) ... 20+ 21+ public boolean verifyOwnership(Comment comment, User user) { 22+ if (comment.getUser() == null) { 23+ return false; 24+ } 25+ 26+ if (!comment.getUser().getId().equals(user.getId())) { 27+ return false; 28+ } 29+ 30+ return true; 31+ } 32+}
新しく CommentService.java を作成しました。コメントに関するビジネスロジック(複雑な処理や判断)を担当します。
CommentRepositoryを使って、データベースとのやり取りを行います。verifyOwnershipメソッドは、コメントの所有権を確認するためのものです。引数で受け取ったコメントの作成者IDと、ログイン中のユーザーIDを比較し、一致すればtrue(本人)、一致しなければfalse(他人)を返します。これにより、本人以外がコメントを削除できないようにする制御が可能になります。
変更点: PostServiceに所有権確認ロジックを追加
/src/main/java/com/example/bbs/service/PostService.java1 import com.example.bbs.model.Post; 2+import com.example.bbs.model.User; 3 import com.example.bbs.repository.PostRepository; 4 import org.springframework.stereotype.Service; 5 6 public void deleteById(Long id) { 7 postRepository.deleteById(id); 8 } 9+ 10+ public boolean verifyOwnership(Post post, User user) { 11+ if (post.getUser() == null) { 12+ return false; 13+ } 14+ 15+ if (!post.getUser().getId().equals(user.getId())) { 16+ return false; 17+ } 18+ 19+ return true; 20+ } 21 }
既存の PostService.java に verifyOwnership メソッドを追加しました。
これは CommentService に追加したものと同様の機能で、投稿の作成者とログイン中のユーザーが同一人物であるかを確認します。このメソッドを使うことで、投稿の編集や削除といった操作を、投稿者本人だけに許可することができます。
変更点: CommentController(コメント用コントローラー)の作成
/src/main/java/com/example/bbs/controller/CommentController.java1+package com.example.bbs.controller; 2+ 3+import com.example.bbs.model.Comment; 4+import com.example.bbs.model.Post; 5+import com.example.bbs.model.User; 6+import com.example.bbs.service.CommentService; 7+import com.example.bbs.service.PostService; 8+import com.example.bbs.service.UserService; 9+import org.springframework.stereotype.Controller; 10+import org.springframework.web.bind.annotation.*; 11+ 12+@Controller 13+@RequestMapping("/comments") 14+public class CommentController { 15+ // ... (コンストラクタ) ... 16+ 17+ @PostMapping("/add") 18+ public String addComment(@RequestParam Long postId, @RequestParam String content) { 19+ Post post = postService.findById(postId).orElseThrow(); 20+ User user = userService.getCurrentUser(); 21+ 22+ Comment comment = new Comment(); 23+ comment.setPost(post); 24+ comment.setUser(user); 25+ comment.setContent(content); 26+ 27+ commentService.save(comment); 28+ return "redirect:/posts/" + postId; 29+ } 30+ 31+ @PostMapping("/{id}/delete") 32+ public String deleteComment(@PathVariable Long id, @RequestParam Long postId) { 33+ User loggedInUser = userService.getCurrentUser(); 34+ Comment comment = commentService.findById(id).orElseThrow(); 35+ 36+ if (!commentService.verifyOwnership(comment, loggedInUser)) { 37+ return "redirect:/posts/" + postId + "?error=notAuthorized"; 38+ } 39+ 40+ commentService.deleteById(id); 41+ return "redirect:/posts/" + postId; 42+ } 43+}
新しく CommentController.java を作成しました。これは、Webブラウザからのコメントに関するリクエスト(URLアクセス)を受け取り、適切な処理を行う司令塔の役割を果たします。
/comments/add: コメント追加フォームから送信されたデータを受け取るメソッドです。どの投稿に対するコメントか(postId)、コメント内容(content)を受け取り、UserServiceで取得したログインユーザー情報と合わせて新しいコメントを作成し、データベースに保存します。/comments/{id}/delete: コメント削除の要求を処理します。まずUserServiceでログインユーザーを取得し、CommentServiceのverifyOwnershipメソッドでコメントの所有者本人であることを確認します。本人であればコメントを削除し、そうでなければエラーとしてリダイレクトします。
変更点: PostControllerにユーザー・コメント関連の処理を追加
/src/main/java/com/example/bbs/controller/PostController.java1 package com.example.bbs.controller; 2 3 import com.example.bbs.model.Post; 4+import com.example.bbs.model.User; 5 import com.example.bbs.service.PostService; 6+import com.example.bbs.service.UserService; 7+import com.example.bbs.service.CommentService; 8 import org.springframework.stereotype.Controller; 9 import org.springframework.ui.Model; 10 // ... 11 public class PostController { 12 private final PostService postService; 13+ private final CommentService commentService; 14+ private final UserService userService; 15 16- public PostController(PostService postService) { 17+ public PostController(PostService postService, CommentService commentService, UserService userService) { 18 this.postService = postService; 19+ this.commentService = commentService; 20+ this.userService = userService; 21 } 22- 23- // 一覧表示 24 @GetMapping 25 public String listPosts(Model model) { 26+ User loggedInUser = userService.getCurrentUser(); 27+ model.addAttribute("loggedInUserId", loggedInUser.getId()); 28 model.addAttribute("posts", postService.findAll()); 29 return "posts/list"; 30 } 31- // 詳細表示 32 @GetMapping("/{id}") 33 public String viewPost(@PathVariable Long id, Model model) { 34+ User loggedInUser = userService.getCurrentUser(); 35+ model.addAttribute("loggedInUserId", loggedInUser.getId()); 36 model.addAttribute("post", postService.findById(id).orElseThrow()); 37+ model.addAttribute("comments", commentService.findByPostId(id)); 38 return "posts/detail"; 39 } 40- 41- // 投稿作成 42 @PostMapping 43 public String createPost(@ModelAttribute Post post) { 44+ User user = userService.getCurrentUser(); 45+ post.setUser(user); 46 postService.save(post); 47- System.out.println(post); 48 return "redirect:/posts"; 49 } 50- 51- // 投稿削除 52 @PostMapping("{id}/delete") 53 public String deletePost(@PathVariable Long id) { 54+ User loggedInUser = userService.getCurrentUser(); 55+ Post post = postService.findById(id).orElseThrow(); 56+ if (!postService.verifyOwnership(post, loggedInUser)) { 57+ return "redirect:/posts?error=notAuthorized"; 58+ } 59 postService.deleteById(id); 60 return "redirect:/posts"; 61 } 62 // ... (他のメソッドも同様に権限チェックを追加)
既存の PostController.java を大幅に修正しました。
- サービスの追加:
UserServiceとCommentServiceを使えるように追加しました。 - ログインユーザー情報の取得: 投稿一覧や詳細ページを表示する際に
userService.getCurrentUser()を呼び出し、ログイン中のユーザーIDをModelを通じてHTML側に渡しています。これにより、HTML側で「ログインしているユーザー」と「投稿やコメントの作成者」を比較できるようになります。 - 投稿とユーザーの紐付け: 新しい投稿を作成する際(
createPost)に、ログインユーザー情報を取得して投稿に設定しています。これにより、「誰がこの投稿を書いたか」が記録されます。 - 権限チェック: 投稿の編集・更新・削除処理を行う前に、
postService.verifyOwnership()を呼び出して、操作しようとしているのが投稿者本人であるかを確認する処理を追加しました。本人でない場合は処理を中断し、一覧ページにリダイレクトさせます。 - コメント情報の取得: 投稿詳細ページを表示する際(
viewPost)に、commentService.findByPostId()を呼び出して、その投稿に紐づくコメントの一覧を取得し、HTML側に渡しています。
変更点: 投稿詳細画面にコメント機能と権限による表示制御を追加
/src/main/resources/templates/posts/detail.html1 <h1 th:text="${post.title}"></h1> 2 <p th:text="${post.content}"></p> 3 <a th:href="@{/posts}" class="btn btn-secondary">Back To List</a> 4- <a th:href="@{/posts/{id}/edit(id=${post.id})}" class="btn btn-primary">Edit</a> 5- <form th:action="@{/posts/{id}/delete(id=${post.id})}" method="post" style="display: inline;"> 6- <button type="submit" class="btn btn-danger">Delete</button> 7+ <span th:if="${post.user != null and loggedInUserId == post.user.id}"> 8+ <a th:href="@{/posts/{id}/edit(id=${post.id})}" class="btn btn-primary">Edit</a> 9+ <form th:action="@{/posts/{id}/delete(id=${post.id})}" method="post" style="display: inline;"> 10+ <button type="submit" class="btn btn-danger">Delete</button> 11+ </form> 12+ </span> 13+ 14+ <!-- Comments Section --> 15+ <h2>Comments</h2> 16+ <div th:each="comment : ${post.comments}"> ... </div> 17+ 18+ <!-- Add Comment Form --> 19+ <h3>Add a Comment</h3> 20+ <form th:action="@{/comments/add}" method="post"> ... </form> 21 </form> 22 </main>
投稿詳細画面 detail.html を修正し、コメント機能を追加しました。
- 権限によるボタン表示制御:
th:if="${...}"というThymeleafの構文を使っています。loggedInUserId(ログインユーザーのID)とpost.user.id(投稿者のID)が一致する場合にのみ、編集ボタンと削除ボタンが表示されるようにしました。これにより、投稿者本人以外はボタンを見ることすらできなくなります。 - コメント一覧表示:
th:each="comment : ${post.comments}"を使い、コントローラーから渡されたコメントのリストを一つずつ取り出して表示しています。各コメントの削除ボタンも、同様にth:ifを使ってコメント投稿者本人にしか表示されないように制御しています。 - コメント投稿フォーム: 新たにコメントを投稿するためのフォームを追加しました。
action="@{/comments/add}"で、フォームの送信先がCommentControllerのaddCommentメソッドになるように指定しています。input type="hidden"を使い、どの投稿へのコメントかを識別するためのpostIdも一緒に送信しています。
変更点: 投稿一覧画面に権限による表示制御を追加
/src/main/resources/templates/posts/list.html1 </td> 2 <td class="text-end"> 3- <a th:href="@{/posts/{id}/edit(id=${post.id})}" class="btn btn-primary btn-sm">Edit</a> 4- <form th:action="@{/posts/{id}/delete(id=${post.id})}" method="post" style="display: inline;"> 5- <button type="submit" class="btn btn-danger btn-sm">Delete</button> 6- </form> 7+ <div th:if="${post.user != null and loggedInUserId == post.user.id}"> 8+ <a th:href="@{/posts/{id}/edit(id=${post.id})}" class="btn btn-primary btn-sm">Edit</a> 9+ <form th:action="@{/posts/{id}/delete(id=${post.id})}" method="post" style="display: inline;"> 10+ <button type="submit" class="btn btn-danger btn-sm">Delete</button> 11+ </form> 12+ </div> 13 </td> 14 </tr> 15 </tbody>
投稿一覧画面 list.html を修正しました。
詳細画面と同様に、th:if を使って権限チェックを行っています。一覧に表示される各投稿について、ログインしているユーザーがその投稿の作成者である場合にのみ、編集ボタンと削除ボタンが表示されるように変更しました。これにより、アプリケーション全体のセキュリティが向上します。
おわりに
今回は、Post、Comment、Userの各モデルを@ManyToOneなどで関連付け、コメント機能のデータベース設計を行いました。UserServiceを作成してログイン中のユーザー情報を取得することで、誰が操作しているかを正確に把握できるようになりました。バックエンドでは、本人確認のロジックを加えて自分以外の投稿やコメントを編集・削除できないよう制御しました。さらにThymeleafのth:ifを使い、画面上でも本人にしか編集・削除ボタンが表示されないようにする仕組みを実装しました。