refactor(mail): extract mail logic into MailService

Introduce MailService as a convenience layer between handlers/services
and MailRepo. Provides Send, SendSystem, SendGuildInvite, and
BroadcastToGuild methods that encapsulate the boolean flag combinations.

GuildService now depends on MailService instead of MailRepo directly,
simplifying its mail-sending calls from verbose SendMail(..., false, true)
to clean SendSystem(recipientID, subject, body).

Guild mail broadcast logic moved from handleMsgMhfSendMail into
MailService.BroadcastToGuild.
This commit is contained in:
Houmgaor
2026-02-24 00:05:56 +01:00
parent 1e9de7920d
commit 077c08fd49
9 changed files with 292 additions and 64 deletions

View File

@@ -194,31 +194,21 @@ func handleMsgMhfOprtMail(s *Session, p mhfpacket.MHFPacket) {
func handleMsgMhfSendMail(s *Session, p mhfpacket.MHFPacket) { func handleMsgMhfSendMail(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfSendMail) pkt := p.(*mhfpacket.MsgMhfSendMail)
if pkt.RecipientID == 0 { // Guild mail if pkt.RecipientID == 0 { // Guild mail broadcast
g, err := s.server.guildRepo.GetByCharID(s.charID) g, err := s.server.guildRepo.GetByCharID(s.charID)
if err != nil { if err != nil {
s.logger.Error("Failed to get guild info for mail") s.logger.Error("Failed to get guild info for mail")
doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4))
return return
} }
gm, err := s.server.guildRepo.GetMembers(g.ID, false) if err := s.server.mailService.BroadcastToGuild(s.charID, g.ID, pkt.Subject, pkt.Body); err != nil {
if err != nil { s.logger.Error("Failed to broadcast guild mail", zap.Error(err))
s.logger.Error("Failed to get guild members for mail")
doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4))
return return
} }
for i := 0; i < len(gm); i++ {
err := s.server.mailRepo.SendMail(s.charID, gm[i].CharID, pkt.Subject, pkt.Body, 0, 0, false, false)
if err != nil {
s.logger.Error("Failed to send mail")
doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4))
return
}
}
} else { } else {
err := s.server.mailRepo.SendMail(s.charID, pkt.RecipientID, pkt.Subject, pkt.Body, pkt.ItemID, pkt.Quantity, false, false) if err := s.server.mailService.Send(s.charID, pkt.RecipientID, pkt.Subject, pkt.Body, pkt.ItemID, pkt.Quantity); err != nil {
if err != nil { s.logger.Error("Failed to send mail", zap.Error(err))
s.logger.Error("Failed to send mail")
} }
} }
doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4))

View File

@@ -386,6 +386,7 @@ func TestHandleMsgMhfSendMail_Direct(t *testing.T) {
server := createMockServer() server := createMockServer()
mock := &mockMailRepo{} mock := &mockMailRepo{}
server.mailRepo = mock server.mailRepo = mock
ensureMailService(server)
session := createMockSession(1, server) session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfSendMail{ pkt := &mhfpacket.MsgMhfSendMail{
@@ -436,6 +437,7 @@ func TestHandleMsgMhfSendMail_Guild(t *testing.T) {
} }
server.mailRepo = mailMock server.mailRepo = mailMock
server.guildRepo = guildMock server.guildRepo = guildMock
ensureMailService(server)
session := createMockSession(1, server) session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfSendMail{ pkt := &mhfpacket.MsgMhfSendMail{
@@ -470,6 +472,7 @@ func TestHandleMsgMhfSendMail_GuildNotFound(t *testing.T) {
guildMock := &mockGuildRepoForMail{getErr: errNotFound} guildMock := &mockGuildRepoForMail{getErr: errNotFound}
server.mailRepo = mailMock server.mailRepo = mailMock
server.guildRepo = guildMock server.guildRepo = guildMock
ensureMailService(server)
session := createMockSession(1, server) session := createMockSession(1, server)
pkt := &mhfpacket.MsgMhfSendMail{ pkt := &mhfpacket.MsgMhfSendMail{

View File

@@ -265,9 +265,10 @@ func (m *mockGoocooRepo) SaveSlot(_ uint32, slot uint32, data []byte) error {
// --- mockGuildRepo (minimal, for SendMail guild path) --- // --- mockGuildRepo (minimal, for SendMail guild path) ---
type mockGuildRepoForMail struct { type mockGuildRepoForMail struct {
guild *Guild guild *Guild
members []*GuildMember members []*GuildMember
getErr error getErr error
getMembersErr error
} }
func (m *mockGuildRepoForMail) GetByCharID(_ uint32) (*Guild, error) { func (m *mockGuildRepoForMail) GetByCharID(_ uint32) (*Guild, error) {
@@ -278,6 +279,9 @@ func (m *mockGuildRepoForMail) GetByCharID(_ uint32) (*Guild, error) {
} }
func (m *mockGuildRepoForMail) GetMembers(_ uint32, _ bool) ([]*GuildMember, error) { func (m *mockGuildRepoForMail) GetMembers(_ uint32, _ bool) ([]*GuildMember, error) {
if m.getMembersErr != nil {
return nil, m.getMembersErr
}
return m.members, nil return m.members, nil
} }

View File

@@ -84,16 +84,16 @@ type AnswerScoutResult struct {
// GuildService encapsulates guild business logic, sitting between handlers and repos. // GuildService encapsulates guild business logic, sitting between handlers and repos.
type GuildService struct { type GuildService struct {
guildRepo GuildRepo guildRepo GuildRepo
mailRepo MailRepo mailSvc *MailService
charRepo CharacterRepo charRepo CharacterRepo
logger *zap.Logger logger *zap.Logger
} }
// NewGuildService creates a new GuildService. // NewGuildService creates a new GuildService.
func NewGuildService(gr GuildRepo, mr MailRepo, cr CharacterRepo, log *zap.Logger) *GuildService { func NewGuildService(gr GuildRepo, ms *MailService, cr CharacterRepo, log *zap.Logger) *GuildService {
return &GuildService{ return &GuildService{
guildRepo: gr, guildRepo: gr,
mailRepo: mr, mailSvc: ms,
charRepo: cr, charRepo: cr,
logger: log, logger: log,
} }
@@ -148,7 +148,7 @@ func (svc *GuildService) OperateMember(actorCharID, targetCharID uint32, action
} }
// Send mail best-effort // Send mail best-effort
if mailErr := svc.mailRepo.SendMail(mail.SenderID, mail.RecipientID, mail.Subject, mail.Body, 0, 0, false, true); mailErr != nil { if mailErr := svc.mailSvc.SendSystem(mail.RecipientID, mail.Subject, mail.Body); mailErr != nil {
svc.logger.Warn("Failed to send guild member operation mail", zap.Error(mailErr)) svc.logger.Warn("Failed to send guild member operation mail", zap.Error(mailErr))
} }
@@ -255,9 +255,8 @@ func (svc *GuildService) Leave(charID, guildID uint32, isApplicant bool, guildNa
} }
// Best-effort withdrawal notification // Best-effort withdrawal notification
if err := svc.mailRepo.SendMail(0, charID, "Withdrawal", if err := svc.mailSvc.SendSystem(charID, "Withdrawal",
fmt.Sprintf("You have withdrawn from 「%s」.", guildName), fmt.Sprintf("You have withdrawn from 「%s」.", guildName)); err != nil {
0, 0, false, true); err != nil {
svc.logger.Warn("Failed to send guild withdrawal notification", zap.Error(err)) svc.logger.Warn("Failed to send guild withdrawal notification", zap.Error(err))
} }
@@ -342,7 +341,7 @@ func (svc *GuildService) AnswerScout(charID, leaderID uint32, accept bool, strin
// Send mails best-effort // Send mails best-effort
for _, m := range mails { for _, m := range mails {
if mailErr := svc.mailRepo.SendMail(m.SenderID, m.RecipientID, m.Subject, m.Body, 0, 0, false, true); mailErr != nil { if mailErr := svc.mailSvc.SendSystem(m.RecipientID, m.Subject, m.Body); mailErr != nil {
svc.logger.Warn("Failed to send guild scout response mail", zap.Error(mailErr)) svc.logger.Warn("Failed to send guild scout response mail", zap.Error(mailErr))
} }
} }

View File

@@ -7,9 +7,15 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
func newTestMailService(mr MailRepo, gr GuildRepo) *MailService {
logger, _ := zap.NewDevelopment()
return NewMailService(mr, gr, logger)
}
func newTestGuildService(gr GuildRepo, mr MailRepo) *GuildService { func newTestGuildService(gr GuildRepo, mr MailRepo) *GuildService {
logger, _ := zap.NewDevelopment() logger, _ := zap.NewDevelopment()
return NewGuildService(gr, mr, nil, logger) ms := newTestMailService(mr, gr)
return NewGuildService(gr, ms, nil, logger)
} }
func TestGuildService_OperateMember(t *testing.T) { func TestGuildService_OperateMember(t *testing.T) {

View File

@@ -0,0 +1,55 @@
package channelserver
import (
"fmt"
"go.uber.org/zap"
)
// MailService encapsulates mail-sending business logic, sitting between
// handlers/services and the MailRepo. It provides convenient methods for
// common mail patterns (system notifications, guild broadcasts, player mail)
// so callers don't need to specify boolean flags directly.
type MailService struct {
mailRepo MailRepo
guildRepo GuildRepo
logger *zap.Logger
}
// NewMailService creates a new MailService.
func NewMailService(mr MailRepo, gr GuildRepo, log *zap.Logger) *MailService {
return &MailService{
mailRepo: mr,
guildRepo: gr,
logger: log,
}
}
// Send sends a player-to-player mail with an optional item attachment.
func (svc *MailService) Send(senderID, recipientID uint32, subject, body string, itemID, quantity uint16) error {
return svc.mailRepo.SendMail(senderID, recipientID, subject, body, itemID, quantity, false, false)
}
// SendSystem sends a system notification mail (no item, flagged as system message).
func (svc *MailService) SendSystem(recipientID uint32, subject, body string) error {
return svc.mailRepo.SendMail(0, recipientID, subject, body, 0, 0, false, true)
}
// SendGuildInvite sends a guild invitation mail (flagged as guild invite).
func (svc *MailService) SendGuildInvite(senderID, recipientID uint32, subject, body string) error {
return svc.mailRepo.SendMail(senderID, recipientID, subject, body, 0, 0, true, false)
}
// BroadcastToGuild sends a mail from senderID to all members of the specified guild.
func (svc *MailService) BroadcastToGuild(senderID, guildID uint32, subject, body string) error {
members, err := svc.guildRepo.GetMembers(guildID, false)
if err != nil {
return fmt.Errorf("get guild members for broadcast: %w", err)
}
for _, m := range members {
if err := svc.mailRepo.SendMail(senderID, m.CharID, subject, body, 0, 0, false, false); err != nil {
return fmt.Errorf("send guild broadcast to char %d: %w", m.CharID, err)
}
}
return nil
}

View File

@@ -0,0 +1,162 @@
package channelserver
import (
"errors"
"testing"
"go.uber.org/zap"
)
func TestMailService_Send(t *testing.T) {
mock := &mockMailRepo{}
logger, _ := zap.NewDevelopment()
svc := NewMailService(mock, nil, logger)
err := svc.Send(1, 42, "Hello", "World", 500, 3)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if len(mock.sentMails) != 1 {
t.Fatalf("Expected 1 mail, got %d", len(mock.sentMails))
}
m := mock.sentMails[0]
if m.senderID != 1 {
t.Errorf("SenderID = %d, want 1", m.senderID)
}
if m.recipientID != 42 {
t.Errorf("RecipientID = %d, want 42", m.recipientID)
}
if m.subject != "Hello" {
t.Errorf("Subject = %q, want %q", m.subject, "Hello")
}
if m.itemID != 500 {
t.Errorf("ItemID = %d, want 500", m.itemID)
}
if m.itemAmount != 3 {
t.Errorf("Quantity = %d, want 3", m.itemAmount)
}
if m.isGuildInvite || m.isSystemMessage {
t.Error("Should not be guild invite or system message")
}
}
func TestMailService_Send_Error(t *testing.T) {
mock := &mockMailRepo{sendErr: errors.New("db fail")}
logger, _ := zap.NewDevelopment()
svc := NewMailService(mock, nil, logger)
err := svc.Send(1, 42, "Hello", "World", 0, 0)
if err == nil {
t.Fatal("Expected error, got nil")
}
}
func TestMailService_SendSystem(t *testing.T) {
mock := &mockMailRepo{}
logger, _ := zap.NewDevelopment()
svc := NewMailService(mock, nil, logger)
err := svc.SendSystem(42, "System Alert", "Something happened")
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if len(mock.sentMails) != 1 {
t.Fatalf("Expected 1 mail, got %d", len(mock.sentMails))
}
m := mock.sentMails[0]
if m.senderID != 0 {
t.Errorf("SenderID = %d, want 0 (system)", m.senderID)
}
if m.recipientID != 42 {
t.Errorf("RecipientID = %d, want 42", m.recipientID)
}
if !m.isSystemMessage {
t.Error("Should be system message")
}
if m.isGuildInvite {
t.Error("Should not be guild invite")
}
}
func TestMailService_SendGuildInvite(t *testing.T) {
mock := &mockMailRepo{}
logger, _ := zap.NewDevelopment()
svc := NewMailService(mock, nil, logger)
err := svc.SendGuildInvite(1, 42, "Invite", "Join us")
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if len(mock.sentMails) != 1 {
t.Fatalf("Expected 1 mail, got %d", len(mock.sentMails))
}
m := mock.sentMails[0]
if !m.isGuildInvite {
t.Error("Should be guild invite")
}
if m.isSystemMessage {
t.Error("Should not be system message")
}
}
func TestMailService_BroadcastToGuild(t *testing.T) {
mailMock := &mockMailRepo{}
guildMock := &mockGuildRepoForMail{
members: []*GuildMember{
{CharID: 100},
{CharID: 200},
{CharID: 300},
},
}
logger, _ := zap.NewDevelopment()
svc := NewMailService(mailMock, guildMock, logger)
err := svc.BroadcastToGuild(1, 10, "News", "Update")
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if len(mailMock.sentMails) != 3 {
t.Fatalf("Expected 3 mails, got %d", len(mailMock.sentMails))
}
recipients := map[uint32]bool{}
for _, m := range mailMock.sentMails {
recipients[m.recipientID] = true
if m.senderID != 1 {
t.Errorf("SenderID = %d, want 1", m.senderID)
}
}
if !recipients[100] || !recipients[200] || !recipients[300] {
t.Errorf("Expected recipients 100, 200, 300, got %v", recipients)
}
}
func TestMailService_BroadcastToGuild_GetMembersError(t *testing.T) {
mailMock := &mockMailRepo{}
guildMock := &mockGuildRepoForMail{getMembersErr: errors.New("db fail")}
logger, _ := zap.NewDevelopment()
svc := NewMailService(mailMock, guildMock, logger)
err := svc.BroadcastToGuild(1, 10, "News", "Update")
if err == nil {
t.Fatal("Expected error, got nil")
}
if len(mailMock.sentMails) != 0 {
t.Errorf("No mails should be sent on error, got %d", len(mailMock.sentMails))
}
}
func TestMailService_BroadcastToGuild_SendError(t *testing.T) {
mailMock := &mockMailRepo{sendErr: errors.New("db fail")}
guildMock := &mockGuildRepoForMail{
members: []*GuildMember{
{CharID: 100},
},
}
logger, _ := zap.NewDevelopment()
svc := NewMailService(mailMock, guildMock, logger)
err := svc.BroadcastToGuild(1, 10, "News", "Update")
if err == nil {
t.Fatal("Expected error, got nil")
}
}

View File

@@ -43,44 +43,45 @@ type Config struct {
// own locks internally and may be acquired at any point. // own locks internally and may be acquired at any point.
type Server struct { type Server struct {
sync.Mutex sync.Mutex
Registry ChannelRegistry Registry ChannelRegistry
ID uint16 ID uint16
GlobalID string GlobalID string
IP string IP string
Port uint16 Port uint16
logger *zap.Logger logger *zap.Logger
db *sqlx.DB db *sqlx.DB
charRepo CharacterRepo charRepo CharacterRepo
guildRepo GuildRepo guildRepo GuildRepo
userRepo UserRepo userRepo UserRepo
gachaRepo GachaRepo gachaRepo GachaRepo
houseRepo HouseRepo houseRepo HouseRepo
festaRepo FestaRepo festaRepo FestaRepo
towerRepo TowerRepo towerRepo TowerRepo
rengokuRepo RengokuRepo rengokuRepo RengokuRepo
mailRepo MailRepo mailRepo MailRepo
stampRepo StampRepo stampRepo StampRepo
distRepo DistributionRepo distRepo DistributionRepo
sessionRepo SessionRepo sessionRepo SessionRepo
eventRepo EventRepo eventRepo EventRepo
achievementRepo AchievementRepo achievementRepo AchievementRepo
shopRepo ShopRepo shopRepo ShopRepo
cafeRepo CafeRepo cafeRepo CafeRepo
goocooRepo GoocooRepo goocooRepo GoocooRepo
divaRepo DivaRepo divaRepo DivaRepo
miscRepo MiscRepo miscRepo MiscRepo
scenarioRepo ScenarioRepo scenarioRepo ScenarioRepo
mercenaryRepo MercenaryRepo mercenaryRepo MercenaryRepo
mailService *MailService
guildService *GuildService guildService *GuildService
achievementService *AchievementService achievementService *AchievementService
gachaService *GachaService gachaService *GachaService
erupeConfig *cfg.Config erupeConfig *cfg.Config
acceptConns chan net.Conn acceptConns chan net.Conn
deleteConns chan net.Conn deleteConns chan net.Conn
sessions map[net.Conn]*Session sessions map[net.Conn]*Session
listener net.Listener // Listener that is created when Server.Start is called. listener net.Listener // Listener that is created when Server.Start is called.
isShuttingDown bool isShuttingDown bool
done chan struct{} // Closed on Shutdown to wake background goroutines. done chan struct{} // Closed on Shutdown to wake background goroutines.
stages StageMap stages StageMap
@@ -156,7 +157,8 @@ func NewServer(config *Config) *Server {
s.scenarioRepo = NewScenarioRepository(config.DB) s.scenarioRepo = NewScenarioRepository(config.DB)
s.mercenaryRepo = NewMercenaryRepository(config.DB) s.mercenaryRepo = NewMercenaryRepository(config.DB)
s.guildService = NewGuildService(s.guildRepo, s.mailRepo, s.charRepo, s.logger) s.mailService = NewMailService(s.mailRepo, s.guildRepo, s.logger)
s.guildService = NewGuildService(s.guildRepo, s.mailService, s.charRepo, s.logger)
s.achievementService = NewAchievementService(s.achievementRepo, s.logger) s.achievementService = NewAchievementService(s.achievementRepo, s.logger)
s.gachaService = NewGachaService(s.gachaRepo, s.userRepo, s.charRepo, s.logger, config.ErupeConfig.GameplayOptions.MaximumNP) s.gachaService = NewGachaService(s.gachaRepo, s.userRepo, s.charRepo, s.logger, config.ErupeConfig.GameplayOptions.MaximumNP)

View File

@@ -55,10 +55,17 @@ func createMockServer() *Server {
return s return s
} }
// ensureMailService wires the MailService from the server's current repos.
// Call this after setting mailRepo and guildRepo on the mock server.
func ensureMailService(s *Server) {
s.mailService = NewMailService(s.mailRepo, s.guildRepo, s.logger)
}
// ensureGuildService wires the GuildService from the server's current repos. // ensureGuildService wires the GuildService from the server's current repos.
// Call this after setting guildRepo, mailRepo, and charRepo on the mock server. // Call this after setting guildRepo, mailRepo, and charRepo on the mock server.
func ensureGuildService(s *Server) { func ensureGuildService(s *Server) {
s.guildService = NewGuildService(s.guildRepo, s.mailRepo, s.charRepo, s.logger) ensureMailService(s)
s.guildService = NewGuildService(s.guildRepo, s.mailService, s.charRepo, s.logger)
} }
// ensureAchievementService wires the AchievementService from the server's current repos. // ensureAchievementService wires the AchievementService from the server's current repos.