From 9e42918d5e484fd4ce1ded651eaf45e120220baf Mon Sep 17 00:00:00 2001 From: Chuyaoyuan Date: Wed, 13 May 2026 13:58:08 +0800 Subject: [PATCH] Inbox Message & bugs --- .../yaoyuan/jiscuss/JiccussApplication.java | 2 + .../jiscuss/controller/UserMsgController.java | 108 ++++++++- .../controller/UserPageController.java | 103 +++++--- .../controller/UserPostController.java | 11 + .../controller/api/RestMsgController.java | 129 ++++++++++ .../com/yaoyuan/jiscuss/dto/InboxItem.java | 23 ++ .../yaoyuan/jiscuss/entity/Discussion.java | 2 +- .../com/yaoyuan/jiscuss/entity/Message.java | 40 +++ .../yaoyuan/jiscuss/entity/Notification.java | 50 ++++ .../repository/DiscussionsRepository.java | 7 + .../repository/LikeCollectRepository.java | 4 + .../jiscuss/repository/MessageRepository.java | 31 +++ .../repository/NotificationRepository.java | 29 +++ .../jiscuss/repository/PostsRepository.java | 7 + .../jiscuss/service/IMessageService.java | 23 ++ .../jiscuss/service/INotificationService.java | 27 +++ .../service/impl/LikeCollectServiceImpl.java | 12 + .../service/impl/MessageServiceImpl.java | 85 +++++++ .../service/impl/NotificationServiceImpl.java | 83 +++++++ .../db/migration/V9__notification_message.sql | 33 +++ src/main/resources/templates/comm/header.ftl | 39 ++- src/main/resources/templates/conversation.ftl | 114 +++++++++ src/main/resources/templates/discussions.ftl | 76 +++++- src/main/resources/templates/messages.ftl | 59 +++++ .../resources/templates/notifications.ftl | 77 ++++++ src/main/resources/templates/user.ftl | 228 ++++++++++-------- 26 files changed, 1262 insertions(+), 140 deletions(-) create mode 100644 src/main/java/com/yaoyuan/jiscuss/controller/api/RestMsgController.java create mode 100644 src/main/java/com/yaoyuan/jiscuss/dto/InboxItem.java create mode 100644 src/main/java/com/yaoyuan/jiscuss/entity/Message.java create mode 100644 src/main/java/com/yaoyuan/jiscuss/entity/Notification.java create mode 100644 src/main/java/com/yaoyuan/jiscuss/repository/MessageRepository.java create mode 100644 src/main/java/com/yaoyuan/jiscuss/repository/NotificationRepository.java create mode 100644 src/main/java/com/yaoyuan/jiscuss/service/IMessageService.java create mode 100644 src/main/java/com/yaoyuan/jiscuss/service/INotificationService.java create mode 100644 src/main/java/com/yaoyuan/jiscuss/service/impl/MessageServiceImpl.java create mode 100644 src/main/java/com/yaoyuan/jiscuss/service/impl/NotificationServiceImpl.java create mode 100644 src/main/resources/db/migration/V9__notification_message.sql create mode 100644 src/main/resources/templates/conversation.ftl create mode 100644 src/main/resources/templates/messages.ftl create mode 100644 src/main/resources/templates/notifications.ftl diff --git a/src/main/java/com/yaoyuan/jiscuss/JiccussApplication.java b/src/main/java/com/yaoyuan/jiscuss/JiccussApplication.java index c5dbb41..d35d200 100644 --- a/src/main/java/com/yaoyuan/jiscuss/JiccussApplication.java +++ b/src/main/java/com/yaoyuan/jiscuss/JiccussApplication.java @@ -3,9 +3,11 @@ package com.yaoyuan.jiscuss; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cache.annotation.EnableCaching; +import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication @EnableCaching +@EnableAsync public class JiccussApplication { public static void main(String[] args) { diff --git a/src/main/java/com/yaoyuan/jiscuss/controller/UserMsgController.java b/src/main/java/com/yaoyuan/jiscuss/controller/UserMsgController.java index 673aad6..2f251d7 100644 --- a/src/main/java/com/yaoyuan/jiscuss/controller/UserMsgController.java +++ b/src/main/java/com/yaoyuan/jiscuss/controller/UserMsgController.java @@ -1,15 +1,115 @@ package com.yaoyuan.jiscuss.controller; +import com.yaoyuan.jiscuss.dto.InboxItem; +import com.yaoyuan.jiscuss.entity.Message; +import com.yaoyuan.jiscuss.entity.Notification; +import com.yaoyuan.jiscuss.entity.User; +import com.yaoyuan.jiscuss.entity.UserInfo; +import com.yaoyuan.jiscuss.service.IMessageService; +import com.yaoyuan.jiscuss.service.INotificationService; +import com.yaoyuan.jiscuss.service.IUsersService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; import org.springframework.stereotype.Controller; +import org.springframework.ui.ModelMap; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.ArrayList; +import java.util.List; /** * @author Chuyaoyuan - * 用户消息控制器 + * 站内私信与通知控制器 */ @Controller public class UserMsgController extends BaseController { - /** - * 首页最新消息 - */ + @Autowired + private IMessageService messageService; + + @Autowired + private INotificationService notificationService; + + @Autowired + private IUsersService usersService; + + /** 收件箱:对话列表 */ + @GetMapping("/messages") + public String inbox(HttpServletRequest request, ModelMap map) { + UserInfo user = getUserInfo(request); + if (user == null) return "redirect:/login"; + + List inboxList = messageService.getInbox(user.getId()); + + List items = new ArrayList<>(); + for (Message msg : inboxList) { + Integer otherId = msg.getSenderId().equals(user.getId()) ? msg.getReceiverId() : msg.getSenderId(); + User other = usersService.findOne(otherId); + String rawName = (other != null) ? other.getUsername() : null; + String otherName = (rawName != null && !rawName.isBlank()) ? rawName : "用户" + otherId; + boolean hasUnread = !Boolean.TRUE.equals(msg.getIsRead()) && msg.getReceiverId().equals(user.getId()); + items.add(new InboxItem(otherId, otherName, msg.getContent(), msg.getCreateTime(), hasUnread)); + } + + map.put("username", user.getUsername()); + map.put("currentUserId", user.getId()); + map.put("inboxItems", items); + map.put("unreadMsgCount", messageService.countUnread(user.getId())); + map.put("unreadNotifCount", notificationService.countUnread(user.getId())); + return "messages"; + } + + /** 对话详情页 */ + @GetMapping("/messages/{otherId}") + public String conversation(@PathVariable Integer otherId, HttpServletRequest request, ModelMap map) { + UserInfo user = getUserInfo(request); + if (user == null) return "redirect:/login"; + + messageService.markConversationRead(user.getId(), otherId); + + List messages = messageService.getConversation(user.getId(), otherId); + User other = usersService.findOne(otherId); + + map.put("username", user.getUsername()); + map.put("currentUserId", user.getId()); + map.put("otherId", otherId); + map.put("otherUsername", other != null ? other.getUsername() : "用户" + otherId); + map.put("messages", messages); + map.put("unreadMsgCount", messageService.countUnread(user.getId())); + map.put("unreadNotifCount", notificationService.countUnread(user.getId())); + return "conversation"; + } + + /** 通知列表页 */ + @GetMapping("/notifications") + public String notifications(@RequestParam(defaultValue = "0") int page, + HttpServletRequest request, ModelMap map) { + UserInfo user = getUserInfo(request); + if (user == null) return "redirect:/login"; + + notificationService.markAllRead(user.getId()); + Page notifs = notificationService.listByUser(user.getId(), page, 20); + + List fromUsernames = new ArrayList<>(); + for (Notification n : notifs.getContent()) { + if (n.getFromUserId() != null) { + User from = usersService.findOne(n.getFromUserId()); + fromUsernames.add(from != null ? from.getUsername() : "用户" + n.getFromUserId()); + } else { + fromUsernames.add("系统"); + } + } + + map.put("username", user.getUsername()); + map.put("notifications", notifs.getContent()); + map.put("fromUsernames", fromUsernames); + map.put("totalPages", notifs.getTotalPages()); + map.put("currentPage", page); + map.put("unreadMsgCount", messageService.countUnread(user.getId())); + map.put("unreadNotifCount", 0L); + return "notifications"; + } } diff --git a/src/main/java/com/yaoyuan/jiscuss/controller/UserPageController.java b/src/main/java/com/yaoyuan/jiscuss/controller/UserPageController.java index c9ea434..0c56e0e 100644 --- a/src/main/java/com/yaoyuan/jiscuss/controller/UserPageController.java +++ b/src/main/java/com/yaoyuan/jiscuss/controller/UserPageController.java @@ -1,68 +1,105 @@ package com.yaoyuan.jiscuss.controller; +import com.yaoyuan.jiscuss.entity.Discussion; +import com.yaoyuan.jiscuss.entity.Post; +import com.yaoyuan.jiscuss.entity.User; import com.yaoyuan.jiscuss.entity.UserInfo; -import com.yaoyuan.jiscuss.service.IDiscussionsService; -import com.yaoyuan.jiscuss.service.ITagsService; +import com.yaoyuan.jiscuss.repository.DiscussionsRepository; +import com.yaoyuan.jiscuss.repository.LikeCollectRepository; +import com.yaoyuan.jiscuss.repository.PostsRepository; +import com.yaoyuan.jiscuss.service.IMessageService; +import com.yaoyuan.jiscuss.service.INotificationService; import com.yaoyuan.jiscuss.service.IUsersService; +import jakarta.servlet.http.HttpServletRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Controller; import org.springframework.ui.ModelMap; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; -import jakarta.servlet.http.HttpServletRequest; +import java.util.ArrayList; +import java.util.List; /** * @author Chuyaoyuan - * @Title: - * @Package com.yaoyuan.jiscuss.controller - * @Description: - * @date */ @Controller public class UserPageController extends BaseController { - private static Logger logger = LoggerFactory.getLogger(UserPageController.class); + private static final Logger logger = LoggerFactory.getLogger(UserPageController.class); @Autowired private IUsersService usersService; @Autowired - private IDiscussionsService discussionsService; + private DiscussionsRepository discussionsRepository; @Autowired - private ITagsService tagsService; + private PostsRepository postsRepository; + + @Autowired + private LikeCollectRepository likeCollectRepository; + + @Autowired + private IMessageService messageService; + + @Autowired + private INotificationService notificationService; /** - * 用户页面 - * - * @return + * 用户个人主页 */ @RequestMapping("/user") - public String user(@RequestParam(defaultValue = "discussion") String type, HttpServletRequest request, ModelMap map) { + public String user(@RequestParam(defaultValue = "discussion") String type, + HttpServletRequest request, ModelMap map) { - // type 判断 - if (type.equals("discussion")) { - map.put("discussion", "active"); + UserInfo currentUser = getUserInfo(request); + if (currentUser == null) return "redirect:/login"; + + User userEntity = usersService.findOne(currentUser.getId()); + long liveDiscCount = discussionsRepository.countByStartUserId(currentUser.getId()); + long liveReplyCount = postsRepository.countByCreateId(currentUser.getId()); + map.put("username", currentUser.getUsername()); + map.put("userEntity", userEntity); + map.put("liveDiscCount", liveDiscCount); + map.put("liveReplyCount", liveReplyCount); + map.put("type", type); + map.put("unreadMsgCount", messageService.countUnread(currentUser.getId())); + map.put("unreadNotifCount", notificationService.countUnread(currentUser.getId())); + + if ("discussion".equals(type)) { + Page discussions = discussionsRepository.findByStartUserIdOrderByStartTimeDesc( + currentUser.getId(), PageRequest.of(0, 20)); + map.put("discussions", discussions.getContent()); + } else if ("reply".equals(type)) { + Page posts = postsRepository.findByCreateIdOrderByCreateTimeDesc( + currentUser.getId(), PageRequest.of(0, 20)); + map.put("posts", posts.getContent()); + // Attach discussion titles + List discTitles = new ArrayList<>(); + for (Post p : posts.getContent()) { + if (p.getDiscussionId() != null) { + Discussion d = discussionsRepository.findById(p.getDiscussionId()).orElse(null); + discTitles.add(d != null ? d.getTitle() : ""); + } else { + discTitles.add(""); + } + } + map.put("discTitles", discTitles); + } else if ("like".equals(type)) { + List likedIds = likeCollectRepository.findLikedDiscussionIdsByUser(currentUser.getId()); + List liked = new ArrayList<>(); + for (Integer id : likedIds) { + Discussion d = discussionsRepository.findById(id).orElse(null); + if (d != null) liked.add(d); + } + map.put("likedDiscussions", liked); } - - if (type.equals("change")) { - map.put("change", "active"); - } - - if (type.equals("like")) { - map.put("like", "active"); - } - - UserInfo user = getUserInfo(request); - if (user != null) { - map.put("username", user.getUsername()); - map.put("data", "Jiscuss 用户:" + user.getUsername()); - } - - + return "user"; } } diff --git a/src/main/java/com/yaoyuan/jiscuss/controller/UserPostController.java b/src/main/java/com/yaoyuan/jiscuss/controller/UserPostController.java index 9a02f57..8a2e7e4 100644 --- a/src/main/java/com/yaoyuan/jiscuss/controller/UserPostController.java +++ b/src/main/java/com/yaoyuan/jiscuss/controller/UserPostController.java @@ -62,6 +62,9 @@ public class UserPostController extends BaseController { @Autowired private IpRegionService ipRegionService; + @Autowired + private com.yaoyuan.jiscuss.service.INotificationService notificationService; + /** * 首页查看主题列表(各种条件筛选,最热最新标签等) @@ -205,6 +208,14 @@ public class UserPostController extends BaseController { } Post savePost = postsService.insert(post); + // Notify the discussion starter about a reply (async, ignores self-reply) + if (post.getDiscussionId() != null && user != null) { + Discussion disc = discussionsService.findOne(post.getDiscussionId()); + if (disc != null && disc.getStartUserId() != null) { + notificationService.notifyReply(disc.getStartUserId(), user.getId(), + disc.getId(), disc.getTitle()); + } + } Map resultobj = new HashMap<>(); logger.info(">>>{}", savePost); resultobj.put("msg", "添加评论成功"); diff --git a/src/main/java/com/yaoyuan/jiscuss/controller/api/RestMsgController.java b/src/main/java/com/yaoyuan/jiscuss/controller/api/RestMsgController.java new file mode 100644 index 0000000..1a077a3 --- /dev/null +++ b/src/main/java/com/yaoyuan/jiscuss/controller/api/RestMsgController.java @@ -0,0 +1,129 @@ +package com.yaoyuan.jiscuss.controller.api; + +import com.yaoyuan.jiscuss.controller.BaseController; +import com.yaoyuan.jiscuss.entity.Message; +import com.yaoyuan.jiscuss.entity.Notification; +import com.yaoyuan.jiscuss.entity.User; +import com.yaoyuan.jiscuss.entity.UserInfo; +import com.yaoyuan.jiscuss.response.ApiResponse; +import com.yaoyuan.jiscuss.service.IMessageService; +import com.yaoyuan.jiscuss.service.INotificationService; +import com.yaoyuan.jiscuss.service.IUsersService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * REST API for private messages and notifications. + */ +@Tag(name = "Message API", description = "Private messages and notifications") +@RestController +@RequestMapping("/msg_api") +public class RestMsgController extends BaseController { + + @Autowired + private IMessageService messageService; + + @Autowired + private INotificationService notificationService; + + @Autowired + private IUsersService usersService; + + // ─── Debug (TEMP) ───────────────────────────────────────────────────── + + /** Temp debug: returns raw message counts and inbox list for current user. */ + @GetMapping("/debug/inbox") + public ApiResponse> debugInbox(HttpServletRequest request) { + UserInfo user = getUserInfo(request); + if (user == null) return ApiResponse.fail(401, "请先登录"); + long unread = messageService.countUnread(user.getId()); + List inbox = messageService.getInbox(user.getId()); + return ApiResponse.ok(Map.of( + "userId", user.getId(), + "username", user.getUsername(), + "unreadCount", unread, + "inboxSize", inbox.size(), + "inbox", inbox + )); + } + + // ─── User card (public — accessible to any authenticated user) ──────── + + /** Returns basic user info for the hover card (discussionsCount, commentsCount). */ + @Operation(summary = "Get user card info") + @GetMapping("/user-card/{id}") + public ApiResponse> getUserCard(@PathVariable Integer id, HttpServletRequest request) { + UserInfo currentUser = getUserInfo(request); + if (currentUser == null) return ApiResponse.fail(401, "请先登录"); + User user = usersService.findOne(id); + if (user == null) return ApiResponse.fail(404, "用户不存在"); + return ApiResponse.ok(Map.of( + "id", user.getId(), + "username", user.getUsername() != null ? user.getUsername() : "", + "discussionsCount", user.getDiscussionsCount() != null ? user.getDiscussionsCount() : 0, + "commentsCount", user.getCommentsCount() != null ? user.getCommentsCount() : 0 + )); + } + + // ─── Notifications ──────────────────────────────────────────────────── + + @Operation(summary = "Get unread notification + message counts") + @GetMapping("/unread-counts") + public ApiResponse> unreadCounts(HttpServletRequest request) { + UserInfo user = getUserInfo(request); + if (user == null) return ApiResponse.ok(Map.of("notifications", 0L, "messages", 0L)); + return ApiResponse.ok(Map.of( + "notifications", notificationService.countUnread(user.getId()), + "messages", messageService.countUnread(user.getId()) + )); + } + + @Operation(summary = "Mark a notification as read") + @PostMapping("/notifications/{id}/read") + public ApiResponse markNotifRead(@PathVariable Integer id, HttpServletRequest request) { + UserInfo user = getUserInfo(request); + if (user == null) return ApiResponse.fail(401, "请先登录"); + notificationService.markOneRead(id, user.getId()); + return ApiResponse.ok(null); + } + + @Operation(summary = "Mark all notifications as read") + @PostMapping("/notifications/read-all") + public ApiResponse markAllNotifRead(HttpServletRequest request) { + UserInfo user = getUserInfo(request); + if (user == null) return ApiResponse.fail(401, "请先登录"); + notificationService.markAllRead(user.getId()); + return ApiResponse.ok(null); + } + + // ─── Messages ───────────────────────────────────────────────────────── + + @Operation(summary = "Send a private message") + @PostMapping("/messages/send") + public ApiResponse sendMessage( + @RequestParam Integer receiverId, + @RequestParam String content, + HttpServletRequest request) { + UserInfo user = getUserInfo(request); + if (user == null) return ApiResponse.fail(401, "请先登录"); + if (user.getId().equals(receiverId)) return ApiResponse.fail(400, "不能给自己发消息"); + if (content == null || content.isBlank()) return ApiResponse.fail(400, "消息内容不能为空"); + Message msg = messageService.send(user.getId(), receiverId, content.trim()); + return ApiResponse.ok(msg); + } + + @Operation(summary = "Get conversation with another user") + @GetMapping("/messages/conversation/{otherId}") + public ApiResponse> getConversation(@PathVariable Integer otherId, HttpServletRequest request) { + UserInfo user = getUserInfo(request); + if (user == null) return ApiResponse.fail(401, "请先登录"); + messageService.markConversationRead(user.getId(), otherId); + return ApiResponse.ok(messageService.getConversation(user.getId(), otherId)); + } +} diff --git a/src/main/java/com/yaoyuan/jiscuss/dto/InboxItem.java b/src/main/java/com/yaoyuan/jiscuss/dto/InboxItem.java new file mode 100644 index 0000000..649259e --- /dev/null +++ b/src/main/java/com/yaoyuan/jiscuss/dto/InboxItem.java @@ -0,0 +1,23 @@ +package com.yaoyuan.jiscuss.dto; + +import lombok.Data; + +import java.util.Date; + +/** View model for one inbox entry (latest message in a conversation). */ +@Data +public class InboxItem { + private final Integer otherId; + private final String otherUsername; + private final String lastContent; + private final Date lastTime; + private final boolean hasUnread; + + public InboxItem(Integer otherId, String otherUsername, String lastContent, Date lastTime, boolean hasUnread) { + this.otherId = otherId; + this.otherUsername = otherUsername; + this.lastContent = lastContent; + this.lastTime = lastTime; + this.hasUnread = hasUnread; + } +} diff --git a/src/main/java/com/yaoyuan/jiscuss/entity/Discussion.java b/src/main/java/com/yaoyuan/jiscuss/entity/Discussion.java index 3306f6b..ebf37d5 100644 --- a/src/main/java/com/yaoyuan/jiscuss/entity/Discussion.java +++ b/src/main/java/com/yaoyuan/jiscuss/entity/Discussion.java @@ -82,6 +82,6 @@ public class Discussion implements Serializable { private Date createTime; @Column(name = "view_count") - private Integer viewCount = 0; + private Integer viewCount; } diff --git a/src/main/java/com/yaoyuan/jiscuss/entity/Message.java b/src/main/java/com/yaoyuan/jiscuss/entity/Message.java new file mode 100644 index 0000000..69c2079 --- /dev/null +++ b/src/main/java/com/yaoyuan/jiscuss/entity/Message.java @@ -0,0 +1,40 @@ +package com.yaoyuan.jiscuss.entity; + +import jakarta.persistence.*; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +@Data +@Entity +@Table(name = "message") +public class Message implements Serializable { + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(name = "sender_id", nullable = false) + private Integer senderId; + + @Column(name = "receiver_id", nullable = false) + private Integer receiverId; + + @Column(name = "content", columnDefinition = "TEXT", nullable = false) + private String content; + + @Column(name = "is_read") + private Boolean isRead = false; + + @Column(name = "create_time") + private Date createTime; + + /** + * Conversation identifier = "min(a,b)_max(a,b)", groups all messages + * between two users into a single conversation thread. + */ + @Column(name = "conversation_id", nullable = false) + private String conversationId; +} diff --git a/src/main/java/com/yaoyuan/jiscuss/entity/Notification.java b/src/main/java/com/yaoyuan/jiscuss/entity/Notification.java new file mode 100644 index 0000000..e2f71c4 --- /dev/null +++ b/src/main/java/com/yaoyuan/jiscuss/entity/Notification.java @@ -0,0 +1,50 @@ +package com.yaoyuan.jiscuss.entity; + +import jakarta.persistence.*; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +@Data +@Entity +@Table(name = "notification") +public class Notification implements Serializable { + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + /** Receiver user ID. */ + @Column(name = "user_id", nullable = false) + private Integer userId; + + /** Sender user ID (null for system notifications). */ + @Column(name = "from_user_id") + private Integer fromUserId; + + /** Notification type: reply / like / system. */ + @Column(name = "type", nullable = false) + private String type; + + @Column(name = "title") + private String title; + + @Column(name = "content", columnDefinition = "TEXT") + private String content; + + /** Related entity ID (e.g., discussion or post id). */ + @Column(name = "related_id") + private Integer relatedId; + + /** Type of related entity: discussion / post. */ + @Column(name = "related_type") + private String relatedType; + + @Column(name = "is_read") + private Boolean isRead = false; + + @Column(name = "create_time") + private Date createTime; +} diff --git a/src/main/java/com/yaoyuan/jiscuss/repository/DiscussionsRepository.java b/src/main/java/com/yaoyuan/jiscuss/repository/DiscussionsRepository.java index 08e455d..b9f0452 100644 --- a/src/main/java/com/yaoyuan/jiscuss/repository/DiscussionsRepository.java +++ b/src/main/java/com/yaoyuan/jiscuss/repository/DiscussionsRepository.java @@ -42,4 +42,11 @@ public interface DiscussionsRepository extends JpaRepository findByStartUserIdOrderByStartTimeDesc( + @org.springframework.data.repository.query.Param("userId") Integer userId, + org.springframework.data.domain.Pageable pageable); + long countByStartUserId(Integer startUserId); } diff --git a/src/main/java/com/yaoyuan/jiscuss/repository/LikeCollectRepository.java b/src/main/java/com/yaoyuan/jiscuss/repository/LikeCollectRepository.java index 2cea515..8cca38c 100644 --- a/src/main/java/com/yaoyuan/jiscuss/repository/LikeCollectRepository.java +++ b/src/main/java/com/yaoyuan/jiscuss/repository/LikeCollectRepository.java @@ -18,4 +18,8 @@ public interface LikeCollectRepository extends JpaRepository findPostVote(@Param("userId") Integer userId, @Param("postId") Integer postId); + + /** Discussions liked by a user (action = 'like'). */ + @Query("SELECT lc.discussionId FROM LikeCollect lc WHERE lc.userId = :userId AND lc.type = 'discussion' AND lc.action = 'like'") + java.util.List findLikedDiscussionIdsByUser(@Param("userId") Integer userId); } diff --git a/src/main/java/com/yaoyuan/jiscuss/repository/MessageRepository.java b/src/main/java/com/yaoyuan/jiscuss/repository/MessageRepository.java new file mode 100644 index 0000000..73f1561 --- /dev/null +++ b/src/main/java/com/yaoyuan/jiscuss/repository/MessageRepository.java @@ -0,0 +1,31 @@ +package com.yaoyuan.jiscuss.repository; + +import com.yaoyuan.jiscuss.entity.Message; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Repository +public interface MessageRepository extends JpaRepository { + + /** Latest messages in a conversation, most recent first. */ + List findByConversationIdOrderByCreateTimeDesc(String conversationId); + + /** All messages sent by user. */ + List findBySenderIdOrderByCreateTimeDesc(Integer senderId); + + /** All messages received by user. */ + List findByReceiverIdOrderByCreateTimeDesc(Integer receiverId); + + long countByReceiverIdAndIsReadFalse(Integer receiverId); + + @Modifying + @Transactional + @Query("UPDATE Message m SET m.isRead = true WHERE m.conversationId = :convId AND m.receiverId = :userId") + void markConversationReadForUser(@Param("convId") String convId, @Param("userId") Integer userId); +} diff --git a/src/main/java/com/yaoyuan/jiscuss/repository/NotificationRepository.java b/src/main/java/com/yaoyuan/jiscuss/repository/NotificationRepository.java new file mode 100644 index 0000000..e4cd224 --- /dev/null +++ b/src/main/java/com/yaoyuan/jiscuss/repository/NotificationRepository.java @@ -0,0 +1,29 @@ +package com.yaoyuan.jiscuss.repository; + +import com.yaoyuan.jiscuss.entity.Notification; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +public interface NotificationRepository extends JpaRepository { + + Page findByUserIdOrderByCreateTimeDesc(Integer userId, Pageable pageable); + + long countByUserIdAndIsReadFalse(Integer userId); + + @Modifying + @Transactional + @Query("UPDATE Notification n SET n.isRead = true WHERE n.userId = :userId") + void markAllReadByUserId(@Param("userId") Integer userId); + + @Modifying + @Transactional + @Query("UPDATE Notification n SET n.isRead = true WHERE n.id = :id AND n.userId = :userId") + void markReadById(@Param("id") Integer id, @Param("userId") Integer userId); +} diff --git a/src/main/java/com/yaoyuan/jiscuss/repository/PostsRepository.java b/src/main/java/com/yaoyuan/jiscuss/repository/PostsRepository.java index 531fb74..1d06614 100644 --- a/src/main/java/com/yaoyuan/jiscuss/repository/PostsRepository.java +++ b/src/main/java/com/yaoyuan/jiscuss/repository/PostsRepository.java @@ -89,4 +89,11 @@ public interface PostsRepository extends JpaRepository { // // @Query("from Post where parentId is not null and discussionId = :dId") // List findAllByDIdAndparentIdNotNull(@Param("dId")Integer dId); + + /** Posts (replies) created by a given user, most recent first. */ + @Query("SELECT p FROM Post p WHERE p.createId = :userId ORDER BY p.createTime DESC") + org.springframework.data.domain.Page findByCreateIdOrderByCreateTimeDesc( + @Param("userId") Integer userId, org.springframework.data.domain.Pageable pageable); + + long countByCreateId(Integer createId); } diff --git a/src/main/java/com/yaoyuan/jiscuss/service/IMessageService.java b/src/main/java/com/yaoyuan/jiscuss/service/IMessageService.java new file mode 100644 index 0000000..0f0e852 --- /dev/null +++ b/src/main/java/com/yaoyuan/jiscuss/service/IMessageService.java @@ -0,0 +1,23 @@ +package com.yaoyuan.jiscuss.service; + +import com.yaoyuan.jiscuss.entity.Message; + +import java.util.List; + +public interface IMessageService { + + /** Send a message from sender to receiver. */ + Message send(Integer senderId, Integer receiverId, String content); + + /** All messages in a conversation (ordered oldest→newest). */ + List getConversation(Integer userId, Integer otherId); + + /** Inbox: latest message per conversation, sorted by latest activity (max 50). */ + List getInbox(Integer userId); + + /** Unread message count for a user. */ + long countUnread(Integer userId); + + /** Mark an entire conversation as read for the current user. */ + void markConversationRead(Integer userId, Integer otherId); +} diff --git a/src/main/java/com/yaoyuan/jiscuss/service/INotificationService.java b/src/main/java/com/yaoyuan/jiscuss/service/INotificationService.java new file mode 100644 index 0000000..6910b71 --- /dev/null +++ b/src/main/java/com/yaoyuan/jiscuss/service/INotificationService.java @@ -0,0 +1,27 @@ +package com.yaoyuan.jiscuss.service; + +import com.yaoyuan.jiscuss.entity.Notification; +import org.springframework.data.domain.Page; + +public interface INotificationService { + + /** Create a notification for a user. */ + Notification create(Integer toUserId, Integer fromUserId, String type, String title, String content, + Integer relatedId, String relatedType); + + /** Convenience: create a "reply" notification. */ + void notifyReply(Integer toUserId, Integer fromUserId, Integer discussionId, String discussionTitle); + + /** Convenience: create a "like" notification for a post. */ + void notifyLike(Integer toUserId, Integer fromUserId, Integer postId, Integer discussionId); + + /** Unread notification count for a user. */ + long countUnread(Integer userId); + + /** Paged notification list for a user. */ + Page listByUser(Integer userId, int page, int size); + + void markAllRead(Integer userId); + + void markOneRead(Integer notificationId, Integer userId); +} diff --git a/src/main/java/com/yaoyuan/jiscuss/service/impl/LikeCollectServiceImpl.java b/src/main/java/com/yaoyuan/jiscuss/service/impl/LikeCollectServiceImpl.java index d233e34..c5c28fb 100644 --- a/src/main/java/com/yaoyuan/jiscuss/service/impl/LikeCollectServiceImpl.java +++ b/src/main/java/com/yaoyuan/jiscuss/service/impl/LikeCollectServiceImpl.java @@ -1,10 +1,12 @@ package com.yaoyuan.jiscuss.service.impl; import com.yaoyuan.jiscuss.entity.LikeCollect; +import com.yaoyuan.jiscuss.entity.Post; import com.yaoyuan.jiscuss.repository.DiscussionsRepository; import com.yaoyuan.jiscuss.repository.LikeCollectRepository; import com.yaoyuan.jiscuss.repository.PostsRepository; import com.yaoyuan.jiscuss.service.ILikeCollectService; +import com.yaoyuan.jiscuss.service.INotificationService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -24,6 +26,9 @@ public class LikeCollectServiceImpl implements ILikeCollectService { @Autowired private PostsRepository postsRepository; + @Autowired + private INotificationService notificationService; + @Transactional @Override public String voteDiscussion(Integer userId, Integer discussionId, String action) { @@ -85,6 +90,13 @@ public class LikeCollectServiceImpl implements ILikeCollectService { vote.setCreateTime(new Date()); likeCollectRepository.save(vote); adjustPost(postId, action, +1); + // Notify post author when liked (not disliked) + if ("like".equals(action)) { + Post post = postsRepository.findById(postId).orElse(null); + if (post != null && post.getCreateId() != null) { + notificationService.notifyLike(post.getCreateId(), userId, postId, post.getDiscussionId()); + } + } return action; } } diff --git a/src/main/java/com/yaoyuan/jiscuss/service/impl/MessageServiceImpl.java b/src/main/java/com/yaoyuan/jiscuss/service/impl/MessageServiceImpl.java new file mode 100644 index 0000000..a9a06cd --- /dev/null +++ b/src/main/java/com/yaoyuan/jiscuss/service/impl/MessageServiceImpl.java @@ -0,0 +1,85 @@ +package com.yaoyuan.jiscuss.service.impl; + +import com.yaoyuan.jiscuss.entity.Message; +import com.yaoyuan.jiscuss.repository.MessageRepository; +import com.yaoyuan.jiscuss.service.IMessageService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Date; +import java.util.List; + +@Service +public class MessageServiceImpl implements IMessageService { + + @Autowired + private MessageRepository messageRepository; + + private String buildConversationId(Integer a, Integer b) { + return Math.min(a, b) + "_" + Math.max(a, b); + } + + @Transactional + @Override + public Message send(Integer senderId, Integer receiverId, String content) { + Message msg = new Message(); + msg.setSenderId(senderId); + msg.setReceiverId(receiverId); + msg.setContent(content); + msg.setIsRead(false); + msg.setCreateTime(new Date()); + msg.setConversationId(buildConversationId(senderId, receiverId)); + return messageRepository.save(msg); + } + + @Transactional(readOnly = true) + @Override + public List getConversation(Integer userId, Integer otherId) { + String convId = buildConversationId(userId, otherId); + List msgs = messageRepository.findByConversationIdOrderByCreateTimeDesc(convId); + // reverse to get oldest-first order + java.util.Collections.reverse(msgs); + return msgs; + } + + @Transactional(readOnly = true) + @Override + public List getInbox(Integer userId) { + // Fetch sent and received separately, merge, then deduplicate by conversationId + List sent = messageRepository.findBySenderIdOrderByCreateTimeDesc(userId); + List received = messageRepository.findByReceiverIdOrderByCreateTimeDesc(userId); + + // Merge both lists, sort by createTime descending + java.util.List all = new java.util.ArrayList<>(); + all.addAll(sent); + all.addAll(received); + all.sort((a, b) -> { + if (a.getCreateTime() == null) return 1; + if (b.getCreateTime() == null) return -1; + return b.getCreateTime().compareTo(a.getCreateTime()); + }); + + // Keep only the latest message per conversation + java.util.LinkedHashMap seen = new java.util.LinkedHashMap<>(); + for (Message m : all) { + String cid = (m.getConversationId() != null && !m.getConversationId().isEmpty()) + ? m.getConversationId() + : Math.min(m.getSenderId(), m.getReceiverId()) + "_" + Math.max(m.getSenderId(), m.getReceiverId()); + seen.putIfAbsent(cid, m); + } + return new java.util.ArrayList<>(seen.values()); + } + + @Transactional(readOnly = true) + @Override + public long countUnread(Integer userId) { + return messageRepository.countByReceiverIdAndIsReadFalse(userId); + } + + @Transactional + @Override + public void markConversationRead(Integer userId, Integer otherId) { + messageRepository.markConversationReadForUser(buildConversationId(userId, otherId), userId); + } +} diff --git a/src/main/java/com/yaoyuan/jiscuss/service/impl/NotificationServiceImpl.java b/src/main/java/com/yaoyuan/jiscuss/service/impl/NotificationServiceImpl.java new file mode 100644 index 0000000..cb3e8db --- /dev/null +++ b/src/main/java/com/yaoyuan/jiscuss/service/impl/NotificationServiceImpl.java @@ -0,0 +1,83 @@ +package com.yaoyuan.jiscuss.service.impl; + +import com.yaoyuan.jiscuss.entity.Notification; +import com.yaoyuan.jiscuss.repository.NotificationRepository; +import com.yaoyuan.jiscuss.service.INotificationService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Date; + +@Service +public class NotificationServiceImpl implements INotificationService { + + @Autowired + private NotificationRepository notificationRepository; + + @Transactional + @Override + public Notification create(Integer toUserId, Integer fromUserId, String type, String title, + String content, Integer relatedId, String relatedType) { + // Do not notify yourself + if (toUserId != null && toUserId.equals(fromUserId)) { + return null; + } + Notification n = new Notification(); + n.setUserId(toUserId); + n.setFromUserId(fromUserId); + n.setType(type); + n.setTitle(title); + n.setContent(content); + n.setRelatedId(relatedId); + n.setRelatedType(relatedType); + n.setIsRead(false); + n.setCreateTime(new Date()); + return notificationRepository.save(n); + } + + @Async + @Override + public void notifyReply(Integer toUserId, Integer fromUserId, Integer discussionId, String discussionTitle) { + create(toUserId, fromUserId, "reply", + "有人回复了你的帖子", + "在话题《" + discussionTitle + "》中回复了你", + discussionId, "discussion"); + } + + @Async + @Override + public void notifyLike(Integer toUserId, Integer fromUserId, Integer postId, Integer discussionId) { + create(toUserId, fromUserId, "like", + "有人赞了你的回复", + null, postId, "post"); + } + + @Transactional(readOnly = true) + @Override + public long countUnread(Integer userId) { + return notificationRepository.countByUserIdAndIsReadFalse(userId); + } + + @Transactional(readOnly = true) + @Override + public Page listByUser(Integer userId, int page, int size) { + return notificationRepository.findByUserIdOrderByCreateTimeDesc( + userId, PageRequest.of(page, size)); + } + + @Transactional + @Override + public void markAllRead(Integer userId) { + notificationRepository.markAllReadByUserId(userId); + } + + @Transactional + @Override + public void markOneRead(Integer notificationId, Integer userId) { + notificationRepository.markReadById(notificationId, userId); + } +} diff --git a/src/main/resources/db/migration/V9__notification_message.sql b/src/main/resources/db/migration/V9__notification_message.sql new file mode 100644 index 0000000..86f189b --- /dev/null +++ b/src/main/resources/db/migration/V9__notification_message.sql @@ -0,0 +1,33 @@ +-- V9: Add notification and private message support + +-- Notification table: system/reply/like alerts to a user +CREATE TABLE IF NOT EXISTS notification ( + id INT PRIMARY KEY AUTO_INCREMENT, + user_id INT NOT NULL, -- receiver + from_user_id INT, -- sender (NULL for system notifications) + type VARCHAR(50) NOT NULL, -- 'reply', 'like', 'system' + title VARCHAR(200), + content TEXT, + related_id INT, -- e.g. discussion_id or post_id + related_type VARCHAR(50), -- 'discussion', 'post' + is_read BOOLEAN DEFAULT FALSE, + create_time DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_notification_user ON notification(user_id); +CREATE INDEX IF NOT EXISTS idx_notification_user_unread ON notification(user_id, is_read); + +-- Message table: private messages between users +CREATE TABLE IF NOT EXISTS message ( + id INT PRIMARY KEY AUTO_INCREMENT, + sender_id INT NOT NULL, + receiver_id INT NOT NULL, + content TEXT NOT NULL, + is_read BOOLEAN DEFAULT FALSE, + create_time DATETIME DEFAULT CURRENT_TIMESTAMP, + conversation_id VARCHAR(50) NOT NULL -- format: min_max of user IDs +); + +CREATE INDEX IF NOT EXISTS idx_message_receiver ON message(receiver_id); +CREATE INDEX IF NOT EXISTS idx_message_conversation ON message(conversation_id, create_time); +CREATE INDEX IF NOT EXISTS idx_message_receiver_unread ON message(receiver_id, is_read); diff --git a/src/main/resources/templates/comm/header.ftl b/src/main/resources/templates/comm/header.ftl index 1bc8e77..b86fb48 100644 --- a/src/main/resources/templates/comm/header.ftl +++ b/src/main/resources/templates/comm/header.ftl @@ -26,9 +26,25 @@ + <#if username??> + + +
<#if username??>
' + + '' ).css({ left: x + 14, top: y + 14 }).show(); } @@ -340,7 +341,7 @@ var userId = $(this).data('user-id'); if (!userId) return; if (cardCache[userId]) { showCard(cardCache[userId], e.clientX, e.clientY); return; } - $.getJSON('/user_api/user/' + userId, function(res) { + $.getJSON('/msg_api/user-card/' + userId, function(res) { if (res && res.code === 10000 && res.data) { cardCache[userId] = res.data; showCard(res.data, e.clientX, e.clientY); @@ -349,8 +350,12 @@ }).on('mousemove', '.user-card-trigger', function(e) { if ($card.is(':visible')) $card.css({ left: e.clientX + 14, top: e.clientY + 14 }); }).on('mouseleave', '.user-card-trigger', function() { - $card.hide(); + // Delay hide so user can click links inside card + setTimeout(function() { + if (!$card.is(':hover')) $card.hide(); + }, 300); }); + $card.on('mouseleave', function() { $card.hide(); }); }); function voteDiscussion(action) { @@ -391,6 +396,69 @@ + +<#-- 私信弹窗 Modal --> + + + diff --git a/src/main/resources/templates/messages.ftl b/src/main/resources/templates/messages.ftl new file mode 100644 index 0000000..c622130 --- /dev/null +++ b/src/main/resources/templates/messages.ftl @@ -0,0 +1,59 @@ + + + +私信收件箱 - Jiscuss + + + <#include "comm/commjs.ftl"/> + + +<#include "comm/header.ftl"/> + +
+

+ +
私信收件箱 + <#if unreadMsgCount?? && (unreadMsgCount > 0)> + ${unreadMsgCount} 未读 + +
+

+
+ + <#if inboxItems?has_content> + + <#else> +
+
+ + 暂无私信 +
+
+ +
+ +<#include "comm/footer.ftl"/> + + diff --git a/src/main/resources/templates/notifications.ftl b/src/main/resources/templates/notifications.ftl new file mode 100644 index 0000000..cfc3164 --- /dev/null +++ b/src/main/resources/templates/notifications.ftl @@ -0,0 +1,77 @@ + + +通知 - Jiscuss + + + + <#include "comm/commjs.ftl"/> + + +<#include "comm/header.ftl"/> + +
+

+ +
通知
+

+
+ + <#if notifications?has_content> +
+ <#list notifications as notif> +
+
+ <#if notif.type == "reply"> + + <#elseif notif.type == "like"> + + <#else> + + +
+
+
+ <#if fromUsernames?has_content && notif?index < fromUsernames?size> + ${fromUsernames[notif?index]} + + ${notif.title!"通知"} + <#if notif.relatedId?? && notif.relatedType?? && notif.relatedType == "discussion"> +  查看帖子 + <#elseif notif.relatedId?? && notif.relatedType?? && notif.relatedType == "post"> +  查看回复 + +
+ <#if notif.content??> +
${notif.content}
+ +
+ <#if notif.createTime??>${notif.createTime?string("yyyy-MM-dd HH:mm")} +
+
+
+ +
+ + <#if (totalPages > 1)> +
+ +
+ + + <#else> +
+
+ + 暂无通知 +
+
+ +
+ +<#include "comm/footer.ftl"/> + + diff --git a/src/main/resources/templates/user.ftl b/src/main/resources/templates/user.ftl index a1c700f..9219388 100644 --- a/src/main/resources/templates/user.ftl +++ b/src/main/resources/templates/user.ftl @@ -1,118 +1,152 @@ -用户详情页 +${username!}的个人主页 - Jiscuss - - - <#include "comm/commjs.ftl"/> + - + <#include "comm/header.ftl"/> -
-

${username}的个人主页

- -

详情

- -
- <#--
--> -
-
-
- -
- 主题... -
-
- 动态... -
-
- 喜欢收藏... -
-
-
-