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

112 lines
3.9 KiB
Go

package service
import (
"context"
"fmt"
"google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
"hyapp/pkg/roommq"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/integration"
"hyapp/services/room-service/internal/room/command"
"hyapp/services/room-service/internal/room/state"
)
// scheduleRoomTreasureOpenFromEnvelope derives the delayed wakeup from the
// already committed countdown outbox. Scheduling after outbox claim keeps
// SendGift free of external MQ side effects before MySQL commit.
func (s *Service) scheduleRoomTreasureOpenFromEnvelope(ctx context.Context, envelope *roomeventsv1.EventEnvelope) error {
if s.roomTreasureOpenScheduler == nil || envelope == nil || envelope.GetEventType() != "RoomTreasureCountdownStarted" {
return nil
}
var event roomeventsv1.RoomTreasureCountdownStarted
if err := proto.Unmarshal(envelope.GetBody(), &event); err != nil {
return err
}
if event.GetBoxId() == "" || event.GetLevel() <= 0 || event.GetOpenAtMs() <= 0 {
return fmt.Errorf("room treasure countdown event is incomplete: event_id=%s", envelope.GetEventId())
}
return s.roomTreasureOpenScheduler.ScheduleRoomTreasureOpen(ctx, integration.RoomTreasureOpenSchedule{
AppCode: appcode.Normalize(envelope.GetAppCode()),
RoomID: envelope.GetRoomId(),
BoxID: event.GetBoxId(),
Level: event.GetLevel(),
OpenAtMS: event.GetOpenAtMs(),
CommandID: roomTreasureOpenCommandID(event.GetBoxId()),
})
}
// HandleRoomTreasureOpenDue handles a RocketMQ delayed wakeup. It pre-checks
// due-ness before writing a command log, because an early-delivered delay
// message must not poison the deterministic open command_id as a no-op.
func (s *Service) HandleRoomTreasureOpenDue(ctx context.Context, due roommq.RoomTreasureOpenDueMessage) error {
ctx = appcode.WithContext(ctx, due.AppCode)
if due.RoomID == "" || due.BoxID == "" || due.Level <= 0 || due.OpenAtMS <= 0 {
return xerr.New(xerr.InvalidArgument, "room treasure open due message is incomplete")
}
roomCell, lease, err := s.ensureCell(ctx, due.RoomID)
if err != nil {
return err
}
currentState, _, err := roomCell.Snapshot(ctx)
if err != nil {
return err
}
owned, err := s.directory.VerifyOwner(ctx, runtimeRoomKeyFromContext(ctx, due.RoomID), s.nodeID, lease.LeaseToken, s.clock.Now())
if err != nil {
return err
}
if !owned {
return xerr.New(xerr.Conflict, "room treasure open lease moved")
}
now := s.clock.Now().UTC()
nowMS := now.UnixMilli()
if currentState == nil || currentState.Treasure == nil || currentState.Treasure.Status != state.TreasureStatusCountdown {
return nil
}
treasure := currentState.Treasure
if treasure.BoxID != due.BoxID || treasure.CurrentLevel != due.Level || treasure.OpenAtMS != due.OpenAtMS {
// Stale delayed messages are expected after retries or if the room reset/opened by another path.
return nil
}
if treasure.ResetAtMS > 0 && nowMS >= treasure.ResetAtMS {
// UTC day reset wins over yesterday's delayed wakeup.
return nil
}
if treasure.OpenAtMS > nowMS {
return xerr.New(xerr.Unavailable, "room treasure open wakeup arrived before open_at_ms")
}
actorUserID := currentState.OwnerUserID
if actorUserID <= 0 {
actorUserID = treasure.IgniterUserID
}
if actorUserID <= 0 {
return xerr.New(xerr.InvalidArgument, "room treasure open actor is missing")
}
commandID := due.CommandID
if commandID == "" {
commandID = roomTreasureOpenCommandID(due.BoxID)
}
_, err = s.openRoomTreasure(ctx, command.OpenRoomTreasure{
Base: command.Base{
AppCode: appcode.FromContext(ctx),
RequestID: idgen.New("req_room_treasure_open_mq"),
CommandID: commandID,
ActorID: actorUserID,
Room: due.RoomID,
GatewayNodeID: s.nodeID,
SessionID: "room-treasure-open-mq",
SentAtMS: nowMS,
},
BoxID: due.BoxID,
Level: due.Level,
})
return err
}