771 lines
33 KiB
Go
771 lines
33 KiB
Go
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, "等待当前数据", "玩家数据还未通过当前服务读回确认。")
|
|
}
|
|
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: "Declared 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: "Declared SCUM action", Message: "RCON text is generated server-side and is not stored in the local 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
|
|
}
|