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
+141
View File
@@ -0,0 +1,141 @@
package api
import (
"browser.local/platform/dto"
"browser.local/platform/repo"
"net/http"
)
// serverGameGiftCatalogs godoc
// @Summary List or create SCUM versioned gift catalogs
// @Description Manages bounded drafts that contain catalog references only, never raw game item commands.
// @Tags game-gifts
// @Produce json
// @Router /api/v1/server-instances/{id}/game-gifts [get,post]
func (h *coreHandlers) serverGameGiftCatalogs(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
values, err := h.core.ListGameGiftCatalogsForSession(bearerToken(r), r.PathValue("id"))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.GameGiftCatalogsFromDomain(values))
case http.MethodPost:
request, err := decodeJSON[dto.GameGiftCatalogRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
value, err := h.core.SaveGameGiftCatalogForSession(bearerToken(r), r.PathValue("id"), request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, dto.GameGiftCatalogFromDomain(value))
default:
writeMethodNotAllowed(w, "GET, POST")
}
}
// serverGameGiftCatalogPublish godoc
// @Summary Publish immutable SCUM gift revision
// @Description Freezes a validated version-fenced gift draft.
// @Tags game-gifts
// @Produce json
// @Router /api/v1/server-instances/{id}/game-gifts/{catalogId}/publish [post]
func (h *coreHandlers) serverGameGiftCatalogPublish(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
value, err := h.core.PublishGameGiftCatalogForSession(bearerToken(r), r.PathValue("catalogId"))
if err != nil {
writeServiceError(w, err)
return
}
if value.ServerInstanceID != r.PathValue("id") {
writeServiceError(w, repo.ErrNotFound)
return
}
writeJSON(w, http.StatusCreated, dto.GameGiftRevisionFromDomain(value))
}
// serverGameGiftCatalogRevisions godoc
// @Summary List immutable SCUM gift revisions
// @Tags game-gifts
// @Produce json
// @Router /api/v1/server-instances/{id}/game-gifts/{catalogId}/revisions [get]
func (h *coreHandlers) serverGameGiftCatalogRevisions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
values, err := h.core.ListGameGiftRevisionsForSession(bearerToken(r), r.PathValue("catalogId"))
if err != nil {
writeServiceError(w, err)
return
}
for _, v := range values {
if v.ServerInstanceID != r.PathValue("id") {
writeServiceError(w, repo.ErrNotFound)
return
}
}
writeJSON(w, http.StatusOK, dto.GameGiftRevisionsFromDomain(values))
}
// serverGameGiftGrants godoc
// @Summary List or request directed SCUM gift grants
// @Description Creates frozen local-player grants for platform-admin approval without raw commands or item codes.
// @Tags game-gifts
// @Produce json
// @Router /api/v1/server-instances/{id}/game-gift-grants [get,post]
func (h *coreHandlers) serverGameGiftGrants(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
values, err := h.core.ListGameGiftGrantsForSession(bearerToken(r), r.PathValue("id"))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.GameGiftGrantsFromDomain(values))
case http.MethodPost:
request, err := decodeJSON[dto.GameGiftGrantRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
value, err := h.core.RequestGameGiftGrantForSession(bearerToken(r), r.PathValue("id"), request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.GameGiftGrantFromDomain(value))
default:
writeMethodNotAllowed(w, "GET, POST")
}
}
// serverGameGiftGrantApprove godoc
// @Summary Approve a frozen directed SCUM gift grant
// @Description Requires a platform administrator and dispatches only the declared typed reward command.
// @Tags game-gifts
// @Produce json
// @Router /api/v1/server-instances/{id}/game-gift-grants/{grantId}/approve [post]
func (h *coreHandlers) serverGameGiftGrantApprove(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
value, err := h.core.ApproveGameGiftGrantForSession(bearerToken(r), r.PathValue("grantId"))
if err != nil {
writeServiceError(w, err)
return
}
if value.ServerInstanceID != r.PathValue("id") {
writeServiceError(w, repo.ErrNotFound)
return
}
writeJSON(w, http.StatusAccepted, dto.GameGiftGrantFromDomain(value))
}
+5
View File
@@ -98,6 +98,11 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/server-instances/{id}/game-players/{playerId}/state", h.serverGamePlayerState)
mux.HandleFunc("/api/v1/server-instances/{id}/game-players/{playerId}/state-patches", h.serverGamePlayerStatePatches)
mux.HandleFunc("/api/v1/server-instances/{id}/game-players/{playerId}/state-patches/{patchId}/approve", h.serverGamePlayerStatePatchApprove)
mux.HandleFunc("/api/v1/server-instances/{id}/game-gifts", h.serverGameGiftCatalogs)
mux.HandleFunc("/api/v1/server-instances/{id}/game-gifts/{catalogId}/publish", h.serverGameGiftCatalogPublish)
mux.HandleFunc("/api/v1/server-instances/{id}/game-gifts/{catalogId}/revisions", h.serverGameGiftCatalogRevisions)
mux.HandleFunc("/api/v1/server-instances/{id}/game-gift-grants", h.serverGameGiftGrants)
mux.HandleFunc("/api/v1/server-instances/{id}/game-gift-grants/{grantId}/approve", h.serverGameGiftGrantApprove)
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/check", h.serverDependenciesCheck)
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/install", h.serverDependenciesInstall)
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies)
+144
View File
@@ -0,0 +1,144 @@
package domain
import "time"
const SCUMRewardDeliverCommandType = "reward.deliver"
const SCUMGiftNotificationCommandType = "player.notify"
type SCUMGiftItemDefinition struct {
Key string
Label string
MaximumQuantity int
}
type SCUMGiftItemCatalog struct {
GameVersion string
Items []SCUMGiftItemDefinition
}
var SCUMGiftItemCatalogs = []SCUMGiftItemCatalog{{GameVersion: "0.9.700.90357", Items: []SCUMGiftItemDefinition{{Key: "bandage", Label: "绷带", MaximumQuantity: 20}, {Key: "water-bottle", Label: "饮用水", MaximumQuantity: 10}, {Key: "improvised-spear", Label: "简易长矛", MaximumQuantity: 2}}}}
type GameGiftItem struct {
CatalogItemKey string
Label string
Quantity int
}
type GameGiftCatalog struct {
ID string
ServerInstanceID string
Name string
GameVersion string
DraftItems []GameGiftItem
LatestRevisionID string
CreatedBy string
UpdatedAt time.Time
CreatedAt time.Time
}
type GameGiftRevision struct {
ID string
CatalogID string
ServerInstanceID string
Revision int
GameVersion string
Items []GameGiftItem
PublishedBy string
PublishedAt time.Time
}
type GameGiftGrantStatus string
const (
GameGiftGrantPendingApproval GameGiftGrantStatus = "pending-approval"
GameGiftGrantQueued GameGiftGrantStatus = "queued"
GameGiftGrantDelivered GameGiftGrantStatus = "delivered"
GameGiftGrantNotificationFailed GameGiftGrantStatus = "notification_failed"
GameGiftGrantFailed GameGiftGrantStatus = "failed"
GameGiftGrantUnknown GameGiftGrantStatus = "unknown"
)
type GameGiftGrant struct {
ID string
ServerInstanceID string
CatalogID string
RevisionID string
RevisionNumber int
GameVersion string
Items []GameGiftItem
GamePlayerRecordID string
GamePlayerID string
PlayerDisplayName string
Notice string
IdempotencyKey string
RequesterID string
ApproverID string
Status GameGiftGrantStatus
DeliveryCommandID string
NotificationCommandID string
DeliverySummary string
NotificationSummary string
CreatedAt time.Time
ApprovedAt time.Time
CompletedAt time.Time
UpdatedAt time.Time
}
type GameGiftCatalogFilter struct {
ServerInstanceID string
Limit int
}
type GameGiftRevisionFilter struct {
CatalogID string
ServerInstanceID string
Limit int
}
type GameGiftGrantFilter struct {
ServerInstanceID string
GamePlayerRecordID string
IdempotencyKey string
Limit int
}
type GameGiftCatalogRequest struct {
ID string
Name string
GameVersion string
Items []GameGiftItem
}
type GameGiftGrantRequest struct {
RevisionID string
GamePlayerRecordID string
Notice string
IdempotencyKey string
}
func CopyGameGiftItems(items []GameGiftItem) []GameGiftItem {
return append([]GameGiftItem(nil), items...)
}
func CopyGameGiftCatalog(value GameGiftCatalog) GameGiftCatalog {
value.DraftItems = CopyGameGiftItems(value.DraftItems)
return value
}
func CopyGameGiftRevision(value GameGiftRevision) GameGiftRevision {
value.Items = CopyGameGiftItems(value.Items)
return value
}
func CopyGameGiftGrant(value GameGiftGrant) GameGiftGrant {
value.Items = CopyGameGiftItems(value.Items)
return value
}
func SCUMGiftCatalogForVersion(version string) (SCUMGiftItemCatalog, bool) {
for _, catalog := range SCUMGiftItemCatalogs {
if catalog.GameVersion == version {
return catalog, true
}
}
return SCUMGiftItemCatalog{}, false
}
func SCUMGiftItemForVersion(version, key string) (SCUMGiftItemDefinition, bool) {
catalog, ok := SCUMGiftCatalogForVersion(version)
if !ok {
return SCUMGiftItemDefinition{}, false
}
for _, item := range catalog.Items {
if item.Key == key {
return item, true
}
}
return SCUMGiftItemDefinition{}, false
}
+121
View File
@@ -0,0 +1,121 @@
package dto
import (
"browser.local/platform/domain"
"time"
)
type GameGiftItemRequest struct {
CatalogItemKey string `json:"catalogItemKey"`
Quantity int `json:"quantity"`
}
type GameGiftItemResponse struct {
CatalogItemKey string `json:"catalogItemKey"`
Label string `json:"label"`
Quantity int `json:"quantity"`
}
type GameGiftCatalogRequest struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
GameVersion string `json:"gameVersion"`
Items []GameGiftItemRequest `json:"items"`
}
type GameGiftCatalogResponse struct {
ID string `json:"id"`
Name string `json:"name"`
GameVersion string `json:"gameVersion"`
DraftItems []GameGiftItemResponse `json:"draftItems"`
LatestRevisionID string `json:"latestRevisionId,omitempty"`
UpdatedAt time.Time `json:"updatedAt"`
}
type GameGiftCatalogListResponse struct {
Items []GameGiftCatalogResponse `json:"items"`
}
type GameGiftRevisionResponse struct {
ID string `json:"id"`
CatalogID string `json:"catalogId"`
Revision int `json:"revision"`
GameVersion string `json:"gameVersion"`
Items []GameGiftItemResponse `json:"items"`
PublishedBy string `json:"publishedBy"`
PublishedAt time.Time `json:"publishedAt"`
}
type GameGiftRevisionListResponse struct {
Items []GameGiftRevisionResponse `json:"items"`
}
type GameGiftGrantRequest struct {
RevisionID string `json:"revisionId"`
GamePlayerRecordID string `json:"gamePlayerRecordId"`
Notice string `json:"notice"`
IdempotencyKey string `json:"idempotencyKey"`
}
type GameGiftGrantResponse struct {
ID string `json:"id"`
RevisionID string `json:"revisionId"`
RevisionNumber int `json:"revisionNumber"`
GameVersion string `json:"gameVersion"`
Items []GameGiftItemResponse `json:"items"`
GamePlayerRecordID string `json:"gamePlayerRecordId"`
PlayerDisplayName string `json:"playerDisplayName"`
Notice string `json:"notice"`
RequesterID string `json:"requesterId"`
ApproverID string `json:"approverId,omitempty"`
Status string `json:"status"`
DeliverySummary string `json:"deliverySummary,omitempty"`
NotificationSummary string `json:"notificationSummary,omitempty"`
CreatedAt time.Time `json:"createdAt"`
ApprovedAt time.Time `json:"approvedAt,omitempty"`
CompletedAt time.Time `json:"completedAt,omitempty"`
}
type GameGiftGrantListResponse struct {
Items []GameGiftGrantResponse `json:"items"`
}
func (r GameGiftCatalogRequest) ToDomain() domain.GameGiftCatalogRequest {
items := make([]domain.GameGiftItem, len(r.Items))
for i, item := range r.Items {
def, _ := domain.SCUMGiftItemForVersion(r.GameVersion, item.CatalogItemKey)
items[i] = domain.GameGiftItem{CatalogItemKey: item.CatalogItemKey, Label: def.Label, Quantity: item.Quantity}
}
return domain.GameGiftCatalogRequest{ID: r.ID, Name: r.Name, GameVersion: r.GameVersion, Items: items}
}
func (r GameGiftGrantRequest) ToDomain() domain.GameGiftGrantRequest {
return domain.GameGiftGrantRequest{RevisionID: r.RevisionID, GamePlayerRecordID: r.GamePlayerRecordID, Notice: r.Notice, IdempotencyKey: r.IdempotencyKey}
}
func giftItems(items []domain.GameGiftItem) []GameGiftItemResponse {
out := make([]GameGiftItemResponse, len(items))
for i, item := range items {
out[i] = GameGiftItemResponse{CatalogItemKey: item.CatalogItemKey, Label: item.Label, Quantity: item.Quantity}
}
return out
}
func GameGiftCatalogFromDomain(v domain.GameGiftCatalog) GameGiftCatalogResponse {
return GameGiftCatalogResponse{ID: v.ID, Name: v.Name, GameVersion: v.GameVersion, DraftItems: giftItems(v.DraftItems), LatestRevisionID: v.LatestRevisionID, UpdatedAt: v.UpdatedAt}
}
func GameGiftCatalogsFromDomain(v []domain.GameGiftCatalog) GameGiftCatalogListResponse {
out := make([]GameGiftCatalogResponse, len(v))
for i, x := range v {
out[i] = GameGiftCatalogFromDomain(x)
}
return GameGiftCatalogListResponse{Items: out}
}
func GameGiftRevisionFromDomain(v domain.GameGiftRevision) GameGiftRevisionResponse {
return GameGiftRevisionResponse{ID: v.ID, CatalogID: v.CatalogID, Revision: v.Revision, GameVersion: v.GameVersion, Items: giftItems(v.Items), PublishedBy: v.PublishedBy, PublishedAt: v.PublishedAt}
}
func GameGiftRevisionsFromDomain(v []domain.GameGiftRevision) GameGiftRevisionListResponse {
out := make([]GameGiftRevisionResponse, len(v))
for i, x := range v {
out[i] = GameGiftRevisionFromDomain(x)
}
return GameGiftRevisionListResponse{Items: out}
}
func GameGiftGrantFromDomain(v domain.GameGiftGrant) GameGiftGrantResponse {
return GameGiftGrantResponse{ID: v.ID, RevisionID: v.RevisionID, RevisionNumber: v.RevisionNumber, GameVersion: v.GameVersion, Items: giftItems(v.Items), GamePlayerRecordID: v.GamePlayerRecordID, PlayerDisplayName: v.PlayerDisplayName, Notice: v.Notice, RequesterID: v.RequesterID, ApproverID: v.ApproverID, Status: string(v.Status), DeliverySummary: v.DeliverySummary, NotificationSummary: v.NotificationSummary, CreatedAt: v.CreatedAt, ApprovedAt: v.ApprovedAt, CompletedAt: v.CompletedAt}
}
func GameGiftGrantsFromDomain(v []domain.GameGiftGrant) GameGiftGrantListResponse {
out := make([]GameGiftGrantResponse, len(v))
for i, x := range v {
out[i] = GameGiftGrantFromDomain(x)
}
return GameGiftGrantListResponse{Items: out}
}
+45
View File
@@ -0,0 +1,45 @@
package model
import "time"
// GameGiftCatalog is the model-first editable, server-version-fenced gift draft.
type GameGiftCatalog struct {
ID string `json:"id" db:"id"`
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
Name string `json:"name" db:"name"`
GameVersion string `json:"gameVersion" db:"game_version"`
LatestRevisionID string `json:"latestRevisionId" db:"latest_revision_id"`
CreatedBy string `json:"createdBy" db:"created_by"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
}
func (GameGiftCatalog) TableName() string { return "game_gift_catalogs" }
// GameGiftRevision is an immutable gift item snapshot.
type GameGiftRevision struct {
ID string `json:"id" db:"id"`
CatalogID string `json:"catalogId" db:"catalog_id"`
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
Revision int `json:"revision" db:"revision"`
GameVersion string `json:"gameVersion" db:"game_version"`
PublishedBy string `json:"publishedBy" db:"published_by"`
PublishedAt time.Time `json:"publishedAt" db:"published_at"`
}
func (GameGiftRevision) TableName() string { return "game_gift_revisions" }
// GameGiftGrant records a directed frozen gift lifecycle without raw game commands.
type GameGiftGrant struct {
ID string `json:"id" db:"id"`
ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"`
RevisionID string `json:"revisionId" db:"revision_id"`
GamePlayerRecordID string `json:"gamePlayerRecordId" db:"game_player_record_id"`
Status string `json:"status" db:"status"`
DeliveryCommandID string `json:"deliveryCommandId" db:"delivery_command_id"`
NotificationCommandID string `json:"notificationCommandId" db:"notification_command_id"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
}
func (GameGiftGrant) TableName() string { return "game_gift_grants" }
+16 -1
View File
@@ -48,6 +48,9 @@ type StoreSnapshot struct {
GameAccessAttempts []domain.GameAccessAttempt `json:"gameAccessAttempts"`
GameSecuritySignals []domain.GameSecuritySignal `json:"gameSecuritySignals"`
GamePlayerStatePatches []domain.GamePlayerStatePatch `json:"gamePlayerStatePatches"`
GameGiftCatalogs []domain.GameGiftCatalog `json:"gameGiftCatalogs"`
GameGiftRevisions []domain.GameGiftRevision `json:"gameGiftRevisions"`
GameGiftGrants []domain.GameGiftGrant `json:"gameGiftGrants"`
}
type FileStore struct {
@@ -214,6 +217,15 @@ func (store *FileStore) GameSecuritySignals() GameSecuritySignalRepository {
func (store *FileStore) GamePlayerStatePatches() GamePlayerStatePatchRepository {
return &persistentRepository[domain.GamePlayerStatePatch, domain.GamePlayerStatePatchFilter]{repository: store.MemoryStore.gamePlayerStatePatches, persist: store.persist}
}
func (store *FileStore) GameGiftCatalogs() GameGiftCatalogRepository {
return &persistentRepository[domain.GameGiftCatalog, domain.GameGiftCatalogFilter]{repository: store.MemoryStore.gameGiftCatalogs, persist: store.persist}
}
func (store *FileStore) GameGiftRevisions() GameGiftRevisionRepository {
return &persistentRepository[domain.GameGiftRevision, domain.GameGiftRevisionFilter]{repository: store.MemoryStore.gameGiftRevisions, persist: store.persist}
}
func (store *FileStore) GameGiftGrants() GameGiftGrantRepository {
return &persistentRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter]{repository: store.MemoryStore.gameGiftGrants, persist: store.persist}
}
func (store *FileStore) load() error {
data, err := os.ReadFile(store.path)
@@ -287,7 +299,7 @@ func (store *FileStore) snapshot() StoreSnapshot {
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches),
GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants),
}
}
@@ -327,6 +339,9 @@ func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) {
loadRepository(store.MemoryStore.gameAccessAttempts, snapshot.GameAccessAttempts)
loadRepository(store.MemoryStore.gameSecuritySignals, snapshot.GameSecuritySignals)
loadRepository(store.MemoryStore.gamePlayerStatePatches, snapshot.GamePlayerStatePatches)
loadRepository(store.MemoryStore.gameGiftCatalogs, snapshot.GameGiftCatalogs)
loadRepository(store.MemoryStore.gameGiftRevisions, snapshot.GameGiftRevisions)
loadRepository(store.MemoryStore.gameGiftGrants, snapshot.GameGiftGrants)
}
type mutableRepository[T any, F any] interface {
+13 -1
View File
@@ -189,6 +189,15 @@ func (store *MySQLStore) GameSecuritySignals() GameSecuritySignalRepository {
func (store *MySQLStore) GamePlayerStatePatches() GamePlayerStatePatchRepository {
return &persistentRepository[domain.GamePlayerStatePatch, domain.GamePlayerStatePatchFilter]{repository: store.MemoryStore.gamePlayerStatePatches, persist: store.persist}
}
func (store *MySQLStore) GameGiftCatalogs() GameGiftCatalogRepository {
return &persistentRepository[domain.GameGiftCatalog, domain.GameGiftCatalogFilter]{repository: store.MemoryStore.gameGiftCatalogs, persist: store.persist}
}
func (store *MySQLStore) GameGiftRevisions() GameGiftRevisionRepository {
return &persistentRepository[domain.GameGiftRevision, domain.GameGiftRevisionFilter]{repository: store.MemoryStore.gameGiftRevisions, persist: store.persist}
}
func (store *MySQLStore) GameGiftGrants() GameGiftGrantRepository {
return &persistentRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter]{repository: store.MemoryStore.gameGiftGrants, persist: store.persist}
}
func (store *MySQLStore) initialize() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -279,7 +288,7 @@ func (store *MySQLStore) snapshot() StoreSnapshot {
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches),
GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants),
}
}
@@ -319,4 +328,7 @@ func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) {
loadRepository(store.MemoryStore.gameAccessAttempts, snapshot.GameAccessAttempts)
loadRepository(store.MemoryStore.gameSecuritySignals, snapshot.GameSecuritySignals)
loadRepository(store.MemoryStore.gamePlayerStatePatches, snapshot.GamePlayerStatePatches)
loadRepository(store.MemoryStore.gameGiftCatalogs, snapshot.GameGiftCatalogs)
loadRepository(store.MemoryStore.gameGiftRevisions, snapshot.GameGiftRevisions)
loadRepository(store.MemoryStore.gameGiftGrants, snapshot.GameGiftGrants)
}
+40
View File
@@ -265,6 +265,23 @@ type GamePlayerStatePatchRepository interface {
List(domain.GamePlayerStatePatchFilter) ([]domain.GamePlayerStatePatch, error)
Update(domain.GamePlayerStatePatch) error
}
type GameGiftCatalogRepository interface {
Create(domain.GameGiftCatalog) error
Get(string) (domain.GameGiftCatalog, error)
List(domain.GameGiftCatalogFilter) ([]domain.GameGiftCatalog, error)
Update(domain.GameGiftCatalog) error
}
type GameGiftRevisionRepository interface {
Create(domain.GameGiftRevision) error
Get(string) (domain.GameGiftRevision, error)
List(domain.GameGiftRevisionFilter) ([]domain.GameGiftRevision, error)
}
type GameGiftGrantRepository interface {
Create(domain.GameGiftGrant) error
Get(string) (domain.GameGiftGrant, error)
List(domain.GameGiftGrantFilter) ([]domain.GameGiftGrant, error)
Update(domain.GameGiftGrant) error
}
type Store interface {
Users() UserRepository
@@ -302,6 +319,9 @@ type Store interface {
GameAccessAttempts() GameAccessAttemptRepository
GameSecuritySignals() GameSecuritySignalRepository
GamePlayerStatePatches() GamePlayerStatePatchRepository
GameGiftCatalogs() GameGiftCatalogRepository
GameGiftRevisions() GameGiftRevisionRepository
GameGiftGrants() GameGiftGrantRepository
}
type MemoryStore struct {
@@ -340,6 +360,9 @@ type MemoryStore struct {
gameAccessAttempts *memoryRepository[domain.GameAccessAttempt, domain.GameAccessAttemptFilter]
gameSecuritySignals *memoryRepository[domain.GameSecuritySignal, domain.GameSecuritySignalFilter]
gamePlayerStatePatches *memoryRepository[domain.GamePlayerStatePatch, domain.GamePlayerStatePatchFilter]
gameGiftCatalogs *memoryRepository[domain.GameGiftCatalog, domain.GameGiftCatalogFilter]
gameGiftRevisions *memoryRepository[domain.GameGiftRevision, domain.GameGiftRevisionFilter]
gameGiftGrants *memoryRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter]
}
func NewMemoryStore() *MemoryStore {
@@ -483,6 +506,9 @@ func NewMemoryStore() *MemoryStore {
gameAccessAttempts: newMemoryRepository(func(v domain.GameAccessAttempt) string { return v.ID }, domain.CopyGameAccessAttempt, matchGameAccessAttempt),
gameSecuritySignals: newMemoryRepository(func(v domain.GameSecuritySignal) string { return v.ID }, domain.CopyGameSecuritySignal, matchGameSecuritySignal),
gamePlayerStatePatches: newMemoryRepository(func(v domain.GamePlayerStatePatch) string { return v.ID }, domain.CopyGamePlayerStatePatch, matchGamePlayerStatePatch),
gameGiftCatalogs: newMemoryRepository(func(v domain.GameGiftCatalog) string { return v.ID }, domain.CopyGameGiftCatalog, matchGameGiftCatalog),
gameGiftRevisions: newMemoryRepository(func(v domain.GameGiftRevision) string { return v.ID }, domain.CopyGameGiftRevision, matchGameGiftRevision),
gameGiftGrants: newMemoryRepository(func(v domain.GameGiftGrant) string { return v.ID }, domain.CopyGameGiftGrant, matchGameGiftGrant),
}
}
@@ -551,6 +577,11 @@ func (store *MemoryStore) GameSecuritySignals() GameSecuritySignalRepository {
func (store *MemoryStore) GamePlayerStatePatches() GamePlayerStatePatchRepository {
return store.gamePlayerStatePatches
}
func (store *MemoryStore) GameGiftCatalogs() GameGiftCatalogRepository { return store.gameGiftCatalogs }
func (store *MemoryStore) GameGiftRevisions() GameGiftRevisionRepository {
return store.gameGiftRevisions
}
func (store *MemoryStore) GameGiftGrants() GameGiftGrantRepository { return store.gameGiftGrants }
type memoryRepository[T any, F any] struct {
mu sync.RWMutex
@@ -870,3 +901,12 @@ func matchGameSecuritySignal(v domain.GameSecuritySignal, f domain.GameSecurityS
func matchGamePlayerStatePatch(v domain.GamePlayerStatePatch, f domain.GamePlayerStatePatchFilter) bool {
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID)
}
func matchGameGiftCatalog(v domain.GameGiftCatalog, f domain.GameGiftCatalogFilter) bool {
return f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID
}
func matchGameGiftRevision(v domain.GameGiftRevision, f domain.GameGiftRevisionFilter) bool {
return (f.CatalogID == "" || v.CatalogID == f.CatalogID) && (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID)
}
func matchGameGiftGrant(v domain.GameGiftGrant, f domain.GameGiftGrantFilter) bool {
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) && (f.IdempotencyKey == "" || v.IdempotencyKey == f.IdempotencyKey)
}
+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)