diff --git a/server/channelserver/handlers_mail.go b/server/channelserver/handlers_mail.go index e4ea05d64..d6981d9a3 100644 --- a/server/channelserver/handlers_mail.go +++ b/server/channelserver/handlers_mail.go @@ -194,31 +194,21 @@ func handleMsgMhfOprtMail(s *Session, p mhfpacket.MHFPacket) { func handleMsgMhfSendMail(s *Session, p mhfpacket.MHFPacket) { 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) if err != nil { s.logger.Error("Failed to get guild info for mail") doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) return } - gm, err := s.server.guildRepo.GetMembers(g.ID, false) - if err != nil { - s.logger.Error("Failed to get guild members for mail") + if err := s.server.mailService.BroadcastToGuild(s.charID, g.ID, pkt.Subject, pkt.Body); err != nil { + s.logger.Error("Failed to broadcast guild mail", zap.Error(err)) doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) 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 { - err := s.server.mailRepo.SendMail(s.charID, pkt.RecipientID, pkt.Subject, pkt.Body, pkt.ItemID, pkt.Quantity, false, false) - if err != nil { - s.logger.Error("Failed to send mail") + if err := s.server.mailService.Send(s.charID, pkt.RecipientID, pkt.Subject, pkt.Body, pkt.ItemID, pkt.Quantity); err != nil { + s.logger.Error("Failed to send mail", zap.Error(err)) } } doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) diff --git a/server/channelserver/handlers_mail_test.go b/server/channelserver/handlers_mail_test.go index d444397d4..e6646c8fe 100644 --- a/server/channelserver/handlers_mail_test.go +++ b/server/channelserver/handlers_mail_test.go @@ -386,6 +386,7 @@ func TestHandleMsgMhfSendMail_Direct(t *testing.T) { server := createMockServer() mock := &mockMailRepo{} server.mailRepo = mock + ensureMailService(server) session := createMockSession(1, server) pkt := &mhfpacket.MsgMhfSendMail{ @@ -436,6 +437,7 @@ func TestHandleMsgMhfSendMail_Guild(t *testing.T) { } server.mailRepo = mailMock server.guildRepo = guildMock + ensureMailService(server) session := createMockSession(1, server) pkt := &mhfpacket.MsgMhfSendMail{ @@ -470,6 +472,7 @@ func TestHandleMsgMhfSendMail_GuildNotFound(t *testing.T) { guildMock := &mockGuildRepoForMail{getErr: errNotFound} server.mailRepo = mailMock server.guildRepo = guildMock + ensureMailService(server) session := createMockSession(1, server) pkt := &mhfpacket.MsgMhfSendMail{ diff --git a/server/channelserver/repo_mocks_test.go b/server/channelserver/repo_mocks_test.go index b7a6bd7a7..0f731e3f1 100644 --- a/server/channelserver/repo_mocks_test.go +++ b/server/channelserver/repo_mocks_test.go @@ -265,9 +265,10 @@ func (m *mockGoocooRepo) SaveSlot(_ uint32, slot uint32, data []byte) error { // --- mockGuildRepo (minimal, for SendMail guild path) --- type mockGuildRepoForMail struct { - guild *Guild - members []*GuildMember - getErr error + guild *Guild + members []*GuildMember + getErr error + getMembersErr 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) { + if m.getMembersErr != nil { + return nil, m.getMembersErr + } return m.members, nil } diff --git a/server/channelserver/svc_guild.go b/server/channelserver/svc_guild.go index d57251716..3ea5268ad 100644 --- a/server/channelserver/svc_guild.go +++ b/server/channelserver/svc_guild.go @@ -84,16 +84,16 @@ type AnswerScoutResult struct { // GuildService encapsulates guild business logic, sitting between handlers and repos. type GuildService struct { guildRepo GuildRepo - mailRepo MailRepo + mailSvc *MailService charRepo CharacterRepo logger *zap.Logger } // 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{ guildRepo: gr, - mailRepo: mr, + mailSvc: ms, charRepo: cr, logger: log, } @@ -148,7 +148,7 @@ func (svc *GuildService) OperateMember(actorCharID, targetCharID uint32, action } // 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)) } @@ -255,9 +255,8 @@ func (svc *GuildService) Leave(charID, guildID uint32, isApplicant bool, guildNa } // Best-effort withdrawal notification - if err := svc.mailRepo.SendMail(0, charID, "Withdrawal", - fmt.Sprintf("You have withdrawn from 「%s」.", guildName), - 0, 0, false, true); err != nil { + if err := svc.mailSvc.SendSystem(charID, "Withdrawal", + fmt.Sprintf("You have withdrawn from 「%s」.", guildName)); err != nil { 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 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)) } } diff --git a/server/channelserver/svc_guild_test.go b/server/channelserver/svc_guild_test.go index 0f82126b4..7276090d8 100644 --- a/server/channelserver/svc_guild_test.go +++ b/server/channelserver/svc_guild_test.go @@ -7,9 +7,15 @@ import ( "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 { 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) { diff --git a/server/channelserver/svc_mail.go b/server/channelserver/svc_mail.go new file mode 100644 index 000000000..36ed8ecd8 --- /dev/null +++ b/server/channelserver/svc_mail.go @@ -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 +} diff --git a/server/channelserver/svc_mail_test.go b/server/channelserver/svc_mail_test.go new file mode 100644 index 000000000..d272e9764 --- /dev/null +++ b/server/channelserver/svc_mail_test.go @@ -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") + } +} diff --git a/server/channelserver/sys_channel_server.go b/server/channelserver/sys_channel_server.go index 56e3cf658..cbc6dd2c9 100644 --- a/server/channelserver/sys_channel_server.go +++ b/server/channelserver/sys_channel_server.go @@ -43,44 +43,45 @@ type Config struct { // own locks internally and may be acquired at any point. type Server struct { sync.Mutex - Registry ChannelRegistry - ID uint16 - GlobalID string - IP string - Port uint16 - logger *zap.Logger - db *sqlx.DB - charRepo CharacterRepo - guildRepo GuildRepo - userRepo UserRepo - gachaRepo GachaRepo - houseRepo HouseRepo - festaRepo FestaRepo - towerRepo TowerRepo - rengokuRepo RengokuRepo - mailRepo MailRepo - stampRepo StampRepo - distRepo DistributionRepo - sessionRepo SessionRepo - eventRepo EventRepo - achievementRepo AchievementRepo - shopRepo ShopRepo - cafeRepo CafeRepo - goocooRepo GoocooRepo - divaRepo DivaRepo - miscRepo MiscRepo - scenarioRepo ScenarioRepo - mercenaryRepo MercenaryRepo + Registry ChannelRegistry + ID uint16 + GlobalID string + IP string + Port uint16 + logger *zap.Logger + db *sqlx.DB + charRepo CharacterRepo + guildRepo GuildRepo + userRepo UserRepo + gachaRepo GachaRepo + houseRepo HouseRepo + festaRepo FestaRepo + towerRepo TowerRepo + rengokuRepo RengokuRepo + mailRepo MailRepo + stampRepo StampRepo + distRepo DistributionRepo + sessionRepo SessionRepo + eventRepo EventRepo + achievementRepo AchievementRepo + shopRepo ShopRepo + cafeRepo CafeRepo + goocooRepo GoocooRepo + divaRepo DivaRepo + miscRepo MiscRepo + scenarioRepo ScenarioRepo + mercenaryRepo MercenaryRepo + mailService *MailService guildService *GuildService achievementService *AchievementService gachaService *GachaService - erupeConfig *cfg.Config - acceptConns chan net.Conn - deleteConns chan net.Conn - sessions map[net.Conn]*Session - listener net.Listener // Listener that is created when Server.Start is called. - isShuttingDown bool - done chan struct{} // Closed on Shutdown to wake background goroutines. + erupeConfig *cfg.Config + acceptConns chan net.Conn + deleteConns chan net.Conn + sessions map[net.Conn]*Session + listener net.Listener // Listener that is created when Server.Start is called. + isShuttingDown bool + done chan struct{} // Closed on Shutdown to wake background goroutines. stages StageMap @@ -156,7 +157,8 @@ func NewServer(config *Config) *Server { s.scenarioRepo = NewScenarioRepository(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.gachaService = NewGachaService(s.gachaRepo, s.userRepo, s.charRepo, s.logger, config.ErupeConfig.GameplayOptions.MaximumNP) diff --git a/server/channelserver/test_helpers_test.go b/server/channelserver/test_helpers_test.go index a25009cef..a403ff7cb 100644 --- a/server/channelserver/test_helpers_test.go +++ b/server/channelserver/test_helpers_test.go @@ -55,10 +55,17 @@ func createMockServer() *Server { 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. // Call this after setting guildRepo, mailRepo, and charRepo on the mock 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.