4b1dea3d3c
ci/woodpecker/tag/build Pipeline was successful
- 新增管理端接口 POST /admin/pending-schools/:id/reject,拒绝理由必填(≤500字) - 拒绝时该校全部待处理记录标记为 rejected 并保存理由 - 新增 NotifySchoolRejected 邮件模板,拒绝理由 HTML 转义后发送至该校全部联系邮箱 - 列表接口返回 rejectReason 并支持 status=rejected 筛选 - 管理页新增拒绝按钮(组级+行级)、拒绝理由弹窗、已拒绝状态标签与理由展示、已拒绝筛选
344 lines
12 KiB
Go
344 lines
12 KiB
Go
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))
|
||
}
|