From 2be589beaeddb8254a37b2efcc230921806c3e04 Mon Sep 17 00:00:00 2001 From: Houmgaor Date: Sat, 21 Feb 2026 14:16:58 +0100 Subject: [PATCH] refactor(channelserver): eliminate *sqlx.Rows/*sql.Rows from repository interfaces Move scan loops from handlers into repository methods so that interfaces return typed slices instead of leaking database cursors. This fixes resource leaks (7 of 12 call sites never closed rows) and makes all 12 methods mockable for unit tests. Affected repos: CafeRepo, ShopRepo, EventRepo, RengokuRepo, DivaRepo, ScenarioRepo, MiscRepo, MercenaryRepo. New structs: DivaEvent, MercenaryLoan, GuildHuntCatUsage. EventRepo.GetEventQuests left as-is (requires broader Server refactor). --- server/channelserver/handlers_cafe.go | 60 ++++++++----------- server/channelserver/handlers_diva.go | 13 ++--- server/channelserver/handlers_event.go | 9 +-- server/channelserver/handlers_mercenary.go | 49 ++++++---------- server/channelserver/handlers_misc.go | 11 +--- server/channelserver/handlers_rengoku.go | 7 +-- server/channelserver/handlers_scenario.go | 12 +--- server/channelserver/handlers_shop.go | 31 +++------- server/channelserver/repo_cafe.go | 12 ++-- server/channelserver/repo_diva.go | 14 ++++- server/channelserver/repo_event.go | 6 +- server/channelserver/repo_interfaces.go | 26 ++++----- server/channelserver/repo_mercenary.go | 67 +++++++++++++++++++--- server/channelserver/repo_misc.go | 19 +++++- server/channelserver/repo_rengoku.go | 14 +++-- server/channelserver/repo_scenario.go | 19 +++++- server/channelserver/repo_shop.go | 12 ++-- 17 files changed, 203 insertions(+), 178 deletions(-) diff --git a/server/channelserver/handlers_cafe.go b/server/channelserver/handlers_cafe.go index d4261ed03..817eac608 100644 --- a/server/channelserver/handlers_cafe.go +++ b/server/channelserver/handlers_cafe.go @@ -118,59 +118,45 @@ type CafeBonus struct { func handleMsgMhfGetCafeDurationBonusInfo(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfGetCafeDurationBonusInfo) - bf := byteframe.NewByteFrame() - var count uint32 - rows, err := s.server.cafeRepo.GetBonuses(s.charID) + bonuses, err := s.server.cafeRepo.GetBonuses(s.charID) if err != nil { s.logger.Error("Error getting cafebonus", zap.Error(err)) doAckBufSucceed(s, pkt.AckHandle, make([]byte, 4)) - } else { - for rows.Next() { - count++ - cafeBonus := &CafeBonus{} - err = rows.StructScan(&cafeBonus) - if err != nil { - s.logger.Error("Error scanning cafebonus", zap.Error(err)) - } - bf.WriteUint32(cafeBonus.TimeReq) - bf.WriteUint32(cafeBonus.ItemType) - bf.WriteUint32(cafeBonus.ItemID) - bf.WriteUint32(cafeBonus.Quantity) - bf.WriteBool(cafeBonus.Claimed) - } - resp := byteframe.NewByteFrame() - resp.WriteUint32(0) - resp.WriteUint32(uint32(TimeAdjusted().Unix())) - resp.WriteUint32(count) - resp.WriteBytes(bf.Data()) - doAckBufSucceed(s, pkt.AckHandle, resp.Data()) + return } + bf := byteframe.NewByteFrame() + for _, cb := range bonuses { + bf.WriteUint32(cb.TimeReq) + bf.WriteUint32(cb.ItemType) + bf.WriteUint32(cb.ItemID) + bf.WriteUint32(cb.Quantity) + bf.WriteBool(cb.Claimed) + } + resp := byteframe.NewByteFrame() + resp.WriteUint32(0) + resp.WriteUint32(uint32(TimeAdjusted().Unix())) + resp.WriteUint32(uint32(len(bonuses))) + resp.WriteBytes(bf.Data()) + doAckBufSucceed(s, pkt.AckHandle, resp.Data()) } func handleMsgMhfReceiveCafeDurationBonus(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfReceiveCafeDurationBonus) bf := byteframe.NewByteFrame() - var count uint32 bf.WriteUint32(0) - rows, err := s.server.cafeRepo.GetClaimable(s.charID, TimeAdjusted().Unix()-s.sessionStart) + claimable, err := s.server.cafeRepo.GetClaimable(s.charID, TimeAdjusted().Unix()-s.sessionStart) if err != nil || !mhfcourse.CourseExists(30, s.courses) { doAckBufSucceed(s, pkt.AckHandle, bf.Data()) } else { - for rows.Next() { - cafeBonus := &CafeBonus{} - err = rows.StructScan(cafeBonus) - if err != nil { - continue - } - count++ - bf.WriteUint32(cafeBonus.ID) - bf.WriteUint32(cafeBonus.ItemType) - bf.WriteUint32(cafeBonus.ItemID) - bf.WriteUint32(cafeBonus.Quantity) + for _, cb := range claimable { + bf.WriteUint32(cb.ID) + bf.WriteUint32(cb.ItemType) + bf.WriteUint32(cb.ItemID) + bf.WriteUint32(cb.Quantity) } _, _ = bf.Seek(0, io.SeekStart) - bf.WriteUint32(count) + bf.WriteUint32(uint32(len(claimable))) doAckBufSucceed(s, pkt.AckHandle, bf.Data()) } } diff --git a/server/channelserver/handlers_diva.go b/server/channelserver/handlers_diva.go index db45d954f..7e2a71676 100644 --- a/server/channelserver/handlers_diva.go +++ b/server/channelserver/handlers_diva.go @@ -78,16 +78,13 @@ func handleMsgMhfGetUdSchedule(s *Session, p mhfpacket.MHFPacket) { const divaIDSentinel = uint32(0xCAFEBEEF) id, start := divaIDSentinel, uint32(0) - rows, err := s.server.divaRepo.GetEvents() + events, err := s.server.divaRepo.GetEvents() if err != nil { s.logger.Error("Failed to query diva schedule", zap.Error(err)) - } else { - defer func() { _ = rows.Close() }() - for rows.Next() { - if err := rows.Scan(&id, &start); err != nil { - s.logger.Error("Failed to scan diva schedule row", zap.Error(err)) - } - } + } else if len(events) > 0 { + last := events[len(events)-1] + id = last.ID + start = last.StartTime } var timestamps []uint32 diff --git a/server/channelserver/handlers_event.go b/server/channelserver/handlers_event.go index 71114d30d..a97d301e4 100644 --- a/server/channelserver/handlers_event.go +++ b/server/channelserver/handlers_event.go @@ -136,18 +136,11 @@ func handleMsgMhfGetKeepLoginBoostStatus(s *Session, p mhfpacket.MHFPacket) { bf := byteframe.NewByteFrame() - var loginBoosts []loginBoost - rows, err := s.server.eventRepo.GetLoginBoosts(s.charID) + loginBoosts, err := s.server.eventRepo.GetLoginBoosts(s.charID) if err != nil || s.server.erupeConfig.GameplayOptions.DisableLoginBoost { - _ = rows.Close() doAckBufSucceed(s, pkt.AckHandle, make([]byte, 35)) return } - for rows.Next() { - var temp loginBoost - _ = rows.StructScan(&temp) - loginBoosts = append(loginBoosts, temp) - } if len(loginBoosts) == 0 { temp := TimeWeekStart() loginBoosts = []loginBoost{ diff --git a/server/channelserver/handlers_mercenary.go b/server/channelserver/handlers_mercenary.go index 05af0ef8f..93419a92c 100644 --- a/server/channelserver/handlers_mercenary.go +++ b/server/channelserver/handlers_mercenary.go @@ -225,27 +225,18 @@ func handleMsgMhfReadMercenaryW(s *Session, p mhfpacket.MHFPacket) { } if pkt.Op != 2 && pkt.Op != 5 { - var loans uint8 - temp := byteframe.NewByteFrame() - rows, err := s.server.mercenaryRepo.GetMercenaryLoans(s.charID) + loans, err := s.server.mercenaryRepo.GetMercenaryLoans(s.charID) if err != nil { s.logger.Error("Failed to query mercenary loans", zap.Error(err)) - } else { - defer func() { _ = rows.Close() }() - for rows.Next() { - if err := rows.Scan(&name, &cid, &pactID); err != nil { - continue - } - loans++ - temp.WriteUint32(uint32(pactID)) - temp.WriteUint32(cid) - temp.WriteUint32(uint32(TimeAdjusted().Unix())) - temp.WriteUint32(uint32(TimeAdjusted().Add(time.Hour * 24 * 7).Unix())) - temp.WriteBytes(stringsupport.PaddedString(name, 18, true)) - } } - bf.WriteUint8(loans) - bf.WriteBytes(temp.Data()) + bf.WriteUint8(uint8(len(loans))) + for _, loan := range loans { + bf.WriteUint32(uint32(loan.PactID)) + bf.WriteUint32(loan.CharID) + bf.WriteUint32(uint32(TimeAdjusted().Unix())) + bf.WriteUint32(uint32(TimeAdjusted().Add(time.Hour * 24 * 7).Unix())) + bf.WriteBytes(stringsupport.PaddedString(loan.Name, 18, true)) + } if pkt.Op != 1 && pkt.Op != 4 { data, _ := s.server.charRepo.LoadColumn(s.charID, "savemercenary") @@ -393,36 +384,28 @@ func getGuildAirouList(s *Session) []Airou { if err != nil { return guildCats } - rows, err := s.server.mercenaryRepo.GetGuildHuntCatsUsed(s.charID) + usages, err := s.server.mercenaryRepo.GetGuildHuntCatsUsed(s.charID) if err != nil { s.logger.Warn("Failed to get recently used airous", zap.Error(err)) return guildCats } - var csvTemp string - var startTemp time.Time - for rows.Next() { - err = rows.Scan(&csvTemp, &startTemp) - if err != nil { - continue - } - if startTemp.Add(time.Second * time.Duration(s.server.erupeConfig.GameplayOptions.TreasureHuntPartnyaCooldown)).Before(TimeAdjusted()) { - for i, j := range stringsupport.CSVElems(csvTemp) { + for _, usage := range usages { + if usage.Start.Add(time.Second * time.Duration(s.server.erupeConfig.GameplayOptions.TreasureHuntPartnyaCooldown)).Before(TimeAdjusted()) { + for i, j := range stringsupport.CSVElems(usage.CatsUsed) { bannedCats[uint32(j)] = i } } } - rows, err = s.server.mercenaryRepo.GetGuildAirou(guild.ID) + airouData, err := s.server.mercenaryRepo.GetGuildAirou(guild.ID) if err != nil { s.logger.Warn("Selecting otomoairou based on guild failed", zap.Error(err)) return guildCats } - for rows.Next() { - var data []byte - err = rows.Scan(&data) - if err != nil || len(data) == 0 { + for _, data := range airouData { + if len(data) == 0 { continue } // first byte has cat existence in general, can skip if 0 diff --git a/server/channelserver/handlers_misc.go b/server/channelserver/handlers_misc.go index 69c94bee5..42f530b32 100644 --- a/server/channelserver/handlers_misc.go +++ b/server/channelserver/handlers_misc.go @@ -254,18 +254,13 @@ func handleMsgMhfGetTrendWeapon(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfGetTrendWeapon) trendWeapons := [14][3]TrendWeapon{} for i := uint8(0); i < 14; i++ { - rows, err := s.server.miscRepo.GetTrendWeapons(i) + ids, err := s.server.miscRepo.GetTrendWeapons(i) if err != nil { continue } - j := 0 - for rows.Next() { + for j, id := range ids { trendWeapons[i][j].WeaponType = i - if err := rows.Scan(&trendWeapons[i][j].WeaponID); err != nil { - s.logger.Error("Failed to scan trend weapon", zap.Error(err)) - break - } - j++ + trendWeapons[i][j].WeaponID = id } } diff --git a/server/channelserver/handlers_rengoku.go b/server/channelserver/handlers_rengoku.go index ff219a192..2571c114d 100644 --- a/server/channelserver/handlers_rengoku.go +++ b/server/channelserver/handlers_rengoku.go @@ -215,7 +215,6 @@ func handleMsgMhfEnumerateRengokuRanking(s *Session, p mhfpacket.MHFPacket) { } } - var score RengokuScore var selfExist bool i := uint32(1) bf := byteframe.NewByteFrame() @@ -225,16 +224,14 @@ func handleMsgMhfEnumerateRengokuRanking(s *Session, p mhfpacket.MHFPacket) { if guild != nil { guildID = guild.ID } - rows, err := s.server.rengokuRepo.GetRanking(pkt.Leaderboard, guildID) + scores, err := s.server.rengokuRepo.GetRanking(pkt.Leaderboard, guildID) if err != nil { s.logger.Error("Failed to query rengoku ranking", zap.Error(err)) doAckBufSucceed(s, pkt.AckHandle, make([]byte, 11)) return } - defer func() { _ = rows.Close() }() - for rows.Next() { - _ = rows.StructScan(&score) + for _, score := range scores { if score.Name == s.Name { bf.WriteUint32(i) bf.WriteUint32(score.Score) diff --git a/server/channelserver/handlers_scenario.go b/server/channelserver/handlers_scenario.go index 622350396..57c6e7399 100644 --- a/server/channelserver/handlers_scenario.go +++ b/server/channelserver/handlers_scenario.go @@ -20,22 +20,12 @@ type Scenario struct { func handleMsgMhfInfoScenarioCounter(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfInfoScenarioCounter) - var scenarios []Scenario - var scenario Scenario - scenarioData, err := s.server.scenarioRepo.GetCounters() + scenarios, err := s.server.scenarioRepo.GetCounters() if err != nil { - _ = scenarioData.Close() s.logger.Error("Failed to get scenario counter info from db", zap.Error(err)) doAckBufSucceed(s, pkt.AckHandle, make([]byte, 1)) return } - for scenarioData.Next() { - err = scenarioData.Scan(&scenario.MainID, &scenario.CategoryID) - if err != nil { - continue - } - scenarios = append(scenarios, scenario) - } // Trim excess scenarios if len(scenarios) > 128 { diff --git a/server/channelserver/handlers_shop.go b/server/channelserver/handlers_shop.go index 53facd174..9c33f8fa7 100644 --- a/server/channelserver/handlers_shop.go +++ b/server/channelserver/handlers_shop.go @@ -57,17 +57,9 @@ func writeShopItems(bf *byteframe.ByteFrame, items []ShopItem, mode cfg.Mode) { } func getShopItems(s *Session, shopType uint8, shopID uint32) []ShopItem { - var items []ShopItem - var temp ShopItem - rows, err := s.server.shopRepo.GetShopItems(shopType, shopID, s.charID) - if err == nil { - for rows.Next() { - err = rows.StructScan(&temp) - if err != nil { - continue - } - items = append(items, temp) - } + items, err := s.server.shopRepo.GetShopItems(shopType, shopID, s.charID) + if err != nil { + return nil } return items } @@ -270,20 +262,11 @@ func handleMsgMhfGetFpointExchangeList(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfGetFpointExchangeList) bf := byteframe.NewByteFrame() - var exchange FPointExchange - var exchanges []FPointExchange + exchanges, _ := s.server.shopRepo.GetFpointExchangeList() var buyables uint16 - rows, err := s.server.shopRepo.GetFpointExchangeList() - if err == nil { - for rows.Next() { - err = rows.StructScan(&exchange) - if err != nil { - continue - } - if exchange.Buyable { - buyables++ - } - exchanges = append(exchanges, exchange) + for _, e := range exchanges { + if e.Buyable { + buyables++ } } if s.server.erupeConfig.RealClientMode <= cfg.Z2 { diff --git a/server/channelserver/repo_cafe.go b/server/channelserver/repo_cafe.go index 4eb7ceb3e..064ef4ffe 100644 --- a/server/channelserver/repo_cafe.go +++ b/server/channelserver/repo_cafe.go @@ -21,8 +21,9 @@ func (r *CafeRepository) ResetAccepted(charID uint32) error { } // GetBonuses returns all cafe bonuses with their claimed status for a character. -func (r *CafeRepository) GetBonuses(charID uint32) (*sqlx.Rows, error) { - return r.db.Queryx(` +func (r *CafeRepository) GetBonuses(charID uint32) ([]CafeBonus, error) { + var result []CafeBonus + err := r.db.Select(&result, ` SELECT cb.id, time_req, item_type, item_id, quantity, ( SELECT count(*) @@ -30,11 +31,13 @@ func (r *CafeRepository) GetBonuses(charID uint32) (*sqlx.Rows, error) { WHERE cb.id = ca.cafe_id AND ca.character_id = $1 )::int::bool AS claimed FROM cafebonus cb ORDER BY id ASC;`, charID) + return result, err } // GetClaimable returns unclaimed cafe bonuses where the character has enough accumulated time. -func (r *CafeRepository) GetClaimable(charID uint32, elapsedSec int64) (*sqlx.Rows, error) { - return r.db.Queryx(` +func (r *CafeRepository) GetClaimable(charID uint32, elapsedSec int64) ([]CafeBonus, error) { + var result []CafeBonus + err := r.db.Select(&result, ` SELECT c.id, time_req, item_type, item_id, quantity FROM cafebonus c WHERE ( @@ -46,6 +49,7 @@ func (r *CafeRepository) GetClaimable(charID uint32, elapsedSec int64) (*sqlx.Ro FROM characters ch WHERE ch.id = $1 ) >= time_req`, charID, elapsedSec) + return result, err } // GetBonusItem returns the item type and quantity for a specific cafe bonus. diff --git a/server/channelserver/repo_diva.go b/server/channelserver/repo_diva.go index 923ced8dd..90b53e201 100644 --- a/server/channelserver/repo_diva.go +++ b/server/channelserver/repo_diva.go @@ -26,7 +26,15 @@ func (r *DivaRepository) InsertEvent(startEpoch uint32) error { return err } -// GetEvents returns all diva events with their ID and start_time epoch. -func (r *DivaRepository) GetEvents() (*sqlx.Rows, error) { - return r.db.Queryx("SELECT id, (EXTRACT(epoch FROM start_time)::int) as start_time FROM events WHERE event_type='diva'") +// DivaEvent represents a diva event row with ID and start_time epoch. +type DivaEvent struct { + ID uint32 `db:"id"` + StartTime uint32 `db:"start_time"` +} + +// GetEvents returns all diva events with their ID and start_time epoch. +func (r *DivaRepository) GetEvents() ([]DivaEvent, error) { + var result []DivaEvent + err := r.db.Select(&result, "SELECT id, (EXTRACT(epoch FROM start_time)::int) as start_time FROM events WHERE event_type='diva'") + return result, err } diff --git a/server/channelserver/repo_event.go b/server/channelserver/repo_event.go index 77ee96791..dd1154fd1 100644 --- a/server/channelserver/repo_event.go +++ b/server/channelserver/repo_event.go @@ -31,8 +31,10 @@ func (r *EventRepository) InsertFeatureWeapon(startTime time.Time, features uint } // GetLoginBoosts returns all login boost rows for a character, ordered by week_req. -func (r *EventRepository) GetLoginBoosts(charID uint32) (*sqlx.Rows, error) { - return r.db.Queryx("SELECT week_req, expiration, reset FROM login_boost WHERE char_id=$1 ORDER BY week_req", charID) +func (r *EventRepository) GetLoginBoosts(charID uint32) ([]loginBoost, error) { + var result []loginBoost + err := r.db.Select(&result, "SELECT week_req, expiration, reset FROM login_boost WHERE char_id=$1 ORDER BY week_req", charID) + return result, err } // InsertLoginBoost creates a new login boost entry. diff --git a/server/channelserver/repo_interfaces.go b/server/channelserver/repo_interfaces.go index a5bf64494..c7802ba38 100644 --- a/server/channelserver/repo_interfaces.go +++ b/server/channelserver/repo_interfaces.go @@ -3,8 +3,6 @@ package channelserver import ( "database/sql" "time" - - "github.com/jmoiron/sqlx" ) // Repository interfaces decouple handlers from concrete PostgreSQL implementations, @@ -224,7 +222,7 @@ type TowerRepo interface { // RengokuRepo defines the contract for rengoku score/ranking data access. type RengokuRepo interface { UpsertScore(charID uint32, maxStagesMp, maxPointsMp, maxStagesSp, maxPointsSp uint32) error - GetRanking(leaderboard uint32, guildID uint32) (*sqlx.Rows, error) + GetRanking(leaderboard uint32, guildID uint32) ([]RengokuScore, error) } // MailRepo defines the contract for in-game mail data access. @@ -270,7 +268,7 @@ type SessionRepo interface { type EventRepo interface { GetFeatureWeapon(startTime time.Time) (activeFeature, error) InsertFeatureWeapon(startTime time.Time, features uint32) error - GetLoginBoosts(charID uint32) (*sqlx.Rows, error) + GetLoginBoosts(charID uint32) ([]loginBoost, error) InsertLoginBoost(charID uint32, weekReq uint8, expiration, reset time.Time) error UpdateLoginBoost(charID uint32, weekReq uint8, expiration, reset time.Time) error GetEventQuests() (*sql.Rows, error) @@ -287,17 +285,17 @@ type AchievementRepo interface { // ShopRepo defines the contract for shop data access. type ShopRepo interface { - GetShopItems(shopType uint8, shopID uint32, charID uint32) (*sqlx.Rows, error) + GetShopItems(shopType uint8, shopID uint32, charID uint32) ([]ShopItem, error) RecordPurchase(charID, shopItemID, quantity uint32) error GetFpointItem(tradeID uint32) (quantity, fpoints int, err error) - GetFpointExchangeList() (*sqlx.Rows, error) + GetFpointExchangeList() ([]FPointExchange, error) } // CafeRepo defines the contract for cafe bonus data access. type CafeRepo interface { ResetAccepted(charID uint32) error - GetBonuses(charID uint32) (*sqlx.Rows, error) - GetClaimable(charID uint32, elapsedSec int64) (*sqlx.Rows, error) + GetBonuses(charID uint32) ([]CafeBonus, error) + GetClaimable(charID uint32, elapsedSec int64) ([]CafeBonus, error) GetBonusItem(bonusID uint32) (itemType, quantity uint32, err error) AcceptBonus(bonusID, charID uint32) error } @@ -314,25 +312,25 @@ type GoocooRepo interface { type DivaRepo interface { DeleteEvents() error InsertEvent(startEpoch uint32) error - GetEvents() (*sqlx.Rows, error) + GetEvents() ([]DivaEvent, error) } // MiscRepo defines the contract for miscellaneous data access. type MiscRepo interface { - GetTrendWeapons(weaponType uint8) (*sql.Rows, error) + GetTrendWeapons(weaponType uint8) ([]uint16, error) UpsertTrendWeapon(weaponID uint16, weaponType uint8) error } // ScenarioRepo defines the contract for scenario counter data access. type ScenarioRepo interface { - GetCounters() (*sqlx.Rows, error) + GetCounters() ([]Scenario, error) } // MercenaryRepo defines the contract for mercenary/rasta data access. type MercenaryRepo interface { NextRastaID() (uint32, error) NextAirouID() (uint32, error) - GetMercenaryLoans(charID uint32) (*sql.Rows, error) - GetGuildHuntCatsUsed(charID uint32) (*sql.Rows, error) - GetGuildAirou(guildID uint32) (*sql.Rows, error) + GetMercenaryLoans(charID uint32) ([]MercenaryLoan, error) + GetGuildHuntCatsUsed(charID uint32) ([]GuildHuntCatUsage, error) + GetGuildAirou(guildID uint32) ([][]byte, error) } diff --git a/server/channelserver/repo_mercenary.go b/server/channelserver/repo_mercenary.go index ef7e247f3..6ecff1e4a 100644 --- a/server/channelserver/repo_mercenary.go +++ b/server/channelserver/repo_mercenary.go @@ -1,7 +1,8 @@ package channelserver import ( - "database/sql" + "fmt" + "time" "github.com/jmoiron/sqlx" ) @@ -30,21 +31,73 @@ func (r *MercenaryRepository) NextAirouID() (uint32, error) { return id, err } +// MercenaryLoan represents a character that has a pact with a rasta. +type MercenaryLoan struct { + Name string + CharID uint32 + PactID int +} + // GetMercenaryLoans returns characters that have a pact with the given character's rasta_id. -func (r *MercenaryRepository) GetMercenaryLoans(charID uint32) (*sql.Rows, error) { - return r.db.Query("SELECT name, id, pact_id FROM characters WHERE pact_id=(SELECT rasta_id FROM characters WHERE id=$1)", charID) +func (r *MercenaryRepository) GetMercenaryLoans(charID uint32) ([]MercenaryLoan, error) { + rows, err := r.db.Query("SELECT name, id, pact_id FROM characters WHERE pact_id=(SELECT rasta_id FROM characters WHERE id=$1)", charID) + if err != nil { + return nil, fmt.Errorf("query mercenary loans: %w", err) + } + defer rows.Close() + var result []MercenaryLoan + for rows.Next() { + var l MercenaryLoan + if err := rows.Scan(&l.Name, &l.CharID, &l.PactID); err != nil { + return nil, fmt.Errorf("scan mercenary loan: %w", err) + } + result = append(result, l) + } + return result, rows.Err() +} + +// GuildHuntCatUsage represents cats_used and start time from a guild hunt. +type GuildHuntCatUsage struct { + CatsUsed string + Start time.Time } // GetGuildHuntCatsUsed returns cats_used and start from guild_hunts for a given character. -func (r *MercenaryRepository) GetGuildHuntCatsUsed(charID uint32) (*sql.Rows, error) { - return r.db.Query(`SELECT cats_used, start FROM guild_hunts gh +func (r *MercenaryRepository) GetGuildHuntCatsUsed(charID uint32) ([]GuildHuntCatUsage, error) { + rows, err := r.db.Query(`SELECT cats_used, start FROM guild_hunts gh INNER JOIN characters c ON gh.host_id = c.id WHERE c.id=$1`, charID) + if err != nil { + return nil, fmt.Errorf("query guild hunt cats: %w", err) + } + defer rows.Close() + var result []GuildHuntCatUsage + for rows.Next() { + var u GuildHuntCatUsage + if err := rows.Scan(&u.CatsUsed, &u.Start); err != nil { + return nil, fmt.Errorf("scan guild hunt cat: %w", err) + } + result = append(result, u) + } + return result, rows.Err() } // GetGuildAirou returns otomoairou data for all characters in a guild. -func (r *MercenaryRepository) GetGuildAirou(guildID uint32) (*sql.Rows, error) { - return r.db.Query(`SELECT c.otomoairou FROM characters c +func (r *MercenaryRepository) GetGuildAirou(guildID uint32) ([][]byte, error) { + rows, err := r.db.Query(`SELECT c.otomoairou FROM characters c INNER JOIN guild_characters gc ON gc.character_id = c.id WHERE gc.guild_id = $1 AND c.otomoairou IS NOT NULL ORDER BY c.id LIMIT 60`, guildID) + if err != nil { + return nil, fmt.Errorf("query guild airou: %w", err) + } + defer rows.Close() + var result [][]byte + for rows.Next() { + var data []byte + if err := rows.Scan(&data); err != nil { + return nil, fmt.Errorf("scan guild airou: %w", err) + } + result = append(result, data) + } + return result, rows.Err() } diff --git a/server/channelserver/repo_misc.go b/server/channelserver/repo_misc.go index d3836e6cd..f12d61010 100644 --- a/server/channelserver/repo_misc.go +++ b/server/channelserver/repo_misc.go @@ -1,7 +1,7 @@ package channelserver import ( - "database/sql" + "fmt" "github.com/jmoiron/sqlx" ) @@ -17,8 +17,21 @@ func NewMiscRepository(db *sqlx.DB) *MiscRepository { } // GetTrendWeapons returns the top 3 weapon IDs for a given weapon type, ordered by count descending. -func (r *MiscRepository) GetTrendWeapons(weaponType uint8) (*sql.Rows, error) { - return r.db.Query("SELECT weapon_id FROM trend_weapons WHERE weapon_type=$1 ORDER BY count DESC LIMIT 3", weaponType) +func (r *MiscRepository) GetTrendWeapons(weaponType uint8) ([]uint16, error) { + rows, err := r.db.Query("SELECT weapon_id FROM trend_weapons WHERE weapon_type=$1 ORDER BY count DESC LIMIT 3", weaponType) + if err != nil { + return nil, fmt.Errorf("query trend_weapons: %w", err) + } + defer rows.Close() + var result []uint16 + for rows.Next() { + var id uint16 + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan trend_weapons: %w", err) + } + result = append(result, id) + } + return result, rows.Err() } // UpsertTrendWeapon increments the count for a weapon, inserting it if it doesn't exist. diff --git a/server/channelserver/repo_rengoku.go b/server/channelserver/repo_rengoku.go index 06c454e2c..4020667b4 100644 --- a/server/channelserver/repo_rengoku.go +++ b/server/channelserver/repo_rengoku.go @@ -62,15 +62,19 @@ func rengokuIsGuildFiltered(leaderboard uint32) bool { // GetRanking returns rengoku scores for the given leaderboard. // For guild-scoped leaderboards (2,3,6,7), guildID filters the results. -func (r *RengokuRepository) GetRanking(leaderboard uint32, guildID uint32) (*sqlx.Rows, error) { +func (r *RengokuRepository) GetRanking(leaderboard uint32, guildID uint32) ([]RengokuScore, error) { col := rengokuColumnForLeaderboard(leaderboard) + var result []RengokuScore + var err error if rengokuIsGuildFiltered(leaderboard) { - return r.db.Queryx( + err = r.db.Select(&result, fmt.Sprintf("SELECT %s AS score %s WHERE guild_id=$1 ORDER BY %s DESC", col, rengokuScoreQueryRepo, col), guildID, ) + } else { + err = r.db.Select(&result, + fmt.Sprintf("SELECT %s AS score %s ORDER BY %s DESC", col, rengokuScoreQueryRepo, col), + ) } - return r.db.Queryx( - fmt.Sprintf("SELECT %s AS score %s ORDER BY %s DESC", col, rengokuScoreQueryRepo, col), - ) + return result, err } diff --git a/server/channelserver/repo_scenario.go b/server/channelserver/repo_scenario.go index 003f52371..3caa0a22b 100644 --- a/server/channelserver/repo_scenario.go +++ b/server/channelserver/repo_scenario.go @@ -1,6 +1,8 @@ package channelserver import ( + "fmt" + "github.com/jmoiron/sqlx" ) @@ -15,6 +17,19 @@ func NewScenarioRepository(db *sqlx.DB) *ScenarioRepository { } // GetCounters returns all scenario counters. -func (r *ScenarioRepository) GetCounters() (*sqlx.Rows, error) { - return r.db.Queryx("SELECT scenario_id, category_id FROM scenario_counter") +func (r *ScenarioRepository) GetCounters() ([]Scenario, error) { + rows, err := r.db.Query("SELECT scenario_id, category_id FROM scenario_counter") + if err != nil { + return nil, fmt.Errorf("query scenario_counter: %w", err) + } + defer rows.Close() + var result []Scenario + for rows.Next() { + var s Scenario + if err := rows.Scan(&s.MainID, &s.CategoryID); err != nil { + return nil, fmt.Errorf("scan scenario_counter: %w", err) + } + result = append(result, s) + } + return result, rows.Err() } diff --git a/server/channelserver/repo_shop.go b/server/channelserver/repo_shop.go index f51ac8a17..fd32c8a43 100644 --- a/server/channelserver/repo_shop.go +++ b/server/channelserver/repo_shop.go @@ -15,11 +15,13 @@ func NewShopRepository(db *sqlx.DB) *ShopRepository { } // GetShopItems returns shop items with per-character purchase counts. -func (r *ShopRepository) GetShopItems(shopType uint8, shopID uint32, charID uint32) (*sqlx.Rows, error) { - return r.db.Queryx(`SELECT id, item_id, cost, quantity, min_hr, min_sr, min_gr, store_level, max_quantity, +func (r *ShopRepository) GetShopItems(shopType uint8, shopID uint32, charID uint32) ([]ShopItem, error) { + var result []ShopItem + err := r.db.Select(&result, `SELECT id, item_id, cost, quantity, min_hr, min_sr, min_gr, store_level, max_quantity, COALESCE((SELECT bought FROM shop_items_bought WHERE shop_item_id=si.id AND character_id=$3), 0) as used_quantity, road_floors, road_fatalis FROM shop_items si WHERE shop_type=$1 AND shop_id=$2 `, shopType, shopID, charID) + return result, err } // RecordPurchase upserts a purchase record, adding to the bought count. @@ -39,6 +41,8 @@ func (r *ShopRepository) GetFpointItem(tradeID uint32) (quantity, fpoints int, e } // GetFpointExchangeList returns all frontier point exchange items ordered by buyable status. -func (r *ShopRepository) GetFpointExchangeList() (*sqlx.Rows, error) { - return r.db.Queryx(`SELECT id, item_type, item_id, quantity, fpoints, buyable FROM fpoint_items ORDER BY buyable DESC`) +func (r *ShopRepository) GetFpointExchangeList() ([]FPointExchange, error) { + var result []FPointExchange + err := r.db.Select(&result, `SELECT id, item_type, item_id, quantity, fpoints, buyable FROM fpoint_items ORDER BY buyable DESC`) + return result, err }