Files
cpdaily_xiaozhushou/backed/internal/service/mail.go
T
becomer 4b1dea3d3c
ci/woodpecker/tag/build Pipeline was successful
待适配学校支持拒绝操作:拒绝必填理由并邮件通知联系邮箱
- 新增管理端接口 POST /admin/pending-schools/:id/reject,拒绝理由必填(≤500字)
- 拒绝时该校全部待处理记录标记为 rejected 并保存理由
- 新增 NotifySchoolRejected 邮件模板,拒绝理由 HTML 转义后发送至该校全部联系邮箱
- 列表接口返回 rejectReason 并支持 status=rejected 筛选
- 管理页新增拒绝按钮(组级+行级)、拒绝理由弹窗、已拒绝状态标签与理由展示、已拒绝筛选
2026-09-09 22:31:06 +08:00

344 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"html"
"log/slog"
"net"
"net/smtp"
"strings"
"time"
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
)
// 系统配置 key(与 system_configs 表对应,后台「系统设置」页可编辑)
const (
CfgSmtpHost = "smtp_host" // SMTP 服务器地址
CfgSmtpPort = "smtp_port" // SMTP 端口
CfgSmtpUser = "smtp_user" // SMTP 用户名
CfgSmtpPassword = "smtp_password" // SMTP 密码/授权码
CfgSmtpFrom = "smtp_from" // 发件人地址(默认取 smtp_user)
CfgSmtpFromName = "smtp_from_name" // 发件人显示名称
CfgSmtpSecure = "smtp_secure" // ssl / starttls / none,留空按端口自动判断
CfgAdminEmails = "admin_emails" // 管理员收件邮箱(多个用英文逗号分隔)
)
type smtpSettings struct {
Host string
Port string
User string
Password string
From string
FromName string
Secure string // ssl / starttls / none
}
// loadSmtpSettings 从 system_configs 读取 SMTP 配置
func loadSmtpSettings() (*smtpSettings, error) {
var configs []model.SystemConfig
database.DB.Find(&configs)
m := make(map[string]string)
for _, cfg := range configs {
m[cfg.Key] = cfg.Value
}
s := &smtpSettings{
Host: strings.TrimSpace(m[CfgSmtpHost]),
Port: strings.TrimSpace(m[CfgSmtpPort]),
User: strings.TrimSpace(m[CfgSmtpUser]),
Password: m[CfgSmtpPassword],
From: strings.TrimSpace(m[CfgSmtpFrom]),
FromName: strings.TrimSpace(m[CfgSmtpFromName]),
Secure: strings.ToLower(strings.TrimSpace(m[CfgSmtpSecure])),
}
if s.From == "" {
s.From = s.User
}
if s.Host == "" || s.Port == "" || s.User == "" {
return nil, errors.New("SMTP 未配置完整(host/port/user 必填)")
}
if s.From == "" {
return nil, errors.New("SMTP 发件人地址未配置")
}
return s, nil
}
// SplitEmailsList 拆分逗号分隔的邮箱列表并过滤空项
func SplitEmailsList(raw string) []string {
var out []string
for _, part := range strings.Split(raw, ",") {
p := strings.TrimSpace(part)
if p != "" {
out = append(out, p)
}
}
return out
}
// smtpSecureMode 决定连接加密方式:显式配置优先,否则按端口推断
func (s *smtpSettings) smtpSecureMode() string {
if s.Secure != "" {
return s.Secure
}
if s.Port == "465" {
return "ssl"
}
return "starttls"
}
// encodeRFC2047 编码邮件头中的非 ASCII 文本(如中文主题/发件人名称)
func encodeRFC2047(text string) string {
if text == "" {
return ""
}
ascii := true
for _, r := range text {
if r > 127 {
ascii = false
break
}
}
if ascii {
return text
}
return "=?utf-8?B?" + base64.StdEncoding.EncodeToString([]byte(text)) + "?="
}
// SendMail 发送一封 HTML 邮件;未配置 SMTP 时返回明确错误(不 panic)
func SendMail(to []string, subject, html string) error {
if len(to) == 0 {
return errors.New("收件人为空")
}
s, err := loadSmtpSettings()
if err != nil {
return err
}
fromName := s.FromName
fromHeader := s.From
if fromName != "" {
fromHeader = encodeRFC2047(fromName) + " <" + s.From + ">"
}
msg := "From: " + fromHeader + "\r\n" +
"To: " + strings.Join(to, ",") + "\r\n" +
"Subject: " + encodeRFC2047(subject) + "\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: text/html; charset=utf-8\r\n" +
"Date: " + time.Now().Format(time.RFC1123Z) + "\r\n\r\n" +
html
addr := net.JoinHostPort(s.Host, s.Port)
auth := smtp.PlainAuth("", s.User, s.Password, s.Host)
var client *smtp.Client
mode := s.smtpSecureMode()
switch mode {
case "ssl":
// 465 直接 TLS
conn, err := tls.Dial("tcp", addr, &tls.Config{
ServerName: s.Host,
MinVersion: tls.VersionTLS12,
})
if err != nil {
return fmt.Errorf("SMTP SSL 连接失败: %w", err)
}
client, err = smtp.NewClient(conn, s.Host)
if err != nil {
conn.Close()
return fmt.Errorf("SMTP 握手失败: %w", err)
}
case "starttls":
conn, err := net.DialTimeout("tcp", addr, 15*time.Second)
if err != nil {
return fmt.Errorf("SMTP 连接失败: %w", err)
}
client, err = smtp.NewClient(conn, s.Host)
if err != nil {
conn.Close()
return fmt.Errorf("SMTP 握手失败: %w", err)
}
if err := client.StartTLS(&tls.Config{ServerName: s.Host, MinVersion: tls.VersionTLS12}); err != nil {
client.Close()
return fmt.Errorf("SMTP STARTTLS 失败: %w", err)
}
case "none":
conn, err := net.DialTimeout("tcp", addr, 15*time.Second)
if err != nil {
return fmt.Errorf("SMTP 连接失败: %w", err)
}
client, err = smtp.NewClient(conn, s.Host)
if err != nil {
conn.Close()
return fmt.Errorf("SMTP 握手失败: %w", err)
}
default:
return fmt.Errorf("不支持的 smtp_secure 值: %s", s.Secure)
}
defer client.Close()
if err := client.Auth(auth); err != nil {
// 明文连接下部分服务器(如本地测试)不支持 AUTH,仅在 secure=none 时容忍
if mode == "none" {
slog.Warn("SMTP AUTH 失败但继续发送(明文模式)", "error", err)
} else {
return fmt.Errorf("SMTP 认证失败: %w", err)
}
}
if err := client.Mail(s.From); err != nil {
return fmt.Errorf("SMTP MAIL FROM 失败: %w", err)
}
for _, rcpt := range to {
if err := client.Rcpt(rcpt); err != nil {
return fmt.Errorf("SMTP RCPT %s 失败: %w", rcpt, err)
}
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("SMTP DATA 失败: %w", err)
}
if _, err := w.Write([]byte(msg)); err != nil {
w.Close()
return fmt.Errorf("SMTP 写入邮件内容失败: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("SMTP 发送内容失败: %w", err)
}
if err := client.Quit(); err != nil {
slog.Debug("SMTP QUIT 警告", "error", err)
}
return nil
}
// SendMailAsync 异步发送邮件(带超时与 panic 保护,不阻塞业务)
func SendMailAsync(to []string, subject, html string) {
go func() {
defer func() {
if r := recover(); r != nil {
slog.Error("发送邮件 panic", "error", r)
}
}()
done := make(chan error, 1)
go func() {
done <- SendMail(to, subject, html)
}()
select {
case err := <-done:
if err != nil {
slog.Error("发送邮件失败", "to", to, "subject", subject, "error", err)
} else {
slog.Info("邮件发送成功", "to", to, "subject", subject)
}
case <-time.After(30 * time.Second):
slog.Error("发送邮件超时", "to", to, "subject", subject)
}
}()
}
// GetAdminEmails 读取管理员通知邮箱列表
func GetAdminEmails() []string {
var configs []model.SystemConfig
database.DB.Find(&configs)
for _, cfg := range configs {
if cfg.Key == CfgAdminEmails {
return SplitEmailsList(cfg.Value)
}
}
return nil
}
// ========== 业务邮件模板 ==========
// EmailBody 组装简单邮件正文
func EmailBody(inner string) string {
return `<div style="font-family:-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;max-width:600px;margin:0 auto;padding:24px;background:#f7f8fa">` +
`<div style="background:#fff;border-radius:12px;padding:24px;border:1px solid #eee">` + inner + `</div>` +
`<p style="color:#aaa;font-size:12px;text-align:center;margin-top:16px">小助手自动通知,请勿直接回复</p></div>`
}
// NotifyNewPendingSchool 新待适配学校提醒(发给管理员)
func NotifyNewPendingSchool(name, username, ip, email, submitAt string) {
adminEmails := GetAdminEmails()
if len(adminEmails) == 0 {
slog.Info("未配置管理员邮箱,跳过新待适配通知", "school", name)
return
}
subject := "【小助手】新的待适配学校:" + name
inner := `<h2 style="margin:0 0 16px;font-size:17px">收到新的待适配学校提交</h2>
<table style="border-collapse:collapse;font-size:14px;color:#333;line-height:2">
<tr><td style="padding-right:16px;color:#888">学校</td><td><b>` + name + `</b></td></tr>
<tr><td style="padding-right:16px;color:#888">测试账号</td><td>` + username + `</td></tr>
<tr><td style="padding-right:16px;color:#888">提交IP</td><td>` + ip + `</td></tr>
<tr><td style="padding-right:16px;color:#888">联系邮箱</td><td>` + email + `</td></tr>
<tr><td style="padding-right:16px;color:#888">提交时间</td><td>` + submitAt + `</td></tr>
</table>
<p style="color:#888;font-size:13px;margin-top:12px">请到管理后台「待适配学校」查看账号密码并安排适配。</p>`
SendMailAsync(adminEmails, subject, EmailBody(inner))
}
// NotifySchoolAdapted 学校适配完成通知(发给该学校提交者留下的全部联系邮箱)
func NotifySchoolAdapted(contactEmails []string, schoolName string) {
if len(contactEmails) == 0 {
slog.Info("学校无联系邮箱,跳过适配完成通知", "school", schoolName)
return
}
subject := "【小助手】" + schoolName + " 已适配完成"
inner := `<h2 style="margin:0 0 16px;font-size:17px">好消息!` + schoolName + ` 已适配完成</h2>
<p style="font-size:14px;color:#555;line-height:1.8">感谢您的耐心等待与反馈。该学校现已支持自动登录签到,您可以重新登录小助手使用签到、查寝等服务。</p>
<p style="font-size:14px;color:#555">若仍有问题,欢迎在应用内提交工单反馈。</p>`
SendMailAsync(contactEmails, subject, EmailBody(inner))
}
// NotifySchoolRejected 学校适配申请拒绝通知(发给该学校提交者留下的全部联系邮箱,附拒绝理由)
func NotifySchoolRejected(contactEmails []string, schoolName, reason string) {
if len(contactEmails) == 0 {
slog.Info("学校无联系邮箱,跳过拒绝通知", "school", schoolName)
return
}
escapedReason := html.EscapeString(reason)
escapedReason = strings.ReplaceAll(escapedReason, "\n", "<br/>")
subject := "【小助手】" + schoolName + " 暂无法适配"
inner := `<h2 style="margin:0 0 16px;font-size:17px">很抱歉,` + schoolName + ` 暂无法适配</h2>
<p style="font-size:14px;color:#555;line-height:1.8">感谢您的反馈。经管理员核实,该学校暂时无法适配,具体原因如下:</p>
<div style="font-size:14px;color:#333;line-height:1.8;background:#f7f8fa;border-radius:8px;padding:12px 16px;margin:12px 0">` + escapedReason + `</div>
<p style="font-size:14px;color:#555">请核对账号信息后重新提交,或如有疑问欢迎在应用内提交工单反馈。</p>`
SendMailAsync(contactEmails, subject, EmailBody(inner))
}
// NotifyNewTicket 新工单提醒(发给管理员)
func NotifyNewTicket(ticketID uint, username, schoolName, realName, title, content, createdAt string) {
adminEmails := GetAdminEmails()
if len(adminEmails) == 0 {
slog.Info("未配置管理员邮箱,跳过新工单通知", "ticketId", ticketID)
return
}
// 内容摘要:HTML 转义防注入,换行转 <br>,超长截断
escaped := html.EscapeString(content)
if len([]rune(escaped)) > 500 {
escaped = string([]rune(escaped)[:500]) + "……"
}
escaped = strings.ReplaceAll(escaped, "\n", "<br/>")
displayName := realName
if displayName == "" {
displayName = username
}
subject := "【小助手】新的工单:" + title
inner := `<h2 style="margin:0 0 16px;font-size:17px">收到新的工单反馈</h2>
<table style="border-collapse:collapse;font-size:14px;color:#333;line-height:2">
<tr><td style="padding-right:16px;color:#888;vertical-align:top">标题</td><td><b>` + html.EscapeString(title) + `</b></td></tr>
<tr><td style="padding-right:16px;color:#888">用户</td><td>` + html.EscapeString(displayName) + `(` + html.EscapeString(username) + `)</td></tr>
<tr><td style="padding-right:16px;color:#888">学校</td><td>` + html.EscapeString(schoolName) + `</td></tr>
<tr><td style="padding-right:16px;color:#888;vertical-align:top">内容</td><td style="max-width:420px">` + escaped + `</td></tr>
<tr><td style="padding-right:16px;color:#888">提交时间</td><td>` + html.EscapeString(createdAt) + `</td></tr>
</table>
<p style="color:#888;font-size:13px;margin-top:12px">请到管理后台「工单」查看详情并回复处理。</p>`
SendMailAsync(adminEmails, subject, EmailBody(inner))
}