服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
1381 lines
62 KiB
Go
1381 lines
62 KiB
Go
package dnfbridge
|
||
|
||
// 金币寄售 (gold consignment) protocol layer.
|
||
//
|
||
// Everything in this file is additive: the item auction keeps its own opcodes
|
||
// and its own body shapes untouched, and each handler below is reached only
|
||
// from a single `body[0] == currentAuctionPayTypeConsignment` branch inserted
|
||
// at the top of the matching item handler. See
|
||
// `金币寄售_客户端逆向规格与实现方案_20260921.md` for the captures each shape
|
||
// came from, and for the ones marked "inferred".
|
||
|
||
import (
|
||
"context"
|
||
"encoding/binary"
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
dnfauction "longheng.io/server/internal/modules/dnf/auction"
|
||
dnfcerashop "longheng.io/server/internal/modules/dnf/cerashop"
|
||
dnfmail "longheng.io/server/internal/modules/dnf/mail"
|
||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||
)
|
||
|
||
const (
|
||
// currentAuctionGoldRegisterRequestSize is the 金币寄售 op183 body:
|
||
// {u8 mode=1, u8 0, u16 0, u32 tier item, u32 qty=1, i32 bid=-1,
|
||
// u32 Cera price, u32 0, u32 lifetime hours}. Live capture
|
||
// 2026-09-21T09:32:48Z: 010000007DEB280001000000FFFFFFFF6F0000000000000018000000
|
||
// (1百万 @ 111 Cera / 24h). Byte for byte the same as 86's
|
||
// GoldConsignmentRegisterRequestParser.
|
||
currentAuctionGoldRegisterRequestSize = 28
|
||
|
||
// currentAuctionGoldCancelRequestSize is the 金币寄售 op184 body. INFERRED:
|
||
// the client's own shape has never been captured because listing did not
|
||
// work yet. It mirrors the item auction's op184 ({u8 mode, i64 listingId}).
|
||
currentAuctionGoldCancelRequestSize = 9
|
||
|
||
// currentAuctionGoldBuyRequestSize / WithTrailer are the 金币寄售 op185 and
|
||
// op334 bodies. INFERRED the same way: the item auction's compact mode-1
|
||
// shape is {u8 mode, i32 total, i64 listingId} with an optional 4-byte
|
||
// trailer, and the gold panel's price control is the same single Cera field.
|
||
currentAuctionGoldBuyRequestSize = 13
|
||
currentAuctionGoldBuyRequestSizeWithTrailer = 17
|
||
|
||
// currentAuctionGoldCategoryRequestSize is the 金币寄售 op187 body. INFERRED
|
||
// from the item auction's fixed 20-byte category request.
|
||
currentAuctionGoldCategoryRequestSize = 20
|
||
|
||
// currentAuctionNoticeMsgID is the class-0 auction notice. Read off the
|
||
// client's own handler registration (registrar FUN_02262980):
|
||
// FUN_02262980(0xa9, FUN_01d80150, 0) <- this notice
|
||
// FUN_02262980(0xb7, FUN_01d80260, 0) <- the service-open notice
|
||
// FUN_01d80150 reads exactly {u8 route, u8 kind, u32 itemID, u16 count}:
|
||
// two one-byte reads through FUN_034e3970(x,1), a u32 through FUN_034e3a50
|
||
// (cursor -= 4) and a u16 through FUN_034e39e0 (cursor -= 2). Its switch on
|
||
// `kind` resolves dstr 60086..60091, and `route` picks which panel receives
|
||
// it via FUN_02fd3b90: 0 ensures owner 7 (the item auction window, 0x824
|
||
// bytes), 1 ensures owner 8 (the 金币寄售 window, 0x250 bytes).
|
||
currentAuctionNoticeMsgID uint16 = 0x00A9
|
||
|
||
// currentAuctionNoticeRouteItem / RouteGold are that route byte.
|
||
currentAuctionNoticeRouteItem = byte(0)
|
||
currentAuctionNoticeRouteGold = byte(1)
|
||
|
||
// The six notice kinds, in FUN_01d80150's switch order.
|
||
currentAuctionNoticeKindOutbid = byte(0) // 60086 竞拍中出现更高价格,金额返还
|
||
currentAuctionNoticeKindSellerSold = byte(1) // 60087 上架的[%s]已被拍下
|
||
currentAuctionNoticeKindBuyerWon = byte(2) // 60088 竞拍的[%s]已拍下
|
||
currentAuctionNoticeKindSellerFailed = byte(3) // 60089 上架的[%s]竞拍失败
|
||
currentAuctionNoticeKindCancelRefund = byte(4) // 60090 拍卖被取消,金额返还
|
||
currentAuctionNoticeKindCancelBid = byte(5) // 60091 竞拍已被取消
|
||
|
||
// Row geometry of the two gold list bodies, used to mark the 竞拍价 slot as
|
||
// "no bid". Both inherit the item auction's row painter, whose price triple
|
||
// sits at row+0x09 (0xA8 record) resp. row+0x08 (0x97 record) - the offsets
|
||
// the repository's own
|
||
// TestCurrentAuctionRowsRenderBidTotalSeparatelyFromBuyoutTotal pins for the
|
||
// item panel, and the offsets the 0x97 normalisation below confirms from the
|
||
// client side.
|
||
currentGoldConsignSearchHeaderSize = 7 // mode u8 + total u32 + count u16
|
||
currentGoldConsignSearchRowSize = 0xA8
|
||
currentGoldConsignSearchBidOffset = 0x09
|
||
|
||
currentGoldConsignOwnedHeaderSize = 2 // mode u8 + count u8
|
||
currentGoldConsignOwnedRowSize = 0x97
|
||
currentGoldConsignOwnedBidOffset = 0x08
|
||
|
||
// currentGoldConsignNoBid is the "no bid" sentinel the 金币寄售 row painter
|
||
// itself tests, and it is the same value the item auction's own three price
|
||
// slots use - see currentAuctionNoPrice for the four painter compares. It is
|
||
// NOT zero and NOT a flag byte: zero is a real price, which is why the first
|
||
// attempt at this fix rendered "当前竞拍 0 点券" instead of removing the line.
|
||
//
|
||
// Owner 8 is the 0x250-byte 金币寄售 window (FUN_02fd3b90 case 8 ->
|
||
// FUN_034e8520(0x250) -> FUN_0208a690), code at 0x0207xxxx..0x0209xxxx. Its
|
||
// row pipeline is sub_20821A0 (op186, the whole 0xA8 record copied into
|
||
// node+8), sub_2082280 (op188, 0x97), sub_2082360 (op189, 0x9F), each
|
||
// allocated by FUN_00759030 with exactly that many bytes and copied by
|
||
// FUN_02081900 / FUN_02081960 / FUN_020819C0 (0x2a, 0x25 and 0x27 dwords plus
|
||
// a u16 and a u8), then normalised by sub_207DEE0 into ONE 0xA8 struct that
|
||
// sub_207DDE0 stores at widget+0x3C8 and painted by sub_2076540.
|
||
//
|
||
// 020768c9 CMP dword ptr [ESI + 0x3d1], -1 ; widget+0x3C8+0x09 = bid
|
||
// 020768d0 JZ 0x02076a33 ; -1 => the whole 当前竞拍
|
||
// ; block is skipped
|
||
// 020768d6 PUSH 0xeb01 ; GetString(60161) = 当前竞拍
|
||
// 0207696b ... GetString(0x1134f=70479) = "%s 点券" ... drawn at row+0x15
|
||
//
|
||
// 0207673e CMP dword ptr [ESI + 0x3d5], EBX(-1) ; widget+0x3C8+0x0D = buyout
|
||
// 02076744 JZ 0x020768c3 ; -1 => that block is skipped
|
||
//
|
||
// So visibility is decided by comparing the slot against 0xFFFFFFFF, not by a
|
||
// state flag and not by the row length. The 0x97 normalisation proves the same
|
||
// convention from the other side: it hard-codes -1 into the struct's 0x11 slot
|
||
// (`MOV dword ptr [EBP-0x9b],0xffffffff` @0207e14e) precisely so that column
|
||
// draws nothing, and the row tooltip FUN_0207a760 derives 「每个: %s」 from the
|
||
// buyout total whenever it sees that -1 (`CMP [ECX+0x3d9],-1` @0207a84d).
|
||
currentGoldConsignNoBid = currentAuctionNoPrice
|
||
)
|
||
|
||
// buildCurrentGoldConsignRegisterSuccessBody is the 金币寄售 op183 success ack.
|
||
//
|
||
// The client routes a registration ack on its SECOND body byte, exactly as it
|
||
// routes the notice and failure acks: 0 is the item auction's window, 1 is the
|
||
// 金币寄售 window. Answering with the item auction's pay byte therefore made the
|
||
// client open the ordinary 上架 dialog after a successful consignment - live
|
||
// 2026-09-21T10:24:01Z, where an op183 mode-1 request got `0100` and the wrong
|
||
// window appeared. The item auction keeps buildCurrentAuctionRegisterSuccessBody
|
||
// unchanged and still answers `0100`, which is correct for its own panel.
|
||
func buildCurrentGoldConsignRegisterSuccessBody(listingID uint64, robotSessionBound bool) []byte {
|
||
var response packetWriter
|
||
response.writeByte(currentAuctionPayTypeConsignment)
|
||
if robotSessionBound {
|
||
response.writeUint64(listingID)
|
||
}
|
||
return response.bytes()
|
||
}
|
||
|
||
// markCurrentGoldConsignNoBid writes the painter's own "no bid" sentinel into the
|
||
// 竞拍价 slot of every record in a gold list body.
|
||
//
|
||
// A consignment has no bidding at all: the captured op183 always carries -1 and
|
||
// the ledger stores StartPrice -1, but dnfauction.Listing.BidUnitPrice falls
|
||
// back to UnitPrice for a buyout-only row, so the shared painter rendered the
|
||
// Cera price in the 竞拍价 column as well - live 2026-09-21, where an op188 row
|
||
// carried `6F000000 6F000000`.
|
||
//
|
||
// The first attempt zeroed the slot and did not work: the painter hides the line
|
||
// only when the slot equals 0xFFFFFFFF (see currentGoldConsignNoBid for the two
|
||
// cmp/jz pairs), so a zero simply rendered "当前竞拍 0 点券". Only the bid slot is
|
||
// written; the buyout total, the unit price, the name, the 期限 byte and the 83-byte
|
||
// item block all go out untouched, which is what keeps a consignment row's
|
||
// 价格(点券) column intact.
|
||
func markCurrentGoldConsignNoBid(body []byte, headerSize, rowSize, bidOffset int) {
|
||
if rowSize <= 0 || bidOffset < 0 || bidOffset+4 > rowSize {
|
||
return
|
||
}
|
||
// A record is only rewritten once it is complete. The builders above always
|
||
// emit whole rows, so this is belt and braces: it keeps a future partial row
|
||
// from being written past its own end.
|
||
for row := 0; ; row++ {
|
||
rowStart := headerSize + row*rowSize
|
||
if rowStart < 0 || rowStart+rowSize > len(body) {
|
||
return
|
||
}
|
||
start := rowStart + bidOffset
|
||
binary.LittleEndian.PutUint32(body[start:start+4], currentGoldConsignNoBid)
|
||
}
|
||
}
|
||
|
||
// buildCurrentAuctionNoticeBody is the exact class-0 op0xA9 payload
|
||
// FUN_01d80150 reads: {u8 route, u8 kind, u32 itemID, u16 count}. Two one-byte
|
||
// reads, a u32, a u16 - eight bytes, no trailing block.
|
||
func buildCurrentAuctionNoticeBody(route, kind byte, itemID uint32) []byte {
|
||
var body packetWriter
|
||
body.writeByte(route)
|
||
body.writeByte(kind)
|
||
body.writeUint32(itemID)
|
||
// FUN_01d80150 reads this u16 into a short[3]; only count[0] is consulted,
|
||
// and only to decide whether to run one extra UI refresh. Zero is the quiet
|
||
// value and is what every notice so far should carry.
|
||
body.writeUint16(0)
|
||
return body.bytes()
|
||
}
|
||
|
||
// sendCurrentAuctionNoticeToOnlineRecipient pushes one class-0 op0xA9 auction
|
||
// notice to an online character. Like the mailbox alarm it is a bridge-owned
|
||
// projection: the durable state (the letter, the sale row) has already
|
||
// committed, and an offline recipient simply misses the transient banner.
|
||
func (s *Service) sendCurrentAuctionNoticeToOnlineRecipient(characterID uint16, route, kind byte, itemID int64) error {
|
||
if s == nil || characterID == 0 || itemID <= 0 || itemID > math.MaxUint32 {
|
||
return nil
|
||
}
|
||
session, online := s.onlineGameSession(characterID)
|
||
if !online {
|
||
return nil
|
||
}
|
||
return s.sendGameUpperRawClass(
|
||
session,
|
||
currentAuctionNoticeMsgID,
|
||
buildCurrentAuctionNoticeBody(route, kind, uint32(itemID)),
|
||
0,
|
||
)
|
||
}
|
||
|
||
// notifyCurrentAuctionNoticeID parses a character id and pushes one notice,
|
||
// logging rather than failing: a missed banner must never undo a committed
|
||
// trade.
|
||
func (s *Service) notifyCurrentAuctionNoticeID(characterID string, route, kind byte, itemID int64) {
|
||
parsed, err := strconv.ParseUint(strings.TrimSpace(characterID), 10, 16)
|
||
if err != nil || parsed == 0 {
|
||
return
|
||
}
|
||
if err := s.sendCurrentAuctionNoticeToOnlineRecipient(uint16(parsed), route, kind, itemID); err != nil {
|
||
s.logGameEvent(nil, "game-auction-notice-failed",
|
||
"recipient", characterID, "route", route, "kind", kind, "item_id", itemID, "error", err)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Owner access
|
||
// ---------------------------------------------------------------------------
|
||
|
||
func (s *Service) currentGoldConsignOwner() (*dnfauction.GoldConsignOwner, error) {
|
||
if s == nil {
|
||
return nil, dnfauction.ErrUnavailable
|
||
}
|
||
repositories, ok := s.repositoryGroup()
|
||
if !ok {
|
||
return nil, dnfauction.ErrUnavailable
|
||
}
|
||
return dnfauction.NewGoldConsignOwner(repositories)
|
||
}
|
||
|
||
// currentGoldConsignFailureCode maps a ledger refusal onto a code the auction
|
||
// panel can actually render. The panel only branches on 7, 8, 140..158, 214 and
|
||
// 235; every other code draws nothing at all, so 86's level codes 0x74/0x75 are
|
||
// deliberately NOT reused - they would be a silent dead button here.
|
||
func currentGoldConsignFailureCode(err error) byte {
|
||
switch {
|
||
case err == nil:
|
||
return currentAuctionErrorUnknown
|
||
case errors.Is(err, dnfauction.ErrGoldConsignNotTier),
|
||
errors.Is(err, dnfauction.ErrItemUnavailable):
|
||
return currentAuctionErrorItemRejected
|
||
case errors.Is(err, dnfauction.ErrGoldConsignSellerLevel),
|
||
errors.Is(err, dnfauction.ErrGoldConsignBuyerLevel),
|
||
errors.Is(err, dnfauction.ErrNotOwner):
|
||
// The panel's 「您没有权限」 is the only rendering code that fits a
|
||
// level gate; the client's own level texts are notices, not this ack.
|
||
return currentAuctionErrorForbidden
|
||
case errors.Is(err, dnfauction.ErrGoldConsignBuyerIsSeller):
|
||
return currentAuctionErrorOwnListing
|
||
case errors.Is(err, dnfauction.ErrGoldUnavailable):
|
||
return currentAuctionErrorGoldShort
|
||
case errors.Is(err, dnfauction.ErrGoldConsignLimit):
|
||
return currentAuctionErrorListingLimit
|
||
case errors.Is(err, dnfauction.ErrGoldConsignPriceRejected),
|
||
errors.Is(err, dnfauction.ErrPriceMismatch):
|
||
return currentAuctionErrorPriceInvalid
|
||
case errors.Is(err, dnfauction.ErrListingMissing),
|
||
errors.Is(err, dnfauction.ErrListingExpired),
|
||
errors.Is(err, dnfauction.ErrGoldConsignNotActive),
|
||
errors.Is(err, dnfauction.ErrGoldConsignNotReserved):
|
||
return currentAuctionErrorNoListing
|
||
default:
|
||
return currentAuctionErrorUnknown
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Request decoders
|
||
// ---------------------------------------------------------------------------
|
||
|
||
type currentGoldConsignRegisterRequest struct {
|
||
GoldItemID int64
|
||
PriceCera int64
|
||
DurationHour int64
|
||
}
|
||
|
||
// decodeCurrentGoldConsignRegisterRequest reads the captured 28-byte op183.
|
||
func decodeCurrentGoldConsignRegisterRequest(body []byte) (currentGoldConsignRegisterRequest, error) {
|
||
if len(body) != currentAuctionGoldRegisterRequestSize || body[0] != currentAuctionPayTypeConsignment {
|
||
return currentGoldConsignRegisterRequest{}, dnfauction.ErrInvalidRequest
|
||
}
|
||
// body[1] and the u16 at 2 are fixed zero in every capture; the quantity is
|
||
// fixed at 1 and the bid price at -1 because a consignment has no stack
|
||
// count control and no bidding. Anything else is not a shape this client
|
||
// can produce, so it is refused rather than reinterpreted.
|
||
if body[1] != 0 || binary.LittleEndian.Uint16(body[2:4]) != 0 {
|
||
return currentGoldConsignRegisterRequest{}, dnfauction.ErrInvalidRequest
|
||
}
|
||
goldItemID := int64(binary.LittleEndian.Uint32(body[4:8]))
|
||
quantity := int64(int32(binary.LittleEndian.Uint32(body[8:12])))
|
||
bidPrice := int64(int32(binary.LittleEndian.Uint32(body[12:16])))
|
||
priceCera := int64(binary.LittleEndian.Uint32(body[16:20]))
|
||
reserved := binary.LittleEndian.Uint32(body[20:24])
|
||
lifetime := int64(binary.LittleEndian.Uint32(body[24:28]))
|
||
if quantity != 1 || bidPrice != -1 || reserved != 0 || priceCera <= 0 {
|
||
return currentGoldConsignRegisterRequest{}, dnfauction.ErrInvalidRequest
|
||
}
|
||
if lifetime != dnfauction.GoldConsignMinDurationHours && lifetime != dnfauction.GoldConsignMaxDurationHours {
|
||
return currentGoldConsignRegisterRequest{}, dnfauction.ErrInvalidRequest
|
||
}
|
||
if _, ok := dnfauction.GoldConsignTierAmount(goldItemID); !ok {
|
||
return currentGoldConsignRegisterRequest{}, dnfauction.ErrGoldConsignNotTier
|
||
}
|
||
return currentGoldConsignRegisterRequest{
|
||
GoldItemID: goldItemID,
|
||
PriceCera: priceCera,
|
||
DurationHour: lifetime,
|
||
}, nil
|
||
}
|
||
|
||
type currentGoldConsignBuyRequest struct {
|
||
ListingID uint64
|
||
ExpectedCera int64
|
||
// GoldItemID is the tier the panel had selected, present only on the
|
||
// captured 17-byte body. It is redundant with the row the listing id
|
||
// resolves to, but comparing the two catches a panel left open across a
|
||
// tier change, so it is checked rather than ignored.
|
||
GoldItemID uint32
|
||
HasGoldItemID bool
|
||
}
|
||
|
||
// decodeCurrentGoldConsignBuyRequest reads the op185 body.
|
||
//
|
||
// CAPTURED 2026-09-21T10:25:15Z as 17 bytes:
|
||
//
|
||
// 01 6F000000 6103B16A00000000 7DEB2800
|
||
// mode i32 total i64 listingId u32 tier
|
||
// (=111 Cera) (=2681725, 1百万)
|
||
//
|
||
// so {u8 mode, i32 total, i64 listingId, u32 tierItemId}, with the total being
|
||
// the whole-listing Cera price, matching the 111 the row was registered at. The
|
||
// item auction's own op185 is 30 bytes and mode 0, so the two cannot collide.
|
||
//
|
||
// A 13-byte body ({u8 mode, i32 total, i64 listingId}) stays accepted: it is the
|
||
// shape this decoder was written against before the capture, and refusing it
|
||
// would break nothing but would also gain nothing. op334 is deliberately NOT
|
||
// routed here - its item body has no mode byte at all, so a gold variant of it
|
||
// remains unknown.
|
||
func decodeCurrentGoldConsignBuyRequest(body []byte) (currentGoldConsignBuyRequest, error) {
|
||
if len(body) != currentAuctionGoldBuyRequestSize &&
|
||
len(body) != currentAuctionGoldBuyRequestSizeWithTrailer {
|
||
return currentGoldConsignBuyRequest{}, dnfauction.ErrInvalidRequest
|
||
}
|
||
if body[0] != currentAuctionPayTypeConsignment {
|
||
return currentGoldConsignBuyRequest{}, dnfauction.ErrInvalidRequest
|
||
}
|
||
expected := int64(int32(binary.LittleEndian.Uint32(body[1:5])))
|
||
listingID := binary.LittleEndian.Uint64(body[5:13])
|
||
if expected <= 0 || listingID == 0 {
|
||
return currentGoldConsignBuyRequest{}, dnfauction.ErrInvalidRequest
|
||
}
|
||
request := currentGoldConsignBuyRequest{ListingID: listingID, ExpectedCera: expected}
|
||
if len(body) == currentAuctionGoldBuyRequestSizeWithTrailer {
|
||
tier := binary.LittleEndian.Uint32(
|
||
body[currentAuctionGoldBuyRequestSize:currentAuctionGoldBuyRequestSizeWithTrailer])
|
||
if tier == 0 {
|
||
return currentGoldConsignBuyRequest{}, dnfauction.ErrInvalidRequest
|
||
}
|
||
request.GoldItemID = tier
|
||
request.HasGoldItemID = true
|
||
}
|
||
return request, nil
|
||
}
|
||
|
||
// decodeCurrentGoldConsignCancelRequest reads the inferred op184 body.
|
||
func decodeCurrentGoldConsignCancelRequest(body []byte) (uint64, error) {
|
||
if len(body) != currentAuctionGoldCancelRequestSize ||
|
||
body[0] != currentAuctionPayTypeConsignment {
|
||
return 0, dnfauction.ErrInvalidRequest
|
||
}
|
||
listingID := binary.LittleEndian.Uint64(body[1:9])
|
||
if listingID == 0 {
|
||
return 0, dnfauction.ErrInvalidRequest
|
||
}
|
||
return listingID, nil
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Rows
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// currentGoldConsignAsListing projects a consignment into the item auction's
|
||
// Listing shape so the proven row painters can render it unchanged. 86 does the
|
||
// same thing: GoldConsignmentSearchAckBuilder emits the shared auction record
|
||
// size, and the gold panel's list is the same 0xA8/0x97 record with the
|
||
// certificate as its item.
|
||
//
|
||
// It is marked INFERRED at the protocol level: an empty gold list is the only
|
||
// response we have ever had accepted on the wire, so the row layout is reused
|
||
// on 86's precedent rather than on a capture of our own client.
|
||
func currentGoldConsignAsListing(g dnfauction.GoldConsignListing) dnfauction.Listing {
|
||
return dnfauction.Listing{
|
||
ID: g.ID,
|
||
SellerID: g.SellerID,
|
||
SellerName: g.SellerName,
|
||
Item: dnfrepo.ItemStack{ItemID: g.GoldItemID, Count: 1},
|
||
// A consignment has no bidding: the captured op183 always carries -1,
|
||
// so the row is buyout-only and UnitPrice is the Cera price.
|
||
UnitPrice: g.PriceCera,
|
||
StartPrice: -1,
|
||
BuyoutPrice: g.PriceCera,
|
||
FeePercent: g.FeePercent,
|
||
CreatedAt: g.CreatedAt,
|
||
ExpiresAt: g.ExpiresAt,
|
||
}
|
||
}
|
||
|
||
func currentGoldConsignAsListings(listings []dnfauction.GoldConsignListing) []dnfauction.Listing {
|
||
out := make([]dnfauction.Listing, 0, len(listings))
|
||
for _, listing := range listings {
|
||
out = append(out, currentGoldConsignAsListing(listing))
|
||
}
|
||
return out
|
||
}
|
||
|
||
// currentGoldConsignTierIDs widens the wire's u32 tier ids for the ledger.
|
||
func currentGoldConsignTierIDs(itemIDs []uint32) []int64 {
|
||
out := make([]int64, 0, len(itemIDs))
|
||
for _, itemID := range itemIDs {
|
||
out = append(out, int64(itemID))
|
||
}
|
||
return out
|
||
}
|
||
|
||
// buildCurrentGoldConsignSearchBody renders the 金币搜索 page. It reuses the
|
||
// item auction's paged builder and only flips the mode byte, so the envelope
|
||
// stays byte-identical to the empty page the client already accepted
|
||
// (01 01 00000000 0000).
|
||
func buildCurrentGoldConsignSearchBody(listings []dnfauction.GoldConsignListing, page uint32, pageSize int) []byte {
|
||
body := buildCurrentAuctionSearchBodyPage(
|
||
currentGoldConsignAsListings(listings),
|
||
// Gold certificates are deliberately NOT item-auction eligible, so the
|
||
// item predicate must not filter them out here.
|
||
func(uint32) bool { return true },
|
||
page,
|
||
pageSize,
|
||
)
|
||
if len(body) > 0 {
|
||
body[0] = currentAuctionPayTypeConsignment
|
||
}
|
||
markCurrentGoldConsignNoBid(
|
||
body,
|
||
currentGoldConsignSearchHeaderSize,
|
||
currentGoldConsignSearchRowSize,
|
||
currentGoldConsignSearchBidOffset,
|
||
)
|
||
return body
|
||
}
|
||
|
||
// buildCurrentGoldConsignOwnedBody renders 我寄售的金币. Same reuse: the item
|
||
// tab's own builder with its seller filter, plus the mode byte and the same
|
||
// 竞拍价 blanking the search page needs.
|
||
func buildCurrentGoldConsignOwnedBody(listings []dnfauction.GoldConsignListing, sellerID string) []byte {
|
||
body := buildCurrentAuctionOwnedBody(
|
||
currentGoldConsignAsListings(listings),
|
||
sellerID,
|
||
func(uint32) bool { return true },
|
||
)
|
||
if len(body) > 0 {
|
||
body[0] = currentAuctionPayTypeConsignment
|
||
}
|
||
markCurrentGoldConsignNoBid(
|
||
body,
|
||
currentGoldConsignOwnedHeaderSize,
|
||
currentGoldConsignOwnedRowSize,
|
||
currentGoldConsignOwnedBidOffset,
|
||
)
|
||
return body
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Mail
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// currentGoldConsignAttachment renders one consignment letter attachment. Gold
|
||
// itself rides the letter's Gold field, so the only attachment kind here is the
|
||
// seller's 代币券 certificate - which the mailbox claim path credits to the
|
||
// account wallet rather than dropping into the material bag.
|
||
func currentGoldConsignVoucherAttachment(proceedsCera int64) dnfrepo.MailAttachment {
|
||
return dnfrepo.MailAttachment{
|
||
ItemID: dnfcerashop.TokenCeraVoucherItemID,
|
||
Count: proceedsCera,
|
||
}
|
||
}
|
||
|
||
// sendCurrentGoldConsignLetter enqueues one system letter. CompletionKey makes
|
||
// the call idempotent, so a retry after a partial failure cannot double-pay.
|
||
//
|
||
// The live mailbox projection is fired here rather than at each call site so
|
||
// every gold letter - purchase, sale proceeds and refund - carries it. The item
|
||
// auction fires the class0/op0x63 alarm next to its own sends; without it a
|
||
// consignment letter stayed invisible until the player happened to open the
|
||
// mailbox, which is what the operator reported first.
|
||
//
|
||
// The alarm alone turned out to be only half of it: FUN_01d7ff70 draws the
|
||
// 「收到新邮件」 banner and bumps a counter, and never repaints the mailbox list
|
||
// (see sendMailboxListRefreshToOnlineRecipient for the disassembly). So the gold
|
||
// path additionally pushes the class0/op0x61 page, which is the only thing that
|
||
// rebuilds the list - the same alarm-then-page pair the GM mailbox-clear refresh
|
||
// already ships. The item auction's own delivery path is left alone on purpose:
|
||
// it must stay byte-identical.
|
||
func (s *Service) sendCurrentGoldConsignLetter(ctx context.Context, request dnfmail.SystemSendRequest) error {
|
||
repositories, ok := s.repositoryGroup()
|
||
if !ok {
|
||
return dnfauction.ErrUnavailable
|
||
}
|
||
if _, err := dnfmail.NewOwner(repositories).SendSystem(ctx, request); err != nil {
|
||
return err
|
||
}
|
||
if characterID, parseErr := strconv.ParseUint(strings.TrimSpace(request.RecipientCharacterID), 10, 16); parseErr == nil && characterID != 0 {
|
||
// The banner first, then the page, so the 「收到新邮件」 notice is not
|
||
// the thing the list rebuild overwrites.
|
||
if alarmErr := s.sendMailboxAlarmToOnlineRecipient(uint16(characterID)); alarmErr != nil {
|
||
s.logGameEvent(nil, "game-goldconsign-mail-alarm-failed",
|
||
"recipient", request.RecipientCharacterID, "source", request.Source, "error", alarmErr)
|
||
}
|
||
if refreshErr := s.sendMailboxListRefreshToOnlineRecipient(uint16(characterID)); refreshErr != nil {
|
||
s.logGameEvent(nil, "game-goldconsign-mail-refresh-failed",
|
||
"recipient", request.RecipientCharacterID, "source", request.Source, "error", refreshErr)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// deliverCurrentGoldConsignSalesLocked sends the two letters a settled sale
|
||
// owes and stamps the row. Both letters are keyed by the listing id, so
|
||
// re-running this cannot pay twice, and a sale whose settle step succeeded but
|
||
// whose letters did not go out is retried on the next consignment command.
|
||
//
|
||
// 记录侧同样靠"这一位"保证只写一条:成交收款记录的条件是 MarkDelivered 报告
|
||
// 本次真的盖了章,重跑(无论是因为上一轮送信失败,还是因为同一条命令里被调了
|
||
// 两次)都只会命中一次。
|
||
func (s *Service) deliverCurrentGoldConsignSalesLocked(ctx context.Context, session *gameSession) {
|
||
owner, err := s.currentGoldConsignOwner()
|
||
if err != nil {
|
||
return
|
||
}
|
||
pending, err := owner.UndeliveredSales(ctx)
|
||
if err != nil {
|
||
return
|
||
}
|
||
for _, sale := range pending {
|
||
if sale.BuyerID == "" || sale.SellerID == "" {
|
||
continue
|
||
}
|
||
itemName := s.currentAuctionItemDisplayName(sale.GoldItemID)
|
||
// Buyer: the gold itself.
|
||
if err := s.sendCurrentGoldConsignLetter(ctx, dnfmail.SystemSendRequest{
|
||
RecipientCharacterID: sale.BuyerID,
|
||
Title: "金币寄售购买",
|
||
Body: "您购买的金币已通过邮件送达。",
|
||
Gold: sale.GoldAmount,
|
||
Source: "goldconsign_purchase",
|
||
CompletionKey: "goldconsign:buyer:" + strconv.FormatUint(sale.ID, 10),
|
||
}); err != nil {
|
||
s.logGameEvent(session, "game-goldconsign-buyer-mail-deferred",
|
||
"listing_id", sale.ID, "buyer_id", sale.BuyerID, "error", err)
|
||
continue
|
||
}
|
||
// Seller: 代币券, the bound cash the consignment system pays in. The
|
||
// letter prints the same itemised statement the seller's client shows:
|
||
// what the gold sold for, the commission taken out of it, and the
|
||
// 代币券 that actually arrives.
|
||
sellerTitle, sellerBody := currentGoldConsignSaleStatement(sale, itemName)
|
||
if err := s.sendCurrentGoldConsignLetter(ctx, dnfmail.SystemSendRequest{
|
||
RecipientCharacterID: sale.SellerID,
|
||
Title: sellerTitle,
|
||
Body: sellerBody,
|
||
Attachments: []dnfrepo.MailAttachment{currentGoldConsignVoucherAttachment(sale.ProceedsCera)},
|
||
Source: "goldconsign_sale_proceeds",
|
||
CompletionKey: "goldconsign:seller:" + strconv.FormatUint(sale.ID, 10),
|
||
}); err != nil {
|
||
s.logGameEvent(session, "game-goldconsign-seller-mail-deferred",
|
||
"listing_id", sale.ID, "seller_id", sale.SellerID, "error", err)
|
||
continue
|
||
}
|
||
freshlyDelivered, deliveredErr := owner.MarkDelivered(ctx, sale.BuyerID, sale.ID)
|
||
if deliveredErr != nil {
|
||
s.logGameEvent(session, "game-goldconsign-delivered-stamp-failed",
|
||
"listing_id", sale.ID, "error", deliveredErr)
|
||
}
|
||
// 成交收款记录只写在**这一次真的盖了章**的那一次
|
||
// (freshlyDelivered,见 GoldConsignOwner.MarkDelivered)。
|
||
//
|
||
// 为什么不能用"这一行是从 UndeliveredSales 里取出来的,所以它一定没送过"
|
||
// 来推断:这一族随每条寄售命令重跑,而重跑恰恰就是"上一次送到一半失败"
|
||
// 的正常恢复路径;只要判据稍微偏一点,重试就会为同一单刷出第二条收款
|
||
// 记录 —— 而记录侧(activity_record.record / 管理工具)都不做去重。
|
||
// 唯一的权威是域层自己报告的这次跃迁。
|
||
if freshlyDelivered {
|
||
fields := goldConsignListingFields(sale)
|
||
fields["buyer_id"] = strings.TrimSpace(sale.BuyerID)
|
||
fields["delivered"] = true
|
||
s.recordGoldConsign(session, "sale_delivered", fields)
|
||
}
|
||
// Both parties get the same class-0 op0xA9 banner the item auction
|
||
// shows, on the gold route so it lands in the 金币寄售 panel: the buyer
|
||
// sees 60088 (竞拍的[%s]已拍下) and the seller 60087 (上架的[%s]已被拍下).
|
||
s.notifyCurrentAuctionNoticeID(sale.BuyerID, currentAuctionNoticeRouteGold, currentAuctionNoticeKindBuyerWon, sale.GoldItemID)
|
||
s.notifyCurrentAuctionNoticeID(sale.SellerID, currentAuctionNoticeRouteGold, currentAuctionNoticeKindSellerSold, sale.GoldItemID)
|
||
}
|
||
}
|
||
|
||
// currentGoldConsignSaleStatement renders a settled consignment's seller letter
|
||
// in the itemised shape the client shows: what the gold sold for, the commission
|
||
// taken out of it, and the 代币券 that actually arrives.
|
||
//
|
||
// - 已成功拍卖[1百万] -
|
||
// 成交价 : + 188 代币券
|
||
// 手续费 : - 56 代币券
|
||
// 拍卖[1百万]后, 您获得了132代币券。
|
||
//
|
||
// The first line is both the subject and the body's first line: the client's
|
||
// mail detail pane draws the body only, so a subject on its own never appears
|
||
// there, while the mailbox list draws the subject.
|
||
//
|
||
// The commission is read off the row rather than recomputed from FeePercent, so
|
||
// a row written under an older frozen rate still prints the money that actually
|
||
// moved. 手续费 is omitted when nothing was charged.
|
||
func currentGoldConsignSaleStatement(sale dnfauction.GoldConsignListing, itemName string) (string, string) {
|
||
fee := sale.PriceCera - sale.ProceedsCera
|
||
if fee < 0 {
|
||
fee = 0
|
||
}
|
||
lines := []string{"- 已成功拍卖[" + itemName + "] -"}
|
||
lines = append(lines, fmt.Sprintf("成交价 : + %d 代币券", sale.PriceCera))
|
||
if fee > 0 {
|
||
lines = append(lines, fmt.Sprintf("手续费 : - %d 代币券", fee))
|
||
}
|
||
lines = append(lines, fmt.Sprintf("拍卖[%s]后, 您获得了%d代币券。", itemName, sale.ProceedsCera))
|
||
return lines[0], strings.Join(lines, "\n")
|
||
}
|
||
|
||
// goldConsignListingFields 一条寄售单的公共记录字段(单据、金币档位、价格、
|
||
// 手续费、卖家)。
|
||
//
|
||
// 上架 / 买家买入 / 成交收款 / 流拍退回四条记录都要这几项:运营拿 listing_id
|
||
// 就能把一单从头串到尾。为什么不各自手写:寄售单在成交之后就会从订单表消失
|
||
// (Settle 会把行从 auction:goldconsign: 搬到 auction:goldconsign:sold:,
|
||
// 过期与下架直接删行),记录是这批字段唯一还能翻出来的地方,四处写就容易
|
||
// 四处漏字段、口径还不一致。
|
||
func goldConsignListingFields(listing dnfauction.GoldConsignListing) map[string]any {
|
||
fields := map[string]any{
|
||
"listing_id": listing.ID,
|
||
"gold_item_id": listing.GoldItemID,
|
||
"gold_amount": listing.GoldAmount,
|
||
"price_cera": listing.PriceCera,
|
||
"fee_percent": listing.FeePercent,
|
||
"fee_cera": goldConsignFeeCera(listing),
|
||
"proceeds_cera": listing.ProceedsCera,
|
||
}
|
||
if sellerID := strings.TrimSpace(listing.SellerID); sellerID != "" {
|
||
fields["seller_id"] = sellerID
|
||
}
|
||
if sellerName := strings.TrimSpace(listing.SellerName); sellerName != "" {
|
||
fields["seller_name"] = sellerName
|
||
}
|
||
if !listing.ExpiresAt.IsZero() {
|
||
fields["expires_at"] = listing.ExpiresAt.UTC().Format(time.RFC3339)
|
||
// 时长由行上的两个时间戳反推,不另存一份:上架时写入的时长在结算阶段
|
||
// 已经拿不到了,而"这单挂了多久"恰是过期/流拍记录里最该有的一项。
|
||
if !listing.CreatedAt.IsZero() {
|
||
if hours := int64(listing.ExpiresAt.Sub(listing.CreatedAt).Hours()); hours > 0 {
|
||
fields["duration_hours"] = hours
|
||
}
|
||
}
|
||
}
|
||
return fields
|
||
}
|
||
|
||
// goldConsignFeeCera 这笔成交抽掉的代币券手续费。
|
||
//
|
||
// 用售价减实得,而不是拿 FeePercent 重算:行上冻结的费率与实付金额才是权威值
|
||
// (历史单据可能按旧费率成交),重算会打出与真实流水对不上的数字。同一个口径
|
||
// 也用在卖家邮件正文里(currentGoldConsignSaleStatement),两处必须一致。
|
||
func goldConsignFeeCera(listing dnfauction.GoldConsignListing) int64 {
|
||
fee := listing.PriceCera - listing.ProceedsCera
|
||
if fee < 0 {
|
||
return 0
|
||
}
|
||
return fee
|
||
}
|
||
|
||
// refundGoldByLetter returns consigned gold to its seller as a system letter,
|
||
// which is the channel both a cancel and an expiry use.
|
||
func (s *Service) refundGoldByLetter(ctx context.Context, session *gameSession, listing dnfauction.GoldConsignListing, refund int64, title, body, source string) error {
|
||
if refund <= 0 {
|
||
return nil
|
||
}
|
||
itemName := s.currentAuctionItemDisplayName(listing.GoldItemID)
|
||
return s.sendCurrentGoldConsignLetter(ctx, dnfmail.SystemSendRequest{
|
||
RecipientCharacterID: listing.SellerID,
|
||
Title: title,
|
||
Body: strings.ReplaceAll(body, "%s", itemName),
|
||
Gold: refund,
|
||
Source: source,
|
||
CompletionKey: "goldconsign:refund:" + source + ":" + strconv.FormatUint(listing.ID, 10),
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// op182 average
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// sendCurrentAuctionGoldAveragePriceLocked answers the 金币寄售 price inquiry.
|
||
// It reports the rolling recent-sale average for the tier; with no sales yet
|
||
// the average is 0, which is both true and the signal the fee resolver uses to
|
||
// fall back to the ordinary band.
|
||
func (s *Service) sendCurrentAuctionGoldAveragePriceLocked(
|
||
session *gameSession,
|
||
opcode uint16,
|
||
body []byte,
|
||
) error {
|
||
if len(body) != currentAuctionGoldAveragePriceRequestSize {
|
||
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
|
||
}
|
||
goldItemID := int64(binary.LittleEndian.Uint32(body[1:5]))
|
||
if goldItemID == 0 {
|
||
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
|
||
}
|
||
var average, samples int64
|
||
if owner, err := s.currentGoldConsignOwner(); err == nil {
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
if value, count, queryErr := owner.AverageCeraPrice(ctx, goldItemID); queryErr == nil {
|
||
average, samples = value, count
|
||
} else {
|
||
s.logGameEvent(session, "game-goldconsign-average-failed",
|
||
"item_id", goldItemID, "error", queryErr)
|
||
}
|
||
}
|
||
var response packetWriter
|
||
response.writeByte(currentAuctionPayTypeConsignment)
|
||
response.writeUint32(currentAuctionUint32(average))
|
||
response.writeUint32(currentAuctionUint32(samples))
|
||
for index := 0; index < 6; index++ {
|
||
response.writeUint32(currentAuctionUint32(average))
|
||
}
|
||
for index := 0; index < 6; index++ {
|
||
response.writeUint32(currentAuctionUint32(samples))
|
||
}
|
||
return s.sendGameUpperSuccess(session, opcode, response.bytes())
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// op183 register
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// goldConsignRejectReason 把一个域层拒绝翻成记录里能读的原因。
|
||
//
|
||
// 与 currentGoldConsignFailureCode(给客户端看的错误码)分开:错误码要落在
|
||
// 客户端已有的文案表里,而记录是给运营看的,越具体越好。未列出的错误统一
|
||
// 落到 "rejected",配合 logGameEvent 里的原始 error 一起看。
|
||
func goldConsignRejectReason(err error) string {
|
||
switch {
|
||
case err == nil:
|
||
return ""
|
||
case errors.Is(err, dnfauction.ErrGoldUnavailable):
|
||
return "gold_unavailable"
|
||
case errors.Is(err, dnfauction.ErrGoldConsignNotTier):
|
||
return "not_a_gold_tier"
|
||
case errors.Is(err, dnfauction.ErrGoldConsignPriceRejected):
|
||
return "price_rejected"
|
||
case errors.Is(err, dnfauction.ErrGoldConsignSellerLevel):
|
||
return "seller_level_too_low"
|
||
case errors.Is(err, dnfauction.ErrGoldConsignBuyerLevel):
|
||
return "buyer_level_too_low"
|
||
case errors.Is(err, dnfauction.ErrGoldConsignBuyerIsSeller):
|
||
return "buyer_is_seller"
|
||
case errors.Is(err, dnfauction.ErrGoldConsignNotReserved):
|
||
return "listing_not_reserved"
|
||
case errors.Is(err, dnfauction.ErrGoldConsignNotActive):
|
||
return "listing_not_active"
|
||
case errors.Is(err, dnfauction.ErrGoldConsignLimit), errors.Is(err, dnfauction.ErrListingLimit):
|
||
return "listing_limit"
|
||
case errors.Is(err, dnfauction.ErrNotOwner):
|
||
return "not_owner"
|
||
case errors.Is(err, dnfauction.ErrListingMissing):
|
||
return "listing_missing"
|
||
case errors.Is(err, dnfauction.ErrInvalidRequest):
|
||
return "invalid_request"
|
||
case errors.Is(err, dnfauction.ErrUnavailable):
|
||
return "ledger_unavailable"
|
||
default:
|
||
return "rejected"
|
||
}
|
||
}
|
||
|
||
func (s *Service) registerCurrentGoldConsignLocked(session *gameSession, opcode uint16, body []byte) error {
|
||
request, err := decodeCurrentGoldConsignRegisterRequest(body)
|
||
if err != nil {
|
||
// 被拒绝的尝试也要留一行:运营最常问的不是"谁挂上了",而是
|
||
// "为什么他挂不上" —— 只有成功记录时这个问题无法回答。
|
||
fields := activityRejectionFields("malformed_body")
|
||
fields["body_len"] = len(body)
|
||
s.recordGoldConsign(session, "list", fields)
|
||
return s.sendGameUpperFailure(session, opcode, currentGoldConsignFailureCode(err))
|
||
}
|
||
sellerID := selectedCharacterIDString(session)
|
||
accountID := strings.TrimSpace(s.accountIDForSession(session))
|
||
if sellerID == "" || accountID == "" {
|
||
fields := activityRejectionFields("identity_missing")
|
||
fields["gold_item_id"] = request.GoldItemID
|
||
fields["price_cera"] = request.PriceCera
|
||
s.recordGoldConsign(session, "list", fields)
|
||
return s.sendGameUpperFailure(session, opcode, currentAuctionErrorForbidden)
|
||
}
|
||
owner, err := s.currentGoldConsignOwner()
|
||
if err != nil {
|
||
fields := activityRejectionFields("ledger_unavailable")
|
||
fields["gold_item_id"] = request.GoldItemID
|
||
fields["price_cera"] = request.PriceCera
|
||
s.recordGoldConsign(session, "list", fields)
|
||
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
// Expiries and undelivered sale letters ride along with any command, the
|
||
// same way the item auction settles won listings on every request.
|
||
s.returnExpiredGoldConsignLocked(ctx, session, owner, sellerID, accountID)
|
||
s.deliverCurrentGoldConsignSalesLocked(ctx, session)
|
||
|
||
// The seller's display name comes from the character row the ledger loads
|
||
// inside the transaction, so the wire name can never disagree with the
|
||
// wallet that was actually debited.
|
||
result, err := owner.Register(ctx, dnfauction.GoldConsignRegisterCommand{
|
||
ListingID: s.nextAuctionListingID.Add(1),
|
||
SellerID: sellerID,
|
||
AccountID: accountID,
|
||
GoldItemID: request.GoldItemID,
|
||
PriceCera: request.PriceCera,
|
||
DurationHour: request.DurationHour,
|
||
HasCoupon: s.currentGoldConsignHasCoupon(session),
|
||
})
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-goldconsign-register-rejected",
|
||
"item_id", request.GoldItemID, "price_cera", request.PriceCera, "error", err)
|
||
fields := activityRejectionFields(goldConsignRejectReason(err))
|
||
fields["gold_item_id"] = request.GoldItemID
|
||
fields["price_cera"] = request.PriceCera
|
||
fields["duration_hours"] = request.DurationHour
|
||
activityIdentityOverride(fields, accountID, sellerID)
|
||
s.recordGoldConsign(session, "list", fields)
|
||
return s.sendGameUpperFailure(session, opcode, currentGoldConsignFailureCode(err))
|
||
}
|
||
if err := s.sendGameUpperSuccess(session, opcode,
|
||
buildCurrentGoldConsignRegisterSuccessBody(result.Listing.ID, session != nil && session.robotSessionBound)); err != nil {
|
||
return err
|
||
}
|
||
// The seller's gold moved, so the wallet row has to be republished.
|
||
if err := s.sendCurrentGoldConsignGoldRefresh(session, result.GoldAfter); err != nil {
|
||
s.logGameEvent(session, "game-goldconsign-register-refresh-failed", "error", err)
|
||
}
|
||
s.logGameEvent(session, "game-goldconsign-register-committed",
|
||
"listing_id", result.Listing.ID, "item_id", result.Listing.GoldItemID,
|
||
"gold", result.Listing.GoldAmount, "price_cera", result.Listing.PriceCera,
|
||
"fee_percent", result.Listing.FeePercent, "proceeds_cera", result.Listing.ProceedsCera,
|
||
"expires_at", result.Listing.ExpiresAt.Unix())
|
||
// 上架记录写在 ACK 之后、与 logGameEvent 并列:Register 已经提交(金币进了
|
||
// 托管),这一单在上架这一点上就是既成事实,客户端 ACK 发不出去也不改变它。
|
||
fields := goldConsignListingFields(result.Listing)
|
||
fields["duration_hours"] = request.DurationHour
|
||
fields["gold_after"] = result.GoldAfter
|
||
activityIdentityOverride(fields, accountID, sellerID)
|
||
s.recordGoldConsign(session, "list", fields)
|
||
return nil
|
||
}
|
||
|
||
// currentGoldConsignHasCoupon reports whether the seller holds the auction
|
||
// coupon the 40..150% band can discount. It reuses the item auction's own
|
||
// entitlement check so both panels agree on what "has the coupon" means.
|
||
func (s *Service) currentGoldConsignHasCoupon(session *gameSession) bool {
|
||
if s == nil || session == nil {
|
||
return false
|
||
}
|
||
repositories, ok := s.repositoryGroup()
|
||
if !ok {
|
||
return false
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
_, _, feePercent := s.currentAuctionEntitlementLocked(ctx, session, repositories)
|
||
return feePercent == dnfauction.DiscountedSaleFeePercent
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// op184 cancel
|
||
// ---------------------------------------------------------------------------
|
||
|
||
func (s *Service) cancelCurrentGoldConsignLocked(session *gameSession, opcode uint16, body []byte) error {
|
||
listingID, err := decodeCurrentGoldConsignCancelRequest(body)
|
||
if err != nil {
|
||
fields := activityRejectionFields("malformed_body")
|
||
fields["body_len"] = len(body)
|
||
s.recordGoldConsign(session, "cancel", fields)
|
||
return s.sendGameUpperFailure(session, opcode, currentGoldConsignFailureCode(err))
|
||
}
|
||
sellerID := selectedCharacterIDString(session)
|
||
accountID := strings.TrimSpace(s.accountIDForSession(session))
|
||
owner, err := s.currentGoldConsignOwner()
|
||
if err != nil {
|
||
fields := activityRejectionFields("ledger_unavailable")
|
||
fields["listing_id"] = listingID
|
||
s.recordGoldConsign(session, "cancel", fields)
|
||
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
result, err := owner.Cancel(ctx, sellerID, accountID, listingID)
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-goldconsign-cancel-rejected",
|
||
"listing_id", listingID, "error", err)
|
||
fields := activityRejectionFields(goldConsignRejectReason(err))
|
||
fields["listing_id"] = listingID
|
||
activityIdentityOverride(fields, accountID, sellerID)
|
||
s.recordGoldConsign(session, "cancel", fields)
|
||
return s.sendGameUpperFailure(session, opcode, currentGoldConsignFailureCode(err))
|
||
}
|
||
if err := s.sendGameUpperSuccess(session, opcode, []byte{currentAuctionPayTypeConsignment}); err != nil {
|
||
return err
|
||
}
|
||
if err := s.refundGoldByLetter(ctx, session, result.Listing, result.RefundGold,
|
||
"金币寄售下架", "您下架的%s已通过邮件返还。", "goldconsign_cancel_return"); err != nil {
|
||
s.logGameEvent(session, "game-goldconsign-cancel-mail-deferred",
|
||
"listing_id", listingID, "error", err)
|
||
}
|
||
// The seller gets the same banner the item auction's cancel sends, on the
|
||
// gold route: kind 5 renders dstr 60091 「[%s]的竞拍已被取消,请确认邮件」.
|
||
// Without it a consignment cancel was completely silent - the client's own
|
||
// op184 handler only pops dstr 60084 (已取消拍卖物品的上架), which is the item
|
||
// panel's wording, and FUN_01d80150 is the only sender for 60091.
|
||
s.notifyCurrentAuctionNoticeID(sellerID, currentAuctionNoticeRouteGold,
|
||
currentAuctionNoticeKindCancelBid, result.Listing.GoldItemID)
|
||
// The seller's gold is credited when they claim the letter, not here, so
|
||
// the cancel itself owes the wallet no refresh.
|
||
s.logGameEvent(session, "game-goldconsign-cancel-committed",
|
||
"listing_id", listingID, "refund_gold", result.RefundGold)
|
||
// 下架记录同时带上退还金币:寄售下架是把托管里的金币原样退回,
|
||
// 与拍卖行的"取消上架没收押金"正好相反,这一栏是两类下架最容易看混的地方。
|
||
fields := goldConsignListingFields(result.Listing)
|
||
fields["refund_gold"] = result.RefundGold
|
||
activityIdentityOverride(fields, accountID, sellerID)
|
||
s.recordGoldConsign(session, "cancel", fields)
|
||
return nil
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// op185 / op334 buy
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// buyCurrentGoldConsignLocked runs the buy saga described at the top of the
|
||
// ledger module: reserve, pay, settle, deliver. Every step after the reserve is
|
||
// either idempotent or retried by the next command, so a crash mid-way can
|
||
// neither double-charge nor lose the consignment.
|
||
func (s *Service) buyCurrentGoldConsignLocked(session *gameSession, opcode uint16, body []byte) error {
|
||
request, err := decodeCurrentGoldConsignBuyRequest(body)
|
||
if err != nil {
|
||
fields := activityRejectionFields("malformed_body")
|
||
fields["body_len"] = len(body)
|
||
s.recordGoldConsign(session, "buy", fields)
|
||
return s.sendGameUpperFailure(session, opcode, currentGoldConsignFailureCode(err))
|
||
}
|
||
buyerID := selectedCharacterIDString(session)
|
||
accountID := strings.TrimSpace(s.accountIDForSession(session))
|
||
if buyerID == "" || accountID == "" {
|
||
fields := activityRejectionFields("identity_missing")
|
||
fields["listing_id"] = request.ListingID
|
||
s.recordGoldConsign(session, "buy", fields)
|
||
return s.sendGameUpperFailure(session, opcode, currentAuctionErrorForbidden)
|
||
}
|
||
owner, err := s.currentGoldConsignOwner()
|
||
if err != nil {
|
||
fields := activityRejectionFields("ledger_unavailable")
|
||
fields["listing_id"] = request.ListingID
|
||
s.recordGoldConsign(session, "buy", fields)
|
||
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
s.deliverCurrentGoldConsignSalesLocked(ctx, session)
|
||
|
||
reservation, err := owner.Reserve(ctx, buyerID, accountID, request.ListingID)
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-goldconsign-buy-reserve-rejected",
|
||
"listing_id", request.ListingID, "error", err)
|
||
fields := activityRejectionFields(goldConsignRejectReason(err))
|
||
fields["listing_id"] = request.ListingID
|
||
activityIdentityOverride(fields, accountID, buyerID)
|
||
s.recordGoldConsign(session, "buy", fields)
|
||
return s.sendGameUpperFailure(session, opcode, currentGoldConsignFailureCode(err))
|
||
}
|
||
listing := reservation.Listing
|
||
if listing.PriceCera != request.ExpectedCera {
|
||
_ = owner.Release(ctx, buyerID, request.ListingID)
|
||
fields := activityRejectionFields("price_mismatch")
|
||
fields["listing_id"] = listing.ID
|
||
fields["price_cera"] = listing.PriceCera
|
||
fields["panel_price_cera"] = request.ExpectedCera
|
||
activityIdentityOverride(fields, accountID, buyerID)
|
||
s.recordGoldConsign(session, "buy", fields)
|
||
return s.sendGameUpperFailure(session, opcode, currentAuctionErrorPriceMismatch)
|
||
}
|
||
// The captured 17-byte body also names the tier the panel had selected. It
|
||
// must agree with the row the listing id resolved to, or the buyer is
|
||
// looking at a stale panel and would receive a different denomination than
|
||
// the one they confirmed. The refusal is the panel-refresh code, the same
|
||
// one a changed price uses.
|
||
if request.HasGoldItemID &&
|
||
(listing.GoldItemID <= 0 || uint32(listing.GoldItemID) != request.GoldItemID) {
|
||
_ = owner.Release(ctx, buyerID, request.ListingID)
|
||
s.logGameEvent(session, "game-goldconsign-buy-tier-mismatch",
|
||
"listing_id", listing.ID, "buyer_id", buyerID,
|
||
"panel_tier", request.GoldItemID, "row_tier", listing.GoldItemID)
|
||
fields := activityRejectionFields("tier_mismatch")
|
||
fields["listing_id"] = listing.ID
|
||
fields["gold_item_id"] = listing.GoldItemID
|
||
fields["panel_gold_item_id"] = request.GoldItemID
|
||
activityIdentityOverride(fields, accountID, buyerID)
|
||
s.recordGoldConsign(session, "buy", fields)
|
||
return s.sendGameUpperFailure(session, opcode, currentAuctionErrorPriceMismatch)
|
||
}
|
||
before, after, charged, err := s.payCurrentGoldConsignCera(ctx, accountID, buyerID, listing)
|
||
if err != nil {
|
||
_ = owner.Release(ctx, buyerID, request.ListingID)
|
||
s.logGameEvent(session, "game-goldconsign-buy-payment-rejected",
|
||
"listing_id", listing.ID, "price_cera", listing.PriceCera, "error", err)
|
||
// 点券不足是这类拒绝里最常见的一种(运营最常被问"他为什么买不了"),
|
||
// 所以它必须留下一条 result=rejected 的记录,而不是只进 packet_log。
|
||
fields := activityRejectionFields(goldConsignRejectReason(err))
|
||
fields["listing_id"] = listing.ID
|
||
fields["gold_item_id"] = listing.GoldItemID
|
||
fields["gold_amount"] = listing.GoldAmount
|
||
fields["price_cera"] = listing.PriceCera
|
||
fields["seller_id"] = strings.TrimSpace(listing.SellerID)
|
||
activityIdentityOverride(fields, accountID, buyerID)
|
||
s.recordGoldConsign(session, "buy", fields)
|
||
return s.sendGameUpperFailure(session, opcode, currentGoldConsignFailureCode(err))
|
||
}
|
||
// 点券消费记录必须记在 settle **之前**:钱已经扣掉并落库了,后面无论
|
||
// settle 成功、还是失败后由 reconciler 补完,这笔消费都已经真实发生。
|
||
// 记在 settle 成功处会漏掉"钱花了但 settle 延后"的那批(对账时最难查)。
|
||
// charged=false 表示这是重放同一个 op(钱包没再扣),绝不能重复记一条。
|
||
if charged {
|
||
s.recordGoldConsignCeraSpend(session, accountID, buyerID, listing, before, after)
|
||
}
|
||
if _, err := owner.Settle(ctx, buyerID, listing.ID); err != nil {
|
||
// The buyer has paid and their marker is durable; the reconciler
|
||
// finishes this on the next command rather than the money vanishing.
|
||
s.logGameEvent(session, "game-goldconsign-buy-settle-deferred",
|
||
"listing_id", listing.ID, "buyer_id", buyerID, "error", err)
|
||
return s.sendGameUpperFailure(session, opcode, currentGoldConsignFailureCode(err))
|
||
}
|
||
if err := s.sendGameUpperSuccess(session, opcode, []byte{currentAuctionPayTypeConsignment}); err != nil {
|
||
return err
|
||
}
|
||
s.deliverCurrentGoldConsignSalesLocked(ctx, session)
|
||
// 钱包刷新顺带把刷新出去的余额回传,买入记录里的"买家代币券余额"就不必
|
||
// 再查一次库:埋点位于玩法主路径上,为凑一个字段多一次仓储往返不划算
|
||
// (刷新失败时这一栏留空,而不是去补一次查询)。
|
||
wallet, walletErr := s.sendCurrentGoldConsignWalletRefresh(session)
|
||
if walletErr != nil {
|
||
s.logGameEvent(session, "game-goldconsign-buy-refresh-failed", "error", walletErr)
|
||
}
|
||
s.logGameEvent(session, "game-goldconsign-buy-committed",
|
||
"listing_id", listing.ID, "buyer_id", buyerID, "seller_id", listing.SellerID,
|
||
"gold", listing.GoldAmount, "price_cera", listing.PriceCera,
|
||
"fee_percent", listing.FeePercent, "proceeds_cera", listing.ProceedsCera)
|
||
// 买入记录同样只在 charged(本次真的扣了钱)时写:重放同一个 op185 时
|
||
// 钱包不会再动,凭 err==nil 记账会写出第二条"买家买入"。
|
||
if charged {
|
||
fields := goldConsignListingFields(listing)
|
||
fields["buyer_id"] = buyerID
|
||
if buyerName := s.activityCharacterName(session); buyerName != "" {
|
||
fields["buyer_name"] = buyerName
|
||
}
|
||
fields["cera_spent"] = listing.PriceCera
|
||
fields["cera_before"] = before
|
||
fields["cera_after"] = after
|
||
if walletErr == nil {
|
||
fields["token_cera_after"] = wallet.TokenCera
|
||
}
|
||
activityIdentityOverride(fields, accountID, buyerID)
|
||
s.recordGoldConsign(session, "buy", fields)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// payCurrentGoldConsignCera debits the buyer's Cera wallet and records the pay
|
||
// marker in the same transaction, so the reconciler can tell a paid-but-
|
||
// unsettled reservation from an abandoned one.
|
||
//
|
||
// Cera is account-scoped metadata, which no character-trade boundary reaches;
|
||
// the mailbox boundary is the narrowest one that yields the Account repository
|
||
// together with the buyer's own character.
|
||
//
|
||
// 返回值专门为"点券消费记录"服务(2026-09):
|
||
// - before/after 本次实际扣费前后的点券余额,供记录里的"消费前/后点券";
|
||
// - charged 本次调用**是否真的扣了钱**。这一位是必需的:下面那句
|
||
// "A retry after a deferred settle must not charge again" 会让重放同一个
|
||
// op185 时直接 return nil —— 与"本次刚扣完"返回值完全一样。
|
||
// 调用方只凭 err==nil 无法区分两者,照着记就会出现两条重复消费;
|
||
// 而记录侧(activity_record.record / 管理工具)都不做去重。
|
||
// - 任何失败路径都返回 charged=false,避免"扣费没落库、记录却写了一条"。
|
||
func (s *Service) payCurrentGoldConsignCera(ctx context.Context, accountID, buyerID string, listing dnfauction.GoldConsignListing) (before, after int64, charged bool, err error) {
|
||
repositories, ok := s.repositoryGroup()
|
||
if !ok || repositories.MailboxAssets == nil {
|
||
return 0, 0, false, dnfauction.ErrUnavailable
|
||
}
|
||
err = repositories.MailboxAssets.WithinMailboxAssets(
|
||
ctx, buyerID, buyerID,
|
||
func(
|
||
accounts dnfrepo.AccountRepository,
|
||
_ dnfrepo.AccountInventoryRepository,
|
||
_ dnfrepo.CharacterRepository,
|
||
_ dnfrepo.InventoryRepository,
|
||
_ dnfrepo.MailboxRepository,
|
||
) error {
|
||
account, found, err := accounts.Load(ctx, accountID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !found {
|
||
return dnfauction.ErrInvalidRequest
|
||
}
|
||
// A retry after a deferred settle must not charge again.
|
||
if _, paid := dnfauction.GoldConsignReadPayMarker(account, listing.ID); paid {
|
||
return nil
|
||
}
|
||
wallet := dnfcerashop.WalletBalance(account)
|
||
if wallet.Cera < listing.PriceCera {
|
||
return dnfauction.ErrGoldUnavailable
|
||
}
|
||
updated := dnfrepo.CloneAccount(account)
|
||
if updated.Metadata == nil {
|
||
updated.Metadata = map[string]string{}
|
||
}
|
||
dnfcerashop.SetBalance(&updated, wallet.Cera-listing.PriceCera)
|
||
updated.Metadata[dnfauction.GoldConsignPayMarkerKey(listing.ID)] = dnfauction.BuildPayMarker(buyerID, listing)
|
||
// 先记下余额变化,再落库;Save 失败会在下面被清掉,
|
||
// 保证"没落库就一定 charged=false"。
|
||
before, after, charged = wallet.Cera, wallet.Cera-listing.PriceCera, true
|
||
return accounts.Save(ctx, updated)
|
||
})
|
||
if err != nil {
|
||
return 0, 0, false, err
|
||
}
|
||
return before, after, charged, nil
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// op186 / op187 search
|
||
// ---------------------------------------------------------------------------
|
||
|
||
func (s *Service) searchCurrentAuctionGoldItemsLocked(session *gameSession, opcode uint16, body []byte) error {
|
||
page, itemIDs, ok := decodeCurrentAuctionSearchBody(body, currentAuctionPayTypeConsignment)
|
||
if !ok {
|
||
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
|
||
}
|
||
owner, err := s.currentGoldConsignOwner()
|
||
if err != nil {
|
||
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
// A stale reserved row from an aborted purchase goes back on sale here,
|
||
// which is the cheapest place to notice one.
|
||
s.reconcileGoldConsignReservationsLocked(ctx, session, owner)
|
||
listings, err := owner.Search(ctx, currentGoldConsignTierIDs(itemIDs))
|
||
if err != nil {
|
||
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
|
||
}
|
||
return s.sendGameUpperSuccess(session, opcode,
|
||
buildCurrentGoldConsignSearchBody(listings, page, GoldConsignSearchPageSize))
|
||
}
|
||
|
||
// GoldConsignSearchPageSize is the gold page size the panel pages through.
|
||
const GoldConsignSearchPageSize = 100
|
||
|
||
func (s *Service) searchCurrentGoldConsignCategoryLocked(session *gameSession, opcode uint16, body []byte) error {
|
||
// INFERRED: the 金币分类 request has not been captured. The item panel
|
||
// sends a fixed 20-byte body, so the gold panel is assumed to send the same
|
||
// 20 bytes with mode 1; anything else is refused rather than guessed at.
|
||
if len(body) != currentAuctionGoldCategoryRequestSize || body[0] != currentAuctionPayTypeConsignment {
|
||
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
|
||
}
|
||
page := binary.LittleEndian.Uint32(body[1:5])
|
||
owner, err := s.currentGoldConsignOwner()
|
||
if err != nil {
|
||
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
s.reconcileGoldConsignReservationsLocked(ctx, session, owner)
|
||
listings, err := owner.Search(ctx, nil)
|
||
if err != nil {
|
||
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
|
||
}
|
||
return s.sendGameUpperSuccess(session, opcode,
|
||
buildCurrentGoldConsignSearchBody(listings, page, GoldConsignSearchPageSize))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// op188 own listings
|
||
// ---------------------------------------------------------------------------
|
||
|
||
func (s *Service) sendCurrentAuctionGoldOwnedItemsLocked(session *gameSession, opcode uint16, body []byte) error {
|
||
if len(body) != 1 || body[0] != currentAuctionPayTypeConsignment {
|
||
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
|
||
}
|
||
sellerID := selectedCharacterIDString(session)
|
||
accountID := strings.TrimSpace(s.accountIDForSession(session))
|
||
owner, err := s.currentGoldConsignOwner()
|
||
if err != nil {
|
||
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
s.returnExpiredGoldConsignLocked(ctx, session, owner, sellerID, accountID)
|
||
s.deliverCurrentGoldConsignSalesLocked(ctx, session)
|
||
s.reconcileGoldConsignReservationsLocked(ctx, session, owner)
|
||
listings, err := owner.ListOwned(ctx, sellerID)
|
||
if err != nil {
|
||
return s.sendGameUpperFailure(session, opcode, currentAuctionFailureCode)
|
||
}
|
||
return s.sendGameUpperSuccess(session, opcode,
|
||
buildCurrentGoldConsignOwnedBody(listings, sellerID))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Sweeps
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// returnExpiredGoldConsignLocked pulls this seller's timed-out consignments and
|
||
// sends their gold home. It mirrors the item auction's lazy expired sweep: any
|
||
// consignment command any player runs is enough to settle somebody's expiry.
|
||
func (s *Service) returnExpiredGoldConsignLocked(ctx context.Context, session *gameSession, owner *dnfauction.GoldConsignOwner, sellerID, accountID string) {
|
||
if owner == nil || sellerID == "" || accountID == "" {
|
||
return
|
||
}
|
||
expired, err := owner.ReturnExpired(ctx, sellerID, accountID)
|
||
if err != nil {
|
||
return
|
||
}
|
||
for _, item := range expired {
|
||
if err := s.refundGoldByLetter(ctx, session, item.Listing, item.Refund,
|
||
"金币寄售流拍", "您寄售的%s已到期流拍,通过邮件返还。", "goldconsign_expired_return"); err != nil {
|
||
s.logGameEvent(session, "game-goldconsign-expiry-mail-deferred",
|
||
"listing_id", item.Listing.ID, "error", err)
|
||
continue
|
||
}
|
||
s.logGameEvent(session, "game-goldconsign-expired",
|
||
"listing_id", item.Listing.ID, "refund_gold", item.Refund)
|
||
// 过期记录写在信件投递成功之后。ReturnExpired 返回的就是它这一次真的
|
||
// 摘下来的行(摘完即从托管表删除),重跑同一个扫描不会再返回同一单,
|
||
// 所以这里不需要额外的去重判据——但邮件失败的那一轮不记:
|
||
// "退回信没发出去"和"金币已经退回去了"是两件事,记录不能替它下结论。
|
||
fields := goldConsignListingFields(item.Listing)
|
||
fields["refund_gold"] = item.Refund
|
||
s.recordGoldConsign(session, "expired", fields)
|
||
}
|
||
}
|
||
|
||
// reconcileGoldConsignReservationsLocked releases reservations nobody paid for.
|
||
// A reservation whose buyer left a pay marker is settled instead: the buyer's
|
||
// Cera is already spent, so the consignment must complete rather than go back
|
||
// on sale.
|
||
func (s *Service) reconcileGoldConsignReservationsLocked(ctx context.Context, session *gameSession, owner *dnfauction.GoldConsignOwner) {
|
||
if owner == nil {
|
||
return
|
||
}
|
||
repositories, ok := s.repositoryGroup()
|
||
if !ok {
|
||
return
|
||
}
|
||
for _, listing := range s.goldConsignReservedListings(ctx, owner) {
|
||
if listing.ReservedAt.IsZero() {
|
||
continue
|
||
}
|
||
if time.Since(listing.ReservedAt) < dnfauction.GoldConsignReservationTTL {
|
||
continue
|
||
}
|
||
if s.goldConsignBuyerPaid(ctx, repositories, listing) {
|
||
if _, err := owner.Settle(ctx, listing.BuyerID, listing.ID); err != nil {
|
||
s.logGameEvent(session, "game-goldconsign-reconcile-settle-failed",
|
||
"listing_id", listing.ID, "error", err)
|
||
continue
|
||
}
|
||
s.logGameEvent(session, "game-goldconsign-reconcile-settled", "listing_id", listing.ID)
|
||
continue
|
||
}
|
||
if err := owner.Release(ctx, listing.BuyerID, listing.ID); err != nil {
|
||
s.logGameEvent(session, "game-goldconsign-reconcile-release-failed",
|
||
"listing_id", listing.ID, "error", err)
|
||
continue
|
||
}
|
||
s.logGameEvent(session, "game-goldconsign-reconcile-released", "listing_id", listing.ID)
|
||
}
|
||
}
|
||
|
||
// goldConsignReservedListings finds every reserved row by walking the owned tab
|
||
// of each reservation's buyer, which is the only listing view the ledger
|
||
// exposes for a non-active status.
|
||
func (s *Service) goldConsignReservedListings(ctx context.Context, owner *dnfauction.GoldConsignOwner) []dnfauction.GoldConsignListing {
|
||
reserved, err := owner.ReservedListings(ctx)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
return reserved
|
||
}
|
||
|
||
// goldConsignBuyerPaid reads the pay marker the payment step wrote.
|
||
func (s *Service) goldConsignBuyerPaid(ctx context.Context, repositories dnfrepo.Group, listing dnfauction.GoldConsignListing) bool {
|
||
if repositories.Account == nil || listing.BuyerID == "" {
|
||
return false
|
||
}
|
||
character, found, err := repositories.Character.Load(ctx, listing.BuyerID)
|
||
if err != nil || !found {
|
||
return false
|
||
}
|
||
accountID := strings.TrimSpace(character.AccountID)
|
||
if accountID == "" {
|
||
return false
|
||
}
|
||
account, found, err := repositories.Account.Load(ctx, accountID)
|
||
if err != nil || !found {
|
||
return false
|
||
}
|
||
_, paid := dnfauction.GoldConsignReadPayMarker(account, listing.ID)
|
||
return paid
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Wallet refresh
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// sendCurrentGoldConsignGoldRefresh republishes the seller's gold after the
|
||
// escrow debit, on the same class-0 op14 projection the item auction uses.
|
||
func (s *Service) sendCurrentGoldConsignGoldRefresh(session *gameSession, goldAfter int64) error {
|
||
if s == nil || session == nil {
|
||
return nil
|
||
}
|
||
return s.sendGameUpperRawClass(session, 14, buildCurrentGoldStateBody(goldAfter), 0)
|
||
}
|
||
|
||
// sendCurrentGoldConsignWalletRefresh republishes the Cera wallet the buyer just
|
||
// spent from: the class-0 op53 projection the cera shop already owns.
|
||
//
|
||
// 返回值是刚发布出去的余额快照(发布失败时为零值),供买入记录填
|
||
// token_cera_after:记录侧要用到的这个数就在函数里,回传比再查一次库便宜。
|
||
func (s *Service) sendCurrentGoldConsignWalletRefresh(session *gameSession) (dnfcerashop.Wallet, error) {
|
||
if s == nil || session == nil {
|
||
return dnfcerashop.Wallet{}, nil
|
||
}
|
||
repositories, ok := s.repositoryGroup()
|
||
if !ok || repositories.Account == nil {
|
||
return dnfcerashop.Wallet{}, nil
|
||
}
|
||
accountID := strings.TrimSpace(s.accountIDForSession(session))
|
||
if accountID == "" {
|
||
return dnfcerashop.Wallet{}, nil
|
||
}
|
||
account, found, err := repositories.Account.Load(context.Background(), accountID)
|
||
if err != nil || !found {
|
||
return dnfcerashop.Wallet{}, err
|
||
}
|
||
wallet := dnfcerashop.WalletBalance(account)
|
||
if err := s.sendGameUpperRawClass(session, currentCeraShopUpdateMsgID,
|
||
buildCurrentCeraShopWalletBody(wallet.Cera, wallet.TokenCera, wallet.HappyTokenCera), 0); err != nil {
|
||
return dnfcerashop.Wallet{}, err
|
||
}
|
||
return wallet, nil
|
||
}
|