diff --git a/backend/internal/service/notification_email_service.go b/backend/internal/service/notification_email_service.go
index b7acccc7aa..76d8dbd7e6 100644
--- a/backend/internal/service/notification_email_service.go
+++ b/backend/internal/service/notification_email_service.go
@@ -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": "
日报
请求量:1024
",
+ "report_start_time": "2026-07-18T01:00:26Z",
+ "report_end_time": "2026-07-19T01:00:26Z",
+ "report_html": "日报
请求量:2,374
",
}
+ 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": "Daily summary
Requests: 1024
",
+ "report_start_time": "2026-07-18T01:00:26Z",
+ "report_end_time": "2026-07-19T01:00:26Z",
+ "report_html": "Daily summary
Requests: 2,374
",
}
+ 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", `
Hello {{recipient_name}},
Your request was blocked by the upstream provider's cyber-security policy.
-
- | Triggered at | {{triggered_at}} |
- | Model | {{model}} |
- | Group | {{group_name}} |
- | Upstream message | {{upstream_message}} |
+
+ | Triggered at | {{triggered_at}} |
+ | Model | {{model}} |
+ | Group | {{group_name}} |
+ | Upstream message | {{upstream_message}} |
If you believe this is a mistake, try rephrasing your request, or apply for authorized security access.
`),
},
@@ -1307,11 +1395,11 @@ var notificationEmailOfficialTemplates = map[string]map[string]notificationEmail
HTML: notificationEmailCard("#ef4444", "网络安全策略拦截提醒", `
{{recipient_name}},您好:
您的请求被上游服务商的网络安全策略(cyber policy)拦截。
-
- | 触发时间 | {{triggered_at}} |
- | 模型 | {{model}} |
- | 所属分组 | {{group_name}} |
- | 上游说明 | {{upstream_message}} |
+
+ | 触发时间 | {{triggered_at}} |
+ | 模型 | {{model}} |
+ | 所属分组 | {{group_name}} |
+ | 上游说明 | {{upstream_message}} |
如认为系误判,可调整请求措辞后重试,或申请获得授权的安全访问权限。
`),
},
@@ -1341,23 +1429,207 @@ var notificationEmailOfficialTemplates = map[string]map[string]notificationEmail
NotificationEmailEventOpsScheduledReport: {
notificationEmailDefaultLocale: {
Subject: "[Ops Report] {{report_name}}",
- HTML: notificationEmailCard("#0891b2", "Ops report", `
-Report: {{report_name}}
-Type: {{report_type}}
-Range: {{report_start_time}} - {{report_end_time}}
-{{report_html}}
`),
+ HTML: notificationEmailOpsScheduledReportTemplate(notificationEmailDefaultLocale),
},
notificationEmailLocaleChinese: {
Subject: "[运维报表] {{report_name}}",
- HTML: notificationEmailCard("#0891b2", "运维报表", `
-报表:{{report_name}}
-类型:{{report_type}}
-时间范围:{{report_start_time}} - {{report_end_time}}
-{{report_html}}
`),
+ HTML: notificationEmailOpsScheduledReportTemplate(notificationEmailLocaleChinese),
},
},
}
+func notificationEmailOpsScheduledReportTemplate(locale string) string {
+ if normalizeNotificationLocale(locale) == notificationEmailLocaleChinese {
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
请求概览
+
+ | 总请求数{{report_total_requests}} |
+ 成功请求{{report_success_count}} |
+
+ | SLA 错误{{report_sla_error_count}} |
+ 业务限流{{report_business_limited_count}} |
+
+
+
可靠性
+
+ | SLA | {{report_sla}} |
+ | 错误率 | {{report_error_rate}} |
+ | 上游错误率(不含 429 / 529) | {{report_upstream_error_rate}} |
+ | 上游错误(不含 429 / 529) | {{report_upstream_error_count_excl_429_529}} |
+ | 上游 429 / 529 | {{report_upstream_429_count}} / {{report_upstream_529_count}} |
+
+
+
延迟表现
+
+ | 请求延迟 p50 / p99 | {{report_latency_p50}} / {{report_latency_p99}} |
+ | 首 Token 时间 p50 / p99 | {{report_ttft_p50}} / {{report_ttft_p99}} |
+
+
+
吞吐量
+
+ | Token 消耗 | {{report_tokens}} |
+ | QPS(当前 / 峰值 / 平均) | {{report_qps_current}} / {{report_qps_peak}} / {{report_qps_avg}} |
+ | TPS(当前 / 峰值 / 平均) | {{report_tps_current}} / {{report_tps_peak}} / {{report_tps_avg}} |
+
+
+
+
{{report_html}}
+
+
+
+
+`
+ }
+
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Request Overview
+
+ | Total Requests{{report_total_requests}} |
+ Successful Requests{{report_success_count}} |
+
+ | SLA Errors{{report_sla_error_count}} |
+ Business Limited{{report_business_limited_count}} |
+
+
+
Reliability
+
+ | SLA | {{report_sla}} |
+ | Error Rate | {{report_error_rate}} |
+ | Upstream Error Rate (excluding 429 / 529) | {{report_upstream_error_rate}} |
+ | Upstream Errors (excluding 429 / 529) | {{report_upstream_error_count_excl_429_529}} |
+ | Upstream 429 / 529 | {{report_upstream_429_count}} / {{report_upstream_529_count}} |
+
+
+
Latency
+
+ | Request Latency p50 / p99 | {{report_latency_p50}} / {{report_latency_p99}} |
+ | Time to First Token p50 / p99 | {{report_ttft_p50}} / {{report_ttft_p99}} |
+
+
+
Throughput
+
+ | Tokens Consumed | {{report_tokens}} |
+ | QPS (current / peak / average) | {{report_qps_current}} / {{report_qps_peak}} / {{report_qps_avg}} |
+ | TPS (current / peak / average) | {{report_tps_current}} / {{report_tps_peak}} / {{report_tps_avg}} |
+
+
+
+
{{report_html}}
+
+
+
+
+`
+}
+
func notificationEmailCard(accent, title, content string) string {
return `
diff --git a/backend/internal/service/notification_email_service_test.go b/backend/internal/service/notification_email_service_test.go
index 9faeda7993..772eb45e9e 100644
--- a/backend/internal/service/notification_email_service_test.go
+++ b/backend/internal/service/notification_email_service_test.go
@@ -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",
+ `{{report_html}}
`,
+ variables,
+ nil,
+ )
+ require.NoError(t, err)
+ require.NotContains(t, rendered.HTML, "Daily summary
")
+}
+
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 .") {
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
diff --git a/backend/internal/service/ops_scheduled_report_service.go b/backend/internal/service/ops_scheduled_report_service.go
index 54aad11438..eba02df40d 100644
--- a/backend/internal/service/ops_scheduled_report_service.go
+++ b/backend/internal/service/ops_scheduled_report_service.go
@@ -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,22 +606,22 @@ 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)
}
}
diff --git a/backend/internal/service/ops_scheduled_report_service_test.go b/backend/internal/service/ops_scheduled_report_service_test.go
new file mode 100644
index 0000000000..e4bfcbf05b
--- /dev/null
+++ b/backend/internal/service/ops_scheduled_report_service_test.go
@@ -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",
+ ``,
+ variables,
+ map[string]string{"report_html": `generated summary
`},
+ )
+ require.NoError(t, err)
+ require.Contains(t, legacyTemplate.HTML, `generated summary
`)
+}
+
+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}}",
+ ``,
+ )
+ 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, ``)
+ require.Contains(t, message, `日报
`)
+}
+
+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))
+}
diff --git a/frontend/src/views/admin/settings/EmailTemplateEditor.vue b/frontend/src/views/admin/settings/EmailTemplateEditor.vue
index 5fcb371e6c..3e28ede484 100644
--- a/frontend/src/views/admin/settings/EmailTemplateEditor.vue
+++ b/frontend/src/views/admin/settings/EmailTemplateEditor.vue
@@ -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 = {
},
"ops.scheduled_report": {
label: "运维定时报表",
- timing: "运维日报、周报、错误摘要或账号健康报表到达配置的发送时间时发送。",
+ timing: "运维日报、周报、错误摘要或账号健康报表到达配置的发送时间时发送;日报和周报的完整指标均可在模板中编辑。",
categoryLabel: "运维",
},
};
@@ -437,7 +460,7 @@ const eventDisplayMetaEn: Record = {
},
"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