package service import ( "encoding/json" "fmt" "strconv" "strings" "browser.local/platform/domain" "browser.local/platform/repo" ) type scumSQLiteMutationJobResult struct { Outcome string `json:"outcome"` AffectedRows int `json:"affectedRows"` MutationChecksum string `json:"mutationChecksum"` ConfirmationRows []map[string]any `json:"confirmationRows"` SafeMessage string `json:"safeMessage"` } func (svc *CoreService) RequestSCUMOperationForSession(sessionID, serverID string, request domain.SCUMOperationRequest) (domain.SCUMOperationRequest, error) { request = domain.CopySCUMOperationRequest(request) user, err := svc.GetCurrentUser(sessionID) if err != nil { return domain.SCUMOperationRequest{}, err } if err := svc.authorizeServerLifecycle(sessionID, serverID); err != nil { return domain.SCUMOperationRequest{}, err } instance, err := svc.store.ServerInstances().Get(serverID) if err != nil { return domain.SCUMOperationRequest{}, err } plugin, err := svc.store.GamePlugins().Get(instance.PluginID) if err != nil { return domain.SCUMOperationRequest{}, err } template, ok := scumOperationTemplate(plugin, request.TemplateKey) if !ok { return domain.SCUMOperationRequest{}, validationError("SCUM operation template is not declared") } if !containsString(plugin.DeclaredPermissions, template.Permission) { return domain.SCUMOperationRequest{}, validationError("SCUM operation permission is not declared") } if strings.TrimSpace(request.IdempotencyKey) == "" || len(request.IdempotencyKey) > 120 { return domain.SCUMOperationRequest{}, validationError("operation idempotency key is required") } existing, err := svc.store.SCUMOperationRequests().List(domain.SCUMOperationRequestFilter{ServerInstanceID: serverID, IdempotencyKey: request.IdempotencyKey}) if err != nil { return domain.SCUMOperationRequest{}, err } if len(existing) > 0 { return domain.CopySCUMOperationRequest(existing[0]), nil } playerID := coalesceString(request.PlayerID, firstString(request.Payload, "playerId", "steamId")) if playerID == "" && request.TemplateKey != "server.reward.command.deliver" { return domain.SCUMOperationRequest{}, validationError("operation playerId is required") } summary := operationSafeSummary(request.TemplateKey, playerID, request.Payload) switch template.Kind { case domain.GameClientBridgeOperationKindRCON: if err := validateSCUMRCONOperationPayload(request.TemplateKey, playerID, request.Payload); err != nil { return domain.SCUMOperationRequest{}, err } case domain.GameClientBridgeOperationKindSQLiteMutation: guard, payload, err := normalizeSCUMSQLiteMutationRequest(template, playerID, request.Payload, request.Guard) if err != nil { return domain.SCUMOperationRequest{}, err } request.Guard = guard request.Payload = payload summary = scumSQLiteMutationSafeSummary(request.TemplateKey, playerID, guard) default: return domain.SCUMOperationRequest{}, validationError("SCUM operation kind is unsupported") } stamp := svc.now() operation := domain.SCUMOperationRequest{ID: "scum-operation-" + fingerprintID(serverID, request.IdempotencyKey), ServerInstanceID: serverID, PluginID: instance.PluginID, TemplateKey: request.TemplateKey, PlayerID: playerID, RequesterID: user.ID, ApprovalLevel: template.ApprovalLevel, Payload: domain.CopyGameClientBridgePayload(request.Payload), Guard: request.Guard, Status: domain.SCUMWorkflowStepWaiting, Reason: bounded(request.Reason, 240), IdempotencyKey: request.IdempotencyKey, SafeSummary: summary, CreatedAt: stamp, UpdatedAt: stamp} if err := svc.store.SCUMOperationRequests().Create(operation); err != nil { return domain.SCUMOperationRequest{}, err } _, err = svc.recordAuditEventWithID(user.ID, "scum.operation.request", "scum-operation", operation.ID, domain.AuditResultQueued, "typed SCUM operation awaiting approval") return domain.CopySCUMOperationRequest(operation), err } func (svc *CoreService) ListSCUMOperationsForSession(sessionID string, filter domain.SCUMOperationRequestFilter) ([]domain.SCUMOperationRequest, error) { if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { return nil, err } values, err := svc.store.SCUMOperationRequests().List(filter) if err != nil { return nil, err } limitSCUMProjectionSlice(&values, filter.Limit) return values, nil } func (svc *CoreService) ApproveSCUMOperationForSession(sessionID, operationID string) (domain.SCUMOperationRequest, error) { operation, err := svc.store.SCUMOperationRequests().Get(operationID) if err != nil { return domain.SCUMOperationRequest{}, err } user, err := svc.GetCurrentUser(sessionID) if err != nil { return domain.SCUMOperationRequest{}, err } if err := svc.authorizeServerLifecycle(sessionID, operation.ServerInstanceID); err != nil { return domain.SCUMOperationRequest{}, err } if operation.ApprovalLevel == domain.GameClientBridgeApprovalLevelPlatformAdmin && !isPlatformAdmin(user) { return domain.SCUMOperationRequest{}, ErrForbidden } if operation.Status != domain.SCUMWorkflowStepWaiting { return domain.SCUMOperationRequest{}, validationError("SCUM operation is not awaiting approval") } instance, err := svc.store.ServerInstances().Get(operation.ServerInstanceID) if err != nil { return domain.SCUMOperationRequest{}, err } plugin, err := svc.store.GamePlugins().Get(instance.PluginID) if err != nil { return domain.SCUMOperationRequest{}, err } template, ok := scumOperationTemplate(plugin, operation.TemplateKey) if !ok { return domain.SCUMOperationRequest{}, validationError("SCUM operation template is not declared") } var jobID string var auditSummary string switch template.Kind { case domain.GameClientBridgeOperationKindRCON: request, err := svc.sourceRCONRequestForSCUMOperation(operation) if err != nil { return domain.SCUMOperationRequest{}, err } dispatch, err := svc.DispatchSourceRCONCommandForSession(sessionID, request) if err != nil { return domain.SCUMOperationRequest{}, err } jobID = dispatch.JobID auditSummary = "typed SCUM operation dispatched through transient RCON input" case domain.GameClientBridgeOperationKindSQLiteMutation: gated, ready, err := svc.applySCUMSQLiteMutationApprovalGate(operation, template) if err != nil || !ready { return gated, err } operation = gated job, err := svc.dispatchSCUMSQLiteMutationOperation(operation, template) if err != nil { return domain.SCUMOperationRequest{}, err } jobID = job.ID auditSummary = "typed SCUM DB mutation dispatched through template-bound Run job" default: return domain.SCUMOperationRequest{}, validationError("SCUM operation kind is unsupported") } stamp := svc.now() operation.ApproverID = user.ID operation.ApprovedAt = stamp operation.Status = domain.SCUMWorkflowStepQueued operation.RunJobID = jobID operation.UpdatedAt = stamp operation.AuditReferences = append(operation.AuditReferences, "job:"+jobID) if err := svc.store.SCUMOperationRequests().Update(operation); err != nil { return domain.SCUMOperationRequest{}, err } _, err = svc.recordAuditEventWithID(user.ID, "scum.operation.approve", "scum-operation", operation.ID, domain.AuditResultQueued, auditSummary) return domain.CopySCUMOperationRequest(operation), err } func (svc *CoreService) ReconcileSCUMOperation(operationID string) (domain.SCUMOperationRequest, error) { operation, err := svc.store.SCUMOperationRequests().Get(operationID) if err != nil { return domain.SCUMOperationRequest{}, err } if strings.TrimSpace(operation.RunJobID) == "" { return domain.CopySCUMOperationRequest(operation), nil } job, err := svc.store.Jobs().Get(operation.RunJobID) if err != nil { return domain.SCUMOperationRequest{}, err } instance, err := svc.store.ServerInstances().Get(operation.ServerInstanceID) if err != nil { return domain.SCUMOperationRequest{}, err } plugin, err := svc.store.GamePlugins().Get(instance.PluginID) if err != nil { return domain.SCUMOperationRequest{}, err } template, _ := scumOperationTemplate(plugin, operation.TemplateKey) stamp := svc.now() switch job.State { case domain.JobStateSucceeded: if template.Kind == domain.GameClientBridgeOperationKindSQLiteMutation { if updated, terminal := reconcileSCUMSQLiteMutationJobResult(operation, template, job); terminal { operation = updated } else { operation = updated operation.Status = domain.SCUMWorkflowStepConfirming } } else if operation.Confirmation.Status == "confirmed" { operation.Status = domain.SCUMWorkflowStepConfirmed } else { operation.Status = domain.SCUMWorkflowStepConfirming } case domain.JobStateFailed: if strings.Contains(strings.ToLower(job.ExecutionResult.Kind), "unknown") || strings.Contains(strings.ToLower(job.ExecutionResult.AuditSummary), "unknown") { operation.Status = domain.SCUMWorkflowStepUnknown } else { operation.Status = domain.SCUMWorkflowStepFailed } operation.CompletedAt = stamp case domain.JobStateCancelled: operation.Status = domain.SCUMWorkflowStepUnknown operation.CompletedAt = stamp } operation.UpdatedAt = stamp if (operation.Status == domain.SCUMWorkflowStepConfirmed || operation.Status == domain.SCUMWorkflowStepFailed || operation.Status == domain.SCUMWorkflowStepUnknown) && operation.CompletedAt.IsZero() { operation.CompletedAt = stamp } if err := svc.store.SCUMOperationRequests().Update(operation); err != nil { return domain.SCUMOperationRequest{}, err } return domain.CopySCUMOperationRequest(operation), nil } func (svc *CoreService) ConfirmSCUMOperation(operationID string, confirmation domain.SCUMOperationConfirmation) (domain.SCUMOperationRequest, error) { operation, err := svc.store.SCUMOperationRequests().Get(operationID) if err != nil { return domain.SCUMOperationRequest{}, err } instance, err := svc.store.ServerInstances().Get(operation.ServerInstanceID) if err != nil { return domain.SCUMOperationRequest{}, err } plugin, err := svc.store.GamePlugins().Get(instance.PluginID) if err != nil { return domain.SCUMOperationRequest{}, err } template, _ := scumOperationTemplate(plugin, operation.TemplateKey) confirmation = domain.CopySCUMOperationConfirmation(confirmation) stamp := svc.now() if confirmation.Status != "confirmed" { operation.Status = domain.SCUMWorkflowStepFailed operation.Confirmation = confirmation operation.CompletedAt = stamp operation.UpdatedAt = stamp if err := svc.store.SCUMOperationRequests().Update(operation); err != nil { return domain.SCUMOperationRequest{}, err } return domain.CopySCUMOperationRequest(operation), nil } if template.Kind == domain.GameClientBridgeOperationKindSQLiteMutation && !scumSQLiteMutationConfirmationMatches(operation, confirmation.ConfirmedFields) { confirmation.Status = "failed" confirmation.SafeSummary = domain.SCUMSafeSummary{Title: "DB mutation confirmation mismatch", Message: "Run readback did not prove the requested SCUM player field value."} operation.Status = domain.SCUMWorkflowStepFailed operation.Confirmation = confirmation operation.CompletedAt = stamp operation.UpdatedAt = stamp if err := svc.store.SCUMOperationRequests().Update(operation); err != nil { return domain.SCUMOperationRequest{}, err } return domain.CopySCUMOperationRequest(operation), nil } operation.Confirmation = confirmation operation.Status = domain.SCUMWorkflowStepConfirmed operation.CompletedAt = stamp operation.UpdatedAt = stamp if err := svc.store.SCUMOperationRequests().Update(operation); err != nil { return domain.SCUMOperationRequest{}, err } return domain.CopySCUMOperationRequest(operation), nil } func (svc *CoreService) sourceRCONRequestForSCUMOperation(operation domain.SCUMOperationRequest) (domain.SourceRCONCommandRequest, error) { command, chat, err := scumRCONCommandForOperation(operation) if err != nil { return domain.SourceRCONCommandRequest{}, err } request := domain.SourceRCONCommandRequest{ServerInstanceID: operation.ServerInstanceID, IdempotencyKey: "scum-operation-" + operation.IdempotencyKey} if chat != "" { request.Kind = domain.SourceRCONCommandKindChat request.ChatType = 4 request.TargetSteamID = operation.PlayerID request.Message = chat return request, nil } request.Kind = domain.SourceRCONCommandKindCommand request.Command = command return request, nil } func scumRCONCommandForOperation(operation domain.SCUMOperationRequest) (command string, chat string, err error) { playerID := operation.PlayerID switch operation.TemplateKey { case "player.fame.set": amount, ok := operationInteger(operation.Payload, "fame", "amount", "value") if !ok { return "", "", validationError("fame amount is required") } return fmt.Sprintf("#SetFamePoints %d %q", amount, playerID), "", nil case "player.currency.normal.set": amount, ok := operationInteger(operation.Payload, "amount", "balance", "normalBalance") if !ok { return "", "", validationError("normal currency amount is required") } return fmt.Sprintf("#SetCurrencyBalance Normal %d %q", amount, playerID), "", nil case "player.currency.gold.set": amount, ok := operationInteger(operation.Payload, "amount", "balance", "goldBalance") if !ok { return "", "", validationError("gold currency amount is required") } return fmt.Sprintf("#SetCurrencyBalance Gold %d %q", amount, playerID), "", nil case "player.notify": message := strings.TrimSpace(firstString(operation.Payload, "message", "notice")) if message == "" || len(message) > 200 { return "", "", validationError("notification message is required") } return "", message, nil default: return "", "", validationError("unsupported SCUM RCON operation template") } } func validateSCUMRCONOperationPayload(templateKey, playerID string, payload map[string]any) error { operation := domain.SCUMOperationRequest{TemplateKey: templateKey, PlayerID: playerID, Payload: payload} command, chat, err := scumRCONCommandForOperation(operation) if err != nil { return err } if strings.ContainsAny(command, "\r\n") || strings.ContainsAny(chat, "\r\n") { return validationError("operation payload contains invalid control characters") } return nil } func normalizeSCUMSQLiteMutationRequest(template domain.GameClientBridgeOperationTemplateDeclaration, playerID string, payload map[string]any, guard domain.SCUMMutationGuard) (domain.SCUMMutationGuard, map[string]any, error) { lowerKey := strings.ToLower(template.Key) if strings.Contains(lowerKey, "fame") || strings.Contains(lowerKey, "currency") { return domain.SCUMMutationGuard{}, nil, validationError("SCUM fame and currency edits must use RCON operation templates") } if template.ApprovalLevel != domain.GameClientBridgeApprovalLevelPlatformAdmin { return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation requires platform-admin approval") } if template.Mutation.FieldKey == "" || template.Mutation.ConfirmationQueryKey == "" || template.Mutation.TableKey == "" || template.Mutation.IdentityKey == "" || template.Mutation.ValueKey == "" { return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation metadata is incomplete") } if template.MaxRowsAffected < 1 { return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation row bound is required") } payload = domain.CopyGameClientBridgePayload(payload) if guard.FieldKey == "" { guard.FieldKey = coalesceString(firstString(payload, "fieldKey"), template.Mutation.FieldKey) } if guard.Before == nil { guard.Before = payload["before"] } if guard.After == nil { guard.After = payload["after"] if guard.After == nil { guard.After = payload["value"] } } if guard.MaxRowsAffected == 0 { guard.MaxRowsAffected = template.MaxRowsAffected } guard.SafetyWindow = coalesceString(guard.SafetyWindow, firstString(payload, "safetyWindow", "maintenanceWindow")) guard.BackupRef = coalesceString(guard.BackupRef, firstString(payload, "backupRef", "snapshotRef")) guard.RequiresOfflinePlayer = template.Safety.RequiresOfflinePlayer guard.RequiresMaintenance = template.Safety.RequiresMaintenanceWindow guard.RequiresBackup = template.Safety.BackupRequired if playerID == "" { return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation playerId is required") } if guard.FieldKey != template.Mutation.FieldKey { return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation field key does not match template") } if guard.Before == nil || guard.After == nil { return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation before and after values are required") } if guard.MaxRowsAffected < 1 || guard.MaxRowsAffected > template.MaxRowsAffected { return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation maxRowsAffected exceeds template bound") } if err := validateSCUMMutationValue(template, guard.Before, "before"); err != nil { return domain.SCUMMutationGuard{}, nil, err } if err := validateSCUMMutationValue(template, guard.After, "after"); err != nil { return domain.SCUMMutationGuard{}, nil, err } for key, value := range map[string]any{"playerId": playerID, "fieldKey": guard.FieldKey, "before": guard.Before, "after": guard.After, "safetyWindow": guard.SafetyWindow, "backupRef": guard.BackupRef} { if value != nil && value != "" { payload[key] = value } } return guard, payload, nil } func validateSCUMMutationValue(template domain.GameClientBridgeOperationTemplateDeclaration, value any, label string) error { switch template.Mutation.AllowedValueType { case "integer": parsed, ok := anyInt64(value) if !ok { return validationError("SCUM DB mutation " + label + " value must be an integer") } if template.Mutation.MinValue != 0 && float64(parsed) < template.Mutation.MinValue || template.Mutation.MaxValue != 0 && float64(parsed) > template.Mutation.MaxValue { return validationError("SCUM DB mutation " + label + " value is outside the template range") } case "number": parsed, ok := anyFloat64(value) if !ok { return validationError("SCUM DB mutation " + label + " value must be numeric") } if template.Mutation.MinValue != 0 && parsed < template.Mutation.MinValue || template.Mutation.MaxValue != 0 && parsed > template.Mutation.MaxValue { return validationError("SCUM DB mutation " + label + " value is outside the template range") } case "string": if strings.TrimSpace(fmt.Sprint(value)) == "" || strings.ContainsAny(fmt.Sprint(value), "\r\n") { return validationError("SCUM DB mutation " + label + " value is invalid") } case "boolean": if _, ok := value.(bool); !ok { return validationError("SCUM DB mutation " + label + " value must be boolean") } default: return validationError("SCUM DB mutation value type is unsupported") } return nil } func (svc *CoreService) applySCUMSQLiteMutationApprovalGate(operation domain.SCUMOperationRequest, template domain.GameClientBridgeOperationTemplateDeclaration) (domain.SCUMOperationRequest, bool, error) { state, err := svc.latestSCUMPlayerLiveState(operation.ServerInstanceID, operation.PlayerID) if err != nil { if err == repo.ErrNotFound { return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待真实玩家投影", "需要先从当前服务的登录日志或 SCUM.db 读取玩家数据。") } return domain.SCUMOperationRequest{}, false, err } if state.Freshness.Status != domain.SCUMProjectionFresh { return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待新鲜投影", "玩家投影不是 fresh,需先刷新 SCUM.db/readback。") } if template.Safety.RequiresOfflinePlayer && state.Online { return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待玩家离线", "DB-only 玩家字段修改必须等玩家离线或进入维护窗口。") } if template.Safety.RequiresMaintenanceWindow && strings.TrimSpace(operation.Guard.SafetyWindow) == "" { return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "缺少维护窗口", "DB mutation 需要记录维护窗口/离线安全证据。") } if template.Safety.BackupRequired && strings.TrimSpace(operation.Guard.BackupRef) == "" { return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "缺少备份快照", "DB mutation 需要 run 或管理员提供 backup/snapshot evidence。") } current, ok := scumCurrentMutationFieldValue(state, operation.Guard.FieldKey) if !ok { return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待字段读回", "当前投影没有该 DB-only 字段,需先执行确认查询。") } if !scumScalarEqual(current, operation.Guard.Before) { return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepBlocked, "before value 已过期", "当前投影值与审批时 before guard 不一致,已阻止写入。") } return domain.CopySCUMOperationRequest(operation), true, nil } func (svc *CoreService) updateSCUMOperationGate(operation domain.SCUMOperationRequest, status domain.SCUMWorkflowStepStatus, title string, message string) (domain.SCUMOperationRequest, bool, error) { operation.Status = status operation.SafeSummary = domain.SCUMSafeSummary{Title: title, Message: message, Details: map[string]string{"template": operation.TemplateKey, "playerId": operation.PlayerID}} operation.UpdatedAt = svc.now() if err := svc.store.SCUMOperationRequests().Update(operation); err != nil { return domain.SCUMOperationRequest{}, false, err } return domain.CopySCUMOperationRequest(operation), false, nil } func (svc *CoreService) latestSCUMPlayerLiveState(serverID, playerID string) (domain.SCUMPlayerLiveState, error) { states, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: serverID, GamePlayerID: playerID}) if err != nil { return domain.SCUMPlayerLiveState{}, err } if len(states) == 0 { states, err = svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: serverID, SteamID: playerID}) if err != nil { return domain.SCUMPlayerLiveState{}, err } } if len(states) == 0 { return domain.SCUMPlayerLiveState{}, repo.ErrNotFound } best := states[0] for _, state := range states[1:] { if state.Freshness.ObservedAt.After(best.Freshness.ObservedAt) || state.UpdatedAt.After(best.UpdatedAt) { best = state } } return domain.CopySCUMPlayerLiveState(best), nil } func scumCurrentMutationFieldValue(state domain.SCUMPlayerLiveState, fieldKey string) (any, bool) { if state.UnknownFields != nil { for _, key := range []string{fieldKey, "field" + fieldKey, "attribute" + fieldKey, "attribute_" + fieldKey, "stat" + fieldKey, "stat_" + fieldKey} { if value, ok := state.UnknownFields[key]; ok { return value, true } } } return nil, false } func (svc *CoreService) dispatchSCUMSQLiteMutationOperation(operation domain.SCUMOperationRequest, template domain.GameClientBridgeOperationTemplateDeclaration) (domain.Job, error) { instance, err := svc.store.ServerInstances().Get(operation.ServerInstanceID) if err != nil { return domain.Job{}, err } jobID := jobIDFromParts("job-scum-sqlite-mutation", instance.ID, operation.IdempotencyKey) job := domain.Job{ID: jobID, ServerInstanceID: instance.ID, RunEndpointID: instance.RunEndpointID, Capability: domain.JobCapabilityRemoteRunProtectedSQL, TargetKey: template.TargetKey, InputRef: "input://scum-operation/" + operation.ID, IdempotencyKey: "scum-sqlite-mutation:" + operation.IdempotencyKey, Progress: domain.JobProgress{Percent: 0, Message: "typed SCUM DB mutation queued"}, RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1}, ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), RemoteAdapterKey: template.TransportKey, RemoteAdapterKind: "protected-sql", TimeoutSeconds: template.TimeoutSeconds, PluginID: operation.PluginID, Inputs: scumSQLiteMutationJobInputs(operation, template)}} created, err := svc.CreateJob(job) if err != nil { return domain.Job{}, err } if created.ID != jobID || created.Capability != domain.JobCapabilityRemoteRunProtectedSQL || created.TargetKey != template.TargetKey || created.ExecutionInput.RemoteAdapterKey != template.TransportKey { return domain.Job{}, validationError("SCUM DB mutation idempotency key is already bound") } return created, nil } func scumSQLiteMutationJobInputs(operation domain.SCUMOperationRequest, template domain.GameClientBridgeOperationTemplateDeclaration) map[string]string { return map[string]string{ "operationId": operation.ID, "templateKey": operation.TemplateKey, "playerId": operation.PlayerID, "fieldKey": operation.Guard.FieldKey, "tableKey": template.Mutation.TableKey, "identityKey": template.Mutation.IdentityKey, "valueKey": template.Mutation.ValueKey, "before": scumScalarString(operation.Guard.Before), "after": scumScalarString(operation.Guard.After), "maxRowsAffected": strconv.Itoa(operation.Guard.MaxRowsAffected), "confirmationQueryKey": template.Mutation.ConfirmationQueryKey, "safetyWindow": operation.Guard.SafetyWindow, "backupRef": operation.Guard.BackupRef, } } func reconcileSCUMSQLiteMutationJobResult(operation domain.SCUMOperationRequest, template domain.GameClientBridgeOperationTemplateDeclaration, job domain.Job) (domain.SCUMOperationRequest, bool) { result, ok := parseSCUMSQLiteMutationJobResult(job.ExecutionResult.Content) if !ok || result.Outcome == "unknown" || strings.Contains(strings.ToLower(job.ExecutionResult.Kind), "unknown") { operation.Status = domain.SCUMWorkflowStepUnknown operation.Confirmation = domain.SCUMOperationConfirmation{Status: "unknown", SafeSummary: domain.SCUMSafeSummary{Title: "DB mutation state unknown", Message: "Run did not return a valid bounded mutation result."}} return operation, true } operation.Confirmation.AffectedRows = result.AffectedRows operation.Confirmation.MutationChecksum = result.MutationChecksum operation.Confirmation.Checksum = coalesceString(operation.Confirmation.Checksum, coalesceString(result.MutationChecksum, job.ExecutionResult.Checksum)) if result.Outcome == "stale-before" { operation.Status = domain.SCUMWorkflowStepFailed operation.Confirmation.Status = "failed" operation.SafeSummary = domain.SCUMSafeSummary{Title: "before value 已过期", Message: "Run 在写入前发现当前 DB 值与 approved before guard 不一致。"} return operation, true } if result.Outcome != "succeeded" || result.AffectedRows < 1 { operation.Status = domain.SCUMWorkflowStepFailed operation.Confirmation.Status = "failed" operation.SafeSummary = domain.SCUMSafeSummary{Title: "DB mutation failed", Message: bounded(coalesceString(result.SafeMessage, "Run reported the mutation did not succeed."), 240)} return operation, true } if result.AffectedRows > template.MaxRowsAffected || result.AffectedRows > operation.Guard.MaxRowsAffected || strings.TrimSpace(result.MutationChecksum) == "" { operation.Status = domain.SCUMWorkflowStepUnknown operation.Confirmation.Status = "unknown" operation.SafeSummary = domain.SCUMSafeSummary{Title: "DB mutation row bound unknown", Message: "Run result exceeded declared row bounds or omitted mutation checksum."} return operation, true } if len(result.ConfirmationRows) > 0 { for _, row := range result.ConfirmationRows { if scumSQLiteMutationConfirmationMatches(operation, row) { operation.Status = domain.SCUMWorkflowStepConfirmed operation.Confirmation.Status = "confirmed" operation.Confirmation.ConfirmedFields = domain.CopyGameClientBridgePayload(row) return operation, true } } operation.Status = domain.SCUMWorkflowStepFailed operation.Confirmation.Status = "failed" operation.SafeSummary = domain.SCUMSafeSummary{Title: "DB mutation confirmation mismatch", Message: "Run confirmation rows did not match the requested after value."} return operation, true } operation.Confirmation.Status = "executed" return operation, false } func parseSCUMSQLiteMutationJobResult(content string) (scumSQLiteMutationJobResult, bool) { if strings.TrimSpace(content) == "" { return scumSQLiteMutationJobResult{}, false } var result scumSQLiteMutationJobResult if err := json.Unmarshal([]byte(content), &result); err != nil { return scumSQLiteMutationJobResult{}, false } result.Outcome = strings.TrimSpace(result.Outcome) return result, result.Outcome != "" } func scumSQLiteMutationConfirmationMatches(operation domain.SCUMOperationRequest, row map[string]any) bool { if row == nil { return false } rowPlayerID := firstString(row, "playerId", "gamePlayerId", "steamId", "steam_id") if rowPlayerID != "" && rowPlayerID != operation.PlayerID { return false } if field := firstString(row, "fieldKey", "field", "attributeKey"); field != "" && field != operation.Guard.FieldKey { return false } for _, key := range []string{"value", "after", operation.Guard.FieldKey, "field" + operation.Guard.FieldKey, "attribute" + operation.Guard.FieldKey, "attribute_" + operation.Guard.FieldKey} { if value, ok := row[key]; ok && scumScalarEqual(value, operation.Guard.After) { return true } } return false } func scumSQLiteMutationSafeSummary(templateKey, playerID string, guard domain.SCUMMutationGuard) domain.SCUMSafeSummary { details := map[string]string{"template": templateKey, "fieldKey": guard.FieldKey, "maxRowsAffected": strconv.Itoa(guard.MaxRowsAffected)} if playerID != "" { details["playerId"] = playerID } if guard.SafetyWindow != "" { details["safetyWindow"] = guard.SafetyWindow } if guard.BackupRef != "" { details["backupRef"] = guard.BackupRef } return domain.SCUMSafeSummary{Title: "Typed SCUM DB mutation", Message: "Run executes this through a declared mutation template with before-value and row-bound guards; raw SQL is not stored.", Details: details} } func operationInteger(payload map[string]any, keys ...string) (int64, bool) { for _, key := range keys { value, exists := payload[key] if !exists { continue } switch typed := value.(type) { case int: return int64(typed), true case int64: return typed, true case uint64: if typed > uint64(^uint64(0)>>1) { return 0, false } return int64(typed), true case float64: if typed == float64(int64(typed)) { return int64(typed), true } case string: parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) if err == nil { return parsed, true } } } return 0, false } func anyInt64(value any) (int64, bool) { switch typed := value.(type) { case int: return int64(typed), true case int8: return int64(typed), true case int16: return int64(typed), true case int32: return int64(typed), true case int64: return typed, true case uint: return int64(typed), true case uint8: return int64(typed), true case uint16: return int64(typed), true case uint32: return int64(typed), true case uint64: if typed > uint64(^uint64(0)>>1) { return 0, false } return int64(typed), true case float64: if typed == float64(int64(typed)) { return int64(typed), true } case float32: if typed == float32(int64(typed)) { return int64(typed), true } case json.Number: parsed, err := typed.Int64() return parsed, err == nil case string: parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) return parsed, err == nil } return 0, false } func anyFloat64(value any) (float64, bool) { switch typed := value.(type) { case int: return float64(typed), true case int64: return float64(typed), true case uint64: return float64(typed), true case float64: return typed, true case float32: return float64(typed), true case json.Number: parsed, err := typed.Float64() return parsed, err == nil case string: parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) return parsed, err == nil } return 0, false } func scumScalarEqual(left any, right any) bool { if leftInt, ok := anyInt64(left); ok { if rightInt, rightOK := anyInt64(right); rightOK { return leftInt == rightInt } } if leftFloat, ok := anyFloat64(left); ok { if rightFloat, rightOK := anyFloat64(right); rightOK { return leftFloat == rightFloat } } return strings.TrimSpace(fmt.Sprint(left)) == strings.TrimSpace(fmt.Sprint(right)) } func scumScalarString(value any) string { if parsed, ok := anyInt64(value); ok { return strconv.FormatInt(parsed, 10) } if parsed, ok := anyFloat64(value); ok { return strconv.FormatFloat(parsed, 'f', -1, 64) } if typed, ok := value.(bool); ok { return strconv.FormatBool(typed) } return bounded(strings.TrimSpace(fmt.Sprint(value)), 512) } func operationSafeSummary(templateKey, playerID string, payload map[string]any) domain.SCUMSafeSummary { details := map[string]string{"template": templateKey} if playerID != "" { details["playerId"] = playerID } if amount, ok := operationInteger(payload, "fame", "amount", "balance", "value", "normalBalance", "goldBalance"); ok { details["value"] = fmt.Sprintf("%d", amount) } return domain.SCUMSafeSummary{Title: "Typed SCUM operation", Message: "RCON text is generated server-side and is not stored in the operation record.", Details: details} } func scumOperationTemplate(plugin domain.GamePlugin, key string) (domain.GameClientBridgeOperationTemplateDeclaration, bool) { for _, template := range plugin.GameClientBridge.OperationTemplates { if template.Key == key { return template, true } } return domain.GameClientBridgeOperationTemplateDeclaration{}, false }