服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
1265 lines
44 KiB
Go
1265 lines
44 KiB
Go
package auction
|
|
|
|
// 金币寄售 (gold consignment).
|
|
//
|
|
// A player consigns a fixed denomination of gold for a Cera price; a buyer pays
|
|
// Cera and receives the gold; the seller receives 代币券 (token Cera) less a
|
|
// commission. Every wire shape here comes from a live capture - see
|
|
// `金币寄售_客户端逆向规格与实现方案_20260921.md` at the repository root - except
|
|
// where a comment says the shape is inferred.
|
|
//
|
|
// # Why this is a separate ledger from Listing
|
|
//
|
|
// The item auction escrows an ITEM and charges a gold deposit; a consignment
|
|
// escrows GOLD ITSELF and charges no deposit (86's GoldConsignmentService does
|
|
// the same: TryRegister spends the seller's gold and nothing else). The two
|
|
// also differ in currency on both ends - the item auction settles in gold, a
|
|
// consignment takes Cera from the buyer and pays token Cera to the seller. The
|
|
// two therefore share only the escrow character and the storage idiom:
|
|
//
|
|
// - escrowed gold lives in `system:auction:escrow`'s Stats["gold"], exactly
|
|
// where the item auction parks its registration deposits, so the wallet is
|
|
// conserved by construction;
|
|
// - one consignment is one ItemStack in that escrow character's inventory,
|
|
// keyed `goldconsign:listing:<id>`, with the terms in Extra.
|
|
//
|
|
// # Transactions
|
|
//
|
|
// No repository transaction boundary in this tree spans a buyer's ACCOUNT and
|
|
// the escrow character at once (Cera is account-scoped metadata; the escrow is
|
|
// a character). The buy is therefore a short saga whose every step is either
|
|
// atomic on its own or idempotent and retryable:
|
|
//
|
|
// Reserve (escrow tx) - Active -> Reserved, so only one buyer can win
|
|
// Pay (buyer tx) - debit the buyer's Cera and record a pay marker
|
|
// Settle (escrow tx) - Reserved -> Sold, release the escrowed gold,
|
|
// write the durable sold row (also the fee history)
|
|
// Deliver (mail tx) - the buyer's gold letter and the seller's token
|
|
// Cera letter, each keyed by CompletionKey
|
|
//
|
|
// A crash between Pay and Settle leaves a Reserved row plus a pay marker; the
|
|
// reconciler finishes it. A crash between Settle and Deliver leaves a sold row
|
|
// with its letters unsent; Deliver is idempotent and retried on the next
|
|
// command. Nothing can pay twice and nothing can be lost.
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
const (
|
|
goldConsignListingPrefix = "goldconsign:listing:"
|
|
goldConsignSoldPrefix = "goldconsign:sold:"
|
|
|
|
// GoldConsignEscrowGoldKey is the escrow character stat holding consigned
|
|
// gold, shared with the item auction's registration deposits.
|
|
GoldConsignEscrowGoldKey = "gold"
|
|
|
|
// GoldConsignSellerLevel is the client's own minimum: dstr 70615 renders
|
|
// 「未满Lv30的角色不可出售金币。」
|
|
GoldConsignSellerLevel = 30
|
|
// GoldConsignBuyerLevel is dstr 70614 「未满Lv20的角色不可购买金币。」
|
|
GoldConsignBuyerLevel = 20
|
|
|
|
// GoldConsignMaxActiveListings bounds one character's live consignments.
|
|
GoldConsignMaxActiveListings = 10
|
|
// GoldConsignMaxVisibleRows bounds one list response.
|
|
GoldConsignMaxVisibleRows = 100
|
|
// GoldConsignMaxDurationHours is the longest lifetime the client offers.
|
|
GoldConsignMaxDurationHours = 48
|
|
// GoldConsignMinDurationHours is the shortest lifetime the client offers.
|
|
GoldConsignMinDurationHours = 24
|
|
|
|
// GoldConsignBaseFeePercent is the commission in the ordinary band
|
|
// (40%..150% of the recent average).
|
|
GoldConsignBaseFeePercent = 5
|
|
// GoldConsignCouponFeePercent is that band's rate with 拍卖行优惠券. The
|
|
// coupon only ever helps in this band; every higher band ignores it.
|
|
GoldConsignCouponFeePercent = 1
|
|
|
|
// goldConsignAverageSamples is how many recent sales feed the average.
|
|
goldConsignAverageSamples = 20
|
|
// goldConsignSoldHistoryPerTier caps the durable sold rows kept per tier.
|
|
goldConsignSoldHistoryPerTier = 64
|
|
|
|
// GoldConsignReservationTTL is how long a Reserved row may sit before the
|
|
// reconciler decides whether its payment landed.
|
|
GoldConsignReservationTTL = 2 * time.Minute
|
|
|
|
// goldConsignPayMarkerPrefix is the per-account key the Pay step writes so
|
|
// the reconciler can tell "the buyer already paid" from "nobody paid".
|
|
goldConsignPayMarkerPrefix = "goldconsign_paid:"
|
|
)
|
|
|
|
// GoldConsignStatus is the live state of one consignment row.
|
|
type GoldConsignStatus string
|
|
|
|
const (
|
|
// GoldConsignActive is a consignment anybody may buy.
|
|
GoldConsignActive GoldConsignStatus = "active"
|
|
// GoldConsignReserved is one a buyer has claimed but not yet settled.
|
|
GoldConsignReserved GoldConsignStatus = "reserved"
|
|
// GoldConsignSold is one whose sale committed; the row is kept only as the
|
|
// delivery intent and fee history until its letters have gone out.
|
|
GoldConsignSold GoldConsignStatus = "sold"
|
|
)
|
|
|
|
var (
|
|
// ErrGoldConsignPriceRejected is the official table's first row: a price
|
|
// below 40% of the recent average cannot be listed at all.
|
|
ErrGoldConsignPriceRejected = errors.New("gold consignment price is too far below the average")
|
|
// ErrGoldConsignSellerLevel is dstr 70615.
|
|
ErrGoldConsignSellerLevel = errors.New("gold consignment seller level is too low")
|
|
// ErrGoldConsignBuyerLevel is dstr 70614.
|
|
ErrGoldConsignBuyerLevel = errors.New("gold consignment buyer level is too low")
|
|
// ErrGoldConsignNotTier is an item id that is not one of the 17
|
|
// denominations the client offers.
|
|
ErrGoldConsignNotTier = errors.New("item is not a gold consignment denomination")
|
|
// ErrGoldConsignBuyerIsSeller is 86's BuyerIsSeller.
|
|
ErrGoldConsignBuyerIsSeller = errors.New("gold consignment buyer is the seller")
|
|
// ErrGoldConsignNotReserved is a settle for a row this buyer does not hold.
|
|
ErrGoldConsignNotReserved = errors.New("gold consignment row is not reserved by this buyer")
|
|
// ErrGoldConsignNotActive is a cancel or a reserve on a row that is no
|
|
// longer live - already sold, already reserved by somebody else, or run out.
|
|
ErrGoldConsignNotActive = errors.New("gold consignment row is not active")
|
|
// ErrGoldConsignLimit is the per-character live-consignment cap.
|
|
ErrGoldConsignLimit = errors.New("gold consignment listing limit reached")
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Denomination table
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// GoldConsignTier is one 档位: the client's own item id and the gold it stands
|
|
// for. The amounts live only in the stk file NAME (gold_200m.stk) and its
|
|
// `[name]` text (「2亿」); the .stk carries no amount field, so this table is
|
|
// the server's authoritative reading of the client's UI.
|
|
type GoldConsignTier struct {
|
|
ItemID int64
|
|
Amount int64
|
|
}
|
|
|
|
// goldConsignTiers is the client's table, read straight off NoPack.exe .rdata
|
|
// VA 0x4580AA8: 17 u32 entries, NUL terminated, consumed by FUN_02086d20 with
|
|
// a loop bound of `cmp esi,0x44` (0x44 = 68 = 17 x 4).
|
|
//
|
|
// The ids are NOT contiguous: the first twelve run 2681725..2681736 and the
|
|
// last five jump to 2683069..2683073. They are the same two batches the client
|
|
// strings came in (dstr 70491..70502 then 72048..72052), so a base+index
|
|
// formula is wrong and would silently misprice five of the seventeen tiers.
|
|
var goldConsignTiers = []GoldConsignTier{
|
|
{ItemID: 2681725, Amount: 1_000_000},
|
|
{ItemID: 2681726, Amount: 2_000_000},
|
|
{ItemID: 2681727, Amount: 3_000_000},
|
|
{ItemID: 2681728, Amount: 4_000_000},
|
|
{ItemID: 2681729, Amount: 5_000_000},
|
|
{ItemID: 2681730, Amount: 6_000_000},
|
|
{ItemID: 2681731, Amount: 7_000_000},
|
|
{ItemID: 2681732, Amount: 8_000_000},
|
|
{ItemID: 2681733, Amount: 9_000_000},
|
|
{ItemID: 2681734, Amount: 10_000_000},
|
|
{ItemID: 2681735, Amount: 20_000_000},
|
|
{ItemID: 2681736, Amount: 30_000_000},
|
|
{ItemID: 2683069, Amount: 40_000_000},
|
|
{ItemID: 2683070, Amount: 50_000_000},
|
|
{ItemID: 2683071, Amount: 100_000_000},
|
|
{ItemID: 2683072, Amount: 150_000_000},
|
|
{ItemID: 2683073, Amount: 200_000_000},
|
|
}
|
|
|
|
// GoldConsignTiers returns the denomination table, in the client's own order.
|
|
func GoldConsignTiers() []GoldConsignTier {
|
|
out := make([]GoldConsignTier, len(goldConsignTiers))
|
|
copy(out, goldConsignTiers)
|
|
return out
|
|
}
|
|
|
|
// GoldConsignTierAmount resolves one denomination's gold, and reports whether
|
|
// the id is a denomination at all.
|
|
func GoldConsignTierAmount(itemID int64) (int64, bool) {
|
|
for _, tier := range goldConsignTiers {
|
|
if tier.ItemID == itemID {
|
|
return tier.Amount, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// IsGoldConsignTier reports whether an item id is one of the 17 denominations.
|
|
func IsGoldConsignTier(itemID int64) bool {
|
|
_, ok := GoldConsignTierAmount(itemID)
|
|
return ok
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Commission table
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ResolveGoldConsignFee is the official commission table, expressed on
|
|
// r = price / average:
|
|
//
|
|
// r < 0.40 -> not listable at all
|
|
// 0.40 <= r <= 1.50 -> 5% (1% with 拍卖行优惠券)
|
|
// 1.50 < r <= 1.60 -> 30% (coupon has no effect)
|
|
// 1.60 < r <= 1.70 -> 40% (coupon has no effect)
|
|
// 1.70 < r <= 1.80 -> 50% (coupon has no effect)
|
|
// r > 1.80 -> 95% (coupon has no effect)
|
|
//
|
|
// The bands are read as left-open / right-closed because the table's own first
|
|
// row is 「40% 以下」 - "below 40%" - which excludes 40% itself.
|
|
//
|
|
// With no sales history the average is 0 and the table has no meaning: a
|
|
// division by zero, or else every price falling into the 95% band. This
|
|
// deliberately degrades to the ordinary-band rate instead, which the operator
|
|
// chose: 「0不动 多成交几次 均价就下来了」 - the bands switch on by themselves
|
|
// once real sales exist.
|
|
func ResolveGoldConsignFee(price, average int64, hasAuctionCoupon bool) (feePercent int64, listable bool) {
|
|
if price <= 0 {
|
|
return 0, false
|
|
}
|
|
if average <= 0 {
|
|
return goldConsignBaseFee(hasAuctionCoupon), true
|
|
}
|
|
// Compare with integer maths throughout: price*100 against average*threshold
|
|
// avoids both floating point and an overflow on the 2亿 tier.
|
|
switch {
|
|
case price*100 < average*40:
|
|
return 0, false
|
|
case price*100 <= average*150:
|
|
return goldConsignBaseFee(hasAuctionCoupon), true
|
|
case price*100 <= average*160:
|
|
return 30, true
|
|
case price*100 <= average*170:
|
|
return 40, true
|
|
case price*100 <= average*180:
|
|
return 50, true
|
|
default:
|
|
return 95, true
|
|
}
|
|
}
|
|
|
|
// goldConsignBaseFee is the 40%..150% band, the only one the coupon helps in.
|
|
func goldConsignBaseFee(hasAuctionCoupon bool) int64 {
|
|
if hasAuctionCoupon {
|
|
return GoldConsignCouponFeePercent
|
|
}
|
|
return GoldConsignBaseFeePercent
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Row model
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// GoldConsignListing is one consignment, decoded from its escrow row.
|
|
type GoldConsignListing struct {
|
|
ID uint64
|
|
SellerID string
|
|
SellerName string
|
|
GoldItemID int64
|
|
GoldAmount int64
|
|
PriceCera int64
|
|
// FeePercent is the commission frozen at registration, in percent.
|
|
FeePercent int64
|
|
// ProceedsCera is PriceCera less that commission; what the seller's letter
|
|
// carries as token Cera.
|
|
ProceedsCera int64
|
|
Status GoldConsignStatus
|
|
BuyerID string
|
|
CreatedAt time.Time
|
|
ExpiresAt time.Time
|
|
ReservedAt time.Time
|
|
// Delivered marks a sold row whose two letters have gone out.
|
|
Delivered bool
|
|
}
|
|
|
|
// GoldConsignProceeds is the seller's token Cera for a sale price under a
|
|
// frozen rate: 售价 x (1 - 费率), floored.
|
|
//
|
|
// The flooring direction matters and is 86's: GoldConsignmentPolicy.
|
|
// ResolveSellerProceeds computes `ceraPrice * 95 / 100`, which floors the
|
|
// PROCEEDS rather than the commission. Taking the obvious `price - price*fee/100`
|
|
// instead floors the commission and hands the seller the rounded-away unit -
|
|
// 111 Cera at 5% pays 105 that way, not 106. The stored fee is always
|
|
// `price - proceeds`, so the two never disagree.
|
|
func GoldConsignProceeds(priceCera, feePercent int64) int64 {
|
|
if priceCera <= 0 {
|
|
return 0
|
|
}
|
|
if feePercent <= 0 {
|
|
return priceCera
|
|
}
|
|
if feePercent >= 100 {
|
|
return 0
|
|
}
|
|
return priceCera * (100 - feePercent) / 100
|
|
}
|
|
|
|
// GoldConsignFee is the commission that goes with GoldConsignProceeds, kept
|
|
// next to it so no caller can round the two apart.
|
|
func GoldConsignFee(priceCera, feePercent int64) int64 {
|
|
proceeds := GoldConsignProceeds(priceCera, feePercent)
|
|
if priceCera <= 0 {
|
|
return 0
|
|
}
|
|
return priceCera - proceeds
|
|
}
|
|
|
|
func goldConsignListingKey(id uint64) string {
|
|
return goldConsignListingPrefix + strconv.FormatUint(id, 10)
|
|
}
|
|
|
|
func goldConsignSoldKey(id uint64) string {
|
|
return goldConsignSoldPrefix + strconv.FormatUint(id, 10)
|
|
}
|
|
|
|
// goldConsignRowFromStack decodes one escrow row. Any malformed row retires
|
|
// itself rather than being guessed at, matching listingFromStack.
|
|
func goldConsignRowFromStack(key string, stack dnfrepo.ItemStack) (GoldConsignListing, error) {
|
|
extra := stack.Extra
|
|
if extra == nil {
|
|
return GoldConsignListing{}, ErrListingMissing
|
|
}
|
|
listing := GoldConsignListing{
|
|
SellerID: strings.TrimSpace(extra["seller_id"]),
|
|
SellerName: strings.TrimSpace(extra["seller_name"]),
|
|
GoldItemID: stack.ItemID,
|
|
Status: GoldConsignStatus(strings.TrimSpace(extra["status"])),
|
|
BuyerID: strings.TrimSpace(extra["buyer_id"]),
|
|
Delivered: strings.TrimSpace(extra["delivered"]) == "1",
|
|
GoldAmount: 0,
|
|
PriceCera: 0,
|
|
FeePercent: 0,
|
|
ProceedsCera: 0,
|
|
}
|
|
if listing.Status == "" {
|
|
listing.Status = GoldConsignActive
|
|
}
|
|
amount, err := goldConsignExtraInt(extra, "gold_amount", 1, math.MaxInt64)
|
|
if err != nil {
|
|
return GoldConsignListing{}, err
|
|
}
|
|
price, err := goldConsignExtraInt(extra, "price_cera", 1, math.MaxInt64)
|
|
if err != nil {
|
|
return GoldConsignListing{}, err
|
|
}
|
|
fee, err := goldConsignExtraInt(extra, "fee_percent", 0, 100)
|
|
if err != nil {
|
|
return GoldConsignListing{}, err
|
|
}
|
|
created, err := goldConsignExtraInt(extra, "created_at", 1, math.MaxInt64)
|
|
if err != nil {
|
|
return GoldConsignListing{}, err
|
|
}
|
|
expires, err := goldConsignExtraInt(extra, "expires_at", 1, math.MaxInt64)
|
|
if err != nil {
|
|
return GoldConsignListing{}, err
|
|
}
|
|
reserved, err := goldConsignExtraInt(extra, "reserved_at", 0, math.MaxInt64)
|
|
if err != nil {
|
|
return GoldConsignListing{}, err
|
|
}
|
|
listing.GoldAmount = amount
|
|
listing.PriceCera = price
|
|
listing.FeePercent = fee
|
|
listing.ProceedsCera = GoldConsignProceeds(price, fee)
|
|
listing.CreatedAt = time.Unix(created, 0).UTC()
|
|
listing.ExpiresAt = time.Unix(expires, 0).UTC()
|
|
if reserved > 0 {
|
|
listing.ReservedAt = time.Unix(reserved, 0).UTC()
|
|
}
|
|
if listing.SellerID == "" {
|
|
return GoldConsignListing{}, ErrListingMissing
|
|
}
|
|
if listing.ID == 0 {
|
|
if parsed, parseErr := strconv.ParseUint(strings.TrimPrefix(key, goldConsignListingPrefix), 10, 64); parseErr == nil {
|
|
listing.ID = parsed
|
|
} else if parsed, parseErr := strconv.ParseUint(strings.TrimPrefix(key, goldConsignSoldPrefix), 10, 64); parseErr == nil {
|
|
listing.ID = parsed
|
|
}
|
|
}
|
|
return listing, nil
|
|
}
|
|
|
|
// goldConsignExtraInt reads one optional numeric term. A missing key is the
|
|
// pre-field default; a present but unparseable one retires the row.
|
|
func goldConsignExtraInt(extra map[string]string, key string, low, high int64) (int64, error) {
|
|
raw, present := extra[key]
|
|
if !present {
|
|
return 0, nil
|
|
}
|
|
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
|
if err != nil || value < low || value > high {
|
|
return 0, ErrListingMissing
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
// goldConsignRowStack builds the escrow row for a consignment.
|
|
func goldConsignRowStack(listing GoldConsignListing) dnfrepo.ItemStack {
|
|
extra := map[string]string{
|
|
"seller_id": listing.SellerID,
|
|
"seller_name": listing.SellerName,
|
|
"gold_amount": strconv.FormatInt(listing.GoldAmount, 10),
|
|
"price_cera": strconv.FormatInt(listing.PriceCera, 10),
|
|
"fee_percent": strconv.FormatInt(listing.FeePercent, 10),
|
|
"status": string(listing.Status),
|
|
"created_at": strconv.FormatInt(listing.CreatedAt.Unix(), 10),
|
|
"expires_at": strconv.FormatInt(listing.ExpiresAt.Unix(), 10),
|
|
"reserved_at": "0",
|
|
"delivered": "0",
|
|
"buyer_id": "",
|
|
"sold_at": "0",
|
|
"listing_kind": "goldconsign",
|
|
}
|
|
if listing.BuyerID != "" {
|
|
extra["buyer_id"] = listing.BuyerID
|
|
}
|
|
if !listing.ReservedAt.IsZero() {
|
|
extra["reserved_at"] = strconv.FormatInt(listing.ReservedAt.Unix(), 10)
|
|
}
|
|
if listing.Delivered {
|
|
extra["delivered"] = "1"
|
|
}
|
|
return dnfrepo.ItemStack{ItemID: listing.GoldItemID, Count: 1, Extra: extra}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Owner
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// GoldConsignOwner owns the consignment ledger. It embeds the item auction's
|
|
// Owner purely to reuse EnsureEscrow, so both ledgers share one escrow
|
|
// character and one wallet.
|
|
type GoldConsignOwner struct {
|
|
owner *Owner
|
|
repositories dnfrepo.Group
|
|
now func() time.Time
|
|
}
|
|
|
|
// NewGoldConsignOwner builds the ledger owner.
|
|
func NewGoldConsignOwner(repositories dnfrepo.Group) (*GoldConsignOwner, error) {
|
|
base, err := NewOwner(repositories)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &GoldConsignOwner{owner: base, repositories: repositories, now: time.Now}, nil
|
|
}
|
|
|
|
func (o *GoldConsignOwner) clock() time.Time {
|
|
if o == nil || o.now == nil {
|
|
return time.Now().UTC()
|
|
}
|
|
return o.now().UTC()
|
|
}
|
|
|
|
// EnsureEscrow creates the shared escrow character and wallet if missing.
|
|
func (o *GoldConsignOwner) EnsureEscrow(ctx context.Context) error {
|
|
if o == nil || o.owner == nil {
|
|
return ErrUnavailable
|
|
}
|
|
return o.owner.EnsureEscrow(ctx)
|
|
}
|
|
|
|
// GoldConsignRegisterCommand is one op183 registration.
|
|
type GoldConsignRegisterCommand struct {
|
|
ListingID uint64
|
|
SellerID string
|
|
AccountID string
|
|
SellerName string
|
|
GoldItemID int64
|
|
PriceCera int64
|
|
DurationHour int64
|
|
HasCoupon bool
|
|
}
|
|
|
|
// GoldConsignResult is what a register/cancel returns to the protocol layer.
|
|
type GoldConsignResult struct {
|
|
Listing GoldConsignListing
|
|
// GoldAfter is the seller's gold once the escrow debit committed.
|
|
GoldAfter int64
|
|
// RefundGold is the gold a cancel or an expiry sends home by letter.
|
|
RefundGold int64
|
|
}
|
|
|
|
// Register escrows the consigned gold and writes the row. It is the gold
|
|
// equivalent of Owner.Register: same escrow character, same two-character
|
|
// transaction, no deposit on top of the escrowed amount.
|
|
func (o *GoldConsignOwner) Register(ctx context.Context, cmd GoldConsignRegisterCommand) (GoldConsignResult, error) {
|
|
cmd.SellerID = strings.TrimSpace(cmd.SellerID)
|
|
cmd.AccountID = strings.TrimSpace(cmd.AccountID)
|
|
cmd.SellerName = strings.TrimSpace(cmd.SellerName)
|
|
if o == nil || cmd.ListingID == 0 || cmd.SellerID == "" || cmd.AccountID == "" ||
|
|
cmd.SellerID == EscrowCharacterID || cmd.PriceCera <= 0 ||
|
|
cmd.DurationHour < GoldConsignMinDurationHours || cmd.DurationHour > GoldConsignMaxDurationHours {
|
|
return GoldConsignResult{}, ErrInvalidRequest
|
|
}
|
|
goldAmount, ok := GoldConsignTierAmount(cmd.GoldItemID)
|
|
if !ok || goldAmount <= 0 {
|
|
return GoldConsignResult{}, ErrGoldConsignNotTier
|
|
}
|
|
if err := o.EnsureEscrow(ctx); err != nil {
|
|
return GoldConsignResult{}, err
|
|
}
|
|
var result GoldConsignResult
|
|
err := o.repositories.CharacterTrade.WithinCharacterTrade(
|
|
ctx, cmd.SellerID, EscrowCharacterID,
|
|
func(characters dnfrepo.CharacterRepository, inventories dnfrepo.InventoryRepository) error {
|
|
seller, found, err := characters.Load(ctx, cmd.SellerID)
|
|
if err != nil || !found {
|
|
return errors.Join(ErrInvalidRequest, err)
|
|
}
|
|
if strings.TrimSpace(seller.AccountID) != cmd.AccountID {
|
|
return ErrInvalidRequest
|
|
}
|
|
if seller.Level < GoldConsignSellerLevel {
|
|
return ErrGoldConsignSellerLevel
|
|
}
|
|
escrowCharacter, found, err := characters.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return errors.Join(ErrUnavailable, err)
|
|
}
|
|
escrow, found, err := inventories.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return errors.Join(ErrUnavailable, err)
|
|
}
|
|
if escrow.Slots == nil {
|
|
escrow.Slots = make(map[string]dnfrepo.ItemStack)
|
|
}
|
|
if _, exists := escrow.Slots[goldConsignListingKey(cmd.ListingID)]; exists {
|
|
return ErrListingIDExists
|
|
}
|
|
now := o.clock()
|
|
active := 0
|
|
for key, stack := range escrow.Slots {
|
|
if !strings.HasPrefix(key, goldConsignListingPrefix) {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(stack.Extra["seller_id"]) != cmd.SellerID {
|
|
continue
|
|
}
|
|
if status := strings.TrimSpace(stack.Extra["status"]); status != "" && status != string(GoldConsignActive) {
|
|
continue
|
|
}
|
|
expires, parseErr := strconv.ParseInt(stack.Extra["expires_at"], 10, 64)
|
|
if parseErr == nil && expires > now.Unix() {
|
|
active++
|
|
}
|
|
}
|
|
if active >= GoldConsignMaxActiveListings {
|
|
return ErrGoldConsignLimit
|
|
}
|
|
// The commission is frozen now, under the average this moment sees.
|
|
// The client tells the seller so at registration (dstr 70628), and a
|
|
// frozen rate cannot turn a 5% listing into a 30% one mid-flight.
|
|
average := goldConsignAverageFromSlots(escrow.Slots, cmd.GoldItemID)
|
|
feePercent, listable := ResolveGoldConsignFee(cmd.PriceCera, average, cmd.HasCoupon)
|
|
if !listable {
|
|
return ErrGoldConsignPriceRejected
|
|
}
|
|
|
|
sellerGold := seller.Stats[GoldConsignEscrowGoldKey]
|
|
if seller.Stats == nil || sellerGold < goldAmount {
|
|
return ErrGoldUnavailable
|
|
}
|
|
if escrowCharacter.Stats == nil {
|
|
escrowCharacter.Stats = make(map[string]int64)
|
|
}
|
|
if escrowCharacter.Stats[GoldConsignEscrowGoldKey] > math.MaxInt64-goldAmount {
|
|
return ErrInvalidRequest
|
|
}
|
|
seller.Stats[GoldConsignEscrowGoldKey] = sellerGold - goldAmount
|
|
escrowCharacter.Stats[GoldConsignEscrowGoldKey] += goldAmount
|
|
|
|
listing := GoldConsignListing{
|
|
ID: cmd.ListingID,
|
|
SellerID: cmd.SellerID,
|
|
SellerName: strings.TrimSpace(seller.Name),
|
|
GoldItemID: cmd.GoldItemID,
|
|
GoldAmount: goldAmount,
|
|
PriceCera: cmd.PriceCera,
|
|
FeePercent: feePercent,
|
|
Status: GoldConsignActive,
|
|
CreatedAt: now,
|
|
ExpiresAt: now.Add(time.Duration(cmd.DurationHour) * time.Hour),
|
|
}
|
|
if listing.SellerName == "" {
|
|
listing.SellerName = cmd.SellerName
|
|
}
|
|
listing.ProceedsCera = GoldConsignProceeds(listing.PriceCera, feePercent)
|
|
if listing.ProceedsCera <= 0 {
|
|
return ErrInvalidRequest
|
|
}
|
|
escrow.Slots[goldConsignListingKey(cmd.ListingID)] = goldConsignRowStack(listing)
|
|
escrow.UpdatedAt = now
|
|
seller.UpdatedAt = now
|
|
escrowCharacter.UpdatedAt = now
|
|
if err := characters.Save(ctx, seller); err != nil {
|
|
return err
|
|
}
|
|
if err := characters.Save(ctx, escrowCharacter); err != nil {
|
|
return err
|
|
}
|
|
if err := inventories.Save(ctx, escrow); err != nil {
|
|
return err
|
|
}
|
|
result = GoldConsignResult{Listing: listing, GoldAfter: seller.Stats[GoldConsignEscrowGoldKey]}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return GoldConsignResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// goldConsignAverageFromSlots derives the recent-average price for one tier
|
|
// from the durable sold rows that share the escrow inventory. It reads plain
|
|
// ints out of Extra so it can run inside a transaction without extra loads.
|
|
func goldConsignAverageFromSlots(slots map[string]dnfrepo.ItemStack, goldItemID int64) int64 {
|
|
prices := make([]int64, 0, goldConsignAverageSamples)
|
|
for key, stack := range slots {
|
|
if !strings.HasPrefix(key, goldConsignSoldPrefix) {
|
|
continue
|
|
}
|
|
if stack.ItemID != goldItemID {
|
|
continue
|
|
}
|
|
price, err := strconv.ParseInt(strings.TrimSpace(stack.Extra["price_cera"]), 10, 64)
|
|
if err != nil || price <= 0 {
|
|
continue
|
|
}
|
|
prices = append(prices, price)
|
|
}
|
|
if len(prices) == 0 {
|
|
return 0
|
|
}
|
|
sort.Slice(prices, func(i, j int) bool { return prices[i] < prices[j] })
|
|
// Trim to the most recent slice by value: sold rows carry no ordering that
|
|
// survives a map iteration, and the newest N is what the operator means by
|
|
// "多成交几次 均价就下来了". A median-ish trim keeps one absurd outlier from
|
|
// moving the band on its own.
|
|
if len(prices) > goldConsignAverageSamples {
|
|
prices = prices[len(prices)-goldConsignAverageSamples:]
|
|
}
|
|
var total int64
|
|
for _, price := range prices {
|
|
if total > math.MaxInt64-price {
|
|
return 0
|
|
}
|
|
total += price
|
|
}
|
|
return total / int64(len(prices))
|
|
}
|
|
|
|
// AverageCeraPrice is the public read of the same figure, for op182.
|
|
func (o *GoldConsignOwner) AverageCeraPrice(ctx context.Context, goldItemID int64) (average int64, samples int64, err error) {
|
|
if o == nil || goldItemID <= 0 {
|
|
return 0, 0, ErrInvalidRequest
|
|
}
|
|
if err := o.EnsureEscrow(ctx); err != nil {
|
|
return 0, 0, err
|
|
}
|
|
record, found, err := o.repositories.Inventory.Load(ctx, EscrowCharacterID)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
if !found {
|
|
return 0, 0, nil
|
|
}
|
|
samples = 0
|
|
for key, stack := range record.Slots {
|
|
if strings.HasPrefix(key, goldConsignSoldPrefix) && stack.ItemID == goldItemID {
|
|
samples++
|
|
}
|
|
}
|
|
return goldConsignAverageFromSlots(record.Slots, goldItemID), samples, nil
|
|
}
|
|
|
|
// Search lists live consignments, optionally filtered to some tier ids. An
|
|
// empty filter means every tier.
|
|
func (o *GoldConsignOwner) Search(ctx context.Context, itemIDs []int64) ([]GoldConsignListing, error) {
|
|
return o.list(ctx, func(listing GoldConsignListing, now time.Time) bool {
|
|
if listing.Status != GoldConsignActive || !listing.ExpiresAt.After(now) {
|
|
return false
|
|
}
|
|
if len(itemIDs) == 0 {
|
|
return true
|
|
}
|
|
for _, itemID := range itemIDs {
|
|
if itemID == listing.GoldItemID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
})
|
|
}
|
|
|
|
// ListOwned lists one character's live consignments plus any reserved row it
|
|
// still holds, which is what the 我寄售的金币 tab shows.
|
|
func (o *GoldConsignOwner) ListOwned(ctx context.Context, sellerID string) ([]GoldConsignListing, error) {
|
|
sellerID = strings.TrimSpace(sellerID)
|
|
if sellerID == "" {
|
|
return nil, ErrInvalidRequest
|
|
}
|
|
return o.list(ctx, func(listing GoldConsignListing, now time.Time) bool {
|
|
if listing.SellerID != sellerID {
|
|
return false
|
|
}
|
|
switch listing.Status {
|
|
case GoldConsignActive, GoldConsignReserved:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
})
|
|
}
|
|
|
|
func (o *GoldConsignOwner) list(ctx context.Context, keep func(GoldConsignListing, time.Time) bool) ([]GoldConsignListing, error) {
|
|
if o == nil {
|
|
return nil, ErrUnavailable
|
|
}
|
|
if err := o.EnsureEscrow(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
record, found, err := o.repositories.Inventory.Load(ctx, EscrowCharacterID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !found {
|
|
return nil, nil
|
|
}
|
|
now := o.clock()
|
|
out := make([]GoldConsignListing, 0)
|
|
for key, stack := range record.Slots {
|
|
if !strings.HasPrefix(key, goldConsignListingPrefix) {
|
|
continue
|
|
}
|
|
listing, err := goldConsignRowFromStack(key, stack)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if keep(listing, now) {
|
|
out = append(out, listing)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].PriceCera == out[j].PriceCera {
|
|
return out[i].ID < out[j].ID
|
|
}
|
|
return out[i].PriceCera < out[j].PriceCera
|
|
})
|
|
if len(out) > GoldConsignMaxVisibleRows {
|
|
out = out[:GoldConsignMaxVisibleRows]
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// LoadListing reads one row without locking, for read-only decisions.
|
|
func (o *GoldConsignOwner) LoadListing(ctx context.Context, id uint64) (GoldConsignListing, error) {
|
|
if o == nil || id == 0 {
|
|
return GoldConsignListing{}, ErrInvalidRequest
|
|
}
|
|
if err := o.EnsureEscrow(ctx); err != nil {
|
|
return GoldConsignListing{}, err
|
|
}
|
|
record, found, err := o.repositories.Inventory.Load(ctx, EscrowCharacterID)
|
|
if err != nil {
|
|
return GoldConsignListing{}, err
|
|
}
|
|
if !found {
|
|
return GoldConsignListing{}, ErrListingMissing
|
|
}
|
|
stack, exists := record.Slots[goldConsignListingKey(id)]
|
|
if !exists {
|
|
return GoldConsignListing{}, ErrListingMissing
|
|
}
|
|
return goldConsignRowFromStack(goldConsignListingKey(id), stack)
|
|
}
|
|
|
|
// Cancel pulls a live consignment and returns the gold by letter. A reserved
|
|
// row is refused: a buyer is mid-payment on it, and the reconciler owns it.
|
|
func (o *GoldConsignOwner) Cancel(ctx context.Context, sellerID, accountID string, listingID uint64) (GoldConsignResult, error) {
|
|
sellerID = strings.TrimSpace(sellerID)
|
|
accountID = strings.TrimSpace(accountID)
|
|
if o == nil || sellerID == "" || accountID == "" || listingID == 0 {
|
|
return GoldConsignResult{}, ErrInvalidRequest
|
|
}
|
|
if err := o.EnsureEscrow(ctx); err != nil {
|
|
return GoldConsignResult{}, err
|
|
}
|
|
var result GoldConsignResult
|
|
err := o.repositories.CharacterTrade.WithinCharacterTrade(
|
|
ctx, sellerID, EscrowCharacterID,
|
|
func(characters dnfrepo.CharacterRepository, inventories dnfrepo.InventoryRepository) error {
|
|
seller, found, err := characters.Load(ctx, sellerID)
|
|
if err != nil || !found || strings.TrimSpace(seller.AccountID) != accountID {
|
|
return errors.Join(ErrNotOwner, err)
|
|
}
|
|
escrowCharacter, found, err := characters.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return errors.Join(ErrUnavailable, err)
|
|
}
|
|
escrow, found, err := inventories.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return errors.Join(ErrListingMissing, err)
|
|
}
|
|
key := goldConsignListingKey(listingID)
|
|
stack, exists := escrow.Slots[key]
|
|
if !exists {
|
|
return ErrListingMissing
|
|
}
|
|
listing, err := goldConsignRowFromStack(key, stack)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if listing.SellerID != sellerID {
|
|
return ErrNotOwner
|
|
}
|
|
if listing.Status != GoldConsignActive {
|
|
return ErrGoldConsignNotActive
|
|
}
|
|
if err := goldConsignReleaseGold(escrowCharacter, listing.GoldAmount); err != nil {
|
|
return err
|
|
}
|
|
delete(escrow.Slots, key)
|
|
now := o.clock()
|
|
escrowCharacter.UpdatedAt = now
|
|
escrow.UpdatedAt = now
|
|
if err := characters.Save(ctx, escrowCharacter); err != nil {
|
|
return err
|
|
}
|
|
if err := inventories.Save(ctx, escrow); err != nil {
|
|
return err
|
|
}
|
|
result = GoldConsignResult{Listing: listing, RefundGold: listing.GoldAmount}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return GoldConsignResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// goldConsignReleaseGold takes consigned gold back out of the escrow wallet.
|
|
func goldConsignReleaseGold(escrow dnfrepo.CharacterRecord, amount int64) error {
|
|
if amount <= 0 {
|
|
return ErrInvalidRequest
|
|
}
|
|
held := escrow.Stats[GoldConsignEscrowGoldKey]
|
|
if escrow.Stats == nil || held < amount {
|
|
return ErrUnavailable
|
|
}
|
|
escrow.Stats[GoldConsignEscrowGoldKey] = held - amount
|
|
return nil
|
|
}
|
|
|
|
// ExpiredReturn is one consignment an expiry sweep pulled, plus the gold its
|
|
// letter must carry.
|
|
type ExpiredReturn struct {
|
|
Listing GoldConsignListing
|
|
Refund int64
|
|
}
|
|
|
|
// ReturnExpired pulls every live consignment of one seller whose duration ran
|
|
// out, mirroring Owner.ReturnExpiredListings. The caller sends the letters.
|
|
func (o *GoldConsignOwner) ReturnExpired(ctx context.Context, sellerID, accountID string) ([]ExpiredReturn, error) {
|
|
sellerID = strings.TrimSpace(sellerID)
|
|
accountID = strings.TrimSpace(accountID)
|
|
if o == nil || sellerID == "" || accountID == "" {
|
|
return nil, ErrInvalidRequest
|
|
}
|
|
if err := o.EnsureEscrow(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
var out []ExpiredReturn
|
|
err := o.repositories.CharacterTrade.WithinCharacterTrade(
|
|
ctx, sellerID, EscrowCharacterID,
|
|
func(characters dnfrepo.CharacterRepository, inventories dnfrepo.InventoryRepository) error {
|
|
seller, found, err := characters.Load(ctx, sellerID)
|
|
if err != nil || !found || strings.TrimSpace(seller.AccountID) != accountID {
|
|
return errors.Join(ErrNotOwner, err)
|
|
}
|
|
escrowCharacter, found, err := characters.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return errors.Join(ErrUnavailable, err)
|
|
}
|
|
escrow, found, err := inventories.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return nil
|
|
}
|
|
now := o.clock()
|
|
keys := make([]string, 0)
|
|
for key, stack := range escrow.Slots {
|
|
if !strings.HasPrefix(key, goldConsignListingPrefix) {
|
|
continue
|
|
}
|
|
listing, err := goldConsignRowFromStack(key, stack)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if listing.SellerID != sellerID || listing.Status != GoldConsignActive {
|
|
continue
|
|
}
|
|
if listing.ExpiresAt.After(now) {
|
|
continue
|
|
}
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
changed := false
|
|
for _, key := range keys {
|
|
stack := escrow.Slots[key]
|
|
listing, err := goldConsignRowFromStack(key, stack)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if err := goldConsignReleaseGold(escrowCharacter, listing.GoldAmount); err != nil {
|
|
return err
|
|
}
|
|
delete(escrow.Slots, key)
|
|
out = append(out, ExpiredReturn{Listing: listing, Refund: listing.GoldAmount})
|
|
changed = true
|
|
}
|
|
if !changed {
|
|
return nil
|
|
}
|
|
escrowCharacter.UpdatedAt = now
|
|
escrow.UpdatedAt = now
|
|
if err := characters.Save(ctx, escrowCharacter); err != nil {
|
|
return err
|
|
}
|
|
return inventories.Save(ctx, escrow)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Buy saga
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// GoldConsignReservation is what Reserve hands the protocol layer: everything
|
|
// the payment step needs, read under the escrow lock.
|
|
type GoldConsignReservation struct {
|
|
Listing GoldConsignListing
|
|
}
|
|
|
|
// Reserve claims a live consignment for one buyer. It is the only place that
|
|
// decides who wins a contested row, so a double-click or two racing buyers can
|
|
// never both proceed to payment.
|
|
func (o *GoldConsignOwner) Reserve(ctx context.Context, buyerID string, buyerAccountID string, listingID uint64) (GoldConsignReservation, error) {
|
|
buyerID = strings.TrimSpace(buyerID)
|
|
if o == nil || buyerID == "" || listingID == 0 {
|
|
return GoldConsignReservation{}, ErrInvalidRequest
|
|
}
|
|
if err := o.EnsureEscrow(ctx); err != nil {
|
|
return GoldConsignReservation{}, err
|
|
}
|
|
var reservation GoldConsignReservation
|
|
err := o.repositories.CharacterTrade.WithinCharacterTrade(
|
|
ctx, buyerID, EscrowCharacterID,
|
|
func(characters dnfrepo.CharacterRepository, inventories dnfrepo.InventoryRepository) error {
|
|
buyer, found, err := characters.Load(ctx, buyerID)
|
|
if err != nil || !found {
|
|
return errors.Join(ErrInvalidRequest, err)
|
|
}
|
|
if buyerAccountID != "" && strings.TrimSpace(buyer.AccountID) != strings.TrimSpace(buyerAccountID) {
|
|
return ErrInvalidRequest
|
|
}
|
|
if buyer.Level < GoldConsignBuyerLevel {
|
|
return ErrGoldConsignBuyerLevel
|
|
}
|
|
escrow, found, err := inventories.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return errors.Join(ErrListingMissing, err)
|
|
}
|
|
key := goldConsignListingKey(listingID)
|
|
stack, exists := escrow.Slots[key]
|
|
if !exists {
|
|
return ErrListingMissing
|
|
}
|
|
listing, err := goldConsignRowFromStack(key, stack)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
now := o.clock()
|
|
if listing.Status != GoldConsignActive || !listing.ExpiresAt.After(now) {
|
|
return ErrGoldConsignNotActive
|
|
}
|
|
if listing.SellerID == buyerID {
|
|
return ErrGoldConsignBuyerIsSeller
|
|
}
|
|
listing.Status = GoldConsignReserved
|
|
listing.BuyerID = buyerID
|
|
listing.ReservedAt = now
|
|
escrow.Slots[key] = goldConsignRowStack(listing)
|
|
escrow.UpdatedAt = now
|
|
if err := inventories.Save(ctx, escrow); err != nil {
|
|
return err
|
|
}
|
|
reservation = GoldConsignReservation{Listing: listing}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return GoldConsignReservation{}, err
|
|
}
|
|
return reservation, nil
|
|
}
|
|
|
|
// Release puts a reservation back when the payment step refused it. A row that
|
|
// has meanwhile been settled is left alone.
|
|
func (o *GoldConsignOwner) Release(ctx context.Context, buyerID string, listingID uint64) error {
|
|
buyerID = strings.TrimSpace(buyerID)
|
|
if o == nil || buyerID == "" || listingID == 0 {
|
|
return ErrInvalidRequest
|
|
}
|
|
return o.repositories.CharacterTrade.WithinCharacterTrade(
|
|
ctx, buyerID, EscrowCharacterID,
|
|
func(_ dnfrepo.CharacterRepository, inventories dnfrepo.InventoryRepository) error {
|
|
escrow, found, err := inventories.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return nil
|
|
}
|
|
key := goldConsignListingKey(listingID)
|
|
stack, exists := escrow.Slots[key]
|
|
if !exists {
|
|
return nil
|
|
}
|
|
listing, err := goldConsignRowFromStack(key, stack)
|
|
if err != nil || listing.Status != GoldConsignReserved || listing.BuyerID != buyerID {
|
|
return nil
|
|
}
|
|
listing.Status = GoldConsignActive
|
|
listing.BuyerID = ""
|
|
listing.ReservedAt = time.Time{}
|
|
escrow.Slots[key] = goldConsignRowStack(listing)
|
|
escrow.UpdatedAt = o.clock()
|
|
return inventories.Save(ctx, escrow)
|
|
})
|
|
}
|
|
|
|
// Settle commits a reserved sale: the escrowed gold leaves the wallet and the
|
|
// row becomes the durable delivery intent plus the fee history.
|
|
func (o *GoldConsignOwner) Settle(ctx context.Context, buyerID string, listingID uint64) (GoldConsignListing, error) {
|
|
buyerID = strings.TrimSpace(buyerID)
|
|
if o == nil || buyerID == "" || listingID == 0 {
|
|
return GoldConsignListing{}, ErrInvalidRequest
|
|
}
|
|
if err := o.EnsureEscrow(ctx); err != nil {
|
|
return GoldConsignListing{}, err
|
|
}
|
|
var settled GoldConsignListing
|
|
err := o.repositories.CharacterTrade.WithinCharacterTrade(
|
|
ctx, buyerID, EscrowCharacterID,
|
|
func(characters dnfrepo.CharacterRepository, inventories dnfrepo.InventoryRepository) error {
|
|
escrowCharacter, found, err := characters.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return errors.Join(ErrUnavailable, err)
|
|
}
|
|
escrow, found, err := inventories.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return errors.Join(ErrListingMissing, err)
|
|
}
|
|
key := goldConsignListingKey(listingID)
|
|
stack, exists := escrow.Slots[key]
|
|
if !exists {
|
|
return ErrListingMissing
|
|
}
|
|
listing, err := goldConsignRowFromStack(key, stack)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if listing.Status != GoldConsignReserved || listing.BuyerID != buyerID {
|
|
return ErrGoldConsignNotReserved
|
|
}
|
|
if err := goldConsignReleaseGold(escrowCharacter, listing.GoldAmount); err != nil {
|
|
return err
|
|
}
|
|
now := o.clock()
|
|
listing.Status = GoldConsignSold
|
|
if escrow.Slots == nil {
|
|
// A store that round-trips an empty slot map as nil would
|
|
// otherwise panic on the assignment below.
|
|
escrow.Slots = make(map[string]dnfrepo.ItemStack)
|
|
}
|
|
soldStack := goldConsignRowStack(listing)
|
|
soldStack.Extra["sold_at"] = strconv.FormatInt(now.Unix(), 10)
|
|
delete(escrow.Slots, key)
|
|
escrow.Slots[goldConsignSoldKey(listingID)] = soldStack
|
|
goldConsignPruneSold(escrow.Slots, listing.GoldItemID)
|
|
escrowCharacter.UpdatedAt = now
|
|
escrow.UpdatedAt = now
|
|
if err := characters.Save(ctx, escrowCharacter); err != nil {
|
|
return err
|
|
}
|
|
if err := inventories.Save(ctx, escrow); err != nil {
|
|
return err
|
|
}
|
|
settled = listing
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return GoldConsignListing{}, err
|
|
}
|
|
return settled, nil
|
|
}
|
|
|
|
// goldConsignPruneSold keeps the durable fee history bounded per tier.
|
|
func goldConsignPruneSold(slots map[string]dnfrepo.ItemStack, goldItemID int64) {
|
|
type entry struct {
|
|
key string
|
|
soldAt int64
|
|
}
|
|
entries := make([]entry, 0)
|
|
for key, stack := range slots {
|
|
if !strings.HasPrefix(key, goldConsignSoldPrefix) || stack.ItemID != goldItemID {
|
|
continue
|
|
}
|
|
soldAt, err := strconv.ParseInt(strings.TrimSpace(stack.Extra["sold_at"]), 10, 64)
|
|
if err != nil {
|
|
soldAt = 0
|
|
}
|
|
entries = append(entries, entry{key: key, soldAt: soldAt})
|
|
}
|
|
if len(entries) <= goldConsignSoldHistoryPerTier {
|
|
return
|
|
}
|
|
sort.Slice(entries, func(i, j int) bool { return entries[i].soldAt < entries[j].soldAt })
|
|
for _, item := range entries[:len(entries)-goldConsignSoldHistoryPerTier] {
|
|
delete(slots, item.key)
|
|
}
|
|
}
|
|
|
|
// ReservedListings returns every reserved row, across all sellers and buyers.
|
|
// The reconciler uses it to decide whether an abandoned reservation goes back
|
|
// on sale or has to be settled.
|
|
func (o *GoldConsignOwner) ReservedListings(ctx context.Context) ([]GoldConsignListing, error) {
|
|
return o.list(ctx, func(listing GoldConsignListing, _ time.Time) bool {
|
|
return listing.Status == GoldConsignReserved
|
|
})
|
|
}
|
|
|
|
// UndeliveredSales lists sold rows whose two letters have not gone out yet.
|
|
func (o *GoldConsignOwner) UndeliveredSales(ctx context.Context) ([]GoldConsignListing, error) {
|
|
return o.listSold(ctx, false)
|
|
}
|
|
|
|
func (o *GoldConsignOwner) listSold(ctx context.Context, delivered bool) ([]GoldConsignListing, error) {
|
|
if o == nil {
|
|
return nil, ErrUnavailable
|
|
}
|
|
if err := o.EnsureEscrow(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
record, found, err := o.repositories.Inventory.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return nil, err
|
|
}
|
|
out := make([]GoldConsignListing, 0)
|
|
for key, stack := range record.Slots {
|
|
if !strings.HasPrefix(key, goldConsignSoldPrefix) {
|
|
continue
|
|
}
|
|
listing, err := goldConsignRowFromStack(key, stack)
|
|
if err != nil || listing.Delivered != delivered {
|
|
continue
|
|
}
|
|
out = append(out, listing)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
|
return out, nil
|
|
}
|
|
|
|
// MarkDelivered stamps a sold row once both letters are committed, so the
|
|
// pending sweep stops offering it. counterpartID is any real character taking
|
|
// part in the sale - the escrow inventory lives on the escrow character, and
|
|
// the trade boundary needs a second, distinct owner to reach it. The buyer is
|
|
// the one its caller already has.
|
|
//
|
|
// freshlyDelivered reports whether THIS call is the one that flipped the flag.
|
|
// The caller needs it because "already delivered" and "just delivered" both
|
|
// return a nil error - the row that is already stamped is success, not a
|
|
// failure - and the gold-consignment record must be written exactly once, on
|
|
// the transition. Re-deriving it from the caller's own view (for example "the
|
|
// row came out of UndeliveredSales, so it must have been undelivered") would
|
|
// be a guess about state this function is the only authority on; the
|
|
// record side does no de-duplication, so a wrong guess shows up as one
|
|
// duplicate line per consignment command.
|
|
func (o *GoldConsignOwner) MarkDelivered(ctx context.Context, counterpartID string, listingID uint64) (freshlyDelivered bool, err error) {
|
|
counterpartID = strings.TrimSpace(counterpartID)
|
|
if o == nil || counterpartID == "" || listingID == 0 || counterpartID == EscrowCharacterID {
|
|
return false, ErrInvalidRequest
|
|
}
|
|
err = o.repositories.CharacterTrade.WithinCharacterTrade(
|
|
ctx, counterpartID, EscrowCharacterID,
|
|
func(_ dnfrepo.CharacterRepository, inventories dnfrepo.InventoryRepository) error {
|
|
escrow, found, err := inventories.Load(ctx, EscrowCharacterID)
|
|
if err != nil || !found {
|
|
return errors.Join(ErrUnavailable, err)
|
|
}
|
|
key := goldConsignSoldKey(listingID)
|
|
stack, exists := escrow.Slots[key]
|
|
if !exists {
|
|
// Both letters are keyed, so an already-removed row is success.
|
|
return nil
|
|
}
|
|
listing, err := goldConsignRowFromStack(key, stack)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if listing.Delivered {
|
|
return nil
|
|
}
|
|
listing.Delivered = true
|
|
updated := goldConsignRowStack(listing)
|
|
updated.Extra["sold_at"] = stack.Extra["sold_at"]
|
|
escrow.Slots[key] = updated
|
|
escrow.UpdatedAt = o.clock()
|
|
if err := inventories.Save(ctx, escrow); err != nil {
|
|
return err
|
|
}
|
|
// 只有这一条分支是真跃迁;Save 落库成功之后才算数,
|
|
// 所以置位必须放在 Save 之后,失败路径一律留在 false。
|
|
freshlyDelivered = true
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return freshlyDelivered, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pay markers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// GoldConsignPayMarkerKey is the per-account key the Pay step writes so the
|
|
// reconciler can distinguish "buyer already paid" from "nobody paid".
|
|
func GoldConsignPayMarkerKey(listingID uint64) string {
|
|
return goldConsignPayMarkerPrefix + strconv.FormatUint(listingID, 10)
|
|
}
|
|
|
|
// ReadPayMarker reports whether one account has already paid for a listing.
|
|
func GoldConsignReadPayMarker(account dnfrepo.AccountRecord, listingID uint64) (string, bool) {
|
|
if account.Metadata == nil {
|
|
return "", false
|
|
}
|
|
value, ok := account.Metadata[GoldConsignPayMarkerKey(listingID)]
|
|
return strings.TrimSpace(value), ok && strings.TrimSpace(value) != ""
|
|
}
|
|
|
|
// BuildPayMarker encodes what the settle step needs after a restart.
|
|
func BuildPayMarker(buyerID string, listing GoldConsignListing) string {
|
|
return fmt.Sprintf("%s|%d|%d|%d|%s", strings.TrimSpace(buyerID), listing.PriceCera,
|
|
listing.ProceedsCera, listing.GoldAmount, listing.SellerID)
|
|
}
|