DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,304 @@
|
||||
package cerashop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
accountCeraMetadataKey = "account_cera"
|
||||
accountTokenCeraMetadataKey = "account_token_cera"
|
||||
accountHappyTokenCeraMetadataKey = "account_happy_token_cera"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrOwnerUnavailable = errors.New("cera shop owner is unavailable")
|
||||
ErrAccountNotFound = errors.New("cera shop account is not found")
|
||||
ErrCharacterNotFound = errors.New("cera shop character is not found")
|
||||
ErrInventoryNotFound = errors.New("cera shop inventory is not found")
|
||||
ErrAccountMismatch = errors.New("cera shop character account mismatch")
|
||||
ErrCeraInsufficient = errors.New("cera shop cera balance is insufficient")
|
||||
ErrProjectorRequired = errors.New("cera shop checkout projector is required")
|
||||
)
|
||||
|
||||
type CheckoutChanges struct {
|
||||
Character bool
|
||||
Inventory bool
|
||||
Equipment bool
|
||||
Settings bool
|
||||
}
|
||||
|
||||
type CheckoutAssets struct {
|
||||
Account *dnfrepo.AccountRecord
|
||||
Character *dnfrepo.CharacterRecord
|
||||
Inventory *dnfrepo.InventoryRecord
|
||||
Equipment *dnfrepo.EquipmentRecord
|
||||
Settings *dnfrepo.SettingsRecord
|
||||
EquipmentFound bool
|
||||
SettingsFound bool
|
||||
}
|
||||
|
||||
type CheckoutProjector func(*CheckoutAssets) (CheckoutChanges, error)
|
||||
|
||||
type CheckoutCommand struct {
|
||||
AccountID string
|
||||
CharacterID string
|
||||
SettingsScope string
|
||||
Cost int64
|
||||
UpdatedAt time.Time
|
||||
// PayPolicy 决定这笔钱从哪些钱包扣。
|
||||
//
|
||||
// **零值 = PayPolicyCeraOnly = 只扣点券 = 改动前的行为**,所以既有调用方一行都
|
||||
// 不用改,也不会因为漏填这个字段而悄悄换一种扣费方式。
|
||||
PayPolicy PayPolicy
|
||||
Project CheckoutProjector
|
||||
}
|
||||
|
||||
type CheckoutResult struct {
|
||||
CeraBefore int64
|
||||
CeraAfter int64
|
||||
TokenCera int64
|
||||
HappyTokenCera int64
|
||||
// Paid 是本次结账各钱包实际扣掉的数量。
|
||||
// 用于日志核对,也是"代币券真的能花掉"这条验收的凭据。
|
||||
Paid WalletPayment
|
||||
}
|
||||
|
||||
// Wallet is the complete account-owned Cera projection consumed by the
|
||||
// current client's class0/op53 update packet.
|
||||
type Wallet struct {
|
||||
Cera int64
|
||||
TokenCera int64
|
||||
HappyTokenCera int64
|
||||
}
|
||||
|
||||
type Owner struct {
|
||||
assets dnfrepo.CeraShopAssetUnitOfWork
|
||||
}
|
||||
|
||||
func NewOwner(repositories dnfrepo.Group) (*Owner, error) {
|
||||
if repositories.CeraShopAssets == nil {
|
||||
return nil, ErrOwnerUnavailable
|
||||
}
|
||||
return &Owner{assets: repositories.CeraShopAssets}, nil
|
||||
}
|
||||
|
||||
func (o *Owner) Checkout(ctx context.Context, command CheckoutCommand) (CheckoutResult, error) {
|
||||
accountID := strings.TrimSpace(command.AccountID)
|
||||
characterID := strings.TrimSpace(command.CharacterID)
|
||||
settingsScope := strings.TrimSpace(command.SettingsScope)
|
||||
if o == nil || o.assets == nil || accountID == "" || characterID == "" || settingsScope == "" || command.Cost < 0 {
|
||||
return CheckoutResult{}, ErrOwnerUnavailable
|
||||
}
|
||||
if command.Project == nil {
|
||||
return CheckoutResult{}, ErrProjectorRequired
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return CheckoutResult{}, err
|
||||
}
|
||||
now := command.UpdatedAt.UTC()
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
}
|
||||
|
||||
var result CheckoutResult
|
||||
err := o.assets.WithinCeraShopAssets(
|
||||
ctx,
|
||||
accountID,
|
||||
characterID,
|
||||
settingsScope,
|
||||
func(
|
||||
accounts dnfrepo.AccountRepository,
|
||||
_ dnfrepo.AccountInventoryRepository,
|
||||
characters dnfrepo.CharacterRepository,
|
||||
inventories dnfrepo.InventoryRepository,
|
||||
equipment dnfrepo.EquipmentRepository,
|
||||
settings dnfrepo.SettingsRepository,
|
||||
) error {
|
||||
if accounts == nil || characters == nil || inventories == nil || equipment == nil || settings == nil {
|
||||
return ErrOwnerUnavailable
|
||||
}
|
||||
account, found, err := accounts.Load(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return ErrAccountNotFound
|
||||
}
|
||||
character, found, err := characters.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return ErrCharacterNotFound
|
||||
}
|
||||
if strings.TrimSpace(character.AccountID) != accountID {
|
||||
return ErrAccountMismatch
|
||||
}
|
||||
inventory, found, err := inventories.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || strings.TrimSpace(inventory.CharacterID) != characterID {
|
||||
return ErrInventoryNotFound
|
||||
}
|
||||
equipmentRecord, equipmentFound, err := equipment.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !equipmentFound {
|
||||
equipmentRecord = dnfrepo.EquipmentRecord{CharacterID: characterID}
|
||||
}
|
||||
settingsRecord, settingsFound, err := settings.Load(ctx, settingsScope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !settingsFound {
|
||||
settingsRecord = dnfrepo.SettingsRecord{Scope: settingsScope}
|
||||
}
|
||||
|
||||
account = dnfrepo.CloneAccount(account)
|
||||
character = dnfrepo.CloneCharacter(character)
|
||||
inventory = dnfrepo.CloneInventory(inventory)
|
||||
equipmentRecord = dnfrepo.CloneEquipment(equipmentRecord)
|
||||
settingsRecord = dnfrepo.CloneSettings(settingsRecord)
|
||||
|
||||
ceraBefore := Balance(account)
|
||||
// 按策略算扣费方案:默认(零值)就是"只扣点券",与改动前完全一致。
|
||||
// 瀑布策略下代币券 / 欢乐代币券会先被花掉,点券只补差额。
|
||||
payment, planErr := PlanWalletPayment(WalletBalance(account), command.Cost, command.PayPolicy)
|
||||
if planErr != nil {
|
||||
if errors.Is(planErr, ErrWalletInsufficient) && command.PayPolicy == PayPolicyCeraOnly {
|
||||
// 保持既有错误口径:只用点券时仍然报 ErrCeraInsufficient,
|
||||
// 免得改了错误类型却没人更新调用方的判定。
|
||||
return fmt.Errorf("%w: balance=%d cost=%d", ErrCeraInsufficient, ceraBefore, command.Cost)
|
||||
}
|
||||
return planErr
|
||||
}
|
||||
ceraAfter := ceraBefore - payment.FromCera
|
||||
ApplyWalletPayment(&account, payment)
|
||||
|
||||
assets := &CheckoutAssets{
|
||||
Account: &account,
|
||||
Character: &character,
|
||||
Inventory: &inventory,
|
||||
Equipment: &equipmentRecord,
|
||||
Settings: &settingsRecord,
|
||||
EquipmentFound: equipmentFound,
|
||||
SettingsFound: settingsFound,
|
||||
}
|
||||
changes, err := command.Project(assets)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
account.UpdatedAt = now
|
||||
if err := accounts.Save(ctx, account); err != nil {
|
||||
return err
|
||||
}
|
||||
if changes.Character {
|
||||
character.UpdatedAt = now
|
||||
if err := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if changes.Inventory {
|
||||
inventory.UpdatedAt = now
|
||||
if err := dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if changes.Equipment {
|
||||
equipmentRecord.UpdatedAt = now
|
||||
if err := dnfrepo.SaveEquipmentFields(ctx, equipment, equipmentRecord, dnfrepo.EquipmentFieldEntries); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if changes.Settings {
|
||||
settingsRecord.UpdatedAt = now
|
||||
if err := dnfrepo.SaveSettingsFields(ctx, settings, settingsRecord, dnfrepo.SettingsFieldValues); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
wallet := WalletBalance(account)
|
||||
result = CheckoutResult{
|
||||
CeraBefore: ceraBefore,
|
||||
CeraAfter: ceraAfter,
|
||||
TokenCera: wallet.TokenCera,
|
||||
HappyTokenCera: wallet.HappyTokenCera,
|
||||
Paid: payment,
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return CheckoutResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func Balance(account dnfrepo.AccountRecord) int64 {
|
||||
return metadataBalance(account, accountCeraMetadataKey)
|
||||
}
|
||||
|
||||
func TokenCeraBalance(account dnfrepo.AccountRecord) int64 {
|
||||
return metadataBalance(account, accountTokenCeraMetadataKey)
|
||||
}
|
||||
|
||||
func HappyTokenCeraBalance(account dnfrepo.AccountRecord) int64 {
|
||||
return metadataBalance(account, accountHappyTokenCeraMetadataKey)
|
||||
}
|
||||
|
||||
func WalletBalance(account dnfrepo.AccountRecord) Wallet {
|
||||
return Wallet{
|
||||
Cera: Balance(account),
|
||||
TokenCera: TokenCeraBalance(account),
|
||||
HappyTokenCera: HappyTokenCeraBalance(account),
|
||||
}
|
||||
}
|
||||
|
||||
func metadataBalance(account dnfrepo.AccountRecord, key string) int64 {
|
||||
raw := strings.TrimSpace(account.Metadata[key])
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || value < 0 {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func SetBalance(account *dnfrepo.AccountRecord, balance int64) {
|
||||
setMetadataBalance(account, accountCeraMetadataKey, balance)
|
||||
}
|
||||
|
||||
func SetTokenCeraBalance(account *dnfrepo.AccountRecord, balance int64) {
|
||||
setMetadataBalance(account, accountTokenCeraMetadataKey, balance)
|
||||
}
|
||||
|
||||
func SetHappyTokenCeraBalance(account *dnfrepo.AccountRecord, balance int64) {
|
||||
setMetadataBalance(account, accountHappyTokenCeraMetadataKey, balance)
|
||||
}
|
||||
|
||||
func setMetadataBalance(account *dnfrepo.AccountRecord, key string, balance int64) {
|
||||
if account == nil {
|
||||
return
|
||||
}
|
||||
if account.Metadata == nil {
|
||||
account.Metadata = make(map[string]string, 4)
|
||||
}
|
||||
if balance < 0 {
|
||||
balance = 0
|
||||
}
|
||||
account.Metadata[key] = strconv.FormatInt(balance, 10)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package cerashop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func TestCheckoutCommitsWalletAndAllProjectedAssets(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := seededCheckoutRepositories(t, 1000)
|
||||
owner, err := NewOwner(repositories)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := owner.Checkout(ctx, CheckoutCommand{
|
||||
AccountID: "account-1",
|
||||
CharacterID: "22",
|
||||
SettingsScope: dnfrepo.CharacterContainerStateScope("22"),
|
||||
Cost: 100,
|
||||
Project: func(assets *CheckoutAssets) (CheckoutChanges, error) {
|
||||
assets.Character.Stats = map[string]int64{"name_tag_item_id": 123}
|
||||
assets.Inventory.Slots = map[string]dnfrepo.ItemStack{"0:9": {ItemID: 456, Count: 1}}
|
||||
assets.Equipment.Entries = map[string]dnfrepo.EquipmentEntry{"30": {SlotIndex: 30, ItemID: 123}}
|
||||
assets.Settings.Values = map[string]string{"main_list_param16": "8"}
|
||||
return CheckoutChanges{Character: true, Inventory: true, Equipment: true, Settings: true}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.CeraBefore != 1000 || result.CeraAfter != 900 {
|
||||
t.Fatalf("checkout result=%+v", result)
|
||||
}
|
||||
account, _, _ := repositories.Account.Load(ctx, "account-1")
|
||||
character, _, _ := repositories.Character.Load(ctx, "22")
|
||||
inventory, _, _ := repositories.Inventory.Load(ctx, "22")
|
||||
equipment, _, _ := repositories.Equipment.Load(ctx, "22")
|
||||
settings, found, _ := repositories.Settings.Load(ctx, dnfrepo.CharacterContainerStateScope("22"))
|
||||
if Balance(account) != 900 ||
|
||||
character.Stats["name_tag_item_id"] != 123 ||
|
||||
inventory.Slots["0:9"].ItemID != 456 ||
|
||||
equipment.Entries["30"].ItemID != 123 ||
|
||||
!found || settings.Values["main_list_param16"] != "8" {
|
||||
t.Fatalf("account=%+v character=%+v inventory=%+v equipment=%+v settings=%+v", account, character, inventory, equipment, settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckoutRollsBackProjectionFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := seededCheckoutRepositories(t, 1000)
|
||||
owner, err := NewOwner(repositories)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projectErr := errors.New("projection rejected")
|
||||
_, err = owner.Checkout(ctx, CheckoutCommand{
|
||||
AccountID: "account-1",
|
||||
CharacterID: "22",
|
||||
SettingsScope: dnfrepo.CharacterContainerStateScope("22"),
|
||||
Cost: 100,
|
||||
Project: func(assets *CheckoutAssets) (CheckoutChanges, error) {
|
||||
assets.Inventory.Slots = make(map[string]dnfrepo.ItemStack)
|
||||
assets.Inventory.Slots["0:9"] = dnfrepo.ItemStack{ItemID: 456, Count: 1}
|
||||
assets.Settings.Values = map[string]string{"main_list_param16": "8"}
|
||||
return CheckoutChanges{Inventory: true, Settings: true}, projectErr
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, projectErr) {
|
||||
t.Fatalf("error=%v want=%v", err, projectErr)
|
||||
}
|
||||
account, _, _ := repositories.Account.Load(ctx, "account-1")
|
||||
inventory, _, _ := repositories.Inventory.Load(ctx, "22")
|
||||
if Balance(account) != 1000 || len(inventory.Slots) != 0 {
|
||||
t.Fatalf("account=%+v inventory=%+v", account, inventory)
|
||||
}
|
||||
if _, found, _ := repositories.Settings.Load(ctx, dnfrepo.CharacterContainerStateScope("22")); found {
|
||||
t.Fatal("failed checkout persisted settings")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckoutRejectsInsufficientCeraBeforeProjection(t *testing.T) {
|
||||
repositories := seededCheckoutRepositories(t, 50)
|
||||
owner, err := NewOwner(repositories)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projected := false
|
||||
_, err = owner.Checkout(context.Background(), CheckoutCommand{
|
||||
AccountID: "account-1",
|
||||
CharacterID: "22",
|
||||
SettingsScope: dnfrepo.CharacterContainerStateScope("22"),
|
||||
Cost: 100,
|
||||
Project: func(*CheckoutAssets) (CheckoutChanges, error) {
|
||||
projected = true
|
||||
return CheckoutChanges{}, nil
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, ErrCeraInsufficient) || projected {
|
||||
t.Fatalf("error=%v projected=%t", err, projected)
|
||||
}
|
||||
}
|
||||
|
||||
func seededCheckoutRepositories(t *testing.T, cera int64) dnfrepo.Group {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
account := dnfrepo.AccountRecord{AccountID: "account-1"}
|
||||
SetBalance(&account, cera)
|
||||
if err := repositories.Account.Save(ctx, account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
|
||||
CharacterID: "22",
|
||||
AccountID: "account-1",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||||
CharacterID: "22",
|
||||
Slots: make(map[string]dnfrepo.ItemStack),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return repositories
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package cerashop
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
// 商城的钱包扣费策略。
|
||||
//
|
||||
// 现场背景:客户端**只上报"支付方式 0 = 点券"**,没有任何"用代币券买"的选项
|
||||
// (见 dnfbridge 的 currentCeraShopPaymentMode*)。也就是说代币券 / 欢乐代币券
|
||||
// 能不能花掉,完全由服务端决定 —— 服务端不主动从这两个钱包扣,它们就是一组
|
||||
// 只能看不能用的数字(这正是现场 2026-09-19 抱怨的另一半)。
|
||||
//
|
||||
// 所以这里实现官方的"瀑布"语义:欢乐代币券 → 代币券 → 点券,依次扣到够为止。
|
||||
type PayPolicy int
|
||||
|
||||
const (
|
||||
// PayPolicyCeraOnly 只扣点券,**这是零值**。
|
||||
//
|
||||
// 零值必须等于改动前的行为:既有调用方一行都不用改,也不会因为漏传字段而
|
||||
// 悄悄换一种扣费方式(Go 里"新增 bool 字段默认 false"就是这种陷阱)。
|
||||
PayPolicyCeraOnly PayPolicy = iota
|
||||
// PayPolicyWaterfall 欢乐代币券 → 代币券 → 点券,依次扣。
|
||||
PayPolicyWaterfall
|
||||
// PayPolicyTokenCeraOnly 只扣欢乐代币券 + 代币券,不动点券。
|
||||
// 对应客户端支持、而本服 PVF 尚未配置的 `[buy only cera point]` 商品;
|
||||
// 余额不够就整笔失败。
|
||||
PayPolicyTokenCeraOnly
|
||||
)
|
||||
|
||||
// ErrWalletInsufficient 表示按给定策略扣不动这笔钱。
|
||||
//
|
||||
// 与 ErrCeraInsufficient(只针对点券)分开:调用方需要区分"点券不够"和
|
||||
// "代币券+点券都不够",日志里才看得清玩家到底缺哪种钱。
|
||||
var ErrWalletInsufficient = errors.New("cera shop: wallet balance is insufficient for the payment policy")
|
||||
|
||||
// WalletPayment 是一次结账从各钱包实际扣掉的数量。
|
||||
type WalletPayment struct {
|
||||
FromHappyTokenCera int64
|
||||
FromTokenCera int64
|
||||
FromCera int64
|
||||
}
|
||||
|
||||
// Total 是这次结账各钱包扣费之和。
|
||||
func (p WalletPayment) Total() int64 {
|
||||
return p.FromHappyTokenCera + p.FromTokenCera + p.FromCera
|
||||
}
|
||||
|
||||
// IsZero 表示没有从任何钱包扣钱。
|
||||
func (p WalletPayment) IsZero() bool {
|
||||
return p.FromHappyTokenCera == 0 && p.FromTokenCera == 0 && p.FromCera == 0
|
||||
}
|
||||
|
||||
// String 便于日志与断言(例如 "happy=100 token=50 cera=0")。
|
||||
func (p WalletPayment) String() string {
|
||||
return fmt.Sprintf("happy=%d token=%d cera=%d", p.FromHappyTokenCera, p.FromTokenCera, p.FromCera)
|
||||
}
|
||||
|
||||
// PlanWalletPayment 按策略算出各钱包要扣多少,**不改动传入的钱包**。
|
||||
//
|
||||
// 返回的 WalletPayment 保证 Total() == cost(成功时);失败时返回零值与错误。
|
||||
// 这是一个纯函数,方便把"扣费顺序"这类最容易出错的逻辑单独钉死。
|
||||
func PlanWalletPayment(wallet Wallet, cost int64, policy PayPolicy) (WalletPayment, error) {
|
||||
if cost < 0 {
|
||||
return WalletPayment{}, fmt.Errorf("%w: negative cost %d", ErrWalletInsufficient, cost)
|
||||
}
|
||||
if cost == 0 {
|
||||
return WalletPayment{}, nil
|
||||
}
|
||||
// 负数余额只可能来自脏数据;当成 0,绝不让它把扣费算成"多扣"。
|
||||
happy := wallet.HappyTokenCera
|
||||
if happy < 0 {
|
||||
happy = 0
|
||||
}
|
||||
token := wallet.TokenCera
|
||||
if token < 0 {
|
||||
token = 0
|
||||
}
|
||||
cera := wallet.Cera
|
||||
if cera < 0 {
|
||||
cera = 0
|
||||
}
|
||||
|
||||
var payment WalletPayment
|
||||
remaining := cost
|
||||
|
||||
// 两种非"纯点券"的策略都先花欢乐代币券:它是活动货币,优先级最高。
|
||||
if policy == PayPolicyWaterfall || policy == PayPolicyTokenCeraOnly {
|
||||
payment.FromHappyTokenCera = minInt64(remaining, happy)
|
||||
remaining -= payment.FromHappyTokenCera
|
||||
if remaining > 0 {
|
||||
payment.FromTokenCera = minInt64(remaining, token)
|
||||
remaining -= payment.FromTokenCera
|
||||
}
|
||||
}
|
||||
if policy == PayPolicyWaterfall && remaining > 0 {
|
||||
payment.FromCera = minInt64(remaining, cera)
|
||||
remaining -= payment.FromCera
|
||||
}
|
||||
// 未知策略一律按"只扣点券"处理,绝不猜一种更宽松的扣法。
|
||||
if policy != PayPolicyWaterfall && policy != PayPolicyTokenCeraOnly {
|
||||
payment = WalletPayment{}
|
||||
remaining = cost
|
||||
payment.FromCera = minInt64(remaining, cera)
|
||||
remaining -= payment.FromCera
|
||||
}
|
||||
if remaining > 0 {
|
||||
return WalletPayment{}, fmt.Errorf("%w: policy=%d cost=%d wallet=%+v", ErrWalletInsufficient, policy, cost, wallet)
|
||||
}
|
||||
return payment, nil
|
||||
}
|
||||
|
||||
// ApplyWalletPayment 把扣费方案写回账号钱包,并返回扣费后的钱包。
|
||||
//
|
||||
// 调用方负责保存账号记录(本函数只改内存里的记录,与仓库无关,便于在事务回调里用)。
|
||||
// 每个字段都夹到 >= 0:方案本身已经保证不会扣超,这里只是最后一道防线。
|
||||
func ApplyWalletPayment(account *dnfrepo.AccountRecord, payment WalletPayment) Wallet {
|
||||
if account == nil {
|
||||
return Wallet{}
|
||||
}
|
||||
wallet := WalletBalance(*account)
|
||||
SetHappyTokenCeraBalance(account, wallet.HappyTokenCera-payment.FromHappyTokenCera)
|
||||
SetTokenCeraBalance(account, wallet.TokenCera-payment.FromTokenCera)
|
||||
SetBalance(account, wallet.Cera-payment.FromCera)
|
||||
return WalletBalance(*account)
|
||||
}
|
||||
|
||||
func minInt64(left, right int64) int64 {
|
||||
if left < right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package cerashop
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
// 支付瀑布:欢乐代币券 → 代币券 → 点券。
|
||||
//
|
||||
// 客户端只上报"支付方式 0 = 点券",没有"用代币券买"的选项,所以代币券能不能花掉
|
||||
// 完全由服务端决定。这一组测试把"扣费顺序"钉死——它是最容易写错、也最容易
|
||||
// 悄悄扣错钱包的地方。
|
||||
|
||||
func TestPlanWalletPaymentCeraOnlyIsTheZeroValue(t *testing.T) {
|
||||
wallet := Wallet{Cera: 100, TokenCera: 500, HappyTokenCera: 500}
|
||||
// 零值必须是"只扣点券":既有调用方一行不改也不该换扣费方式。
|
||||
var policy PayPolicy
|
||||
if policy != PayPolicyCeraOnly {
|
||||
t.Fatalf("PayPolicy 零值=%v want PayPolicyCeraOnly", policy)
|
||||
}
|
||||
payment, err := PlanWalletPayment(wallet, 80, policy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payment.FromCera != 80 || payment.FromTokenCera != 0 || payment.FromHappyTokenCera != 0 {
|
||||
t.Fatalf("只扣点券模式动了别的钱包: %s", payment)
|
||||
}
|
||||
// 点券不够就该失败,而不是把代币券也花了。
|
||||
if _, err := PlanWalletPayment(wallet, 101, PayPolicyCeraOnly); !errors.Is(err, ErrWalletInsufficient) {
|
||||
t.Fatalf("点券不够应失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanWalletPaymentWaterfallSpendsInOrder(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
wallet Wallet
|
||||
cost int64
|
||||
want WalletPayment
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "happy_covers_all",
|
||||
wallet: Wallet{Cera: 999, TokenCera: 999, HappyTokenCera: 100},
|
||||
cost: 40,
|
||||
want: WalletPayment{FromHappyTokenCera: 40},
|
||||
},
|
||||
{
|
||||
name: "happy_exhausted_then_token",
|
||||
wallet: Wallet{Cera: 999, TokenCera: 100, HappyTokenCera: 30},
|
||||
cost: 50,
|
||||
want: WalletPayment{FromHappyTokenCera: 30, FromTokenCera: 20},
|
||||
},
|
||||
{
|
||||
name: "happy_and_token_exhausted_then_cera",
|
||||
wallet: Wallet{Cera: 999, TokenCera: 20, HappyTokenCera: 10},
|
||||
cost: 100,
|
||||
want: WalletPayment{FromHappyTokenCera: 10, FromTokenCera: 20, FromCera: 70},
|
||||
},
|
||||
{
|
||||
name: "exactly_enough_across_all_three",
|
||||
wallet: Wallet{Cera: 70, TokenCera: 20, HappyTokenCera: 10},
|
||||
cost: 100,
|
||||
want: WalletPayment{FromHappyTokenCera: 10, FromTokenCera: 20, FromCera: 70},
|
||||
},
|
||||
{
|
||||
name: "one_short",
|
||||
wallet: Wallet{Cera: 69, TokenCera: 20, HappyTokenCera: 10},
|
||||
cost: 100,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "zero_cera_still_payable_with_token",
|
||||
wallet: Wallet{Cera: 0, TokenCera: 300, HappyTokenCera: 0},
|
||||
cost: 250,
|
||||
want: WalletPayment{FromTokenCera: 250},
|
||||
},
|
||||
{
|
||||
name: "cost_zero_is_a_no_op",
|
||||
wallet: Wallet{Cera: 5, TokenCera: 5, HappyTokenCera: 5},
|
||||
cost: 0,
|
||||
want: WalletPayment{},
|
||||
},
|
||||
{
|
||||
// 负数余额只可能来自脏数据,当成 0,绝不能让扣费算出"多扣"。
|
||||
name: "negative_balances_treated_as_zero",
|
||||
wallet: Wallet{Cera: 100, TokenCera: -50, HappyTokenCera: -50},
|
||||
cost: 100,
|
||||
want: WalletPayment{FromCera: 100},
|
||||
},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
payment, err := PlanWalletPayment(testCase.wallet, testCase.cost, PayPolicyWaterfall)
|
||||
if testCase.wantErr {
|
||||
if !errors.Is(err, ErrWalletInsufficient) {
|
||||
t.Fatalf("期望余额不足,实际 err=%v payment=%s", err, payment)
|
||||
}
|
||||
if !payment.IsZero() {
|
||||
t.Fatalf("失败时必须返回零方案,绝不部分扣费: %s", payment)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payment != testCase.want {
|
||||
t.Fatalf("payment=%s want=%s", payment, testCase.want)
|
||||
}
|
||||
if payment.Total() != testCase.cost {
|
||||
t.Fatalf("扣费总额=%d want=%d", payment.Total(), testCase.cost)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// `[buy only cera point]` 商品:不能动点券。
|
||||
func TestPlanWalletPaymentTokenCeraOnlyNeverTouchesCera(t *testing.T) {
|
||||
wallet := Wallet{Cera: 100000, TokenCera: 30, HappyTokenCera: 10}
|
||||
payment, err := PlanWalletPayment(wallet, 40, PayPolicyTokenCeraOnly)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payment.FromCera != 0 || payment.FromHappyTokenCera != 10 || payment.FromTokenCera != 30 {
|
||||
t.Fatalf("不该动点券: %s", payment)
|
||||
}
|
||||
// 券不够时失败,即使点券充足。
|
||||
if _, err := PlanWalletPayment(wallet, 41, PayPolicyTokenCeraOnly); !errors.Is(err, ErrWalletInsufficient) {
|
||||
t.Fatalf("券不够应失败(点券再多也不能用): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 未知策略一律按"只扣点券"处理:绝不猜一种更宽松的扣法。
|
||||
func TestPlanWalletPaymentUnknownPolicyFallsBackToCeraOnly(t *testing.T) {
|
||||
wallet := Wallet{Cera: 100, TokenCera: 500, HappyTokenCera: 500}
|
||||
payment, err := PlanWalletPayment(wallet, 60, PayPolicy(99))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payment.FromCera != 60 || payment.FromTokenCera != 0 || payment.FromHappyTokenCera != 0 {
|
||||
t.Fatalf("未知策略不该动代币券钱包: %s", payment)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanWalletPaymentRejectsNegativeCost(t *testing.T) {
|
||||
if _, err := PlanWalletPayment(Wallet{Cera: 100}, -1, PayPolicyWaterfall); !errors.Is(err, ErrWalletInsufficient) {
|
||||
t.Fatalf("负成本应失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyWalletPaymentWritesOnlyThePlannedFields(t *testing.T) {
|
||||
account := &dnfrepo.AccountRecord{AccountID: "account-1", Metadata: map[string]string{}}
|
||||
SetBalance(account, 700)
|
||||
SetTokenCeraBalance(account, 100)
|
||||
SetHappyTokenCeraBalance(account, 50)
|
||||
after := ApplyWalletPayment(account, WalletPayment{FromHappyTokenCera: 50, FromTokenCera: 20, FromCera: 30})
|
||||
want := Wallet{Cera: 670, TokenCera: 80, HappyTokenCera: 0}
|
||||
if after != want {
|
||||
t.Fatalf("after=%+v want=%+v", after, want)
|
||||
}
|
||||
if wallet := WalletBalance(*account); wallet != want {
|
||||
t.Fatalf("账号记录=%+v want=%+v", wallet, want)
|
||||
}
|
||||
// nil 账号不该 panic(调用方多在事务回调里,panic 会连累整条连接)。
|
||||
if got := ApplyWalletPayment(nil, WalletPayment{FromCera: 1}); got != (Wallet{}) {
|
||||
t.Fatalf("nil 账号应返回零钱包: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Package cerashop:代币券 / 欢乐代币券的"入钱包"语义。
|
||||
//
|
||||
// 背景(现场 2026-09-19):代币券和欢乐代币券在道具 PVF 里是普通
|
||||
// `[stackable type] [material]`,既没有 `[action type]` 也没有任何"使用后转成点券"
|
||||
// 的定义 —— 也就是说**客户端自己根本无法把它们变成钱包余额**。任何把它们当普通道具
|
||||
// 发进背包的路径,玩家拿到的都是一件永远用不掉的"材料",这正是现场的抱怨。
|
||||
//
|
||||
// 所以转换 100% 由服务端负责:发放时识别券的道具号,直接累加到账号钱包,不占背包格。
|
||||
//
|
||||
// 道具号来源(真机 runtime/data/dnf/Script.pvf):
|
||||
//
|
||||
// stackable/cash/chn_Trade_Gold/trade_point.stk id 2681762 `代币券`
|
||||
// stackable/cash/chn_Trade_Gold/trade_point_event.stk id 2681917 `欢乐代币券`
|
||||
package cerashop
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
// TokenCeraVoucherItemID 是 `代币券`(trade_point.stk)。
|
||||
TokenCeraVoucherItemID int64 = 2681762
|
||||
// HappyTokenCeraVoucherItemID 是 `欢乐代币券`(trade_point_event.stk)。
|
||||
HappyTokenCeraVoucherItemID int64 = 2681917
|
||||
)
|
||||
|
||||
// MaxWalletBalance 是三个钱包字段的上限。
|
||||
//
|
||||
// 客户端把这三个值按 **有符号 32 位** 读进字段(反编译 `class0/0x0035` 的 handler
|
||||
// `FUN_01d83c20` 读的是 i32),所以上限必须是 MaxInt32 而不是 MaxInt64:
|
||||
// 超过就会在客户端显示成负数。所有入账都必须在这里挡住。
|
||||
const MaxWalletBalance int64 = math.MaxInt32
|
||||
|
||||
// ErrWalletOverflow 表示入账会让钱包超过 MaxWalletBalance。
|
||||
// 调用方应该把它映射成自己模块的"请求非法"错误,而不是静默截断。
|
||||
var ErrWalletOverflow = errors.New("cera shop: wallet balance would exceed the client limit")
|
||||
|
||||
// VoucherKind 是券对应的钱包字段。
|
||||
type VoucherKind int
|
||||
|
||||
const (
|
||||
// VoucherNone 表示这件道具不是券。
|
||||
VoucherNone VoucherKind = iota
|
||||
// VoucherTokenCera 是代币券,入账到 Wallet.TokenCera。
|
||||
VoucherTokenCera
|
||||
// VoucherHappyTokenCera 是欢乐代币券,入账到 Wallet.HappyTokenCera。
|
||||
VoucherHappyTokenCera
|
||||
)
|
||||
|
||||
// VoucherKindOf 判断一件道具是不是券。不是券返回 VoucherNone。
|
||||
func VoucherKindOf(itemID int64) VoucherKind {
|
||||
switch itemID {
|
||||
case TokenCeraVoucherItemID:
|
||||
return VoucherTokenCera
|
||||
case HappyTokenCeraVoucherItemID:
|
||||
return VoucherHappyTokenCera
|
||||
default:
|
||||
return VoucherNone
|
||||
}
|
||||
}
|
||||
|
||||
// IsVoucher 是上面的布尔简写,给"这批奖励里有没有券"这类判断用。
|
||||
func IsVoucher(itemID int64) bool {
|
||||
return VoucherKindOf(itemID) != VoucherNone
|
||||
}
|
||||
|
||||
// VoucherItemID 返回某类券的道具号(VoucherNone 返回 0)。
|
||||
func VoucherItemID(kind VoucherKind) int64 {
|
||||
switch kind {
|
||||
case VoucherTokenCera:
|
||||
return TokenCeraVoucherItemID
|
||||
case VoucherHappyTokenCera:
|
||||
return HappyTokenCeraVoucherItemID
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// VoucherItemIDs 返回全部券道具号,供"发放前先摘出来"的过滤使用。
|
||||
func VoucherItemIDs() []int64 {
|
||||
return []int64{TokenCeraVoucherItemID, HappyTokenCeraVoucherItemID}
|
||||
}
|
||||
|
||||
// VoucherCounts 是一批待入账的券数量。
|
||||
type VoucherCounts struct {
|
||||
TokenCera int64
|
||||
HappyTokenCera int64
|
||||
}
|
||||
|
||||
// IsZero 表示没有任何券需要入账。
|
||||
func (c VoucherCounts) IsZero() bool {
|
||||
return c.TokenCera == 0 && c.HappyTokenCera == 0
|
||||
}
|
||||
|
||||
// Add 累加另一种券的数量。
|
||||
func (c VoucherCounts) Add(other VoucherCounts) VoucherCounts {
|
||||
return VoucherCounts{
|
||||
TokenCera: c.TokenCera + other.TokenCera,
|
||||
HappyTokenCera: c.HappyTokenCera + other.HappyTokenCera,
|
||||
}
|
||||
}
|
||||
|
||||
// CreditVoucherCounts 把券数量累加到账号钱包。
|
||||
//
|
||||
// 语义与既有邮件领取路径完全一致(那里已经这么做了),本函数只是把它提取成共享实现,
|
||||
// 让任务奖励、礼盒开启、GM 补发都走同一条路,避免各写一套溢出判断。
|
||||
//
|
||||
// - **全有或全无**:任何一项溢出都不写任何字段,账号记录保持原样,返回 ErrWalletOverflow。
|
||||
// - 调用方负责保存 account 与它的 UpdatedAt。
|
||||
// - 负数一律当 0:券数量为负只可能来自上游 bug,不该把余额扣下去。
|
||||
func CreditVoucherCounts(account *dnfrepo.AccountRecord, counts VoucherCounts) (Wallet, error) {
|
||||
if account == nil {
|
||||
return Wallet{}, errors.New("cera shop: account is required")
|
||||
}
|
||||
tokenCera := counts.TokenCera
|
||||
if tokenCera < 0 {
|
||||
tokenCera = 0
|
||||
}
|
||||
happyTokenCera := counts.HappyTokenCera
|
||||
if happyTokenCera < 0 {
|
||||
happyTokenCera = 0
|
||||
}
|
||||
if tokenCera == 0 && happyTokenCera == 0 {
|
||||
return WalletBalance(*account), nil
|
||||
}
|
||||
wallet := WalletBalance(*account)
|
||||
if tokenCera > MaxWalletBalance-wallet.TokenCera {
|
||||
return wallet, ErrWalletOverflow
|
||||
}
|
||||
if happyTokenCera > MaxWalletBalance-wallet.HappyTokenCera {
|
||||
return wallet, ErrWalletOverflow
|
||||
}
|
||||
if tokenCera > 0 {
|
||||
SetTokenCeraBalance(account, wallet.TokenCera+tokenCera)
|
||||
}
|
||||
if happyTokenCera > 0 {
|
||||
SetHappyTokenCeraBalance(account, wallet.HappyTokenCera+happyTokenCera)
|
||||
}
|
||||
return WalletBalance(*account), nil
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package cerashop
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
// 代币券 / 欢乐代币券必须能落成钱包余额,而不是背包里的一件"材料"。
|
||||
// 这里守住券的道具号识别与入账语义(溢出、全有或全无)。
|
||||
|
||||
func TestVoucherKindOfRecognizesBothVouchers(t *testing.T) {
|
||||
cases := []struct {
|
||||
itemID int64
|
||||
want VoucherKind
|
||||
}{
|
||||
{2681762, VoucherTokenCera},
|
||||
{2681917, VoucherHappyTokenCera},
|
||||
{0, VoucherNone},
|
||||
{2681762 - 1, VoucherNone},
|
||||
{2681762 + 1, VoucherNone},
|
||||
{2681917 + 1, VoucherNone},
|
||||
{100050, VoucherNone},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
if got := VoucherKindOf(testCase.itemID); got != testCase.want {
|
||||
t.Fatalf("VoucherKindOf(%d)=%v want=%v", testCase.itemID, got, testCase.want)
|
||||
}
|
||||
if IsVoucher(testCase.itemID) != (testCase.want != VoucherNone) {
|
||||
t.Fatalf("IsVoucher(%d) 与 VoucherKindOf 不一致", testCase.itemID)
|
||||
}
|
||||
}
|
||||
if VoucherItemID(VoucherTokenCera) != 2681762 || VoucherItemID(VoucherHappyTokenCera) != 2681917 ||
|
||||
VoucherItemID(VoucherNone) != 0 {
|
||||
t.Fatal("VoucherItemID 与 VoucherKindOf 不是同一套映射")
|
||||
}
|
||||
if ids := VoucherItemIDs(); len(ids) != 2 || !IsVoucher(ids[0]) || !IsVoucher(ids[1]) {
|
||||
t.Fatalf("VoucherItemIDs=%v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreditVoucherCountsAddsBothWallets(t *testing.T) {
|
||||
account := &dnfrepo.AccountRecord{
|
||||
AccountID: "account-1",
|
||||
Metadata: map[string]string{"account_cera": "700", "account_token_cera": "5"},
|
||||
}
|
||||
wallet, err := CreditVoucherCounts(account, VoucherCounts{TokenCera: 100, HappyTokenCera: 250})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wallet.Cera != 700 || wallet.TokenCera != 105 || wallet.HappyTokenCera != 250 {
|
||||
t.Fatalf("钱包=%+v want cera=700 token=105 happy=250", wallet)
|
||||
}
|
||||
// 点券字段绝不能被券的入账碰到。
|
||||
if got := Balance(*account); got != 700 {
|
||||
t.Fatalf("点券被改动了: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 数量为 0 时是空操作,不该写任何键(否则会给账号留下无意义的新元数据)。
|
||||
func TestCreditVoucherCountsIgnoresZeroAndNegative(t *testing.T) {
|
||||
account := &dnfrepo.AccountRecord{AccountID: "account-1"}
|
||||
wallet, err := CreditVoucherCounts(account, VoucherCounts{})
|
||||
if err != nil || wallet.TokenCera != 0 || wallet.HappyTokenCera != 0 {
|
||||
t.Fatalf("空入账 err=%v wallet=%+v", err, wallet)
|
||||
}
|
||||
if len(account.Metadata) != 0 {
|
||||
t.Fatalf("空入账不该写元数据: %v", account.Metadata)
|
||||
}
|
||||
// 负数只可能来自上游 bug,按 0 处理而不是把余额扣下去。
|
||||
if _, err := CreditVoucherCounts(account, VoucherCounts{TokenCera: -50, HappyTokenCera: -1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wallet := WalletBalance(*account); wallet.TokenCera != 0 || wallet.HappyTokenCera != 0 {
|
||||
t.Fatalf("负数不该扣余额: %+v", wallet)
|
||||
}
|
||||
}
|
||||
|
||||
// 溢出必须整笔拒绝,且**一个字段都不写**(全有或全无)——否则会出现
|
||||
// "代币券溢出失败、欢乐代币券却已经进账"的半截状态。
|
||||
func TestCreditVoucherCountsRefusesOverflowAtomically(t *testing.T) {
|
||||
account := &dnfrepo.AccountRecord{
|
||||
AccountID: "account-1",
|
||||
Metadata: map[string]string{"account_token_cera": "2147483647"},
|
||||
}
|
||||
_, err := CreditVoucherCounts(account, VoucherCounts{TokenCera: 1, HappyTokenCera: 100})
|
||||
if !errors.Is(err, ErrWalletOverflow) {
|
||||
t.Fatalf("代币券溢出应返回 ErrWalletOverflow,实际=%v", err)
|
||||
}
|
||||
if wallet := WalletBalance(*account); wallet.TokenCera != math.MaxInt32 || wallet.HappyTokenCera != 0 {
|
||||
t.Fatalf("溢出时不该写任何字段: %+v", wallet)
|
||||
}
|
||||
|
||||
// 边界:刚好填满到上限是允许的(不是溢出)。
|
||||
account = &dnfrepo.AccountRecord{AccountID: "account-1"}
|
||||
wallet, err := CreditVoucherCounts(account, VoucherCounts{TokenCera: math.MaxInt32})
|
||||
if err != nil || wallet.TokenCera != math.MaxInt32 {
|
||||
t.Fatalf("刚好到上限应当允许: err=%v wallet=%+v", err, wallet)
|
||||
}
|
||||
// 再 +1 就溢出。
|
||||
if _, err := CreditVoucherCounts(account, VoucherCounts{TokenCera: 1}); !errors.Is(err, ErrWalletOverflow) {
|
||||
t.Fatalf("超过上限应溢出,实际=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// nil 账号必须报错而不是 panic:调用方多为事务回调,panic 会连累整条连接。
|
||||
func TestCreditVoucherCountsRejectsNilAccount(t *testing.T) {
|
||||
if _, err := CreditVoucherCounts(nil, VoucherCounts{TokenCera: 1}); err == nil {
|
||||
t.Fatal("nil 账号应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoucherCountsAddAndIsZero(t *testing.T) {
|
||||
if !(VoucherCounts{}).IsZero() {
|
||||
t.Fatal("空 counts 应当是 zero")
|
||||
}
|
||||
if (VoucherCounts{TokenCera: 1}).IsZero() {
|
||||
t.Fatal("非空 counts 不该是 zero")
|
||||
}
|
||||
sum := VoucherCounts{TokenCera: 3}.Add(VoucherCounts{TokenCera: 4, HappyTokenCera: 5})
|
||||
if sum.TokenCera != 7 || sum.HappyTokenCera != 5 {
|
||||
t.Fatalf("Add=%+v", sum)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package cerashop
|
||||
|
||||
import (
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
// WalletCurrency 按名字标识账号钱包里的一个字段,供 GM / 管理接口读写。
|
||||
//
|
||||
// 存在的理由:钱包的三个字段分别落在账号 Metadata 的三个键上,键名是 cerashop 的
|
||||
// 内部细节。GM 接口需要按名字操作它们(尤其是造测试数据:把点券清零、只留代币券),
|
||||
// 所以在这里开一个受控的出口,而不是让调用方去猜 "account_token_cera" 这种字符串。
|
||||
type WalletCurrency string
|
||||
|
||||
const (
|
||||
// CurrencyCera 是点券。
|
||||
CurrencyCera WalletCurrency = "cera"
|
||||
// CurrencyTokenCera 是代币券。
|
||||
CurrencyTokenCera WalletCurrency = "token_cera"
|
||||
// CurrencyHappyTokenCera 是欢乐代币券。
|
||||
CurrencyHappyTokenCera WalletCurrency = "happy_token_cera"
|
||||
)
|
||||
|
||||
// MetadataKey 返回该字段在账号 Metadata 里的键名。
|
||||
func (c WalletCurrency) MetadataKey() (string, bool) {
|
||||
switch c {
|
||||
case CurrencyCera:
|
||||
return accountCeraMetadataKey, true
|
||||
case CurrencyTokenCera:
|
||||
return accountTokenCeraMetadataKey, true
|
||||
case CurrencyHappyTokenCera:
|
||||
return accountHappyTokenCeraMetadataKey, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// Known 表示这是一个合法的钱包字段名。
|
||||
func (c WalletCurrency) Known() bool {
|
||||
_, ok := c.MetadataKey()
|
||||
return ok
|
||||
}
|
||||
|
||||
// BalanceOf 读该字段的当前值(未知字段返回 0,false)。
|
||||
func (c WalletCurrency) BalanceOf(account dnfrepo.AccountRecord) (int64, bool) {
|
||||
switch c {
|
||||
case CurrencyCera:
|
||||
return Balance(account), true
|
||||
case CurrencyTokenCera:
|
||||
return TokenCeraBalance(account), true
|
||||
case CurrencyHappyTokenCera:
|
||||
return HappyTokenCeraBalance(account), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// SetOn 把值写进账号记录(调用方负责保存账号记录)。未知字段返回 false,不做任何改动。
|
||||
//
|
||||
// 值会被夹到 [0, MaxWalletBalance]:客户端按有符号 32 位读这三个字段,超过上限会
|
||||
// 显示成负数,所以这里直接挡住,而不是写进去等客户端出问题。
|
||||
func (c WalletCurrency) SetOn(account *dnfrepo.AccountRecord, value int64) bool {
|
||||
if account == nil || !c.Known() {
|
||||
return false
|
||||
}
|
||||
if value < 0 {
|
||||
value = 0
|
||||
}
|
||||
if value > MaxWalletBalance {
|
||||
value = MaxWalletBalance
|
||||
}
|
||||
switch c {
|
||||
case CurrencyCera:
|
||||
SetBalance(account, value)
|
||||
case CurrencyTokenCera:
|
||||
SetTokenCeraBalance(account, value)
|
||||
case CurrencyHappyTokenCera:
|
||||
SetHappyTokenCeraBalance(account, value)
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in new issue
Block a user