package usecase import ( "backend/pkg/chat/domain/entity" "backend/pkg/chat/domain/repository" "backend/pkg/chat/domain/usecase" "backend/pkg/library/centrifugo" errs "backend/pkg/library/errors" "backend/pkg/utils" "context" "crypto/md5" "encoding/hex" "fmt" "math" "time" "github.com/gocql/gocql" "github.com/google/uuid" ) const ( defaultPageSize = 20 maxPageSize = 100 // 設置最大分頁大小 ) type MessageUseCaseParam struct { MessageRepo repository.MessageRepository RoomRepo repository.RoomRepository MsgClient *centrifugo.Client Logger errs.Logger } type MessageUseCase struct { MessageUseCaseParam } // NewMessageUseCase 創建新的訊息 UseCase func NewMessageUseCase(param MessageUseCaseParam) usecase.MessageUseCase { return &MessageUseCase{ param, } } func (use *MessageUseCase) SendMessage(ctx context.Context, param usecase.SendMessageReq) error { // 驗證輸入參數 if err := use.validateSendMessageReq(param); err != nil { return err } // 驗證使用者是否在房間中(優先檢查,避免不必要的去重操作) if err := use.verifyRoomMembership(ctx, param.UID, param.RoomID); err != nil { return err } // 去重檢查 now := time.Now().UTC() bucketSec := now.Unix() contentMD5 := calculateMD5(param.Content) isDuplicate, err := use.MessageRepo.CheckAndInsertDedup(ctx, repository.CheckDupReq{ RoomID: param.RoomID, UID: param.UID, // 修復:應該是 param.UID 而不是 param.RoomID BucketSec: bucketSec, ContentMD5: contentMD5, }) if err != nil { return use.logError("messageRepo.CheckAndInsertDedup", param, err, "failed to check message deduplication") } if isDuplicate { return errs.InputInvalidFormatError("duplicate message detected") } // 建立並儲存訊息 msg, err := use.createMessage(param, now) if err != nil { return err } if err = use.MessageRepo.Insert(ctx, msg); err != nil { return use.logError("messageRepo.Insert", param, err, "failed to insert message") } // 發布到 Centrifugo(非阻塞,失敗不影響主流程) use.publishToCentrifugo(ctx, param.RoomID, msg, now) return nil } // validateSendMessageReq 驗證發送訊息的請求參數 func (use *MessageUseCase) validateSendMessageReq(param usecase.SendMessageReq) error { if param.Content == "" { return errs.InputInvalidFormatError("content cannot be empty") } if param.RoomID == "" { return errs.InputInvalidFormatError("room_id cannot be empty") } if param.UID == "" { return errs.InputInvalidFormatError("uid cannot be empty") } return nil } // verifyRoomMembership 驗證使用者是否在房間中 func (use *MessageUseCase) verifyRoomMembership(ctx context.Context, uid, roomID string) error { isMember, err := use.RoomRepo.IsUserInRoom(ctx, uid, roomID) if err != nil { return use.logError("roomRepo.IsUserInRoom", map[string]interface{}{ "uid": uid, "roomID": roomID, }, err, "failed to check room membership") } if !isMember { return errs.AuthForbiddenErrorL( use.Logger, []errs.LogField{ {Key: "uid", Val: uid}, {Key: "roomID", Val: roomID}, }, "user is not a member of the room") } return nil } // createMessage 建立訊息實體 func (use *MessageUseCase) createMessage(param usecase.SendMessageReq, now time.Time) (*entity.Message, error) { roomID, err := gocql.ParseUUID(param.RoomID) if err != nil { return nil, errs.InputInvalidFormatError("invalid room_id format").Wrap(err) } msgID, err := uuid.NewV7() if err != nil { return nil, errs.SysInternalError("failed to generate message id").Wrap(err) } return &entity.Message{ RoomID: roomID, BucketDay: utils.GetBucketDay(now), UID: param.UID, MsgID: msgID, Content: param.Content, }, nil } // publishToCentrifugo 發布訊息到 Centrifugo func (use *MessageUseCase) publishToCentrifugo(ctx context.Context, roomID string, msg *entity.Message, now time.Time) { channel := fmt.Sprintf("room:%s", roomID) messageData := map[string]interface{}{ "msg_id": msg.MsgID.String(), "uid": msg.UID, "content": msg.Content, "timestamp": now.UnixNano(), // 使用實際時間戳,而不是 msg.TS(可能為 0) "room_id": msg.RoomID.String(), } if _, err := use.MsgClient.PublishJSON(ctx, channel, messageData); err != nil { // 記錄錯誤但不影響主流程,因為訊息已經成功儲存 if use.Logger != nil { use.Logger.WithFields( errs.LogField{Key: "roomID", Val: roomID}, errs.LogField{Key: "msgID", Val: msg.MsgID.String()}, errs.LogField{Key: "error", Val: err.Error()}, ).Error(fmt.Sprintf("failed to publish message to Centrifugo: %v", err)) } } } // logError 統一的錯誤記錄方法 func (use *MessageUseCase) logError(funcName string, param interface{}, err error, message string) error { return errs.DBErrorErrorL( use.Logger, []errs.LogField{ {Key: "param", Val: param}, {Key: "func", Val: funcName}, {Key: "err", Val: err.Error()}, }, message) } func (use *MessageUseCase) ListMessages(ctx context.Context, req usecase.ListMessagesReq) ([]usecase.Message, int64, error) { // 驗證輸入參數 if err := use.validateListMessagesReq(req); err != nil { return nil, 0, err } // 驗證使用者是否在房間中 if err := use.verifyRoomMembership(ctx, req.UID, req.RoomID); err != nil { return nil, 0, err } // 取得 bucket_day(如果未提供則使用今天) bucketDay := req.BucketDay if bucketDay == "" { bucketDay = utils.GetTodayBucketDay() } // 防止 PageSize overflow 並設置合理的範圍 pageSize := use.normalizePageSize(req.PageSize) // 查詢訊息 messages, err := use.MessageRepo.ListMessages(ctx, repository.ListMessagesReq{ RoomID: req.RoomID, BucketDay: bucketDay, PageSize: pageSize, LastTS: req.LastTS, }) if err != nil { return nil, 0, use.logError("messageRepo.ListMessages", req, err, "failed to list messages") } // 轉換為 usecase.Message result := make([]usecase.Message, 0, len(messages)) for _, msg := range messages { result = append(result, usecase.Message{ RoomID: msg.RoomID.String(), BucketDay: msg.BucketDay, TS: msg.TS, UID: msg.UID, Content: msg.Content, }) } // 計算總數(只在第一頁時計算) var total int64 if req.LastTS == 0 { total, err = use.MessageRepo.Count(ctx, req.RoomID) if err != nil { return nil, 0, use.logError("messageRepo.Count", req, err, "failed to count messages") } } return result, total, nil } // validateListMessagesReq 驗證查詢訊息的請求參數 func (use *MessageUseCase) validateListMessagesReq(req usecase.ListMessagesReq) error { if req.RoomID == "" { return errs.InputInvalidFormatError("room_id cannot be empty") } if req.UID == "" { return errs.InputInvalidFormatError("uid cannot be empty") } return nil } // normalizePageSize 正規化分頁大小 func (use *MessageUseCase) normalizePageSize(pageSize int64) int { // 檢查是否超過 int 的最大值 if pageSize > int64(math.MaxInt) { return maxPageSize } size := int(pageSize) if size <= 0 { return defaultPageSize } if size > maxPageSize { return maxPageSize } return size } // calculateMD5 計算字串的 MD5 雜湊值 func calculateMD5(content string) string { hash := md5.Sum([]byte(content)) return hex.EncodeToString(hash[:]) }