package dnfbridge import ( "context" "errors" "fmt" "strings" "time" dnfcerashop "longheng.io/server/internal/modules/dnf/cerashop" dnfrepo "longheng.io/server/internal/modules/dnf/repository" "longheng.io/server/internal/modules/dnf/revivecoin" ) // 本文件实现 GmTool90CN 需要的另外两类 admin 能力: // // POST /local/dnf-gm/character/revive-coin 复活币钱包(主背包固定槽位里的 item_id=1) // POST /local/dnf-gm/account/cera 账号点券(三个钱包字段之一) // // 两者都复用模块内的权威实现,不自己发明行结构: // - 复活币:revivecoin.Grant / Consolidate 会先把旧版散落行并进固定钱包槽, // 并把 amount_or_count / count / value_a / instance_value 这些镜像键一次写全。 // - 点券:cerashop.WalletCurrency.SetOn 会夹到 [0, MaxWalletBalance], // 避免客户端按有符号 32 位读成负数。 var ( // ErrGMReviveCoinUnavailable 表示仓储不可用。 ErrGMReviveCoinUnavailable = errors.New("gm revive coin unavailable") // ErrGMAccountWalletUnavailable 表示账号仓储不可用。 ErrGMAccountWalletUnavailable = errors.New("gm account wallet unavailable") ) // GMReviveCoinRequest 对应 POST /local/dnf-gm/character/revive-coin。 // // Amount 为增量(可为负),Value 为直接覆写;二者只能传其一。 type GMReviveCoinRequest struct { CharacterID string `json:"character_id"` Amount *int64 `json:"amount,omitempty"` Value *int64 `json:"value,omitempty"` Source string `json:"source,omitempty"` } // GMReviveCoinResult 描述一次复活币调整。 type GMReviveCoinResult struct { CharacterID string `json:"character_id"` Before int64 `json:"before"` After int64 `json:"after"` Slot string `json:"slot"` } // GMAdjustReviveCoin 把角色复活币余额调整到目标值。 // // 复活币不是独立表,而是主背包固定钱包槽(revivecoin.WalletKey(),默认 "0:1") // 里 item_id=1 的一条堆叠;余额就是它的 count。 func (s *Service) GMAdjustReviveCoin(ctx context.Context, request GMReviveCoinRequest) (GMReviveCoinResult, error) { result := GMReviveCoinResult{Slot: revivecoin.WalletKey()} if s == nil { return result, ErrGMReviveCoinUnavailable } if ctx == nil { ctx = context.Background() } characterID := strings.TrimSpace(request.CharacterID) if characterID == "" { return result, fmt.Errorf("%w: character_id is required", ErrGMRequestInvalid) } if request.Amount == nil && request.Value == nil { return result, fmt.Errorf("%w: amount or value is required", ErrGMRequestInvalid) } if request.Amount != nil && request.Value != nil { return result, fmt.Errorf("%w: amount and value are mutually exclusive", ErrGMRequestInvalid) } result.CharacterID = characterID source := strings.TrimSpace(request.Source) if source == "" { source = "gm_tool_revive_coin" } repositories, available := s.repositoryGroup() if !available || repositories.Inventory == nil || repositories.CharacterSettlement == nil { return result, ErrGMRepositoryUnavailable } err := repositories.CharacterSettlement.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error { inventory, found, loadErr := tx.Inventory.Load(ctx, characterID) if loadErr != nil { return loadErr } if !found { return fmt.Errorf("%w: character=%s", ErrGMInventoryNotFound, characterID) } inventory = dnfrepo.CloneInventory(inventory) if inventory.Slots == nil { inventory.Slots = map[string]dnfrepo.ItemStack{} } // 先合并,才能读到含旧版散落行的真实余额。 consolidated, consErr := revivecoin.Consolidate(&inventory) if consErr != nil { return consErr } result.Before = consolidated.Total target := consolidated.Total if request.Value != nil { target = *request.Value } else { target = consolidated.Total + *request.Amount } if target < 0 { target = 0 } if target == consolidated.Total { // 余额没变也要落库:Consolidate 可能已经把旧版散落行并走了。 inventory.UpdatedAt = time.Now().UTC() result.After = target return dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots) } if target == 0 { // 归零就是把钱包行整行删掉;保留一条 count=0 的行会让客户端 // 在快捷栏里看到一个空的道具格。 delete(inventory.Slots, revivecoin.WalletKey()) } else if err := gmSetReviveCoinWallet(&inventory, target, source); err != nil { return err } result.After = target inventory.UpdatedAt = time.Now().UTC() return dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots) }) if err != nil { return result, err } return result, nil } // gmSetReviveCoinWallet 把钱包余额写成 target(target>0)。 // // 先删掉现有钱包行,再用 revivecoin.Grant 建一条结构完整的新行, // 然后把 count 与四个镜像键改成目标值 —— 这样 extra 的键集合始终与 // 权威实现一致,不会因为手工拼装而漏键。 func gmSetReviveCoinWallet(inventory *dnfrepo.InventoryRecord, target int64, source string) error { if inventory == nil || target <= 0 { return fmt.Errorf("%w: target=%d", ErrGMRequestInvalid, target) } key := revivecoin.WalletKey() delete(inventory.Slots, key) if _, err := revivecoin.Grant(inventory, target, source); err != nil { return err } stack := inventory.Slots[key] if stack.Extra == nil { stack.Extra = map[string]string{} } value := fmt.Sprintf("%d", target) stack.ItemID = revivecoin.WalletItemID stack.Count = target stack.Extra["amount_or_count"] = value stack.Extra["count"] = value stack.Extra["value_a"] = value stack.Extra["instance_value"] = value inventory.Slots[key] = stack return nil } // GMAccountCeraRequest 对应 POST /local/dnf-gm/account/cera。 // // Currency 取 cera / token_cera / happy_token_cera,留空按 cera。 // Amount 为增量(可为负),Value 为直接覆写;二者只能传其一。 type GMAccountCeraRequest struct { AccountID string `json:"account_id"` Currency string `json:"currency,omitempty"` Amount *int64 `json:"amount,omitempty"` Value *int64 `json:"value,omitempty"` } // GMAccountCeraResult 描述一次账号钱包调整。 type GMAccountCeraResult struct { AccountID string `json:"account_id"` Currency string `json:"currency"` Before int64 `json:"before"` After int64 `json:"after"` } // GMAdjustAccountCera 调整账号级别的点券 / 代币券余额。 // // 工具端的"账号点券"入口走这里;点券是账号维度,因此不能用需要 // character_id 的 wallet/update 接口。 func (s *Service) GMAdjustAccountCera(ctx context.Context, request GMAccountCeraRequest) (GMAccountCeraResult, error) { result := GMAccountCeraResult{} if s == nil { return result, ErrGMAccountWalletUnavailable } if ctx == nil { ctx = context.Background() } accountID := strings.TrimSpace(request.AccountID) if accountID == "" { return result, fmt.Errorf("%w: account_id is required", ErrGMRequestInvalid) } if request.Amount == nil && request.Value == nil { return result, fmt.Errorf("%w: amount or value is required", ErrGMRequestInvalid) } if request.Amount != nil && request.Value != nil { return result, fmt.Errorf("%w: amount and value are mutually exclusive", ErrGMRequestInvalid) } currencyName := strings.ToLower(strings.TrimSpace(request.Currency)) if currencyName == "" { currencyName = string(dnfcerashop.CurrencyCera) } currency := dnfcerashop.WalletCurrency(currencyName) if !currency.Known() { return result, fmt.Errorf("%w: unsupported currency=%s", ErrGMRequestInvalid, currencyName) } result.AccountID = accountID result.Currency = currencyName repositories, available := s.repositoryGroup() if !available || repositories.Account == nil { return result, ErrGMRepositoryUnavailable } account, found, err := repositories.Account.Load(ctx, accountID) if err != nil { return result, err } if !found { return result, fmt.Errorf("%w: account not found", ErrGMRequestInvalid) } account = dnfrepo.CloneAccount(account) before, _ := currency.BalanceOf(account) result.Before = before target := before if request.Value != nil { target = *request.Value } else { target = before + *request.Amount } if target < 0 { target = 0 } if !currency.SetOn(&account, target) { return result, fmt.Errorf("%w: currency=%s cannot be written", ErrGMRequestInvalid, currencyName) } account.UpdatedAt = time.Now().UTC() if err := repositories.Account.Save(ctx, account); err != nil { return result, err } after, _ := currency.BalanceOf(account) result.After = after // 落库后推送给该账号下所有在线角色:点券是账号级钱包,需刷新每个已选角色的 // 钱包投影(class-0 op53),让在线角色的点券余额即时刷新,不必离线/重登。 for _, session := range s.currentOnlineChatSessions() { if session == nil { continue } if strings.TrimSpace(s.accountIDForSession(session)) != accountID { continue } if _, pushErr := s.sendCurrentGoldConsignWalletRefresh(session); pushErr != nil { s.logWarn("dnf90 GM cera live refresh failed", "account_id", accountID, "currency", currencyName, "error", pushErr) } } return result, nil }