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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
207
internal/service/schedule_service.go
Normal file
207
internal/service/schedule_service.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user