feat(schedule): add course table screens and navigation

Add complete schedule functionality including:
- Schedule screen with weekly course table view
- Course detail screen with transparent modal presentation
- New ScheduleStack navigator integrated into main tab bar
- Schedule service for API interactions
- Type definitions for course entities

Also includes bug fixes for group invite/request handlers
to include required groupId parameter.
This commit is contained in:
2026-03-12 08:38:14 +08:00
parent 21293644b8
commit 0a0cbacbcc
25 changed files with 3050 additions and 260 deletions

View File

@@ -4,8 +4,10 @@ import (
"context"
"errors"
"fmt"
"log"
"time"
"carrot_bbs/internal/cache"
"carrot_bbs/internal/dto"
"carrot_bbs/internal/model"
"carrot_bbs/internal/pkg/sse"
@@ -58,6 +60,9 @@ type chatServiceImpl struct {
userRepo *repository.UserRepository
sensitive SensitiveService
sseHub *sse.Hub
// 缓存相关字段
conversationCache *cache.ConversationCache
}
// NewChatService 创建聊天服务
@@ -68,12 +73,25 @@ func NewChatService(
sensitive SensitiveService,
sseHub *sse.Hub,
) ChatService {
// 创建适配器
convRepoAdapter := cache.NewConversationRepositoryAdapter(repo)
msgRepoAdapter := cache.NewMessageRepositoryAdapter(repo)
// 创建会话缓存
conversationCache := cache.NewConversationCache(
cache.GetCache(),
convRepoAdapter,
msgRepoAdapter,
cache.DefaultConversationCacheSettings(),
)
return &chatServiceImpl{
db: db,
repo: repo,
userRepo: userRepo,
sensitive: sensitive,
sseHub: sseHub,
db: db,
repo: repo,
userRepo: userRepo,
sensitive: sensitive,
sseHub: sseHub,
conversationCache: conversationCache,
}
}
@@ -86,18 +104,33 @@ func (s *chatServiceImpl) publishSSEToUsers(userIDs []string, event string, payl
// GetOrCreateConversation 获取或创建私聊会话
func (s *chatServiceImpl) GetOrCreateConversation(ctx context.Context, user1ID, user2ID string) (*model.Conversation, error) {
return s.repo.GetOrCreatePrivateConversation(user1ID, user2ID)
conv, err := s.repo.GetOrCreatePrivateConversation(user1ID, user2ID)
if err != nil {
return nil, err
}
// 失效会话列表缓存
if s.conversationCache != nil {
s.conversationCache.InvalidateConversationList(user1ID)
s.conversationCache.InvalidateConversationList(user2ID)
}
return conv, nil
}
// GetConversationList 获取用户的会话列表
// GetConversationList 获取用户的会话列表(带缓存)
func (s *chatServiceImpl) GetConversationList(ctx context.Context, userID string, page, pageSize int) ([]*model.Conversation, int64, error) {
// 优先使用缓存
if s.conversationCache != nil {
return s.conversationCache.GetConversationList(ctx, userID, page, pageSize)
}
return s.repo.GetConversations(userID, page, pageSize)
}
// GetConversationByID 获取会话详情
// GetConversationByID 获取会话详情(带缓存)
func (s *chatServiceImpl) GetConversationByID(ctx context.Context, conversationID string, userID string) (*model.Conversation, error) {
// 验证用户是否是会话参与者
participant, err := s.repo.GetParticipant(conversationID, userID)
participant, err := s.getParticipant(ctx, conversationID, userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("conversation not found or no permission")
@@ -105,21 +138,33 @@ func (s *chatServiceImpl) GetConversationByID(ctx context.Context, conversationI
return nil, fmt.Errorf("failed to get participant: %w", err)
}
// 获取会话信息
conv, err := s.repo.GetConversation(conversationID)
// 获取会话信息(优先使用缓存)
var conv *model.Conversation
if s.conversationCache != nil {
conv, err = s.conversationCache.GetConversation(ctx, conversationID)
} else {
conv, err = s.repo.GetConversation(conversationID)
}
if err != nil {
return nil, fmt.Errorf("failed to get conversation: %w", err)
}
// 填充用户的已读位置信息
_ = participant // 可以用于返回已读位置等信息
return conv, nil
}
// getParticipant 获取参与者信息(优先使用缓存)
func (s *chatServiceImpl) getParticipant(ctx context.Context, conversationID, userID string) (*model.ConversationParticipant, error) {
if s.conversationCache != nil {
return s.conversationCache.GetParticipant(ctx, conversationID, userID)
}
return s.repo.GetParticipant(conversationID, userID)
}
// DeleteConversationForSelf 仅自己删除会话
func (s *chatServiceImpl) DeleteConversationForSelf(ctx context.Context, conversationID string, userID string) error {
participant, err := s.repo.GetParticipant(conversationID, userID)
participant, err := s.getParticipant(ctx, conversationID, userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("conversation not found or no permission")
@@ -133,12 +178,18 @@ func (s *chatServiceImpl) DeleteConversationForSelf(ctx context.Context, convers
if err := s.repo.HideConversationForUser(conversationID, userID); err != nil {
return fmt.Errorf("failed to hide conversation: %w", err)
}
// 失效会话列表缓存
if s.conversationCache != nil {
s.conversationCache.InvalidateConversationList(userID)
}
return nil
}
// SetConversationPinned 设置会话置顶(用户维度)
func (s *chatServiceImpl) SetConversationPinned(ctx context.Context, conversationID string, userID string, isPinned bool) error {
participant, err := s.repo.GetParticipant(conversationID, userID)
participant, err := s.getParticipant(ctx, conversationID, userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("conversation not found or no permission")
@@ -152,13 +203,20 @@ func (s *chatServiceImpl) SetConversationPinned(ctx context.Context, conversatio
if err := s.repo.UpdatePinned(conversationID, userID, isPinned); err != nil {
return fmt.Errorf("failed to update pinned status: %w", err)
}
// 失效缓存
if s.conversationCache != nil {
s.conversationCache.InvalidateParticipant(conversationID, userID)
s.conversationCache.InvalidateConversationList(userID)
}
return nil
}
// SendMessage 发送消息(使用 segments
func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conversationID string, segments model.MessageSegments, replyToID *string) (*model.Message, error) {
// 首先验证会话是否存在
conv, err := s.repo.GetConversation(conversationID)
conv, err := s.getConversation(ctx, conversationID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("会话不存在,请重新创建会话")
@@ -166,9 +224,9 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
return nil, fmt.Errorf("failed to get conversation: %w", err)
}
// 拉黑限制:仅拦截被拉黑方 -> 拉黑人方向
// 拉黑限制:仅拦截"被拉黑方 -> 拉黑人"方向
if conv.Type == model.ConversationTypePrivate && s.userRepo != nil {
participants, pErr := s.repo.GetConversationParticipants(conversationID)
participants, pErr := s.getParticipants(ctx, conversationID)
if pErr != nil {
return nil, fmt.Errorf("failed to get participants: %w", pErr)
}
@@ -209,7 +267,7 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
}
// 验证用户是否是会话参与者
participant, err := s.repo.GetParticipant(conversationID, senderID)
participant, err := s.getParticipant(ctx, conversationID, senderID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("您不是该会话的参与者")
@@ -231,11 +289,27 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
return nil, fmt.Errorf("failed to save message: %w", err)
}
// 新消息会改变分页结果,先失效分页缓存,避免读到旧列表
if s.conversationCache != nil {
s.conversationCache.InvalidateMessagePages(conversationID)
}
// 异步写入缓存
go func() {
if err := s.cacheMessage(context.Background(), conversationID, message); err != nil {
log.Printf("[ChatService] async cache message failed, convID=%s, msgID=%s, err=%v", conversationID, message.ID, err)
}
}()
// 获取会话中的参与者并发送 SSE
participants, err := s.repo.GetConversationParticipants(conversationID)
participants, err := s.getParticipants(ctx, conversationID)
if err == nil {
targetIDs := make([]string, 0, len(participants))
for _, p := range participants {
// 私聊场景下,发送者已经从 HTTP 响应拿到消息,避免再通过 SSE 回推导致本端重复展示。
if conv.Type == model.ConversationTypePrivate && p.UserID == senderID {
continue
}
targetIDs = append(targetIDs, p.UserID)
}
detailType := "private"
@@ -250,6 +324,10 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
if p.UserID == senderID {
continue
}
// 失效未读数缓存
if s.conversationCache != nil {
s.conversationCache.InvalidateUnreadCount(p.UserID, conversationID)
}
if totalUnread, uErr := s.repo.GetAllUnreadCount(p.UserID); uErr == nil {
s.publishSSEToUsers([]string{p.UserID}, "conversation_unread", map[string]interface{}{
"conversation_id": conversationID,
@@ -259,11 +337,46 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
}
}
// 失效会话列表缓存
if s.conversationCache != nil {
for _, p := range participants {
s.conversationCache.InvalidateConversationList(p.UserID)
}
}
_ = participant // 避免未使用变量警告
return message, nil
}
// getConversation 获取会话(优先使用缓存)
func (s *chatServiceImpl) getConversation(ctx context.Context, conversationID string) (*model.Conversation, error) {
if s.conversationCache != nil {
return s.conversationCache.GetConversation(ctx, conversationID)
}
return s.repo.GetConversation(conversationID)
}
// getParticipants 获取会话参与者(优先使用缓存)
func (s *chatServiceImpl) getParticipants(ctx context.Context, conversationID string) ([]*model.ConversationParticipant, error) {
if s.conversationCache != nil {
return s.conversationCache.GetParticipants(ctx, conversationID)
}
return s.repo.GetConversationParticipants(conversationID)
}
// cacheMessage 缓存消息(内部方法)
func (s *chatServiceImpl) cacheMessage(ctx context.Context, convID string, msg *model.Message) error {
if s.conversationCache == nil {
return nil
}
asyncCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
return s.conversationCache.CacheMessage(asyncCtx, convID, msg)
}
func containsImageSegment(segments model.MessageSegments) bool {
for _, seg := range segments {
if seg.Type == string(model.ContentTypeImage) || seg.Type == "image" {
@@ -273,10 +386,10 @@ func containsImageSegment(segments model.MessageSegments) bool {
return false
}
// GetMessages 获取消息历史(分页)
// GetMessages 获取消息历史(分页,带缓存
func (s *chatServiceImpl) GetMessages(ctx context.Context, conversationID string, userID string, page, pageSize int) ([]*model.Message, int64, error) {
// 验证用户是否是会话参与者
_, err := s.repo.GetParticipant(conversationID, userID)
_, err := s.getParticipant(ctx, conversationID, userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, 0, errors.New("conversation not found or no permission")
@@ -284,13 +397,18 @@ func (s *chatServiceImpl) GetMessages(ctx context.Context, conversationID string
return nil, 0, fmt.Errorf("failed to get participant: %w", err)
}
// 优先使用缓存
if s.conversationCache != nil {
return s.conversationCache.GetMessages(ctx, conversationID, page, pageSize)
}
return s.repo.GetMessages(conversationID, page, pageSize)
}
// GetMessagesAfterSeq 获取指定seq之后的消息用于增量同步
func (s *chatServiceImpl) GetMessagesAfterSeq(ctx context.Context, conversationID string, userID string, afterSeq int64, limit int) ([]*model.Message, error) {
// 验证用户是否是会话参与者
_, err := s.repo.GetParticipant(conversationID, userID)
_, err := s.getParticipant(ctx, conversationID, userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("conversation not found or no permission")
@@ -308,7 +426,7 @@ func (s *chatServiceImpl) GetMessagesAfterSeq(ctx context.Context, conversationI
// GetMessagesBeforeSeq 获取指定seq之前的历史消息用于下拉加载更多
func (s *chatServiceImpl) GetMessagesBeforeSeq(ctx context.Context, conversationID string, userID string, beforeSeq int64, limit int) ([]*model.Message, error) {
// 验证用户是否是会话参与者
_, err := s.repo.GetParticipant(conversationID, userID)
_, err := s.getParticipant(ctx, conversationID, userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("conversation not found or no permission")
@@ -326,7 +444,7 @@ func (s *chatServiceImpl) GetMessagesBeforeSeq(ctx context.Context, conversation
// MarkAsRead 标记已读
func (s *chatServiceImpl) MarkAsRead(ctx context.Context, conversationID string, userID string, seq int64) error {
// 验证用户是否是会话参与者
_, err := s.repo.GetParticipant(conversationID, userID)
_, err := s.getParticipant(ctx, conversationID, userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("conversation not found or no permission")
@@ -334,17 +452,27 @@ func (s *chatServiceImpl) MarkAsRead(ctx context.Context, conversationID string,
return fmt.Errorf("failed to get participant: %w", err)
}
// 更新参与者的已读位置
// 1. 先写入DB保证数据一致性DB是唯一数据源
err = s.repo.UpdateLastReadSeq(conversationID, userID, seq)
if err != nil {
return fmt.Errorf("failed to update last read seq: %w", err)
}
participants, pErr := s.repo.GetConversationParticipants(conversationID)
// 2. DB 写入成功后失效缓存Cache-Aside 模式)
if s.conversationCache != nil {
// 失效参与者缓存,下次读取时会从 DB 加载最新数据
s.conversationCache.InvalidateParticipant(conversationID, userID)
// 失效未读数缓存
s.conversationCache.InvalidateUnreadCount(userID, conversationID)
// 失效会话列表缓存
s.conversationCache.InvalidateConversationList(userID)
}
participants, pErr := s.getParticipants(ctx, conversationID)
if pErr == nil {
detailType := "private"
groupID := ""
if conv, convErr := s.repo.GetConversation(conversationID); convErr == nil && conv.Type == model.ConversationTypeGroup {
if conv, convErr := s.getConversation(ctx, conversationID); convErr == nil && conv.Type == model.ConversationTypeGroup {
detailType = "group"
if conv.GroupID != nil {
groupID = *conv.GroupID
@@ -372,10 +500,10 @@ func (s *chatServiceImpl) MarkAsRead(ctx context.Context, conversationID string,
return nil
}
// GetUnreadCount 获取指定会话的未读消息数
// GetUnreadCount 获取指定会话的未读消息数(带缓存)
func (s *chatServiceImpl) GetUnreadCount(ctx context.Context, conversationID string, userID string) (int64, error) {
// 验证用户是否是会话参与者
_, err := s.repo.GetParticipant(conversationID, userID)
_, err := s.getParticipant(ctx, conversationID, userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, errors.New("conversation not found or no permission")
@@ -383,6 +511,11 @@ func (s *chatServiceImpl) GetUnreadCount(ctx context.Context, conversationID str
return 0, fmt.Errorf("failed to get participant: %w", err)
}
// 优先使用缓存
if s.conversationCache != nil {
return s.conversationCache.GetUnreadCount(ctx, userID, conversationID)
}
return s.repo.GetUnreadCount(conversationID, userID)
}
@@ -427,10 +560,15 @@ func (s *chatServiceImpl) RecallMessage(ctx context.Context, messageID string, u
return fmt.Errorf("failed to recall message: %w", err)
}
if participants, pErr := s.repo.GetConversationParticipants(message.ConversationID); pErr == nil {
// 失效消息缓存
if s.conversationCache != nil {
s.conversationCache.InvalidateConversation(message.ConversationID)
}
if participants, pErr := s.getParticipants(ctx, message.ConversationID); pErr == nil {
detailType := "private"
groupID := ""
if conv, convErr := s.repo.GetConversation(message.ConversationID); convErr == nil && conv.Type == model.ConversationTypeGroup {
if conv, convErr := s.getConversation(ctx, message.ConversationID); convErr == nil && conv.Type == model.ConversationTypeGroup {
detailType = "group"
if conv.GroupID != nil {
groupID = *conv.GroupID
@@ -465,7 +603,7 @@ func (s *chatServiceImpl) DeleteMessage(ctx context.Context, messageID string, u
}
// 验证用户是否是会话参与者
_, err = s.repo.GetParticipant(message.ConversationID, userID)
_, err = s.getParticipant(ctx, message.ConversationID, userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("no permission to delete this message")
@@ -485,6 +623,11 @@ func (s *chatServiceImpl) DeleteMessage(ctx context.Context, messageID string, u
return fmt.Errorf("failed to delete message: %w", err)
}
// 失效消息缓存
if s.conversationCache != nil {
s.conversationCache.InvalidateConversation(message.ConversationID)
}
return nil
}
@@ -495,19 +638,19 @@ func (s *chatServiceImpl) SendTyping(ctx context.Context, senderID string, conve
}
// 验证用户是否是会话参与者
_, err := s.repo.GetParticipant(conversationID, senderID)
_, err := s.getParticipant(ctx, conversationID, senderID)
if err != nil {
return
}
// 获取会话中的其他参与者
participants, err := s.repo.GetConversationParticipants(conversationID)
participants, err := s.getParticipants(ctx, conversationID)
if err != nil {
return
}
detailType := "private"
if conv, convErr := s.repo.GetConversation(conversationID); convErr == nil && conv.Type == model.ConversationTypeGroup {
if conv, convErr := s.getConversation(ctx, conversationID); convErr == nil && conv.Type == model.ConversationTypeGroup {
detailType = "group"
}
for _, p := range participants {
@@ -537,7 +680,7 @@ func (s *chatServiceImpl) IsUserOnline(userID string) bool {
// 适用于群聊等由调用方自行负责推送的场景
func (s *chatServiceImpl) SaveMessage(ctx context.Context, senderID string, conversationID string, segments model.MessageSegments, replyToID *string) (*model.Message, error) {
// 验证会话是否存在
_, err := s.repo.GetConversation(conversationID)
_, err := s.getConversation(ctx, conversationID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("会话不存在,请重新创建会话")
@@ -546,7 +689,7 @@ func (s *chatServiceImpl) SaveMessage(ctx context.Context, senderID string, conv
}
// 验证用户是否是会话参与者
_, err = s.repo.GetParticipant(conversationID, senderID)
_, err = s.getParticipant(ctx, conversationID, senderID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("您不是该会话的参与者")
@@ -566,5 +709,17 @@ func (s *chatServiceImpl) SaveMessage(ctx context.Context, senderID string, conv
return nil, fmt.Errorf("failed to save message: %w", err)
}
// 新消息会改变分页结果,先失效分页缓存,避免读到旧列表
if s.conversationCache != nil {
s.conversationCache.InvalidateMessagePages(conversationID)
}
// 异步写入缓存
go func() {
if err := s.cacheMessage(context.Background(), conversationID, message); err != nil {
log.Printf("[ChatService] async cache message failed, convID=%s, msgID=%s, err=%v", conversationID, message.ID, err)
}
}()
return message, nil
}

View File

@@ -145,6 +145,45 @@ func (s *groupService) publishGroupNotice(groupID string, notice groupNoticeMess
}
}
// invalidateConversationCachesAfterSystemMessage 系统消息写入后失效相关缓存
func (s *groupService) invalidateConversationCachesAfterSystemMessage(conversationID string) {
if conversationID == "" || s.messageRepo == nil {
return
}
// 新系统消息会影响消息分页列表
cache.InvalidateMessagePages(s.cache, conversationID)
// 参与者列表可能发生变化(加群/退群)后,这里统一清理一次
s.cache.Delete(cache.ParticipantListKey(conversationID))
participants, err := s.messageRepo.GetConversationParticipants(conversationID)
if err != nil {
return
}
for _, p := range participants {
if p == nil || p.UserID == "" {
continue
}
// 会话最后消息、未读数会变化,清理用户维度缓存
cache.InvalidateConversationList(s.cache, p.UserID)
cache.InvalidateUnreadConversation(s.cache, p.UserID)
cache.InvalidateUnreadDetail(s.cache, p.UserID, conversationID)
}
}
// invalidateConversationCachesAfterMembershipChange 成员变更后失效相关缓存
func (s *groupService) invalidateConversationCachesAfterMembershipChange(conversationID, userID string) {
if conversationID == "" {
return
}
s.cache.Delete(cache.ParticipantListKey(conversationID))
if userID != "" {
s.cache.Delete(cache.ParticipantKey(conversationID, userID))
cache.InvalidateConversationList(s.cache, userID)
cache.InvalidateUnreadConversation(s.cache, userID)
cache.InvalidateUnreadDetail(s.cache, userID, conversationID)
}
}
// ==================== 群组管理 ====================
// CreateGroup 创建群组
@@ -444,6 +483,7 @@ func (s *groupService) broadcastMemberJoinNotice(groupID string, targetUserID st
log.Printf("[broadcastMemberJoinNotice] 保存入群提示消息失败: groupID=%s, userID=%s, err=%v", groupID, targetUserID, err)
} else {
savedMessage = msg
s.invalidateConversationCachesAfterSystemMessage(conv.ID)
}
} else {
log.Printf("[broadcastMemberJoinNotice] 获取群组会话失败: groupID=%s, err=%v", groupID, err)
@@ -502,6 +542,7 @@ func (s *groupService) addMemberToGroupAndConversation(group *model.Group, userI
if err := s.messageRepo.AddParticipant(conv.ID, userID); err != nil {
log.Printf("[addMemberToGroupAndConversation] 添加会话参与者失败: groupID=%s, userID=%s, err=%v", group.ID, userID, err)
}
s.invalidateConversationCachesAfterMembershipChange(conv.ID, userID)
}
}
cache.InvalidateGroupMembers(s.cache, group.ID)
@@ -1036,6 +1077,7 @@ func (s *groupService) LeaveGroup(userID string, groupID string) error {
// 如果移除参与者失败,记录日志但不阻塞退出群流程
fmt.Printf("[WARN] LeaveGroup: failed to remove participant %s from conversation %s, error: %v\n", userID, conv.ID, err)
}
s.invalidateConversationCachesAfterMembershipChange(conv.ID, userID)
}
// 失效群组成员缓存
@@ -1092,6 +1134,7 @@ func (s *groupService) RemoveMember(userID string, groupID string, targetUserID
if err := s.messageRepo.RemoveParticipant(conv.ID, targetUserID); err != nil {
log.Printf("[RemoveMember] 移除会话参与者失败: groupID=%s, userID=%s, err=%v", groupID, targetUserID, err)
}
s.invalidateConversationCachesAfterMembershipChange(conv.ID, targetUserID)
}
}
@@ -1290,6 +1333,7 @@ func (s *groupService) MuteMember(userID string, groupID string, targetUserID st
} else {
savedMessage = msg
log.Printf("[MuteMember] 禁言消息已保存, ID=%s, Seq=%d", msg.ID, msg.Seq)
s.invalidateConversationCachesAfterSystemMessage(conv.ID)
}
} else {
log.Printf("[MuteMember] 获取群组会话失败: %v", err)

View File

@@ -2,11 +2,14 @@ package service
import (
"context"
"log"
"time"
"carrot_bbs/internal/cache"
"carrot_bbs/internal/model"
"carrot_bbs/internal/repository"
"gorm.io/gorm"
)
// 缓存TTL常量
@@ -21,15 +24,37 @@ const (
// MessageService 消息服务
type MessageService struct {
db *gorm.DB
// 基础仓储
messageRepo *repository.MessageRepository
cache cache.Cache
// 缓存相关字段
conversationCache *cache.ConversationCache
// 基础缓存(用于简单缓存操作)
baseCache cache.Cache
}
// NewMessageService 创建消息服务
func NewMessageService(messageRepo *repository.MessageRepository) *MessageService {
func NewMessageService(db *gorm.DB, messageRepo *repository.MessageRepository) *MessageService {
// 创建适配器
convRepoAdapter := cache.NewConversationRepositoryAdapter(messageRepo)
msgRepoAdapter := cache.NewMessageRepositoryAdapter(messageRepo)
// 创建会话缓存
conversationCache := cache.NewConversationCache(
cache.GetCache(),
convRepoAdapter,
msgRepoAdapter,
cache.DefaultConversationCacheSettings(),
)
return &MessageService{
messageRepo: messageRepo,
cache: cache.GetCache(),
db: db,
messageRepo: messageRepo,
conversationCache: conversationCache,
baseCache: cache.GetCache(),
}
}
@@ -61,20 +86,50 @@ func (s *MessageService) SendMessage(ctx context.Context, senderID, receiverID s
return nil, err
}
// 新消息会改变分页结果,先失效分页缓存,避免读到旧列表
if s.conversationCache != nil {
s.conversationCache.InvalidateMessagePages(conv.ID)
}
// 异步写入缓存
go func() {
if err := s.cacheMessage(context.Background(), conv.ID, msg); err != nil {
log.Printf("[MessageService] async cache message failed, convID=%s, msgID=%s, err=%v", conv.ID, msg.ID, err)
}
}()
// 失效会话列表缓存(发送者和接收者)
cache.InvalidateConversationList(s.cache, senderID)
cache.InvalidateConversationList(s.cache, receiverID)
s.conversationCache.InvalidateConversationList(senderID)
s.conversationCache.InvalidateConversationList(receiverID)
// 失效未读数缓存
cache.InvalidateUnreadConversation(s.cache, receiverID)
cache.InvalidateUnreadDetail(s.cache, receiverID, conv.ID)
cache.InvalidateUnreadConversation(s.baseCache, receiverID)
s.conversationCache.InvalidateUnreadCount(receiverID, conv.ID)
return msg, nil
}
// cacheMessage 缓存消息(内部方法)
func (s *MessageService) cacheMessage(ctx context.Context, convID string, msg *model.Message) error {
if s.conversationCache == nil {
return nil
}
asyncCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
return s.conversationCache.CacheMessage(asyncCtx, convID, msg)
}
// GetConversations 获取会话列表(带缓存)
// userID 参数为 string 类型UUID格式与JWT中user_id保持一致
func (s *MessageService) GetConversations(ctx context.Context, userID string, page, pageSize int) ([]*model.Conversation, int64, error) {
// 优先使用 ConversationCache
if s.conversationCache != nil {
return s.conversationCache.GetConversationList(ctx, userID, page, pageSize)
}
// 降级到基础缓存
cacheSettings := cache.GetSettings()
conversationTTL := cacheSettings.ConversationTTL
if conversationTTL <= 0 {
@@ -92,7 +147,7 @@ func (s *MessageService) GetConversations(ctx context.Context, userID string, pa
// 生成缓存键
cacheKey := cache.ConversationListKey(userID, page, pageSize)
result, err := cache.GetOrLoadTyped[*ConversationListResult](
s.cache,
s.baseCache,
cacheKey,
conversationTTL,
jitter,
@@ -117,8 +172,14 @@ func (s *MessageService) GetConversations(ctx context.Context, userID string, pa
return result.Conversations, result.Total, nil
}
// GetMessages 获取消息列表
// GetMessages 获取消息列表(带缓存)
func (s *MessageService) GetMessages(ctx context.Context, conversationID string, page, pageSize int) ([]*model.Message, int64, error) {
// 优先使用 ConversationCache
if s.conversationCache != nil {
return s.conversationCache.GetMessages(ctx, conversationID, page, pageSize)
}
// 降级到直接访问数据库
return s.messageRepo.GetMessages(conversationID, page, pageSize)
}
@@ -127,20 +188,25 @@ func (s *MessageService) GetMessagesAfterSeq(ctx context.Context, conversationID
return s.messageRepo.GetMessagesAfterSeq(conversationID, afterSeq, limit)
}
// MarkAsRead 标记为已读
// MarkAsRead 标记为已读(使用 Cache-Aside 模式)
// userID 参数为 string 类型UUID格式与JWT中user_id保持一致
func (s *MessageService) MarkAsRead(ctx context.Context, conversationID string, userID string, lastReadSeq int64) error {
// 1. 先写入DB保证数据一致性DB是唯一数据源
err := s.messageRepo.UpdateLastReadSeq(conversationID, userID, lastReadSeq)
if err != nil {
return err
}
// 失效未读数缓存
cache.InvalidateUnreadConversation(s.cache, userID)
cache.InvalidateUnreadDetail(s.cache, userID, conversationID)
// 失效会话列表缓存
cache.InvalidateConversationList(s.cache, userID)
// 2. DB 写入成功后失效缓存Cache-Aside 模式)
if s.conversationCache != nil {
// 失效参与者缓存,下次读取时会从 DB 加载最新数据
s.conversationCache.InvalidateParticipant(conversationID, userID)
// 失效未读数缓存
s.conversationCache.InvalidateUnreadCount(userID, conversationID)
// 失效会话列表缓存
s.conversationCache.InvalidateConversationList(userID)
}
cache.InvalidateUnreadConversation(s.baseCache, userID)
return nil
}
@@ -148,6 +214,12 @@ func (s *MessageService) MarkAsRead(ctx context.Context, conversationID string,
// GetUnreadCount 获取未读消息数(带缓存)
// userID 参数为 string 类型UUID格式与JWT中user_id保持一致
func (s *MessageService) GetUnreadCount(ctx context.Context, conversationID string, userID string) (int64, error) {
// 优先使用 ConversationCache
if s.conversationCache != nil {
return s.conversationCache.GetUnreadCount(ctx, userID, conversationID)
}
// 降级到基础缓存
cacheSettings := cache.GetSettings()
unreadTTL := cacheSettings.UnreadCountTTL
if unreadTTL <= 0 {
@@ -166,7 +238,7 @@ func (s *MessageService) GetUnreadCount(ctx context.Context, conversationID stri
cacheKey := cache.UnreadDetailKey(userID, conversationID)
return cache.GetOrLoadTyped[int64](
s.cache,
s.baseCache,
cacheKey,
unreadTTL,
jitter,
@@ -186,14 +258,18 @@ func (s *MessageService) GetOrCreateConversation(ctx context.Context, user1ID, u
}
// 失效会话列表缓存
cache.InvalidateConversationList(s.cache, user1ID)
cache.InvalidateConversationList(s.cache, user2ID)
s.conversationCache.InvalidateConversationList(user1ID)
s.conversationCache.InvalidateConversationList(user2ID)
return conv, nil
}
// GetConversationParticipants 获取会话参与者列表
func (s *MessageService) GetConversationParticipants(conversationID string) ([]*model.ConversationParticipant, error) {
// 优先使用缓存
if s.conversationCache != nil {
return s.conversationCache.GetParticipants(context.Background(), conversationID)
}
return s.messageRepo.GetConversationParticipants(conversationID)
}
@@ -204,12 +280,12 @@ func ParseConversationID(idStr string) (string, error) {
// InvalidateUserConversationCache 失效用户会话相关缓存(供外部调用)
func (s *MessageService) InvalidateUserConversationCache(userID string) {
cache.InvalidateConversationList(s.cache, userID)
cache.InvalidateUnreadConversation(s.cache, userID)
s.conversationCache.InvalidateConversationList(userID)
cache.InvalidateUnreadConversation(s.baseCache, userID)
}
// InvalidateUserUnreadCache 失效用户未读数缓存(供外部调用)
func (s *MessageService) InvalidateUserUnreadCache(userID, conversationID string) {
cache.InvalidateUnreadConversation(s.cache, userID)
cache.InvalidateUnreadDetail(s.cache, userID, conversationID)
cache.InvalidateUnreadConversation(s.baseCache, userID)
s.conversationCache.InvalidateUnreadCount(userID, conversationID)
}

View File

@@ -73,9 +73,20 @@ func (s *PostService) Create(ctx context.Context, userID, title, content string,
}
func (s *PostService) reviewPostAsync(postID, userID, title, content string, images []string) {
defer func() {
if r := recover(); r != nil {
log.Printf("[ERROR] Panic in post moderation async flow, fallback publish post=%s panic=%v", postID, r)
if err := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "system"); err != nil {
log.Printf("[WARN] Failed to publish post %s after panic recovery: %v", postID, err)
return
}
s.invalidatePostCaches(postID)
}
}()
// 未启用AI时直接发布
if s.postAIService == nil || !s.postAIService.IsEnabled() {
if err := s.postRepo.UpdateModerationStatus(postID, model.PostStatusPublished, "", "system"); err != nil {
if err := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "system"); err != nil {
log.Printf("[WARN] Failed to publish post without AI moderation: %v", err)
} else {
s.invalidatePostCaches(postID)
@@ -87,7 +98,7 @@ func (s *PostService) reviewPostAsync(postID, userID, title, content string, ima
if err != nil {
var rejectedErr *PostModerationRejectedError
if errors.As(err, &rejectedErr) {
if updateErr := s.postRepo.UpdateModerationStatus(postID, model.PostStatusRejected, rejectedErr.UserMessage(), "ai"); updateErr != nil {
if updateErr := s.updateModerationStatusWithRetry(postID, model.PostStatusRejected, rejectedErr.UserMessage(), "ai"); updateErr != nil {
log.Printf("[WARN] Failed to reject post %s: %v", postID, updateErr)
} else {
s.invalidatePostCaches(postID)
@@ -97,7 +108,7 @@ func (s *PostService) reviewPostAsync(postID, userID, title, content string, ima
}
// 规则审核不可用时降级为发布避免长时间pending
if updateErr := s.postRepo.UpdateModerationStatus(postID, model.PostStatusPublished, "", "system"); updateErr != nil {
if updateErr := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "system"); updateErr != nil {
log.Printf("[WARN] Failed to publish post %s after moderation error: %v", postID, updateErr)
} else {
s.invalidatePostCaches(postID)
@@ -106,7 +117,7 @@ func (s *PostService) reviewPostAsync(postID, userID, title, content string, ima
return
}
if err := s.postRepo.UpdateModerationStatus(postID, model.PostStatusPublished, "", "ai"); err != nil {
if err := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "ai"); err != nil {
log.Printf("[WARN] Failed to publish post %s: %v", postID, err)
return
}
@@ -127,6 +138,26 @@ func (s *PostService) reviewPostAsync(postID, userID, title, content string, ima
}
}
func (s *PostService) updateModerationStatusWithRetry(postID string, status model.PostStatus, rejectReason string, reviewedBy string) error {
const maxAttempts = 3
const retryDelay = 200 * time.Millisecond
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
if err := s.postRepo.UpdateModerationStatus(postID, status, rejectReason, reviewedBy); err != nil {
lastErr = err
if attempt < maxAttempts {
log.Printf("[WARN] UpdateModerationStatus failed post=%s attempt=%d/%d err=%v", postID, attempt, maxAttempts, err)
time.Sleep(time.Duration(attempt) * retryDelay)
continue
}
} else {
return nil
}
}
return lastErr
}
func (s *PostService) invalidatePostCaches(postID string) {
cache.InvalidatePostDetail(s.cache, postID)
cache.InvalidatePostList(s.cache)

View File

@@ -0,0 +1,207 @@
package service
import (
"encoding/json"
"errors"
"regexp"
"sort"
"strings"
"carrot_bbs/internal/dto"
"carrot_bbs/internal/model"
"carrot_bbs/internal/repository"
"gorm.io/gorm"
)
var (
ErrInvalidSchedulePayload = &ServiceError{Code: 400, Message: "invalid schedule payload"}
ErrScheduleCourseNotFound = &ServiceError{Code: 404, Message: "schedule course not found"}
ErrScheduleForbidden = &ServiceError{Code: 403, Message: "forbidden schedule operation"}
ErrScheduleColorDuplicated = &ServiceError{Code: 400, Message: "course color already used"}
)
var hexColorRegex = regexp.MustCompile(`^#[0-9A-F]{6}$`)
type CreateScheduleCourseInput struct {
Name string
Teacher string
Location string
DayOfWeek int
StartSection int
EndSection int
Weeks []int
Color string
}
type ScheduleService interface {
ListCourses(userID string, week int) ([]*dto.ScheduleCourseResponse, error)
CreateCourse(userID string, input CreateScheduleCourseInput) (*dto.ScheduleCourseResponse, error)
UpdateCourse(userID, courseID string, input CreateScheduleCourseInput) (*dto.ScheduleCourseResponse, error)
DeleteCourse(userID, courseID string) error
}
type scheduleService struct {
repo repository.ScheduleRepository
}
func NewScheduleService(repo repository.ScheduleRepository) ScheduleService {
return &scheduleService{repo: repo}
}
func (s *scheduleService) ListCourses(userID string, week int) ([]*dto.ScheduleCourseResponse, error) {
courses, err := s.repo.ListByUserID(userID)
if err != nil {
return nil, err
}
result := make([]*dto.ScheduleCourseResponse, 0, len(courses))
for _, item := range courses {
weeks := dto.ParseWeeksJSON(item.Weeks)
if week > 0 && !containsWeek(weeks, week) {
continue
}
result = append(result, dto.ConvertScheduleCourseToResponse(item, weeks))
}
return result, nil
}
func (s *scheduleService) CreateCourse(userID string, input CreateScheduleCourseInput) (*dto.ScheduleCourseResponse, error) {
entity, weeks, err := buildScheduleEntity(userID, input, nil)
if err != nil {
return nil, err
}
if err := s.ensureUniqueColor(userID, entity.Color, ""); err != nil {
return nil, err
}
if err := s.repo.Create(entity); err != nil {
return nil, err
}
return dto.ConvertScheduleCourseToResponse(entity, weeks), nil
}
func (s *scheduleService) UpdateCourse(userID, courseID string, input CreateScheduleCourseInput) (*dto.ScheduleCourseResponse, error) {
existing, err := s.repo.GetByID(courseID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrScheduleCourseNotFound
}
return nil, err
}
if existing.UserID != userID {
return nil, ErrScheduleForbidden
}
entity, weeks, err := buildScheduleEntity(userID, input, existing)
if err != nil {
return nil, err
}
if err := s.ensureUniqueColor(userID, entity.Color, entity.ID); err != nil {
return nil, err
}
if err := s.repo.Update(entity); err != nil {
return nil, err
}
return dto.ConvertScheduleCourseToResponse(entity, weeks), nil
}
func (s *scheduleService) DeleteCourse(userID, courseID string) error {
existing, err := s.repo.GetByID(courseID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrScheduleCourseNotFound
}
return err
}
if existing.UserID != userID {
return ErrScheduleForbidden
}
return s.repo.DeleteByID(courseID)
}
func buildScheduleEntity(userID string, input CreateScheduleCourseInput, target *model.ScheduleCourse) (*model.ScheduleCourse, []int, error) {
name := strings.TrimSpace(input.Name)
if name == "" || input.DayOfWeek < 0 || input.DayOfWeek > 6 || input.StartSection < 1 || input.EndSection < input.StartSection {
return nil, nil, ErrInvalidSchedulePayload
}
weeks := normalizeWeeks(input.Weeks)
if len(weeks) == 0 {
return nil, nil, ErrInvalidSchedulePayload
}
weeksJSON, err := json.Marshal(weeks)
if err != nil {
return nil, nil, err
}
entity := target
if entity == nil {
entity = &model.ScheduleCourse{
UserID: userID,
}
}
normalizedColor := normalizeHexColor(input.Color)
if normalizedColor == "" || !hexColorRegex.MatchString(normalizedColor) {
return nil, nil, ErrInvalidSchedulePayload
}
entity.Name = name
entity.Teacher = strings.TrimSpace(input.Teacher)
entity.Location = strings.TrimSpace(input.Location)
entity.DayOfWeek = input.DayOfWeek
entity.StartSection = input.StartSection
entity.EndSection = input.EndSection
entity.Weeks = string(weeksJSON)
entity.Color = normalizedColor
return entity, weeks, nil
}
func (s *scheduleService) ensureUniqueColor(userID, color, excludeID string) error {
exists, err := s.repo.ExistsColorByUser(userID, color, excludeID)
if err != nil {
return err
}
if exists {
return ErrScheduleColorDuplicated
}
return nil
}
func normalizeWeeks(source []int) []int {
unique := make(map[int]struct{}, len(source))
result := make([]int, 0, len(source))
for _, w := range source {
if w < 1 || w > 30 {
continue
}
if _, exists := unique[w]; exists {
continue
}
unique[w] = struct{}{}
result = append(result, w)
}
sort.Ints(result)
return result
}
func containsWeek(weeks []int, target int) bool {
for _, week := range weeks {
if week == target {
return true
}
}
return false
}
func normalizeHexColor(color string) string {
trimmed := strings.TrimSpace(color)
if trimmed == "" {
return ""
}
if strings.HasPrefix(trimmed, "#") {
return strings.ToUpper(trimmed)
}
return "#" + strings.ToUpper(trimmed)
}

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"log"
"strings"
"time"
"carrot_bbs/internal/cache"
"carrot_bbs/internal/dto"
@@ -84,8 +85,17 @@ func (s *VoteService) CreateVotePost(ctx context.Context, userID string, req *dt
}
func (s *VoteService) reviewVotePostAsync(postID, userID, title, content string, images []string) {
defer func() {
if r := recover(); r != nil {
log.Printf("[ERROR] Panic in vote post moderation async flow, fallback publish post=%s panic=%v", postID, r)
if err := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "system"); err != nil {
log.Printf("[WARN] Failed to publish vote post %s after panic recovery: %v", postID, err)
}
}
}()
if s.postAIService == nil || !s.postAIService.IsEnabled() {
if err := s.postRepo.UpdateModerationStatus(postID, model.PostStatusPublished, "", "system"); err != nil {
if err := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "system"); err != nil {
log.Printf("[WARN] Failed to publish vote post without AI moderation: %v", err)
}
return
@@ -95,24 +105,44 @@ func (s *VoteService) reviewVotePostAsync(postID, userID, title, content string,
if err != nil {
var rejectedErr *PostModerationRejectedError
if errors.As(err, &rejectedErr) {
if updateErr := s.postRepo.UpdateModerationStatus(postID, model.PostStatusRejected, rejectedErr.UserMessage(), "ai"); updateErr != nil {
if updateErr := s.updateModerationStatusWithRetry(postID, model.PostStatusRejected, rejectedErr.UserMessage(), "ai"); updateErr != nil {
log.Printf("[WARN] Failed to reject vote post %s: %v", postID, updateErr)
}
s.notifyModerationRejected(userID, rejectedErr.Reason)
return
}
if updateErr := s.postRepo.UpdateModerationStatus(postID, model.PostStatusPublished, "", "system"); updateErr != nil {
if updateErr := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "system"); updateErr != nil {
log.Printf("[WARN] Failed to publish vote post %s after moderation error: %v", postID, updateErr)
}
return
}
if err := s.postRepo.UpdateModerationStatus(postID, model.PostStatusPublished, "", "ai"); err != nil {
if err := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "ai"); err != nil {
log.Printf("[WARN] Failed to publish vote post %s: %v", postID, err)
}
}
func (s *VoteService) updateModerationStatusWithRetry(postID string, status model.PostStatus, rejectReason string, reviewedBy string) error {
const maxAttempts = 3
const retryDelay = 200 * time.Millisecond
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
if err := s.postRepo.UpdateModerationStatus(postID, status, rejectReason, reviewedBy); err != nil {
lastErr = err
if attempt < maxAttempts {
log.Printf("[WARN] UpdateModerationStatus for vote post failed post=%s attempt=%d/%d err=%v", postID, attempt, maxAttempts, err)
time.Sleep(time.Duration(attempt) * retryDelay)
continue
}
} else {
return nil
}
}
return lastErr
}
func (s *VoteService) notifyModerationRejected(userID, reason string) {
if s.systemMessageService == nil || strings.TrimSpace(userID) == "" {
return