Files
90CNServer/server/internal/services/dnfbridge/cera_shop_purchase.go
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

2136 lines
104 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
dnfcargo "longheng.io/server/internal/modules/dnf/cargo"
dnfcerashop "longheng.io/server/internal/modules/dnf/cerashop"
"longheng.io/server/internal/modules/dnf/dnfenum"
"longheng.io/server/internal/modules/dnf/premium"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
"longheng.io/server/internal/modules/dnf/purchaselimit"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
currentCeraShopRequestHeaderSize = 4
currentCeraShopRequestItemHeader = 7
currentCeraShopRequestItemStride = 15
currentCeraShopRequestItemTail = 6
currentCeraShopRequestMaxItems = 64
currentCeraShopUpdateMsgID = 0x0035
currentCeraShopPaymentModeCera byte = 0
currentCeraShopPaymentModeAvatarCoupon byte = 1
// The current client consumes class1/op415 only after a successful
// character-slot ticket checkout. Persist the newly unlocked limit with
// the account transaction so a selector transition cannot lose the effect.
currentCharacterSlotExtendEffectPendingMetadataKey = "character_slot_extend_effect_pending_limit"
// Current NoPack sub_232A9F0 groups list 7 as pet bodies 0..139,
// pet equipment 140..188, and pet consumables 189..238 (inclusive).
currentCeraShopPetConsumableSlotStart int16 = 189
currentCeraShopPetConsumableSlotEnd int16 = 238
)
var (
errCurrentCeraShopRequestMalformed = errors.New("dnf cera shop purchase request is malformed")
errCurrentCeraShopPaymentUnsupported = errors.New("dnf cera shop payment mode is unsupported")
errCurrentCeraShopCatalogUnavailable = errors.New("dnf cera shop catalog is unavailable")
errCurrentCeraShopProductUnavailable = errors.New("dnf cera shop product is unavailable")
errCurrentCeraShopOwnerUnavailable = errors.New("dnf cera shop selected character is unavailable")
errCurrentCeraShopCeraInsufficient = errors.New("dnf cera shop cera balance is insufficient")
// errCurrentCeraShopGoldInsufficient:金币标价商品的角色金币不够。整笔结账回滚,
// 客户端收到码 13「金币不足」(见 cera_shop_protocol.go 的码表,那是这张表里
// 唯一一句"金币不足",不需要另造文案)。
errCurrentCeraShopGoldInsufficient = errors.New("dnf cera shop character gold is insufficient")
// errCurrentCeraShopMixedPaymentUnsupported:一辆购物车里同时有金币标价商品和
// 点券标价商品。**整笔拒绝,不猜**。
//
// 为什么不"两边各扣各的":客户端的支付方式位只有一个(现场恒为 0 = 点券),混合
// 车在客户端本来就不是一个自洽的状态;而猜错任何一种口径都会变成经济事故
// (少扣 = 白送,多扣 = 玩家花了钱没拿到东西)。拒绝既不会白送也不会多扣,
// 日志里有 commodities 可查,真出现再按现场数据决定口径。
errCurrentCeraShopMixedPaymentUnsupported = errors.New("dnf cera shop cart mixes gold-priced and cera-priced commodities")
// errCurrentCeraShopProductPriceAmbiguous:同一行同时写了金币与 Cera 两个非 0 标价。
// 这是 PVF 数据坏,同样是拒绝而不是二选一。
errCurrentCeraShopProductPriceAmbiguous = errors.New("dnf cera shop product row carries both a gold and a cera price")
// errCurrentCeraShopGiftRequestMalformed:赠送前缀(形态位 + DSTR 名字)本身畸形。
// 与普通购买解析失败分开,日志里才能一眼看出"是赠送包没解析对"。
errCurrentCeraShopGiftRequestMalformed = errors.New("dnf cera shop gift request prefix is malformed")
// errCurrentCeraShopGiftDeliveryUnsupported:赠送形态解析成功,但本服还不能把
// 商品投递给对方。**必须显式拒绝**,绝不能让这样一个包掉进普通购买分支——
// 那会把"送给别人"变成"买给自己",是真正的经济事故。
errCurrentCeraShopGiftDeliveryUnsupported = errors.New("dnf cera shop gift delivery is not implemented")
)
// currentCeraShopGiftRecipientMaxNameBytes 与角色名上限(30 字节 GB18030)取同一个量级;
// 这里只是防畸形长度,真正的名字合法性由 decodeCharacterName + 查库决定。
const currentCeraShopGiftRecipientMaxNameBytes = 30
// currentCeraShopPurchaseRequest follows the current client's sendBuyPacket
// body. Each row starts with seven fixed bytes (payment/category, attribute,
// purchase type and commodity), then two variable tables: u8+5*n avatar
// choices and u8+8*n [package data selection] choices. A six-byte linked-item
// tail closes the row. Fifteen bytes is therefore only the zero-choice minimum,
// not a fixed stride. The first row's first byte remains the checkout payment
// selector used by this account-local implementation.
type currentCeraShopPurchaseRequest struct {
PaymentMode byte
Items []currentCeraShopPurchaseRequestItem
// GiftRecipientName 非空表示这是**赠送形态**的结账:现场抓包
// (2026-09-27 16:14:02,legacy cmd=1 type=64 body_len=28)的 body 是
//
// b0 | 01 | u32 名字长度 | 名字(GB18030) | u8 赠送类型 | b2 b3 ... 原样
// ^^ ^^ ^^^^^^^^^^^ ^^^^^^^^^^^^ ^^^^^^^^^^ ^^^^^^^^^^^^^^
// \___/ \___________________________________________/ \_____________/
// 普通 body 的头两字节 赠送前缀 普通 body 从第 3 字节起
//
// 也就是"把 DSTR+类型 插在普通购物车 body 的第 2 个字节之后,并把第 2 个字节
// 置 1"。这与客户端 CashShopBuyWindow::sendBuyPacket(RVA 0x02E7471A)完全一致:
// 它先写 [edi+0x282C]、再写 [edi+0x2828],后者 == 1 时才追加 WriteDstr(收件人)
// + WriteU8(类型)。普通购买那一包 body[1] = 0(现场 3 包全是 0),所以**形态位在
// 下标 1,不是下标 0**。
//
// 名字与类型已被剥掉,Items/PaymentMode 仍由同一个解析器从**还原后的**普通
// body 里读出来。
GiftRecipientName string
GiftRecipientRaw []byte
// GiftKind 是前缀尾部的 u8(客户端 [edi+0x2834])。现场那一包 = 0。
GiftKind byte
}
// currentCeraShopGiftPrefixFixedSize 是赠送前缀的固定部分:
// [形态位 0][赠送位 1][u32 名字长度][u8 类型] —— 长度字段之外的 7 个字节。
const currentCeraShopGiftPrefixFixedSize = 7
// currentCeraShopGiftPrefix 是拆出来的赠送前缀 + 还原后的普通 body。
type currentCeraShopGiftPrefix struct {
Gift bool
Name string
NameRaw []byte
Kind byte
Cart []byte
}
// splitCurrentCeraShopGiftPrefix 把赠送前缀从 body 上剥下来,并**还原**出一份与普通
// 购买逐字节同形的 body 交给原来的解析器。
//
// 前缀畸形时返回错误——**绝不能让畸形前缀落进普通购买解析器**:那样 body[2] 会被当成
// 行数、commodity 会从一个错位的地方读出来,最后变成一次跟真实原因无关的失败,正是
// 现场 16:14 那次「购买后提示错误」。
func splitCurrentCeraShopGiftPrefix(body []byte) (currentCeraShopGiftPrefix, error) {
// 形态位在**下标 1**。body[0] 两种形态都是 0,不能拿它当判据。
if len(body) < currentCeraShopGiftPrefixFixedSize || body[1] != 1 {
return currentCeraShopGiftPrefix{Cart: body}, nil
}
length := int(binary.LittleEndian.Uint32(body[2:6]))
prefix := currentCeraShopGiftPrefixFixedSize + length
if length <= 0 || length > currentCeraShopGiftRecipientMaxNameBytes || prefix >= len(body) {
return currentCeraShopGiftPrefix{Gift: true}, errCurrentCeraShopGiftRequestMalformed
}
raw := body[6 : 6+length]
if bytes.IndexByte(raw, 0) >= 0 {
return currentCeraShopGiftPrefix{Gift: true}, errCurrentCeraShopGiftRequestMalformed
}
name, err := decodeCharacterName(raw)
// 注意:x/text 的 GB18030 解码器很宽松(0x80/0xFF 这类单字节也能解出来),所以
// name/reject 这一层实际拦不下"乱名字";乱名字会在查库那一步落到"查无此人",
// 由赠送查询那条路径给客户端一个明确回执。
if err != nil || strings.TrimSpace(name) == "" {
return currentCeraShopGiftPrefix{Gift: true}, errCurrentCeraShopGiftRequestMalformed
}
cart := make([]byte, 0, 2+len(body)-prefix)
cart = append(cart, body[0], 0) // 还原普通头的头两字节:赠送位清零
cart = append(cart, body[prefix:]...)
return currentCeraShopGiftPrefix{
Gift: true,
Name: name,
NameRaw: append([]byte(nil), raw...),
Kind: body[6+length],
Cart: cart,
}, nil
}
type currentCeraShopPurchaseRequestItem struct {
CommodityID uint32
AttributeValue byte
AvatarChoices []currentCeraPackageChoice
PackageSelections []currentCeraShopPackageSelection
LinkedItemID uint32
InventorySlot uint16
}
type currentCeraShopPackageSelection struct {
PackageItemID uint32
GroupIndex uint16
ChoiceIndex uint16
}
type currentCeraShopCommitResult struct {
Products []currentCeraShopProduct
Updates []currentCeraShopItemUpdate
MainInventoryExpansion uint16
AvatarInventoryExpansion uint16
PersonalCargoSlotCount uint16
AccountCargoSelectionKey uint16
CharacterSlotLimit uint16
RenameEntitlementsAdded uint16
PetInventoryChanged bool
CeraAfter int64
TokenCera int64
HappyTokenCera int64
PremiumActivations []currentCeraShopPremiumActivation
NameTagActivation *currentNameTagCardActivation
// BuyRestrictNotes 记录本次结账消费掉的商城限购(“维度:键:已购/上限”)。
// 商城道具的限购在道具 PVF 里没有字段,只能看这条日志确认计数真的在走。
BuyRestrictNotes []string
// BuyCountGiftCount 是本次结账后角色的多买多送累计次数(活动未启用时为 0)。
// 仅供 committed 日志核对;真正下发给客户端的是
// sendCurrentCeraShopBuyCountGiftProjection(它重新读一次角色,拿的是权威值)。
BuyCountGiftCount int64
// BuyCountGiftNotes 记录本次跨档发奖(“tier:档位:item:道具号:x数量”)。
BuyCountGiftNotes []string
// BuyCountGiftRewards 是本次跨档发奖的原始数据,供结账收尾时逐条推
// `class0/0x02D2` 奖励弹窗(参考实现就是每拿到一件赠品发一包)。
BuyCountGiftRewards []currentCeraShopBuyCountGiftTier
// PaidHappyTokenCera / PaidTokenCera / PaidCera 是本次结账各钱包实际扣掉的数量
// (支付瀑布的结果)。点券之外的两种是"代币券真的能花掉"的凭据。
PaidHappyTokenCera int64
PaidTokenCera int64
PaidCera int64
// PaidGold 是本次结账从**角色金币**实际扣掉的数量(金币标价商品走这条)。
// 它与上面三个点券钱包并列:是"金币真的被扣了"的唯一凭据,也是赠送投递失败时
// 退款金额的唯一来源。点券标价商品恒为 0。
PaidGold int64
// GoldAfter 是扣完金币之后的角色余额,直接用来发 op14 金币包;PaidGold 为 0 时
// 无意义(不发包)。
GoldAfter int64
// GiftAttachments 是**赠送**结账里本该落进付款方背包、改为邮寄给收件人的商品。
// 只有赠送形态会填,普通购买永远是 nil(所以"赠送变成买给自己"这条红线
// 在类型层面就能被断言到)。
//
// 注意口径:这里只有**买到的商品**(含礼包自动展开出的奖励)。
// 一比一赠品(multibonus)与多买多送的档位奖励**不进这里** —— 活动奖励归付款方。
GiftAttachments []dnfrepo.MailAttachment
// GiftGrantNotes 记录这次赠送投递了什么("commodity:item:xN"),只用于日志核对。
GiftGrantNotes []string
}
// currentCeraShopPremiumActivation is one premium contract activated at
// checkout: the premium type (580+slot for devil perks) and its remaining
// seconds, used for the class0/op66 activation notification.
type currentCeraShopPremiumActivation struct {
PremiumType int64
RemainingSeconds int64
}
// currentCeraShopItemUpdate is an actual changed inventory container. A
// Cera package belongs in list 0, while a direct avatar product belongs in
// list 1. Keep the container with the changed rows so a list-0 item update
// can never make a client interpret an avatar entry as a normal bag row.
type currentCeraShopItemUpdate struct {
ListType byte
Entries []currentItemListEntry
}
type currentCeraShopResolvedPurchase struct {
Product currentCeraShopProduct
AttributeValue byte
MainInventoryUpgradeStage uint16
AvatarInventoryUpgradeStage uint16
PersonalCargoUpgradeTarget uint16
AccountCargoUpgrade bool
CharacterSlotExpansion bool
CharacterRenameEntitlement bool
AvatarCouponItemID int64
// PremiumType > 0 means the purchase is consumed at checkout and activates
// this account-level premium contract instead of entering any container
// (86JP ConsumedOnPurchase). PremiumPackage activates every 魔王契约 devil
// slot for the same duration ([charac premium package]).
PremiumType int64
PremiumDurationSeconds int64
PremiumPackage bool
NameTagDurationSeconds int64
AutoOpenRewards []currentCeraShopAutoOpenReward
}
type currentCeraShopChangedSlot struct {
ListType byte
Slot int16
Product currentCeraShopProduct
// Bonus marks a slot filled by a one-plus-one IPG gift
// (etc/chn_cerashop_multibonus.etc) rather than by the purchased product
// itself; Product keeps the parent commodity for provenance while
// Product.ItemID is the granted bonus item.
Bonus bool
}
func parseCurrentCeraShopPurchaseRequest(body []byte) (currentCeraShopPurchaseRequest, error) {
giftPrefix, err := splitCurrentCeraShopGiftPrefix(body)
if err != nil {
return currentCeraShopPurchaseRequest{}, err
}
body = giftPrefix.Cart
if len(body) < currentCeraShopRequestHeaderSize+currentCeraShopRequestItemStride {
return currentCeraShopPurchaseRequest{}, errCurrentCeraShopRequestMalformed
}
count := int(body[2])
if count == 0 {
count = 1
}
if count < 1 || count > currentCeraShopRequestMaxItems ||
len(body) < currentCeraShopRequestHeaderSize+count*currentCeraShopRequestItemStride {
return currentCeraShopPurchaseRequest{}, errCurrentCeraShopRequestMalformed
}
request := currentCeraShopPurchaseRequest{Items: make([]currentCeraShopPurchaseRequestItem, 0, count)}
cursor := currentCeraShopRequestHeaderSize
for index := 0; index < count; index++ {
if cursor > len(body)-currentCeraShopRequestItemHeader {
return currentCeraShopPurchaseRequest{}, errCurrentCeraShopRequestMalformed
}
itemOffset := cursor
if index == 0 {
request.PaymentMode = body[itemOffset]
}
commodityID := binary.LittleEndian.Uint32(body[itemOffset+3 : itemOffset+7])
if commodityID == 0 {
return currentCeraShopPurchaseRequest{}, errCurrentCeraShopRequestMalformed
}
item := currentCeraShopPurchaseRequestItem{
CommodityID: commodityID,
AttributeValue: body[itemOffset+1],
}
cursor += currentCeraShopRequestItemHeader
if cursor >= len(body) {
return currentCeraShopPurchaseRequest{}, errCurrentCeraShopRequestMalformed
}
avatarCount := int(body[cursor])
cursor++
if avatarCount > currentCeraPackageMaxChoices || avatarCount > (len(body)-cursor)/currentCeraPackageChoiceStride {
return currentCeraShopPurchaseRequest{}, errCurrentCeraShopRequestMalformed
}
item.AvatarChoices = make([]currentCeraPackageChoice, 0, avatarCount)
for choiceIndex := 0; choiceIndex < avatarCount; choiceIndex++ {
itemID := binary.LittleEndian.Uint32(body[cursor : cursor+4])
if itemID == 0 {
return currentCeraShopPurchaseRequest{}, errCurrentCeraShopRequestMalformed
}
item.AvatarChoices = append(item.AvatarChoices, currentCeraPackageChoice{ItemID: itemID, Option: body[cursor+4]})
cursor += currentCeraPackageChoiceStride
}
if cursor >= len(body) {
return currentCeraShopPurchaseRequest{}, errCurrentCeraShopRequestMalformed
}
selectionCount := int(body[cursor])
cursor++
const selectionStride = 8
if selectionCount > currentCeraPackageMaxChoices || selectionCount > (len(body)-cursor)/selectionStride {
return currentCeraShopPurchaseRequest{}, errCurrentCeraShopRequestMalformed
}
item.PackageSelections = make([]currentCeraShopPackageSelection, 0, selectionCount)
for selectionIndex := 0; selectionIndex < selectionCount; selectionIndex++ {
packageItemID := binary.LittleEndian.Uint32(body[cursor : cursor+4])
if packageItemID == 0 {
return currentCeraShopPurchaseRequest{}, errCurrentCeraShopRequestMalformed
}
item.PackageSelections = append(item.PackageSelections, currentCeraShopPackageSelection{
PackageItemID: packageItemID,
GroupIndex: binary.LittleEndian.Uint16(body[cursor+4 : cursor+6]),
ChoiceIndex: binary.LittleEndian.Uint16(body[cursor+6 : cursor+8]),
})
cursor += selectionStride
}
if cursor > len(body)-currentCeraShopRequestItemTail {
return currentCeraShopPurchaseRequest{}, errCurrentCeraShopRequestMalformed
}
item.LinkedItemID = binary.LittleEndian.Uint32(body[cursor : cursor+4])
item.InventorySlot = binary.LittleEndian.Uint16(body[cursor+4 : cursor+6])
cursor += currentCeraShopRequestItemTail
request.Items = append(request.Items, item)
}
if cursor != len(body) {
return currentCeraShopPurchaseRequest{}, errCurrentCeraShopRequestMalformed
}
if giftPrefix.Gift {
request.GiftRecipientName = giftPrefix.Name
request.GiftRecipientRaw = giftPrefix.NameRaw
request.GiftKind = giftPrefix.Kind
}
return request, nil
}
// recordCurrentCeraShopPurchase 记一条商城点券消费记录。
//
// 只在商城一处埋点:go-server 里扣点券只有两条路径——商城结账(本文件)和
// 金库升级(走 cargo owner,通过 alignedcmd.Result.CeraSpent 上报,见 game_aligned.go)。
// 余额取结账事务返回的权威值,不做"扣了多少自己算余额"这种推导。
func (s *Service) recordCurrentCeraShopPurchase(
session *gameSession,
request currentCeraShopPurchaseRequest,
result currentCeraShopCommitResult,
ok bool,
reason string,
bodyLen int,
) {
if s == nil {
return
}
paymentMode := "点券"
if request.PaymentMode == currentCeraShopPaymentModeAvatarCoupon {
paymentMode = "装扮兑换券"
}
requested := make([]map[string]any, 0, len(request.Items))
for _, item := range request.Items {
row := map[string]any{
"commodity_id": item.CommodityID,
}
if item.AttributeValue != 0 {
row["attribute_value"] = item.AttributeValue
}
if item.LinkedItemID != 0 {
row["linked_item_id"] = item.LinkedItemID
}
if item.InventorySlot != 0 {
row["inventory_slot"] = item.InventorySlot
}
if len(item.AvatarChoices) > 0 {
row["avatar_choices"] = len(item.AvatarChoices)
}
if len(item.PackageSelections) > 0 {
row["package_selections"] = len(item.PackageSelections)
}
requested = append(requested, row)
}
fields := map[string]any{
"result": "ok",
"payment_mode": request.PaymentMode,
"payment_name": paymentMode,
"request_items": requested,
"item_count": len(request.Items),
"body_len": bodyLen,
}
if !ok {
fields["result"] = "rejected"
fields["reason"] = reason
s.recordCeraSpend(session, fields)
return
}
granted := make([]map[string]any, 0, len(result.Products))
totalCera := int64(0)
totalGold := int64(0)
for _, product := range result.Products {
row := map[string]any{
"commodity_id": product.CommodityID,
"item_id": product.ItemID,
"count": product.Count,
"section": product.Section,
"cera_price": product.CeraPrice,
}
// 金币标价才写 gold_price:点券商品的这一列恒 0,写进去只会让 177 行商品
// 的记录凭空多一个 0 字段(既有记录的字段集不该被改动污染)。
if product.GoldPrice != 0 {
row["gold_price"] = product.GoldPrice
}
granted = append(granted, row)
totalCera += int64(product.CeraPrice) * int64(product.Count)
totalGold += int64(product.GoldPrice) * int64(product.Count)
}
slots := make([]string, 0, 8)
for _, update := range result.Updates {
for _, entry := range update.Entries {
if len(entry.data) < 2 {
continue
}
slots = append(slots, fmt.Sprintf("%d:%d", update.ListType, int16(binary.LittleEndian.Uint16(entry.data[0:2]))))
}
}
activations := make([]map[string]any, 0, len(result.PremiumActivations))
for _, activation := range result.PremiumActivations {
activations = append(activations, map[string]any{
"premium_type": activation.PremiumType,
"remaining_seconds": activation.RemainingSeconds,
})
}
fields["granted"] = granted
fields["changed_slots"] = slots
// 点券维度的"花了多少 / 之前多少"必须按**实际扣掉的点券**算。
//
// 支付瀑布上线后 `totalCera`(商品标价之和)不再等于点券扣费:券可能承担了全部或
// 部分金额。继续用它会让日志声称"点券消费了整笔标价",并与同一条日志里的新列
// paid_cera 自相矛盾,按 cera_before - cera_after 核算也对不上。
fields["cera_spent"] = result.PaidCera
fields["cera_total_price"] = totalCera
fields["cera_after"] = result.CeraAfter
if request.PaymentMode == currentCeraShopPaymentModeCera {
// 点券付款时余额差就是本次实际扣掉的点券;装扮兑换券付款不扣点券,
// 别给出会误导的 before。
fields["cera_before"] = result.CeraAfter + result.PaidCera
}
fields["token_cera_after"] = result.TokenCera
fields["happy_token_cera_after"] = result.HappyTokenCera
// 支付瀑布里两张券**实际扣了多少 + 扣之前的余额**。
//
// ── 为什么必须记(2026-10-05,现场排查"这单是不是白拿")────────────────
// 改造前这条记录只有 `cera_spent`(点券实际扣除)和两张券的**之后**余额。
// 于是一笔"全额用欢乐代币券支付"的正经消费,在记录里长成这样:
//
// 实扣点券 = 0
// 欢乐代币券余额 = 0 ← 这是"之后"的余额,不是"扣了多少"
//
// 两个 0 摆在一起,看起来就是一分钱没付。现场为了判断那一单到底有没有付钱,
// 只能把服务器上加密封包日志(3GB)拉回来解密,再去 packet_log 的
// game-cera-shop-purchase-committed 里翻 paid_happy_token_cera —— 而那三个字段
// 本来就只写在 packet-log 事件里,商家侧的记录根本看不到。
//
// 注意 `gold_*` 那一路**早就有 before**(见上面 gold_before),券这一路是**漏项**,
// 不是设计取舍。
//
// 只在非 0 时写:与 gold_* 同一个口径 —— 既有记录的字段集不该被无意义地撑大
// (178 行商品里绝大多数用不到券)。字段缺席 = 该项为 0,语义完备。
if result.PaidTokenCera != 0 {
fields["token_cera_spent"] = result.PaidTokenCera
fields["token_cera_before"] = result.TokenCera + result.PaidTokenCera
}
if result.PaidHappyTokenCera != 0 {
fields["happy_token_cera_spent"] = result.PaidHappyTokenCera
fields["happy_token_cera_before"] = result.HappyTokenCera + result.PaidHappyTokenCera
}
// 金币标价商品:与上面三个点券钱包**并列**记录,不要混进 cera_spent ——
// 金币是角色货币、点券是账号货币,混在一起会让"点券消费"这条审计线失真。
if result.PaidGold != 0 {
fields["gold_spent"] = result.PaidGold
fields["gold_after"] = result.GoldAfter
fields["gold_before"] = result.GoldAfter + result.PaidGold
fields["gold_total_price"] = totalGold
}
if len(activations) > 0 {
fields["premium_activations"] = activations
}
if result.MainInventoryExpansion != 0 {
fields["main_inventory_expansion"] = result.MainInventoryExpansion
}
if result.AvatarInventoryExpansion != 0 {
fields["avatar_inventory_expansion"] = result.AvatarInventoryExpansion
}
if result.PersonalCargoSlotCount != 0 {
fields["personal_cargo_slot_count"] = result.PersonalCargoSlotCount
}
if result.AccountCargoSelectionKey != 0 {
fields["account_cargo_selection_key"] = result.AccountCargoSelectionKey
}
if result.CharacterSlotLimit != 0 {
fields["character_slot_limit"] = result.CharacterSlotLimit
}
if result.RenameEntitlementsAdded != 0 {
fields["rename_entitlements_added"] = result.RenameEntitlementsAdded
}
if result.NameTagActivation != nil {
fields["name_tag_item_id"] = result.NameTagActivation.ItemID
fields["name_tag_expire_at"] = result.NameTagActivation.ExpireAt
}
if result.PetInventoryChanged {
fields["pet_inventory_changed"] = true
}
s.recordCeraSpend(session, fields)
}
func (s *Service) handleCurrentCeraShopPurchase(session *gameSession, body []byte) error {
request, err := parseCurrentCeraShopPurchaseRequest(body)
if err != nil {
// 赠送前缀畸形要和普通购买畸形分开记:现场 2026-09-27 16:14 的那一包
// 就是被当成普通购买解析、最后报了一个跟真实原因无关的失败码。
//
// 形态位是**下标 1**(见文件头那张图),不是下标 0 —— 两种形态的 body[0]
// 都是 0,拿它判断等于永远判 false(这条分支早先就是那么写的,形同虚设)。
event := "game-cera-shop-purchase-rejected"
if errors.Is(err, errCurrentCeraShopGiftRequestMalformed) ||
len(body) > 1 && body[1] == 1 {
event = "game-cera-shop-gift-purchase-rejected"
}
s.logGameEvent(session, event, "body_len", len(body), "reason", err)
s.recordCurrentCeraShopPurchase(session, request, currentCeraShopCommitResult{}, false, err.Error(), len(body))
if errors.Is(err, errCurrentCeraShopGiftRequestMalformed) {
// 畸形包不是"这件商品不能送":回 16「出现未知错误」,别让客户端去怪商品。
return s.sendCurrentCeraShopPurchaseFailureWithCode(session, currentCeraShopPurchaseFailureCodeUnknown)
}
return s.sendCurrentCeraShopPurchaseFailure(session)
}
if request.GiftRecipientName != "" {
// 赠送功能总开关(暂时关闭):**先于一切判定**拒绝,不下单、不扣点券、不发邮件。
// op136 已经拦过一次,这里是第二道 —— 不信任客户端是否走过那一步。
if !currentCeraShopGiftEnabled {
if noticeErr := s.sendCurrentCeraShopGiftClosedNotice(session, "cera_shop_gift_purchase_disabled"); noticeErr != nil {
s.logGameEvent(session, "game-cera-shop-gift-closed-notice-failed",
"char_id", session.selectedCharacterID,
"error", noticeErr)
}
s.logGameEvent(session, "game-cera-shop-gift-purchase-rejected",
"char_id", session.selectedCharacterID,
"recipient_name", request.GiftRecipientName,
"item_count", len(request.Items),
"body_len", len(body),
"failure_code", currentCeraShopPurchaseFailureCodeGiftForbidden,
"reason", "gift_feature_disabled")
s.recordCurrentCeraShopPurchase(session, request, currentCeraShopCommitResult{}, false, "gift feature is disabled", len(body))
return s.sendCurrentCeraShopGiftFailure(session, currentCeraShopPurchaseFailureCodeGiftForbidden)
}
// 赠送形态:先做赠送判定(互为好友满 7 天 / 同大区 / 不能送自己 /
// 赠送方账号满 7 天 / 双方基础职业相同)。判定用的是 op64 **自己**的包,
// 不信任客户端是否走过 op136 —— 否则改过的客户端可以直接跳过来下单。
//
// 判定失败必须在**扣钱之前**返回:这是"赠送不能被当成买给自己"之外的第二条
// 经济红线(判定失败却已经扣钱 = 玩家花了钱什么都没拿到)。
giftSessionSender, senderErr := s.currentCeraShopGiftSender(context.Background(), session)
if senderErr != nil {
s.logGameEvent(session, "game-cera-shop-gift-purchase-rejected",
"char_id", session.selectedCharacterID,
"recipient_name", request.GiftRecipientName,
"reason", "sender_unavailable",
"error", senderErr)
s.recordCurrentCeraShopPurchase(session, request, currentCeraShopCommitResult{}, false, senderErr.Error(), len(body))
return s.sendCurrentCeraShopGiftFailure(session, currentCeraShopPurchaseFailureCodeGiftForbidden)
}
giftRecipient, giftErr := s.validateCurrentCeraShopGift(context.Background(), session, request)
if giftErr != nil {
code := currentCeraShopGiftFailureCode(giftErr)
s.logGameEvent(session, "game-cera-shop-gift-purchase-rejected",
"char_id", session.selectedCharacterID,
"recipient_name", request.GiftRecipientName,
"item_count", len(request.Items),
"body_len", len(body),
"failure_code", code,
"reason", giftErr)
s.recordCurrentCeraShopPurchase(session, request, currentCeraShopCommitResult{}, false, giftErr.Error(), len(body))
return s.sendCurrentCeraShopGiftFailure(session, code)
}
// 判定通过:走**和普通购买完全同一套**结账(价钱/限购/多买多送/点券瀑布都不变),
// 商品由 commit 收集成邮件附件(result.GiftAttachments),结账后投递给收件人。
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
catalog, catalogErr := s.currentCeraShopCatalog()
if catalogErr != nil {
s.logGameEvent(session, "game-cera-shop-gift-purchase-rejected",
"char_id", session.selectedCharacterID,
"recipient_name", request.GiftRecipientName,
"reason", catalogErr)
s.recordCurrentCeraShopPurchase(session, request, currentCeraShopCommitResult{}, false, catalogErr.Error(), len(body))
return s.sendCurrentCeraShopGiftFailure(session, currentCeraShopPurchaseFailureCodeGiftForbidden)
}
result, commitErr := s.commitCurrentCeraShopPurchase(ctx, session, catalog, request)
if commitErr != nil {
code := currentCeraShopGiftFailureCode(commitErr)
event := "game-cera-shop-gift-purchase-rejected"
if errors.Is(commitErr, errCurrentPurchaseLimitReached) {
event = "game-cera-shop-purchase-limit-rejected"
}
s.logGameEvent(session, event,
"char_id", session.selectedCharacterID,
"recipient_name", request.GiftRecipientName,
"item_count", len(request.Items),
"body_len", len(body),
"failure_code", code,
"reason", commitErr)
s.recordCurrentCeraShopPurchase(session, request, currentCeraShopCommitResult{}, false, commitErr.Error(), len(body))
if errors.Is(commitErr, errCurrentPurchaseLimitReached) {
return s.sendCurrentCeraShopPurchaseLimitFailure(session)
}
return s.sendCurrentCeraShopGiftFailure(session, code)
}
// 钱已经扣了,从这里开始必须"要么投递成功、要么退款"。
mailID, deliverErr := s.deliverCurrentCeraShopGift(ctx, session, request, result, giftSessionSender, giftRecipient)
if deliverErr != nil {
// 退款**必须换一个新 context**:上面这个 ctx 是给"判定 + 结账 + 投递"共用的
// 3 秒预算,投递失败很可能就是因为它已经超时了。拿同一个(已过期的)
// context 去退款,Checkout 会在第一行 ctx.Err() 上直接返回,
// 于是"钱扣了、东西没给、退款也失败",只剩一行日志。
refundCtx, refundCancel := context.WithTimeout(context.Background(), createWriteTimeout)
refundErr := s.refundCurrentCeraShopGift(refundCtx, session, result, giftSessionSender)
refundCancel()
// 这次结账已经吃掉的限购/多买多送计数与已发的一比一赠品**不会**被回滚
// (它们和钱在同一次结账里提交)。这里把它们一起记进日志:真出现
// delivery-failed 时,运营要能一眼看到"这一单还留下了什么"。
s.logGameEvent(session, "game-cera-shop-gift-delivery-failed",
"char_id", session.selectedCharacterID,
"recipient_name", request.GiftRecipientName,
"recipient_character_id", giftRecipient.characterID,
"gift", currentCeraShopGiftResultSummary(result),
"paid_cera", currentCeraShopGiftPaidSummary(result),
"refund_error", refundErr,
"buy_restrict_consumed", strings.Join(result.BuyRestrictNotes, ","),
"buy_count_gift_count", result.BuyCountGiftCount,
"buy_count_gift_rewards", strings.Join(result.BuyCountGiftNotes, ","),
"error", deliverErr)
s.recordCurrentCeraShopPurchase(session, request, result, false, deliverErr.Error(), len(body))
return s.sendCurrentCeraShopGiftFailure(session, currentCeraShopGiftFailureCode(deliverErr))
}
s.recordCurrentCeraShopPurchase(session, request, result, true, "", len(body))
s.logGameEvent(session, "game-cera-shop-gift-delivered",
"char_id", session.selectedCharacterID,
"recipient_name", request.GiftRecipientName,
"recipient_character_id", giftRecipient.characterID,
"mail_id", mailID,
"gift", currentCeraShopGiftResultSummary(result),
"attachment_count", len(result.GiftAttachments),
"paid_cera", currentCeraShopGiftPaidSummary(result),
"cera_after", result.CeraAfter,
"commodities", currentCeraShopCommoditySummary(result.Products))
if err := s.sendGameUpperRawClass(session, currentCeraShopUpdateMsgID, buildCurrentCeraShopWalletBody(result.CeraAfter, result.TokenCera, result.HappyTokenCera), 0); err != nil {
return err
}
// 赠送形态同样可能买的是金币标价商品(付款方扣金币)。
if err := s.sendCurrentCeraShopGoldWallet(session, result); err != nil {
return err
}
// 付款方背包里**确实变了**的东西也得推给他:一比一赠品、多买多送跨档奖励都落在
// 付款方身上(活动奖励归付款方),不推这一包的话玩家要小退/重建容器才看得见。
for _, update := range result.Updates {
if len(update.Entries) == 0 {
continue
}
body := buildCurrentItemUpdateBody(update.ListType, update.Entries)
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketWalkoutPartyMember), body, 0); err != nil {
return err
}
}
if result.PetInventoryChanged {
petBody, _, _, ok := s.buildCurrentItemListBodyForSession(ctx, session, currentPetInventoryListType)
if !ok {
s.logGameEvent(session, "game-cera-shop-gift-pet-refresh-failed",
"char_id", session.selectedCharacterID,
"reason", "repository_snapshot_unavailable")
} else if err := s.sendCurrentSceneItemList(session, uint16(dnfenum.CmdPacketLeaveParty), petBody); err != nil {
return err
}
}
// 多买多送次数只增在**付款方**身上(活动奖励归付款方),所以这里要刷新。
if err := s.sendCurrentCeraShopBuyCountGiftProjection(ctx, session, "cera_shop_gift_after_ack"); err != nil {
s.logGameEvent(session, "game-cera-shop-buycount-gift-send-failed", "source", "cera_shop_gift_after_ack", "err", err)
}
// 回包与普通购买**同形**:每件商品一条 op64 成功包,bonus 列表照旧带上一比一赠品
// (那是付款方的活动奖励,要在付款方的"商城购物奖励"面板里显示)。
for _, product := range result.Products {
if err := s.sendGameUpperRawClass(
session,
uint16(dnfenum.CmdPacketBuyCerashopItem),
buildCurrentCeraShopPurchaseSuccessBodyWithCount(product.CommodityID, product.Count, catalog.bonusByCommodity[product.CommodityID]...),
dnfproto.DefaultChannelClassification,
); err != nil {
return err
}
}
return nil
}
if request.PaymentMode != currentCeraShopPaymentModeCera && request.PaymentMode != currentCeraShopPaymentModeAvatarCoupon {
s.logGameEvent(session, "game-cera-shop-purchase-rejected",
"body_len", len(body), "item_count", len(request.Items), "payment_mode", request.PaymentMode,
"reason", errCurrentCeraShopPaymentUnsupported)
s.recordCurrentCeraShopPurchase(session, request, currentCeraShopCommitResult{}, false, errCurrentCeraShopPaymentUnsupported.Error(), len(body))
return s.sendCurrentCeraShopPurchaseFailure(session)
}
catalog, err := s.currentCeraShopCatalog()
if err != nil {
s.logGameEvent(session, "game-cera-shop-purchase-rejected", "body_len", len(body), "reason", err)
s.recordCurrentCeraShopPurchase(session, request, currentCeraShopCommitResult{}, false, err.Error(), len(body))
return s.sendCurrentCeraShopPurchaseFailure(session)
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
result, err := s.commitCurrentCeraShopPurchase(ctx, session, catalog, request)
if err != nil {
event := "game-cera-shop-purchase-rejected"
if errors.Is(err, errCurrentPurchaseLimitReached) {
// PVF 限购/商城限购超限单独记一条,便于与“点券不足/商品不可买”区分开。
event = "game-cera-shop-purchase-limit-rejected"
}
s.logGameEvent(session, event,
"body_len", len(body), "item_count", len(request.Items), "reason", err)
s.recordCurrentCeraShopPurchase(session, request, currentCeraShopCommitResult{}, false, err.Error(), len(body))
if errors.Is(err, errCurrentPurchaseLimitReached) {
return s.sendCurrentCeraShopPurchaseLimitFailure(session)
}
// 钱不够(点券/代币券整条瀑布都不够)必须回 90CN 的 3「点券不足,请充值后
// 再次尝试」,不能沿用兜底的 4「无法购买物品」—— 现场"购买之后提示错误"
// 就是这个错位:账号点券为 0 时玩家看到的是"无法购买物品",看不出缺钱。
if errors.Is(err, dnfcerashop.ErrWalletInsufficient) || errors.Is(err, dnfcerashop.ErrCeraInsufficient) {
return s.sendCurrentCeraShopPurchaseFailureWithCode(session, currentCeraShopPurchaseFailureCodeInsufficientCera)
}
// 金币标价商品用的是角色金币,不是点券:缺钱也要说"金币不足"(码 13),
// 不能回"点券不足"—— 玩家会去充点券,然后发现还是买不了。
if errors.Is(err, errCurrentCeraShopGoldInsufficient) {
return s.sendCurrentCeraShopPurchaseFailureWithCode(session, currentCeraShopPurchaseFailureCodeInsufficientGold)
}
return s.sendCurrentCeraShopPurchaseFailure(session)
}
// 结账已经落库,从这里开始无论后续发包是否成功,这条消费都必须留在记录里。
s.recordCurrentCeraShopPurchase(session, request, result, true, "", len(body))
if result.NameTagActivation != nil {
activation := result.NameTagActivation
s.logGameEvent(session, "game-name-tag-card-applied",
"char_id", session.selectedCharacterID,
"action", activation.Action,
"item_id", activation.ItemID,
"prev_item_id", activation.PreviousItemID,
"expire_time", activation.ExpireAt,
"prev_expire_time", activation.PreviousExpire)
}
// 装扮栏扩展券是商城列表里唯一"由客户端现算商品号"的格子:当前客户端的
// sub_16A03C0(ECX=角色对象) 读取装扮栏扩展阶段后直接返回 0x190F4+阶段 这张券
// (0x190F4..0x19102 = 1..15 阶段, 0x197DF..0x197E3 = 16..20 阶段),所以它在
// 收到 class1/op64 购买成功回包时会立刻重建可见商品列表,并重新挑一次"下一阶段"。
// 因此新阶段必须先于回包发布:先发回包的话,客户端重建列表时读到的仍是旧阶段,
// 页面上依旧是刚买过的那张券,用户再点一次就是"买已拥有的阶段"→ 服务器拒绝,
// 也就是"商城不实时刷新下一阶段"的现场。旧版 86JP 服务端在
// CeraShopHandler 里对同一现象的处理就是先发 ITEM_LIST 再发购买回包
// ("publish the new avatar expansion value first so that rebuild selects the
// next stage")。背包/金库扩展券不依赖这一步,因为它们的券在商城里是多行静态
// 列出的,不靠阶段推导,所以这里也只提前装扮栏这一张表。
avatarInventoryRefreshedBeforeAck := false
if result.AvatarInventoryExpansion != 0 {
avatarBody, _, _, avatarOK := s.buildCurrentItemListBodyForSession(ctx, session, currentAvatarInventoryListType)
switch {
case !avatarOK:
// 快照失败绝不拦回包:交易已经落库,回包必须发出去,否则客户端一直停在
// "正在购买",用户重试就是二次扣点。下面 ACK 之后的同一条刷新会重试。
s.logGameEvent(session, "game-cera-shop-avatar-inventory-refresh-deferred",
"char_id", session.selectedCharacterID,
"reason", "repository_snapshot_unavailable")
default:
if err := s.sendCurrentSceneItemList(session, uint16(dnfenum.CmdPacketLeaveParty), avatarBody); err != nil {
return err
}
avatarInventoryRefreshedBeforeAck = true
s.logGameEvent(session, "game-cera-shop-avatar-inventory-refresh-before-ack",
"char_id", session.selectedCharacterID,
"list_type", currentAvatarInventoryListType,
"avatar_inventory_expansion", result.AvatarInventoryExpansion,
"body_len", len(avatarBody),
"reason", "client_rebuilds_cerashop_product_list_on_purchase_ack")
}
}
// 账号金库扩容券必须和装扮栏扩展券同一条口径:客户端是在**处理 class1/op64 购买回包
// 的那一刻**重建仓库/金库面板并按当前容量铺格子的。回包之后再发 list 12 只更新行数据,
// 不会重建格子——现场 2026-09-18:买了"扩大帐号金库"之后格子还画着锁、实际能放东西,
// 切一下仓库页(重建面板)才正常。所以这里先把带新等级的 list 12 发出去,再发回包。
accountCargoRefreshedBeforeAck := false
if result.AccountCargoSelectionKey != 0 {
cargoBody, _, _, cargoOK := s.buildCurrentItemListBodyForSession(ctx, session, 12)
switch {
case !cargoOK:
// 和装扮栏一样:快照失败绝不拦回包(交易已落库),ACK 之后那条刷新会重试。
s.logGameEvent(session, "game-cera-shop-account-cargo-refresh-deferred",
"char_id", session.selectedCharacterID,
"reason", "repository_snapshot_unavailable")
default:
if err := s.sendCurrentSceneItemList(session, uint16(dnfenum.CmdPacketLeaveParty), cargoBody); err != nil {
return err
}
accountCargoRefreshedBeforeAck = true
s.logGameEvent(session, "game-cera-shop-account-cargo-refresh-before-ack",
"char_id", session.selectedCharacterID,
"list_type", 12,
"account_cargo_level", result.AccountCargoSelectionKey,
"body_len", len(cargoBody),
"reason", "client_rebuilds_vault_panel_on_purchase_ack")
}
}
// 买满限购的商品必须在**购买回包之前**告诉客户端:当前客户端的商城列表是在收到
// class1/op64 回包时重建的,通知晚于回包就会出现"这一格还在、但再买就被拒"的现象。
if err := s.sendCurrentCeraShopBuyRestrictProjection(ctx, session, "cera_shop_purchase_before_ack"); err != nil {
s.logGameEvent(session, "game-cera-shop-buy-restrict-send-failed", "err", err)
}
for _, product := range result.Products {
if err := s.sendGameUpperRawClass(
session,
uint16(dnfenum.CmdPacketBuyCerashopItem),
buildCurrentCeraShopPurchaseSuccessBodyWithCount(product.CommodityID, product.Count, catalog.bonusByCommodity[product.CommodityID]...),
dnfproto.DefaultChannelClassification,
); err != nil {
return err
}
}
// 多买多送跨档拿到的赠品:**必须紧跟购买回包**推 `class0/0x02D2` 奖励弹窗。
//
// 时序是这条功能的命门:客户端的"购买结果"窗口是在收到 class1/op64 回包时建立的,
// 0x02D2 是挂到那个窗口上、从它下面**滑下来**的"奖励活动"面板。发晚了(比如排到
// 道具列表/钱包/次数通知之后)窗口已经收尾,面板就没有宿主可挂 —— 现场
// 2026-09-19 就是这样:包发出去了、日志全对,客户端什么都不弹。
//
// 86JP 参考实现同样是"每条购物车行发出 ACK 之后、紧跟一包 0x02D2"
// (CeraShopHandler.SendPurchaseAckAndBonusAsync 的 foreach)。
//
// 纯展示:失败只记日志,不让一次已经落库的结账变成失败。
for _, reward := range result.BuyCountGiftRewards {
if err := s.sendCurrentCeraShopBonusItemPopup(session, reward.ItemID, reward.Count, "cera_shop_buy_count_gift"); err != nil {
s.logGameEvent(session, "game-cera-shop-bonus-item-send-failed",
"source", "cera_shop_buy_count_gift", "item_id", reward.ItemID, "err", err)
}
}
// The current client commits the Cera-shop result before it applies the
// typed actor refresh. Sending mode0 before the class1/op64 success ACK can
// race the shop UI owner and leave endpoint 30 empty until the next login.
if result.NameTagActivation != nil {
if err := s.currentSendNameTagRefresh(ctx, session); err != nil {
s.logGameEvent(session, "game-name-tag-card-refresh-failed", "err", err)
}
}
notifiedPremiumActivations := 0
devilPremiumActivated := false
crystalPremiumActivated := false
blackDiamondActivated := false
privateStorePremiumActivated := false
// Current EXE class0/op66 (sub_1D61460, sub-op 2) reads a one-byte premium
// type. Ordinary PVF premium types use that route. Devil service slots are
// internal account types 580..587; sending them here truncates the type to
// 68..75 and displays unrelated Korean tarot-card effects.
for _, activation := range result.PremiumActivations {
if premium.IsDevilSlotType(activation.PremiumType) {
devilPremiumActivated = true
}
if activation.PremiumType == premium.TypeCrystal {
crystalPremiumActivated = true
}
if premium.IsBlackDiamondStorageType(activation.PremiumType) {
blackDiamondActivated = true
}
if activation.PremiumType == premium.TypePrivateStore {
privateStorePremiumActivated = true
}
wireType, canNotify := currentPremiumActivationWireType(activation.PremiumType)
if !canNotify {
continue
}
if err := s.sendGameUpperRawClass(
session,
currentPremiumActivatedMsgID,
buildCurrentPremiumActivatedBody(wireType, activation.RemainingSeconds),
0,
); err != nil {
return err
}
notifiedPremiumActivations++
}
if blackDiamondActivated {
if err := s.refreshCurrentBlackDiamondAfterActivation(ctx, session, "cera_shop_purchase_after_black_diamond_activation"); err != nil {
return err
}
}
if privateStorePremiumActivated {
if err := s.currentSendPrivateStoreActorRefresh(ctx, session, "cera_shop_purchase_after_private_store_activation"); err != nil {
return err
}
}
if crystalPremiumActivated {
// Current sub_1D61460 records premium type 97, but the crystal panel is
// rebuilt by the raw class0/op898 handler sub_1E7FAD0. Without this
// immediate follow-up a purchase made after scene bootstrap remains
// locally disabled and the client never emits its op535 selection.
if err := s.sendCurrentCrystalContractState(session, "cera_shop_purchase_after_crystal_contract_activation"); err != nil {
return err
}
}
for _, update := range result.Updates {
if len(update.Entries) == 0 {
continue
}
body := buildCurrentItemUpdateBody(update.ListType, update.Entries)
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketWalkoutPartyMember), body, 0); err != nil {
return err
}
}
if result.PersonalCargoSlotCount != 0 || result.AccountCargoSelectionKey != 0 {
// 仓库/金库容量变了:先重建仓库底板再发列表。当前 EXE 的面板格子是建面板时按容量
// 铺的,只更新列表数据不会把新解锁的格子解锁(现场 2026-09-18:买了"扩大帐号金库"
// 之后格子还画着锁、实际能放东西,切一下仓库页才正常)。
if err := s.sendCurrentCargoPadResetBeforeItemLists(
session,
"cera_shop_purchase_after_ack_before_cargo_expansion_lists",
); err != nil {
return err
}
}
if result.MainInventoryExpansion != 0 {
mainBody, _, _, ok := s.buildCurrentItemListBodyForSession(ctx, session, currentExeInventoryMainListType)
if !ok {
return errCurrentCeraShopOwnerUnavailable
}
if err := s.sendCurrentSceneItemList(session, uint16(dnfenum.CmdPacketLeaveParty), mainBody); err != nil {
return err
}
}
if result.AvatarInventoryExpansion != 0 && !avatarInventoryRefreshedBeforeAck {
avatarBody, _, _, ok := s.buildCurrentItemListBodyForSession(ctx, session, currentAvatarInventoryListType)
if !ok {
return errCurrentCeraShopOwnerUnavailable
}
if err := s.sendCurrentSceneItemList(session, uint16(dnfenum.CmdPacketLeaveParty), avatarBody); err != nil {
return err
}
}
if result.PersonalCargoSlotCount != 0 {
cargoBody, _, _, ok := s.buildCurrentItemListBodyForSession(ctx, session, currentExeInventoryPersonalCargoListType)
if !ok {
return errCurrentCeraShopOwnerUnavailable
}
if err := s.sendCurrentSceneItemList(session, uint16(dnfenum.CmdPacketLeaveParty), cargoBody); err != nil {
return err
}
}
if result.AccountCargoSelectionKey != 0 && !accountCargoRefreshedBeforeAck {
cargoBody, _, _, ok := s.buildCurrentItemListBodyForSession(ctx, session, 12)
if !ok {
return errCurrentCeraShopOwnerUnavailable
}
if err := s.sendCurrentSceneItemList(session, uint16(dnfenum.CmdPacketLeaveParty), cargoBody); err != nil {
return err
}
}
if result.PetInventoryChanged {
petBody, _, _, ok := s.buildCurrentItemListBodyForSession(ctx, session, currentPetInventoryListType)
if !ok {
return errCurrentCeraShopOwnerUnavailable
}
if err := s.sendCurrentSceneItemList(session, uint16(dnfenum.CmdPacketLeaveParty), petBody); err != nil {
return err
}
}
if devilPremiumActivated {
if err := s.sendCurrentPremiumServiceState(session, "cera_shop_purchase_after_devil_contract_activation", currentPremiumServiceTypeForSession(session)); err != nil {
return err
}
}
if err := s.sendGameUpperRawClass(session, currentCeraShopUpdateMsgID, buildCurrentCeraShopWalletBody(result.CeraAfter, result.TokenCera, result.HappyTokenCera), 0); err != nil {
return err
}
if err := s.sendCurrentCeraShopGoldWallet(session, result); err != nil {
return err
}
// 多买多送已购买次数(类 0 通知 1035)。客户端没有"请求次数"的上行包,纯靠服务端推;
// 窗口每次打开都会重读这个值。这里重新加载一次角色取权威次数(事务已提交)。
// 纯展示:失败只记日志,绝不让一次购买因为推送失败而变成失败。
if err := s.sendCurrentCeraShopBuyCountGiftProjection(ctx, session, "cera_shop_purchase_after_ack"); err != nil {
s.logGameEvent(session, "game-cera-shop-buycount-gift-send-failed", "source", "cera_shop_purchase_after_ack", "err", err)
}
if result.RenameEntitlementsAdded != 0 {
if err := s.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketUseRenameCard), nil); err != nil {
return err
}
}
s.logGameEvent(session, "game-cera-shop-purchase-committed",
"item_count", len(result.Products), "container_update_count", len(result.Updates), "main_inventory_expansion", result.MainInventoryExpansion, "avatar_inventory_expansion", result.AvatarInventoryExpansion, "personal_cargo_slot_count", result.PersonalCargoSlotCount, "account_cargo_selection_key", result.AccountCargoSelectionKey, "cera_after", result.CeraAfter, "premium_activation_count", len(result.PremiumActivations),
"premium_notification_count", notifiedPremiumActivations,
"crystal_state_refreshed", crystalPremiumActivated,
"private_store_license_notified", currentCeraShopActivationContains(result.PremiumActivations, premium.TypePrivateStore),
// 商城限购:本次消费掉的计数(空 = 该商品不限购)。
"buy_restrict", strings.Join(result.BuyRestrictNotes, ","),
// 多买多送:本次结账后的累计次数与跨档发奖(空 = 本次没有商品参与该活动)。
"buy_count_gift_count", result.BuyCountGiftCount,
"buy_count_gift", strings.Join(result.BuyCountGiftNotes, ","),
// 本次结账的商品号。**必须记**:多买多送/限购都只对特定商品生效,
// 没有这一列就只能去翻收包 hex 才能判断"为什么没计数"(现场 2026-09-19 踩过)。
"commodities", currentCeraShopCommoditySummary(result.Products),
// 支付瀑布:各钱包实际扣掉多少(线上验收"代币券真的能花"就是看这三个数)。
"paid_happy_token_cera", result.PaidHappyTokenCera,
"paid_token_cera", result.PaidTokenCera,
"paid_cera", result.PaidCera,
// 金币标价商品实际扣掉的角色金币。**必须记**:没有这一列就无法从日志回答
// "这单到底扣没扣金币",而 2026-10-01 的现场正是卡在这个问题上。
"paid_gold", result.PaidGold,
"gold_after", result.GoldAfter,
"ack_body", "current_exe_op64_sub_CD9490", "item_update_body", "current_exe_op14_raw77", "cera_body", "current_exe_op53_u8_u32_u32_u32", "premium_body", "current_exe_class0_op66_u8_types_only_internal_devil_slots_suppressed")
return nil
}
// currentCeraShopCommoditySummary 把本次结账的商品号拼成 "108722,104412" 这样的串。
//
// 商城的多买多送与限购都**只对特定商品**生效(多买多送更是一张 112 个商品号的
// 白名单)。现场 2026-09-19 排查"为什么买了不计数"时,日志里没有商品号,只能去翻
// 收包 hex 手工解 —— 所以这一列是必须的,不是为了好看。
func currentCeraShopCommoditySummary(products []currentCeraShopProduct) string {
if len(products) == 0 {
return ""
}
parts := make([]string, 0, len(products))
for _, product := range products {
parts = append(parts, strconv.FormatUint(uint64(product.CommodityID), 10))
}
return strings.Join(parts, ",")
}
func currentCeraShopActivationContains(activations []currentCeraShopPremiumActivation, premiumType int64) bool {
for _, activation := range activations {
if activation.PremiumType == premiumType {
return true
}
}
return false
}
func (s *Service) commitCurrentCeraShopPurchase(
ctx context.Context,
session *gameSession,
catalog *pvfCeraShopCatalog,
request currentCeraShopPurchaseRequest,
) (currentCeraShopCommitResult, error) {
if s == nil {
return currentCeraShopCommitResult{}, errCurrentCeraShopOwnerUnavailable
}
// Settings records are routed by their scope while wallet/inventory rows
// are routed by character id. Those keys can live on different database
// shards, so serialize this checkout owner and compensate the saved header
// if the character settlement rejects; do not pretend the two shards share
// one SQL transaction.
s.ceraShopPurchaseMu.Lock()
defer s.ceraShopPurchaseMu.Unlock()
if session == nil || session.selectedCharacterID == 0 || catalog == nil || catalog.items == nil || len(request.Items) == 0 {
return currentCeraShopCommitResult{}, errCurrentCeraShopOwnerUnavailable
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.CharacterSettlement == nil || repositories.RentalAssets == nil {
return currentCeraShopCommitResult{}, errCurrentCeraShopOwnerUnavailable
}
// 赠送形态:本该落进付款方背包的**商品**改为收集成邮件附件
// (见 cera_shop_gift_delivery.go)。判定用 request.GiftRecipientName,
// 而不是任何客户端传来的"形态位"—— 名字是唯一经过了严格解析(长度/编码/非空)
// 的赠送凭据。
giftDelivery := strings.TrimSpace(request.GiftRecipientName) != ""
giftAttachments := make([]dnfrepo.MailAttachment, 0, 2)
giftGrantNotes := make([]string, 0, 2)
purchases := make([]currentCeraShopResolvedPurchase, 0, len(request.Items))
// totalCost 是点券标价之和(走 Checkout 的 Cost + 支付瀑布);
// totalGoldCost 是金币标价之和(只能在事务回调里扣角色金币,见下面 Project)。
// 一次结账只允许其中一种非 0(见下面的混合车守卫)。
var totalCost uint64
var totalGoldCost uint64
switch request.PaymentMode {
case currentCeraShopPaymentModeCera:
case currentCeraShopPaymentModeAvatarCoupon:
// The current client puts every selected avatar in the same 15-byte
// cart-row format. Coupon checkout consumes one matching voucher per
// row, atomically, so multi-select must use the same path as a Cera
// cart instead of being rejected as a one-item-only purchase.
default:
return currentCeraShopCommitResult{}, errCurrentCeraShopPaymentUnsupported
}
// Any purchased item can be a premiumlist contract item (86JP
// IsContractItem). The premium catalog is loaded lazily; both catalogs
// come from the same runtime archive in production. The two contract
// sections are meaningless without it and fail closed, while ordinary
// sections tolerate a load failure (logged) and keep their normal
// delivery path.
var premiumCatalog *currentPremiumCatalog
premiumCatalogFailed := false
loadPremiumCatalog := func() error {
if premiumCatalog != nil {
return nil
}
loaded, err := s.currentPremiumCatalog()
if err != nil {
return err
}
premiumCatalog = loaded
return nil
}
for _, requestItem := range request.Items {
product, found := catalog.Product(requestItem.CommodityID)
if !found {
return currentCeraShopCommitResult{}, fmt.Errorf("%w: commodity=%d", errCurrentCeraShopProductUnavailable, requestItem.CommodityID)
}
product, err := catalog.resolvePurchase(product)
if err != nil {
return currentCeraShopCommitResult{}, err
}
resolved := currentCeraShopResolvedPurchase{Product: product, AttributeValue: requestItem.AttributeValue}
if request.PaymentMode == currentCeraShopPaymentModeAvatarCoupon {
couponItemID, err := catalog.resolveAvatarCouponItemID(product)
if err != nil {
return currentCeraShopCommitResult{}, err
}
resolved.AvatarCouponItemID = couponItemID
} else {
// 一行只能有一种标价:金币或 Cera。
//
// 改造前这里**只看 CeraPrice**,金币标价行(Cera=0)因此被当成免费商品,
// 这是现场"买了金币标价道具不扣金币"的直接原因。现在金币列也进来了。
if product.GoldPrice != 0 && product.CeraPrice != 0 {
return currentCeraShopCommitResult{}, fmt.Errorf("%w: commodity=%d gold=%d cera=%d",
errCurrentCeraShopProductPriceAmbiguous, product.CommodityID, product.GoldPrice, product.CeraPrice)
}
if product.GoldPrice != 0 {
if uint64(product.GoldPrice) > math.MaxUint64-totalGoldCost {
return currentCeraShopCommitResult{}, errCurrentCeraShopProductUnavailable
}
totalGoldCost += uint64(product.GoldPrice)
} else {
if uint64(product.CeraPrice) > math.MaxUint64-totalCost {
return currentCeraShopCommitResult{}, errCurrentCeraShopProductUnavailable
}
totalCost += uint64(product.CeraPrice)
}
}
switch {
case product.Section == "selectable character premium":
if err := loadPremiumCatalog(); err != nil {
return currentCeraShopCommitResult{}, fmt.Errorf("%w: premium catalog: %v", errCurrentCeraShopProductUnavailable, err)
}
slot, ok := premiumCatalog.devilSlots[product.CommodityID]
if !ok {
return currentCeraShopCommitResult{}, fmt.Errorf("%w: commodity=%d is not a purchasable devil contract slot", errCurrentCeraShopProductUnavailable, product.CommodityID)
}
resolved.PremiumType = premium.DevilSlotType(slot.Slot)
resolved.PremiumDurationSeconds = slot.Days * 86400
purchases = append(purchases, resolved)
continue
case product.Section == "charac premium package":
if product.PremiumPackageDays == 0 {
return currentCeraShopCommitResult{}, fmt.Errorf("%w: commodity=%d has no contract day count", errCurrentCeraShopProductUnavailable, product.CommodityID)
}
resolved.PremiumPackage = true
resolved.PremiumDurationSeconds = int64(product.PremiumPackageDays) * 86400
purchases = append(purchases, resolved)
continue
}
if err := loadPremiumCatalog(); err != nil {
if !premiumCatalogFailed {
premiumCatalogFailed = true
s.logPacketEvent("game-cera-shop-premium-catalog-unavailable", "error", err)
}
} else if contract, ok := premiumCatalog.contractsByItem[int64(product.ItemID)]; ok {
resolved.PremiumType = contract.PremiumType
resolved.PremiumDurationSeconds = contract.DurationSeconds
purchases = append(purchases, resolved)
continue
}
definition, definitionErr := catalog.items.ResolveItem(product.ItemID)
if definitionErr != nil {
return currentCeraShopCommitResult{}, fmt.Errorf("%w: commodity=%d item=%d: %v", errCurrentCeraShopProductUnavailable, product.CommodityID, product.ItemID, definitionErr)
}
autoOpenRewards, isAutoOpenPackage, autoOpenErr := resolveCurrentCeraShopAutoOpenRewards(
catalog,
definition,
product.Count,
requestItem.AvatarChoices,
requestItem.PackageSelections,
)
if autoOpenErr != nil {
return currentCeraShopCommitResult{}, fmt.Errorf("%w: commodity=%d item=%d: %v", errCurrentCeraShopProductUnavailable, product.CommodityID, product.ItemID, autoOpenErr)
}
if isAutoOpenPackage {
resolved.AutoOpenRewards = autoOpenRewards
purchases = append(purchases, resolved)
continue
}
if durationSeconds, isNameTag := currentNameTagCardDuration(catalog.source, definition); isNameTag {
resolved.NameTagDurationSeconds = durationSeconds
purchases = append(purchases, resolved)
continue
}
if catalog.isCharacterSlotExpansionTicket(definition) {
if product.Count != 1 {
return currentCeraShopCommitResult{}, fmt.Errorf("%w: commodity=%d character slot expansions require count=1", errCurrentCeraShopProductUnavailable, product.CommodityID)
}
resolved.CharacterSlotExpansion = true
purchases = append(purchases, resolved)
continue
}
if autoUse, isRenameCard := catalog.characterRenameCardKind(definition); isRenameCard && autoUse {
if product.Count != 1 {
return currentCeraShopCommitResult{}, fmt.Errorf("%w: commodity=%d character rename cards require count=1", errCurrentCeraShopProductUnavailable, product.CommodityID)
}
resolved.CharacterRenameEntitlement = true
purchases = append(purchases, resolved)
continue
}
if stage, isMainInventoryUpgrade := currentCeraShopMainInventoryUpgradeStage(definition); isMainInventoryUpgrade {
if stage == 0 {
return currentCeraShopCommitResult{}, fmt.Errorf("%w: commodity=%d item=%d has an unsupported main inventory upgrade tier", errCurrentCeraShopProductUnavailable, product.CommodityID, product.ItemID)
}
if product.Count != 1 {
return currentCeraShopCommitResult{}, fmt.Errorf("%w: commodity=%d main inventory upgrades require count=1", errCurrentCeraShopProductUnavailable, product.CommodityID)
}
resolved.MainInventoryUpgradeStage = stage
}
if stage, isAvatarInventoryUpgrade, avatarUpgradeErr := catalog.avatarInventoryUpgradeStage(definition); avatarUpgradeErr != nil {
return currentCeraShopCommitResult{}, fmt.Errorf("%w: commodity=%d item=%d avatar inventory upgrade: %v", errCurrentCeraShopProductUnavailable, product.CommodityID, product.ItemID, avatarUpgradeErr)
} else if isAvatarInventoryUpgrade {
if stage == 0 || product.Count != 1 {
return currentCeraShopCommitResult{}, fmt.Errorf("%w: commodity=%d avatar inventory upgrades require one supported stage", errCurrentCeraShopProductUnavailable, product.CommodityID)
}
resolved.AvatarInventoryUpgradeStage = stage
}
if target, isPersonalCargoUpgrade := catalog.personalCargoUpgradeTarget(definition); isPersonalCargoUpgrade {
if target == 0 {
return currentCeraShopCommitResult{}, fmt.Errorf("%w: commodity=%d item=%d has an unsupported personal cargo upgrade tier", errCurrentCeraShopProductUnavailable, product.CommodityID, product.ItemID)
}
if product.Count != 1 {
return currentCeraShopCommitResult{}, fmt.Errorf("%w: commodity=%d personal cargo upgrades require count=1", errCurrentCeraShopProductUnavailable, product.CommodityID)
}
resolved.PersonalCargoUpgradeTarget = target
}
if catalog.isAccountCargoUpgradeTool(definition) {
if product.Count != 1 {
return currentCeraShopCommitResult{}, fmt.Errorf("%w: commodity=%d account cargo upgrades require count=1", errCurrentCeraShopProductUnavailable, product.CommodityID)
}
resolved.AccountCargoUpgrade = true
}
purchases = append(purchases, resolved)
}
if totalCost > math.MaxInt64 {
return currentCeraShopCommitResult{}, errCurrentCeraShopProductUnavailable
}
if totalGoldCost > math.MaxInt64 {
return currentCeraShopCommitResult{}, errCurrentCeraShopProductUnavailable
}
// 混合车:整笔拒绝(在**扣任何钱之前**)。见 errCurrentCeraShopMixedPaymentUnsupported。
if totalCost != 0 && totalGoldCost != 0 {
return currentCeraShopCommitResult{}, fmt.Errorf("%w: gold=%d cera=%d commodities=%s",
errCurrentCeraShopMixedPaymentUnsupported, totalGoldCost, totalCost,
currentCeraShopCommodityIDList(request.Items))
}
goldCost := int64(totalGoldCost)
if giftDelivery {
// 赠送只能送"邮箱能表达成实例行"的道具。任何**结账即消费**的商品
// (契约时长/改名卡/各种扩容/装扮券兑换)都只作用在付款方自己身上,
// 放行就等于"送给别人 = 给自己买"。在**扣钱之前**整笔拒绝,
// 客户端收到的是「该商品无法赠送」。
for _, purchase := range purchases {
if currentCeraShopGiftConsumedAtCheckout(purchase) {
return currentCeraShopCommitResult{}, fmt.Errorf(
"%w: commodity=%d item=%d is consumed at checkout and cannot be gifted",
errCurrentCeraShopGiftNotGiftable, purchase.Product.CommodityID, purchase.Product.ItemID)
}
}
}
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
accountID := strings.TrimSpace(s.accountIDForSession(session))
checkoutOwner, err := dnfcerashop.NewOwner(repositories)
if err != nil {
return currentCeraShopCommitResult{}, errCurrentCeraShopOwnerUnavailable
}
accountCargoUpgrades := 0
for _, purchase := range purchases {
if purchase.AccountCargoUpgrade {
accountCargoUpgrades++
}
}
if accountCargoUpgrades != 0 {
if accountCargoUpgrades != 1 || len(purchases) != 1 {
return currentCeraShopCommitResult{}, fmt.Errorf("%w: account cargo upgrade must be purchased alone", errCurrentCeraShopProductUnavailable)
}
return commitCurrentCeraShopAccountCargoUpgrade(ctx, checkoutOwner, accountID, characterID, purchases[0], int64(totalCost), goldCost, s.gameplayNow())
}
var result currentCeraShopCommitResult
checkoutNow := s.gameplayNow().UTC()
// restrictNotes 收集本次结账消费掉的限购(“维度:键:已购/上限”),只用于日志核对。
// 事务回调可能被重试,所以在回调开头重置。
restrictNotes := make([]string, 0, len(purchases))
// 多买多送:同样的"事务回调可能重试,所以开头重置"约定。
buyCountGiftNotes := make([]string, 0, 4)
buyCountGiftRewards := make([]currentCeraShopBuyCountGiftTier, 0, 4)
buyCountGiftAfter := int64(0)
checkoutResult, err := checkoutOwner.Checkout(ctx, dnfcerashop.CheckoutCommand{
AccountID: accountID,
CharacterID: characterID,
SettingsScope: dnfrepo.CharacterContainerStateScope(characterID),
Cost: int64(totalCost),
// 支付瀑布:欢乐代币券 → 代币券 → 点券,依次扣到够为止。
//
// 客户端只上报"支付方式 0 = 点券",没有"用代币券买"的选项,所以代币券能
// 不能花掉完全由服务端决定 —— 不主动从这两个钱包扣,它们就是只能看不能用的
// 数字。不够时仍然走既有的"点券不足"失败 ACK,客户端可见行为不变。
PayPolicy: dnfcerashop.PayPolicyWaterfall,
UpdatedAt: checkoutNow,
Project: func(assets *dnfcerashop.CheckoutAssets) (dnfcerashop.CheckoutChanges, error) {
if assets == nil || assets.Account == nil || assets.Character == nil || assets.Inventory == nil ||
assets.Equipment == nil || assets.Settings == nil {
return dnfcerashop.CheckoutChanges{}, errCurrentCeraShopOwnerUnavailable
}
restrictNotes = restrictNotes[:0]
buyCountGiftNotes = buyCountGiftNotes[:0]
buyCountGiftRewards = buyCountGiftRewards[:0]
buyCountGiftAfter = 0
// 事务回调可能被重试:赠送的收集器也必须在这里清空,否则一次重试会
// 让同一件商品被邮寄两次。
giftAttachments = giftAttachments[:0]
giftGrantNotes = giftGrantNotes[:0]
if giftDelivery {
// 每日赠送额度(账号维度、10 万点券/天):额度记账必须和扣款在**同一次
// 结账事务**里。超了就整笔回滚 —— 一分钱不扣,客户端收到 11
//「超出赠送金额上限」。(文案 70107/0xA3 是"每个账号每天最多可以赠送
// 10万点券的礼物",在 op136 阶段也会先拦一次。)
if err := consumeCurrentCeraShopGiftDailyCera(assets.Account, int64(totalCost), checkoutNow); err != nil {
return dnfcerashop.CheckoutChanges{}, err
}
}
buyCountGiftPurchases := 0
containerStateAfter, mainInventoryExpansion, avatarInventoryExpansion, personalCargoSlotCount, hasContainerUpgrade, err :=
currentCeraShopPrepareContainerUpgrades(
assets.Settings,
assets.SettingsFound,
characterID,
purchases,
checkoutNow,
)
if err != nil {
return dnfcerashop.CheckoutChanges{}, err
}
changes := dnfcerashop.CheckoutChanges{Settings: hasContainerUpgrade}
if hasContainerUpgrade {
*assets.Settings = containerStateAfter
}
placementMainSlotCount := uint16(0)
if assets.Settings != nil {
if state, stateErr := dnfrepo.CharacterContainerStateFromSettings(*assets.Settings, characterID); stateErr == nil {
placementMainSlotCount = state.MainSlotCount
}
}
account := assets.Account
character := assets.Character
inventory := assets.Inventory
equipment := assets.Equipment
// 金币标价商品:在**同一次结账事务**里扣角色金币。
//
// 为什么必须在这里而不是 Checkout 里:Checkout 的 Cost + 支付瀑布只认三个
// **账号级点券钱包**(account_cera / token_cera / happy_token_cera),
// 角色金币住在 character.Stats["gold"](= dnf_characters.gold),
// 只有 CheckoutAssets.Character 摸得到。同一事务保证"扣了金币没发货"与
// "发了货没扣金币"都不可能发生(项目回调返回错误 = 整笔回滚)。
goldAfter, goldErr := chargeCurrentCeraShopGold(character, goldCost)
if goldErr != nil {
return dnfcerashop.CheckoutChanges{}, goldErr
}
if goldCost != 0 {
// 少这一句就是"扣在内存里、重启就回来":金币是角色记录的一个字段,
// 不显式要求保存 stats,Checkout 根本不会写角色行。
changes.Character = true
}
changedSlots := make(map[string]currentCeraShopChangedSlot)
// A cart may consume the same coupon stack more than once. Preserve
// only its final row image in the outbound list update; sending the
// intermediate and final counts for the same slot makes this client
// misread the multi-select response as an inventory-space failure.
entriesByList := make(map[byte]map[int16]currentItemListEntry)
recordEntry := func(listType byte, entry currentItemListEntry) {
if len(entry.data) < 2 {
return
}
entries := entriesByList[listType]
if entries == nil {
entries = make(map[int16]currentItemListEntry)
entriesByList[listType] = entries
}
slot := int16(binary.LittleEndian.Uint16(entry.data[0:2]))
entries[slot] = entry
}
premiumActivations := make([]currentCeraShopPremiumActivation, 0, len(purchases))
petInventoryChanged := false
inventoryChanged := false
characterSlotLimit := uint16(0)
renameEntitlementsAdded := uint16(0)
var nameTagActivation *currentNameTagCardActivation
premiumNow := checkoutNow
for _, purchase := range purchases {
product := purchase.Product
// PVF 限购:商场与 NPC 商店共用同一套计数,按“被购买的道具 id”
// 记账;账号维度落在账号记录 Metadata(本事务无条件保存),角色
// 维度落在角色 Stats(需要 changes.Character)。
if product.ItemID != 0 {
if limitDefinition, resolveErr := catalog.items.ResolveItem(product.ItemID); resolveErr == nil {
limitPolicy, limitState, limitErr := consumeCurrentPurchaseLimit(character, account, limitDefinition, product.Count, s.currentPurchaseLimitTime())
if limitErr != nil {
return dnfcerashop.CheckoutChanges{}, fmt.Errorf("%w: commodity=%d item=%d scope=%s period=%s used=%d requested=%d limit=%d: %w",
errCurrentPurchaseLimitReached, product.CommodityID, product.ItemID, limitPolicy.Scope, limitPolicy.Period,
limitState.Used, product.Count, limitPolicy.Limit, limitErr)
}
if limitPolicy.Scope == purchaselimit.ScopeCharacter {
changes.Character = true
}
}
}
// 商城自己的限购:`etc/cerashop.etc [buy restrict item]` 按**商品号**
// 计角色维度次数(客户端工具提示“每个角色只能购买N次”就是这一列);
// 没有这一行时按道具文案 / 商品行 Marker==101 兜底成账号维度。
// 一次结账的一行 = 买一次,所以每次消费 1。
for _, restrictPolicy := range catalog.BuyRestrictPolicies(product.CommodityID) {
_, restrictState, restrictErr := consumeCurrentPurchaseLimitPolicy(
character, account, restrictPolicy, 1, s.currentPurchaseLimitTime())
if restrictErr != nil {
return dnfcerashop.CheckoutChanges{}, fmt.Errorf("%w: commodity=%d source=%s scope=%s used=%d requested=1 limit=%d %s: %w",
errCurrentPurchaseLimitReached, product.CommodityID, restrictPolicy.Section, restrictPolicy.Scope,
restrictState.Used, restrictPolicy.Limit, purchaselimit.CountKey(restrictPolicy), restrictErr)
}
if restrictPolicy.Scope == purchaselimit.ScopeCharacter {
changes.Character = true
}
restrictNotes = append(restrictNotes, fmt.Sprintf("%s:%s:%d:%d/%d",
restrictPolicy.Scope, restrictPolicy.Section, product.CommodityID,
restrictState.Used+1, restrictPolicy.Limit))
}
// 多买多送:只有 `event/chn_event/chn_buycountgiftsecond.evt` 的
// `[bonus event ipg]` 列出的商品才计数(折扣券的 +1000 镜像号也算,
// 见 BuyCountGiftEligible)。一次结账的一行 = 买一次,
// 与上面商城限购的记法保持一致。
if catalog.BuyCountGiftEligible(product.CommodityID) {
buyCountGiftPurchases++
}
if purchase.AvatarCouponItemID != 0 {
entry, err := consumeCurrentCeraShopAvatarCoupon(inventory, purchase.AvatarCouponItemID)
if err != nil {
return dnfcerashop.CheckoutChanges{}, err
}
recordEntry(dnfrepo.MainInventoryListType, entry)
inventoryChanged = true
}
if purchase.CharacterSlotExpansion {
currentLimit := dnfrepo.AccountCharacterSlotLimit(*account)
if currentLimit >= dnfrepo.MaxCharacterSlotLimit {
return dnfcerashop.CheckoutChanges{}, fmt.Errorf("%w: commodity=%d character_slot_limit=%d", errCurrentCeraShopProductUnavailable, product.CommodityID, currentLimit)
}
if err := dnfrepo.SetAccountCharacterSlotLimit(account, currentLimit+1); err != nil {
return dnfcerashop.CheckoutChanges{}, err
}
characterSlotLimit = uint16(currentLimit + 1)
account.Metadata[currentCharacterSlotExtendEffectPendingMetadataKey] = strconv.Itoa(currentLimit + 1)
continue
}
if purchase.CharacterRenameEntitlement {
if character.Stats == nil {
character.Stats = make(map[string]int64, 4)
}
if character.Level < currentCharacterRenameMinimumLevel {
return dnfcerashop.CheckoutChanges{}, fmt.Errorf("%w: commodity=%d character level=%d requires=%d", errCurrentCeraShopProductUnavailable, product.CommodityID, character.Level, currentCharacterRenameMinimumLevel)
}
if character.Stats[currentCharacterRenameEntitlementStat] != 0 {
return dnfcerashop.CheckoutChanges{}, fmt.Errorf("%w: commodity=%d character rename entitlement already active", errCurrentCeraShopProductUnavailable, product.CommodityID)
}
// Same 90-day window as the bag path: the product must not be
// charged for a character that cannot rename yet.
lastRename := character.Stats[currentCharacterRenameLastUnixStat]
if lastRename < 0 || lastRename > 0 && checkoutNow.Unix() < lastRename+int64(currentCharacterRenameCooldown/time.Second) {
return dnfcerashop.CheckoutChanges{}, fmt.Errorf("%w: commodity=%d character rename cooldown active", errCurrentCeraShopProductUnavailable, product.CommodityID)
}
character.Stats[currentCharacterRenameEntitlementStat] = 1
changes.Character = true
renameEntitlementsAdded++
continue
}
if purchase.MainInventoryUpgradeStage != 0 || purchase.AvatarInventoryUpgradeStage != 0 || purchase.PersonalCargoUpgradeTarget != 0 {
// Container upgrades are consumed at checkout. They have no
// inventory row: the real effect is the persisted op13 header
// state prepared above.
continue
}
if purchase.PremiumPackage {
// [charac premium package]: consumed at checkout; every 魔王契约
// devil slot renews for the package day count (86JP
// ConsumedOnPurchase + UpsertPremiumExpire per slot).
for devilSlot := int64(0); devilSlot < premium.DevilSlotCount; devilSlot++ {
premiumType := premium.DevilSlotType(devilSlot)
premium.Upsert(account, premiumType, purchase.PremiumDurationSeconds, 1, premiumNow)
premiumActivations = append(premiumActivations, currentCeraShopPremiumActivation{
PremiumType: premiumType,
RemainingSeconds: premium.ExpireAt(*account, premiumType) - premiumNow.Unix(),
})
}
continue
}
if purchase.PremiumType > 0 {
// Devil contract slot or premiumlist contract item: consumed at
// checkout, account-level premium renews; nothing enters a bag.
premium.Upsert(account, purchase.PremiumType, purchase.PremiumDurationSeconds, 1, premiumNow)
for _, activationType := range premium.ActivationTypes(purchase.PremiumType) {
premiumActivations = append(premiumActivations, currentCeraShopPremiumActivation{
PremiumType: activationType,
RemainingSeconds: premium.ExpireAt(*account, activationType) - premiumNow.Unix(),
})
}
continue
}
if purchase.NameTagDurationSeconds > 0 {
if nameTagActivation == nil {
activation, err := applyCurrentNameTagCardAssets(
character,
equipment,
product.ItemID,
purchase.NameTagDurationSeconds,
premiumNow,
)
if err != nil {
return dnfcerashop.CheckoutChanges{}, err
}
nameTagActivation = &activation
changes.Character = true
changes.Equipment = true
}
continue
}
if len(purchase.AutoOpenRewards) != 0 {
if giftDelivery {
// 赠送:礼包自动展开出来的奖励属于**买到的商品**,要发给收件人。
// 落格失败(放不下)在这里不该发生——赠送走邮件,不占付款方背包。
for _, reward := range purchase.AutoOpenRewards {
definition, err := currentPVFItemDefinitionForGrantAt(reward.Definition, premiumNow)
if err != nil {
return dnfcerashop.CheckoutChanges{}, fmt.Errorf("%w: commodity=%d package=%d reward=%d expiration: %v", errCurrentCeraShopProductUnavailable, product.CommodityID, product.ItemID, reward.Definition.ItemID, err)
}
rewardProduct := currentCeraShopProduct{
CommodityID: product.CommodityID,
ItemID: definition.ItemID,
Count: reward.Count,
Section: product.Section,
}
if reward.Avatar {
rewardProduct.Section = "avatar"
}
if err := appendCurrentCeraShopGiftGrant(&giftAttachments, &giftGrantNotes, catalog.source, definition, rewardProduct, reward.Option, reward.Count); err != nil {
return dnfcerashop.CheckoutChanges{}, err
}
}
} else {
for _, reward := range purchase.AutoOpenRewards {
definition, err := currentPVFItemDefinitionForGrantAt(reward.Definition, premiumNow)
if err != nil {
return dnfcerashop.CheckoutChanges{}, fmt.Errorf("%w: commodity=%d package=%d reward=%d expiration: %v", errCurrentCeraShopProductUnavailable, product.CommodityID, product.ItemID, reward.Definition.ItemID, err)
}
rewardProduct := currentCeraShopProduct{
CommodityID: product.CommodityID,
ItemID: definition.ItemID,
Count: reward.Count,
Section: product.Section,
}
switch {
case reward.Avatar:
rewardProduct.Section = "avatar"
for granted := uint32(0); granted < reward.Count; granted++ {
slot, grantErr := grantCurrentCeraShopAvatar(inventory, catalog.source, definition, rewardProduct, reward.Option)
if grantErr != nil {
return dnfcerashop.CheckoutChanges{}, grantErr
}
change := currentCeraShopChangedSlot{ListType: 1, Slot: slot, Product: rewardProduct}
changedSlots[currentCeraShopInventorySlotKey(change.ListType, change.Slot)] = change
}
inventoryChanged = true
case isCurrentCeraShopPetArtifactItem(definition):
for granted := uint32(0); granted < reward.Count; granted++ {
slot, grantErr := grantCurrentCeraShopPetArtifact(inventory, definition)
if grantErr != nil {
return dnfcerashop.CheckoutChanges{}, grantErr
}
change := currentCeraShopChangedSlot{ListType: currentPetInventoryListType, Slot: slot, Product: rewardProduct}
changedSlots[currentCeraShopInventorySlotKey(change.ListType, change.Slot)] = change
}
petInventoryChanged = true
inventoryChanged = true
case isCurrentCeraShopCreatureItem(definition):
for granted := uint32(0); granted < reward.Count; granted++ {
slot, grantErr := grantCurrentCeraShopPet(inventory, definition)
if grantErr != nil {
return dnfcerashop.CheckoutChanges{}, grantErr
}
change := currentCeraShopChangedSlot{ListType: currentPetInventoryListType, Slot: slot, Product: rewardProduct}
changedSlots[currentCeraShopInventorySlotKey(change.ListType, change.Slot)] = change
}
petInventoryChanged = true
inventoryChanged = true
case isCurrentCeraShopPetConsumable(definition):
slots, grantErr := grantCurrentCeraShopPetConsumable(inventory, definition, reward.Count)
if grantErr != nil {
return dnfcerashop.CheckoutChanges{}, grantErr
}
for _, slot := range slots {
change := currentCeraShopChangedSlot{ListType: currentPetInventoryListType, Slot: slot, Product: rewardProduct}
changedSlots[currentCeraShopInventorySlotKey(change.ListType, change.Slot)] = change
}
petInventoryChanged = true
inventoryChanged = true
default:
slots, grantErr := grantCurrentCeraShopProduct(inventory, definition, reward.Count, placementMainSlotCount)
if grantErr != nil {
return dnfcerashop.CheckoutChanges{}, grantErr
}
for _, slot := range slots {
change := currentCeraShopChangedSlot{ListType: dnfrepo.MainInventoryListType, Slot: int16(slot), Product: rewardProduct}
changedSlots[currentCeraShopInventorySlotKey(change.ListType, change.Slot)] = change
}
inventoryChanged = true
}
}
}
// Multibonus is keyed by the purchased commodity, not by the
// physical source row. An auto-open package still receives it,
// while the [cera package] source itself never enters list 0.
//
// 赠送形态下这一段**照旧落在付款方**:一比一赠品是"买了才有"的
// 活动奖励,按用户确认的口径归付款方(A 送道具给 B,奖励归 A)。
for _, bonus := range catalog.bonusByCommodity[product.CommodityID] {
bonusDefinition, err := catalog.items.ResolveItem(bonus.ItemID)
if err != nil {
return dnfcerashop.CheckoutChanges{}, fmt.Errorf("%w: commodity=%d bonus item=%d: %v", errCurrentCeraShopProductUnavailable, product.CommodityID, bonus.ItemID, err)
}
bonusDefinition, err = currentPVFItemDefinitionForGrantAt(bonusDefinition, premiumNow)
if err != nil {
return dnfcerashop.CheckoutChanges{}, fmt.Errorf("%w: commodity=%d bonus item=%d expiration: %v", errCurrentCeraShopProductUnavailable, product.CommodityID, bonus.ItemID, err)
}
var bonusMainSlots []uint16
var bonusPetSlots []int16
switch {
case isCurrentCeraShopPetArtifactItem(bonusDefinition):
for granted := uint32(0); granted < bonus.Count; granted++ {
slot, grantErr := grantCurrentCeraShopPetArtifact(inventory, bonusDefinition)
if grantErr != nil {
return dnfcerashop.CheckoutChanges{}, grantErr
}
bonusPetSlots = append(bonusPetSlots, slot)
}
case isCurrentCeraShopCreatureItem(bonusDefinition):
for granted := uint32(0); granted < bonus.Count; granted++ {
slot, grantErr := grantCurrentCeraShopPet(inventory, bonusDefinition)
if grantErr != nil {
return dnfcerashop.CheckoutChanges{}, grantErr
}
bonusPetSlots = append(bonusPetSlots, slot)
}
case isCurrentCeraShopPetConsumable(bonusDefinition):
var grantErr error
bonusPetSlots, grantErr = grantCurrentCeraShopPetConsumable(inventory, bonusDefinition, bonus.Count)
if grantErr != nil {
return dnfcerashop.CheckoutChanges{}, grantErr
}
default:
var grantErr error
bonusMainSlots, grantErr = grantCurrentCeraShopProduct(inventory, bonusDefinition, bonus.Count, placementMainSlotCount)
if grantErr != nil {
return dnfcerashop.CheckoutChanges{}, grantErr
}
}
inventoryChanged = true
bonusProduct := currentCeraShopProduct{CommodityID: product.CommodityID, ItemID: bonus.ItemID, Section: product.Section}
for _, slot := range bonusMainSlots {
change := currentCeraShopChangedSlot{ListType: dnfrepo.MainInventoryListType, Slot: int16(slot), Product: bonusProduct, Bonus: true}
changedSlots[currentCeraShopInventorySlotKey(change.ListType, change.Slot)] = change
}
for _, slot := range bonusPetSlots {
change := currentCeraShopChangedSlot{ListType: currentPetInventoryListType, Slot: slot, Product: bonusProduct, Bonus: true}
changedSlots[currentCeraShopInventorySlotKey(change.ListType, change.Slot)] = change
}
if len(bonusPetSlots) > 0 {
petInventoryChanged = true
}
}
continue
}
definition, err := catalog.items.ResolveItem(product.ItemID)
if err != nil {
return dnfcerashop.CheckoutChanges{}, fmt.Errorf("%w: commodity=%d item=%d: %v", errCurrentCeraShopProductUnavailable, product.CommodityID, product.ItemID, err)
}
definition, err = currentCeraShopProductDefinitionForGrantAt(definition, product, premiumNow)
if err != nil {
return dnfcerashop.CheckoutChanges{}, fmt.Errorf("%w: commodity=%d item=%d expiration: %v", errCurrentCeraShopProductUnavailable, product.CommodityID, product.ItemID, err)
}
if giftDelivery {
// 赠送:买到的商品直接变成邮件附件发给收件人,付款方背包一个格子都不动。
// 这一件能不能邮寄由 appendCurrentCeraShopGiftGrant 判定(判定不通过就
// 整笔结账回滚,点券不会扣),所以不需要在这里区分时装/宠物/消耗品。
// 时装会走真正的落格函数,保住买到的时长与属性(见该函数注释)。
if err := appendCurrentCeraShopGiftGrant(&giftAttachments, &giftGrantNotes, catalog.source, definition, product, purchase.AttributeValue, product.Count); err != nil {
return dnfcerashop.CheckoutChanges{}, err
}
} else {
if product.Section == "avatar" {
slot, err := grantCurrentCeraShopAvatar(inventory, catalog.source, definition, product, purchase.AttributeValue)
if err != nil {
return dnfcerashop.CheckoutChanges{}, err
}
change := currentCeraShopChangedSlot{ListType: 1, Slot: slot, Product: product}
changedSlots[currentCeraShopInventorySlotKey(change.ListType, change.Slot)] = change
inventoryChanged = true
continue
}
if isCurrentCeraShopPetArtifactItem(definition) {
slot, err := grantCurrentCeraShopPetArtifact(inventory, definition)
if err != nil {
return dnfcerashop.CheckoutChanges{}, err
}
change := currentCeraShopChangedSlot{ListType: currentPetInventoryListType, Slot: slot, Product: product}
changedSlots[currentCeraShopInventorySlotKey(change.ListType, change.Slot)] = change
petInventoryChanged = true
inventoryChanged = true
continue
}
if isCurrentCeraShopCreatureItem(definition) {
slot, err := grantCurrentCeraShopPet(inventory, definition)
if err != nil {
return dnfcerashop.CheckoutChanges{}, err
}
change := currentCeraShopChangedSlot{ListType: currentPetInventoryListType, Slot: slot, Product: product}
changedSlots[currentCeraShopInventorySlotKey(change.ListType, change.Slot)] = change
petInventoryChanged = true
inventoryChanged = true
continue
}
if isCurrentCeraShopPetConsumable(definition) {
slots, err := grantCurrentCeraShopPetConsumable(inventory, definition, product.Count)
if err != nil {
return dnfcerashop.CheckoutChanges{}, err
}
for _, slot := range slots {
change := currentCeraShopChangedSlot{ListType: currentPetInventoryListType, Slot: slot, Product: product}
changedSlots[currentCeraShopInventorySlotKey(change.ListType, change.Slot)] = change
}
petInventoryChanged = true
inventoryChanged = true
continue
}
slots, err := grantCurrentCeraShopProduct(inventory, definition, product.Count, placementMainSlotCount)
if err != nil {
return dnfcerashop.CheckoutChanges{}, err
}
inventoryChanged = true
for _, slot := range slots {
change := currentCeraShopChangedSlot{ListType: dnfrepo.MainInventoryListType, Slot: int16(slot), Product: product}
changedSlots[currentCeraShopInventorySlotKey(change.ListType, change.Slot)] = change
}
}
// One-plus-one IPG gifts: absolute per-purchase counts from the
// runtime multibonus document, granted into the same container and
// transaction as the purchased product.
//
// 赠送形态下这一段**照旧落在付款方**:活动奖励归付款方(用户口径)。
for _, bonus := range catalog.bonusByCommodity[product.CommodityID] {
bonusDefinition, err := catalog.items.ResolveItem(bonus.ItemID)
if err != nil {
return dnfcerashop.CheckoutChanges{}, fmt.Errorf("%w: commodity=%d bonus item=%d: %v", errCurrentCeraShopProductUnavailable, product.CommodityID, bonus.ItemID, err)
}
bonusDefinition, err = currentPVFItemDefinitionForGrantAt(bonusDefinition, premiumNow)
if err != nil {
return dnfcerashop.CheckoutChanges{}, fmt.Errorf("%w: commodity=%d bonus item=%d expiration: %v", errCurrentCeraShopProductUnavailable, product.CommodityID, bonus.ItemID, err)
}
var bonusMainSlots []uint16
var bonusPetSlots []int16
switch {
case isCurrentCeraShopPetArtifactItem(bonusDefinition):
for granted := uint32(0); granted < bonus.Count; granted++ {
slot, grantErr := grantCurrentCeraShopPetArtifact(inventory, bonusDefinition)
if grantErr != nil {
return dnfcerashop.CheckoutChanges{}, grantErr
}
bonusPetSlots = append(bonusPetSlots, slot)
}
case isCurrentCeraShopCreatureItem(bonusDefinition):
for granted := uint32(0); granted < bonus.Count; granted++ {
slot, grantErr := grantCurrentCeraShopPet(inventory, bonusDefinition)
if grantErr != nil {
return dnfcerashop.CheckoutChanges{}, grantErr
}
bonusPetSlots = append(bonusPetSlots, slot)
}
case isCurrentCeraShopPetConsumable(bonusDefinition):
var grantErr error
bonusPetSlots, grantErr = grantCurrentCeraShopPetConsumable(inventory, bonusDefinition, bonus.Count)
if grantErr != nil {
return dnfcerashop.CheckoutChanges{}, grantErr
}
default:
var grantErr error
bonusMainSlots, grantErr = grantCurrentCeraShopProduct(inventory, bonusDefinition, bonus.Count, placementMainSlotCount)
if grantErr != nil {
return dnfcerashop.CheckoutChanges{}, grantErr
}
}
inventoryChanged = true
bonusProduct := currentCeraShopProduct{CommodityID: product.CommodityID, ItemID: bonus.ItemID, Section: product.Section}
for _, slot := range bonusMainSlots {
change := currentCeraShopChangedSlot{ListType: dnfrepo.MainInventoryListType, Slot: int16(slot), Product: bonusProduct, Bonus: true}
changedSlots[currentCeraShopInventorySlotKey(change.ListType, change.Slot)] = change
}
for _, slot := range bonusPetSlots {
change := currentCeraShopChangedSlot{ListType: currentPetInventoryListType, Slot: slot, Product: bonusProduct, Bonus: true}
changedSlots[currentCeraShopInventorySlotKey(change.ListType, change.Slot)] = change
}
if len(bonusPetSlots) > 0 {
petInventoryChanged = true
}
}
}
// 多买多送:把本次结账计入累计次数,并在**同一事务里**发放新跨越的档位奖励。
//
// - 计数键落在角色 Stats(与 purchaselimit 角色维度同一套约定,零 schema 变更),
// 所以小退、重启服务端之后进度都还在;
// - 发奖用的是与主商品、one-plus-one 赠品完全相同的落格函数(含邮件溢出兜底),
// 失败就整笔结账失败——点券不会被扣,"扣了钱没给奖励"不会发生;
// - 奖励 = `(before, after]` 之间新跨越的档位。计数单调递增,所以每个档位
// 天然只发一次,不需要额外的"已领取"位。
if plan := catalog.BuyCountGiftPlan(); plan.Enabled() && buyCountGiftPurchases > 0 {
counted := buyCountGiftPurchases
if counted > currentCeraShopBuyCountGiftMaxCountedPerCheckout {
counted = currentCeraShopBuyCountGiftMaxCountedPerCheckout
}
before := currentCeraShopBuyCountGiftCount(*character)
after := before + int64(counted)
rewards := plan.RewardsBetween(before, after)
grants, grantErr := grantCurrentCeraShopBuyCountGiftRewards(
catalog, inventory, rewards, placementMainSlotCount, premiumNow)
if grantErr != nil {
return dnfcerashop.CheckoutChanges{}, grantErr
}
for _, reward := range rewards {
buyCountGiftNotes = append(buyCountGiftNotes,
fmt.Sprintf("tier:%d:item:%d:x%d", reward.Tier, reward.ItemID, reward.Count))
}
buyCountGiftRewards = append(buyCountGiftRewards, rewards...)
for _, grant := range grants {
// Section 用固定标记:这条更新的来源是活动奖励而不是买到的商品,
// 日志/核对时一眼能分辨(ItemID 也换成了奖励道具而不是商品道具)。
//
// ⚠ 这里**不要**设 `Bonus: true`:那个字段在既有代码里是"一比一赠品"
// 的专用标记,会让格子被写成 `last_cera_shop_bonus = "one_plus_one_ipg"`,
// 把多买多送的奖励标成一比一赠品。识别来源靠 Section 就够了
// (下面的物化循环会把 `last_cera_shop_section = "buy_count_gift"` 写进去)。
change := currentCeraShopChangedSlot{
ListType: grant.ListType,
Slot: grant.Slot,
Product: currentCeraShopProduct{ItemID: grant.ItemID, Section: "buy_count_gift"},
}
key := currentCeraShopInventorySlotKey(change.ListType, change.Slot)
// 奖励和刚买到的道具可能并叠进**同一个格子**(同道具同格,或奖励落进
// 本来就有的那堆)。这种情况下不要用奖励的 change 覆盖商品那条:
// `changedSlots` 是按 列表:格 索引的,覆盖会把该格写成
// `last_cera_shop_commodity=0`,把"这是买来的"这条线索抹掉。
// 出站行内容取自最终 inventory,所以跳过登记不影响发给客户端的更新。
if existing, claimed := changedSlots[key]; claimed && existing.Product.CommodityID != 0 {
if grant.ListType == currentPetInventoryListType {
petInventoryChanged = true
}
continue
}
changedSlots[key] = change
if grant.ListType == currentPetInventoryListType {
petInventoryChanged = true
}
}
if len(grants) > 0 {
inventoryChanged = true
}
setCurrentCeraShopBuyCountGiftCount(character, after)
// 计数写在角色 Stats 上,必须让事务把角色记录也一起保存。
changes.Character = true
buyCountGiftAfter = after
}
for _, change := range changedSlots {
key := currentCeraShopInventorySlotKey(change.ListType, change.Slot)
stack, found := inventory.Slots[key]
if !found || stack.ItemID <= 0 || (change.ListType != 1 && stack.Count <= 0) {
return dnfcerashop.CheckoutChanges{}, errCurrentCeraShopProductUnavailable
}
entry := currentItemListEntryFromStack(change.ListType, change.Slot, stack)
stack.RawEntry = append([]byte(nil), entry.data[:]...)
if stack.Extra == nil {
stack.Extra = make(map[string]string, 5)
}
stack.Extra["last_grant_source"] = "cera_shop"
stack.Extra["last_cera_shop_commodity"] = strconv.FormatUint(uint64(change.Product.CommodityID), 10)
stack.Extra["last_cera_shop_section"] = change.Product.Section
if change.Bonus {
stack.Extra["last_cera_shop_bonus"] = "one_plus_one_ipg"
}
if change.Product.DurationDays > 0 {
stack.Extra["cera_shop_duration_days"] = strconv.FormatUint(uint64(change.Product.DurationDays), 10)
stack.Extra["expiration_source"] = "runtime_pvf_cera_shop_duration_grant"
}
inventory.Slots[key] = stack
recordEntry(change.ListType, entry)
}
updates := make([]currentCeraShopItemUpdate, 0, len(entriesByList))
for _, listType := range []byte{dnfrepo.MainInventoryListType, 1} {
entriesBySlot := entriesByList[listType]
if len(entriesBySlot) == 0 {
continue
}
entries := make([]currentItemListEntry, 0, len(entriesBySlot))
for _, entry := range entriesBySlot {
entries = append(entries, entry)
}
sort.Slice(entries, func(left, right int) bool {
return binary.LittleEndian.Uint16(entries[left].data[0:2]) < binary.LittleEndian.Uint16(entries[right].data[0:2])
})
updates = append(updates, currentCeraShopItemUpdate{ListType: listType, Entries: entries})
}
products := make([]currentCeraShopProduct, 0, len(purchases))
for _, purchase := range purchases {
products = append(products, purchase.Product)
}
result = currentCeraShopCommitResult{
Products: products,
Updates: updates,
MainInventoryExpansion: mainInventoryExpansion,
AvatarInventoryExpansion: avatarInventoryExpansion,
PersonalCargoSlotCount: personalCargoSlotCount,
CharacterSlotLimit: characterSlotLimit,
RenameEntitlementsAdded: renameEntitlementsAdded,
PetInventoryChanged: petInventoryChanged,
PremiumActivations: premiumActivations,
NameTagActivation: nameTagActivation,
PaidGold: goldCost,
GoldAfter: goldAfter,
}
changes.Inventory = inventoryChanged
return changes, nil
},
})
if err != nil {
return currentCeraShopCommitResult{}, err
}
result.CeraAfter = checkoutResult.CeraAfter
result.TokenCera = checkoutResult.TokenCera
result.HappyTokenCera = checkoutResult.HappyTokenCera
// 把本次消费掉的限购抄进结果,供 committed 事件的日志核对(线上验收就是看这里
// 有没有随购买一起增长)。
result.BuyRestrictNotes = append(result.BuyRestrictNotes, restrictNotes...)
result.BuyCountGiftCount = buyCountGiftAfter
result.BuyCountGiftNotes = append(result.BuyCountGiftNotes, buyCountGiftNotes...)
result.BuyCountGiftRewards = append(result.BuyCountGiftRewards, buyCountGiftRewards...)
result.PaidHappyTokenCera = checkoutResult.Paid.FromHappyTokenCera
result.PaidTokenCera = checkoutResult.Paid.FromTokenCera
result.PaidCera = checkoutResult.Paid.FromCera
// 赠送:把事务回调里收集到的商品附件带出结账。**必须复制**——收集器是闭包变量,
// 普通购买时它是空的(nil 切片),赠送时它才是投递的唯一依据。
result.GiftAttachments = append(result.GiftAttachments, giftAttachments...)
result.GiftGrantNotes = append(result.GiftGrantNotes, giftGrantNotes...)
return result, nil
}
// chargeCurrentCeraShopGold 在结账事务里扣角色金币,返回扣完后的余额。
//
// cost <= 0(点券标价商品、以及三个价格列全 0 的免费行)是**空操作**,直接返回 0 ——
// 这是"不修 A 坏 B"的关键:既有 177 行点券标价商品与那 1 行全 0 的 `[coin]` 行
// 走的都是这条路,行为一字不变。
//
// 调用方负责把 changes.Character 置位(否则扣了不落库)与整笔回滚(返回错误时)。
func chargeCurrentCeraShopGold(character *dnfrepo.CharacterRecord, cost int64) (int64, error) {
if cost <= 0 {
return 0, nil
}
if character == nil || character.Stats == nil {
return 0, fmt.Errorf("%w: character stats are unavailable", errCurrentCeraShopGoldInsufficient)
}
held := character.Stats["gold"]
if held < cost {
// held < 0 是脏数据,同样按不够处理,绝不扣成负数。
return 0, fmt.Errorf("%w: gold=%d cost=%d", errCurrentCeraShopGoldInsufficient, held, cost)
}
character.Stats["gold"] = held - cost
return character.Stats["gold"], nil
}
// currentCeraShopCommodityIDList 把购物车里的商品号拼成 "104824,104825"。
// 只用于失败日志 —— 混合车/歧义标价被拒时,没有商品号就只能去翻收包 hex。
func currentCeraShopCommodityIDList(items []currentCeraShopPurchaseRequestItem) string {
if len(items) == 0 {
return ""
}
parts := make([]string, 0, len(items))
for _, item := range items {
parts = append(parts, strconv.FormatUint(uint64(item.CommodityID), 10))
}
return strings.Join(parts, ",")
}
func commitCurrentCeraShopAccountCargoUpgrade(
ctx context.Context,
owner *dnfcerashop.Owner,
accountID string,
characterID string,
purchase currentCeraShopResolvedPurchase,
cost int64,
goldCost int64,
now time.Time,
) (currentCeraShopCommitResult, error) {
if owner == nil || accountID == "" || characterID == "" || cost < 0 || goldCost < 0 {
return currentCeraShopCommitResult{}, errCurrentCeraShopOwnerUnavailable
}
var result currentCeraShopCommitResult
checkout, err := owner.Checkout(ctx, dnfcerashop.CheckoutCommand{
AccountID: accountID,
CharacterID: characterID,
SettingsScope: dnfrepo.CharacterContainerStateScope(characterID),
Cost: cost,
// 与主购买路径同一套瀑布:金库升级也是商城里用点券买的商品,
// 代币券在两边都能花,不能只让其中一边认。
PayPolicy: dnfcerashop.PayPolicyWaterfall,
UpdatedAt: now,
Project: func(assets *dnfcerashop.CheckoutAssets) (dnfcerashop.CheckoutChanges, error) {
if assets == nil || assets.Account == nil || assets.Character == nil ||
assets.Character.Stats == nil {
return dnfcerashop.CheckoutChanges{}, errCurrentCeraShopOwnerUnavailable
}
account := assets.Account
if account.Metadata == nil {
account.Metadata = make(map[string]string, 4)
}
// 与主购买路径同口径:金币标价的金库升级工具也在同一事务里扣角色金币。
// 当前 PVF 里 5 个金币标价商品都不是金库升级工具(goldCost 恒为 0),
// 走的是空操作分支;留着是为了"金币商品一旦被配到这一类也不会白送"。
goldAfter, goldErr := chargeCurrentCeraShopGold(assets.Character, goldCost)
if goldErr != nil {
return dnfcerashop.CheckoutChanges{}, goldErr
}
previous, err := strconv.ParseInt(strings.TrimSpace(account.Metadata["account_cargo_level"]), 10, 64)
if err != nil || previous <= 0 {
return dnfcerashop.CheckoutChanges{}, errCurrentCeraShopProductUnavailable
}
next, ok := dnfcargo.NextAccountCargoTier(previous)
if !ok || next <= previous || next > math.MaxUint16 {
return dnfcerashop.CheckoutChanges{}, errCurrentCeraShopProductUnavailable
}
account.Metadata["account_cargo_created"] = "true"
account.Metadata["account_cargo_level"] = strconv.FormatInt(next, 10)
if strings.TrimSpace(account.Metadata["account_cargo_gold"]) == "" {
account.Metadata["account_cargo_gold"] = "0"
}
result = currentCeraShopCommitResult{
Products: []currentCeraShopProduct{purchase.Product},
AccountCargoSelectionKey: uint16(next),
PaidGold: goldCost,
GoldAfter: goldAfter,
}
if goldCost != 0 {
return dnfcerashop.CheckoutChanges{Character: true}, nil
}
return dnfcerashop.CheckoutChanges{}, nil
},
})
if err != nil {
return currentCeraShopCommitResult{}, err
}
result.CeraAfter = checkout.CeraAfter
result.TokenCera = checkout.TokenCera
result.HappyTokenCera = checkout.HappyTokenCera
// 支付瀑布的各钱包扣费量也要回填,否则这条路径的
// paid_happy_token_cera / paid_token_cera / paid_cera 恒为 0,
// 而"代币券真的能花"的验收就是看这三个数 —— 现场会误判成"券没被扣"。
result.PaidHappyTokenCera = checkout.Paid.FromHappyTokenCera
result.PaidTokenCera = checkout.Paid.FromTokenCera
result.PaidCera = checkout.Paid.FromCera
return result, nil
}