lawless/server/modules/economy.go
徐勤民 521603a899
一些检测仍在等待运行
Docs Build / build-and-deploy (push) Waiting to run
refactor(client): 删除游戏核心管理器和场景脚本
- 移除 ConfigManager 配置管理器类
- 移除 GameManager 全局单例管理器类
- 移除 NetworkManager 网络连接管理器类
- 移除 CharacterData 和 ItemData 数据模型类
- 移除 BagScene、BattleScene、LobbyScene 等场景脚本
- 移除 EncounterBubble 和 EventFeedPanel UI组件脚本
- 更新代理邀请文档中的服务器连接方式
- 更新同步状态表格中的代理任务分配信息
- 添加 MiMo 任务完成总结和审查修复记录
2026-07-03 21:34:51 +08:00

731 行
21 KiB
Go

package modules
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strconv"
"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, 0)
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, &currencyCode, &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(&currentPrice)
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, &currencyCode, &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)
}