feat: implement email notification system for task status updates and user registration

This commit is contained in:
RecordingTheSmile
2025-08-21 17:40:52 +08:00
parent a42a99b316
commit de33bdce72
15 changed files with 1080 additions and 4 deletions
+3 -1
View File
@@ -2,4 +2,6 @@
.vscode
.cursor
node_modules
.claude
.claude
*.exe
.serena
+12 -1
View File
@@ -19,4 +19,15 @@ mode = "dev"
baseUrl = ""
[redis]
url = "redis://127.0.0.1:6379"
url = "redis://127.0.0.1:6379"
[email]
# SMTP服务器配置
smtpHost = "smtp.qq.com"
smtpPort = 587
# 发件人邮箱和密码(需要使用授权码,不是登录密码)
smtpUsername = ""
smtpPassword = ""
# 发件人邮箱和显示名称
fromEmail = ""
fromName = "今日校园系统"
+1
View File
@@ -84,6 +84,7 @@ require (
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/wneessen/go-mail v0.6.2 // indirect
github.com/zclconf/go-cty v1.14.4 // indirect
github.com/zclconf/go-cty-yaml v1.1.0 // indirect
go.opentelemetry.io/otel v1.32.0 // indirect
+7
View File
@@ -197,6 +197,8 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/wneessen/go-mail v0.6.2 h1:c6V7c8D2mz868z9WJ+8zDKtUyLfZ1++uAZmo2GRFji8=
github.com/wneessen/go-mail v0.6.2/go.mod h1:L/PYjPK3/2ZlNb2/FjEBIn9n1rUWjW+Toy531oVmeb4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8=
github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
@@ -226,6 +228,7 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -253,6 +256,7 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -268,6 +272,7 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
@@ -279,6 +284,7 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -288,6 +294,7 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
+14
View File
@@ -22,6 +22,20 @@ const (
ConfigRedisUrl = "redis.url"
ConfigTaskConcurrency = "task.concurrency"
// Email配置常量
ConfigEmailSMTPHost = "email.smtpHost"
ConfigEmailSMTPPort = "email.smtpPort"
ConfigEmailSMTPUsername = "email.smtpUsername"
ConfigEmailSMTPPassword = "email.smtpPassword"
ConfigEmailFromEmail = "email.fromEmail"
ConfigEmailFromName = "email.fromName"
)
const (
// 应用程序常量
AppName = "今日校园系统"
AppCopyright = "© 2024 今日校园系统. 保留所有权利."
)
var (
+59 -2
View File
@@ -14,7 +14,9 @@ import (
"likedream-goframe/internal/ent/cpuser"
"likedream-goframe/internal/ent/sysuser"
"likedream-goframe/internal/framework/errors"
"likedream-goframe/internal/service/email"
"likedream-goframe/internal/utils"
"time"
"github.com/bytedance/sonic"
"github.com/google/uuid"
@@ -54,7 +56,7 @@ func (c *CpdailyTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task)
var errorMessage string
if e1, ok := utils.Of[*errors.BusinessError](e); ok {
if e1, ok := utils.Of[*errors.BusinessError](err); ok {
errorMessage = e1.Message()
} else {
errorMessage = "未知的错误导致任务执行失败"
@@ -70,6 +72,15 @@ func (c *CpdailyTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task)
Exec(ctx)
utils.LogError(ctx, "创建任务日志失败", oops.With("task_id", taskPayload.CpTaskID).Wrap(e))
// 发送任务失败通知邮件
c.sendTaskNotificationEmail(ctx, targetTask, "failed", map[string]string{
"任务名称": targetTask.Title,
"执行时间": time.Now().Format("2006-01-02 15:04:05"),
"执行结果": "失败",
"错误信息": errorMessage,
})
panic(err)
})
@@ -145,7 +156,53 @@ func (c *CpdailyTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task)
SetCpUserID(targetTask.Edges.CpUser.ID).
SetSystemUserID(targetTask.Edges.CpUser.Edges.Sysuser.ID).
SetIsSuccess(true).
SetRealName(realTitle)
SetRealName(realTitle).
ExecX(ctx)
// 发送任务成功通知邮件
c.sendTaskNotificationEmail(ctx, targetTask, email.TaskStatusSuccess, map[string]string{
"任务名称": realTitle,
"执行时间": time.Now().Format("2006-01-02 15:04:05"),
"执行结果": "成功",
})
return
}
// sendTaskNotificationEmail 发送任务通知邮件
func (c *CpdailyTaskHandler) sendTaskNotificationEmail(ctx context.Context, task *ent.CpTask, status email.TaskStatus, details map[string]string) {
// 检查是否需要发送邮件通知
if task.NotificationType == cptask.NotificationTypeNone {
return
}
var emailAddr string
var taskName string
switch status {
case email.TaskStatusSuccess:
taskName = "任务[成功]通知"
case email.TaskStatusFailed:
taskName = "任务[失败]通知"
default:
taskName = "任务状态通知"
}
// 根据通知类型确定邮件地址
switch task.NotificationType {
case cptask.NotificationTypeCpUserEmail:
if task.Edges.CpUser != nil && task.Edges.CpUser.Email != "" {
emailAddr = task.Edges.CpUser.Email
}
case cptask.NotificationTypeSystemUserEmail:
if task.Edges.CpUser != nil && task.Edges.CpUser.Edges.Sysuser != nil && task.Edges.CpUser.Edges.Sysuser.Email != "" {
emailAddr = task.Edges.CpUser.Edges.Sysuser.Email
}
}
if emailAddr == "" {
return // 没有邮箱地址,不发送邮件
}
email.NewV1().SendTaskNotificationEmail(ctx, emailAddr, taskName, status, details)
}
+166
View File
@@ -0,0 +1,166 @@
# 邮件模板系统
## 概述
本邮件服务使用Go template系统实现,支持HTML邮件模板渲染,提供灵活的邮件内容定制功能。
## 目录结构
```
internal/service/email/
├── email_service.go # 邮件服务实现
├── email_service_test.go # 单元测试
└── templates/ # 邮件模板目录
├── signup.html # 注册邮件模板
└── task_notification.html # 任务通知邮件模板
```
## 功能特性
### 1. 模板系统
- 使用Go `html/template` 包
- 支持模板嵌入(embed.FS)
- 模板文件与代码分离,易于维护
- 支持条件渲染和循环
### 2. 邮件类型
- **注册邮件**: 用户注册成功后发送欢迎邮件
- **任务通知邮件**: 任务执行状态通知
### 3. 模板数据结构
#### 注册邮件数据
```go
type SignupEmailData struct {
Username string // 用户名
}
```
#### 任务通知邮件数据
```go
type TaskNotificationEmailData struct {
TaskName string // 任务名称
Status string // 原始状态
StatusText string // 显示状态文本
StatusColor string // 状态颜色
Details map[string]interface{} // 详细信息
}
```
## 使用方法
### 1. 配置邮件服务
在 `config.toml` 中配置SMTP信息:
```toml
[email]
smtpHost = "smtp.qq.com"
smtpPort = 587
smtpUsername = "your-email@qq.com"
smtpPassword = "your-app-password"
fromEmail = "your-email@qq.com"
fromName = "今日校园系统"
```
### 2. 发送注册邮件
```go
import "likedream-goframe/internal/service"
// 通过全局服务实例发送
result := service.Global.Email.SendSignupEmail(ctx, "user@example.com", "用户名")
// 或者创建新实例
emailService := email.NewV1()
result := emailService.SendSignupEmail(ctx, "user@example.com", "用户名")
```
### 3. 发送任务通知邮件
```go
details := map[string]interface{}{
"任务ID": "123",
"执行时间": "2024-01-01 12:00:00",
"错误信息": "网络超时",
}
result := service.Global.Email.SendTaskNotificationEmail(
ctx,
"user@example.com",
"今日校园签到",
"failed",
details,
)
```
## 模板语法
### 基本变量替换
```html
<h2>亲爱的 {{.Username}},您好!</h2>
```
### 条件渲染
```html
{{if .Details}}
<h3>详细信息:</h3>
{{end}}
```
### 循环渲染
```html
{{range $key, $value := .Details}}
<li><strong>{{$key}}:</strong> {{$value}}</li>
{{end}}
```
### 变量输出
```html
<div style="color: {{.StatusColor}};">
状态: {{.StatusText}}
</div>
```
## 自定义模板
### 1. 添加新模板
1. 在 `templates/` 目录下创建新的HTML文件
2. 在 `email_service.go` 中添加对应的数据结构
3. 添加渲染方法
4. 添加发送方法
### 2. 修改现有模板
直接编辑 `templates/` 目录下的HTML文件即可,无需重新编译。
## 状态映射
任务通知邮件支持以下状态:
- `success`/`completed` → 成功 (绿色 #4CAF50)
- `failed`/`error` → 失败 (红色 #f44336)
- `running`/`processing` → 进行中 (橙色 #ff9800)
- `pending` → 待处理 (蓝色 #2196F3)
## 测试
运行单元测试:
```bash
go test ./internal/service/email -v
```
测试包括:
- 模板渲染功能测试
- 邮件数据验证
- 模板语法检查
## 注意事项
1. **邮件配置**: 确保SMTP配置正确,QQ邮箱需要使用授权码而非登录密码
2. **模板安全**: 使用`html/template`包自动转义HTML,防止XSS攻击
3. **异步发送**: 邮件发送采用异步方式,不会阻塞主要业务流程
4. **错误处理**: 邮件发送失败不会影响主要功能的执行
5. **模板缓存**: 模板只解析一次,使用`sync.Once`确保线程安全
## 扩展建议
1. 添加更多邮件模板(密码重置、账户验证等)
2. 支持邮件模板主题配置
3. 添加邮件发送队列和重试机制
4. 支持附件发送
5. 添加邮件发送统计和监控
+175
View File
@@ -0,0 +1,175 @@
package email
import (
"bytes"
"context"
"embed"
"fmt"
"html/template"
"likedream-goframe/internal/cst"
"likedream-goframe/internal/utils"
"sync"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
"github.com/samber/oops"
"github.com/wneessen/go-mail"
)
//go:embed templates/*.gohtml
var templateFiles embed.FS
type EmailServiceV1 struct {
config EmailConfig
templates *template.Template
tmplOnce sync.Once
}
type EmailConfig struct {
SMTPHost string
SMTPPort int
SMTPUsername string
SMTPPassword string
FromEmail string
FromName string
}
func NewV1() *EmailServiceV1 {
ctx := gctx.New()
cfg := g.Cfg()
config := EmailConfig{
SMTPHost: cfg.MustGetWithEnv(ctx, cst.ConfigEmailSMTPHost).String(),
SMTPPort: cfg.MustGetWithEnv(ctx, cst.ConfigEmailSMTPPort).Int(),
SMTPUsername: cfg.MustGetWithEnv(ctx, cst.ConfigEmailSMTPUsername).String(),
SMTPPassword: cfg.MustGetWithEnv(ctx, cst.ConfigEmailSMTPPassword).String(),
FromEmail: cfg.MustGetWithEnv(ctx, cst.ConfigEmailFromEmail).String(),
FromName: cfg.MustGetWithEnv(ctx, cst.ConfigEmailFromName).String(),
}
return &EmailServiceV1{
config: config,
}
}
// SendSignupEmail 发送注册成功邮件
func (s *EmailServiceV1) SendSignupEmail(ctx context.Context, toEmail, username string) *utils.Response[any] {
if s.config.SMTPUsername == "" || s.config.SMTPPassword == "" {
return utils.FailBusiness("邮件服务未配置")
}
subject := fmt.Sprintf("欢迎注册%s", cst.AppName)
body, err := s.renderSignupEmailTemplate(username)
if err != nil {
return utils.FailBusiness(fmt.Sprintf("邮件模板渲染失败: %v", err))
}
err = s.sendEmail(ctx, toEmail, subject, body)
if err != nil {
return utils.FailBusiness(fmt.Sprintf("邮件发送失败: %v", err))
}
return utils.SuccessWithMsg("注册邮件发送成功")
}
// SendTaskNotificationEmail 发送任务状态通知邮件
func (s *EmailServiceV1) SendTaskNotificationEmail(ctx context.Context, toEmail, taskName string, status TaskStatus, details map[string]string) *utils.Response[any] {
if s.config.SMTPUsername == "" || s.config.SMTPPassword == "" {
return utils.FailBusiness("邮件服务未配置")
}
subject := fmt.Sprintf("任务状态通知: %s", taskName)
body, err := s.renderTaskNotificationTemplate(taskName, status, details)
if err != nil {
return utils.FailBusiness(fmt.Sprintf("邮件模板渲染失败: %v", err))
}
err = s.sendEmail(ctx, toEmail, subject, body)
if err != nil {
return utils.FailBusiness(fmt.Sprintf("邮件发送失败: %v", err))
}
return utils.SuccessWithMsg("通知邮件发送成功")
}
// sendEmail 发送邮件的底层方法
func (s *EmailServiceV1) sendEmail(ctx context.Context, to, subject, body string) error {
message := mail.NewMsg()
e := message.FromFormat(s.config.FromName, s.config.FromEmail)
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
e = message.AddTo(to)
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
message.Subject(subject)
message.SetBodyString(mail.TypeTextHTML, body)
client, e := mail.NewClient(s.config.SMTPHost, mail.WithPort(s.config.SMTPPort), mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover),
mail.WithUsername(s.config.SMTPUsername), mail.WithPassword(s.config.SMTPPassword))
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
e = client.DialAndSendWithContext(ctx, message)
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
return nil
}
// initTemplates 初始化邮件模板
func (s *EmailServiceV1) initTemplates() error {
var err error
s.templates, err = template.ParseFS(templateFiles, "templates/*.gohtml")
if err != nil {
return fmt.Errorf("解析邮件模板失败: %v", err)
}
return nil
}
// getTemplates 获取模板实例,使用sync.Once确保只初始化一次
func (s *EmailServiceV1) getTemplates() (*template.Template, error) {
var err error
s.tmplOnce.Do(func() {
err = s.initTemplates()
})
if err != nil {
return nil, err
}
return s.templates, nil
}
// renderSignupEmailTemplate 渲染注册邮件模板
func (s *EmailServiceV1) renderSignupEmailTemplate(username string) (string, error) {
tmpl, err := s.getTemplates()
if err != nil {
return "", err
}
data := NewSignupTemplateData(username, cst.AppName, cst.AppCopyright)
var buf bytes.Buffer
err = tmpl.ExecuteTemplate(&buf, "signup.gohtml", data)
if err != nil {
return "", fmt.Errorf("渲染注册邮件模板失败: %v", err)
}
return buf.String(), nil
}
// renderTaskNotificationTemplate 渲染任务通知邮件模板
func (s *EmailServiceV1) renderTaskNotificationTemplate(taskName string, status TaskStatus, details map[string]string) (string, error) {
tmpl, err := s.getTemplates()
if err != nil {
return "", err
}
data := NewTaskNotificationTemplateData(taskName, status, details, cst.AppName, cst.AppCopyright)
var buf bytes.Buffer
err = tmpl.ExecuteTemplate(&buf, "task_notification.gohtml", data)
if err != nil {
return "", fmt.Errorf("渲染任务通知邮件模板失败: %v", err)
}
return buf.String(), nil
}
@@ -0,0 +1,99 @@
package email
import (
"context"
"fmt"
"likedream-goframe/internal/cst"
"testing"
)
func TestEmailTemplates(t *testing.T) {
service := &EmailServiceV1{}
// 测试注册邮件模板
t.Run("signup_email_template", func(t *testing.T) {
body, err := service.renderSignupEmailTemplate("测试用户")
if err != nil {
t.Fatalf("渲染注册邮件模板失败: %v", err)
}
if body == "" {
t.Fatal("注册邮件模板内容为空")
}
// 检查是否包含用户名
if !containsString(body, "测试用户") {
t.Fatal("注册邮件模板中没有包含用户名")
}
// 检查是否包含网站名称常量
if !containsString(body, cst.AppName) {
t.Fatalf("注册邮件模板中没有包含网站名称: %s", cst.AppName)
}
// 检查是否包含版权信息常量
if !containsString(body, cst.AppCopyright) {
t.Fatalf("注册邮件模板中没有包含版权信息: %s", cst.AppCopyright)
}
fmt.Printf("注册邮件模板渲染成功,长度: %d\n", len(body))
})
// 测试任务通知邮件模板
t.Run("task_notification_template", func(t *testing.T) {
details := map[string]string{
"任务ID": "123",
"执行时间": "2024-01-01 12:00:00",
}
body, err := service.renderTaskNotificationTemplate("测试任务", "success", details)
if err != nil {
t.Fatalf("渲染任务通知邮件模板失败: %v", err)
}
if body == "" {
t.Fatal("任务通知邮件模板内容为空")
}
// 检查是否包含任务名称
if !containsString(body, "测试任务") {
t.Fatal("任务通知邮件模板中没有包含任务名称")
}
// 检查是否包含版权信息常量
if !containsString(body, cst.AppCopyright) {
t.Fatalf("任务通知邮件模板中没有包含版权信息: %s", cst.AppCopyright)
}
fmt.Printf("任务通知邮件模板渲染成功,长度: %d\n", len(body))
})
}
func containsString(s, substr string) bool {
return len(s) >= len(substr) && findSubstring(s, substr) != -1
}
func findSubstring(s, substr string) int {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}
// 这个测试需要真实的SMTP配置,通常在集成测试中运行
func TestEmailSending(t *testing.T) {
t.Skip("跳过真实邮件发送测试,需要SMTP配置")
// 这里可以添加真实的邮件发送测试
// 需要配置真实的SMTP信息
service := NewV1()
ctx := context.Background()
result := service.SendSignupEmail(ctx, "test@example.com", "测试用户")
if !result.IsResponse() {
t.Fatalf("发送注册邮件失败: %+v", result)
}
}
+142
View File
@@ -0,0 +1,142 @@
package email
// BaseTemplateData 基础模板数据
type BaseTemplateData struct {
Title string `json:"title"` // 页面标题
HeaderIcon string `json:"header_icon"` // 页眉图标
HeaderTitle string `json:"header_title"` // 页眉标题
HeaderColor string `json:"header_color"` // 主题颜色
AppName string `json:"app_name"` // 应用名称
AppCopyright string `json:"app_copyright"` // 版权信息
}
// SignupTemplateData 注册邮件模板数据
type SignupTemplateData struct {
BaseTemplateData
Username string `json:"username"` // 用户名
}
// NewSignupTemplateData 创建注册邮件模板数据
func NewSignupTemplateData(username, appName, copyright string) *SignupTemplateData {
return &SignupTemplateData{
BaseTemplateData: BaseTemplateData{
Title: "欢迎注册" + appName,
HeaderIcon: "🎉",
HeaderTitle: "欢迎加入" + appName + "!",
HeaderColor: "#667eea",
AppName: appName,
AppCopyright: copyright,
},
Username: username,
}
}
// TaskStatus 任务状态枚举
type TaskStatus string
const (
TaskStatusSuccess TaskStatus = "success" // 成功
TaskStatusFailed TaskStatus = "failed" // 失败
TaskStatusRunning TaskStatus = "running" // 运行中
TaskStatusPending TaskStatus = "pending" // 等待中
)
// GetStatusColor 获取状态对应的颜色
func (s TaskStatus) GetStatusColor() string {
switch s {
case TaskStatusSuccess:
return "#10b981" // 绿色
case TaskStatusFailed:
return "#ef4444" // 红色
case TaskStatusRunning:
return "#3b82f6" // 蓝色
case TaskStatusPending:
return "#f59e0b" // 橙色
default:
return "#6b7280" // 灰色
}
}
// GetStatusText 获取状态文本
func (s TaskStatus) GetStatusText() string {
switch s {
case TaskStatusSuccess:
return "执行成功"
case TaskStatusFailed:
return "执行失败"
case TaskStatusRunning:
return "执行中"
case TaskStatusPending:
return "等待执行"
default:
return "未知状态"
}
}
// GetStatusIcon 获取状态图标
func (s TaskStatus) GetStatusIcon() string {
switch s {
case TaskStatusSuccess:
return "✅"
case TaskStatusFailed:
return "❌"
case TaskStatusRunning:
return "🔄"
case TaskStatusPending:
return "⏳"
default:
return "📬"
}
}
// TaskNotificationTemplateData 任务通知邮件模板数据
type TaskNotificationTemplateData struct {
BaseTemplateData
TaskName string `json:"task_name"` // 任务名称
Status TaskStatus `json:"status"` // 任务状态
StatusText string `json:"status_text"` // 状态文本
Details map[string]string `json:"details"` // 详细信息
}
// NewTaskNotificationTemplateData 创建任务通知邮件模板数据
func NewTaskNotificationTemplateData(taskName string, status TaskStatus, details map[string]string, appName, copyright string) *TaskNotificationTemplateData {
return &TaskNotificationTemplateData{
BaseTemplateData: BaseTemplateData{
Title: "任务状态通知",
HeaderIcon: status.GetStatusIcon(),
HeaderTitle: "任务状态通知",
HeaderColor: status.GetStatusColor(),
AppName: appName,
AppCopyright: copyright,
},
TaskName: taskName,
Status: status,
StatusText: status.GetStatusText(),
Details: details,
}
}
// TemplateType 模板类型枚举
type TemplateType string
const (
TemplateTypeSignup TemplateType = "signup" // 注册模板
TemplateTypeTaskNotification TemplateType = "task_notification" // 任务通知模板
)
// GetTemplatePath 获取模板文件路径
func (t TemplateType) GetTemplatePath() string {
switch t {
case TemplateTypeSignup:
return "internal/service/email/templates/signup.gohtml"
case TemplateTypeTaskNotification:
return "internal/service/email/templates/task_notification.gohtml"
default:
return ""
}
}
// GetLayoutPath 获取布局模板路径
func GetLayoutPath() string {
return "internal/service/email/templates/layout.gohtml"
}
@@ -0,0 +1,218 @@
{{define "layout"}}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.7;
color: #2d3748;
background: {{if .HeaderColor}}linear-gradient(135deg, {{.HeaderColor}}20 0%, {{.HeaderColor}}10 100%){{else}}linear-gradient(135deg, #667eea20 0%, #764ba210 100%){{end}};
padding: 20px 0;
}
.container {
max-width: 600px;
margin: 0 auto;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.header {
background: {{if .HeaderColor}}{{.HeaderColor}}{{else}}linear-gradient(135deg, #667eea 0%, #764ba2 100%){{end}};
{{if .HeaderColor}}background: linear-gradient(135deg, {{.HeaderColor}} 0%, {{.HeaderColor}}dd 100%);{{end}}
color: white;
padding: 40px 30px;
text-align: center;
position: relative;
}
.header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="20" cy="20" r="2" fill="rgba(255,255,255,0.1)"/><circle cx="80" cy="40" r="3" fill="rgba(255,255,255,0.05)"/><circle cx="40" cy="70" r="1.5" fill="rgba(255,255,255,0.1)"/></svg>');
opacity: 0.3;
}
.header h1 {
font-size: 28px;
font-weight: 700;
margin: 0;
position: relative;
z-index: 1;
}
.header-icon {
font-size: 48px;
margin-bottom: 15px;
display: block;
opacity: 0.9;
}
.content {
padding: 40px 30px;
background: #ffffff;
}
.content h2 {
color: #2d3748;
font-size: 22px;
font-weight: 600;
margin-bottom: 20px;
}
.content p {
margin-bottom: 16px;
font-size: 16px;
line-height: 1.7;
}
.highlight-text {
background: {{if .HeaderColor}}linear-gradient(135deg, {{.HeaderColor}}, {{.HeaderColor}}aa){{else}}linear-gradient(135deg, #667eea, #764ba2){{end}};
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-weight: 600;
}
.card {
background: linear-gradient(135deg, #ffffff 0%, #f8faff 100%);
border: 1px solid #e2e8f0;
padding: 30px;
border-radius: 16px;
margin: 25px 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
position: relative;
}
.card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background: {{if .HeaderColor}}{{.HeaderColor}}{{else}}#667eea{{end}};
border-radius: 2px;
}
.badge {
display: inline-flex;
align-items: center;
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
font-weight: 600;
background: {{if .HeaderColor}}{{.HeaderColor}}15{{else}}#667eea15{{end}};
color: {{if .HeaderColor}}{{.HeaderColor}}{{else}}#667eea{{end}};
border: 1px solid {{if .HeaderColor}}{{.HeaderColor}}30{{else}}#667eea30{{end}};
}
.badge::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
background: {{if .HeaderColor}}{{.HeaderColor}}{{else}}#667eea{{end}};
margin-right: 8px;
}
.info-box {
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
border: 1px solid #0ea5e9;
border-radius: 12px;
padding: 20px;
margin: 25px 0;
color: #0369a1;
font-size: 15px;
position: relative;
}
.info-box::before {
content: '💡';
position: absolute;
top: 20px;
left: 20px;
}
.info-box p {
margin: 0;
padding-left: 35px;
}
.footer {
padding: 30px;
text-align: center;
background: #f8faff;
color: #718096;
font-size: 13px;
border-top: 1px solid #e2e8f0;
}
.footer p {
margin: 5px 0;
line-height: 1.5;
}
@media (max-width: 640px) {
body {
padding: 10px 0;
}
.container {
margin: 0 10px;
border-radius: 12px;
}
.header, .content {
padding: 25px 20px;
}
.header h1 {
font-size: 24px;
}
.header-icon {
font-size: 36px;
margin-bottom: 10px;
}
.card {
padding: 20px;
}
}
</style>
{{block "extra-styles" .}}{{end}}
</head>
<body>
<div class="container">
<div class="header">
{{if .HeaderIcon}}<span class="header-icon">{{.HeaderIcon}}</span>{{end}}
<h1>{{.HeaderTitle}}</h1>
</div>
<div class="content">
{{block "content" .}}{{end}}
</div>
<div class="footer">
<p>{{.AppCopyright}}</p>
<p>这是一封自动发送的邮件,请勿直接回复。</p>
</div>
</div>
</body>
</html>
{{end}}
@@ -0,0 +1,53 @@
{{template "layout" .}}
{{define "extra-styles"}}
<style>
.feature-list {
background: linear-gradient(135deg, #f8faff 0%, #f1f5f9 100%);
border-radius: 12px;
padding: 25px;
margin: 25px 0;
border-left: 4px solid #667eea;
}
.feature-list ul {
list-style: none;
padding: 0;
}
.feature-list li {
padding: 8px 0;
position: relative;
padding-left: 30px;
font-size: 15px;
}
.feature-list li::before {
content: '✓';
position: absolute;
left: 0;
top: 8px;
color: #667eea;
font-weight: bold;
font-size: 16px;
}
</style>
{{end}}
{{define "content"}}
<h2>亲爱的 <span class="highlight-text">{{.Username}}</span>,您好!</h2>
<p>恭喜您成功注册<strong>{{.AppName}}</strong>账户!我们很高兴为您提供智能化的校园服务。</p>
{{/* <div class="feature-list">*/}}
{{/* <p><strong>🚀 您现在可以使用以下功能:</strong></p>*/}}
{{/* <ul>*/}}
{{/* <li>自动化完成校园日常任务</li>*/}}
{{/* <li>任务状态实时监控</li>*/}}
{{/* <li>邮件通知提醒</li>*/}}
{{/* <li>任务执行历史查看</li>*/}}
{{/* </ul>*/}}
{{/* </div>*/}}
<p>如果您在使用过程中遇到任何问题,请随时联系我们的技术支持团队。我们将竭诚为您服务!</p>
<p><strong>祝您使用愉快!</strong> 🌟</p>
{{end}}
@@ -0,0 +1,118 @@
{{template "layout" .}}
{{define "extra-styles"}}
<style>
.task-info h2 {
padding-left: 20px;
}
.status-badge {
margin: 10px 0 20px 20px;
}
.details-section {
padding-left: 20px;
}
.details-section h3 {
color: #4a5568;
font-size: 16px;
font-weight: 600;
margin-bottom: 15px;
display: flex;
align-items: center;
}
.details-section h3::before {
content: '📋';
margin-right: 8px;
}
.details-list {
background: #f8faff;
border-radius: 12px;
padding: 20px;
border-left: 3px solid {{.StatusColor}};
}
.details-list ul {
list-style: none;
padding: 0;
}
.details-list li {
padding: 8px 0;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.details-list li:last-child {
border-bottom: none;
}
.detail-key {
font-weight: 600;
color: #4a5568;
flex: 0 0 120px;
}
.detail-value {
color: #2d3748;
text-align: right;
flex: 1;
}
@media (max-width: 640px) {
.task-info h2 {
font-size: 18px;
padding-left: 15px;
}
.details-section, .status-badge {
margin-left: 15px;
}
.details-list li {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.detail-key {
flex: none;
}
.detail-value {
text-align: left;
}
}
</style>
{{end}}
{{define "content"}}
<div class="card">
<h2>{{.TaskName}}</h2>
<div class="badge">{{.StatusText}}</div>
{{if .Details}}
<div class="details-section">
<h3>详细信息</h3>
<div class="details-list">
<ul>
{{range $key, $value := .Details}}
<li>
<span class="detail-key">{{$key}}</span>
<span class="detail-value">{{$value}}</span>
</li>
{{end}}
</ul>
</div>
</div>
{{end}}
</div>
<div class="info-box">
<p>如需查看更多详情,请登录系统查看任务执行历史记录。</p>
</div>
{{end}}
+3
View File
@@ -2,6 +2,7 @@ package service
import (
"likedream-goframe/internal/service/agent"
"likedream-goframe/internal/service/email"
"likedream-goframe/internal/service/menu"
"likedream-goframe/internal/service/role"
"likedream-goframe/internal/service/user_login"
@@ -12,6 +13,7 @@ type globalService struct {
Role *role.RoleServiceV1
Menu *menu.MenuServiceV1
Agent *agent.AgentServiceV1
Email *email.EmailServiceV1
}
var Global = globalService{
@@ -19,4 +21,5 @@ var Global = globalService{
Role: role.NewV1(),
Menu: menu.NewV1(),
Agent: agent.NewV1(),
Email: email.NewV1(),
}
@@ -10,6 +10,7 @@ import (
"likedream-goframe/internal/ent"
"likedream-goframe/internal/ent/sysrole"
"likedream-goframe/internal/ent/sysuser"
"likedream-goframe/internal/service/email"
"likedream-goframe/internal/utils"
)
@@ -69,6 +70,15 @@ func (s *UserLoginServiceV1) Register(ctx context.Context, req *v1.UserRegisterR
With("提交事务失败").
Wrap(err)
})
// 注册成功后发送欢迎邮件(异步发送,不影响注册结果)
go func() {
// 创建新的context避免原context被取消
emailCtx := context.Background()
emailService := email.NewV1()
emailService.SendSignupEmail(emailCtx, req.Email, req.Username)
}()
return utils.SuccessWithMsg("注册成功")
}