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

@@ -268,6 +268,7 @@ 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

@@ -71,6 +71,7 @@ type Server struct {
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
@@ -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.