From 361983cf4d3519717a4e9c869a0533d38a2a8180 Mon Sep 17 00:00:00 2001 From: Jlypx Date: Sun, 19 Jul 2026 11:23:26 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E8=BF=90?= =?UTF-8?q?=E7=BB=B4=E5=AE=9A=E6=97=B6=E6=8A=A5=E8=A1=A8=E9=82=AE=E4=BB=B6?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/notification_email_service.go | 332 ++++++++++++++++-- .../notification_email_service_test.go | 120 ++++++- .../service/ops_scheduled_report_service.go | 205 +++++++++-- .../ops_scheduled_report_service_test.go | 143 ++++++++ .../admin/settings/EmailTemplateEditor.vue | 31 +- 5 files changed, 768 insertions(+), 63 deletions(-) create mode 100644 backend/internal/service/ops_scheduled_report_service_test.go 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_name}}

+

{{site_name}} 的运行概览

+
+
+ + + + + + +
+

请求概览

+ + + + + + + + +

可靠性

+ + + + + + + + +

延迟表现

+ + + + + +

吞吐量

+ + + + + + +
+
{{report_html}}
+
+ +
+ +` + } + + return ` + + + + + + + +
+
+

Operations report

+

{{report_name}}

+

{{site_name}} runtime overview

+
+
+ + + + + + +
+

Request Overview

+ + + + + + + + +

Reliability

+ + + + + + + + +

Latency

+ + + + + +

Throughput

+ + + + + + +
+
{{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..975436670f 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..2ddb20c1db 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,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("

%s

No data.

", htmlEscape(title)) 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", + `
{{report_html}}
`, + 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}}", + `
{{report_html}}
`, + ) + 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 From 2cff4bf169160369cd0a45010ac85778b44b778c Mon Sep 17 00:00:00 2001 From: Jlypx Date: Sun, 19 Jul 2026 11:39:20 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20golangci-lint?= =?UTF-8?q?=20=E6=A3=80=E6=9F=A5=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/notification_email_service_test.go | 2 +- .../internal/service/ops_scheduled_report_service.go | 11 +++-------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/backend/internal/service/notification_email_service_test.go b/backend/internal/service/notification_email_service_test.go index 975436670f..772eb45e9e 100644 --- a/backend/internal/service/notification_email_service_test.go +++ b/backend/internal/service/notification_email_service_test.go @@ -665,7 +665,7 @@ func (s *notificationEmailTestSMTPServer) handleConn(conn net.Conn) { if strings.TrimRight(dataLine, "\r\n") == "." { break } - message.WriteString(dataLine) + _, _ = message.WriteString(dataLine) } s.messageMu.Lock() s.messageBodies = append(s.messageBodies, message.String()) diff --git a/backend/internal/service/ops_scheduled_report_service.go b/backend/internal/service/ops_scheduled_report_service.go index 2ddb20c1db..eba02df40d 100644 --- a/backend/internal/service/ops_scheduled_report_service.go +++ b/backend/internal/service/ops_scheduled_report_service.go @@ -537,13 +537,13 @@ func formatOpsReportInteger(value int64) string { var builder strings.Builder builder.Grow(len(raw) + (len(raw)-start-1)/3) - builder.WriteString(raw[:start]) + _, _ = 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(',') } - builder.WriteByte(raw[start+offset]) + _ = builder.WriteByte(raw[start+offset]) } return builder.String() } @@ -625,11 +625,6 @@ func (s *OpsScheduledReportService) generateReportContent(ctx context.Context, r } } -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("

%s

No data.

", htmlEscape(title))