package service import ( "bytes" "context" "time" "hyapp/pkg/appcode" "hyapp/pkg/xerr" "hyapp/services/room-service/internal/room/command" "hyapp/services/room-service/internal/room/outbox" "hyapp/services/room-service/internal/room/rank" "hyapp/services/room-service/internal/room/state" "hyapp/services/room-service/internal/router" ) func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayable bool, mutate func(now time.Time, current *state.RoomState, localRank *rank.LocalRank) (mutationResult, []outbox.Record, error)) (mutationResult, error) { startedAt := time.Now() var idempotencyMS int64 var leaseMS int64 var saveMutationMS int64 var snapshotMS int64 var projectionMS int64 if cmd.RoomID() == "" || cmd.ID() == "" || cmd.ActorUserID() == 0 { // command meta 是幂等、路由、审计和权限判断的共同基础,缺一不可。 return mutationResult{}, xerr.New(xerr.InvalidArgument, "command meta is incomplete") } if s.isDraining() { // 下线中的节点只让已经进入执行链路的命令收尾,不再接收新的 Room Cell 命令。 return mutationResult{}, xerr.New(xerr.Unavailable, "room service is draining") } idempotencyStartedAt := time.Now() payload, seen, err := s.commandPayloadForIdempotency(ctx, cmd) idempotencyMS = elapsedMS(idempotencyStartedAt) if err != nil { return mutationResult{}, err } if seen { // 幂等命令不重新执行 mutate,也不会再次调用 wallet 或写 outbox。 snapshot, err := s.currentSnapshot(ctx, cmd.RoomID()) if err != nil { return mutationResult{}, err } return mutationResult{snapshot: snapshot}, nil } // 确保当前节点持有房间 lease,并在没有内存 Cell 时完成恢复。 leaseStartedAt := time.Now() roomCell, lease, err := s.ensureCell(ctx, cmd.RoomID()) leaseMS = elapsedMS(leaseStartedAt) if err != nil { return mutationResult{}, err } value, err := roomCell.Do(ctx, func(current *state.RoomState, currentRank *rank.LocalRank) (any, error) { // 在副本上执行命令,只有 command log/outbox 保存成功后才整体替换当前状态。 nextState := current.Clone() nextRank := currentRank.Clone() now := s.clock.Now() result, outboxRecords, err := mutate(now, nextState, nextRank) if err != nil { // 校验、钱包或业务失败时不提交状态,不写 command log。 return nil, err } if result.snapshot == nil { // 无特殊返回需求的命令默认返回变更后的快照。 result.snapshot = nextState.ToProto() } result.snapshot = snapshotWithApp(ctx, result.snapshot) if result.snapshot.GetVersion() != current.Version { // 版本变化代表本命令产生实际状态变更,需要持久化命令和事件。 if err := s.ensureLeaseStillOwned(ctx, cmd.RoomID(), lease); err != nil { return nil, err } saveStartedAt := time.Now() if err := s.repository.SaveMutation(ctx, MutationCommit{ Command: CommandRecord{ AppCode: appcode.FromContext(ctx), RoomID: cmd.RoomID(), RoomVersion: nextState.Version, CommandID: cmd.ID(), CommandType: cmd.Type(), Payload: commandPayloadForRecord(payload, result), Replayable: replayable, OwnerNodeID: s.nodeID, LeaseToken: lease.LeaseToken, CreatedAtMS: now.UnixMilli(), }, OutboxRecords: outboxRecords, RoomStatus: result.roomStatus, RoomSeatCount: result.roomSeatCount, }); err != nil { return nil, err } saveMutationMS += elapsedMS(saveStartedAt) // 保存已提交 outbox 记录,出 Cell 锁后由 outbox worker 异步投递 IM/activity,避免外部慢调用占住房间串行执行队列。 result.outboxRecords = outboxRecords // 持久化成功后再替换内存状态,避免内存提交领先于恢复来源。 current.ReplaceWith(nextState) currentRank.ReplaceWith(nextRank) result.snapshot = snapshotWithApp(ctx, current.ToProto()) result.applied = true } else { // no-op 命令仍写入 command log,保证相同 command_id 重试可命中幂等, // 不同 payload 复用 command_id 会被后续入口拒绝为 CONFLICT。 if err := s.ensureLeaseStillOwned(ctx, cmd.RoomID(), lease); err != nil { return nil, err } saveStartedAt := time.Now() if err := s.repository.SaveMutation(ctx, MutationCommit{ Command: CommandRecord{ AppCode: appcode.FromContext(ctx), RoomID: cmd.RoomID(), RoomVersion: current.Version, CommandID: cmd.ID(), CommandType: cmd.Type(), Payload: payload, Replayable: false, OwnerNodeID: s.nodeID, LeaseToken: lease.LeaseToken, CreatedAtMS: now.UnixMilli(), }, }); err != nil { return nil, err } saveMutationMS += elapsedMS(saveStartedAt) } return result, nil }) if err != nil { return mutationResult{}, err } result := value.(mutationResult) if result.applied && shouldSnapshot(result.snapshot, s.snapshotEveryN) { // 快照失败时返回错误,因为没有快照会增加恢复成本并可能影响 guard 查询。 snapshotStartedAt := time.Now() if err := s.persistSnapshot(ctx, result.snapshot); err != nil { return mutationResult{}, err } snapshotMS = elapsedMS(snapshotStartedAt) } if result.applied { projectionStartedAt := time.Now() // 列表读模型最终一致,失败只记录日志,不回滚已经提交的 Room Cell 命令。 s.projectRoomListBestEffort(ctx, result.snapshot) // 当前房间恢复读模型同样最终一致;权威校验仍以 Room Cell 快照为准。 s.projectRoomPresenceBestEffort(ctx, result.snapshot) projectionMS = elapsedMS(projectionStartedAt) } logRoomCommandTiming(ctx, cmd.RoomID(), cmd.ID(), cmd.Type(), result.applied, elapsedMS(startedAt), idempotencyMS, leaseMS, saveMutationMS, snapshotMS, projectionMS, result.walletDebitMS, 0, 0, len(result.outboxRecords) > 0, result.syncEvent != nil) return result, nil } func (s *Service) commandPayloadForIdempotency(ctx context.Context, cmd command.Command) ([]byte, bool, error) { payload, err := command.Serialize(cmd) if err != nil { return nil, false, err } record, exists, err := s.repository.GetCommand(ctx, cmd.RoomID(), cmd.ID()) if err != nil { return nil, false, err } if !exists { return payload, false, nil } currentIDPayload, err := command.IdempotencyPayload(payload) if err != nil { return nil, true, err } storedIDPayload, err := command.IdempotencyPayload(record.Payload) if err != nil { return nil, true, err } if record.CommandType != cmd.Type() || !bytes.Equal(storedIDPayload, currentIDPayload) { return nil, true, xerr.New(xerr.Conflict, "command_id payload conflict") } return payload, true, nil } func commandPayloadForRecord(defaultPayload []byte, result mutationResult) []byte { if len(result.commandPayload) > 0 { return result.commandPayload } return defaultPayload } func (s *Service) ensureLeaseStillOwned(ctx context.Context, roomID string, lease router.Lease) error { ok, err := s.directory.VerifyOwner(ctx, runtimeRoomKeyFromContext(ctx, roomID), s.nodeID, lease.LeaseToken, s.clock.Now()) if err != nil { return err } if !ok { return xerr.New(xerr.Unavailable, "room lease is no longer owned by this node") } return nil } func elapsedMS(start time.Time) int64 { return time.Since(start).Milliseconds() }