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" ) // scheduleRoomRocketLaunchFromEnvelope derives the delayed wakeup from the // already committed ignited outbox. Scheduling after outbox claim keeps // SendGift free of external MQ side effects before MySQL commit. func (s *Service) scheduleRoomRocketLaunchFromEnvelope(ctx context.Context, envelope *roomeventsv1.EventEnvelope) error { if s.roomRocketLaunchScheduler == nil || envelope == nil || envelope.GetEventType() != "RoomRocketIgnited" { return nil } var event roomeventsv1.RoomRocketIgnited if err := proto.Unmarshal(envelope.GetBody(), &event); err != nil { return err } if event.GetRocketId() == "" || event.GetLevel() <= 0 || event.GetLaunchAtMs() <= 0 { return fmt.Errorf("room rocket ignited event is incomplete: event_id=%s", envelope.GetEventId()) } return s.roomRocketLaunchScheduler.ScheduleRoomRocketLaunch(ctx, integration.RoomRocketLaunchSchedule{ AppCode: appcode.Normalize(envelope.GetAppCode()), RoomID: envelope.GetRoomId(), RocketID: event.GetRocketId(), Level: event.GetLevel(), LaunchAtMS: event.GetLaunchAtMs(), ResetAtMS: event.GetResetAtMs(), CommandID: roomRocketLaunchCommandID(event.GetRocketId()), }) } // HandleRoomRocketLaunchDue 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) HandleRoomRocketLaunchDue(ctx context.Context, due roommq.RoomRocketLaunchDueMessage) error { ctx = appcode.WithContext(ctx, due.AppCode) if due.RoomID == "" || due.RocketID == "" || due.Level <= 0 || due.LaunchAtMS <= 0 { return xerr.New(xerr.InvalidArgument, "room rocket launch 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 rocket launch lease moved") } now := s.clock.Now().UTC() nowMS := now.UnixMilli() if currentState == nil || currentState.Rocket == nil { return nil } rocket := currentState.Rocket pending, _, ok := findRocketPendingLaunch(rocket.PendingLaunches, due.RocketID, due.Level) if !ok || pending.LaunchAtMS != due.LaunchAtMS { // Stale delayed messages are expected after retries or if the room reset/launched by another path. return nil } if pending.ResetAtMS > 0 && nowMS >= pending.ResetAtMS { // UTC day reset wins over yesterday's delayed wakeup. return nil } if pending.LaunchAtMS > nowMS { return xerr.New(xerr.Unavailable, "room rocket launch wakeup arrived before launch_at_ms") } actorUserID := currentState.OwnerUserID if actorUserID <= 0 { actorUserID = pending.IgniterUserID } if actorUserID <= 0 { return xerr.New(xerr.InvalidArgument, "room rocket launch actor is missing") } commandID := due.CommandID if commandID == "" { commandID = roomRocketLaunchCommandID(due.RocketID) } _, err = s.launchRoomRocket(ctx, command.LaunchRoomRocket{ Base: command.Base{ AppCode: appcode.FromContext(ctx), RequestID: idgen.New("req_room_rocket_launch_mq"), CommandID: commandID, ActorID: actorUserID, Room: due.RoomID, GatewayNodeID: s.nodeID, SessionID: "room-rocket-launch-mq", SentAtMS: nowMS, }, RocketID: due.RocketID, Level: due.Level, }) return err }