| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926 |
- package service
- import (
- "context"
- "fmt"
- "log"
- "strconv"
- "strings"
- "sync"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/google/uuid"
- "github.com/redis/go-redis/v9"
- "github.com/robfig/cron/v3"
- )
- const (
- opsScheduledReportJobName = "ops_scheduled_reports"
- opsScheduledReportLeaderLockKeyDefault = "ops:scheduled_reports:leader"
- opsScheduledReportLeaderLockTTLDefault = 5 * time.Minute
- opsScheduledReportLastRunKeyPrefix = "ops:scheduled_reports:last_run:"
- opsScheduledReportTickInterval = 1 * time.Minute
- )
- var opsScheduledReportCronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
- var opsScheduledReportReleaseScript = redis.NewScript(`
- if redis.call("GET", KEYS[1]) == ARGV[1] then
- return redis.call("DEL", KEYS[1])
- end
- return 0
- `)
- type OpsScheduledReportService struct {
- opsService *OpsService
- userService *UserService
- emailService *EmailService
- redisClient *redis.Client
- cfg *config.Config
- instanceID string
- loc *time.Location
- distributedLockOn bool
- warnNoRedisOnce sync.Once
- startOnce sync.Once
- stopOnce sync.Once
- stopCtx context.Context
- stop context.CancelFunc
- wg sync.WaitGroup
- }
- func NewOpsScheduledReportService(
- opsService *OpsService,
- userService *UserService,
- emailService *EmailService,
- redisClient *redis.Client,
- cfg *config.Config,
- ) *OpsScheduledReportService {
- lockOn := cfg == nil || strings.TrimSpace(cfg.RunMode) != config.RunModeSimple
- loc := time.Local
- if cfg != nil && strings.TrimSpace(cfg.Timezone) != "" {
- if parsed, err := time.LoadLocation(strings.TrimSpace(cfg.Timezone)); err == nil && parsed != nil {
- loc = parsed
- }
- }
- return &OpsScheduledReportService{
- opsService: opsService,
- userService: userService,
- emailService: emailService,
- redisClient: redisClient,
- cfg: cfg,
- instanceID: uuid.NewString(),
- loc: loc,
- distributedLockOn: lockOn,
- warnNoRedisOnce: sync.Once{},
- startOnce: sync.Once{},
- stopOnce: sync.Once{},
- stopCtx: nil,
- stop: nil,
- wg: sync.WaitGroup{},
- }
- }
- func (s *OpsScheduledReportService) Start() {
- s.StartWithContext(context.Background())
- }
- func (s *OpsScheduledReportService) StartWithContext(ctx context.Context) {
- if s == nil {
- return
- }
- if ctx == nil {
- ctx = context.Background()
- }
- if s.cfg != nil && !s.cfg.Ops.Enabled {
- return
- }
- if s.opsService == nil || s.emailService == nil {
- return
- }
- s.startOnce.Do(func() {
- s.stopCtx, s.stop = context.WithCancel(ctx)
- s.wg.Add(1)
- go s.run()
- })
- }
- func (s *OpsScheduledReportService) Stop() {
- if s == nil {
- return
- }
- s.stopOnce.Do(func() {
- if s.stop != nil {
- s.stop()
- }
- })
- s.wg.Wait()
- }
- func (s *OpsScheduledReportService) run() {
- defer s.wg.Done()
- ticker := time.NewTicker(opsScheduledReportTickInterval)
- defer ticker.Stop()
- s.runOnce()
- for {
- select {
- case <-ticker.C:
- s.runOnce()
- case <-s.stopCtx.Done():
- return
- }
- }
- }
- func (s *OpsScheduledReportService) runOnce() {
- if s == nil || s.opsService == nil || s.emailService == nil {
- return
- }
- startedAt := time.Now().UTC()
- runAt := startedAt
- ctx, cancel := context.WithTimeout(s.stopCtx, 60*time.Second)
- defer cancel()
- // Respect ops monitoring enabled switch.
- if !s.opsService.IsMonitoringEnabled(ctx) {
- return
- }
- release, ok := s.tryAcquireLeaderLock(ctx)
- if !ok {
- return
- }
- if release != nil {
- defer release()
- }
- now := time.Now()
- if s.loc != nil {
- now = now.In(s.loc)
- }
- reports := s.listScheduledReports(ctx, now)
- if len(reports) == 0 {
- return
- }
- reportsTotal := len(reports)
- reportsDue := 0
- sentAttempts := 0
- for _, report := range reports {
- if report == nil || !report.Enabled {
- continue
- }
- if report.NextRunAt.After(now) {
- continue
- }
- reportsDue++
- attempts, err := s.runReport(ctx, report, now)
- if err != nil {
- s.recordHeartbeatError(runAt, time.Since(startedAt), err)
- return
- }
- sentAttempts += attempts
- }
- result := truncateString(fmt.Sprintf("reports=%d due=%d send_attempts=%d", reportsTotal, reportsDue, sentAttempts), 2048)
- s.recordHeartbeatSuccess(runAt, time.Since(startedAt), result)
- }
- type opsScheduledReport struct {
- Name string
- ReportType string
- Schedule string
- Enabled bool
- TimeRange time.Duration
- Recipients []string
- ErrorDigestMinCount int
- AccountHealthErrorRateThreshold float64
- LastRunAt *time.Time
- 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
- }
- if ctx == nil {
- ctx = context.Background()
- }
- emailCfg, err := s.opsService.GetEmailNotificationConfig(ctx)
- if err != nil || emailCfg == nil {
- return nil
- }
- if !emailCfg.Report.Enabled {
- return nil
- }
- recipients := normalizeEmails(emailCfg.Report.Recipients)
- type reportDef struct {
- enabled bool
- name string
- kind string
- timeRange time.Duration
- schedule string
- }
- defs := []reportDef{
- {enabled: emailCfg.Report.DailySummaryEnabled, name: "日报", kind: "daily_summary", timeRange: 24 * time.Hour, schedule: emailCfg.Report.DailySummarySchedule},
- {enabled: emailCfg.Report.WeeklySummaryEnabled, name: "周报", kind: "weekly_summary", timeRange: 7 * 24 * time.Hour, schedule: emailCfg.Report.WeeklySummarySchedule},
- {enabled: emailCfg.Report.ErrorDigestEnabled, name: "错误摘要", kind: "error_digest", timeRange: 24 * time.Hour, schedule: emailCfg.Report.ErrorDigestSchedule},
- {enabled: emailCfg.Report.AccountHealthEnabled, name: "账号健康", kind: "account_health", timeRange: 24 * time.Hour, schedule: emailCfg.Report.AccountHealthSchedule},
- }
- out := make([]*opsScheduledReport, 0, len(defs))
- for _, d := range defs {
- if !d.enabled {
- continue
- }
- spec := strings.TrimSpace(d.schedule)
- if spec == "" {
- continue
- }
- sched, err := opsScheduledReportCronParser.Parse(spec)
- if err != nil {
- log.Printf("[OpsScheduledReport] invalid cron spec=%q for report=%s: %v", spec, d.kind, err)
- continue
- }
- lastRun := s.getLastRunAt(ctx, d.kind)
- base := lastRun
- if base.IsZero() {
- // Allow a schedule matching the current minute to trigger right after startup.
- base = now.Add(-1 * time.Minute)
- }
- next := sched.Next(base)
- if next.IsZero() {
- continue
- }
- var lastRunPtr *time.Time
- if !lastRun.IsZero() {
- lastCopy := lastRun
- lastRunPtr = &lastCopy
- }
- out = append(out, &opsScheduledReport{
- Name: d.name,
- ReportType: d.kind,
- Schedule: spec,
- Enabled: true,
- TimeRange: d.timeRange,
- Recipients: recipients,
- ErrorDigestMinCount: emailCfg.Report.ErrorDigestMinCount,
- AccountHealthErrorRateThreshold: emailCfg.Report.AccountHealthErrorRateThreshold,
- LastRunAt: lastRunPtr,
- NextRunAt: next,
- })
- }
- return out
- }
- func (s *OpsScheduledReportService) runReport(ctx context.Context, report *opsScheduledReport, now time.Time) (int, error) {
- if s == nil || s.opsService == nil || s.emailService == nil || report == nil {
- return 0, nil
- }
- if ctx == nil {
- ctx = context.Background()
- }
- // 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.generateReportContent(ctx, report, now)
- if err != nil {
- return 0, err
- }
- if strings.TrimSpace(content.html) == "" {
- // Skip sending when the report decides not to emit content (e.g., digest below min count).
- return 0, nil
- }
- recipients := report.Recipients
- if len(recipients) == 0 && s.userService != nil {
- admin, err := s.userService.GetFirstAdmin(ctx)
- if err == nil && admin != nil && strings.TrimSpace(admin.Email) != "" {
- recipients = []string{strings.TrimSpace(admin.Email)}
- }
- }
- if len(recipients) == 0 {
- return 0, nil
- }
- attempts := 0
- for _, to := range recipients {
- addr := strings.TrimSpace(to)
- if addr == "" {
- 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,
- 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
- }
- }
- 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
- }
- }
- return attempts, nil
- }
- func opsScheduledReportDeliverySourceID(report *opsScheduledReport) string {
- if report == nil {
- return "scheduled_report"
- }
- parts := []string{
- strings.TrimSpace(report.ReportType),
- strings.TrimSpace(report.Name),
- strings.TrimSpace(report.Schedule),
- }
- joined := strings.Trim(strings.Join(parts, ":"), ":")
- if joined == "" {
- return "scheduled_report"
- }
- return joined
- }
- func opsScheduledReportEmailVariables(report *opsScheduledReport, now time.Time) map[string]string {
- end := now.UTC()
- start := end
- name := "Ops report"
- reportType := "scheduled_report"
- if report != nil {
- if strings.TrimSpace(report.Name) != "" {
- name = strings.TrimSpace(report.Name)
- }
- if strings.TrimSpace(report.ReportType) != "" {
- reportType = strings.TrimSpace(report.ReportType)
- }
- if report.TimeRange > 0 {
- start = end.Add(-report.TimeRange)
- }
- }
- return map[string]string{
- "report_name": name,
- "report_type": reportType,
- "report_start_time": start.Format(time.RFC3339),
- "report_end_time": end.Format(time.RFC3339),
- }
- }
- 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 opsScheduledReportContent{}, fmt.Errorf("service not initialized")
- }
- if report.TimeRange <= 0 {
- return opsScheduledReportContent{}, fmt.Errorf("invalid time range")
- }
- end := now.UTC()
- start := end.Add(-report.TimeRange)
- switch strings.TrimSpace(report.ReportType) {
- case "daily_summary", "weekly_summary":
- overview, err := s.opsService.GetDashboardOverview(ctx, &OpsDashboardFilter{
- StartTime: start,
- EndTime: end,
- Platform: "",
- GroupID: nil,
- QueryMode: OpsQueryModeAuto,
- })
- if err != nil {
- // If pre-aggregation isn't ready but the report is requested, fall back to raw.
- if strings.TrimSpace(report.ReportType) == "daily_summary" || strings.TrimSpace(report.ReportType) == "weekly_summary" {
- overview, err = s.opsService.GetDashboardOverview(ctx, &OpsDashboardFilter{
- StartTime: start,
- EndTime: end,
- Platform: "",
- GroupID: nil,
- QueryMode: OpsQueryModeRaw,
- })
- }
- if err != nil {
- return opsScheduledReportContent{}, err
- }
- }
- 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
- endTime := end
- filter := &OpsErrorLogFilter{
- StartTime: &startTime,
- EndTime: &endTime,
- Page: 1,
- PageSize: 100,
- }
- out, err := s.opsService.GetErrorLogs(ctx, filter)
- if err != nil {
- return opsScheduledReportContent{}, err
- }
- if report.ErrorDigestMinCount > 0 && out != nil && out.Total < report.ErrorDigestMinCount {
- return opsScheduledReportContent{}, 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 opsScheduledReportContent{}, err
- }
- _ = report.AccountHealthErrorRateThreshold // reserved for future per-account error rate report
- return opsScheduledReportContent{html: buildOpsAccountHealthEmailHTML(report.Name, start, end, avail)}, nil
- default:
- return opsScheduledReportContent{}, fmt.Errorf("unknown report type: %s", report.ReportType)
- }
- }
- 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))
- }
- latP50 := "-"
- latP99 := "-"
- if overview.Duration.P50 != nil {
- latP50 = fmt.Sprintf("%dms", *overview.Duration.P50)
- }
- if overview.Duration.P99 != nil {
- latP99 = fmt.Sprintf("%dms", *overview.Duration.P99)
- }
- ttftP50 := "-"
- ttftP99 := "-"
- if overview.TTFT.P50 != nil {
- ttftP50 = fmt.Sprintf("%dms", *overview.TTFT.P50)
- }
- if overview.TTFT.P99 != nil {
- ttftP99 = fmt.Sprintf("%dms", *overview.TTFT.P99)
- }
- return fmt.Sprintf(`
- <h2>%s</h2>
- <p><b>Period</b>: %s ~ %s (UTC)</p>
- <ul>
- <li><b>Total Requests</b>: %d</li>
- <li><b>Success</b>: %d</li>
- <li><b>Errors (SLA)</b>: %d</li>
- <li><b>Business Limited</b>: %d</li>
- <li><b>SLA</b>: %.2f%%</li>
- <li><b>Error Rate</b>: %.2f%%</li>
- <li><b>Upstream Error Rate (excl 429/529)</b>: %.2f%%</li>
- <li><b>Upstream Errors</b>: excl429/529=%d, 429=%d, 529=%d</li>
- <li><b>Latency</b>: p50=%s, p99=%s</li>
- <li><b>TTFT</b>: p50=%s, p99=%s</li>
- <li><b>Tokens</b>: %d</li>
- <li><b>QPS</b>: current=%.1f, peak=%.1f, avg=%.1f</li>
- <li><b>TPS</b>: current=%.1f, peak=%.1f, avg=%.1f</li>
- </ul>
- `,
- htmlEscape(strings.TrimSpace(title)),
- htmlEscape(start.UTC().Format(time.RFC3339)),
- htmlEscape(end.UTC().Format(time.RFC3339)),
- overview.RequestCountTotal,
- overview.SuccessCount,
- overview.ErrorCountSLA,
- overview.BusinessLimitedCount,
- overview.SLA*100,
- overview.ErrorRate*100,
- overview.UpstreamErrorRate*100,
- overview.UpstreamErrorCountExcl429529,
- overview.Upstream429Count,
- overview.Upstream529Count,
- htmlEscape(latP50),
- htmlEscape(latP99),
- htmlEscape(ttftP50),
- htmlEscape(ttftP99),
- overview.TokenConsumed,
- overview.QPS.Current,
- overview.QPS.Peak,
- overview.QPS.Avg,
- overview.TPS.Current,
- overview.TPS.Peak,
- overview.TPS.Avg,
- )
- }
- func buildOpsErrorDigestEmailHTML(title string, start, end time.Time, list *OpsErrorLogList) string {
- total := 0
- recent := []*OpsErrorLog{}
- if list != nil {
- total = list.Total
- recent = list.Errors
- }
- if len(recent) > 10 {
- recent = recent[:10]
- }
- rows := ""
- for _, item := range recent {
- if item == nil {
- continue
- }
- rows += fmt.Sprintf(
- "<tr><td>%s</td><td>%s</td><td>%d</td><td>%s</td></tr>",
- htmlEscape(item.CreatedAt.UTC().Format(time.RFC3339)),
- htmlEscape(item.Platform),
- item.StatusCode,
- htmlEscape(truncateString(item.Message, 180)),
- )
- }
- if rows == "" {
- rows = "<tr><td colspan=\"4\">No recent errors.</td></tr>"
- }
- return fmt.Sprintf(`
- <h2>%s</h2>
- <p><b>Period</b>: %s ~ %s (UTC)</p>
- <p><b>Total Errors</b>: %d</p>
- <h3>Recent</h3>
- <table border="1" cellpadding="6" cellspacing="0" style="border-collapse:collapse;">
- <thead><tr><th>Time</th><th>Platform</th><th>Status</th><th>Message</th></tr></thead>
- <tbody>%s</tbody>
- </table>
- `,
- htmlEscape(strings.TrimSpace(title)),
- htmlEscape(start.UTC().Format(time.RFC3339)),
- htmlEscape(end.UTC().Format(time.RFC3339)),
- total,
- rows,
- )
- }
- func buildOpsAccountHealthEmailHTML(title string, start, end time.Time, avail *OpsAccountAvailability) string {
- total := 0
- available := 0
- rateLimited := 0
- hasError := 0
- if avail != nil && avail.Accounts != nil {
- for _, a := range avail.Accounts {
- if a == nil {
- continue
- }
- total++
- if a.IsAvailable {
- available++
- }
- if a.IsRateLimited {
- rateLimited++
- }
- if a.HasError {
- hasError++
- }
- }
- }
- return fmt.Sprintf(`
- <h2>%s</h2>
- <p><b>Period</b>: %s ~ %s (UTC)</p>
- <ul>
- <li><b>Total Accounts</b>: %d</li>
- <li><b>Available</b>: %d</li>
- <li><b>Rate Limited</b>: %d</li>
- <li><b>Error</b>: %d</li>
- </ul>
- <p>Note: This report currently reflects account availability status only.</p>
- `,
- htmlEscape(strings.TrimSpace(title)),
- htmlEscape(start.UTC().Format(time.RFC3339)),
- htmlEscape(end.UTC().Format(time.RFC3339)),
- total,
- available,
- rateLimited,
- hasError,
- )
- }
- func (s *OpsScheduledReportService) tryAcquireLeaderLock(ctx context.Context) (func(), bool) {
- if s == nil || !s.distributedLockOn {
- return nil, true
- }
- if s.redisClient == nil {
- s.warnNoRedisOnce.Do(func() {
- log.Printf("[OpsScheduledReport] redis not configured; running without distributed lock")
- })
- return nil, true
- }
- if ctx == nil {
- ctx = context.Background()
- }
- key := opsScheduledReportLeaderLockKeyDefault
- ttl := opsScheduledReportLeaderLockTTLDefault
- if strings.TrimSpace(key) == "" {
- key = "ops:scheduled_reports:leader"
- }
- if ttl <= 0 {
- ttl = 5 * time.Minute
- }
- ok, err := s.redisClient.SetNX(ctx, key, s.instanceID, ttl).Result()
- if err != nil {
- // Prefer fail-closed to avoid duplicate report sends when Redis is flaky.
- log.Printf("[OpsScheduledReport] leader lock SetNX failed; skipping this cycle: %v", err)
- return nil, false
- }
- if !ok {
- return nil, false
- }
- return func() {
- _, _ = opsScheduledReportReleaseScript.Run(ctx, s.redisClient, []string{key}, s.instanceID).Result()
- }, true
- }
- func (s *OpsScheduledReportService) getLastRunAt(ctx context.Context, reportType string) time.Time {
- if s == nil || s.redisClient == nil {
- return time.Time{}
- }
- kind := strings.TrimSpace(reportType)
- if kind == "" {
- return time.Time{}
- }
- key := opsScheduledReportLastRunKeyPrefix + kind
- raw, err := s.redisClient.Get(ctx, key).Result()
- if err != nil || strings.TrimSpace(raw) == "" {
- return time.Time{}
- }
- sec, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
- if err != nil || sec <= 0 {
- return time.Time{}
- }
- last := time.Unix(sec, 0)
- // Cron schedules are interpreted in the configured timezone (s.loc). Ensure the base time
- // passed into cron.Next() uses the same location; otherwise the job will drift by timezone
- // offset (e.g. Asia/Shanghai default would run 8h later after the first execution).
- if s.loc != nil {
- return last.In(s.loc)
- }
- return last.UTC()
- }
- func (s *OpsScheduledReportService) setLastRunAt(ctx context.Context, reportType string, t time.Time) {
- if s == nil || s.redisClient == nil {
- return
- }
- kind := strings.TrimSpace(reportType)
- if kind == "" {
- return
- }
- if t.IsZero() {
- t = time.Now().UTC()
- }
- key := opsScheduledReportLastRunKeyPrefix + kind
- _ = s.redisClient.Set(ctx, key, strconv.FormatInt(t.UTC().Unix(), 10), 14*24*time.Hour).Err()
- }
- func (s *OpsScheduledReportService) recordHeartbeatSuccess(runAt time.Time, duration time.Duration, result string) {
- if s == nil || s.opsService == nil || s.opsService.opsRepo == nil {
- return
- }
- now := time.Now().UTC()
- durMs := duration.Milliseconds()
- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
- defer cancel()
- msg := strings.TrimSpace(result)
- if msg == "" {
- msg = "ok"
- }
- msg = truncateString(msg, 2048)
- _ = s.opsService.opsRepo.UpsertJobHeartbeat(ctx, &OpsUpsertJobHeartbeatInput{
- JobName: opsScheduledReportJobName,
- LastRunAt: &runAt,
- LastSuccessAt: &now,
- LastDurationMs: &durMs,
- LastResult: &msg,
- })
- }
- func (s *OpsScheduledReportService) recordHeartbeatError(runAt time.Time, duration time.Duration, err error) {
- if s == nil || s.opsService == nil || s.opsService.opsRepo == nil || err == nil {
- return
- }
- now := time.Now().UTC()
- durMs := duration.Milliseconds()
- msg := truncateString(err.Error(), 2048)
- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
- defer cancel()
- _ = s.opsService.opsRepo.UpsertJobHeartbeat(ctx, &OpsUpsertJobHeartbeatInput{
- JobName: opsScheduledReportJobName,
- LastRunAt: &runAt,
- LastErrorAt: &now,
- LastError: &msg,
- LastDurationMs: &durMs,
- })
- }
- func normalizeEmails(in []string) []string {
- if len(in) == 0 {
- return nil
- }
- seen := make(map[string]struct{}, len(in))
- out := make([]string, 0, len(in))
- for _, raw := range in {
- addr := strings.ToLower(strings.TrimSpace(raw))
- if addr == "" {
- continue
- }
- if _, ok := seen[addr]; ok {
- continue
- }
- seen[addr] = struct{}{}
- out = append(out, addr)
- }
- return out
- }
|