feat(server): queue dynamic operator mail grants

This commit is contained in:
2026-10-03 00:56:48 +08:00
parent 30d9ef7f4e
commit 46eed875a9
6 changed files with 901 additions and 53 deletions
+6
View File
@@ -82,6 +82,7 @@ func serve(args []string) (serveErr error) {
playerSeed := fs.String("player-seed", "", "versioned starter inventory and characters")
readonlySeed := fs.String("readonly-seed", "", "versioned server schedules and optional feature defaults")
mailSeed := fs.String("mail-seed", "", "versioned starter mailbox")
mailGrantSpool := fs.String("mail-grant-spool", "", "optional local JSON spool for idempotent dynamic system mail")
stateFile := fs.String("state", "", "account SQLite database override")
deckSeed := fs.String("deck-seed", "", "versioned starter deck")
worldSeed := fs.String("world-seed", "", "versioned starter world")
@@ -340,6 +341,11 @@ func serve(args []string) (serveErr error) {
if err := mailService.AttachSeedPath(filepath.Clean(*mailSeed)); err != nil {
return fmt.Errorf("watch mail seed: %w", err)
}
if *mailGrantSpool != "" {
if err := mailService.AttachGrantSpoolPath(*mailGrantSpool); err != nil {
return fmt.Errorf("attach mail grant spool: %w", err)
}
}
missionDesign, err := gamedata.LoadMissionDesign(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load mission GameData: %w", err)
+201
View File
@@ -0,0 +1,201 @@
package mail
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"os"
"path/filepath"
"strings"
"time"
"unicode/utf8"
"bd2server/internal/server/gamedata"
)
// GrantSpool is a local operator input, independent of the versioned starter
// mailbox and player state. Writers atomically replace the whole JSON file.
// Version 1 uses Unix milliseconds; a stable identity is never issued twice,
// including after the player opens the mail or the server restarts.
type GrantSpool struct {
Version uint64 `json:"version"`
Grants []Grant `json:"grants"`
}
type Grant struct {
Identity string `json:"identity"`
Title string `json:"title"`
Body string `json:"body"`
SentAt int64 `json:"sent_at"`
Rewards []GrantReward `json:"rewards"`
}
type GrantReward struct {
Type uint64 `json:"type"`
ID uint64 `json:"id"`
Count uint64 `json:"count"`
}
type compensation struct {
identity, title, body string
sentAt time.Time
rewards []gamedata.Reward
}
func (g compensation) validate() error {
if strings.TrimSpace(g.identity) == "" || strings.TrimSpace(g.identity) != g.identity || utf8.RuneCountInString(g.identity) > 500 ||
strings.TrimSpace(g.title) == "" || utf8.RuneCountInString(g.title) > 500 ||
strings.TrimSpace(g.body) == "" || utf8.RuneCountInString(g.body) > 5000 ||
g.sentAt.IsZero() || g.sentAt.UnixMilli() <= 0 || g.sentAt.UnixMilli() > math.MaxInt64-int64(30*24*time.Hour/time.Millisecond) || len(g.rewards) == 0 {
return errors.New("mail: invalid compensation identity, content, time or rewards")
}
for _, reward := range g.rewards {
if reward.Count == 0 || reward.Count > math.MaxInt32 || (!currencyRewardTypes[reward.Type] && (!itemDBInfoTypes[reward.Type] || reward.ID == 0)) ||
(currencyRewardTypes[reward.Type] && reward.ID != 0) {
return fmt.Errorf("mail: compensation %q has unsupported reward type=%d id=%d count=%d", g.identity, reward.Type, reward.ID, reward.Count)
}
}
return nil
}
// AttachGrantSpoolPath enables reads before each /MailInfo. An absent file is
// an empty queue, allowing an operator to create it after server startup.
// Existing malformed input is rejected immediately without issuing mail.
func (s *Service) AttachGrantSpoolPath(path string) error {
if s == nil || strings.TrimSpace(path) == "" {
return errors.New("mail: invalid grant spool path")
}
abs, err := filepath.Abs(filepath.Clean(path))
if err != nil {
return fmt.Errorf("mail: resolve grant spool path: %w", err)
}
if _, err := readGrantSpool(abs); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
s.grantSpoolPath = abs
return nil
}
func readGrantSpool(path string) ([]compensation, error) {
if path == "" {
return nil, nil
}
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("mail: read grant spool: %w", err)
}
grants, err := decodeGrantSpool(data)
if err != nil {
return nil, fmt.Errorf("mail: reject grant spool: %w", err)
}
return grants, nil
}
// Decode the entire file before returning any grants. The exact object reader
// rejects missing, unknown, duplicate and null fields at every schema level.
func decodeGrantSpool(data []byte) ([]compensation, error) {
if !utf8.Valid(data) {
return nil, errors.New("invalid UTF-8")
}
root, err := exactSpoolObject(data, "version", "grants")
if err != nil {
return nil, err
}
var version uint64
if err := json.Unmarshal(root["version"], &version); err != nil || version != 1 {
return nil, errors.New("unsupported grant spool version")
}
var rawGrants []json.RawMessage
if err := json.Unmarshal(root["grants"], &rawGrants); err != nil {
return nil, err
}
seen := make(map[string]bool, len(rawGrants))
grants := make([]compensation, 0, len(rawGrants))
for i, raw := range rawGrants {
fields, err := exactSpoolObject(raw, "identity", "title", "body", "sent_at", "rewards")
if err != nil {
return nil, fmt.Errorf("grant %d: %w", i, err)
}
var entry Grant
if err := json.Unmarshal(raw, &entry); err != nil {
return nil, fmt.Errorf("grant %d: %w", i, err)
}
if seen[entry.Identity] {
return nil, fmt.Errorf("duplicate grant identity %q", entry.Identity)
}
seen[entry.Identity] = true
var rawRewards []json.RawMessage
if err := json.Unmarshal(fields["rewards"], &rawRewards); err != nil {
return nil, err
}
grant := compensation{identity: entry.Identity, title: entry.Title, body: entry.Body, sentAt: time.UnixMilli(entry.SentAt)}
for j, reward := range rawRewards {
if _, err := exactSpoolObject(reward, "type", "id", "count"); err != nil {
return nil, fmt.Errorf("grant %d reward %d: %w", i, j, err)
}
grant.rewards = append(grant.rewards, gamedata.Reward{Type: entry.Rewards[j].Type, ID: entry.Rewards[j].ID, Count: entry.Rewards[j].Count})
}
if err := grant.validate(); err != nil {
return nil, fmt.Errorf("grant %d: %w", i, err)
}
grants = append(grants, grant)
}
return grants, nil
}
func exactSpoolObject(data []byte, names ...string) (map[string]json.RawMessage, error) {
decoder := json.NewDecoder(bytes.NewReader(data))
token, err := decoder.Token()
if err != nil {
return nil, err
}
if token != json.Delim('{') {
return nil, errors.New("expected JSON object")
}
wanted := make(map[string]bool, len(names))
for _, name := range names {
wanted[name] = true
}
fields := make(map[string]json.RawMessage, len(names))
for decoder.More() {
token, err := decoder.Token()
if err != nil {
return nil, err
}
name, ok := token.(string)
if !ok || !wanted[name] {
return nil, fmt.Errorf("unknown field %q", token)
}
if _, exists := fields[name]; exists {
return nil, fmt.Errorf("duplicate field %q", name)
}
var value json.RawMessage
if err := decoder.Decode(&value); err != nil {
return nil, err
}
if bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
return nil, fmt.Errorf("null field %q", name)
}
fields[name] = value
}
if _, err := decoder.Token(); err != nil {
return nil, err
}
if _, err := decoder.Token(); err != io.EOF {
return nil, errors.New("trailing JSON content")
}
for _, name := range names {
if _, exists := fields[name]; !exists {
return nil, fmt.Errorf("missing field %q", name)
}
}
return fields, nil
}
+299
View File
@@ -0,0 +1,299 @@
package mail
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"bd2server/internal/server/accountstate"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
func spoolTestService(t *testing.T, store stateio.Store) (*Service, *player.Inventory, *player.Wallet) {
t.Helper()
inventory, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(store, player.Currency{})
if err != nil {
t.Fatal(err)
}
service, err := OpenService(store, &Starter{Version: "2.35.10", MailCount: 1, MaxMailID: 100}, inventory, wallet)
if err != nil {
t.Fatal(err)
}
return service, inventory, wallet
}
func spoolGrant(identity string, rewards ...GrantReward) Grant {
return Grant{Identity: identity, Title: "系统奖励", Body: "请领取邮件附件。", SentAt: time.Date(2026, 10, 3, 1, 0, 0, 0, time.UTC).UnixMilli(), Rewards: rewards}
}
func writeSpool(t *testing.T, path string, grants ...Grant) {
t.Helper()
data, err := json.Marshal(GrantSpool{Version: 1, Grants: grants})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
func TestGrantSpoolImportsDynamicMailOnceAcrossOpenAndRestart(t *testing.T) {
store := stateio.NewMemory()
service, _, wallet := spoolTestService(t, store)
path := filepath.Join(t.TempDir(), "grants.json")
if err := service.AttachGrantSpoolPath(path); err != nil {
t.Fatal(err)
}
request := wire.AppendVarint(nil, 1, 1)
if _, _, _, err := service.Handle("/MailInfo", request); err != nil {
t.Fatal(err)
}
writeSpool(t, path,
spoolGrant("dev-mail:a", GrantReward{Type: 4, Count: 123}),
spoolGrant("dev-mail:b", GrantReward{Type: 8, ID: 9, Count: 10}),
)
// Spool attachment never changes the versioned starter mailbox.
if len(service.Starter.Mails) != 0 {
t.Fatal("starter changed")
}
for i := 0; i < 2; i++ {
code, response, handled, err := service.Handle("/MailInfo", request)
if err != nil || !handled || code != packetCode {
t.Fatalf("code=%d handled=%v err=%v", code, handled, err)
}
count, _, _ := wire.Varint(response, 2)
max, _, _ := wire.Varint(response, 3)
if count != 3 || max != 102 {
t.Fatalf("count=%d max=%d", count, max)
}
}
if wallet.Snapshot().Gold != 0 {
t.Fatal("unopened mail granted currency")
}
open := wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 101)
if _, _, _, err := service.Handle("/MailOpen", open); err != nil {
t.Fatal(err)
}
reopened, inventory, wallet := spoolTestService(t, store)
if err := reopened.AttachGrantSpoolPath(path); err != nil {
t.Fatal(err)
}
_, response, _, err := reopened.Handle("/MailInfo", request)
if err != nil {
t.Fatal(err)
}
if count, _, _ := wire.Varint(response, 2); count != 2 {
t.Fatalf("count=%d", count)
}
if len(reopened.dynamic) != 2 || reopened.state.NextDynamicMailID != 103 {
t.Fatalf("reissued mail: %+v", reopened.state)
}
open = wire.AppendVarint(wire.AppendVarint(nil, 1, 3), 2, 102)
if _, _, _, err := reopened.Handle("/MailOpen", open); err != nil {
t.Fatal(err)
}
if wallet.Snapshot().Gold != 123 || len(inventory.All()) != 1 || inventory.All()[0].Count != 10 {
t.Fatalf("wallet=%+v inventory=%+v", wallet.Snapshot(), inventory.All())
}
if _, _, _, err := reopened.Handle("/MailInfo", request); err != nil {
t.Fatal(err)
}
if reopened.state.NextDynamicMailID != 103 {
t.Fatal("opened grants were reissued")
}
}
func TestGrantSpoolPersistsIssuedIdentityInSQLiteRequestTransaction(t *testing.T) {
dir := t.TempDir()
database := filepath.Join(dir, "state.db")
path := filepath.Join(dir, "grants.json")
writeSpool(t, path, spoolGrant("dev-mail:sqlite", GrantReward{Type: 4, Count: 123}))
repository, err := accountstate.Open(database)
if err != nil {
t.Fatal(err)
}
service, _, _ := spoolTestService(t, repository)
if err := service.AttachGrantSpoolPath(path); err != nil {
t.Fatal(err)
}
operation, err := repository.BeginOperation()
if err != nil {
t.Fatal(err)
}
request := wire.AppendVarint(nil, 1, 1)
if _, _, _, err := service.Handle("/MailInfo", request); err != nil {
t.Fatal(err)
}
if err := operation.Commit(); err != nil {
t.Fatal(err)
}
if err := repository.Close(); err != nil {
t.Fatal(err)
}
repository, err = accountstate.Open(database)
if err != nil {
t.Fatal(err)
}
defer repository.Close()
service, _, _ = spoolTestService(t, repository)
if err := service.AttachGrantSpoolPath(path); err != nil {
t.Fatal(err)
}
operation, err = repository.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, _, _, err := service.Handle("/MailInfo", request); err != nil {
t.Fatal(err)
}
if err := operation.Commit(); err != nil {
t.Fatal(err)
}
if len(service.dynamic) != 1 || service.issued["dev-mail:sqlite"] != 101 || service.state.NextDynamicMailID != 102 {
t.Fatalf("SQLite restart reissued grant: state=%+v issued=%+v", service.state, service.issued)
}
}
func TestGrantSpoolValidatesEntireBatchBeforeIssuing(t *testing.T) {
store := stateio.NewMemory()
service, _, _ := spoolTestService(t, store)
path := filepath.Join(t.TempDir(), "grants.json")
if err := service.AttachGrantSpoolPath(path); err != nil {
t.Fatal(err)
}
good := spoolGrant("a", GrantReward{Type: 4, Count: 10})
bad := spoolGrant("b", GrantReward{Type: 6, ID: 1, Count: 1})
writeSpool(t, path, good, bad)
if _, _, _, err := service.Handle("/MailInfo", wire.AppendVarint(nil, 1, 1)); err == nil {
t.Fatal("accepted unsupported second grant")
}
assertNoSpoolMail(t, service, store)
writeSpool(t, path, good, good)
if _, _, _, err := service.Handle("/MailInfo", wire.AppendVarint(nil, 1, 2)); err == nil {
t.Fatal("accepted duplicate identity")
}
assertNoSpoolMail(t, service, store)
writeSpool(t, path, good)
if _, _, _, err := service.Handle("/MailInfo", wire.AppendVarint(nil, 1, 3)); err != nil {
t.Fatal(err)
}
if service.issued["a"] != 101 {
t.Fatal("valid replacement did not import")
}
}
func assertNoSpoolMail(t *testing.T, service *Service, store stateio.EntryStore) {
t.Helper()
if len(service.dynamic) != 0 || len(service.issued) != 0 || service.state.NextDynamicMailID != 101 {
t.Fatalf("partial memory update: %+v", service.state)
}
for _, bucket := range []string{"dynamic", "issued"} {
entries, err := store.ListEntries("mail", bucket)
if err != nil || len(entries) != 0 {
t.Fatalf("partial storage %s: entries=%v err=%v", bucket, entries, err)
}
}
}
type failingSpoolStore struct {
*stateio.Memory
fail bool
writes int
}
func (s *failingSpoolStore) SaveWithEntries(domain string, core []byte, changes []stateio.EntryMutation) error {
if domain == "mail" && len(changes) != 0 {
s.writes++
if s.fail {
return errors.New("injected spool save failure")
}
}
return s.Memory.SaveWithEntries(domain, core, changes)
}
func TestGrantSpoolBatchWriteFailureLeavesMemoryAndStorageUnchanged(t *testing.T) {
store := &failingSpoolStore{Memory: stateio.NewMemory(), fail: true}
service, _, _ := spoolTestService(t, store)
path := filepath.Join(t.TempDir(), "grants.json")
writeSpool(t, path, spoolGrant("a", GrantReward{Type: 4, Count: 10}), spoolGrant("b", GrantReward{Type: 4, Count: 20}))
if err := service.AttachGrantSpoolPath(path); err != nil {
t.Fatal(err)
}
request := wire.AppendVarint(nil, 1, 1)
if _, _, _, err := service.Handle("/MailInfo", request); err == nil {
t.Fatal("save failure ignored")
}
assertNoSpoolMail(t, service, store)
if store.writes != 1 {
t.Fatalf("batch used %d writes", store.writes)
}
store.fail = false
if _, _, _, err := service.Handle("/MailInfo", request); err != nil {
t.Fatal(err)
}
if len(service.dynamic) != 2 || service.state.NextDynamicMailID != 103 {
t.Fatal("retry did not issue complete batch")
}
}
func TestGrantSpoolBatchIDExhaustionDoesNotIssueFirstMail(t *testing.T) {
store := stateio.NewMemory()
service, _, _ := spoolTestService(t, store)
service.state.NextDynamicMailID = ^uint64(0) - 1
path := filepath.Join(t.TempDir(), "grants.json")
writeSpool(t, path, spoolGrant("a", GrantReward{Type: 4, Count: 10}), spoolGrant("b", GrantReward{Type: 4, Count: 20}))
if err := service.AttachGrantSpoolPath(path); err != nil {
t.Fatal(err)
}
if _, _, _, err := service.Handle("/MailInfo", wire.AppendVarint(nil, 1, 1)); err == nil {
t.Fatal("accepted overflowing batch")
}
if len(service.dynamic) != 0 || service.state.NextDynamicMailID != ^uint64(0)-1 {
t.Fatal("partially imported overflowing batch")
}
}
func TestGrantSpoolRejectsMalformedSchema(t *testing.T) {
valid := `{"version":1,"grants":[{"identity":"a","title":"t","body":"b","sent_at":1790989200000,"rewards":[{"type":4,"id":0,"count":10}]}]}`
if _, err := decodeGrantSpool([]byte(valid)); err != nil {
t.Fatal(err)
}
invalid := []string{
`null`, `{}`, `{"version":1,"grants":null}`, `{"version":1,"grants":[],"extra":0}`,
strings.Replace(valid, `"version":1`, `"version":2`, 1),
strings.Replace(valid, `"version":1`, `"version":1,"version":1`, 1),
strings.Replace(valid, `"title":"t"`, `"title":"t","title":"u"`, 1),
strings.Replace(valid, `"title":"t"`, `"Title":"t"`, 1),
strings.Replace(valid, `"body":"b"`, `"body":null`, 1),
strings.Replace(valid, `"identity":"a"`, `"identity":" "`, 1),
strings.Replace(valid, `"sent_at":1790989200000`, `"sent_at":0`, 1),
strings.Replace(valid, `"sent_at":1790989200000`, `"sent_at":9223372036854775807`, 1),
strings.Replace(valid, `"count":10`, `"count":0`, 1),
strings.Replace(valid, `"count":10`, `"count":1.5`, 1),
strings.Replace(valid, `"count":10`, `"count":null`, 1),
strings.Replace(valid, `"count":10`, `"count":10,"count":10`, 1),
strings.Replace(valid, `"count":10`, `"count":10,"extra":0`, 1),
strings.Replace(valid, `"id":0,`, ``, 1),
strings.Replace(valid, `"id":0`, `"id":1`, 1),
strings.Replace(valid, `"type":4`, `"type":8`, 1),
valid + `{}`,
}
for _, input := range invalid {
if _, err := decodeGrantSpool([]byte(input)); err == nil {
t.Errorf("accepted malformed spool: %s", input)
}
}
if _, err := decodeGrantSpool([]byte(`{"version":1,"grants":[]}`)); err != nil {
t.Fatal(err)
}
}
+71 -45
View File
@@ -197,16 +197,17 @@ type stateSnapshot struct {
// Service owns mailbox visibility and idempotent reward delivery. Starter is
// immutable source data; only opened IDs are persisted in the player state.
type Service struct {
mu sync.Mutex
Starter *Starter
seedPath string
seedStamp fileStamp
storage stateio.AtomicEntryStore
inventory *player.Inventory
wallet *player.Wallet
state stateSnapshot
dynamic map[uint64]MailDBInfo
issued map[string]uint64
mu sync.Mutex
Starter *Starter
seedPath string
seedStamp fileStamp
grantSpoolPath string
storage stateio.AtomicEntryStore
inventory *player.Inventory
wallet *player.Wallet
state stateSnapshot
dynamic map[uint64]MailDBInfo
issued map[string]uint64
}
type fileStamp struct {
@@ -339,9 +340,16 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
s.mu.Lock()
defer s.mu.Unlock()
if path == "/MailInfo" {
grants, err := readGrantSpool(s.grantSpoolPath)
if err != nil {
return 0, nil, true, err
}
if err := s.reloadSeedIfChanged(); err != nil {
return 0, nil, true, err
}
if err := s.enqueueCompensations(grants); err != nil {
return 0, nil, true, err
}
return packetCode, s.info(), true, nil
}
response, err := s.open(request)
@@ -571,54 +579,72 @@ func (s *Service) persist(next stateSnapshot) error {
// completed reward. Identity is period-scoped and makes repeated rollover
// checks idempotent.
func (s *Service) EnqueueCompensation(identity, title, body string, rewards []gamedata.Reward, sentAt time.Time) error {
if identity == "" || title == "" || body == "" || sentAt.IsZero() || len(rewards) == 0 {
return errors.New("mail: invalid compensation")
grant := compensation{identity: identity, title: title, body: body, rewards: rewards, sentAt: sentAt}
if err := grant.validate(); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.issued[identity]; exists {
return nil
}
if s.state.NextDynamicMailID == 0 || s.state.NextDynamicMailID == ^uint64(0) {
return errors.New("mail: dynamic mail ID exhausted")
}
entry := MailDBInfo{
MailID: s.state.NextDynamicMailID, MailType: 2, Title: title, Body: body,
SentAt: uint64(sentAt.UTC().UnixMilli()), ExpiresAt: uint64(sentAt.UTC().Add(30 * 24 * time.Hour).UnixMilli()),
}
for _, reward := range rewards {
if reward.Type == 0 || reward.Count == 0 || (!currencyRewardTypes[reward.Type] && (!itemDBInfoTypes[reward.Type] || reward.ID == 0)) {
return fmt.Errorf("mail: compensation %q has unsupported reward type=%d id=%d count=%d", identity, reward.Type, reward.ID, reward.Count)
}
entry.RewardTypes = append(entry.RewardTypes, reward.Type)
entry.RewardIDs = append(entry.RewardIDs, reward.ID)
entry.RewardCounts = append(entry.RewardCounts, reward.Count)
}
payload, err := json.Marshal(entry)
if err != nil {
return err
}
issuedPayload, err := json.Marshal(entry.MailID)
if err != nil {
return err
}
return s.enqueueCompensations([]compensation{grant})
}
// enqueueCompensations shares the EnqueueCompensation allocator and durable
// identity ledger. The entire batch is prepared before a single atomic write;
// neither storage nor memory can retain a partially imported grant spool.
// Caller holds s.mu and has validated every grant.
func (s *Service) enqueueCompensations(grants []compensation) error {
next := s.state
next.Opened = append([]uint64(nil), s.state.Opened...)
next.NextDynamicMailID++
var changes []stateio.EntryMutation
var entries []MailDBInfo
var identities []string
for _, grant := range grants {
if _, exists := s.issued[grant.identity]; exists {
continue
}
if next.NextDynamicMailID == 0 || next.NextDynamicMailID == ^uint64(0) {
return errors.New("mail: dynamic mail ID exhausted")
}
entry := MailDBInfo{
MailID: next.NextDynamicMailID, MailType: 2, Title: grant.title, Body: grant.body,
SentAt: uint64(grant.sentAt.UTC().UnixMilli()), ExpiresAt: uint64(grant.sentAt.UTC().Add(30 * 24 * time.Hour).UnixMilli()),
}
for _, reward := range grant.rewards {
entry.RewardTypes = append(entry.RewardTypes, reward.Type)
entry.RewardIDs = append(entry.RewardIDs, reward.ID)
entry.RewardCounts = append(entry.RewardCounts, reward.Count)
}
payload, err := json.Marshal(entry)
if err != nil {
return err
}
issuedPayload, err := json.Marshal(entry.MailID)
if err != nil {
return err
}
changes = append(changes,
stateio.EntryMutation{Bucket: "dynamic", Key: strconv.FormatUint(entry.MailID, 10), Payload: payload},
stateio.EntryMutation{Bucket: "issued", Key: grant.identity, Payload: issuedPayload},
)
entries = append(entries, entry)
identities = append(identities, grant.identity)
next.NextDynamicMailID++
}
if len(entries) == 0 {
return nil
}
core, err := json.Marshal(next)
if err != nil {
return err
}
changes := []stateio.EntryMutation{
{Bucket: "dynamic", Key: strconv.FormatUint(entry.MailID, 10), Payload: payload},
{Bucket: "issued", Key: identity, Payload: issuedPayload},
}
if err := s.storage.SaveWithEntries("mail", core, changes); err != nil {
return err
}
s.state = next
s.dynamic[entry.MailID] = entry
s.issued[identity] = entry.MailID
for i, entry := range entries {
s.dynamic[entry.MailID] = entry
s.issued[identities[i]] = entry.MailID
}
return nil
}