✨ graph api发送邮件

This commit is contained in:
sugarBecomer
2026-03-26 15:19:43 +08:00
parent 5a244c329e
commit 9fa83657e3
12 changed files with 648 additions and 246 deletions
+3 -2
View File
@@ -1,6 +1,6 @@
module likedream-goframe
go 1.24.0
go 1.25.0
require (
entgo.io/ent v0.14.5
@@ -28,10 +28,12 @@ require (
github.com/wenlng/go-captcha-assets v1.0.7
github.com/wenlng/go-captcha/v2 v2.0.4
github.com/wneessen/go-mail v0.6.2
github.com/xuri/excelize/v2 v2.10.0
go.uber.org/automaxprocs v1.6.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.43.0
golang.org/x/net v0.46.0
golang.org/x/oauth2 v0.36.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
resty.dev/v3 v3.0.0-beta.3
@@ -100,7 +102,6 @@ require (
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/excelize/v2 v2.10.0 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zclconf/go-cty v1.14.4 // indirect
+4 -4
View File
@@ -200,8 +200,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@@ -281,10 +281,10 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+15 -17
View File
@@ -2,6 +2,7 @@ package api
import (
"context"
"likedream-goframe/internal/common/cst"
"likedream-goframe/internal/controller/agent"
"likedream-goframe/internal/controller/balance"
@@ -26,56 +27,53 @@ import (
func StartServer(ctx context.Context) {
s := g.Server()
// 设置端口
port, err := g.Cfg().Get(ctx, cst.ConfigServerPort)
utils.OkOrPanic(err)
s.SetNameToUriType(3)
// 设置中间件
s.Use(
middleware.ExceptionHandler,
)
s.Use(middleware.ExceptionHandler)
s.BindStatusHandler(404, common.NewBaseV1().NotFound)
// 业务路由
s.Group("/v1", func(group *ghttp.RouterGroup) {
group.Bind(user_login.NewUserLoginV1())
// 系统设置路由
group.Bind(config.NewMailOAuthCtrlV1())
systemGroup := group.Group("/system").Middleware(middleware.AuthHandler, middleware.PermissionHandler)
systemGroup.Bind(role.NewV1())
systemGroup.Bind(menu.NewV1())
systemGroup.Bind(user.NewUserCtrlV1())
systemGroup.Bind(config.NewConfigCtrlV1())
systemGroup.Bind(config.NewAIConfigCtrlV1())
//systemGroup.Bind(agent.NewV1())
// 用户信息路由
userGroup := group.Group("/user").Middleware(middleware.AuthHandler, middleware.PermissionHandler)
userGroup.Bind(user_login.NewUserInfoV1())
userGroup.Bind(balance.NewBalanceCtrlV1())
// 代理信息路由
agentGroup := group.Group("/agent").Middleware(middleware.AuthHandler, middleware.PermissionHandler)
agentGroup.Bind(agent.NewV1())
agentGroup.Bind(card.NewCardCtrlV1())
// 今日校园
cpdailyGroup := group.Group("/cpdaily").Middleware(middleware.AuthHandler, middleware.PermissionHandler)
cpdailyGroup.Bind(cpdaily.NewCpUserCtrlV1())
cpdailyGroup.Bind(cpdaily.NewCpTaskCtrlV1())
cpdailyGroup.Bind(cpdaily.NewCpImageCtrlV1())
cpdailyGroup.Bind(cpdaily.NewCpTaskLogCtrlV1())
cpdailyGroup.Bind(cpdaily.NewCpLeaveCtrlV1())
// 娱乐
musicGroup := group.Group("/music").Middleware(middleware.AuthHandler, middleware.PermissionHandler)
musicGroup.Bind(music.NewMusicCtrlV1())
// 网课
courseGroup := group.Group("/course").Middleware(middleware.AuthHandler, middleware.PermissionHandler)
courseGroup.Bind(course.NewXxtCtrlV1())
courseGroup.Bind(course.NewSmartEduSCCtrlV1())
})
s.SetPort(port.Int())
s.GetOpenApi().Config.IgnorePkgPath = true
// 设置全局异常捕获
e := s.Start()
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
err = s.Start()
utils.OkOrPanic(err, oops.WithContext(ctx).Wrap)
}
func StopServer() {
+46 -10
View File
@@ -24,26 +24,62 @@ type ConfigListReq struct {
}
type ConfigPublicGetReq struct {
g.Meta `path:"/config/public/{key}" method:"get" tags:"Config" summary:"获取公开配置(无需认证)" auth:"false"`
g.Meta `path:"/config/public/{key}" method:"get" tags:"Config" summary:"获取公开配置" auth:"false"`
Key string `json:"key" v:"required#配置键不能为空"`
}
// SMTP 配置
type SmtpConfigGetReq struct {
g.Meta `path:"/config/smtp" method:"get" tags:"Config" summary:"获取SMTP配置" permission:"sys:config:edit"`
g.Meta `path:"/config/smtp" method:"get" tags:"Config" summary:"获取邮件配置" permission:"sys:config:edit"`
}
type SmtpConfigUpdateReq struct {
g.Meta `path:"/config/smtp" method:"put" tags:"Config" summary:"更新SMTP配置" permission:"sys:config:edit"`
SmtpHost string `json:"smtpHost" v:"required#SMTP服务器地址不能为空"`
SmtpPort int `json:"smtpPort" v:"required#SMTP端口不能为空"`
SmtpUsername string `json:"smtpUsername" v:"required#SMTP用户名不能为空"`
SmtpPassword string `json:"smtpPassword" v:"required#SMTP密码不能为空"`
FromEmail string `json:"fromEmail" v:"required#发件人邮箱不能为空"`
g.Meta `path:"/config/smtp" method:"put" tags:"Config" summary:"更新邮件配置" permission:"sys:config:edit"`
AuthMode string `json:"authMode" v:"required#请选择邮件认证模式"`
SmtpHost string `json:"smtpHost"`
SmtpPort int `json:"smtpPort"`
SmtpUsername string `json:"smtpUsername"`
SmtpPassword string `json:"smtpPassword"`
FromEmail string `json:"fromEmail" v:"required|email#发件人邮箱不能为空|请输入正确的发件人邮箱"`
FromName string `json:"fromName" v:"required#发件人名称不能为空"`
OAuthTenant string `json:"oauthTenant"`
OAuthClientID string `json:"oauthClientId"`
OAuthClientSecret string `json:"oauthClientSecret"`
OAuthRedirectURL string `json:"oauthRedirectUrl"`
OAuthRefreshToken string `json:"oauthRefreshToken"`
OAuthScope string `json:"oauthScope"`
}
type SmtpTestReq struct {
g.Meta `path:"/config/smtp/test" method:"post" tags:"Config" summary:"发送测试邮件" permission:"sys:config:edit"`
ToEmail string `json:"toEmail" v:"required|email#收件人邮箱不能为空|请输入有效邮箱"`
ToEmail string `json:"toEmail" v:"required|email#收件人邮箱不能为空|请输入正确的收件人邮箱"`
}
type OutlookOAuthAuthorizeURLReq struct {
g.Meta `path:"/config/smtp/oauth/outlook/authorize-url" method:"post" tags:"Config" summary:"获取 Outlook OAuth2 授权地址" permission:"sys:config:edit"`
OAuthTenant string `json:"oauthTenant"`
OAuthClientID string `json:"oauthClientId" v:"required#Client ID 不能为空"`
OAuthClientSecret string `json:"oauthClientSecret" v:"required#Client Secret 不能为空"`
OAuthScope string `json:"oauthScope"`
FromEmail string `json:"fromEmail" v:"required|email#发件人邮箱不能为空|请输入正确的发件人邮箱"`
FrontendOrigin string `json:"frontendOrigin" v:"required#前端来源地址不能为空"`
CallbackBaseURL string `json:"callbackBaseUrl" v:"required#API 基础地址不能为空"`
}
type OutlookOAuthAuthorizeURLResp struct {
AuthURL string `json:"authUrl"`
CallbackURL string `json:"callbackUrl"`
State string `json:"state"`
}
type OutlookOAuthCallbackReq struct {
g.Meta `path:"/mail/oauth/outlook/callback" method:"get" tags:"MailOAuth" summary:"Outlook OAuth2 回调" auth:"false"`
Code string `json:"code"`
State string `json:"state"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
+10 -8
View File
@@ -28,22 +28,26 @@ const (
ConfigTaskConcurrency = "task.concurrency"
// Email配置常量
ConfigEmailAuthMode = "email.authMode"
ConfigEmailSMTPHost = "email.smtpHost"
ConfigEmailSMTPPort = "email.smtpPort"
ConfigEmailSMTPUsername = "email.smtpUsername"
ConfigEmailSMTPPassword = "email.smtpPassword"
ConfigEmailFromEmail = "email.fromEmail"
ConfigEmailFromName = "email.fromName"
ConfigEmailOAuthTenant = "email.oauthTenant"
ConfigEmailOAuthClientID = "email.oauthClientId"
ConfigEmailOAuthClientSecret = "email.oauthClientSecret"
ConfigEmailOAuthRedirectURL = "email.oauthRedirectUrl"
ConfigEmailOAuthRefreshToken = "email.oauthRefreshToken"
ConfigEmailOAuthScope = "email.oauthScope"
// 基础积分配置
ConfigBasePointCpdailyTask = "basePoint.cpdailyTask"
ConfigBasePointCpdailyLeave = "basePoint.cpdailyLeave"
ConfigBasePointCpdailyPhone = "basePoint.cpdailyPhone"
// 系统配置
ConfigSystemAllowRegister = "system.allowRegister" // 是否允许注册
ConfigSystemRequireInviteCode = "system.requireInviteCode" // 是否必须邀请码
ConfigSystemAllowRegister = "system.allowRegister"
ConfigSystemRequireInviteCode = "system.requireInviteCode"
ConfigRedisCpdailyTask = "price.cpdaily_task"
ConfigRedisCpdailyLeave = "price.cpdaily_leave"
@@ -51,15 +55,13 @@ const (
ConfigRedisXxt = "price.xxt"
ConfigRedisSmarteduSc = "price.smartedu_sc"
// 第三方题库配置
ConfigQuesBankUrl = "quesbank.url"
ConfigQuesBankKey = "quesbank.key"
)
const (
// 应用程序常量
AppName = "若梦系统"
AppCopyright = "© 2025 若梦系统. 保留所有权利."
AppCopyright = "© 2025 若梦系统. 保留所有权利"
)
var (
+7 -7
View File
@@ -2,14 +2,14 @@ package config
import (
"context"
v1 "likedream-goframe/internal/api/v1"
"likedream-goframe/internal/ent"
"likedream-goframe/internal/service"
"likedream-goframe/internal/utils"
)
type ConfigCtrlV1 struct {
}
type ConfigCtrlV1 struct{}
func (c ConfigCtrlV1) Get(ctx context.Context, req *v1.ConfigGetReq) (res *utils.Response[*ent.SysConfig], err error) {
defer utils.Pass(&err)
@@ -31,13 +31,11 @@ func (c ConfigCtrlV1) Update(ctx context.Context, req *v1.ConfigUpdateReq) (res
return service.Global.Config.Update(ctx, req), nil
}
// GetPriceConfig 获取费率配置
func (c ConfigCtrlV1) GetPriceConfig(ctx context.Context, req *v1.PriceConfigGetReq) (res *utils.Response[*v1.PriceConfigResp], err error) {
defer utils.Pass(&err)
return service.Global.Config.GetPriceConfig(ctx, req), nil
}
// UpdatePriceConfig 更新费率配置
func (c ConfigCtrlV1) UpdatePriceConfig(ctx context.Context, req *v1.PriceConfigUpdateReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.Config.UpdatePriceConfig(ctx, req), nil
@@ -47,20 +45,22 @@ func NewConfigCtrlV1() IConfigCtrlV1 {
return &ConfigCtrlV1{}
}
// GetSmtpConfig 获取SMTP配置
func (c ConfigCtrlV1) GetSmtpConfig(ctx context.Context, req *v1.SmtpConfigGetReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.Config.GetSmtpConfig(ctx, req), nil
}
// UpdateSmtpConfig 更新SMTP配置
func (c ConfigCtrlV1) UpdateSmtpConfig(ctx context.Context, req *v1.SmtpConfigUpdateReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.Config.UpdateSmtpConfig(ctx, req), nil
}
// TestSmtp 发送测试邮件
func (c ConfigCtrlV1) TestSmtp(ctx context.Context, req *v1.SmtpTestReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.Config.TestSmtp(ctx, req), nil
}
func (c ConfigCtrlV1) GetOutlookOAuthAuthorizeURL(ctx context.Context, req *v1.OutlookOAuthAuthorizeURLReq) (res *utils.Response[*v1.OutlookOAuthAuthorizeURLResp], err error) {
defer utils.Pass(&err)
return service.Global.Config.GetOutlookOAuthAuthorizeURL(ctx, req), nil
}
@@ -2,6 +2,7 @@ package config
import (
"context"
v1 "likedream-goframe/internal/api/v1"
"likedream-goframe/internal/ent"
"likedream-goframe/internal/utils"
@@ -17,4 +18,5 @@ type IConfigCtrlV1 interface {
GetSmtpConfig(ctx context.Context, req *v1.SmtpConfigGetReq) (*utils.Response[any], error)
UpdateSmtpConfig(ctx context.Context, req *v1.SmtpConfigUpdateReq) (*utils.Response[any], error)
TestSmtp(ctx context.Context, req *v1.SmtpTestReq) (*utils.Response[any], error)
GetOutlookOAuthAuthorizeURL(ctx context.Context, req *v1.OutlookOAuthAuthorizeURLReq) (*utils.Response[*v1.OutlookOAuthAuthorizeURLResp], error)
}
@@ -0,0 +1,37 @@
package config
import (
"context"
"fmt"
v1 "likedream-goframe/internal/api/v1"
mailservice "likedream-goframe/internal/service/email"
"likedream-goframe/internal/utils"
"github.com/gogf/gf/v2/net/ghttp"
)
type MailOAuthCtrlV1 struct{}
func NewMailOAuthCtrlV1() *MailOAuthCtrlV1 {
return &MailOAuthCtrlV1{}
}
func (c *MailOAuthCtrlV1) OutlookCallback(ctx context.Context, req *v1.OutlookOAuthCallbackReq) (res *utils.Response[any], err error) {
r := ghttp.RequestFromCtx(ctx)
r.Response.Header().Set("Content-Type", "text/html; charset=utf-8")
if req.Error != "" {
r.Response.Write(mailservice.BuildOutlookOAuthErrorHTML(fmt.Sprintf("%s: %s", req.Error, req.ErrorDescription)))
return nil, nil
}
refreshToken, frontendOrigin, exchangeErr := mailservice.NewV1().ExchangeOutlookAuthorizeCode(ctx, req.State, req.Code)
if exchangeErr != nil {
r.Response.Write(mailservice.BuildOutlookOAuthPopupHTML("*", false, exchangeErr.Error(), ""))
return nil, nil
}
r.Response.Write(mailservice.BuildOutlookOAuthPopupHTML(frontendOrigin, true, "授权成功", refreshToken))
return nil, nil
}
+11
View File
@@ -4,10 +4,21 @@ type SendPayload struct {
To string
Subject string
Body string
AuthMode string
FromName string
FromEmail string
SMTPHost string
SMTPPort int
SMTPUsername string
SMTPPassword string
OAuthTenant string
OAuthClientID string
OAuthClientSecret string
OAuthRedirectURL string
OAuthRefreshToken string
OAuthScope string
}
+17 -34
View File
@@ -4,58 +4,41 @@ import (
"bytes"
"context"
"encoding/gob"
"likedream-goframe/internal/framework/logger"
"likedream-goframe/internal/framework/model/email"
payloadmodel "likedream-goframe/internal/framework/model/email"
mailservice "likedream-goframe/internal/service/email"
"likedream-goframe/internal/utils"
"time"
"github.com/hibiken/asynq"
"github.com/samber/oops"
"github.com/wneessen/go-mail"
"go.uber.org/zap"
)
type EmailTaskHandler struct {
}
type EmailTaskHandler struct{}
func (h *EmailTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task) (err error) {
defer utils.Pass(&err)
var payload email.SendPayload
e := gob.NewDecoder(bytes.NewReader(task.Payload())).Decode(&payload)
var payload payloadmodel.SendPayload
if e := gob.NewDecoder(bytes.NewReader(task.Payload())).Decode(&payload); e != nil {
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
logger.Logger.Info("开始处理邮件发送", zap.String("邮箱", payload.To))
}
logger.Logger.Info("start sending email", zap.String("to", payload.To))
defer func() {
if r := recover(); r != nil {
// 打印日志
logger.Logger.Error("邮件发送失败", zap.String("邮箱", payload.To), zap.Error(r.(error)))
// 重新抛出相同的 panic
logger.Logger.Error("send email failed", zap.String("to", payload.To), zap.Error(r.(error)))
panic(r)
}
}()
message := mail.NewMsg()
e = message.FromFormat(payload.FromName, payload.FromEmail)
svc := mailservice.NewV1()
cfg := mailservice.ConfigFromPayload(payload)
if e := svc.SendMessageNow(ctx, cfg, payload.To, payload.Subject, payload.Body); e != nil {
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
}
e = message.AddTo(payload.To)
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
message.Subject(payload.Subject)
message.SetBodyString(mail.TypeTextHTML, payload.Body)
client, e := mail.NewClient(payload.SMTPHost, mail.WithPort(payload.SMTPPort),
mail.WithSMTPAuth(mail.SMTPAuthLogin), // Outlook 使用 LOGIN 认证
mail.WithTLSPortPolicy(mail.TLSMandatory),
mail.WithTimeout(30*time.Second), // 连接超时 30 秒
mail.WithUsername(payload.SMTPUsername), mail.WithPassword(payload.SMTPPassword))
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
defer utils.LogError(ctx, "关闭邮件客户端失败", client.Close())
e = client.DialAndSendWithContext(ctx, message)
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
logger.Logger.Info("邮件发送完成", zap.String("邮箱", payload.To))
return
logger.Logger.Info("email sent", zap.String("to", payload.To))
return nil
}
+121 -68
View File
@@ -3,45 +3,37 @@ package config
import (
"context"
"fmt"
v1 "likedream-goframe/internal/api/v1"
cst2 "likedream-goframe/internal/common/cst"
"likedream-goframe/internal/common/db"
errors2 "likedream-goframe/internal/common/errors"
"likedream-goframe/internal/ent"
"likedream-goframe/internal/ent/sysconfig"
"likedream-goframe/internal/service/email"
mailservice "likedream-goframe/internal/service/email"
"likedream-goframe/internal/utils"
"time"
mail "github.com/wneessen/go-mail"
)
type ConfigServiceV1 struct {
}
type ConfigServiceV1 struct{}
func NewV1() *ConfigServiceV1 {
return &ConfigServiceV1{}
}
// Get 获取配置
func (s *ConfigServiceV1) Get(ctx context.Context, req *v1.ConfigGetReq) *utils.Response[*ent.SysConfig] {
config, err := db.Global.SysConfig.Query().
Where(sysconfig.KeyEQ(req.Key)).
Only(ctx)
if err != nil {
if ent.IsNotFound(err) {
utils.OkOrPanic(errors2.NewBusinessError(404, "配置不存在"))
}
utils.OkOrPanic(err)
}
return utils.SuccessWithData[*ent.SysConfig](config)
}
// PublicGet 获取公开配置(无需认证)
func (s *ConfigServiceV1) PublicGet(ctx context.Context, req *v1.ConfigPublicGetReq) *utils.Response[*ent.SysConfig] {
// 只允许获取特定的公开配置
allowedKeys := []string{"system.announcement", "system.allowRegister", "system.requireInviteCode"}
allowed := false
@@ -51,7 +43,6 @@ func (s *ConfigServiceV1) PublicGet(ctx context.Context, req *v1.ConfigPublicGet
break
}
}
if !allowed {
utils.OkOrPanic(errors2.NewBusinessError(403, "该配置不允许公开访问"))
}
@@ -59,10 +50,8 @@ func (s *ConfigServiceV1) PublicGet(ctx context.Context, req *v1.ConfigPublicGet
config, err := db.Global.SysConfig.Query().
Where(sysconfig.KeyEQ(req.Key)).
Only(ctx)
if err != nil {
if ent.IsNotFound(err) {
// 如果配置不存在,返回空值
return utils.SuccessWithData[*ent.SysConfig](nil)
}
utils.OkOrPanic(err)
@@ -71,7 +60,6 @@ func (s *ConfigServiceV1) PublicGet(ctx context.Context, req *v1.ConfigPublicGet
return utils.SuccessWithData[*ent.SysConfig](config)
}
// List 获取配置列表
func (s *ConfigServiceV1) List(ctx context.Context, req *v1.ConfigListReq) *utils.Response[[]*ent.SysConfig] {
query := db.Global.SysConfig.Query().
Order(ent.Asc(sysconfig.FieldKey))
@@ -89,37 +77,30 @@ func (s *ConfigServiceV1) List(ctx context.Context, req *v1.ConfigListReq) *util
return utils.SuccessWithTotal[[]*ent.SysConfig](data, total)
}
// Update 更新配置
func (s *ConfigServiceV1) Update(ctx context.Context, req *v1.ConfigUpdateReq) *utils.Response[any] {
// 检查配置是否存在
config, err := db.Global.SysConfig.Query().
Where(sysconfig.KeyEQ(req.Key)).
Only(ctx)
if err != nil {
if ent.IsNotFound(err) {
// 如果不存在,创建新配置
db.Global.SysConfig.Create().
SetKey(req.Key).
SetValue(req.Value).
SetDescription(req.Description).
SetType("markdown"). // 默认为 markdown 类型
SetType("markdown").
SaveX(ctx)
return utils.SuccessWithMsg("配置创建成功")
}
utils.OkOrPanic(err)
}
// 更新配置
db.Global.SysConfig.UpdateOne(config).
SetValue(req.Value).
SetDescription(req.Description).
ExecX(ctx)
return utils.SuccessWithMsg("配置更新成功")
}
// 费率配置键常量
const (
PriceKeyCpdailyTask = "price.cpdaily_task"
PriceKeyCpdailyLeave = "price.cpdaily_leave"
@@ -128,7 +109,6 @@ const (
PriceKeySmarteduSc = "price.smartedu_sc"
)
// GetPriceConfig 获取费率配置
func (s *ConfigServiceV1) GetPriceConfig(ctx context.Context, req *v1.PriceConfigGetReq) *utils.Response[*v1.PriceConfigResp] {
resp := &v1.PriceConfigResp{
CpdailyTask: s.getConfigValue(ctx, PriceKeyCpdailyTask, "0.5"),
@@ -140,7 +120,6 @@ func (s *ConfigServiceV1) GetPriceConfig(ctx context.Context, req *v1.PriceConfi
return utils.SuccessWithData[*v1.PriceConfigResp](resp)
}
// getConfigValue 获取配置值,不存在则返回默认值
func (s *ConfigServiceV1) getConfigValue(ctx context.Context, key, defaultValue string) string {
config, err := db.Global.SysConfig.Query().
Where(sysconfig.KeyEQ(key)).
@@ -151,9 +130,7 @@ func (s *ConfigServiceV1) getConfigValue(ctx context.Context, key, defaultValue
return config.Value
}
// UpdatePriceConfig 更新费率配置
func (s *ConfigServiceV1) UpdatePriceConfig(ctx context.Context, req *v1.PriceConfigUpdateReq) *utils.Response[any] {
// 更新各项费率配置
if req.CpdailyTask != "" {
s.upsertConfig(ctx, PriceKeyCpdailyTask, req.CpdailyTask, "今日校园签到单价")
}
@@ -172,12 +149,10 @@ func (s *ConfigServiceV1) UpdatePriceConfig(ctx context.Context, req *v1.PriceCo
return utils.SuccessWithMsg("费率配置更新成功")
}
// upsertConfig 更新或创建配置
func (s *ConfigServiceV1) upsertConfig(ctx context.Context, key, value, description string) {
config, err := db.Global.SysConfig.Query().
Where(sysconfig.KeyEQ(key)).
Only(ctx)
if err != nil {
if ent.IsNotFound(err) {
db.Global.SysConfig.Create().
@@ -196,76 +171,154 @@ func (s *ConfigServiceV1) upsertConfig(ctx context.Context, key, value, descript
ExecX(ctx)
}
// GetSmtpConfig 获取SMTP配置
func (s *ConfigServiceV1) GetSmtpConfig(ctx context.Context, req *v1.SmtpConfigGetReq) *utils.Response[any] {
keys := []string{
cst2.ConfigEmailAuthMode,
cst2.ConfigEmailSMTPHost,
cst2.ConfigEmailSMTPPort,
cst2.ConfigEmailSMTPUsername,
cst2.ConfigEmailSMTPPassword,
cst2.ConfigEmailFromEmail,
cst2.ConfigEmailFromName,
cst2.ConfigEmailOAuthTenant,
cst2.ConfigEmailOAuthClientID,
cst2.ConfigEmailOAuthClientSecret,
cst2.ConfigEmailOAuthRedirectURL,
cst2.ConfigEmailOAuthRefreshToken,
cst2.ConfigEmailOAuthScope,
}
result := make(map[string]string)
result := make(map[string]string, len(keys))
for _, key := range keys {
config, err := db.Global.SysConfig.Query().Where(sysconfig.KeyEQ(key)).Only(ctx)
if err == nil {
result[key] = config.Value
}
}
return utils.SuccessWithData[any](map[string]string{
"smtpHost": result[cst2.ConfigEmailSMTPHost],
"smtpPort": result[cst2.ConfigEmailSMTPPort],
"smtpUsername": result[cst2.ConfigEmailSMTPUsername],
"smtpPassword": result[cst2.ConfigEmailSMTPPassword],
"fromEmail": result[cst2.ConfigEmailFromEmail],
"fromName": result[cst2.ConfigEmailFromName],
cfg := mailservice.NormalizeConfig(mailservice.EmailConfig{
AuthMode: result[cst2.ConfigEmailAuthMode],
SMTPHost: result[cst2.ConfigEmailSMTPHost],
SMTPPort: atoiOrZero(result[cst2.ConfigEmailSMTPPort]),
SMTPUsername: result[cst2.ConfigEmailSMTPUsername],
SMTPPassword: result[cst2.ConfigEmailSMTPPassword],
FromEmail: result[cst2.ConfigEmailFromEmail],
FromName: result[cst2.ConfigEmailFromName],
OAuthTenant: result[cst2.ConfigEmailOAuthTenant],
OAuthClientID: result[cst2.ConfigEmailOAuthClientID],
OAuthClientSecret: result[cst2.ConfigEmailOAuthClientSecret],
OAuthRedirectURL: result[cst2.ConfigEmailOAuthRedirectURL],
OAuthRefreshToken: result[cst2.ConfigEmailOAuthRefreshToken],
OAuthScope: result[cst2.ConfigEmailOAuthScope],
})
return utils.SuccessWithData[any](map[string]any{
"authMode": cfg.AuthMode,
"smtpHost": cfg.SMTPHost,
"smtpPort": cfg.SMTPPort,
"smtpUsername": cfg.SMTPUsername,
"smtpPassword": cfg.SMTPPassword,
"fromEmail": cfg.FromEmail,
"fromName": cfg.FromName,
"oauthTenant": cfg.OAuthTenant,
"oauthClientId": cfg.OAuthClientID,
"oauthClientSecret": cfg.OAuthClientSecret,
"oauthRedirectUrl": cfg.OAuthRedirectURL,
"oauthRefreshToken": cfg.OAuthRefreshToken,
"oauthScope": cfg.OAuthScope,
})
}
// UpdateSmtpConfig 更新SMTP配置
func (s *ConfigServiceV1) UpdateSmtpConfig(ctx context.Context, req *v1.SmtpConfigUpdateReq) *utils.Response[any] {
configs := map[string]string{
cst2.ConfigEmailSMTPHost: req.SmtpHost,
cst2.ConfigEmailSMTPPort: fmt.Sprintf("%d", req.SmtpPort),
cst2.ConfigEmailSMTPUsername: req.SmtpUsername,
cst2.ConfigEmailSMTPPassword: req.SmtpPassword,
cst2.ConfigEmailFromEmail: req.FromEmail,
cst2.ConfigEmailFromName: req.FromName,
cfg := mailservice.NormalizeConfig(mailservice.EmailConfig{
AuthMode: req.AuthMode,
SMTPHost: req.SmtpHost,
SMTPPort: req.SmtpPort,
SMTPUsername: req.SmtpUsername,
SMTPPassword: req.SmtpPassword,
FromEmail: req.FromEmail,
FromName: req.FromName,
OAuthTenant: req.OAuthTenant,
OAuthClientID: req.OAuthClientID,
OAuthClientSecret: req.OAuthClientSecret,
OAuthRedirectURL: req.OAuthRedirectURL,
OAuthRefreshToken: req.OAuthRefreshToken,
OAuthScope: req.OAuthScope,
})
if err := mailservice.NewV1().ValidateConfig(cfg); err != nil {
utils.OkOrPanic(errors2.NewBusinessError(400, err.Error()))
}
configs := map[string]string{
cst2.ConfigEmailAuthMode: cfg.AuthMode,
cst2.ConfigEmailSMTPHost: cfg.SMTPHost,
cst2.ConfigEmailSMTPPort: fmt.Sprintf("%d", cfg.SMTPPort),
cst2.ConfigEmailSMTPUsername: cfg.SMTPUsername,
cst2.ConfigEmailSMTPPassword: cfg.SMTPPassword,
cst2.ConfigEmailFromEmail: cfg.FromEmail,
cst2.ConfigEmailFromName: cfg.FromName,
cst2.ConfigEmailOAuthTenant: cfg.OAuthTenant,
cst2.ConfigEmailOAuthClientID: cfg.OAuthClientID,
cst2.ConfigEmailOAuthClientSecret: cfg.OAuthClientSecret,
cst2.ConfigEmailOAuthRedirectURL: cfg.OAuthRedirectURL,
cst2.ConfigEmailOAuthRefreshToken: cfg.OAuthRefreshToken,
cst2.ConfigEmailOAuthScope: cfg.OAuthScope,
}
for key, value := range configs {
exists, _ := db.Global.SysConfig.Query().Where(sysconfig.KeyEQ(key)).Exist(ctx)
if exists {
db.Global.SysConfig.Update().Where(sysconfig.KeyEQ(key)).SetValue(value).ExecX(ctx)
} else {
db.Global.SysConfig.Create().SetKey(key).SetValue(value).SetDescription("SMTP邮件配置").ExecX(ctx)
db.Global.SysConfig.Create().
SetKey(key).
SetValue(value).
SetDescription("邮件配置").
ExecX(ctx)
}
}
return utils.SuccessWithMsg("SMTP配置保存成功")
return utils.SuccessWithMsg("邮件配置保存成功")
}
// TestSmtp 发送测试邮件
func (s *ConfigServiceV1) TestSmtp(ctx context.Context, req *v1.SmtpTestReq) *utils.Response[any] {
emailService := email.NewV1()
// 直接同步发送测试邮件,不走队列
msg := mail.NewMsg()
e := msg.FromFormat(emailService.GetConfig().FromName, emailService.GetConfig().FromEmail)
utils.OkOrPanic(e)
e = msg.AddTo(req.ToEmail)
utils.OkOrPanic(e)
msg.Subject("若梦系统 - SMTP测试邮件")
msg.SetBodyString(mail.TypeTextHTML, "<h2>SMTP配置测试成功</h2><p>如果您收到此邮件,说明SMTP配置正确。</p>")
emailService := mailservice.NewV1()
cfg := emailService.GetConfig()
client, e := mail.NewClient(cfg.SMTPHost, mail.WithPort(cfg.SMTPPort),
mail.WithSMTPAuth(mail.SMTPAuthLogin),
mail.WithTLSPortPolicy(mail.TLSMandatory),
mail.WithTimeout(30*time.Second),
mail.WithUsername(cfg.SMTPUsername), mail.WithPassword(cfg.SMTPPassword))
utils.OkOrPanic(e)
defer client.Close()
if err := emailService.ValidateConfig(cfg); err != nil {
utils.OkOrPanic(errors2.NewBusinessError(400, err.Error()))
}
e = client.DialAndSendWithContext(ctx, msg)
utils.OkOrPanic(e)
subject := "邮件配置测试"
body := "<h2>邮件配置测试成功</h2><p>如果您收到此邮件,说明当前邮件配置可以正常发信。</p>"
if err := emailService.SendMessageNow(ctx, cfg, req.ToEmail, subject, body); err != nil {
utils.OkOrPanic(err)
}
return utils.SuccessWithMsg("测试邮件发送成功")
}
func (s *ConfigServiceV1) GetOutlookOAuthAuthorizeURL(ctx context.Context, req *v1.OutlookOAuthAuthorizeURLReq) *utils.Response[*v1.OutlookOAuthAuthorizeURLResp] {
cfg := mailservice.NormalizeConfig(mailservice.EmailConfig{
AuthMode: mailservice.AuthModeOutlookOAuth2,
FromEmail: req.FromEmail,
FromName: "Microsoft Graph OAuth2",
OAuthTenant: req.OAuthTenant,
OAuthClientID: req.OAuthClientID,
OAuthClientSecret: req.OAuthClientSecret,
OAuthScope: req.OAuthScope,
})
resp, err := mailservice.NewV1().BuildOutlookAuthorizeURL(cfg, req.CallbackBaseURL, req.FrontendOrigin)
if err != nil {
utils.OkOrPanic(errors2.NewBusinessError(400, err.Error()))
}
return utils.SuccessWithData(resp)
}
func atoiOrZero(value string) int {
if value == "" {
return 0
}
var result int
_, _ = fmt.Sscanf(value, "%d", &result)
return result
}
+353 -74
View File
@@ -5,45 +5,70 @@ import (
"context"
"embed"
"encoding/gob"
"encoding/json"
"fmt"
"html/template"
db "likedream-goframe/internal/common/db"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
cst2 "likedream-goframe/internal/common/cst"
db "likedream-goframe/internal/common/db"
"likedream-goframe/internal/ent/sysconfig"
"likedream-goframe/internal/framework/logger"
email2 "likedream-goframe/internal/framework/model/email"
"likedream-goframe/internal/framework/queue"
"likedream-goframe/internal/utils"
"time"
"github.com/gogf/gf/v2/os/gctx"
"github.com/hibiken/asynq"
"github.com/samber/oops"
mail "github.com/wneessen/go-mail"
"go.uber.org/zap"
)
//go:embed templates/*.gohtml
var templateFiles embed.FS
type EmailServiceV1 struct {
}
const (
AuthModeSMTP = "smtp"
AuthModeOutlookOAuth2 = "outlook_oauth2"
DefaultOutlookSMTPHost = "smtp-mail.outlook.com"
DefaultOutlookSMTPPort = 587
DefaultOutlookTenant = "consumers"
DefaultOutlookOAuthScope = "offline_access https://graph.microsoft.com/Mail.Send"
DefaultOutlookRedirectURL = "http://localhost:3200/api/v1/mail/oauth/outlook/callback"
DefaultGraphSendMailURL = "https://graph.microsoft.com/v1.0/me/sendMail"
)
type EmailServiceV1 struct{}
type EmailConfig struct {
AuthMode string
SMTPHost string
SMTPPort int
SMTPUsername string
SMTPPassword string
FromEmail string
FromName string
OAuthTenant string
OAuthClientID string
OAuthClientSecret string
OAuthRedirectURL string
OAuthRefreshToken string
OAuthScope string
}
func NewV1() *EmailServiceV1 {
return &EmailServiceV1{}
}
// GetConfig 从数据库读取当前邮件配置
func (s *EmailServiceV1) GetConfig() EmailConfig {
ctx := gctx.New()
@@ -59,17 +84,301 @@ func (s *EmailServiceV1) GetConfig() EmailConfig {
port, _ := strconv.Atoi(getVal(cst2.ConfigEmailSMTPPort))
return EmailConfig{
cfg := EmailConfig{
AuthMode: getVal(cst2.ConfigEmailAuthMode),
SMTPHost: getVal(cst2.ConfigEmailSMTPHost),
SMTPPort: port,
SMTPUsername: getVal(cst2.ConfigEmailSMTPUsername),
SMTPPassword: getVal(cst2.ConfigEmailSMTPPassword),
FromEmail: getVal(cst2.ConfigEmailFromEmail),
FromName: getVal(cst2.ConfigEmailFromName),
OAuthTenant: getVal(cst2.ConfigEmailOAuthTenant),
OAuthClientID: getVal(cst2.ConfigEmailOAuthClientID),
OAuthClientSecret: getVal(cst2.ConfigEmailOAuthClientSecret),
OAuthRedirectURL: getVal(cst2.ConfigEmailOAuthRedirectURL),
OAuthRefreshToken: getVal(cst2.ConfigEmailOAuthRefreshToken),
OAuthScope: getVal(cst2.ConfigEmailOAuthScope),
}
return NormalizeConfig(cfg)
}
func NormalizeConfig(cfg EmailConfig) EmailConfig {
if cfg.AuthMode == "" {
cfg.AuthMode = AuthModeSMTP
}
if cfg.FromEmail != "" && cfg.SMTPUsername == "" {
cfg.SMTPUsername = cfg.FromEmail
}
if cfg.AuthMode == AuthModeOutlookOAuth2 {
cfg.OAuthTenant = DefaultOutlookTenant
cfg.OAuthScope = DefaultOutlookOAuthScope
cfg.OAuthRedirectURL = DefaultOutlookRedirectURL
} else if cfg.SMTPPort == 0 {
cfg.SMTPPort = 587
}
return cfg
}
func (s *EmailServiceV1) ValidateConfig(cfg EmailConfig) error {
cfg = NormalizeConfig(cfg)
if cfg.FromEmail == "" {
return fmt.Errorf("fromEmail is required")
}
if cfg.FromName == "" {
return fmt.Errorf("fromName is required")
}
switch cfg.AuthMode {
case AuthModeSMTP:
if cfg.SMTPHost == "" {
return fmt.Errorf("smtpHost is required")
}
if cfg.SMTPPort <= 0 {
return fmt.Errorf("smtpPort is required")
}
if cfg.SMTPUsername == "" {
return fmt.Errorf("smtpUsername is required")
}
if cfg.SMTPPassword == "" {
return fmt.Errorf("smtpPassword is required")
}
case AuthModeOutlookOAuth2:
if cfg.OAuthClientID == "" {
return fmt.Errorf("oauthClientId is required")
}
if cfg.OAuthClientSecret == "" {
return fmt.Errorf("oauthClientSecret is required")
}
if cfg.OAuthRefreshToken == "" {
return fmt.Errorf("oauthRefreshToken is required")
}
default:
return fmt.Errorf("unsupported authMode: %s", cfg.AuthMode)
}
return nil
}
func (s *EmailServiceV1) AccessToken(ctx context.Context, cfg EmailConfig) (string, error) {
cfg = NormalizeConfig(cfg)
if cfg.AuthMode != AuthModeOutlookOAuth2 {
return "", fmt.Errorf("access token is only available in oauth2 mode")
}
form := url.Values{
"client_id": {cfg.OAuthClientID},
"client_secret": {cfg.OAuthClientSecret},
"grant_type": {"refresh_token"},
"refresh_token": {cfg.OAuthRefreshToken},
"scope": {cfg.OAuthScope},
}
if cfg.OAuthRedirectURL != "" {
form.Set("redirect_uri", cfg.OAuthRedirectURL)
}
tokenURL := fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", cfg.OAuthTenant)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
var tokenResp struct {
AccessToken string `json:"access_token"`
Scope string `json:"scope"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
if err := json.Unmarshal(bodyBytes, &tokenResp); err != nil {
return "", fmt.Errorf("decode oauth token response failed: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if tokenResp.ErrorDescription != "" {
return "", fmt.Errorf("oauth token refresh failed: %s (%s)", tokenResp.ErrorDescription, tokenResp.Error)
}
return "", fmt.Errorf("oauth token refresh failed with status %d", resp.StatusCode)
}
if tokenResp.AccessToken == "" {
return "", fmt.Errorf("empty access token returned by oauth2 token endpoint")
}
return strings.TrimSpace(tokenResp.AccessToken), nil
}
func (s *EmailServiceV1) newClient(ctx context.Context, cfg EmailConfig) (*mail.Client, error) {
cfg = NormalizeConfig(cfg)
if err := s.ValidateConfig(cfg); err != nil {
return nil, err
}
if cfg.AuthMode != AuthModeSMTP {
return nil, fmt.Errorf("smtp client is only available in smtp mode")
}
options := []mail.Option{
mail.WithPort(cfg.SMTPPort),
mail.WithTLSPortPolicy(mail.TLSMandatory),
mail.WithTimeout(30 * time.Second),
mail.WithUsername(cfg.SMTPUsername),
}
options = append(options,
mail.WithSMTPAuth(mail.SMTPAuthLogin),
mail.WithPassword(cfg.SMTPPassword),
)
return mail.NewClient(cfg.SMTPHost, options...)
}
func (s *EmailServiceV1) newMessage(fromName, fromEmail, to, subject, body string) (*mail.Msg, error) {
msg := mail.NewMsg()
if err := msg.FromFormat(fromName, fromEmail); err != nil {
return nil, err
}
if err := msg.AddTo(to); err != nil {
return nil, err
}
msg.Subject(subject)
msg.SetBodyString(mail.TypeTextHTML, body)
return msg, nil
}
func (s *EmailServiceV1) SendMessageNow(ctx context.Context, cfg EmailConfig, to, subject, body string) error {
cfg = NormalizeConfig(cfg)
if err := s.ValidateConfig(cfg); err != nil {
return err
}
if cfg.AuthMode == AuthModeOutlookOAuth2 {
return s.sendGraphMessageNow(ctx, cfg, to, subject, body)
}
msg, err := s.newMessage(cfg.FromName, cfg.FromEmail, to, subject, body)
if err != nil {
return err
}
client, err := s.newClient(ctx, cfg)
if err != nil {
return err
}
defer client.Close()
return client.DialAndSendWithContext(ctx, msg)
}
func (s *EmailServiceV1) sendGraphMessageNow(ctx context.Context, cfg EmailConfig, to, subject, body string) error {
accessToken, err := s.AccessToken(ctx, cfg)
if err != nil {
return err
}
payload := map[string]any{
"message": map[string]any{
"subject": subject,
"body": map[string]any{
"contentType": "HTML",
"content": body,
},
"toRecipients": []map[string]any{
{
"emailAddress": map[string]any{
"address": to,
},
},
},
},
"saveToSentItems": true,
}
bodyBytes, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, DefaultGraphSendMailURL, bytes.NewReader(bodyBytes))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
var graphErr struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&graphErr)
if graphErr.Error.Message != "" {
return fmt.Errorf("graph sendMail failed: %s (%s)", graphErr.Error.Message, graphErr.Error.Code)
}
return fmt.Errorf("graph sendMail failed with status %d", resp.StatusCode)
}
return nil
}
func ConfigFromPayload(payload email2.SendPayload) EmailConfig {
return NormalizeConfig(EmailConfig{
AuthMode: payload.AuthMode,
SMTPHost: payload.SMTPHost,
SMTPPort: payload.SMTPPort,
SMTPUsername: payload.SMTPUsername,
SMTPPassword: payload.SMTPPassword,
FromEmail: payload.FromEmail,
FromName: payload.FromName,
OAuthTenant: payload.OAuthTenant,
OAuthClientID: payload.OAuthClientID,
OAuthClientSecret: payload.OAuthClientSecret,
OAuthRedirectURL: payload.OAuthRedirectURL,
OAuthRefreshToken: payload.OAuthRefreshToken,
OAuthScope: payload.OAuthScope,
})
}
func PayloadFromConfig(cfg EmailConfig, to, subject, body string) *email2.SendPayload {
cfg = NormalizeConfig(cfg)
return &email2.SendPayload{
To: to,
Subject: subject,
Body: body,
AuthMode: cfg.AuthMode,
FromName: cfg.FromName,
FromEmail: cfg.FromEmail,
SMTPHost: cfg.SMTPHost,
SMTPPort: cfg.SMTPPort,
SMTPUsername: cfg.SMTPUsername,
SMTPPassword: cfg.SMTPPassword,
OAuthTenant: cfg.OAuthTenant,
OAuthClientID: cfg.OAuthClientID,
OAuthClientSecret: cfg.OAuthClientSecret,
OAuthRedirectURL: cfg.OAuthRedirectURL,
OAuthRefreshToken: cfg.OAuthRefreshToken,
OAuthScope: cfg.OAuthScope,
}
}
// templatePrefix 根据当前品牌返回模板文件前缀
func templatePrefix() string {
if IsNewBrand() {
return "new_"
@@ -77,45 +386,39 @@ func templatePrefix() string {
return ""
}
// brandAppName 获取当前品牌的应用名称
func brandAppName() string {
return GetBrandConfig().AppName
}
// brandCopyright 获取当前品牌的版权信息
func brandCopyright() string {
return GetBrandConfig().AppCopyright
}
// SendSignupEmail 发送注册成功邮件
func (s *EmailServiceV1) SendSignupEmail(ctx context.Context, toEmail, username string, token string) *utils.Response[any] {
cfg := s.GetConfig()
if cfg.SMTPUsername == "" || cfg.SMTPPassword == "" {
logger.Logger.Error("邮件服务未配置")
if err := s.ValidateConfig(cfg); err != nil {
logger.Logger.Error("mail config is invalid", zap.Error(err))
return utils.FailBusiness("邮件服务未配置")
}
appName := brandAppName()
subject := fmt.Sprintf("欢迎注册%s", appName)
subject := fmt.Sprintf("欢迎注册%s", brandAppName())
body, err := s.renderSignupEmailTemplate(username, token)
if err != nil {
logger.Logger.Error("邮件模板渲染失败")
logger.Logger.Error("render signup email failed", zap.Error(err))
return utils.FailBusiness(fmt.Sprintf("邮件模板渲染失败: %v", err))
}
err = s.sendEmail(ctx, toEmail, subject, body)
if err != nil {
logger.Logger.Error("邮件发送失败")
if err = s.sendEmail(ctx, toEmail, subject, body); err != nil {
logger.Logger.Error("send signup email failed", zap.Error(err))
return utils.FailBusiness(fmt.Sprintf("邮件发送失败: %v", err))
}
return utils.SuccessWithMsg("注册邮件发送成功")
}
// SendTaskNotificationEmail 发送任务状态通知邮件
func (s *EmailServiceV1) SendTaskNotificationEmail(ctx context.Context, toEmail, taskName string, status email2.TaskStatus, details map[string]string) *utils.Response[any] {
cfg := s.GetConfig()
if cfg.SMTPUsername == "" || cfg.SMTPPassword == "" {
if err := s.ValidateConfig(cfg); err != nil {
return utils.FailBusiness("邮件服务未配置")
}
@@ -125,91 +428,79 @@ func (s *EmailServiceV1) SendTaskNotificationEmail(ctx context.Context, toEmail,
return utils.FailBusiness(fmt.Sprintf("邮件模板渲染失败: %v", err))
}
err = s.sendEmail(ctx, toEmail, subject, body)
if err != nil {
if err = s.sendEmail(ctx, toEmail, subject, body); err != nil {
return utils.FailBusiness(fmt.Sprintf("邮件发送失败: %v", err))
}
return utils.SuccessWithMsg("通知邮件发送成功")
}
// SendResetPasswordEmail 发送重置密码邮件
func (s *EmailServiceV1) SendResetPasswordEmail(ctx context.Context, toEmail, username string, token string) *utils.Response[any] {
cfg := s.GetConfig()
if cfg.SMTPUsername == "" || cfg.SMTPPassword == "" {
logger.Logger.Error("邮件服务未配置")
if err := s.ValidateConfig(cfg); err != nil {
logger.Logger.Error("mail config is invalid", zap.Error(err))
return utils.FailBusiness("邮件服务未配置")
}
appName := brandAppName()
subject := fmt.Sprintf("重置%s账户密码", appName)
subject := fmt.Sprintf("重置%s账户密码", brandAppName())
body, err := s.renderResetPasswordEmailTemplate(username, token)
if err != nil {
logger.Logger.Error("邮件模板渲染失败")
logger.Logger.Error("render reset password email failed", zap.Error(err))
return utils.FailBusiness(fmt.Sprintf("邮件模板渲染失败: %v", err))
}
err = s.sendEmail(ctx, toEmail, subject, body)
if err != nil {
logger.Logger.Error("邮件发送失败")
if err = s.sendEmail(ctx, toEmail, subject, body); err != nil {
logger.Logger.Error("send reset password email failed", zap.Error(err))
return utils.FailBusiness(fmt.Sprintf("邮件发送失败: %v", err))
}
return utils.SuccessWithMsg("重置密码邮件发送成功")
}
// SendBalanceMigrateEmail 发送积分转移邮件
func (s *EmailServiceV1) SendBalanceMigrateEmail(ctx context.Context, toEmail, username string, balance int, token string) *utils.Response[any] {
cfg := s.GetConfig()
if cfg.SMTPUsername == "" || cfg.SMTPPassword == "" {
logger.Logger.Error("邮件服务未配置")
if err := s.ValidateConfig(cfg); err != nil {
logger.Logger.Error("mail config is invalid", zap.Error(err))
return utils.FailBusiness("邮件服务未配置")
}
appName := brandAppName()
subject := fmt.Sprintf("%s积分转移通知", appName)
subject := fmt.Sprintf("%s积分转移通知", brandAppName())
body, err := s.renderBalanceMigrateEmailTemplate(username, balance, token)
if err != nil {
logger.Logger.Error("邮件模板渲染失败")
logger.Logger.Error("render balance migrate email failed", zap.Error(err))
return utils.FailBusiness(fmt.Sprintf("邮件模板渲染失败: %v", err))
}
err = s.sendEmail(ctx, toEmail, subject, body)
if err != nil {
logger.Logger.Error("邮件发送失败")
if err = s.sendEmail(ctx, toEmail, subject, body); err != nil {
logger.Logger.Error("send balance migrate email failed", zap.Error(err))
return utils.FailBusiness(fmt.Sprintf("邮件发送失败: %v", err))
}
return utils.SuccessWithMsg("积分转移邮件发送成功")
}
// sendEmail 发送邮件的底层方法
func (s *EmailServiceV1) sendEmail(ctx context.Context, to, subject, body string) error {
cfg := s.GetConfig()
if err := s.ValidateConfig(cfg); err != nil {
return err
}
payload := bytes.NewBuffer(nil)
if err := gob.NewEncoder(payload).Encode(PayloadFromConfig(cfg, to, subject, body)); err != nil {
return err
}
e := gob.NewEncoder(payload).Encode(&email2.SendPayload{
To: to,
Subject: subject,
Body: body,
FromName: cfg.FromName,
FromEmail: cfg.FromEmail,
SMTPHost: cfg.SMTPHost,
SMTPPort: cfg.SMTPPort,
SMTPUsername: cfg.SMTPUsername,
SMTPPassword: cfg.SMTPPassword,
})
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
task := queue.AddTask(ctx, asynq.NewTask(cst2.TaskEmail, payload.Bytes()), asynq.Queue(cst2.TaskQueueEmail),
task := queue.AddTask(
ctx,
asynq.NewTask(cst2.TaskEmail, payload.Bytes()),
asynq.Queue(cst2.TaskQueueEmail),
asynq.MaxRetry(1),
asynq.Timeout(5*time.Minute),
)
logger.Logger.Info("添加任务: ", zap.String("task_id", task.ID), zap.String("邮箱", to))
logger.Logger.Info("add email task", zap.String("task_id", task.ID), zap.String("to", to))
return nil
}
// parseTemplates 根据当前品牌解析 layout + content 模板
func parseTemplates(contentTemplate string) (*template.Template, error) {
prefix := templatePrefix()
layoutFile := fmt.Sprintf("templates/%slayout.gohtml", prefix)
@@ -217,7 +508,6 @@ func parseTemplates(contentTemplate string) (*template.Template, error) {
return template.ParseFS(templateFiles, layoutFile, contentFile)
}
// renderSignupEmailTemplate 渲染注册邮件模板
func (s *EmailServiceV1) renderSignupEmailTemplate(username string, token string) (string, error) {
tmpl, err := parseTemplates("signup_confirm.gohtml")
if err != nil {
@@ -229,15 +519,12 @@ func (s *EmailServiceV1) renderSignupEmailTemplate(username string, token string
prefix := templatePrefix()
var buf bytes.Buffer
err = tmpl.ExecuteTemplate(&buf, prefix+"signup_confirm.gohtml", data)
if err != nil {
if err = tmpl.ExecuteTemplate(&buf, prefix+"signup_confirm.gohtml", data); err != nil {
return "", fmt.Errorf("渲染注册邮件模板失败: %v", err)
}
return buf.String(), nil
}
// renderTaskNotificationTemplate 渲染任务通知邮件模板
func (s *EmailServiceV1) renderTaskNotificationTemplate(taskName string, status email2.TaskStatus, details map[string]string) (string, error) {
tmpl, err := parseTemplates("task_notification.gohtml")
if err != nil {
@@ -249,15 +536,12 @@ func (s *EmailServiceV1) renderTaskNotificationTemplate(taskName string, status
prefix := templatePrefix()
var buf bytes.Buffer
err = tmpl.ExecuteTemplate(&buf, prefix+"task_notification.gohtml", data)
if err != nil {
if err = tmpl.ExecuteTemplate(&buf, prefix+"task_notification.gohtml", data); err != nil {
return "", fmt.Errorf("渲染任务通知邮件模板失败: %v", err)
}
return buf.String(), nil
}
// renderResetPasswordEmailTemplate 渲染重置密码邮件模板
func (s *EmailServiceV1) renderResetPasswordEmailTemplate(username string, token string) (string, error) {
tmpl, err := parseTemplates("reset_password.gohtml")
if err != nil {
@@ -269,15 +553,12 @@ func (s *EmailServiceV1) renderResetPasswordEmailTemplate(username string, token
prefix := templatePrefix()
var buf bytes.Buffer
err = tmpl.ExecuteTemplate(&buf, prefix+"reset_password.gohtml", data)
if err != nil {
if err = tmpl.ExecuteTemplate(&buf, prefix+"reset_password.gohtml", data); err != nil {
return "", fmt.Errorf("渲染重置密码邮件模板失败: %v", err)
}
return buf.String(), nil
}
// renderBalanceMigrateEmailTemplate 渲染积分转移邮件模板
func (s *EmailServiceV1) renderBalanceMigrateEmailTemplate(username string, balance int, token string) (string, error) {
tmpl, err := parseTemplates("balance_migrate.gohtml")
if err != nil {
@@ -289,10 +570,8 @@ func (s *EmailServiceV1) renderBalanceMigrateEmailTemplate(username string, bala
prefix := templatePrefix()
var buf bytes.Buffer
err = tmpl.ExecuteTemplate(&buf, prefix+"balance_migrate.gohtml", data)
if err != nil {
if err = tmpl.ExecuteTemplate(&buf, prefix+"balance_migrate.gohtml", data); err != nil {
return "", fmt.Errorf("渲染积分转移邮件模板失败: %v", err)
}
return buf.String(), nil
}