refactor(channelserver): extract Goocoo, Diva, Misc, Scenario, and Mercenary repositories

Move remaining raw s.server.db.* queries from handler files into
dedicated repository structs, completing the repository extraction
effort. Also adds SaveCharacterData and SaveHouseData to
CharacterRepository.

Fixes guild_hunts query to select both cats_used and start columns
to match the existing two-column Scan call. Adds slot index
validation in GoocooRepository to prevent SQL injection via
fmt.Sprintf.
This commit is contained in:
Houmgaor
2026-02-21 13:27:08 +01:00
parent f17cb96b52
commit 2738b19c32
13 changed files with 235 additions and 30 deletions

View File

@@ -75,14 +75,11 @@ func (save *CharacterSaveData) Save(s *Session) {
save.compSave = save.decompSave
}
_, err := s.server.db.Exec(`UPDATE characters SET savedata=$1, is_new_character=false, hr=$2, gr=$3, is_female=$4, weapon_type=$5, weapon_id=$6 WHERE id=$7
`, save.compSave, save.HR, save.GR, save.Gender, save.WeaponType, save.WeaponID, save.CharID)
if err != nil {
if err := s.server.charRepo.SaveCharacterData(save.CharID, save.compSave, save.HR, save.GR, save.Gender, save.WeaponType, save.WeaponID); err != nil {
s.logger.Error("Failed to update savedata", zap.Error(err), zap.Uint32("charID", save.CharID))
}
if _, err := s.server.db.Exec(`UPDATE user_binary SET house_tier=$1, house_data=$2, bookshelf=$3, gallery=$4, tore=$5, garden=$6 WHERE id=$7
`, save.HouseTier, save.HouseData, save.BookshelfData, save.GalleryData, save.ToreData, save.GardenData, s.charID); err != nil {
if err := s.server.charRepo.SaveHouseData(s.charID, save.HouseTier, save.HouseData, save.BookshelfData, save.GalleryData, save.ToreData, save.GardenData); err != nil {
s.logger.Error("Failed to update user binary house data", zap.Error(err))
}
}

View File

@@ -20,7 +20,7 @@ const (
)
func cleanupDiva(s *Session) {
if _, err := s.server.db.Exec("DELETE FROM events WHERE event_type='diva'"); err != nil {
if err := s.server.divaRepo.DeleteEvents(); err != nil {
s.logger.Error("Failed to delete diva events", zap.Error(err))
}
}
@@ -59,7 +59,7 @@ func generateDivaTimestamps(s *Session, start uint32, debug bool) []uint32 {
cleanupDiva(s)
// Generate a new diva defense, starting midnight tomorrow
start = uint32(midnight.Add(24 * time.Hour).Unix())
if _, err := s.server.db.Exec("INSERT INTO events (event_type, start_time) VALUES ('diva', to_timestamp($1)::timestamp without time zone)", start); err != nil {
if err := s.server.divaRepo.InsertEvent(start); err != nil {
s.logger.Error("Failed to insert diva event", zap.Error(err))
}
}
@@ -78,7 +78,7 @@ func handleMsgMhfGetUdSchedule(s *Session, p mhfpacket.MHFPacket) {
const divaIDSentinel = uint32(0xCAFEBEEF)
id, start := divaIDSentinel, uint32(0)
rows, err := s.server.db.Queryx("SELECT id, (EXTRACT(epoch FROM start_time)::int) as start_time FROM events WHERE event_type='diva'")
rows, err := s.server.divaRepo.GetEvents()
if err != nil {
s.logger.Error("Failed to query diva schedule", zap.Error(err))
} else {

View File

@@ -9,17 +9,16 @@ import (
)
func getGoocooData(s *Session, cid uint32) [][]byte {
var goocoo []byte
var goocoos [][]byte
for i := 0; i < 5; i++ {
err := s.server.db.QueryRow(fmt.Sprintf("SELECT goocoo%d FROM goocoo WHERE id=$1", i), cid).Scan(&goocoo)
for i := uint32(0); i < 5; i++ {
goocoo, err := s.server.goocooRepo.GetSlot(cid, i)
if err != nil {
if _, err := s.server.db.Exec("INSERT INTO goocoo (id) VALUES ($1)", s.charID); err != nil {
if err := s.server.goocooRepo.EnsureExists(s.charID); err != nil {
s.logger.Error("Failed to insert goocoo record", zap.Error(err))
}
return goocoos
}
if err == nil && goocoo != nil {
if goocoo != nil {
goocoos = append(goocoos, goocoo)
}
}
@@ -45,7 +44,7 @@ func handleMsgMhfUpdateGuacot(s *Session, p mhfpacket.MHFPacket) {
continue
}
if goocoo.Data1[0] == 0 {
if _, err := s.server.db.Exec(fmt.Sprintf("UPDATE goocoo SET goocoo%d=NULL WHERE id=$1", goocoo.Index), s.charID); err != nil {
if err := s.server.goocooRepo.ClearSlot(s.charID, goocoo.Index); err != nil {
s.logger.Error("Failed to clear goocoo slot", zap.Error(err))
}
} else {
@@ -59,7 +58,7 @@ func handleMsgMhfUpdateGuacot(s *Session, p mhfpacket.MHFPacket) {
}
bf.WriteUint8(uint8(len(goocoo.Name)))
bf.WriteBytes(goocoo.Name)
if _, err := s.server.db.Exec(fmt.Sprintf("UPDATE goocoo SET goocoo%d=$1 WHERE id=$2", goocoo.Index), bf.Data(), s.charID); err != nil {
if err := s.server.goocooRepo.SaveSlot(s.charID, goocoo.Index, bf.Data()); err != nil {
s.logger.Error("Failed to update goocoo slot", zap.Error(err))
}
dumpSaveData(s, bf.Data(), fmt.Sprintf("goocoo-%d", goocoo.Index))

View File

@@ -168,8 +168,8 @@ func handleMsgMhfEnumerateMercenaryLog(s *Session, p mhfpacket.MHFPacket) {
func handleMsgMhfCreateMercenary(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfCreateMercenary)
var nextID uint32
if err := s.server.db.QueryRow("SELECT nextval('rasta_id_seq')").Scan(&nextID); err != nil {
nextID, err := s.server.mercenaryRepo.NextRastaID()
if err != nil {
s.logger.Error("Failed to get next rasta ID", zap.Error(err))
doAckSimpleFail(s, pkt.AckHandle, nil)
return
@@ -227,7 +227,7 @@ func handleMsgMhfReadMercenaryW(s *Session, p mhfpacket.MHFPacket) {
if pkt.Op != 2 && pkt.Op != 5 {
var loans uint8
temp := byteframe.NewByteFrame()
rows, err := s.server.db.Query("SELECT name, id, pact_id FROM characters WHERE pact_id=(SELECT rasta_id FROM characters WHERE id=$1)", s.charID)
rows, err := s.server.mercenaryRepo.GetMercenaryLoans(s.charID)
if err != nil {
s.logger.Error("Failed to query mercenary loans", zap.Error(err))
} else {
@@ -323,7 +323,8 @@ func handleMsgMhfSaveOtomoAirou(s *Session, p mhfpacket.MHFPacket) {
dataLen := bf.ReadUint32()
catID := bf.ReadUint32()
if catID == 0 {
if err := s.server.db.QueryRow("SELECT nextval('airou_id_seq')").Scan(&catID); err != nil {
catID, err = s.server.mercenaryRepo.NextAirouID()
if err != nil {
s.logger.Error("Failed to get next airou ID", zap.Error(err))
doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4))
return
@@ -392,9 +393,7 @@ func getGuildAirouList(s *Session) []Airou {
if err != nil {
return guildCats
}
rows, err := s.server.db.Query(`SELECT cats_used FROM guild_hunts gh
INNER JOIN characters c ON gh.host_id = c.id WHERE c.id=$1
`, s.charID)
rows, err := s.server.mercenaryRepo.GetGuildHuntCatsUsed(s.charID)
if err != nil {
s.logger.Warn("Failed to get recently used airous", zap.Error(err))
return guildCats
@@ -414,10 +413,7 @@ func getGuildAirouList(s *Session) []Airou {
}
}
rows, err = s.server.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`, guild.ID)
rows, err = s.server.mercenaryRepo.GetGuildAirou(guild.ID)
if err != nil {
s.logger.Warn("Selecting otomoairou based on guild failed", zap.Error(err))
return guildCats

View File

@@ -259,7 +259,7 @@ 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.db.Query(`SELECT weapon_id FROM trend_weapons WHERE weapon_type=$1 ORDER BY count DESC LIMIT 3`, i)
rows, err := s.server.miscRepo.GetTrendWeapons(i)
if err != nil {
continue
}
@@ -288,8 +288,7 @@ func handleMsgMhfGetTrendWeapon(s *Session, p mhfpacket.MHFPacket) {
func handleMsgMhfUpdateUseTrendWeaponLog(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfUpdateUseTrendWeaponLog)
if _, err := s.server.db.Exec(`INSERT INTO trend_weapons (weapon_id, weapon_type, count) VALUES ($1, $2, 1) ON CONFLICT (weapon_id) DO
UPDATE SET count = trend_weapons.count+1`, pkt.WeaponID, pkt.WeaponType); err != nil {
if err := s.server.miscRepo.UpsertTrendWeapon(pkt.WeaponID, pkt.WeaponType); err != nil {
s.logger.Error("Failed to update trend weapon log", zap.Error(err))
}
doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4))

View File

@@ -22,7 +22,7 @@ func handleMsgMhfInfoScenarioCounter(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfInfoScenarioCounter)
var scenarios []Scenario
var scenario Scenario
scenarioData, err := s.server.db.Queryx("SELECT scenario_id, category_id FROM scenario_counter")
scenarioData, err := s.server.scenarioRepo.GetCounters()
if err != nil {
_ = scenarioData.Close()
s.logger.Error("Failed to get scenario counter info from db", zap.Error(err))

View File

@@ -210,3 +210,17 @@ func (r *CharacterRepository) FindByRastaID(rastaID int) (charID uint32, name st
err = r.db.QueryRow("SELECT name, id FROM characters WHERE rasta_id=$1", rastaID).Scan(&name, &charID)
return
}
// SaveCharacterData updates the core save fields on a character.
func (r *CharacterRepository) SaveCharacterData(charID uint32, compSave []byte, hr, gr uint16, isFemale bool, weaponType uint8, weaponID uint16) error {
_, err := r.db.Exec(`UPDATE characters SET savedata=$1, is_new_character=false, hr=$2, gr=$3, is_female=$4, weapon_type=$5, weapon_id=$6 WHERE id=$7`,
compSave, hr, gr, isFemale, weaponType, weaponID, charID)
return err
}
// SaveHouseData updates house-related fields in user_binary.
func (r *CharacterRepository) SaveHouseData(charID uint32, houseTier []byte, houseData, bookshelf, gallery, tore, garden []byte) error {
_, err := r.db.Exec(`UPDATE user_binary SET house_tier=$1, house_data=$2, bookshelf=$3, gallery=$4, tore=$5, garden=$6 WHERE id=$7`,
houseTier, houseData, bookshelf, gallery, tore, garden, charID)
return err
}

View File

@@ -0,0 +1,32 @@
package channelserver
import (
"github.com/jmoiron/sqlx"
)
// DivaRepository centralizes all database access for diva defense events.
type DivaRepository struct {
db *sqlx.DB
}
// NewDivaRepository creates a new DivaRepository.
func NewDivaRepository(db *sqlx.DB) *DivaRepository {
return &DivaRepository{db: db}
}
// DeleteEvents removes all diva events.
func (r *DivaRepository) DeleteEvents() error {
_, err := r.db.Exec("DELETE FROM events WHERE event_type='diva'")
return err
}
// InsertEvent creates a new diva event with the given start epoch.
func (r *DivaRepository) InsertEvent(startEpoch uint32) error {
_, err := r.db.Exec("INSERT INTO events (event_type, start_time) VALUES ('diva', to_timestamp($1)::timestamp without time zone)", startEpoch)
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'")
}

View File

@@ -0,0 +1,59 @@
package channelserver
import (
"fmt"
"github.com/jmoiron/sqlx"
)
// GoocooRepository centralizes all database access for the goocoo table.
type GoocooRepository struct {
db *sqlx.DB
}
// NewGoocooRepository creates a new GoocooRepository.
func NewGoocooRepository(db *sqlx.DB) *GoocooRepository {
return &GoocooRepository{db: db}
}
// validGoocooSlot validates the slot index to prevent SQL injection.
func validGoocooSlot(slot uint32) error {
if slot > 4 {
return fmt.Errorf("invalid goocoo slot index: %d", slot)
}
return nil
}
// EnsureExists creates a goocoo record if it doesn't already exist.
func (r *GoocooRepository) EnsureExists(charID uint32) error {
_, err := r.db.Exec("INSERT INTO goocoo (id) VALUES ($1) ON CONFLICT DO NOTHING", charID)
return err
}
// GetSlot reads a single goocoo slot by character ID and slot index (0-4).
func (r *GoocooRepository) GetSlot(charID uint32, slot uint32) ([]byte, error) {
if err := validGoocooSlot(slot); err != nil {
return nil, err
}
var data []byte
err := r.db.QueryRow(fmt.Sprintf("SELECT goocoo%d FROM goocoo WHERE id=$1", slot), charID).Scan(&data)
return data, err
}
// ClearSlot sets a goocoo slot to NULL.
func (r *GoocooRepository) ClearSlot(charID uint32, slot uint32) error {
if err := validGoocooSlot(slot); err != nil {
return err
}
_, err := r.db.Exec(fmt.Sprintf("UPDATE goocoo SET goocoo%d=NULL WHERE id=$1", slot), charID)
return err
}
// SaveSlot writes data to a goocoo slot.
func (r *GoocooRepository) SaveSlot(charID uint32, slot uint32, data []byte) error {
if err := validGoocooSlot(slot); err != nil {
return err
}
_, err := r.db.Exec(fmt.Sprintf("UPDATE goocoo SET goocoo%d=$1 WHERE id=$2", slot), data, charID)
return err
}

View File

@@ -0,0 +1,50 @@
package channelserver
import (
"database/sql"
"github.com/jmoiron/sqlx"
)
// MercenaryRepository centralizes database access for mercenary/rasta/airou sequences and queries.
type MercenaryRepository struct {
db *sqlx.DB
}
// NewMercenaryRepository creates a new MercenaryRepository.
func NewMercenaryRepository(db *sqlx.DB) *MercenaryRepository {
return &MercenaryRepository{db: db}
}
// NextRastaID returns the next value from the rasta_id_seq sequence.
func (r *MercenaryRepository) NextRastaID() (uint32, error) {
var id uint32
err := r.db.QueryRow("SELECT nextval('rasta_id_seq')").Scan(&id)
return id, err
}
// NextAirouID returns the next value from the airou_id_seq sequence.
func (r *MercenaryRepository) NextAirouID() (uint32, error) {
var id uint32
err := r.db.QueryRow("SELECT nextval('airou_id_seq')").Scan(&id)
return id, err
}
// 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)
}
// 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
INNER JOIN characters c ON gh.host_id = c.id WHERE c.id=$1`, charID)
}
// 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
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)
}

View File

@@ -0,0 +1,29 @@
package channelserver
import (
"database/sql"
"github.com/jmoiron/sqlx"
)
// MiscRepository centralizes database access for miscellaneous game tables.
type MiscRepository struct {
db *sqlx.DB
}
// NewMiscRepository creates a new MiscRepository.
func NewMiscRepository(db *sqlx.DB) *MiscRepository {
return &MiscRepository{db: db}
}
// 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)
}
// UpsertTrendWeapon increments the count for a weapon, inserting it if it doesn't exist.
func (r *MiscRepository) UpsertTrendWeapon(weaponID uint16, weaponType uint8) error {
_, err := r.db.Exec(`INSERT INTO trend_weapons (weapon_id, weapon_type, count) VALUES ($1, $2, 1) ON CONFLICT (weapon_id) DO
UPDATE SET count = trend_weapons.count+1`, weaponID, weaponType)
return err
}

View File

@@ -0,0 +1,20 @@
package channelserver
import (
"github.com/jmoiron/sqlx"
)
// ScenarioRepository centralizes all database access for the scenario_counter table.
type ScenarioRepository struct {
db *sqlx.DB
}
// NewScenarioRepository creates a new ScenarioRepository.
func NewScenarioRepository(db *sqlx.DB) *ScenarioRepository {
return &ScenarioRepository{db: db}
}
// GetCounters returns all scenario counters.
func (r *ScenarioRepository) GetCounters() (*sqlx.Rows, error) {
return r.db.Queryx("SELECT scenario_id, category_id FROM scenario_counter")
}

View File

@@ -62,6 +62,11 @@ type Server struct {
achievementRepo *AchievementRepository
shopRepo *ShopRepository
cafeRepo *CafeRepository
goocooRepo *GoocooRepository
divaRepo *DivaRepository
miscRepo *MiscRepository
scenarioRepo *ScenarioRepository
mercenaryRepo *MercenaryRepository
erupeConfig *cfg.Config
acceptConns chan net.Conn
deleteConns chan net.Conn
@@ -148,6 +153,11 @@ func NewServer(config *Config) *Server {
s.achievementRepo = NewAchievementRepository(config.DB)
s.shopRepo = NewShopRepository(config.DB)
s.cafeRepo = NewCafeRepository(config.DB)
s.goocooRepo = NewGoocooRepository(config.DB)
s.divaRepo = NewDivaRepository(config.DB)
s.miscRepo = NewMiscRepository(config.DB)
s.scenarioRepo = NewScenarioRepository(config.DB)
s.mercenaryRepo = NewMercenaryRepository(config.DB)
// Mezeporta
s.stages["sl1Ns200p0a0u0"] = NewStage("sl1Ns200p0a0u0")