mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
feat: 优化运维定时报表邮件模板
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user