feat: implement email notification system for task status updates and user registration
This commit is contained in:
+3
-1
@@ -2,4 +2,6 @@
|
||||
.vscode
|
||||
.cursor
|
||||
node_modules
|
||||
.claude
|
||||
.claude
|
||||
*.exe
|
||||
.serena
|
||||
+12
-1
@@ -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 = "今日校园系统"
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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. 添加邮件发送统计和监控
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}}
|
||||
@@ -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("注册成功")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user