backend/pkg/chat/usecase/message.go

273 lines
7.2 KiB
Go
Raw Normal View History

2026-01-06 07:15:18 +00:00
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[:])
}