729 行
21 KiB
Go
729 行
21 KiB
Go
|
|
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)
|
||
|
|
}
|