优化邮件通知模板与发送样式
This commit is contained in:
parent
2f53e96306
commit
63c2643c72
@ -11,7 +11,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
@ -414,50 +413,19 @@ func (s *Service) buildPayload(event Event) (mailPayload, error) {
|
||||
} else if event.Type != "" {
|
||||
title = eventTypeName(event.Type)
|
||||
}
|
||||
text := buildText(event, title, status, domainName)
|
||||
text, htmlBody := buildMailContent(event, title, status, domainName, s.config)
|
||||
return mailPayload{
|
||||
From: address{Name: fromName, Email: fromEmail},
|
||||
To: recipients,
|
||||
Subject: fmt.Sprintf("[Scert] %s:%s(%s)", title, domainName, status),
|
||||
Subject: buildMailSubject(title, status, domainName),
|
||||
Text: text,
|
||||
HTML: "<pre>" + html.EscapeString(text) + "</pre>",
|
||||
HTML: htmlBody,
|
||||
CC: []address{},
|
||||
BCC: []address{},
|
||||
Attrs: []any{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildText(event Event, title, status, domainName string) string {
|
||||
lines := []string{
|
||||
"Scert 证书管理服务通知",
|
||||
"任务类型:" + title,
|
||||
"任务状态:" + status,
|
||||
"域名:" + domainName,
|
||||
}
|
||||
if len(event.Domains) > 0 {
|
||||
lines = append(lines, "证书域名:"+strings.Join(event.Domains, "、"))
|
||||
}
|
||||
if event.ExpiresAt != nil && !event.ExpiresAt.IsZero() {
|
||||
lines = append(lines, "证书到期时间:"+event.ExpiresAt.Local().Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
if event.CertificateSerial != "" {
|
||||
lines = append(lines, "证书序列号:"+event.CertificateSerial)
|
||||
}
|
||||
if event.ManagementURL != "" {
|
||||
lines = append(lines, "管理后台:"+event.ManagementURL)
|
||||
}
|
||||
if event.Deployment.Total > 0 {
|
||||
lines = append(lines, fmt.Sprintf("部署结果:总计 %d 台,成功 %d 台,失败 %d 台,状态 %s", event.Deployment.Total, event.Deployment.Success, event.Deployment.Failed, event.Deployment.Status))
|
||||
for _, result := range event.Deployment.Results {
|
||||
lines = append(lines, fmt.Sprintf("- %s:%s(%s)", result.Server, result.Status, result.Message))
|
||||
}
|
||||
}
|
||||
if event.Error != "" {
|
||||
lines = append(lines, "错误信息:"+event.Error)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func eventStatus(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "success":
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
|
||||
)
|
||||
|
||||
type credentialReaderStub struct {
|
||||
@ -348,3 +349,103 @@ func TestSessionLoginReturnsBusinessMessageWithoutCredentials(t *testing.T) {
|
||||
t.Fatalf("登录错误不得包含密码:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMailContentSimplifiesDeploymentFailure(t *testing.T) {
|
||||
cfg := newTestConfig("http://mail.example.test")
|
||||
cfg.IncludeDeploymentDetails = true
|
||||
cfg.IncludeCertificateSerial = true
|
||||
cfg.IncludeManagementURL = true
|
||||
service := New(cfg, nil)
|
||||
payload, err := service.buildPayload(Event{
|
||||
Type: "certificate_renewal",
|
||||
Status: "failed",
|
||||
Domain: "scert-multi-20260930.apmmk.cn",
|
||||
Domains: []string{
|
||||
"scert-multi-20260930.apmmk.cn",
|
||||
},
|
||||
CertificateSerial: "serial-123",
|
||||
ManagementURL: "https://scert.example.test/tasks/1",
|
||||
Deployment: deployment.Report{
|
||||
Total: 2,
|
||||
Failed: 2,
|
||||
Status: "failed",
|
||||
Results: []deployment.Result{
|
||||
{Server: "第一台生产测试服务器", Status: "failed", Message: "本次部署未完成,已恢复旧文件,但服务或站点恢复状态未确认,已保留部署锁"},
|
||||
{Server: "第二台 SSL 推送测试服务器", Status: "failed", Message: "remote HTTPS connection failed: tls: failed to verify certificate: x509: certificate is valid for *.vcer.cn, vcer.cn, not scert-multi-20260930.apmmk.cn"},
|
||||
},
|
||||
},
|
||||
Error: "部署后 HTTPS 校验失败:服务器 第二台 SSL 推送测试服务器 的 HTTPS 校验失败:重试 6 次后仍未通过:remote HTTPS connection failed: tls: failed to verify certificate: x509: certificate is valid for *.vcer.cn, vcer.cn, not scert-multi-20260930.apmmk.cn",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("构造失败通知失败:%v", err)
|
||||
}
|
||||
if !strings.Contains(payload.Subject, "证书续期失败") {
|
||||
t.Fatalf("邮件主题未突出失败状态:%q", payload.Subject)
|
||||
}
|
||||
for _, value := range []string{
|
||||
"摘要:证书续期未完成,共 2 台服务器失败",
|
||||
"问题概述:",
|
||||
"第二台 SSL 推送测试服务器:HTTPS 证书域名不匹配",
|
||||
"处理结果:",
|
||||
"第一台生产测试服务器:失败,旧文件已恢复,但服务或站点状态尚未确认",
|
||||
"第二台 SSL 推送测试服务器:失败,HTTPS 证书域名不匹配",
|
||||
} {
|
||||
if !strings.Contains(payload.Text, value) {
|
||||
t.Fatalf("纯文本通知缺少关键信息 %q:%s", value, payload.Text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(payload.Text, "certificate is valid for") || strings.Contains(payload.HTML, "certificate is valid for") {
|
||||
t.Fatal("通知不应暴露冗长的底层 TLS 错误")
|
||||
}
|
||||
if !strings.Contains(payload.HTML, "border-radius") || !strings.Contains(payload.HTML, "问题概述") || !strings.Contains(payload.HTML, "部署概况") {
|
||||
t.Fatalf("HTML 通知未生成统一样式或重点区域:%s", payload.HTML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMailContentHonorsOptionalFields(t *testing.T) {
|
||||
cfg := newTestConfig("http://mail.example.test")
|
||||
cfg.IncludeDeploymentDetails = false
|
||||
cfg.IncludeCertificateSerial = false
|
||||
cfg.IncludeManagementURL = false
|
||||
service := New(cfg, nil)
|
||||
payload, err := service.buildPayload(Event{
|
||||
Type: "certificate_issue",
|
||||
Status: "success",
|
||||
Domain: "example.com",
|
||||
CertificateSerial: "serial-hidden",
|
||||
ManagementURL: "https://scert.example.test/hidden",
|
||||
Deployment: deployment.Report{
|
||||
Total: 1,
|
||||
Success: 1,
|
||||
Status: "success",
|
||||
Results: []deployment.Result{{Server: "测试服务器", Status: "success", Message: "部署完成"}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("构造成功通知失败:%v", err)
|
||||
}
|
||||
if !strings.Contains(payload.Text, "部署概况:") || strings.Contains(payload.Text, "测试服务器") {
|
||||
t.Fatalf("部署概况与明细开关处理错误:%s", payload.Text)
|
||||
}
|
||||
for _, hidden := range []string{"serial-hidden", "https://scert.example.test/hidden", "测试服务器"} {
|
||||
if strings.Contains(payload.Text, hidden) || strings.Contains(payload.HTML, hidden) {
|
||||
t.Fatalf("关闭可选字段后仍包含 %q", hidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLMailEscapesDynamicValues(t *testing.T) {
|
||||
cfg := newTestConfig("http://mail.example.test")
|
||||
service := New(cfg, nil)
|
||||
payload, err := service.buildPayload(Event{
|
||||
Status: "failed",
|
||||
Domain: "<script>alert(1)</script>",
|
||||
Error: "错误 <script>alert(2)</script>",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("构造转义通知失败:%v", err)
|
||||
}
|
||||
if strings.Contains(payload.HTML, "<script>") || !strings.Contains(payload.HTML, "<script>") {
|
||||
t.Fatalf("HTML 动态内容未正确转义:%s", payload.HTML)
|
||||
}
|
||||
}
|
||||
|
||||
@ -205,23 +205,30 @@ func buildSMTPMessage(payload mailPayload) []byte {
|
||||
to = append(to, formatSMTPAddress(item))
|
||||
}
|
||||
subject := mime.QEncoding.Encode("UTF-8", payload.Subject)
|
||||
content := payload.Text
|
||||
content = strings.ReplaceAll(content, "\r\n", "\n")
|
||||
content = strings.ReplaceAll(content, "\r", "\n")
|
||||
content = strings.ReplaceAll(content, "\n", "\r\n")
|
||||
if !strings.HasSuffix(content, "\r\n") {
|
||||
content += "\r\n"
|
||||
}
|
||||
boundary := "=_ScertNotificationBoundary_2026"
|
||||
text := normalizeMailBody(payload.Text)
|
||||
htmlBody := normalizeMailBody(payload.HTML)
|
||||
message := "From: " + from + "\r\n" +
|
||||
"To: " + strings.Join(to, ", ") + "\r\n" +
|
||||
"Subject: " + subject + "\r\n" +
|
||||
"Date: " + time.Now().Format(time.RFC1123Z) + "\r\n" +
|
||||
"MIME-Version: 1.0\r\n" +
|
||||
"Content-Type: multipart/alternative; boundary=\"" + boundary + "\"\r\n\r\n" +
|
||||
"--" + boundary + "\r\n" +
|
||||
"Content-Type: text/plain; charset=UTF-8\r\n" +
|
||||
"Content-Transfer-Encoding: 8bit\r\n\r\n" + content
|
||||
"Content-Transfer-Encoding: 8bit\r\n\r\n" + text + "\r\n" +
|
||||
"--" + boundary + "\r\n" +
|
||||
"Content-Type: text/html; charset=UTF-8\r\n" +
|
||||
"Content-Transfer-Encoding: 8bit\r\n\r\n" + htmlBody + "\r\n" +
|
||||
"--" + boundary + "--\r\n"
|
||||
return []byte(message)
|
||||
}
|
||||
|
||||
func normalizeMailBody(value string) string {
|
||||
value = strings.ReplaceAll(value, "\r\n", "\n")
|
||||
value = strings.ReplaceAll(value, "\r", "\n")
|
||||
return strings.ReplaceAll(value, "\n", "\r\n")
|
||||
}
|
||||
func formatSMTPAddress(value address) string {
|
||||
parsed, err := mail.ParseAddress(value.Email)
|
||||
if err != nil || parsed.Address == "" {
|
||||
|
||||
@ -432,3 +432,24 @@ func TestSMTPAddressFormatting(t *testing.T) {
|
||||
t.Fatalf("SMTP address encoding is incorrect: %s", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSMTPMessageIncludesStyledHTMLAlternative(t *testing.T) {
|
||||
message := string(buildSMTPMessage(mailPayload{
|
||||
From: address{Name: "Scert", Email: "admin@example.com"},
|
||||
To: []address{{Email: "receiver@example.com"}},
|
||||
Subject: "证书任务失败",
|
||||
Text: "纯文本通知",
|
||||
HTML: "<p style=\"color:red\">HTML 通知</p>",
|
||||
}))
|
||||
for _, value := range []string{
|
||||
"Content-Type: multipart/alternative;",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"Content-Type: text/html; charset=UTF-8",
|
||||
"纯文本通知",
|
||||
"<p style=\"color:red\">HTML 通知</p>",
|
||||
} {
|
||||
if !strings.Contains(message, value) {
|
||||
t.Fatalf("SMTP multipart 邮件缺少 %q:%s", value, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
318
backend/internal/notification/template.go
Normal file
318
backend/internal/notification/template.go
Normal file
@ -0,0 +1,318 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
|
||||
)
|
||||
|
||||
type mailContent struct {
|
||||
title string
|
||||
status string
|
||||
statusColor string
|
||||
statusSummary string
|
||||
domain string
|
||||
domains []string
|
||||
expiresAt string
|
||||
certificateSerial string
|
||||
managementURL string
|
||||
problem string
|
||||
deployment deployment.Report
|
||||
includeDeployment bool
|
||||
hasDeployment bool
|
||||
}
|
||||
|
||||
func buildMailSubject(title, status, domainName string) string {
|
||||
if status == "成功" {
|
||||
return fmt.Sprintf("[Scert] %s完成:%s", title, domainName)
|
||||
}
|
||||
return fmt.Sprintf("[Scert] %s%s:%s", title, status, domainName)
|
||||
}
|
||||
|
||||
func buildMailContent(event Event, title, status, domainName string, cfg config.NotificationConfig) (string, string) {
|
||||
content := mailContent{
|
||||
title: title,
|
||||
status: status,
|
||||
statusColor: mailStatusColor(status),
|
||||
statusSummary: mailStatusSummary(event, title, status),
|
||||
domain: domainName,
|
||||
domains: append([]string(nil), event.Domains...),
|
||||
deployment: event.Deployment,
|
||||
includeDeployment: cfg.IncludeDeploymentDetails,
|
||||
hasDeployment: hasDeployment(event.Deployment),
|
||||
}
|
||||
if event.ExpiresAt != nil && !event.ExpiresAt.IsZero() {
|
||||
content.expiresAt = event.ExpiresAt.Local().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
if cfg.IncludeCertificateSerial {
|
||||
content.certificateSerial = strings.TrimSpace(event.CertificateSerial)
|
||||
}
|
||||
if cfg.IncludeManagementURL {
|
||||
content.managementURL = safeHTTPURL(event.ManagementURL)
|
||||
}
|
||||
if status != "成功" {
|
||||
content.problem = problemOverview(event)
|
||||
}
|
||||
return buildTextMail(content), buildHTMLMail(content)
|
||||
}
|
||||
|
||||
func buildTextMail(content mailContent) string {
|
||||
lines := []string{
|
||||
"Scert 证书管理通知",
|
||||
"",
|
||||
"状态:" + content.status,
|
||||
"摘要:" + content.statusSummary,
|
||||
"",
|
||||
"任务信息:",
|
||||
"- 任务类型:" + content.title,
|
||||
"- 域名:" + content.domain,
|
||||
}
|
||||
if len(content.domains) > 0 {
|
||||
lines = append(lines, "- 证书域名:"+strings.Join(content.domains, "、"))
|
||||
}
|
||||
if content.expiresAt != "" {
|
||||
lines = append(lines, "- 证书到期时间:"+content.expiresAt)
|
||||
}
|
||||
if content.certificateSerial != "" {
|
||||
lines = append(lines, "- 证书序列号:"+content.certificateSerial)
|
||||
}
|
||||
if content.hasDeployment {
|
||||
lines = append(lines, "", "部署概况:", "- "+deploymentSummary(content.deployment))
|
||||
}
|
||||
if content.problem != "" {
|
||||
lines = append(lines, "", "问题概述:", content.problem)
|
||||
}
|
||||
if content.includeDeployment && len(content.deployment.Results) > 0 {
|
||||
lines = append(lines, "", "处理结果:")
|
||||
for _, result := range content.deployment.Results {
|
||||
lines = append(lines, fmt.Sprintf("- %s:%s,%s", result.Server, resultStatus(result.Status), resultSummary(result.Message)))
|
||||
}
|
||||
}
|
||||
if content.managementURL != "" {
|
||||
lines = append(lines, "", "管理后台:"+content.managementURL)
|
||||
}
|
||||
if content.status != "成功" {
|
||||
lines = append(lines, "", "请登录管理后台查看完整执行记录,再决定是否重试。")
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func buildHTMLMail(content mailContent) string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString(`<!doctype html><html lang="zh-CN"><body style="margin:0;padding:0;background:#f4f7fb;color:#172033;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",Arial,sans-serif;line-height:1.6;">`)
|
||||
builder.WriteString(`<div style="padding:32px 16px;"><div style="max-width:680px;margin:0 auto;background:#ffffff;border:1px solid #e4e9f2;border-radius:16px;overflow:hidden;">`)
|
||||
builder.WriteString(`<div style="padding:24px 28px;background:#14213d;color:#ffffff;"><div style="font-size:13px;letter-spacing:.08em;opacity:.8;">SCERT 证书管理服务</div><div style="margin-top:6px;font-size:24px;font-weight:700;">证书管理通知</div></div>`)
|
||||
builder.WriteString(`<div style="padding:28px;">`)
|
||||
builder.WriteString(`<div style="display:inline-block;padding:4px 12px;border-radius:999px;background:` + content.statusColor + `;color:#ffffff;font-size:13px;font-weight:700;">` + escapeHTML(content.status) + `</div>`)
|
||||
builder.WriteString(`<h1 style="margin:14px 0 6px;font-size:24px;line-height:1.35;color:#172033;">` + escapeHTML(content.statusSummary) + `</h1>`)
|
||||
builder.WriteString(`<p style="margin:0 0 24px;color:#667085;font-size:14px;">` + escapeHTML(content.domain) + `</p>`)
|
||||
|
||||
builder.WriteString(`<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;border:1px solid #e4e9f2;border-radius:10px;overflow:hidden;font-size:14px;">`)
|
||||
writeHTMLField(&builder, "任务类型", content.title)
|
||||
writeHTMLField(&builder, "域名", content.domain)
|
||||
if len(content.domains) > 0 {
|
||||
writeHTMLField(&builder, "证书域名", strings.Join(content.domains, "、"))
|
||||
}
|
||||
if content.expiresAt != "" {
|
||||
writeHTMLField(&builder, "证书到期时间", content.expiresAt)
|
||||
}
|
||||
if content.certificateSerial != "" {
|
||||
writeHTMLField(&builder, "证书序列号", content.certificateSerial)
|
||||
}
|
||||
builder.WriteString(`</table>`)
|
||||
|
||||
if content.problem != "" {
|
||||
builder.WriteString(`<div style="margin-top:24px;padding:16px 18px;border-left:4px solid #d92d20;background:#fff3f1;border-radius:8px;"><div style="font-size:14px;font-weight:700;color:#b42318;">问题概述</div><div style="margin-top:6px;color:#7a271a;font-size:14px;">` + escapeHTML(content.problem) + `</div></div>`)
|
||||
}
|
||||
if content.hasDeployment {
|
||||
builder.WriteString(`<div style="margin-top:24px;"><div style="font-size:16px;font-weight:700;color:#172033;">部署概况</div><div style="margin-top:8px;padding:12px 14px;background:#f7f9fc;border-radius:8px;color:#344054;font-size:14px;">` + escapeHTML(deploymentSummary(content.deployment)) + `</div></div>`)
|
||||
}
|
||||
if content.includeDeployment && len(content.deployment.Results) > 0 {
|
||||
builder.WriteString(`<div style="margin-top:24px;"><div style="font-size:16px;font-weight:700;color:#172033;">处理结果</div>`)
|
||||
for _, result := range content.deployment.Results {
|
||||
color := resultColor(result.Status)
|
||||
builder.WriteString(`<div style="margin-top:8px;padding:12px 14px;border:1px solid #e4e9f2;border-radius:8px;font-size:14px;"><span style="font-weight:700;color:` + color + `;">` + escapeHTML(result.Server) + ` · ` + escapeHTML(resultStatus(result.Status)) + `</span><div style="margin-top:4px;color:#667085;">` + escapeHTML(resultSummary(result.Message)) + `</div></div>`)
|
||||
}
|
||||
builder.WriteString(`</div>`)
|
||||
}
|
||||
if content.managementURL != "" {
|
||||
builder.WriteString(`<div style="margin-top:28px;"><a href="` + escapeHTML(content.managementURL) + `" style="display:inline-block;padding:10px 18px;border-radius:8px;background:#2563eb;color:#ffffff;text-decoration:none;font-size:14px;font-weight:700;">打开管理后台</a></div>`)
|
||||
}
|
||||
builder.WriteString(`</div><div style="padding:16px 28px;background:#f8fafc;color:#98a2b3;font-size:12px;">此邮件由 Scert 自动发送,请勿直接回复。</div></div></div></body></html>`)
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func writeHTMLField(builder *strings.Builder, label, value string) {
|
||||
builder.WriteString(`<tr><td style="width:34%;padding:10px 12px;border-bottom:1px solid #e4e9f2;background:#f8fafc;color:#667085;">` + escapeHTML(label) + `</td><td style="padding:10px 12px;border-bottom:1px solid #e4e9f2;color:#172033;word-break:break-word;">` + escapeHTML(value) + `</td></tr>`)
|
||||
}
|
||||
|
||||
func mailStatusColor(status string) string {
|
||||
switch status {
|
||||
case "成功":
|
||||
return "#16803c"
|
||||
case "失败", "错误":
|
||||
return "#c4322b"
|
||||
default:
|
||||
return "#667085"
|
||||
}
|
||||
}
|
||||
|
||||
func mailStatusSummary(event Event, title, status string) string {
|
||||
if event.Deployment.Total > 0 && event.Deployment.Failed > 0 {
|
||||
return fmt.Sprintf("%s未完成,共 %d 台服务器失败", title, event.Deployment.Failed)
|
||||
}
|
||||
if event.Deployment.Total > 0 && event.Deployment.Success == event.Deployment.Total {
|
||||
return fmt.Sprintf("%s已完成,共 %d 台服务器成功", title, event.Deployment.Success)
|
||||
}
|
||||
if status == "成功" {
|
||||
return title + "已完成"
|
||||
}
|
||||
return title + "未完成,请查看问题概述"
|
||||
}
|
||||
|
||||
func hasDeployment(report deployment.Report) bool {
|
||||
return report.Total > 0 || len(report.Results) > 0
|
||||
}
|
||||
|
||||
func deploymentSummary(report deployment.Report) string {
|
||||
status := deploymentStatus(report.Status)
|
||||
return fmt.Sprintf("总计 %d 台,成功 %d 台,失败 %d 台,状态:%s", report.Total, report.Success, report.Failed, status)
|
||||
}
|
||||
|
||||
func deploymentStatus(value string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "success":
|
||||
return "成功"
|
||||
case "partial":
|
||||
return "部分成功"
|
||||
case "failed", "failure":
|
||||
return "失败"
|
||||
default:
|
||||
return "未知"
|
||||
}
|
||||
}
|
||||
|
||||
func resultStatus(value string) string {
|
||||
status := eventStatus(value)
|
||||
if status == "未知" && strings.EqualFold(strings.TrimSpace(value), "partial") {
|
||||
return "部分成功"
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func resultColor(value string) string {
|
||||
switch resultStatus(value) {
|
||||
case "成功":
|
||||
return "#16803c"
|
||||
case "失败", "错误":
|
||||
return "#c4322b"
|
||||
default:
|
||||
return "#667085"
|
||||
}
|
||||
}
|
||||
|
||||
func problemOverview(event Event) string {
|
||||
items := make([]string, 0, 3)
|
||||
if detail := failureDetail(event.Error); detail != "" {
|
||||
if source := failureSource(event.Error); source != "" {
|
||||
detail = source + ":" + detail
|
||||
}
|
||||
items = appendUnique(items, detail)
|
||||
}
|
||||
for _, result := range event.Deployment.Results {
|
||||
if result.Status == "success" {
|
||||
continue
|
||||
}
|
||||
detail := failureDetail(result.Message)
|
||||
if detail == "" {
|
||||
continue
|
||||
}
|
||||
if result.Server != "" {
|
||||
detail = result.Server + ":" + detail
|
||||
}
|
||||
items = appendUnique(items, detail)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return "任务未完成,请登录管理后台查看详细执行记录。"
|
||||
}
|
||||
if len(items) > 2 {
|
||||
items = items[:2]
|
||||
}
|
||||
return strings.Join(items, ";")
|
||||
}
|
||||
|
||||
func failureSource(value string) string {
|
||||
const prefix = "服务器 "
|
||||
start := strings.Index(value, prefix)
|
||||
if start < 0 {
|
||||
return ""
|
||||
}
|
||||
value = value[start+len(prefix):]
|
||||
end := strings.Index(value, " 的")
|
||||
if end < 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(value[:end])
|
||||
}
|
||||
|
||||
func failureDetail(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
lower := strings.ToLower(value)
|
||||
switch {
|
||||
case strings.Contains(lower, "x509:") && strings.Contains(lower, "certificate is valid for"):
|
||||
return "HTTPS 证书域名不匹配,服务器返回的证书不包含目标域名"
|
||||
case strings.Contains(value, "HTTPS 校验失败") || strings.Contains(lower, "https verification"):
|
||||
return "HTTPS 校验失败,请检查服务器证书、域名和 SNI 配置"
|
||||
case strings.Contains(value, "恢复旧文件") && (strings.Contains(value, "未确认") || strings.Contains(value, "待核查")):
|
||||
return "旧文件已恢复,但服务或站点状态尚未确认"
|
||||
case strings.Contains(value, "部署锁"):
|
||||
return "部署锁仍被保留,请先核查服务器和站点状态,再决定是否重试"
|
||||
case strings.Contains(lower, "connection failed") || strings.Contains(value, "连接失败"):
|
||||
return "无法连接服务器,请检查网络、端口和服务状态"
|
||||
default:
|
||||
return compactText(value, 160)
|
||||
}
|
||||
}
|
||||
|
||||
func resultSummary(value string) string {
|
||||
if detail := failureDetail(value); detail != "" {
|
||||
return detail
|
||||
}
|
||||
return "处理完成"
|
||||
}
|
||||
|
||||
func compactText(value string, limit int) string {
|
||||
value = strings.Join(strings.Fields(strings.TrimSpace(value)), " ")
|
||||
if len([]rune(value)) <= limit {
|
||||
return value
|
||||
}
|
||||
return string([]rune(value)[:limit]) + "…"
|
||||
}
|
||||
|
||||
func appendUnique(items []string, value string) []string {
|
||||
for _, item := range items {
|
||||
if item == value {
|
||||
return items
|
||||
}
|
||||
}
|
||||
return append(items, value)
|
||||
}
|
||||
|
||||
func safeHTTPURL(value string) string {
|
||||
parsed, err := url.Parse(strings.TrimSpace(value))
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return ""
|
||||
}
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func escapeHTML(value string) string {
|
||||
return html.EscapeString(value)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user