backend/pkg/chat/usecase/message.go

273 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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[:])
}