Store SCUM facts in typed platform tables

Run pushes typed SCUM facts through POST /api/v1/run/scum/facts, but the
production router rejected that path before the signature middleware because
runServiceRequest listed individual Run channel prefixes, and MySQL kept SCUM
rows inside the whole metadata snapshot instead of platform tables.

- /api/v1/run/ is now the signed machine channel space while
  /api/v1/run/endpoints keeps normal bearer/admin authorization.
- MySQL gets real scum_user, scum_user_trajectory, scum_vehicle,
  scum_vehicle_trajectory and scum_vehicle_lock tables with parameterized
  per-row repositories instead of full snapshot rewrites. Snapshot-shaped
  tables from the unreleased interim build are replaced, and SCUM rows still
  inside a metadata snapshot are migrated once.
- Facts ingest verifies the target server plugin type and converges stale
  online users to offline after SCUMUserOfflineAfter.
- The plugin page and browser read one bounded /scum/surface response instead
  of five list calls per refresh.

Call-count budget for one server: per 5s facts batch, one SELECT plus one
INSERT/UPDATE per reported user and vehicle, one INSERT per moved trajectory
sample or new lock row, one bounded stale-user SELECT, and a trajectory
retention DELETE at most once per hour. One browser refresh issues one surface
request every 15s instead of five list requests.
This commit is contained in:
npc0-hue
2026-09-15 12:12:11 +08:00
parent 63e2340db7
commit a822e9250a
20 changed files with 1288 additions and 72 deletions
+1
View File
@@ -186,6 +186,7 @@ type Core interface {
ListSCUMVehiclesForSession(string, domain.SCUMVehicleFilter) ([]domain.SCUMVehicle, error)
ListSCUMVehicleTrajectoriesForSession(string, domain.SCUMVehicleTrajectoryFilter) ([]domain.SCUMVehicleTrajectory, error)
ListSCUMVehicleLocksForSession(string, domain.SCUMVehicleLockFilter) ([]domain.SCUMVehicleLock, error)
GetSCUMSurfaceForSession(string, string) (domain.SCUMSurface, error)
IngestSCUMFacts(domain.SCUMFactIngest) (domain.SCUMFactIngestResult, error)
ListPluginDataForSession(string, domain.PluginDataFilter) ([]domain.PluginDataRecord, error)
PutPluginDataForSession(string, domain.PluginDataRecord) (domain.PluginDataRecord, error)
+111
View File
@@ -19,6 +19,9 @@ func (svc *CoreService) ListSCUMUsersForSession(sessionID string, filter domain.
if _, err := svc.GetServerInstanceForSession(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
if _, err := svc.reconcileSCUMUserPresence(filter.ServerInstanceID, svc.now()); err != nil {
return nil, err
}
users, err := svc.store.SCUMUsers().List(filter)
if err != nil {
return nil, err
@@ -138,6 +141,79 @@ func (svc *CoreService) ListSCUMVehicleLocksForSession(sessionID string, filter
return domain.CopySCUMVehicleLocks(items), nil
}
func (svc *CoreService) GetSCUMSurfaceForSession(sessionID, serverInstanceID string) (domain.SCUMSurface, error) {
if strings.TrimSpace(serverInstanceID) == "" {
return domain.SCUMSurface{}, validationError("serverInstanceId is required")
}
if _, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID); err != nil {
return domain.SCUMSurface{}, err
}
if _, err := svc.reconcileSCUMUserPresence(serverInstanceID, svc.now()); err != nil {
return domain.SCUMSurface{}, err
}
users, err := svc.store.SCUMUsers().List(domain.SCUMUserFilter{ServerInstanceID: serverInstanceID, Limit: domain.SCUMDefaultListLimit})
if err != nil {
return domain.SCUMSurface{}, err
}
vehicles, err := svc.store.SCUMVehicles().List(domain.SCUMVehicleFilter{ServerInstanceID: serverInstanceID, Limit: domain.SCUMDefaultListLimit})
if err != nil {
return domain.SCUMSurface{}, err
}
userTracks, err := svc.store.SCUMUserTrajectories().List(domain.SCUMUserTrajectoryFilter{ServerInstanceID: serverInstanceID, Limit: domain.SCUMDefaultTrajectoryLimit})
if err != nil {
return domain.SCUMSurface{}, err
}
vehicleTracks, err := svc.store.SCUMVehicleTrajectories().List(domain.SCUMVehicleTrajectoryFilter{ServerInstanceID: serverInstanceID, Limit: domain.SCUMDefaultTrajectoryLimit})
if err != nil {
return domain.SCUMSurface{}, err
}
locks, err := svc.store.SCUMVehicleLocks().List(domain.SCUMVehicleLockFilter{ServerInstanceID: serverInstanceID, Limit: domain.SCUMDefaultTrajectoryLimit})
if err != nil {
return domain.SCUMSurface{}, err
}
return domain.SCUMSurface{Users: limitSCUMUsers(users), Vehicles: limitSCUMVehicles(vehicles), UserTrajectories: limitSCUMUserTrajectories(userTracks), VehicleTrajectories: limitSCUMVehicleTrajectories(vehicleTracks), VehicleLocks: limitSCUMVehicleLocks(locks)}, nil
}
func limitSCUMUsers(values []domain.SCUMUser) []domain.SCUMUser {
sort.SliceStable(values, func(i, j int) bool { return values[i].LastActivityAt.After(values[j].LastActivityAt) })
if len(values) > domain.SCUMDefaultListLimit {
values = values[:domain.SCUMDefaultListLimit]
}
return domain.CopySCUMUsers(values)
}
func limitSCUMVehicles(values []domain.SCUMVehicle) []domain.SCUMVehicle {
sort.SliceStable(values, func(i, j int) bool { return values[i].LastObservedAt.After(values[j].LastObservedAt) })
if len(values) > domain.SCUMDefaultListLimit {
values = values[:domain.SCUMDefaultListLimit]
}
return domain.CopySCUMVehicles(values)
}
func limitSCUMUserTrajectories(values []domain.SCUMUserTrajectory) []domain.SCUMUserTrajectory {
sort.SliceStable(values, func(i, j int) bool { return values[i].ObservedAt.After(values[j].ObservedAt) })
if len(values) > domain.SCUMDefaultTrajectoryLimit {
values = values[:domain.SCUMDefaultTrajectoryLimit]
}
return domain.CopySCUMUserTrajectories(values)
}
func limitSCUMVehicleTrajectories(values []domain.SCUMVehicleTrajectory) []domain.SCUMVehicleTrajectory {
sort.SliceStable(values, func(i, j int) bool { return values[i].ObservedAt.After(values[j].ObservedAt) })
if len(values) > domain.SCUMDefaultTrajectoryLimit {
values = values[:domain.SCUMDefaultTrajectoryLimit]
}
return domain.CopySCUMVehicleTrajectories(values)
}
func limitSCUMVehicleLocks(values []domain.SCUMVehicleLock) []domain.SCUMVehicleLock {
sort.SliceStable(values, func(i, j int) bool { return values[i].LockedAt.After(values[j].LockedAt) })
if len(values) > domain.SCUMDefaultTrajectoryLimit {
values = values[:domain.SCUMDefaultTrajectoryLimit]
}
return domain.CopySCUMVehicleLocks(values)
}
func (svc *CoreService) IngestSCUMFacts(batch domain.SCUMFactIngest) (domain.SCUMFactIngestResult, error) {
batch = domain.CopySCUMFactIngest(batch)
if strings.TrimSpace(batch.ServerInstanceID) == "" {
@@ -159,7 +235,17 @@ func (svc *CoreService) IngestSCUMFacts(batch domain.SCUMFactIngest) (domain.SCU
if instance.RunEndpointID != batch.RunEndpointID {
return domain.SCUMFactIngestResult{}, ErrForbidden
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.SCUMFactIngestResult{}, err
}
if !strings.EqualFold(strings.TrimSpace(plugin.ServerType), "scum") {
return domain.SCUMFactIngestResult{}, ErrForbidden
}
stamp := svc.now()
if _, err := svc.reconcileSCUMUserPresence(instance.ID, stamp); err != nil {
return domain.SCUMFactIngestResult{}, err
}
welcomeCount := 0
for _, fact := range batch.Users {
if err := svc.ingestSCUMUserFact(instance, fact, stamp, &welcomeCount); err != nil {
@@ -174,6 +260,31 @@ func (svc *CoreService) IngestSCUMFacts(batch domain.SCUMFactIngest) (domain.SCU
return domain.SCUMFactIngestResult{Accepted: true, AcceptedUserCount: len(batch.Users), AcceptedVehicleCount: len(batch.Vehicles), WelcomeQueuedCount: welcomeCount, ServerTime: stamp}, nil
}
// reconcileSCUMUserPresence converges stale online flags from the last typed
// Run fact. A missing offline fact is treated as a presence timeout only; no
// trajectory row is written and the last observed coordinates stay untouched.
func (svc *CoreService) reconcileSCUMUserPresence(serverInstanceID string, stamp time.Time) (int, error) {
online := true
stale, err := svc.store.SCUMUsers().List(domain.SCUMUserFilter{
ServerInstanceID: serverInstanceID,
Online: &online,
StaleBefore: stamp.Add(-domain.SCUMUserOfflineAfter),
})
if err != nil {
return 0, err
}
updated := 0
for _, user := range stale {
user.Online = false
user.UpdatedAt = stamp
if err := svc.store.SCUMUsers().Update(user); err != nil {
return updated, err
}
updated++
}
return updated, nil
}
func (svc *CoreService) ingestSCUMUserFact(instance domain.ServerInstance, fact domain.SCUMUserFact, stamp time.Time, welcomeCount *int) error {
steamID := strings.TrimSpace(fact.SteamID)
if steamID == "" {
+38
View File
@@ -1,6 +1,7 @@
package service
import (
"errors"
"testing"
"time"
@@ -126,3 +127,40 @@ func TestSCUMFactIngestQueuesFreshWelcomeAfterActivityGap(t *testing.T) {
t.Fatalf("expected two welcome jobs after activity gap, jobs=%+v err=%v", jobs, err)
}
}
func TestSCUMStaleOnlineUserConvergesToOffline(t *testing.T) {
svc, session, runSession, instance := newSourceRCONFixture(t)
staleAt := fixedTime.Add(-16 * time.Minute)
if _, err := svc.IngestSCUMFacts(domain.SCUMFactIngest{
RunEndpointID: instance.RunEndpointID, SessionToken: runSession, ServerInstanceID: instance.ID,
Users: []domain.SCUMUserFact{{SteamID: "76561198000000003", DisplayName: "Stale", Online: true, Login: true, ObservedAt: staleAt, LoginObservedAt: staleAt}},
}); err != nil {
t.Fatalf("ingest stale SCUM user fact: %v", err)
}
users, err := svc.ListSCUMUsersForSession(session, domain.SCUMUserFilter{ServerInstanceID: instance.ID})
if err != nil || len(users) != 1 {
t.Fatalf("list stale SCUM users: users=%+v err=%v", users, err)
}
if users[0].Online {
t.Fatalf("expected a user without activity for %s to converge offline: %+v", domain.SCUMUserOfflineAfter, users[0])
}
}
func TestSCUMFactsRejectedForNonSCUMServerInstance(t *testing.T) {
svc, _, runSession, instance := newSourceRCONFixture(t)
if err := svc.store.GamePlugins().Create(domain.GamePlugin{ID: "server.other", Name: "Other", Version: "1.0.0", ServerType: "other"}); err != nil {
t.Fatalf("create non-SCUM plugin: %v", err)
}
if err := svc.store.ServerInstances().Create(domain.ServerInstance{ID: "server-other", PluginID: "server.other", RunEndpointID: instance.RunEndpointID, Name: "Other Server", State: domain.ServerInstanceStateRunning, ConfigVersion: 1}); err != nil {
t.Fatalf("create non-SCUM server: %v", err)
}
_, err := svc.IngestSCUMFacts(domain.SCUMFactIngest{
RunEndpointID: instance.RunEndpointID, SessionToken: runSession, ServerInstanceID: "server-other",
Users: []domain.SCUMUserFact{{SteamID: "76561198000000004", Online: true}},
})
if !errors.Is(err, ErrForbidden) {
t.Fatalf("expected SCUM facts for a non-SCUM server to be rejected, got %v", err)
}
}