package modules import ( "context" "database/sql" "encoding/json" "github.com/heroiclabs/nakama-common/runtime" ) // RegisterEconomy 注册经济/背包/交易行/拍卖/情报相关 RPC。 func RegisterEconomy(initializer runtime.Initializer) error { rpcs := map[string]func(context.Context, runtime.Logger, *sql.DB, runtime.NakamaModule, string) (string, error){ "EconomyService/GetCurrencyBalances": getCurrencyBalances, "EconomyService/CreateMarketOrder": createMarketOrder, "EconomyService/CancelMarketOrder": cancelMarketOrder, "EconomyService/BuyMarketOrder": buyMarketOrder, "EconomyService/ListAuctions": listAuctions, "EconomyService/BidAuction": bidAuction, "EconomyService/ListIntelligence": listIntelligence, "EconomyService/BuyIntelligence": buyIntelligence, } for name, fn := range rpcs { if err := initializer.RegisterRpc(name, fn); err != nil { return err } } return nil } type currencyReq struct { CharacterID string `json:"character_id"` } type marketOrderReq struct { InventoryID string `json:"inventory_id"` CurrencyCode string `json:"currency_code"` UnitPrice string `json:"unit_price"` Quantity int32 `json:"quantity"` DurationHours int32 `json:"duration_hours"` } type orderIDReq struct { OrderID string `json:"order_id"` } type buyOrderReq struct { OrderID string `json:"order_id"` Quantity int32 `json:"quantity"` } type auctionListReq struct { WorldTier int32 `json:"world_tier"` AuctionType string `json:"auction_type"` Category string `json:"category"` Status string `json:"status"` Page int32 `json:"page"` PageSize int32 `json:"page_size"` } type bidReq struct { AuctionID string `json:"auction_id"` Amount string `json:"amount"` IsAutoBid bool `json:"is_auto_bid"` AutoMaxAmount string `json:"auto_max_amount"` } type intelListReq struct { IntelType string `json:"intel_type"` TargetWorldTier int32 `json:"target_world_tier"` Page int32 `json:"page"` PageSize int32 `json:"page_size"` } type buyIntelReq struct { OrderID string `json:"order_id"` } type currencyBalanceData struct { CurrencyCode string `json:"currency_code"` Amount string `json:"amount"` TotalEarned string `json:"total_earned"` TotalSpent string `json:"total_spent"` } type marketOrderData struct { OrderID string `json:"order_id"` Status string `json:"status"` ListedAt string `json:"listed_at"` ExpiredAt string `json:"expired_at"` TaxRate float64 `json:"tax_rate"` } type tradeData struct { TradeID string `json:"trade_id"` OrderID string `json:"order_id"` Quantity int32 `json:"quantity"` TotalPrice string `json:"total_price"` Tax string `json:"tax"` CurrencyCode string `json:"currency_code"` Items []itemAmountData `json:"items"` } type itemAmountData struct { InventoryID string `json:"inventory_id"` ItemID string `json:"item_id"` Quantity int32 `json:"quantity"` } type auctionItemData struct { AuctionID string `json:"auction_id"` SellerID string `json:"seller_id"` ItemID string `json:"item_id"` Category string `json:"category"` CurrencyCode string `json:"currency_code"` StartPrice string `json:"start_price"` Status string `json:"status"` StartedAt string `json:"started_at"` EndedAt string `json:"ended_at"` } type auctionListData struct { Total int32 `json:"total"` List []auctionItemData `json:"list"` } type bidData struct { BidID string `json:"bid_id"` AuctionID string `json:"auction_id"` Amount string `json:"amount"` DepositPaid string `json:"deposit_paid"` IsAutoBid bool `json:"is_auto_bid"` BidAt string `json:"bid_at"` } type intelItemData struct { OrderID string `json:"order_id"` SellerID string `json:"seller_id"` IntelType string `json:"intel_type"` TargetWorldTier int32 `json:"target_world_tier"` ContentSummary string `json:"content_summary"` CurrencyCode string `json:"currency_code"` Price string `json:"price"` PurchaseCount int32 `json:"purchase_count"` MaxPurchase int32 `json:"max_purchase"` } type intelListData struct { Total int32 `json:"total"` List []intelItemData `json:"list"` } type intelBuffData struct { BuffID string `json:"buff_id"` OrderID string `json:"order_id"` DetailData interface{} `json:"detail_data"` ExpiresAt string `json:"expires_at"` } func getCurrencyBalances(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) { traceID := newTraceID() var req currencyReq if err := json.Unmarshal([]byte(payload), &req); err != nil { return errResp(6001, "invalid payload", traceID) } // 查询货币余额 rows, err := db.QueryContext(ctx, ` SELECT currency_code, amount, total_earned, total_spent FROM currency_balances WHERE character_id = $1 ORDER BY currency_code `, req.CharacterID) if err != nil { logger.Error("get currency balances failed: %v", err) return errResp(9002, "internal error", traceID) } defer rows.Close() var balances []currencyBalanceData for rows.Next() { var b currencyBalanceData if err := rows.Scan(&b.CurrencyCode, &b.Amount, &b.TotalEarned, &b.TotalSpent); err != nil { continue } balances = append(balances, b) } return okResp(map[string]interface{}{"balances": balances}, traceID) } func createMarketOrder(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) { traceID := newTraceID() var req marketOrderReq if err := json.Unmarshal([]byte(payload), &req); err != nil { return errResp(6002, "invalid payload", traceID) } // 获取玩家角色ID uid := userIDFromCtx(ctx) var charID string err := db.QueryRowContext(ctx, ` SELECT c.id FROM characters c JOIN players p ON c.player_id = p.id WHERE p.nakama_user_id = $1 AND c.status = 'active' ORDER BY c.created_at DESC LIMIT 1 `, uid).Scan(&charID) if err != nil { return errResp(6003, "character not found", traceID) } // 校验物品归属和可交易性 var canTrade bool var itemID string err = db.QueryRowContext(ctx, ` SELECT can_trade, item_id FROM inventories WHERE id = $1 AND character_id = $2 `, req.InventoryID, charID).Scan(&canTrade, &itemID) if err != nil { return errResp(6004, "item not found", traceID) } if !canTrade { return errResp(6005, "item not tradable", traceID) } // 计算总价和税率 totalPrice := float64(req.Quantity) * parseFloat(req.UnitPrice) taxRate := 0.05 // 默认5%税率 // 创建挂单 var orderID string err = db.QueryRowContext(ctx, ` INSERT INTO market_orders ( seller_id, item_id, inventory_id, currency_code, unit_price, quantity, total_price, tax_rate, status, world_tier, listed_at, expired_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'active', 1, NOW(), NOW() + INTERVAL '24 hours') RETURNING id::text `, charID, itemID, req.InventoryID, req.CurrencyCode, req.UnitPrice, req.Quantity, totalPrice, taxRate).Scan(&orderID) if err != nil { logger.Error("create market order failed: %v", err) return errResp(9002, "internal error", traceID) } logger.Info("EconomyService/CreateMarketOrder success: order_id=%s", orderID) return okResp(marketOrderData{ OrderID: orderID, Status: "active", TaxRate: taxRate, }, traceID) } func cancelMarketOrder(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) { traceID := newTraceID() var req orderIDReq if err := json.Unmarshal([]byte(payload), &req); err != nil { return errResp(6006, "invalid payload", traceID) } // 获取玩家角色ID uid := userIDFromCtx(ctx) var charID string err := db.QueryRowContext(ctx, ` SELECT c.id FROM characters c JOIN players p ON c.player_id = p.id WHERE p.nakama_user_id = $1 AND c.status = 'active' ORDER BY c.created_at DESC LIMIT 1 `, uid).Scan(&charID) if err != nil { return errResp(6003, "character not found", traceID) } // 校验订单状态和所有权 var orderStatus string var sellerID string err = db.QueryRowContext(ctx, ` SELECT status, seller_id FROM market_orders WHERE id = $1 `, req.OrderID).Scan(&orderStatus, &sellerID) if err != nil { return errResp(6007, "order not found", traceID) } if sellerID != charID { return errResp(6008, "not order owner", traceID) } if orderStatus != "active" { return errResp(6009, "order not active", traceID) } // 撤销订单 _, err = db.ExecContext(ctx, ` UPDATE market_orders SET status = 'cancelled' WHERE id = $1 `, req.OrderID) if err != nil { logger.Error("cancel market order failed: %v", err) return errResp(9002, "internal error", traceID) } logger.Info("EconomyService/CancelMarketOrder success: order_id=%s", req.OrderID) return okResp(marketOrderData{OrderID: req.OrderID, Status: "cancelled"}, traceID) } func buyMarketOrder(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) { traceID := newTraceID() var req buyOrderReq if err := json.Unmarshal([]byte(payload), &req); err != nil { return errResp(6009, "invalid payload", traceID) } // 获取买家角色ID uid := userIDFromCtx(ctx) var buyerID string err := db.QueryRowContext(ctx, ` SELECT c.id FROM characters c JOIN players p ON c.player_id = p.id WHERE p.nakama_user_id = $1 AND c.status = 'active' ORDER BY c.created_at DESC LIMIT 1 `, uid).Scan(&buyerID) if err != nil { return errResp(6003, "character not found", traceID) } // 查询订单信息 var orderStatus string var sellerID string var currencyCode string var unitPrice float64 var totalQuantity int32 err = db.QueryRowContext(ctx, ` SELECT status, seller_id, currency_code, unit_price, quantity FROM market_orders WHERE id = $1 `, req.OrderID).Scan(&orderStatus, &sellerID, ¤cyCode, &unitPrice, &totalQuantity) if err != nil { return errResp(6007, "order not found", traceID) } if orderStatus != "active" { return errResp(6009, "order not active", traceID) } if sellerID == buyerID { return errResp(6010, "cannot buy own order", traceID) } if req.Quantity > totalQuantity { return errResp(6011, "insufficient quantity", traceID) } // 计算总价和税费 totalPrice := float64(req.Quantity) * unitPrice taxRate := 0.05 tax := totalPrice * taxRate // 检查买家余额 var balance float64 err = db.QueryRowContext(ctx, ` SELECT COALESCE(amount, 0) FROM currency_balances WHERE character_id = $1 AND currency_code = $2 `, buyerID, currencyCode).Scan(&balance) if err != nil { balance = 0 } if balance < totalPrice+tax { return errResp(6012, "insufficient balance", traceID) } // 扣款 _, err = db.ExecContext(ctx, ` UPDATE currency_balances SET amount = amount - $1, total_spent = total_spent + $1, updated_at = NOW() WHERE character_id = $2 AND currency_code = $3 `, totalPrice+tax, buyerID, currencyCode) if err != nil { logger.Error("deduct balance failed: %v", err) return errResp(9002, "internal error", traceID) } // 增加卖家收入 _, err = db.ExecContext(ctx, ` UPDATE currency_balances SET amount = amount + $1, total_earned = total_earned + $1, updated_at = NOW() WHERE character_id = $2 AND currency_code = $3 `, totalPrice, sellerID, currencyCode) if err != nil { logger.Error("add seller balance failed: %v", err) } // 更新订单状态 _, err = db.ExecContext(ctx, ` UPDATE market_orders SET status = 'sold', quantity = quantity - $1 WHERE id = $2 `, req.Quantity, req.OrderID) if err != nil { logger.Error("update order status failed: %v", err) } // 记录审计日志 _, err = db.ExecContext(ctx, ` INSERT INTO economy_audit_logs (character_id, entity_type, entity_id, currency_code, flow_type, reason_code, amount, related_id, world_tier) VALUES ($1, 'character', $1, $2, 'transfer', 'market_buy', $3, $4, 1) `, buyerID, currencyCode, totalPrice, req.OrderID) if err != nil { logger.Error("insert audit log failed: %v", err) } logger.Info("EconomyService/BuyMarketOrder success: order_id=%s buyer=%s", req.OrderID, buyerID) return okResp(tradeData{ TradeID: genUUID(), OrderID: req.OrderID, Quantity: req.Quantity, TotalPrice: fmt.Sprintf("%.2f", totalPrice), Tax: fmt.Sprintf("%.2f", tax), CurrencyCode: currencyCode, }, traceID) } func listAuctions(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) { traceID := newTraceID() var req auctionListReq if err := json.Unmarshal([]byte(payload), &req); err != nil { return errResp(6013, "invalid payload", traceID) } if req.Page < 1 { req.Page = 1 } if req.PageSize < 1 || req.PageSize > 50 { req.PageSize = 20 } offset := (req.Page - 1) * req.PageSize // 查询拍卖列表 query := ` SELECT a.id, a.seller_id, a.item_id, a.category, a.currency_code, a.start_price::text, a.status, a.started_at::text, a.ended_at::text FROM auctions a WHERE a.status IN ('active', 'extended') ` args := []interface{}{} if req.AuctionType != "" { query += " AND a.auction_type = $1" args = append(args, req.AuctionType) } if req.Category != "" { query += " AND a.category = $2" args = append(args, req.Category) } query += " ORDER BY a.created_at DESC LIMIT $3 OFFSET $4" args = append(args, req.PageSize, offset) rows, err := db.QueryContext(ctx, query, args...) if err != nil { logger.Error("list auctions failed: %v", err) return errResp(9002, "internal error", traceID) } defer rows.Close() var auctions []auctionItemData for rows.Next() { var a auctionItemData if err := rows.Scan( &a.AuctionID, &a.SellerID, &a.ItemID, &a.Category, &a.CurrencyCode, &a.StartPrice, &a.Status, &a.StartedAt, &a.EndedAt, ); err != nil { continue } auctions = append(auctions, a) } // 查询总数 var total int32 err = db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM auctions WHERE status IN ('active', 'extended') `).Scan(&total) if err != nil { total = 0 } return okResp(auctionListData{Total: total, List: auctions}, traceID) } func bidAuction(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) { traceID := newTraceID() uid := userIDFromCtx(ctx) if uid == "" { return errResp(1001, "missing token", traceID) } var req bidReq if err := json.Unmarshal([]byte(payload), &req); err != nil { return errResp(6013, "invalid payload", traceID) } // 获取竞拍者角色ID var bidderID string err := db.QueryRowContext(ctx, ` SELECT c.id FROM characters c JOIN players p ON c.player_id = p.id WHERE p.nakama_user_id = $1 AND c.status = 'active' ORDER BY c.created_at DESC LIMIT 1 `, uid).Scan(&bidderID) if err != nil { return errResp(6003, "character not found", traceID) } // 查询拍卖信息 var auctionStatus string var sellerID string var startPrice float64 var taxRate float64 err = db.QueryRowContext(ctx, ` SELECT status, seller_id, start_price, tax_rate FROM auctions WHERE id = $1 `, req.AuctionID).Scan(&auctionStatus, &sellerID, &startPrice, &taxRate) if err != nil { return errResp(8202, "auction not found", traceID) } if auctionStatus != "active" && auctionStatus != "extended" { return errResp(8203, "auction not active", traceID) } if sellerID == bidderID { return errResp(8204, "cannot bid on own auction", traceID) } // 获取当前最高出价 var currentPrice float64 err = db.QueryRowContext(ctx, ` SELECT COALESCE(MAX(amount), $1) FROM auction_bids WHERE auction_id = $2 `, startPrice, req.AuctionID).Scan(¤tPrice) if err != nil { currentPrice = startPrice } // 检查出价是否足够 bidAmount, _ := strconv.ParseFloat(req.Amount, 64) minBid := currentPrice * 1.05 // 最小加价5% if bidAmount < minBid { return errResp(8205, "bid too low", traceID) } // 记录出价 var bidID string err = db.QueryRowContext(ctx, ` INSERT INTO auction_bids (auction_id, bidder_id, amount, is_auto_bid, auto_max_amount, bid_at) VALUES ($1, $2, $3, $4, $5, NOW()) RETURNING id::text `, req.AuctionID, bidderID, bidAmount, req.IsAutoBid, req.AutoMaxAmount).Scan(&bidID) if err != nil { logger.Error("place bid failed: %v", err) return errResp(9002, "internal error", traceID) } logger.Info("EconomyService/BidAuction success: bid_id=%s auction=%s amount=%.2f", bidID, req.AuctionID, bidAmount) return okResp(bidData{ BidID: bidID, AuctionID: req.AuctionID, Amount: req.Amount, }, traceID) } func listIntelligence(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) { traceID := newTraceID() var req intelListReq if err := json.Unmarshal([]byte(payload), &req); err != nil { return errResp(6018, "invalid payload", traceID) } if req.Page < 1 { req.Page = 1 } if req.PageSize < 1 || req.PageSize > 50 { req.PageSize = 20 } offset := (req.Page - 1) * req.PageSize // 查询情报列表 query := ` SELECT io.id, io.seller_id, io.intel_type, io.target_world_tier, io.content_summary, io.currency_code, io.price::text, io.purchase_count, io.max_purchase FROM intelligence_orders io WHERE io.status = 'active' ` args := []interface{}{} if req.IntelType != "" { query += " AND io.intel_type = $1" args = append(args, req.IntelType) } if req.TargetWorldTier > 0 { query += " AND io.target_world_tier = $2" args = append(args, req.TargetWorldTier) } query += " ORDER BY io.created_at DESC LIMIT $3 OFFSET $4" args = append(args, req.PageSize, offset) rows, err := db.QueryContext(ctx, query, args...) if err != nil { logger.Error("list intelligence failed: %v", err) return errResp(9002, "internal error", traceID) } defer rows.Close() var intelItems []intelItemData for rows.Next() { var i intelItemData if err := rows.Scan( &i.OrderID, &i.SellerID, &i.IntelType, &i.TargetWorldTier, &i.ContentSummary, &i.CurrencyCode, &i.Price, &i.PurchaseCount, &i.MaxPurchase, ); err != nil { continue } intelItems = append(intelItems, i) } // 查询总数 var total int32 err = db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM intelligence_orders WHERE status = 'active' `).Scan(&total) if err != nil { total = 0 } return okResp(intelListData{Total: total, List: intelItems}, traceID) } func buyIntelligence(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) { traceID := newTraceID() uid := userIDFromCtx(ctx) if uid == "" { return errResp(1001, "missing token", traceID) } var req buyIntelReq if err := json.Unmarshal([]byte(payload), &req); err != nil { return errResp(6018, "invalid payload", traceID) } // 获取买家角色ID var buyerID string err := db.QueryRowContext(ctx, ` SELECT c.id FROM characters c JOIN players p ON c.player_id = p.id WHERE p.nakama_user_id = $1 AND c.status = 'active' ORDER BY c.created_at DESC LIMIT 1 `, uid).Scan(&buyerID) if err != nil { return errResp(6003, "character not found", traceID) } // 查询情报信息 var intelStatus string var sellerID string var currencyCode string var price float64 var maxPurchase int32 var purchaseCount int32 err = db.QueryRowContext(ctx, ` SELECT status, seller_id, currency_code, price, max_purchase, purchase_count FROM intelligence_orders WHERE id = $1 `, req.OrderID).Scan(&intelStatus, &sellerID, ¤cyCode, &price, &maxPurchase, &purchaseCount) if err != nil { return errResp(8206, "intelligence not found", traceID) } if intelStatus != "active" { return errResp(8207, "intelligence not active", traceID) } if sellerID == buyerID { return errResp(8208, "cannot buy own intelligence", traceID) } if purchaseCount >= maxPurchase { return errResp(8209, "intelligence sold out", traceID) } // 检查买家余额 var balance float64 err = db.QueryRowContext(ctx, ` SELECT COALESCE(amount, 0) FROM currency_balances WHERE character_id = $1 AND currency_code = $2 `, buyerID, currencyCode).Scan(&balance) if err != nil { balance = 0 } if balance < price { return errResp(6012, "insufficient balance", traceID) } // 扣款 _, err = db.ExecContext(ctx, ` UPDATE currency_balances SET amount = amount - $1, total_spent = total_spent + $1, updated_at = NOW() WHERE character_id = $2 AND currency_code = $3 `, price, buyerID, currencyCode) if err != nil { logger.Error("deduct balance failed: %v", err) return errResp(9002, "internal error", traceID) } // 增加卖家收入 _, err = db.ExecContext(ctx, ` UPDATE currency_balances SET amount = amount + $1, total_earned = total_earned + $1, updated_at = NOW() WHERE character_id = $2 AND currency_code = $3 `, price, sellerID, currencyCode) if err != nil { logger.Error("add seller balance failed: %v", err) } // 更新情报购买次数 _, err = db.ExecContext(ctx, ` UPDATE intelligence_orders SET purchase_count = purchase_count + 1 WHERE id = $1 `, req.OrderID) if err != nil { logger.Error("update intelligence purchase count failed: %v", err) } logger.Info("EconomyService/BuyIntelligence success: order_id=%s buyer=%s", req.OrderID, buyerID) return okResp(intelBuffData{ BuffID: genUUID(), OrderID: req.OrderID, }, traceID) }