feat(scum): add versioned gift grants

This commit is contained in:
npc0-hue
2026-07-28 16:40:35 +08:00
parent 4527175a6f
commit e2d0bc0595
26 changed files with 1224 additions and 14 deletions
+290
View File
@@ -0,0 +1,290 @@
package service
import (
"browser.local/platform/domain"
"browser.local/platform/repo"
"fmt"
"sort"
"strings"
"time"
)
func (svc *CoreService) ListGameGiftCatalogsForSession(sessionID, serverID string) ([]domain.GameGiftCatalog, error) {
if err := svc.authorizeServerLifecycle(sessionID, serverID); err != nil {
return nil, err
}
return svc.store.GameGiftCatalogs().List(domain.GameGiftCatalogFilter{ServerInstanceID: serverID})
}
func (svc *CoreService) SaveGameGiftCatalogForSession(sessionID, serverID string, request domain.GameGiftCatalogRequest) (domain.GameGiftCatalog, error) {
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.GameGiftCatalog{}, err
}
if err = svc.authorizeServerLifecycle(sessionID, serverID); err != nil {
return domain.GameGiftCatalog{}, err
}
if err = validateGiftItems(request.GameVersion, request.Items); err != nil {
return domain.GameGiftCatalog{}, err
}
stamp := svc.now()
catalog := domain.GameGiftCatalog{ID: fmt.Sprintf("game-gift-catalog-%d", stamp.UnixNano()), ServerInstanceID: serverID, Name: strings.TrimSpace(request.Name), GameVersion: request.GameVersion, DraftItems: domain.CopyGameGiftItems(request.Items), CreatedBy: user.ID, CreatedAt: stamp, UpdatedAt: stamp}
if request.ID != "" {
existing, getErr := svc.store.GameGiftCatalogs().Get(request.ID)
if getErr != nil || existing.ServerInstanceID != serverID {
return domain.GameGiftCatalog{}, repo.ErrNotFound
}
catalog.ID, catalog.CreatedBy, catalog.CreatedAt, catalog.LatestRevisionID = existing.ID, existing.CreatedBy, existing.CreatedAt, existing.LatestRevisionID
}
if len(catalog.Name) < 2 || len(catalog.Name) > 80 {
return domain.GameGiftCatalog{}, validationError("gift catalog name must be 2 to 80 characters")
}
if request.ID == "" {
if err = svc.store.GameGiftCatalogs().Create(catalog); err != nil {
return domain.GameGiftCatalog{}, err
}
} else if err = svc.store.GameGiftCatalogs().Update(catalog); err != nil {
return domain.GameGiftCatalog{}, err
}
_, err = svc.recordAuditEventWithID(user.ID, "game-gift.catalog.save", "game-gift-catalog", catalog.ID, domain.AuditResultSuccess, "version-fenced gift draft saved")
return domain.CopyGameGiftCatalog(catalog), err
}
func (svc *CoreService) PublishGameGiftCatalogForSession(sessionID, catalogID string) (domain.GameGiftRevision, error) {
catalog, err := svc.store.GameGiftCatalogs().Get(catalogID)
if err != nil {
return domain.GameGiftRevision{}, err
}
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.GameGiftRevision{}, err
}
if err = svc.authorizeServerLifecycle(sessionID, catalog.ServerInstanceID); err != nil {
return domain.GameGiftRevision{}, err
}
if err = validateGiftItems(catalog.GameVersion, catalog.DraftItems); err != nil {
return domain.GameGiftRevision{}, err
}
revisions, err := svc.store.GameGiftRevisions().List(domain.GameGiftRevisionFilter{CatalogID: catalog.ID})
if err != nil {
return domain.GameGiftRevision{}, err
}
stamp := svc.now()
revision := domain.GameGiftRevision{ID: fmt.Sprintf("game-gift-revision-%d", stamp.UnixNano()), CatalogID: catalog.ID, ServerInstanceID: catalog.ServerInstanceID, Revision: len(revisions) + 1, GameVersion: catalog.GameVersion, Items: domain.CopyGameGiftItems(catalog.DraftItems), PublishedBy: user.ID, PublishedAt: stamp}
if err = svc.store.GameGiftRevisions().Create(revision); err != nil {
return domain.GameGiftRevision{}, err
}
catalog.LatestRevisionID, catalog.UpdatedAt = revision.ID, stamp
if err = svc.store.GameGiftCatalogs().Update(catalog); err != nil {
return domain.GameGiftRevision{}, err
}
_, err = svc.recordAuditEventWithID(user.ID, "game-gift.catalog.publish", "game-gift-revision", revision.ID, domain.AuditResultSuccess, "immutable gift revision published")
return domain.CopyGameGiftRevision(revision), err
}
func (svc *CoreService) ListGameGiftRevisionsForSession(sessionID, catalogID string) ([]domain.GameGiftRevision, error) {
catalog, err := svc.store.GameGiftCatalogs().Get(catalogID)
if err != nil {
return nil, err
}
if err = svc.authorizeServerLifecycle(sessionID, catalog.ServerInstanceID); err != nil {
return nil, err
}
return svc.store.GameGiftRevisions().List(domain.GameGiftRevisionFilter{CatalogID: catalogID})
}
func (svc *CoreService) RequestGameGiftGrantForSession(sessionID, serverID string, request domain.GameGiftGrantRequest) (domain.GameGiftGrant, error) {
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.GameGiftGrant{}, err
}
if err = svc.authorizeServerLifecycle(sessionID, serverID); err != nil {
return domain.GameGiftGrant{}, err
}
if strings.TrimSpace(request.IdempotencyKey) == "" || len(request.IdempotencyKey) > 120 {
return domain.GameGiftGrant{}, validationError("gift grant idempotency key is required")
}
existing, err := svc.store.GameGiftGrants().List(domain.GameGiftGrantFilter{ServerInstanceID: serverID, IdempotencyKey: request.IdempotencyKey})
if err != nil {
return domain.GameGiftGrant{}, err
}
if len(existing) > 0 {
return domain.CopyGameGiftGrant(existing[0]), nil
}
revision, err := svc.store.GameGiftRevisions().Get(request.RevisionID)
if err != nil || revision.ServerInstanceID != serverID {
return domain.GameGiftGrant{}, repo.ErrNotFound
}
if err = validateGiftItems(revision.GameVersion, revision.Items); err != nil {
return domain.GameGiftGrant{}, err
}
player, err := svc.store.GamePlayers().Get(request.GamePlayerRecordID)
if err != nil || player.ServerInstanceID != serverID {
return domain.GameGiftGrant{}, repo.ErrNotFound
}
notice := strings.TrimSpace(request.Notice)
if len(notice) < 1 || len(notice) > 200 {
return domain.GameGiftGrant{}, validationError("gift notification must be 1 to 200 characters")
}
stamp := svc.now()
grant := domain.GameGiftGrant{ID: fmt.Sprintf("game-gift-grant-%d", stamp.UnixNano()), ServerInstanceID: serverID, CatalogID: revision.CatalogID, RevisionID: revision.ID, RevisionNumber: revision.Revision, GameVersion: revision.GameVersion, Items: domain.CopyGameGiftItems(revision.Items), GamePlayerRecordID: player.ID, GamePlayerID: player.GamePlayerID, PlayerDisplayName: player.DisplayName, Notice: notice, IdempotencyKey: request.IdempotencyKey, RequesterID: user.ID, Status: domain.GameGiftGrantPendingApproval, CreatedAt: stamp, UpdatedAt: stamp}
if err = svc.store.GameGiftGrants().Create(grant); err != nil {
return domain.GameGiftGrant{}, err
}
_, err = svc.recordAuditEventWithID(user.ID, "game-gift.grant.request", "game-gift-grant", grant.ID, domain.AuditResultQueued, "frozen gift grant awaiting platform administrator approval")
return domain.CopyGameGiftGrant(grant), err
}
func (svc *CoreService) ApproveGameGiftGrantForSession(sessionID, grantID string) (domain.GameGiftGrant, error) {
grant, err := svc.store.GameGiftGrants().Get(grantID)
if err != nil {
return domain.GameGiftGrant{}, err
}
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.GameGiftGrant{}, err
}
if !isPlatformAdmin(user) {
return domain.GameGiftGrant{}, ErrForbidden
}
if err = svc.authorizeServerLifecycle(sessionID, grant.ServerInstanceID); err != nil {
return domain.GameGiftGrant{}, err
}
if grant.Status != domain.GameGiftGrantPendingApproval {
return domain.GameGiftGrant{}, validationError("gift grant is not awaiting approval")
}
if err = validateGiftItems(grant.GameVersion, grant.Items); err != nil {
return domain.GameGiftGrant{}, err
}
sessions, err := svc.store.GamePlayerSessions().List(domain.GamePlayerSessionFilter{GamePlayerRecordID: grant.GamePlayerRecordID, OpenOnly: true})
if err != nil {
return domain.GameGiftGrant{}, err
}
if len(sessions) == 0 {
return domain.GameGiftGrant{}, validationError("gift target player is offline")
}
instance, err := svc.store.ServerInstances().Get(grant.ServerInstanceID)
if err != nil {
return domain.GameGiftGrant{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.GameGiftGrant{}, err
}
profile, ok := gameClientBridgeProfileKey(plugin)
if !ok {
return domain.GameGiftGrant{}, validationError("SCUM gift companion profile is unavailable")
}
command, err := svc.queueGameClientBridgeCommand(user.ID, domain.GameClientBridgeQueueRequest{ServerInstanceID: grant.ServerInstanceID, PluginID: instance.PluginID, ProfileKey: profile, CommandType: domain.SCUMRewardDeliverCommandType, Payload: giftDeliveryPayload(grant), IdempotencyKey: grant.ID, Priority: 10, ExpiresAt: svc.now().Add(2 * time.Minute)})
if err != nil {
return domain.GameGiftGrant{}, err
}
stamp := svc.now()
grant.Status, grant.ApproverID, grant.ApprovedAt, grant.UpdatedAt, grant.DeliveryCommandID = domain.GameGiftGrantQueued, user.ID, stamp, stamp, command.ID
if err = svc.store.GameGiftGrants().Update(grant); err != nil {
return domain.GameGiftGrant{}, err
}
_, err = svc.recordAuditEventWithID(user.ID, "game-gift.grant.approve", "game-gift-grant", grant.ID, domain.AuditResultQueued, "platform administrator approved frozen gift grant")
return domain.CopyGameGiftGrant(grant), err
}
func (svc *CoreService) ListGameGiftGrantsForSession(sessionID, serverID string) ([]domain.GameGiftGrant, error) {
if err := svc.authorizeServerLifecycle(sessionID, serverID); err != nil {
return nil, err
}
grants, err := svc.store.GameGiftGrants().List(domain.GameGiftGrantFilter{ServerInstanceID: serverID})
if err != nil {
return nil, err
}
for i := range grants {
if err = svc.reconcileGameGiftGrant(&grants[i]); err != nil {
return nil, err
}
}
sort.Slice(grants, func(i, j int) bool { return grants[i].CreatedAt.After(grants[j].CreatedAt) })
return grants, nil
}
func validateGiftItems(version string, items []domain.GameGiftItem) error {
if _, ok := domain.SCUMGiftCatalogForVersion(version); !ok {
return validationError("SCUM game version has no verified gift item catalog")
}
if len(items) == 0 || len(items) > 8 {
return validationError("gift requires 1 to 8 catalog items")
}
seen := map[string]bool{}
for _, item := range items {
def, ok := domain.SCUMGiftItemForVersion(version, item.CatalogItemKey)
if !ok || seen[item.CatalogItemKey] || item.Quantity < 1 || item.Quantity > def.MaximumQuantity || item.Label != def.Label {
return validationError("gift item is not valid for this SCUM version")
}
seen[item.CatalogItemKey] = true
}
return nil
}
func giftDeliveryPayload(grant domain.GameGiftGrant) map[string]any {
items := make([]any, len(grant.Items))
for i, item := range grant.Items {
items[i] = map[string]any{"catalogItemKey": item.CatalogItemKey, "quantity": item.Quantity}
}
return map[string]any{"grantId": grant.ID, "playerId": grant.GamePlayerID, "items": items}
}
func (svc *CoreService) reconcileGameGiftGrant(grant *domain.GameGiftGrant) error {
if grant.Status == domain.GameGiftGrantQueued {
command, err := svc.store.GameClientBridgeCommands().Get(grant.DeliveryCommandID)
if err != nil {
return err
}
if command.State == domain.GameClientBridgeCommandFailed {
grant.Status = domain.GameGiftGrantFailed
grant.DeliverySummary = command.Result.Summary
return svc.finishGiftGrant(grant)
}
if command.State == domain.GameClientBridgeCommandExpired || command.State == domain.GameClientBridgeCommandCancelled {
grant.Status = domain.GameGiftGrantUnknown
grant.DeliverySummary = command.Result.Summary
return svc.finishGiftGrant(grant)
}
if command.State == domain.GameClientBridgeCommandSucceeded {
grant.Status = domain.GameGiftGrantDelivered
grant.DeliverySummary = command.Result.Summary
instance, err := svc.store.ServerInstances().Get(grant.ServerInstanceID)
if err != nil {
return err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return err
}
profile, ok := gameClientBridgeProfileKey(plugin)
if !ok {
return validationError("SCUM gift companion profile is unavailable")
}
notification, err := svc.queueGameClientBridgeCommand("component:gift-lifecycle", domain.GameClientBridgeQueueRequest{ServerInstanceID: grant.ServerInstanceID, PluginID: instance.PluginID, ProfileKey: profile, CommandType: domain.SCUMGiftNotificationCommandType, Payload: map[string]any{"playerId": grant.GamePlayerID, "message": grant.Notice}, IdempotencyKey: grant.ID + ":notify", Priority: 10, ExpiresAt: svc.now().Add(time.Minute)})
if err != nil {
grant.Status = domain.GameGiftGrantNotificationFailed
grant.NotificationSummary = "targeted notification could not be queued"
} else {
grant.NotificationCommandID = notification.ID
}
return svc.finishGiftGrant(grant)
}
}
if grant.Status == domain.GameGiftGrantDelivered && grant.NotificationCommandID != "" {
command, err := svc.store.GameClientBridgeCommands().Get(grant.NotificationCommandID)
if err != nil {
return err
}
if command.State == domain.GameClientBridgeCommandFailed || command.State == domain.GameClientBridgeCommandExpired || command.State == domain.GameClientBridgeCommandCancelled {
grant.Status = domain.GameGiftGrantNotificationFailed
grant.NotificationSummary = command.Result.Summary
return svc.finishGiftGrant(grant)
}
}
return nil
}
func (svc *CoreService) finishGiftGrant(grant *domain.GameGiftGrant) error {
stamp := svc.now()
grant.UpdatedAt = stamp
if grant.Status == domain.GameGiftGrantFailed || grant.Status == domain.GameGiftGrantUnknown || grant.Status == domain.GameGiftGrantNotificationFailed {
grant.CompletedAt = stamp
}
if err := svc.store.GameGiftGrants().Update(*grant); err != nil {
return err
}
_, err := svc.recordAuditEventWithID("component:gift-lifecycle", "game-gift.grant.result", "game-gift-grant", grant.ID, domain.AuditResultSuccess, "gift delivery lifecycle result recorded")
return err
}
+143
View File
@@ -0,0 +1,143 @@
package service
import (
"browser.local/platform/domain"
"testing"
)
func TestGameGiftGrantFreezesRevisionAndIsIdempotent(t *testing.T) {
svc, session, player := gameGiftFixture(t, true)
catalog, err := svc.SaveGameGiftCatalogForSession(session, "server-1", domain.GameGiftCatalogRequest{Name: "月光补给", GameVersion: "0.9.700.90357", Items: []domain.GameGiftItem{{CatalogItemKey: "bandage", Label: "绷带", Quantity: 2}}})
if err != nil {
t.Fatal(err)
}
revision, err := svc.PublishGameGiftCatalogForSession(session, catalog.ID)
if err != nil {
t.Fatal(err)
}
grant, err := svc.RequestGameGiftGrantForSession(session, "server-1", domain.GameGiftGrantRequest{RevisionID: revision.ID, GamePlayerRecordID: player.ID, Notice: "请查收补给", IdempotencyKey: "gift-once"})
if err != nil {
t.Fatal(err)
}
duplicate, err := svc.RequestGameGiftGrantForSession(session, "server-1", domain.GameGiftGrantRequest{RevisionID: revision.ID, GamePlayerRecordID: player.ID, Notice: "changed", IdempotencyKey: "gift-once"})
if err != nil || duplicate.ID != grant.ID {
t.Fatalf("idempotency=%+v err=%v", duplicate, err)
}
catalog.DraftItems[0].Quantity = 9
if err = svc.store.GameGiftCatalogs().Update(catalog); err != nil {
t.Fatal(err)
}
stored, _ := svc.store.GameGiftGrants().Get(grant.ID)
if stored.Items[0].Quantity != 2 || stored.PlayerDisplayName != "Moon" {
t.Fatalf("grant was not frozen: %+v", stored)
}
if _, err = svc.SaveGameGiftCatalogForSession(session, "server-1", domain.GameGiftCatalogRequest{Name: "坏礼包", GameVersion: "0.9.700.90357", Items: []domain.GameGiftItem{{CatalogItemKey: "not-verified", Label: "bad", Quantity: 1}}}); err == nil {
t.Fatal("invalid catalog item accepted")
}
}
func TestGameGiftApprovalOfflineAndNotificationFailureAreSafe(t *testing.T) {
offlineSvc, offlineSession, offlinePlayer := gameGiftFixture(t, false)
grant := giftGrantForTest(t, offlineSvc, offlineSession, offlinePlayer)
if _, err := offlineSvc.ApproveGameGiftGrantForSession(offlineSession, grant.ID); err == nil {
t.Fatal("offline player was dispatched")
}
svc, session, player := gameGiftFixture(t, true)
grant = giftGrantForTest(t, svc, session, player)
approved, err := svc.ApproveGameGiftGrantForSession(session, grant.ID)
if err != nil || approved.Status != domain.GameGiftGrantQueued {
t.Fatalf("approve=%+v err=%v", approved, err)
}
claimed, err := svc.claimGameClientBridgeCommands(bridgeComponent(), 1)
if err != nil || len(claimed) != 1 {
t.Fatalf("claim delivery=%+v err=%v", claimed, err)
}
if _, err = svc.completeGameClientBridgeCommand(bridgeComponent(), domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: claimed[0].ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultSucceeded, Summary: "delivered"}); err != nil {
t.Fatal(err)
}
grants, err := svc.ListGameGiftGrantsForSession(session, "server-1")
if err != nil || grants[0].Status != domain.GameGiftGrantDelivered {
t.Fatalf("delivery result=%+v err=%v", grants, err)
}
claimed, err = svc.claimGameClientBridgeCommands(bridgeComponent(), 1)
if err != nil || len(claimed) != 1 {
t.Fatalf("claim notification=%+v err=%v", claimed, err)
}
if _, err = svc.completeGameClientBridgeCommand(bridgeComponent(), domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: claimed[0].ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultFailed, Summary: "chat unavailable"}); err != nil {
t.Fatal(err)
}
grants, err = svc.ListGameGiftGrantsForSession(session, "server-1")
if err != nil || grants[0].Status != domain.GameGiftGrantNotificationFailed {
t.Fatalf("notification failure=%+v err=%v", grants, err)
}
commands, _ := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{})
if len(commands) != 2 {
t.Fatalf("notification failure redelivered item: %d commands", len(commands))
}
}
func TestGameGiftUnknownIsTerminalAndNeverRetried(t *testing.T) {
svc, session, player := gameGiftFixture(t, true)
grant := giftGrantForTest(t, svc, session, player)
approved, err := svc.ApproveGameGiftGrantForSession(session, grant.ID)
if err != nil {
t.Fatal(err)
}
command, _ := svc.store.GameClientBridgeCommands().Get(approved.DeliveryCommandID)
command.State = domain.GameClientBridgeCommandExpired
if err = svc.store.GameClientBridgeCommands().Update(command); err != nil {
t.Fatal(err)
}
values, err := svc.ListGameGiftGrantsForSession(session, "server-1")
if err != nil || values[0].Status != domain.GameGiftGrantUnknown {
t.Fatalf("unknown=%+v err=%v", values, err)
}
commands, _ := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{})
if len(commands) != 1 {
t.Fatalf("unknown result retried: %d", len(commands))
}
}
func gameGiftFixture(t *testing.T, online bool) (*CoreService, string, domain.GamePlayer) {
t.Helper()
svc, clock := newGameClientBridgeService(t)
plugin, _ := svc.store.GamePlugins().Get("game.scum")
plugin.GameClientBridge.Commands = []domain.GameClientBridgeCommandDeclaration{{Type: domain.SCUMRewardDeliverCommandType, ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, TimeoutSeconds: 120, MaxPayloadBytes: 4096}, {Type: domain.SCUMGiftNotificationCommandType, ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, TimeoutSeconds: 60, MaxPayloadBytes: 2048}}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatal(err)
}
user := domain.User{ID: "gift-admin", DisplayName: "Gift Admin", Email: "gift@example.test", Roles: []string{"platform-admin"}, Status: domain.UserStatusActive, PasswordHash: "secret", CreatedAt: *clock, UpdatedAt: *clock}
if err := svc.store.Users().Create(user); err != nil {
t.Fatal(err)
}
auth, err := svc.issueAuthSession(user, "test")
if err != nil {
t.Fatal(err)
}
if err = svc.store.ServerInstances().Create(domain.ServerInstance{ID: "server-1", PluginID: "game.scum", OwnerUserID: user.ID}); err != nil {
t.Fatal(err)
}
player := domain.GamePlayer{ID: "gift-player", ServerInstanceID: "server-1", GamePlayerID: "steam-1", DisplayName: "Moon"}
if err = svc.store.GamePlayers().Create(player); err != nil {
t.Fatal(err)
}
if online {
if err = svc.store.GamePlayerSessions().Create(domain.GamePlayerSession{ID: "gift-online", ServerInstanceID: "server-1", GamePlayerRecordID: player.ID, StartedAt: *clock}); err != nil {
t.Fatal(err)
}
}
return svc, auth.SessionID, player
}
func giftGrantForTest(t *testing.T, svc *CoreService, session string, player domain.GamePlayer) domain.GameGiftGrant {
t.Helper()
catalog, err := svc.SaveGameGiftCatalogForSession(session, "server-1", domain.GameGiftCatalogRequest{Name: "月光补给", GameVersion: "0.9.700.90357", Items: []domain.GameGiftItem{{CatalogItemKey: "bandage", Label: "绷带", Quantity: 2}}})
if err != nil {
t.Fatal(err)
}
revision, err := svc.PublishGameGiftCatalogForSession(session, catalog.ID)
if err != nil {
t.Fatal(err)
}
grant, err := svc.RequestGameGiftGrantForSession(session, "server-1", domain.GameGiftGrantRequest{RevisionID: revision.ID, GamePlayerRecordID: player.ID, Notice: "请查收补给", IdempotencyKey: "request-" + catalog.ID})
if err != nil {
t.Fatal(err)
}
return grant
}
+7
View File
@@ -209,6 +209,13 @@ type Core interface {
RequestGamePlayerStatePatchForSession(string, string, domain.GamePlayerStatePatchRequest) (domain.GamePlayerStatePatch, error)
ApproveGamePlayerStatePatchForSession(string, string) (domain.GamePlayerStatePatch, error)
ListGamePlayerStatePatchesForSession(string, string) ([]domain.GamePlayerStatePatch, error)
ListGameGiftCatalogsForSession(string, string) ([]domain.GameGiftCatalog, error)
SaveGameGiftCatalogForSession(string, string, domain.GameGiftCatalogRequest) (domain.GameGiftCatalog, error)
PublishGameGiftCatalogForSession(string, string) (domain.GameGiftRevision, error)
ListGameGiftRevisionsForSession(string, string) ([]domain.GameGiftRevision, error)
RequestGameGiftGrantForSession(string, string, domain.GameGiftGrantRequest) (domain.GameGiftGrant, error)
ApproveGameGiftGrantForSession(string, string) (domain.GameGiftGrant, error)
ListGameGiftGrantsForSession(string, string) ([]domain.GameGiftGrant, error)
CreateAuditEvent(domain.AuditEvent) (domain.AuditEvent, error)
GetAuditEvent(string) (domain.AuditEvent, error)
ListAuditEvents(domain.AuditEventFilter) ([]domain.AuditEvent, error)