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) {
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))

View File

@@ -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{

View File

@@ -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
}

View File

@@ -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))
}
}

View File

@@ -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) {

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.
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)

View File

@@ -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.