package service import ( "errors" "fmt" "sort" "strings" "time" "browser.local/platform/domain" "browser.local/platform/repo" "browser.local/platform/validator" ) func (svc *CoreService) ListSCUMUsersForSession(sessionID string, filter domain.SCUMUserFilter) ([]domain.SCUMUser, error) { if strings.TrimSpace(filter.ServerInstanceID) == "" { return nil, validationError("serverInstanceId is required") } if _, err := svc.GetServerInstanceForSession(sessionID, filter.ServerInstanceID); err != nil { return nil, err } users, err := svc.store.SCUMUsers().List(filter) if err != nil { return nil, err } sort.SliceStable(users, func(i, j int) bool { if !users[i].LastActivityAt.Equal(users[j].LastActivityAt) { return users[i].LastActivityAt.After(users[j].LastActivityAt) } return users[i].SteamID < users[j].SteamID }) limit := boundedSCUMLimit(filter.Limit, domain.SCUMDefaultListLimit) if len(users) > limit { users = users[:limit] } stamp := svc.now() for index := range users { if !users[index].LastLoginAt.IsZero() { users[index].LastLoginAge = stamp.Sub(users[index].LastLoginAt) } } return domain.CopySCUMUsers(users), nil } func (svc *CoreService) ListSCUMUserTrajectoriesForSession(sessionID string, filter domain.SCUMUserTrajectoryFilter) ([]domain.SCUMUserTrajectory, error) { if strings.TrimSpace(filter.ServerInstanceID) == "" { return nil, validationError("serverInstanceId is required") } if _, err := svc.GetServerInstanceForSession(sessionID, filter.ServerInstanceID); err != nil { return nil, err } items, err := svc.store.SCUMUserTrajectories().List(filter) if err != nil { return nil, err } sort.SliceStable(items, func(i, j int) bool { if !items[i].ObservedAt.Equal(items[j].ObservedAt) { return items[i].ObservedAt.After(items[j].ObservedAt) } return items[i].ID < items[j].ID }) limit := boundedSCUMLimit(filter.Limit, domain.SCUMDefaultTrajectoryLimit) if len(items) > limit { items = items[:limit] } return domain.CopySCUMUserTrajectories(items), nil } func (svc *CoreService) ListSCUMVehiclesForSession(sessionID string, filter domain.SCUMVehicleFilter) ([]domain.SCUMVehicle, error) { if strings.TrimSpace(filter.ServerInstanceID) == "" { return nil, validationError("serverInstanceId is required") } if _, err := svc.GetServerInstanceForSession(sessionID, filter.ServerInstanceID); err != nil { return nil, err } vehicles, err := svc.store.SCUMVehicles().List(filter) if err != nil { return nil, err } sort.SliceStable(vehicles, func(i, j int) bool { if !vehicles[i].LastObservedAt.Equal(vehicles[j].LastObservedAt) { return vehicles[i].LastObservedAt.After(vehicles[j].LastObservedAt) } return vehicles[i].GameVehicleID < vehicles[j].GameVehicleID }) limit := boundedSCUMLimit(filter.Limit, domain.SCUMDefaultListLimit) if len(vehicles) > limit { vehicles = vehicles[:limit] } return domain.CopySCUMVehicles(vehicles), nil } func (svc *CoreService) ListSCUMVehicleTrajectoriesForSession(sessionID string, filter domain.SCUMVehicleTrajectoryFilter) ([]domain.SCUMVehicleTrajectory, error) { if strings.TrimSpace(filter.ServerInstanceID) == "" { return nil, validationError("serverInstanceId is required") } if _, err := svc.GetServerInstanceForSession(sessionID, filter.ServerInstanceID); err != nil { return nil, err } items, err := svc.store.SCUMVehicleTrajectories().List(filter) if err != nil { return nil, err } sort.SliceStable(items, func(i, j int) bool { if !items[i].ObservedAt.Equal(items[j].ObservedAt) { return items[i].ObservedAt.After(items[j].ObservedAt) } return items[i].ID < items[j].ID }) limit := boundedSCUMLimit(filter.Limit, domain.SCUMDefaultTrajectoryLimit) if len(items) > limit { items = items[:limit] } return domain.CopySCUMVehicleTrajectories(items), nil } func (svc *CoreService) ListSCUMVehicleLocksForSession(sessionID string, filter domain.SCUMVehicleLockFilter) ([]domain.SCUMVehicleLock, error) { if strings.TrimSpace(filter.ServerInstanceID) == "" { return nil, validationError("serverInstanceId is required") } if _, err := svc.GetServerInstanceForSession(sessionID, filter.ServerInstanceID); err != nil { return nil, err } items, err := svc.store.SCUMVehicleLocks().List(filter) if err != nil { return nil, err } sort.SliceStable(items, func(i, j int) bool { if !items[i].LockedAt.Equal(items[j].LockedAt) { return items[i].LockedAt.After(items[j].LockedAt) } return items[i].ID < items[j].ID }) limit := boundedSCUMLimit(filter.Limit, domain.SCUMDefaultTrajectoryLimit) if len(items) > limit { items = items[:limit] } return domain.CopySCUMVehicleLocks(items), nil } func (svc *CoreService) IngestSCUMFacts(batch domain.SCUMFactIngest) (domain.SCUMFactIngestResult, error) { batch = domain.CopySCUMFactIngest(batch) if strings.TrimSpace(batch.ServerInstanceID) == "" { return domain.SCUMFactIngestResult{}, validationError("serverInstanceId is required") } if len(batch.Users) == 0 && len(batch.Vehicles) == 0 { return domain.SCUMFactIngestResult{}, validationError("SCUM facts are required") } if len(batch.Users) > 1000 || len(batch.Vehicles) > 1000 { return domain.SCUMFactIngestResult{}, validationError("SCUM fact batch is too large") } if err := svc.validateRunSession(batch.RunEndpointID, batch.SessionToken); err != nil { return domain.SCUMFactIngestResult{}, err } instance, err := svc.store.ServerInstances().Get(batch.ServerInstanceID) if err != nil { return domain.SCUMFactIngestResult{}, err } if instance.RunEndpointID != batch.RunEndpointID { return domain.SCUMFactIngestResult{}, ErrForbidden } stamp := svc.now() welcomeCount := 0 for _, fact := range batch.Users { if err := svc.ingestSCUMUserFact(instance, fact, stamp, &welcomeCount); err != nil { return domain.SCUMFactIngestResult{}, err } } for _, fact := range batch.Vehicles { if err := svc.ingestSCUMVehicleFact(instance, fact, stamp); err != nil { return domain.SCUMFactIngestResult{}, err } } return domain.SCUMFactIngestResult{Accepted: true, AcceptedUserCount: len(batch.Users), AcceptedVehicleCount: len(batch.Vehicles), WelcomeQueuedCount: welcomeCount, ServerTime: stamp}, nil } func (svc *CoreService) ingestSCUMUserFact(instance domain.ServerInstance, fact domain.SCUMUserFact, stamp time.Time, welcomeCount *int) error { steamID := strings.TrimSpace(fact.SteamID) if steamID == "" { return validationError("SCUM user steamId is required") } observedAt := nonZeroTime(fact.ObservedAt, stamp) loginAt := nonZeroTime(fact.LoginObservedAt, observedAt) userID := scumUserID(instance.ID, steamID) user, err := svc.store.SCUMUsers().Get(userID) created := false if errors.Is(err, repo.ErrNotFound) { created = true user = domain.SCUMUser{ID: userID, ServerInstanceID: instance.ID, SteamID: steamID, RegisteredAt: loginAt, CreatedAt: stamp} } else if err != nil { return err } previousActivity := user.LastActivityAt freshLogin := created || (fact.Login && (previousActivity.IsZero() || loginAt.Sub(previousActivity) > domain.SCUMUserOfflineAfter)) moved := fact.Online && fact.Position != nil && userPositionMoved(user, fact.Position) if fact.Login || created { user.LastLoginAt = loginAt } user.DisplayName = firstNonBlank(fact.DisplayName, user.DisplayName) if fact.SquadID != nil { user.SquadID = *fact.SquadID } user.LastLoginIP = firstNonBlank(fact.LoginIP, user.LastLoginIP) user.Online = fact.Online user.LastActivityAt = latestTime(user.LastActivityAt, observedAt) if fact.Position != nil { user.X, user.Y, user.Z = floatPtr(fact.Position.X), floatPtr(fact.Position.Y), floatPtr(fact.Position.Z) } if fact.BankBalance != nil { user.BankBalance = int64Ptr(*fact.BankBalance) } if fact.GoldBars != nil { user.GoldBars = int64Ptr(*fact.GoldBars) } user.GameVehicleID = strings.TrimSpace(fact.RiddenGameVehicleID) if user.GameVehicleID != "" { user.RiddenVehicleID = scumVehicleID(instance.ID, user.GameVehicleID) } else { user.RiddenVehicleID = "" } user.UpdatedAt = stamp if created { if err := svc.store.SCUMUsers().Create(user); err != nil { return err } } else if err := svc.store.SCUMUsers().Update(user); err != nil { return err } if moved { track := domain.SCUMUserTrajectory{ID: scumTrajectoryID("scum-user-track", instance.ID, steamID, observedAt), ServerInstanceID: instance.ID, SCUMUserID: user.ID, SteamID: steamID, DisplayName: user.DisplayName, X: fact.Position.X, Y: fact.Position.Y, Z: fact.Position.Z, RiddenVehicleID: user.RiddenVehicleID, GameVehicleID: user.GameVehicleID, ObservedAt: observedAt, CreatedAt: stamp} if err := createUnlessDuplicate(svc.store.SCUMUserTrajectories(), track); err != nil { return err } } if freshLogin { queued, queueErr := svc.queueSCUMWelcomeMessage(instance, user, stamp) if queueErr != nil { user.WelcomeLastError = queueErr.Error() } else if queued { user.WelcomeQueuedAt = stamp.Add(domain.SCUMWelcomeMessageDelay) *welcomeCount = *welcomeCount + 1 } user.UpdatedAt = stamp if err := svc.store.SCUMUsers().Update(user); err != nil { return err } } return nil } func (svc *CoreService) ingestSCUMVehicleFact(instance domain.ServerInstance, fact domain.SCUMVehicleFact, stamp time.Time) error { gameVehicleID := strings.TrimSpace(fact.GameVehicleID) if gameVehicleID == "" { return validationError("SCUM vehicle gameVehicleId is required") } observedAt := nonZeroTime(fact.ObservedAt, stamp) vehicleID := scumVehicleID(instance.ID, gameVehicleID) vehicle, err := svc.store.SCUMVehicles().Get(vehicleID) created := false if errors.Is(err, repo.ErrNotFound) { created = true vehicle = domain.SCUMVehicle{ID: vehicleID, ServerInstanceID: instance.ID, GameVehicleID: gameVehicleID, Exists: true, CreatedAt: stamp} } else if err != nil { return err } moved := fact.Position != nil && vehiclePositionMoved(vehicle, fact.Position) vehicle.VehicleClass = firstNonBlank(fact.VehicleClass, vehicle.VehicleClass) vehicle.DisplayName = firstNonBlank(fact.DisplayName, vehicle.DisplayName) if fact.Exists != nil { vehicle.Exists = *fact.Exists } if fact.Locked != nil { vehicle.Locked = *fact.Locked } if fact.Position != nil { vehicle.X, vehicle.Y, vehicle.Z = floatPtr(fact.Position.X), floatPtr(fact.Position.Y), floatPtr(fact.Position.Z) } vehicle.LastObservedAt = latestTime(vehicle.LastObservedAt, observedAt) vehicle.UpdatedAt = stamp if created { if err := svc.store.SCUMVehicles().Create(vehicle); err != nil { return err } } else if err := svc.store.SCUMVehicles().Update(vehicle); err != nil { return err } if moved { track := domain.SCUMVehicleTrajectory{ID: scumTrajectoryID("scum-vehicle-track", instance.ID, gameVehicleID, observedAt), ServerInstanceID: instance.ID, SCUMVehicleID: vehicle.ID, GameVehicleID: gameVehicleID, VehicleClass: vehicle.VehicleClass, X: fact.Position.X, Y: fact.Position.Y, Z: fact.Position.Z, ObservedAt: observedAt, CreatedAt: stamp} if err := createUnlessDuplicate(svc.store.SCUMVehicleTrajectories(), track); err != nil { return err } } if strings.TrimSpace(fact.LockedBySteamID) != "" && !fact.LockedAt.IsZero() { lockUserID := scumUserID(instance.ID, strings.TrimSpace(fact.LockedBySteamID)) lock := domain.SCUMVehicleLock{ID: scumTrajectoryID("scum-vehicle-lock", instance.ID, gameVehicleID+":"+fact.LockedBySteamID, fact.LockedAt), ServerInstanceID: instance.ID, SCUMVehicleID: vehicle.ID, GameVehicleID: gameVehicleID, SCUMUserID: lockUserID, SteamID: strings.TrimSpace(fact.LockedBySteamID), LockedAt: fact.LockedAt, CreatedAt: stamp} if err := createUnlessDuplicate(svc.store.SCUMVehicleLocks(), lock); err != nil { return err } } return nil } func (svc *CoreService) queueSCUMWelcomeMessage(instance domain.ServerInstance, user domain.SCUMUser, stamp time.Time) (bool, error) { if instance.State != domain.ServerInstanceStateRunning { return false, nil } resolution, err := svc.resolveSourceRCONDispatch(instance) if err != nil { return false, err } message := fmt.Sprintf("欢迎 %s 回到服务器", firstNonBlank(user.DisplayName, user.SteamID)) request := domain.SourceRCONCommandRequest{ServerInstanceID: instance.ID, Kind: domain.SourceRCONCommandKindChat, ChatType: 4, Message: message, IdempotencyKey: scumWelcomeIdempotencyKey(instance.ID, user.SteamID, user.LastLoginAt)} if err := validator.ValidateSourceRCONCommandRequest(request); err != nil { return false, err } if existing, err := svc.store.Jobs().GetByIdempotency(instance.RunEndpointID, request.IdempotencyKey); err == nil { return existing.QueueEligibleAt.Equal(stamp.Add(domain.SCUMWelcomeMessageDelay)) || !existing.QueueEligibleAt.IsZero(), nil } else if !errors.Is(err, repo.ErrNotFound) { return false, err } command := sourceRCONCommandText(request) jobID := jobIDFromParts("job-source-rcon", instance.ID, request.IdempotencyKey) if err := svc.sourceRCONCommands.Put(jobID, command); err != nil { return false, err } job := domain.Job{ID: jobID, ServerInstanceID: instance.ID, RunEndpointID: instance.RunEndpointID, Capability: domain.JobCapabilityRemoteRunRCONCommand, TargetKey: resolution.transport.TargetKey, InputRef: "input://source-rcon/" + jobID, IdempotencyKey: request.IdempotencyKey, State: domain.JobStateQueued, Progress: domain.JobProgress{Percent: 0, Message: "SCUM welcome message queued"}, RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1}, QueueEligibleAt: stamp.Add(domain.SCUMWelcomeMessageDelay), ExecutionInput: domain.JobExecutionInput{WorkspaceScope: resolution.binding.ProfileKey, RemoteAdapterKey: resolution.transport.Key, RemoteAdapterKind: string(domain.RemoteAdapterRCON), TimeoutSeconds: sourceRCONTimeoutSeconds, PluginID: resolution.plugin.ID, SourceRCON: resolution.plan}} created, err := svc.CreateJob(job) if err != nil { svc.sourceRCONCommands.Delete(jobID) return false, err } return created.ID == jobID, nil } func userPositionMoved(user domain.SCUMUser, position *domain.SCUMPosition) bool { if position == nil || user.X == nil || user.Y == nil || user.Z == nil { return true } return *user.X != position.X || *user.Y != position.Y || *user.Z != position.Z } func vehiclePositionMoved(vehicle domain.SCUMVehicle, position *domain.SCUMPosition) bool { if position == nil || vehicle.X == nil || vehicle.Y == nil || vehicle.Z == nil { return true } return *vehicle.X != position.X || *vehicle.Y != position.Y || *vehicle.Z != position.Z } func boundedSCUMLimit(requested int, fallback int) int { if requested <= 0 { return fallback } if requested > 1000 { return 1000 } return requested } func scumUserID(serverID string, steamID string) string { return "scum-user-" + fingerprintID(serverID, strings.TrimSpace(steamID)) } func scumVehicleID(serverID string, gameVehicleID string) string { return "scum-vehicle-" + fingerprintID(serverID, strings.TrimSpace(gameVehicleID)) } func scumTrajectoryID(prefix string, serverID string, subjectID string, observedAt time.Time) string { return prefix + "-" + fingerprintID(serverID, strings.TrimSpace(subjectID)+"\x00"+observedAt.UTC().Format(time.RFC3339Nano)) } func scumWelcomeIdempotencyKey(serverID string, steamID string, loginAt time.Time) string { return "scum-welcome:" + fingerprintID(serverID, strings.TrimSpace(steamID)+"\x00"+loginAt.UTC().Format(time.RFC3339Nano)) } func nonZeroTime(value time.Time, fallback time.Time) time.Time { if value.IsZero() { return fallback } return value.UTC() } func latestTime(left time.Time, right time.Time) time.Time { if left.IsZero() || right.After(left) { return right } return left } func firstNonBlank(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" } func floatPtr(value float64) *float64 { return &value } func int64Ptr(value int64) *int64 { return &value } type scumCreateOnlyRepository[T any] interface { Create(T) error } func createUnlessDuplicate[T any](repository scumCreateOnlyRepository[T], value T) error { if err := repository.Create(value); err != nil && !errors.Is(err, repo.ErrDuplicate) { return err } return nil }