feat: 优化运维定时报表邮件模板

This commit is contained in:
Jlypx
2026-07-19 11:23:26 +08:00
parent d4b9797ff7
commit 361983cf4d
5 changed files with 768 additions and 63 deletions
@@ -51,6 +51,31 @@ var (
notificationEmailPlaceholderPattern = regexp.MustCompile(`{{\s*([a-zA-Z][a-zA-Z0-9_]*)\s*}}`)
notificationEmailLocales = []string{notificationEmailDefaultLocale, notificationEmailLocaleChinese}
notificationEmailCommonPlaceholders = []string{"site_name", "recipient_name", "recipient_email"}
// Keep summary values separate so admins can rearrange or omit individual metrics in the template.
notificationEmailOpsSummaryPlaceholders = []string{
"report_summary_display",
"report_total_requests",
"report_success_count",
"report_sla_error_count",
"report_business_limited_count",
"report_sla",
"report_error_rate",
"report_upstream_error_rate",
"report_upstream_error_count_excl_429_529",
"report_upstream_429_count",
"report_upstream_529_count",
"report_latency_p50",
"report_latency_p99",
"report_ttft_p50",
"report_ttft_p99",
"report_tokens",
"report_qps_current",
"report_qps_peak",
"report_qps_avg",
"report_tps_current",
"report_tps_peak",
"report_tps_avg",
}
)
type NotificationEmailService struct {
@@ -504,6 +529,34 @@ func (s *NotificationEmailService) runtimeVariables(ctx context.Context, event,
for key, value := range input.Variables {
variables[key] = value
}
if event == NotificationEmailEventOpsScheduledReport {
// Scheduled reports may be sent by integrations that only provide report_html.
// Do not let preview sample values appear in a live email in that case.
if _, ok := input.Variables["report_html"]; !ok {
variables["report_html"] = ""
}
if _, ok := input.Variables["report_detail_display"]; !ok {
// Keep legacy/custom templates useful when they only render report_html.
variables["report_detail_display"] = "block"
}
hasSummaryValues := false
for _, placeholder := range notificationEmailOpsSummaryPlaceholders {
if _, ok := input.Variables[placeholder]; ok {
if placeholder != "report_summary_display" {
hasSummaryValues = true
}
continue
}
variables[placeholder] = "-"
}
if _, ok := input.Variables["report_summary_display"]; !ok {
if hasSummaryValues {
variables["report_summary_display"] = "block"
} else {
variables["report_summary_display"] = "none"
}
}
}
variables["site_name"] = s.siteName(ctx)
variables["recipient_email"] = input.RecipientEmail
if strings.TrimSpace(input.RecipientName) != "" {
@@ -844,7 +897,7 @@ func isSafeNotificationEmailURL(raw string) bool {
func notificationEmailSampleVariables(locale string) map[string]string {
if normalizeNotificationLocale(locale) == notificationEmailLocaleChinese {
return map[string]string{
variables := map[string]string{
"site_name": defaultSiteName,
"recipient_name": "张三",
"recipient_email": "user@example.com",
@@ -885,12 +938,14 @@ func notificationEmailSampleVariables(locale string) map[string]string {
"alert_description": "最近 10 分钟错误率超过阈值",
"report_name": "日报",
"report_type": "daily_summary",
"report_start_time": "2026-05-19 12:00",
"report_end_time": "2026-05-20 12:00",
"report_html": "<h2>日报</h2><p>请求量:1024</p>",
"report_start_time": "2026-07-18T01:00:26Z",
"report_end_time": "2026-07-19T01:00:26Z",
"report_html": "<h2>日报</h2><p>请求量:2,374</p>",
}
addNotificationEmailOpsSummarySampleVariables(variables)
return variables
}
return map[string]string{
variables := map[string]string{
"site_name": defaultSiteName,
"recipient_name": "Alex",
"recipient_email": "user@example.com",
@@ -931,10 +986,38 @@ func notificationEmailSampleVariables(locale string) map[string]string {
"alert_description": "Error rate exceeded threshold in the last 10 minutes.",
"report_name": "Daily summary",
"report_type": "daily_summary",
"report_start_time": "2026-05-19 12:00",
"report_end_time": "2026-05-20 12:00",
"report_html": "<h2>Daily summary</h2><p>Requests: 1024</p>",
"report_start_time": "2026-07-18T01:00:26Z",
"report_end_time": "2026-07-19T01:00:26Z",
"report_html": "<h2>Daily summary</h2><p>Requests: 2,374</p>",
}
addNotificationEmailOpsSummarySampleVariables(variables)
return variables
}
func addNotificationEmailOpsSummarySampleVariables(variables map[string]string) {
variables["report_summary_display"] = "block"
variables["report_detail_display"] = "none"
variables["report_total_requests"] = "2,374"
variables["report_success_count"] = "1,451"
variables["report_sla_error_count"] = "2"
variables["report_business_limited_count"] = "921"
variables["report_sla"] = "99.86%"
variables["report_error_rate"] = "0.14%"
variables["report_upstream_error_rate"] = "0.28%"
variables["report_upstream_error_count_excl_429_529"] = "4"
variables["report_upstream_429_count"] = "0"
variables["report_upstream_529_count"] = "0"
variables["report_latency_p50"] = "8,231 ms"
variables["report_latency_p99"] = "151,260 ms"
variables["report_ttft_p50"] = "1,674 ms"
variables["report_ttft_p99"] = "11,222 ms"
variables["report_tokens"] = "121,550,190"
variables["report_qps_current"] = "0.0"
variables["report_qps_peak"] = "1.2"
variables["report_qps_avg"] = "0.0"
variables["report_tps_current"] = "0.0"
variables["report_tps_peak"] = "133421.2"
variables["report_tps_avg"] = "1406.8"
}
var notificationEmailEventOrder = []string{
@@ -1061,8 +1144,13 @@ var notificationEmailEventDefinitions = map[string]NotificationEmailEventInfo{
Description: "Sent to configured operations recipients for scheduled daily/weekly/error/account-health reports.",
Category: "ops",
Optional: false,
Placeholders: append(append([]string{}, notificationEmailCommonPlaceholders...),
"report_name", "report_type", "report_start_time", "report_end_time", "report_html"),
Placeholders: append(
append(
append([]string{}, notificationEmailCommonPlaceholders...),
"report_name", "report_type", "report_start_time", "report_end_time",
),
append(append([]string{}, notificationEmailOpsSummaryPlaceholders...), "report_detail_display", "report_html")...,
),
},
}
@@ -1294,11 +1382,11 @@ var notificationEmailOfficialTemplates = map[string]map[string]notificationEmail
HTML: notificationEmailCard("#ef4444", "Cyber-security policy notice", `
<p>Hello {{recipient_name}},</p>
<p>Your request was blocked by the upstream provider's cyber-security policy.</p>
<table style="width:100%;border-collapse:collapse;">
<tr><td>Triggered at</td><td>{{triggered_at}}</td></tr>
<tr><td>Model</td><td>{{model}}</td></tr>
<tr><td>Group</td><td>{{group_name}}</td></tr>
<tr><td>Upstream message</td><td>{{upstream_message}}</td></tr>
<table style="width:100%;border-collapse:collapse;table-layout:fixed;">
<tr><td style="width:128px;vertical-align:top;">Triggered at</td><td style="overflow-wrap:anywhere;word-break:break-word;">{{triggered_at}}</td></tr>
<tr><td style="width:128px;vertical-align:top;">Model</td><td style="overflow-wrap:anywhere;word-break:break-word;">{{model}}</td></tr>
<tr><td style="width:128px;vertical-align:top;">Group</td><td style="overflow-wrap:anywhere;word-break:break-word;">{{group_name}}</td></tr>
<tr><td style="width:128px;vertical-align:top;">Upstream message</td><td style="overflow-wrap:anywhere;word-break:break-all;white-space:pre-wrap;">{{upstream_message}}</td></tr>
</table>
<p>If you believe this is a mistake, try rephrasing your request, or apply for authorized security access.</p>`),
},
@@ -1307,11 +1395,11 @@ var notificationEmailOfficialTemplates = map[string]map[string]notificationEmail
HTML: notificationEmailCard("#ef4444", "网络安全策略拦截提醒", `
<p>{{recipient_name}},您好:</p>
<p>您的请求被上游服务商的网络安全策略(cyber policy)拦截。</p>
<table style="width:100%;border-collapse:collapse;">
<tr><td>触发时间</td><td>{{triggered_at}}</td></tr>
<tr><td>模型</td><td>{{model}}</td></tr>
<tr><td>所属分组</td><td>{{group_name}}</td></tr>
<tr><td>上游说明</td><td>{{upstream_message}}</td></tr>
<table style="width:100%;border-collapse:collapse;table-layout:fixed;">
<tr><td style="width:128px;vertical-align:top;">触发时间</td><td style="overflow-wrap:anywhere;word-break:break-word;">{{triggered_at}}</td></tr>
<tr><td style="width:128px;vertical-align:top;">模型</td><td style="overflow-wrap:anywhere;word-break:break-word;">{{model}}</td></tr>
<tr><td style="width:128px;vertical-align:top;">所属分组</td><td style="overflow-wrap:anywhere;word-break:break-word;">{{group_name}}</td></tr>
<tr><td style="width:128px;vertical-align:top;">上游说明</td><td style="overflow-wrap:anywhere;word-break:break-all;white-space:pre-wrap;">{{upstream_message}}</td></tr>
</table>
<p>如认为系误判,可调整请求措辞后重试,或申请获得授权的安全访问权限。</p>`),
},
@@ -1341,23 +1429,207 @@ var notificationEmailOfficialTemplates = map[string]map[string]notificationEmail
NotificationEmailEventOpsScheduledReport: {
notificationEmailDefaultLocale: {
Subject: "[Ops Report] {{report_name}}",
HTML: notificationEmailCard("#0891b2", "Ops report", `
<p><strong>Report</strong>: {{report_name}}</p>
<p><strong>Type</strong>: {{report_type}}</p>
<p><strong>Range</strong>: {{report_start_time}} - {{report_end_time}}</p>
<div>{{report_html}}</div>`),
HTML: notificationEmailOpsScheduledReportTemplate(notificationEmailDefaultLocale),
},
notificationEmailLocaleChinese: {
Subject: "[运维报表] {{report_name}}",
HTML: notificationEmailCard("#0891b2", "运维报表", `
<p><strong>报表</strong>:{{report_name}}</p>
<p><strong>类型</strong>:{{report_type}}</p>
<p><strong>时间范围</strong>:{{report_start_time}} - {{report_end_time}}</p>
<div>{{report_html}}</div>`),
HTML: notificationEmailOpsScheduledReportTemplate(notificationEmailLocaleChinese),
},
},
}
func notificationEmailOpsScheduledReportTemplate(locale string) string {
if normalizeNotificationLocale(locale) == notificationEmailLocaleChinese {
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { margin: 0; padding: 24px 12px; background: #f4f6f8; color: #1f2937; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
.container { width: 100%; max-width: 680px; margin: 0 auto; background: #ffffff; border: 1px solid #dfe7ea; border-radius: 8px; overflow: hidden; }
.header { padding: 28px 32px 24px; background: #0f766e; color: #ffffff; }
.eyebrow { margin: 0 0 8px; color: #ccfbf1; font-size: 12px; font-weight: 700; letter-spacing: 0; text-transform: uppercase; }
h1 { margin: 0; font-size: 26px; line-height: 1.3; }
.header p { margin: 8px 0 0; color: #e6fffb; font-size: 14px; }
.content { padding: 28px 32px 32px; }
.meta { width: 100%; margin: 0 0 20px; border-collapse: collapse; background: #f8fafc; border: 1px solid #e2e8f0; }
.meta td { padding: 10px 12px; border-bottom: 1px solid #e2e8f0; font-size: 13px; vertical-align: top; }
.meta tr:last-child td { border-bottom: 0; }
.meta-label { width: 112px; color: #64748b; font-weight: 600; }
.section-title { margin: 28px 0 12px; color: #0f172a; font-size: 16px; line-height: 1.4; }
.metric-grid { width: 100%; border-collapse: separate; border-spacing: 8px; margin: -8px; }
.metric-cell { width: 50%; padding: 14px 16px; border: 1px solid #e2e8f0; background: #ffffff; vertical-align: top; }
.metric-label { display: block; color: #64748b; font-size: 12px; line-height: 1.4; }
.metric-value { display: block; margin-top: 6px; color: #0f172a; font-size: 20px; font-weight: 700; line-height: 1.2; }
.metric-value.good { color: #15803d; }
.metric-value.alert { color: #b91c1c; }
.detail { width: 100%; border-collapse: collapse; }
.detail td { padding: 10px 12px; border-bottom: 1px solid #e2e8f0; font-size: 13px; }
.detail td:first-child { width: 56%; color: #475569; }
.detail td:last-child { color: #0f172a; font-weight: 600; text-align: right; }
.report-detail { margin-top: 28px; }
.report-detail:empty { display: none; }
.footer { padding: 18px 32px; background: #f8fafc; border-top: 1px solid #e2e8f0; color: #64748b; font-size: 12px; line-height: 1.6; }
@media only screen and (max-width: 620px) {
body { padding: 0; }
.container { border: 0; border-radius: 0; }
.header, .content, .footer { padding-left: 20px; padding-right: 20px; }
.metric-grid, .metric-grid tbody, .metric-grid tr, .metric-cell { display: block; width: 100% !important; box-sizing: border-box; }
.metric-cell { margin: 8px 0; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<p class="eyebrow">运维报表</p>
<h1>{{report_name}}</h1>
<p>{{site_name}} 的运行概览</p>
</div>
<div class="content">
<table class="meta" role="presentation">
<tr><td class="meta-label">报表</td><td>{{report_name}}</td></tr>
<tr><td class="meta-label">类型</td><td>{{report_type}}</td></tr>
<tr><td class="meta-label">统计周期</td><td>{{report_start_time}} 至 {{report_end_time}} (UTC)</td></tr>
</table>
<div style="display: {{report_summary_display}};">
<h2 class="section-title">请求概览</h2>
<table class="metric-grid" role="presentation"><tr>
<td class="metric-cell"><span class="metric-label">总请求数</span><span class="metric-value">{{report_total_requests}}</span></td>
<td class="metric-cell"><span class="metric-label">成功请求</span><span class="metric-value good">{{report_success_count}}</span></td>
</tr><tr>
<td class="metric-cell"><span class="metric-label">SLA 错误</span><span class="metric-value alert">{{report_sla_error_count}}</span></td>
<td class="metric-cell"><span class="metric-label">业务限流</span><span class="metric-value">{{report_business_limited_count}}</span></td>
</tr></table>
<h2 class="section-title">可靠性</h2>
<table class="detail" role="presentation">
<tr><td>SLA</td><td>{{report_sla}}</td></tr>
<tr><td>错误率</td><td>{{report_error_rate}}</td></tr>
<tr><td>上游错误率(不含 429 / 529)</td><td>{{report_upstream_error_rate}}</td></tr>
<tr><td>上游错误(不含 429 / 529)</td><td>{{report_upstream_error_count_excl_429_529}}</td></tr>
<tr><td>上游 429 / 529</td><td>{{report_upstream_429_count}} / {{report_upstream_529_count}}</td></tr>
</table>
<h2 class="section-title">延迟表现</h2>
<table class="detail" role="presentation">
<tr><td>请求延迟 p50 / p99</td><td>{{report_latency_p50}} / {{report_latency_p99}}</td></tr>
<tr><td>首 Token 时间 p50 / p99</td><td>{{report_ttft_p50}} / {{report_ttft_p99}}</td></tr>
</table>
<h2 class="section-title">吞吐量</h2>
<table class="detail" role="presentation">
<tr><td>Token 消耗</td><td>{{report_tokens}}</td></tr>
<tr><td>QPS(当前 / 峰值 / 平均)</td><td>{{report_qps_current}} / {{report_qps_peak}} / {{report_qps_avg}}</td></tr>
<tr><td>TPS(当前 / 峰值 / 平均)</td><td>{{report_tps_current}} / {{report_tps_peak}} / {{report_tps_avg}}</td></tr>
</table>
</div>
<div class="report-detail" style="display: {{report_detail_display}};">{{report_html}}</div>
</div>
<div class="footer">此邮件由 {{site_name}} 自动发送,请勿直接回复。</div>
</div>
</body>
</html>`
}
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { margin: 0; padding: 24px 12px; background: #f4f6f8; color: #1f2937; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
.container { width: 100%; max-width: 680px; margin: 0 auto; background: #ffffff; border: 1px solid #dfe7ea; border-radius: 8px; overflow: hidden; }
.header { padding: 28px 32px 24px; background: #0f766e; color: #ffffff; }
.eyebrow { margin: 0 0 8px; color: #ccfbf1; font-size: 12px; font-weight: 700; letter-spacing: 0; text-transform: uppercase; }
h1 { margin: 0; font-size: 26px; line-height: 1.3; }
.header p { margin: 8px 0 0; color: #e6fffb; font-size: 14px; }
.content { padding: 28px 32px 32px; }
.meta { width: 100%; margin: 0 0 20px; border-collapse: collapse; background: #f8fafc; border: 1px solid #e2e8f0; }
.meta td { padding: 10px 12px; border-bottom: 1px solid #e2e8f0; font-size: 13px; vertical-align: top; }
.meta tr:last-child td { border-bottom: 0; }
.meta-label { width: 112px; color: #64748b; font-weight: 600; }
.section-title { margin: 28px 0 12px; color: #0f172a; font-size: 16px; line-height: 1.4; }
.metric-grid { width: 100%; border-collapse: separate; border-spacing: 8px; margin: -8px; }
.metric-cell { width: 50%; padding: 14px 16px; border: 1px solid #e2e8f0; background: #ffffff; vertical-align: top; }
.metric-label { display: block; color: #64748b; font-size: 12px; line-height: 1.4; }
.metric-value { display: block; margin-top: 6px; color: #0f172a; font-size: 20px; font-weight: 700; line-height: 1.2; }
.metric-value.good { color: #15803d; }
.metric-value.alert { color: #b91c1c; }
.detail { width: 100%; border-collapse: collapse; }
.detail td { padding: 10px 12px; border-bottom: 1px solid #e2e8f0; font-size: 13px; }
.detail td:first-child { width: 56%; color: #475569; }
.detail td:last-child { color: #0f172a; font-weight: 600; text-align: right; }
.report-detail { margin-top: 28px; }
.report-detail:empty { display: none; }
.footer { padding: 18px 32px; background: #f8fafc; border-top: 1px solid #e2e8f0; color: #64748b; font-size: 12px; line-height: 1.6; }
@media only screen and (max-width: 620px) {
body { padding: 0; }
.container { border: 0; border-radius: 0; }
.header, .content, .footer { padding-left: 20px; padding-right: 20px; }
.metric-grid, .metric-grid tbody, .metric-grid tr, .metric-cell { display: block; width: 100% !important; box-sizing: border-box; }
.metric-cell { margin: 8px 0; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<p class="eyebrow">Operations report</p>
<h1>{{report_name}}</h1>
<p>{{site_name}} runtime overview</p>
</div>
<div class="content">
<table class="meta" role="presentation">
<tr><td class="meta-label">Report</td><td>{{report_name}}</td></tr>
<tr><td class="meta-label">Type</td><td>{{report_type}}</td></tr>
<tr><td class="meta-label">Reporting period</td><td>{{report_start_time}} to {{report_end_time}} (UTC)</td></tr>
</table>
<div style="display: {{report_summary_display}};">
<h2 class="section-title">Request Overview</h2>
<table class="metric-grid" role="presentation"><tr>
<td class="metric-cell"><span class="metric-label">Total Requests</span><span class="metric-value">{{report_total_requests}}</span></td>
<td class="metric-cell"><span class="metric-label">Successful Requests</span><span class="metric-value good">{{report_success_count}}</span></td>
</tr><tr>
<td class="metric-cell"><span class="metric-label">SLA Errors</span><span class="metric-value alert">{{report_sla_error_count}}</span></td>
<td class="metric-cell"><span class="metric-label">Business Limited</span><span class="metric-value">{{report_business_limited_count}}</span></td>
</tr></table>
<h2 class="section-title">Reliability</h2>
<table class="detail" role="presentation">
<tr><td>SLA</td><td>{{report_sla}}</td></tr>
<tr><td>Error Rate</td><td>{{report_error_rate}}</td></tr>
<tr><td>Upstream Error Rate (excluding 429 / 529)</td><td>{{report_upstream_error_rate}}</td></tr>
<tr><td>Upstream Errors (excluding 429 / 529)</td><td>{{report_upstream_error_count_excl_429_529}}</td></tr>
<tr><td>Upstream 429 / 529</td><td>{{report_upstream_429_count}} / {{report_upstream_529_count}}</td></tr>
</table>
<h2 class="section-title">Latency</h2>
<table class="detail" role="presentation">
<tr><td>Request Latency p50 / p99</td><td>{{report_latency_p50}} / {{report_latency_p99}}</td></tr>
<tr><td>Time to First Token p50 / p99</td><td>{{report_ttft_p50}} / {{report_ttft_p99}}</td></tr>
</table>
<h2 class="section-title">Throughput</h2>
<table class="detail" role="presentation">
<tr><td>Tokens Consumed</td><td>{{report_tokens}}</td></tr>
<tr><td>QPS (current / peak / average)</td><td>{{report_qps_current}} / {{report_qps_peak}} / {{report_qps_avg}}</td></tr>
<tr><td>TPS (current / peak / average)</td><td>{{report_tps_current}} / {{report_tps_peak}} / {{report_tps_avg}}</td></tr>
</table>
</div>
<div class="report-detail" style="display: {{report_detail_display}};">{{report_html}}</div>
</div>
<div class="footer">This email was sent automatically by {{site_name}}. Please do not reply directly.</div>
</div>
</body>
</html>`
}
func notificationEmailCard(accent, title, content string) string {
return `<!DOCTYPE html>
<html>
@@ -158,6 +158,104 @@ func TestNotificationEmailAdditionalEventsAreListedAndPreviewable(t *testing.T)
}
}
func TestCyberPolicyNoticeTemplateWrapsLongUpstreamMessages(t *testing.T) {
ctx := context.Background()
svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil)
longMessage := strings.Repeat("0123456789abcdef", 256)
for _, locale := range []string{"en", "zh"} {
preview, err := svc.PreviewTemplate(ctx, NotificationEmailPreviewInput{
Event: NotificationEmailEventCyberPolicyNotice,
Locale: locale,
Variables: map[string]string{
"upstream_message": longMessage,
},
})
require.NoError(t, err)
require.Contains(t, preview.HTML, "table-layout:fixed")
require.Contains(t, preview.HTML, "overflow-wrap:anywhere")
require.Contains(t, preview.HTML, "word-break:break-all")
require.NotContains(t, preview.HTML, "{{upstream_message}}")
}
}
func TestOpsScheduledReportTemplateExposesEditableSummaryMetrics(t *testing.T) {
ctx := context.Background()
svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil)
requiredPlaceholders := []string{
"report_summary_display",
"report_detail_display",
"report_total_requests",
"report_success_count",
"report_sla_error_count",
"report_business_limited_count",
"report_sla",
"report_error_rate",
"report_upstream_error_rate",
"report_upstream_error_count_excl_429_529",
"report_upstream_429_count",
"report_upstream_529_count",
"report_latency_p50",
"report_latency_p99",
"report_ttft_p50",
"report_ttft_p99",
"report_tokens",
"report_qps_current",
"report_qps_peak",
"report_qps_avg",
"report_tps_current",
"report_tps_peak",
"report_tps_avg",
}
for _, locale := range []string{"en", "zh"} {
tmpl, err := svc.GetTemplate(ctx, NotificationEmailEventOpsScheduledReport, locale)
require.NoError(t, err)
for _, placeholder := range requiredPlaceholders {
require.Contains(t, tmpl.Placeholders, placeholder)
require.Contains(t, tmpl.HTML, "{{"+placeholder+"}}")
}
preview, err := svc.PreviewTemplate(ctx, NotificationEmailPreviewInput{
Event: NotificationEmailEventOpsScheduledReport,
Locale: locale,
})
require.NoError(t, err)
require.Contains(t, preview.HTML, "2,374")
require.Contains(t, preview.HTML, "99.86%")
require.Contains(t, preview.HTML, "151,260 ms")
require.Contains(t, preview.HTML, `style="display: none;"`)
require.NotContains(t, preview.HTML, "{{report_total_requests}}")
}
}
func TestOpsScheduledReportRuntimeVariablesDoNotLeakPreviewSamples(t *testing.T) {
ctx := context.Background()
svc := NewNotificationEmailService(newNotificationEmailMemorySettingRepo(), nil)
variables := svc.runtimeVariables(ctx, NotificationEmailEventOpsScheduledReport, "en", NotificationEmailSendInput{})
require.Equal(t, "none", variables["report_summary_display"])
require.Equal(t, "block", variables["report_detail_display"])
require.Empty(t, variables["report_html"])
for _, placeholder := range notificationEmailOpsSummaryPlaceholders {
if placeholder == "report_summary_display" {
continue
}
require.Equal(t, "-", variables[placeholder])
}
rendered, err := renderNotificationEmail(
NotificationEmailEventOpsScheduledReport,
"Report",
`<div style="display: {{report_detail_display}};">{{report_html}}</div>`,
variables,
nil,
)
require.NoError(t, err)
require.NotContains(t, rendered.HTML, "<h2>Daily summary</h2>")
}
func TestNotificationEmailRawHTMLVariablesAreTrustedOnlyForHTMLPlaceholders(t *testing.T) {
require.True(t, notificationEmailRawHTMLAllowed(NotificationEmailEventOpsScheduledReport, "report_html"))
require.False(t, notificationEmailRawHTMLAllowed(NotificationEmailEventOpsScheduledReport, "recipient_name"))
@@ -455,9 +553,11 @@ func TestNotificationEmailMemorySettingRepoSatisfiesInterface(t *testing.T) {
}
type notificationEmailTestSMTPServer struct {
listener net.Listener
wg sync.WaitGroup
messages atomic.Int64
listener net.Listener
wg sync.WaitGroup
messages atomic.Int64
messageMu sync.Mutex
messageBodies []string
}
func startNotificationEmailTestSMTPServer(t *testing.T) *notificationEmailTestSMTPServer {
@@ -489,6 +589,15 @@ func (s *notificationEmailTestSMTPServer) messageCount() int64 {
return s.messages.Load()
}
func (s *notificationEmailTestSMTPServer) lastMessage() string {
s.messageMu.Lock()
defer s.messageMu.Unlock()
if len(s.messageBodies) == 0 {
return ""
}
return s.messageBodies[len(s.messageBodies)-1]
}
func (s *notificationEmailTestSMTPServer) close() {
_ = s.listener.Close()
s.wg.Wait()
@@ -547,6 +656,7 @@ func (s *notificationEmailTestSMTPServer) handleConn(conn net.Conn) {
if !writeLine("354 End data with <CR><LF>.<CR><LF>") {
return
}
var message strings.Builder
for {
dataLine, err := rw.ReadString('\n')
if err != nil {
@@ -555,7 +665,11 @@ func (s *notificationEmailTestSMTPServer) handleConn(conn net.Conn) {
if strings.TrimRight(dataLine, "\r\n") == "." {
break
}
message.WriteString(dataLine)
}
s.messageMu.Lock()
s.messageBodies = append(s.messageBodies, message.String())
s.messageMu.Unlock()
s.messages.Add(1)
if !writeLine("250 2.0.0 OK") {
return
@@ -219,6 +219,11 @@ type opsScheduledReport struct {
NextRunAt time.Time
}
type opsScheduledReportContent struct {
html string
overview *OpsDashboardOverview
}
func (s *OpsScheduledReportService) listScheduledReports(ctx context.Context, now time.Time) []*opsScheduledReport {
if s == nil || s.opsService == nil {
return nil
@@ -316,11 +321,11 @@ func (s *OpsScheduledReportService) runReport(ctx context.Context, report *opsSc
// Mark as "run" up-front so a broken SMTP config doesn't spam retries every minute.
s.setLastRunAt(ctx, report.ReportType, now)
content, err := s.generateReportHTML(ctx, report, now)
content, err := s.generateReportContent(ctx, report, now)
if err != nil {
return 0, err
}
if strings.TrimSpace(content) == "" {
if strings.TrimSpace(content.html) == "" {
// Skip sending when the report decides not to emit content (e.g., digest below min count).
return 0, nil
}
@@ -336,9 +341,6 @@ func (s *OpsScheduledReportService) runReport(ctx context.Context, report *opsSc
return 0, nil
}
subject := fmt.Sprintf("[Ops Report] %s", strings.TrimSpace(report.Name))
templateVariables := opsScheduledReportEmailVariables(report, now)
attempts := 0
for _, to := range recipients {
addr := strings.TrimSpace(to)
@@ -346,25 +348,40 @@ func (s *OpsScheduledReportService) runReport(ctx context.Context, report *opsSc
continue
}
attempts++
locale := ""
if s.emailService.notificationEmailService != nil {
locale = s.emailService.notificationEmailService.ResolveRecipientLocale(ctx, 0, addr)
templateVariables := opsScheduledReportLocalizedEmailVariables(report, now, locale)
rawHTMLVariables := map[string]string{"report_html": content.html}
if isOpsSummaryReport(report) {
templateVariables = opsSummaryReportEmailVariables(report, now, content.overview, locale)
}
if err := s.emailService.notificationEmailService.Send(ctx, NotificationEmailSendInput{
Event: NotificationEmailEventOpsScheduledReport,
RecipientEmail: addr,
RecipientName: emailRecipientName(addr),
SourceType: "ops_scheduled_report",
SourceID: opsScheduledReportDeliverySourceID(report),
ReminderKey: now.UTC().Format("2006-01-02T15:04"),
Variables: templateVariables,
RawHTMLVariables: map[string]string{
"report_html": content,
},
Event: NotificationEmailEventOpsScheduledReport,
Locale: locale,
RecipientEmail: addr,
RecipientName: emailRecipientName(addr),
SourceType: "ops_scheduled_report",
SourceID: opsScheduledReportDeliverySourceID(report),
ReminderKey: now.UTC().Format("2006-01-02T15:04"),
Variables: templateVariables,
RawHTMLVariables: rawHTMLVariables,
}); err == nil {
continue
} else if !shouldFallbackNotificationEmail(err) {
continue
}
}
if err := s.emailService.SendEmail(ctx, addr, subject, content); err != nil {
subjectName := strings.TrimSpace(report.Name)
if locale != "" {
subjectName = opsScheduledReportLocalizedName(report, locale)
}
subjectPrefix := "[Ops Report]"
if strings.HasPrefix(strings.ToLower(locale), "zh") {
subjectPrefix = "[运维报表]"
}
subject := fmt.Sprintf("%s %s", subjectPrefix, subjectName)
if err := s.emailService.SendEmail(ctx, addr, subject, content.html); err != nil {
// Ignore per-recipient failures; continue best-effort.
continue
}
@@ -412,12 +429,138 @@ func opsScheduledReportEmailVariables(report *opsScheduledReport, now time.Time)
}
}
func (s *OpsScheduledReportService) generateReportHTML(ctx context.Context, report *opsScheduledReport, now time.Time) (string, error) {
func opsScheduledReportLocalizedEmailVariables(report *opsScheduledReport, now time.Time, locale string) map[string]string {
variables := opsScheduledReportEmailVariables(report, now)
variables["report_html"] = ""
variables["report_detail_display"] = "block"
for _, placeholder := range notificationEmailOpsSummaryPlaceholders {
variables[placeholder] = "-"
}
variables["report_summary_display"] = "none"
if name := opsScheduledReportLocalizedName(report, locale); name != "" {
variables["report_name"] = name
}
return variables
}
func opsScheduledReportLocalizedName(report *opsScheduledReport, locale string) string {
if report == nil {
return "Ops report"
}
chinese := strings.HasPrefix(strings.ToLower(strings.TrimSpace(locale)), "zh")
switch strings.TrimSpace(report.ReportType) {
case "daily_summary":
if chinese {
return "日报"
}
return "Daily summary"
case "weekly_summary":
if chinese {
return "周报"
}
return "Weekly summary"
case "error_digest":
if chinese {
return "错误摘要"
}
return "Error digest"
case "account_health":
if chinese {
return "账号健康"
}
return "Account health"
default:
return strings.TrimSpace(report.Name)
}
}
func isOpsSummaryReport(report *opsScheduledReport) bool {
if report == nil {
return false
}
switch strings.TrimSpace(report.ReportType) {
case "daily_summary", "weekly_summary":
return true
default:
return false
}
}
func opsSummaryReportEmailVariables(report *opsScheduledReport, now time.Time, overview *OpsDashboardOverview, locale string) map[string]string {
variables := opsScheduledReportLocalizedEmailVariables(report, now, locale)
variables["report_detail_display"] = "none"
if overview == nil {
for _, placeholder := range notificationEmailOpsSummaryPlaceholders {
if placeholder == "report_summary_display" {
continue
}
variables[placeholder] = "-"
}
variables["report_summary_display"] = "block"
return variables
}
variables["report_summary_display"] = "block"
variables["report_total_requests"] = formatOpsReportInteger(overview.RequestCountTotal)
variables["report_success_count"] = formatOpsReportInteger(overview.SuccessCount)
variables["report_sla_error_count"] = formatOpsReportInteger(overview.ErrorCountSLA)
variables["report_business_limited_count"] = formatOpsReportInteger(overview.BusinessLimitedCount)
variables["report_sla"] = fmt.Sprintf("%.2f%%", overview.SLA*100)
variables["report_error_rate"] = fmt.Sprintf("%.2f%%", overview.ErrorRate*100)
variables["report_upstream_error_rate"] = fmt.Sprintf("%.2f%%", overview.UpstreamErrorRate*100)
variables["report_upstream_error_count_excl_429_529"] = formatOpsReportInteger(overview.UpstreamErrorCountExcl429529)
variables["report_upstream_429_count"] = formatOpsReportInteger(overview.Upstream429Count)
variables["report_upstream_529_count"] = formatOpsReportInteger(overview.Upstream529Count)
variables["report_latency_p50"] = formatOpsReportMilliseconds(overview.Duration.P50)
variables["report_latency_p99"] = formatOpsReportMilliseconds(overview.Duration.P99)
variables["report_ttft_p50"] = formatOpsReportMilliseconds(overview.TTFT.P50)
variables["report_ttft_p99"] = formatOpsReportMilliseconds(overview.TTFT.P99)
variables["report_tokens"] = formatOpsReportInteger(overview.TokenConsumed)
variables["report_qps_current"] = fmt.Sprintf("%.1f", overview.QPS.Current)
variables["report_qps_peak"] = fmt.Sprintf("%.1f", overview.QPS.Peak)
variables["report_qps_avg"] = fmt.Sprintf("%.1f", overview.QPS.Avg)
variables["report_tps_current"] = fmt.Sprintf("%.1f", overview.TPS.Current)
variables["report_tps_peak"] = fmt.Sprintf("%.1f", overview.TPS.Peak)
variables["report_tps_avg"] = fmt.Sprintf("%.1f", overview.TPS.Avg)
return variables
}
func formatOpsReportInteger(value int64) string {
raw := strconv.FormatInt(value, 10)
start := 0
if strings.HasPrefix(raw, "-") {
start = 1
}
if len(raw)-start <= 3 {
return raw
}
var builder strings.Builder
builder.Grow(len(raw) + (len(raw)-start-1)/3)
builder.WriteString(raw[:start])
digitLen := len(raw) - start
for offset := 0; offset < digitLen; offset++ {
if offset > 0 && (digitLen-offset)%3 == 0 {
builder.WriteByte(',')
}
builder.WriteByte(raw[start+offset])
}
return builder.String()
}
func formatOpsReportMilliseconds(value *int) string {
if value == nil {
return "-"
}
return fmt.Sprintf("%s ms", formatOpsReportInteger(int64(*value)))
}
func (s *OpsScheduledReportService) generateReportContent(ctx context.Context, report *opsScheduledReport, now time.Time) (opsScheduledReportContent, error) {
if s == nil || s.opsService == nil || report == nil {
return "", fmt.Errorf("service not initialized")
return opsScheduledReportContent{}, fmt.Errorf("service not initialized")
}
if report.TimeRange <= 0 {
return "", fmt.Errorf("invalid time range")
return opsScheduledReportContent{}, fmt.Errorf("invalid time range")
}
end := now.UTC()
@@ -444,10 +587,13 @@ func (s *OpsScheduledReportService) generateReportHTML(ctx context.Context, repo
})
}
if err != nil {
return "", err
return opsScheduledReportContent{}, err
}
}
return buildOpsSummaryEmailHTML(report.Name, start, end, overview), nil
return opsScheduledReportContent{
html: buildOpsSummaryEmailHTML(report.Name, start, end, overview),
overview: overview,
}, nil
case "error_digest":
// Lightweight digest: list recent errors (status>=400) and breakdown by type.
startTime := start
@@ -460,25 +606,30 @@ func (s *OpsScheduledReportService) generateReportHTML(ctx context.Context, repo
}
out, err := s.opsService.GetErrorLogs(ctx, filter)
if err != nil {
return "", err
return opsScheduledReportContent{}, err
}
if report.ErrorDigestMinCount > 0 && out != nil && out.Total < report.ErrorDigestMinCount {
return "", nil
return opsScheduledReportContent{}, nil
}
return buildOpsErrorDigestEmailHTML(report.Name, start, end, out), nil
return opsScheduledReportContent{html: buildOpsErrorDigestEmailHTML(report.Name, start, end, out)}, nil
case "account_health":
// Best-effort: use account availability (not error rate yet).
avail, err := s.opsService.GetAccountAvailability(ctx, "", nil)
if err != nil {
return "", err
return opsScheduledReportContent{}, err
}
_ = report.AccountHealthErrorRateThreshold // reserved for future per-account error rate report
return buildOpsAccountHealthEmailHTML(report.Name, start, end, avail), nil
return opsScheduledReportContent{html: buildOpsAccountHealthEmailHTML(report.Name, start, end, avail)}, nil
default:
return "", fmt.Errorf("unknown report type: %s", report.ReportType)
return opsScheduledReportContent{}, fmt.Errorf("unknown report type: %s", report.ReportType)
}
}
func (s *OpsScheduledReportService) generateReportHTML(ctx context.Context, report *opsScheduledReport, now time.Time) (string, error) {
content, err := s.generateReportContent(ctx, report, now)
return content.html, err
}
func buildOpsSummaryEmailHTML(title string, start, end time.Time, overview *OpsDashboardOverview) string {
if overview == nil {
return fmt.Sprintf("<h2>%s</h2><p>No data.</p>", htmlEscape(title))
@@ -0,0 +1,143 @@
package service
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestOpsSummaryReportEmailVariables(t *testing.T) {
latencyP50 := 8231
latencyP99 := 151260
ttftP50 := 1674
ttftP99 := 11222
now := time.Date(2026, time.July, 19, 1, 0, 26, 0, time.UTC)
report := &opsScheduledReport{
Name: "日报",
ReportType: "daily_summary",
TimeRange: 24 * time.Hour,
}
overview := &OpsDashboardOverview{
RequestCountTotal: 2374,
SuccessCount: 1451,
ErrorCountSLA: 2,
BusinessLimitedCount: 921,
SLA: 0.9986,
ErrorRate: 0.0014,
UpstreamErrorRate: 0.0028,
UpstreamErrorCountExcl429529: 4,
Upstream429Count: 0,
Upstream529Count: 0,
TokenConsumed: 121550190,
Duration: OpsPercentiles{P50: &latencyP50, P99: &latencyP99},
TTFT: OpsPercentiles{P50: &ttftP50, P99: &ttftP99},
QPS: OpsRateSummary{Current: 0, Peak: 1.2, Avg: 0},
TPS: OpsRateSummary{Current: 0, Peak: 133421.2, Avg: 1406.8},
}
variables := opsSummaryReportEmailVariables(report, now, overview, "en")
require.Equal(t, "Daily summary", variables["report_name"])
require.Equal(t, "daily_summary", variables["report_type"])
require.Equal(t, "2026-07-18T01:00:26Z", variables["report_start_time"])
require.Equal(t, "2026-07-19T01:00:26Z", variables["report_end_time"])
require.Equal(t, "2,374", variables["report_total_requests"])
require.Equal(t, "1,451", variables["report_success_count"])
require.Equal(t, "2", variables["report_sla_error_count"])
require.Equal(t, "921", variables["report_business_limited_count"])
require.Equal(t, "99.86%", variables["report_sla"])
require.Equal(t, "0.14%", variables["report_error_rate"])
require.Equal(t, "0.28%", variables["report_upstream_error_rate"])
require.Equal(t, "4", variables["report_upstream_error_count_excl_429_529"])
require.Equal(t, "8,231 ms", variables["report_latency_p50"])
require.Equal(t, "151,260 ms", variables["report_latency_p99"])
require.Equal(t, "1,674 ms", variables["report_ttft_p50"])
require.Equal(t, "11,222 ms", variables["report_ttft_p99"])
require.Equal(t, "121,550,190", variables["report_tokens"])
require.Equal(t, "1.2", variables["report_qps_peak"])
require.Equal(t, "133421.2", variables["report_tps_peak"])
require.Equal(t, "1406.8", variables["report_tps_avg"])
require.Empty(t, variables["report_html"])
require.Equal(t, "none", variables["report_detail_display"])
zhVariables := opsSummaryReportEmailVariables(report, now, overview, "zh-CN")
require.Equal(t, "日报", zhVariables["report_name"])
emptyVariables := opsSummaryReportEmailVariables(report, now, nil, "en")
require.Equal(t, "block", emptyVariables["report_summary_display"])
require.Equal(t, "none", emptyVariables["report_detail_display"])
legacyTemplate, err := renderNotificationEmail(
NotificationEmailEventOpsScheduledReport,
"Report",
`<section>{{report_html}}</section>`,
variables,
map[string]string{"report_html": `<h2>generated summary</h2>`},
)
require.NoError(t, err)
require.Contains(t, legacyTemplate.HTML, `<h2>generated summary</h2>`)
}
func TestOpsScheduledReportVariablesDoNotUsePreviewMetrics(t *testing.T) {
now := time.Date(2026, time.July, 19, 1, 0, 26, 0, time.UTC)
report := &opsScheduledReport{
Name: "错误摘要",
ReportType: "error_digest",
TimeRange: 24 * time.Hour,
}
variables := opsScheduledReportLocalizedEmailVariables(report, now, "en")
require.Equal(t, "Error digest", variables["report_name"])
require.Empty(t, variables["report_html"])
require.Equal(t, "block", variables["report_detail_display"])
for _, placeholder := range notificationEmailOpsSummaryPlaceholders {
if placeholder == "report_summary_display" {
require.Equal(t, "none", variables[placeholder])
continue
}
require.Equal(t, "-", variables[placeholder])
}
}
func TestOpsScheduledReportLegacyTemplateReceivesSummaryHTML(t *testing.T) {
ctx := context.Background()
repo := newNotificationEmailMemorySettingRepo()
smtpServer := startNotificationEmailTestSMTPServer(t)
require.NoError(t, repo.SetMultiple(ctx, smtpServer.settings()))
emailService := NewEmailService(repo, nil)
notificationService := NewNotificationEmailService(repo, emailService)
_, err := notificationService.UpdateTemplate(
ctx,
NotificationEmailEventOpsScheduledReport,
"en",
"Legacy report {{report_name}}",
`<section data-template="legacy">{{report_html}}</section>`,
)
require.NoError(t, err)
svc := &OpsScheduledReportService{
opsService: &OpsService{opsRepo: &opsRepoMock{}},
emailService: emailService,
}
report := &opsScheduledReport{
Name: "日报",
ReportType: "daily_summary",
TimeRange: 24 * time.Hour,
Recipients: []string{"ops@example.com"},
}
attempts, err := svc.runReport(ctx, report, time.Date(2026, time.July, 19, 1, 0, 26, 0, time.UTC))
require.NoError(t, err)
require.Equal(t, 1, attempts)
require.Equal(t, int64(1), smtpServer.messageCount())
message := smtpServer.lastMessage()
require.Contains(t, message, `<section data-template="legacy">`)
require.Contains(t, message, `<h2>日报</h2>`)
}
func TestFormatOpsReportIntegerGroupsDigits(t *testing.T) {
require.Equal(t, "2,374", formatOpsReportInteger(2374))
require.Equal(t, "-1,234", formatOpsReportInteger(-1234))
require.Equal(t, "42", formatOpsReportInteger(42))
}
@@ -286,6 +286,29 @@ const fallbackPlaceholders = [
"{{report_type}}",
"{{report_start_time}}",
"{{report_end_time}}",
"{{report_summary_display}}",
"{{report_detail_display}}",
"{{report_total_requests}}",
"{{report_success_count}}",
"{{report_sla_error_count}}",
"{{report_business_limited_count}}",
"{{report_sla}}",
"{{report_error_rate}}",
"{{report_upstream_error_rate}}",
"{{report_upstream_error_count_excl_429_529}}",
"{{report_upstream_429_count}}",
"{{report_upstream_529_count}}",
"{{report_latency_p50}}",
"{{report_latency_p99}}",
"{{report_ttft_p50}}",
"{{report_ttft_p99}}",
"{{report_tokens}}",
"{{report_qps_current}}",
"{{report_qps_peak}}",
"{{report_qps_avg}}",
"{{report_tps_current}}",
"{{report_tps_peak}}",
"{{report_tps_avg}}",
"{{report_html}}",
];
@@ -374,7 +397,7 @@ const eventDisplayMeta: Record<string, EventDisplayMeta> = {
},
"ops.scheduled_report": {
label: "运维定时报表",
timing: "运维日报、周报、错误摘要或账号健康报表到达配置的发送时间时发送。",
timing: "运维日报、周报、错误摘要或账号健康报表到达配置的发送时间时发送;日报和周报的完整指标均可在模板中编辑。",
categoryLabel: "运维",
},
};
@@ -437,7 +460,7 @@ const eventDisplayMetaEn: Record<string, EventDisplayMeta> = {
},
"ops.scheduled_report": {
label: "Ops Scheduled Report",
timing: "Sent when a configured daily, weekly, error digest, or account health report reaches its scheduled send time.",
timing: "Sent when a configured daily, weekly, error digest, or account health report reaches its scheduled send time. Every daily and weekly summary metric is editable in this template.",
categoryLabel: "Ops",
},
};
@@ -505,7 +528,9 @@ const selectedEventDescription = computed(() => {
});
const placeholderList = computed(() => {
const combined = [...placeholders.value, ...fallbackPlaceholders];
const combined = placeholders.value.length
? placeholders.value
: fallbackPlaceholders;
return Array.from(
new Set(
combined