2026-05-20 14:34:10 +08:00

166 lines
6.0 KiB
Go

package roomnotice
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
"hyapp/pkg/logx"
"hyapp/pkg/tencentim"
)
// Repository 是 room notice 模块需要的持久化边界。
type Repository interface {
ClaimRoomKickEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) ([]RoomKickEvent, error)
ClaimRoomKickEnvelope(ctx context.Context, workerID string, envelope *roomeventsv1.EventEnvelope, lockTTL time.Duration) (RoomKickEvent, bool, error)
MarkRoomKickDelivered(ctx context.Context, event RoomKickEvent, deliveredPayload []byte, nowMs int64) error
MarkRoomKickRetryable(ctx context.Context, event RoomKickEvent, retryCount int, nextRetryAtMS int64, lastErr string, nowMs int64) error
MarkRoomKickFailed(ctx context.Context, event RoomKickEvent, retryCount int, lastErr string, nowMs int64) error
}
// UserMessagePublisher 把房间私有 notice 投递到用户个人实时通道。
type UserMessagePublisher interface {
PublishUserCustomMessage(ctx context.Context, message tencentim.CustomUserMessage) error
}
// Config 保存 room notice 的进程级配置。
type Config struct {
NodeID string
}
// Service 消费 room_outbox 中需要私有通知的房间事实,不拥有房间状态。
type Service struct {
cfg Config
repository Repository
publisher UserMessagePublisher
}
// New 创建 room notice 服务。
func New(cfg Config, repository Repository, publisher UserMessagePublisher) *Service {
return &Service{cfg: cfg, repository: repository, publisher: publisher}
}
// ProcessRoomKickNotices 处理一批“被踢用户本人”的私有通知。
func (s *Service) ProcessRoomKickNotices(ctx context.Context, options RoomNoticeWorkerOptions) (int, error) {
options = normalizeRoomNoticeWorkerOptions(options, s.cfg.NodeID)
if s.repository == nil {
return 0, fmt.Errorf("notice repository is not configured")
}
if s.publisher == nil {
return 0, fmt.Errorf("notice publisher is not configured")
}
events, err := s.repository.ClaimRoomKickEvents(ctx, options.WorkerID, options.BatchSize, options.LockTTL)
if err != nil {
return 0, err
}
processed := 0
for _, event := range events {
if err := s.publishRoomKickEvent(ctx, event, options); err != nil {
return processed, err
}
processed++
}
return processed, nil
}
// ProcessRoomOutboxEnvelope handles one room_outbox MQ message. Unknown event
// types are acknowledged by returning handled=false; poison messages for known
// types still return an error so MQ can retry or dead-letter them.
func (s *Service) ProcessRoomOutboxEnvelope(ctx context.Context, envelope *roomeventsv1.EventEnvelope, options RoomNoticeWorkerOptions) (bool, error) {
if envelope == nil || envelope.GetEventType() != eventRoomUserKicked {
return false, nil
}
options = normalizeRoomNoticeWorkerOptions(options, s.cfg.NodeID)
if s.repository == nil {
return true, fmt.Errorf("notice repository is not configured")
}
if s.publisher == nil {
return true, fmt.Errorf("notice publisher is not configured")
}
event, claimed, err := s.repository.ClaimRoomKickEnvelope(ctx, options.WorkerID, envelope, options.LockTTL)
if err != nil || !claimed {
return true, err
}
return true, s.publishRoomKickEvent(ctx, event, options)
}
func (s *Service) publishRoomKickEvent(ctx context.Context, event RoomKickEvent, options RoomNoticeWorkerOptions) error {
payload, err := roomKickNoticePayload(event)
if err != nil {
return s.markFailed(ctx, event, options, err)
}
publishCtx, cancel := context.WithTimeout(ctx, options.PublishTimeout)
defer cancel()
err = s.publisher.PublishUserCustomMessage(publishCtx, tencentim.CustomUserMessage{
ToAccount: tencentim.FormatUserID(event.TargetUserID),
EventID: event.EventID,
Desc: "room_user_kicked",
Ext: "room_notice",
PayloadJSON: payload,
})
nowMs := time.Now().UnixMilli()
if err == nil {
if markErr := s.repository.MarkRoomKickDelivered(ctx, event, payload, nowMs); markErr != nil {
return markErr
}
logx.Info(ctx, "notice_room_kick_delivered",
slog.String("event_id", event.EventID),
slog.String("app_code", event.AppCode),
slog.String("room_id", event.RoomID),
slog.Int64("target_user_id", event.TargetUserID),
)
return nil
}
return s.markFailed(ctx, event, options, err)
}
func (s *Service) markFailed(ctx context.Context, event RoomKickEvent, options RoomNoticeWorkerOptions, cause error) error {
nowMs := time.Now().UnixMilli()
nextRetryCount := event.RetryCount + 1
if nextRetryCount >= options.MaxRetryCount {
if err := s.repository.MarkRoomKickFailed(ctx, event, nextRetryCount, cause.Error(), nowMs); err != nil {
return err
}
logx.Error(ctx, "notice_room_kick_dead_letter", cause,
slog.String("event_id", event.EventID),
slog.String("app_code", event.AppCode),
slog.String("room_id", event.RoomID),
slog.Int64("target_user_id", event.TargetUserID),
slog.Int("retry_count", nextRetryCount),
)
return nil
}
nextRetryAtMS := nowMs + roomNoticeBackoff(nextRetryCount, options).Milliseconds()
if err := s.repository.MarkRoomKickRetryable(ctx, event, nextRetryCount, nextRetryAtMS, cause.Error(), nowMs); err != nil {
return err
}
logx.Error(ctx, "notice_room_kick_retryable", cause,
slog.String("event_id", event.EventID),
slog.String("app_code", event.AppCode),
slog.String("room_id", event.RoomID),
slog.Int64("target_user_id", event.TargetUserID),
slog.Int("retry_count", nextRetryCount),
slog.Int64("next_retry_at_ms", nextRetryAtMS),
)
return nil
}
func roomKickNoticePayload(event RoomKickEvent) (json.RawMessage, error) {
payload := map[string]any{
"event_id": event.EventID,
"event_type": "room_user_kicked",
"source_event_type": event.EventType,
"app_code": event.AppCode,
"room_id": event.RoomID,
"room_version": event.RoomVersion,
"actor_user_id": event.ActorUserID,
"target_user_id": event.TargetUserID,
"occurred_at_ms": event.OccurredAtMS,
"source_created_at_ms": event.CreatedAtMS,
}
return json.Marshal(payload)
}