273 lines
7.2 KiB
Go
273 lines
7.2 KiB
Go
|
|
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[:])
|
|||
|
|
}
|