From 88346b4d537bc61c047b0cb7939db58d943119e2 Mon Sep 17 00:00:00 2001 From: benjamin Date: Wed, 20 May 2026 11:05:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=B7=BB=E5=8A=A0=E9=82=AE?= =?UTF-8?q?=E4=BB=B6=E6=A8=A1=E6=9D=BF=E7=AE=A1=E7=90=86=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../internal/handler/admin/setting_handler.go | 178 +++++++++++++++++- backend/internal/handler/dto/settings.go | 56 ++++++ backend/internal/server/routes/admin.go | 5 + 3 files changed, 232 insertions(+), 7 deletions(-) diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index eaaae471..ba00b12d 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -56,13 +56,14 @@ func firstNonEmpty(values ...string) string { // SettingHandler 系统设置处理器 type SettingHandler struct { - settingService *service.SettingService - emailService *service.EmailService - turnstileService *service.TurnstileService - opsService *service.OpsService - paymentConfigService *service.PaymentConfigService - paymentService *service.PaymentService - userAttributeService *service.UserAttributeService + settingService *service.SettingService + emailService *service.EmailService + turnstileService *service.TurnstileService + opsService *service.OpsService + paymentConfigService *service.PaymentConfigService + paymentService *service.PaymentService + userAttributeService *service.UserAttributeService + notificationEmailService *service.NotificationEmailService } // NewSettingHandler 创建系统设置处理器 @@ -78,6 +79,12 @@ func NewSettingHandler(settingService *service.SettingService, emailService *ser } } +// SetNotificationEmailService attaches the notification template service without changing +// the constructor signature used by existing unit tests. +func (h *SettingHandler) SetNotificationEmailService(notificationEmailService *service.NotificationEmailService) { + h.notificationEmailService = notificationEmailService +} + // GetSettings 获取所有系统设置 // GET /api/v1/admin/settings func (h *SettingHandler) GetSettings(c *gin.Context) { @@ -3332,3 +3339,160 @@ func (h *SettingHandler) ensureUserAttributeDefinition(ctx context.Context, key, } slog.Info("dingtalk: created user attribute definition", "key", key, "name", name, "type", attrType) } + +// ListEmailTemplates returns all editable notification email templates. +// GET /api/v1/admin/settings/email-templates +func (h *SettingHandler) ListEmailTemplates(c *gin.Context) { + if h.notificationEmailService == nil { + response.InternalError(c, "notification email service is not configured") + return + } + events := h.notificationEmailService.ListEventInfos() + templates, err := h.notificationEmailService.ListTemplates(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, dto.EmailTemplateListResponse{ + Events: emailTemplateEventOptionsToDTO(events), + Locales: h.notificationEmailService.SupportedLocales(), + Templates: emailTemplateSummariesToDTO(templates), + Placeholders: emailTemplatePlaceholderUnion(events), + }) +} + +// GetEmailTemplate returns one editable notification email template. +// GET /api/v1/admin/settings/email-templates/:event/:locale +func (h *SettingHandler) GetEmailTemplate(c *gin.Context) { + if h.notificationEmailService == nil { + response.InternalError(c, "notification email service is not configured") + return + } + tmpl, err := h.notificationEmailService.GetTemplate(c.Request.Context(), c.Param("event"), c.Param("locale")) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Success(c, emailTemplateDetailToDTO(tmpl)) +} + +// UpdateEmailTemplate saves an override for one event/locale template. +// PUT /api/v1/admin/settings/email-templates/:event/:locale +func (h *SettingHandler) UpdateEmailTemplate(c *gin.Context) { + if h.notificationEmailService == nil { + response.InternalError(c, "notification email service is not configured") + return + } + var req dto.UpdateEmailTemplateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + tmpl, err := h.notificationEmailService.UpdateTemplate(c.Request.Context(), c.Param("event"), c.Param("locale"), req.Subject, req.HTML) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Success(c, emailTemplateDetailToDTO(tmpl)) +} + +// RestoreOfficialEmailTemplate removes an override and returns the built-in template. +// POST /api/v1/admin/settings/email-templates/:event/:locale/restore-official +func (h *SettingHandler) RestoreOfficialEmailTemplate(c *gin.Context) { + if h.notificationEmailService == nil { + response.InternalError(c, "notification email service is not configured") + return + } + tmpl, err := h.notificationEmailService.RestoreOfficialTemplate(c.Request.Context(), c.Param("event"), c.Param("locale")) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Success(c, emailTemplateDetailToDTO(tmpl)) +} + +// PreviewEmailTemplate renders a template with safe sample variables without saving it. +// POST /api/v1/admin/settings/email-templates/preview +func (h *SettingHandler) PreviewEmailTemplate(c *gin.Context) { + if h.notificationEmailService == nil { + response.InternalError(c, "notification email service is not configured") + return + } + var req dto.PreviewEmailTemplateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + preview, err := h.notificationEmailService.PreviewTemplate(c.Request.Context(), service.NotificationEmailPreviewInput{ + Event: req.Event, + Locale: req.Locale, + Subject: req.Subject, + HTML: req.HTML, + Variables: req.Variables, + }) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Success(c, dto.EmailTemplatePreviewResponse{Subject: preview.Subject, HTML: preview.HTML}) +} + +func emailTemplateEventOptionsToDTO(events []service.NotificationEmailEventInfo) []dto.EmailTemplateEventOption { + items := make([]dto.EmailTemplateEventOption, 0, len(events)) + for _, event := range events { + items = append(items, dto.EmailTemplateEventOption{ + Value: event.Event, + Label: event.Label, + Description: event.Description, + }) + } + return items +} + +func emailTemplateSummariesToDTO(templates []service.NotificationEmailTemplate) []dto.EmailTemplateSummary { + items := make([]dto.EmailTemplateSummary, 0, len(templates)) + for _, tmpl := range templates { + items = append(items, dto.EmailTemplateSummary{ + Event: tmpl.Event, + Locale: tmpl.Locale, + Subject: tmpl.Subject, + IsCustom: tmpl.IsCustom, + UpdatedAt: emailTemplateUpdatedAt(tmpl), + }) + } + return items +} + +func emailTemplateDetailToDTO(tmpl service.NotificationEmailTemplate) dto.EmailTemplateDetail { + return dto.EmailTemplateDetail{ + Event: tmpl.Event, + Locale: tmpl.Locale, + Subject: tmpl.Subject, + HTML: tmpl.HTML, + IsCustom: tmpl.IsCustom, + UpdatedAt: emailTemplateUpdatedAt(tmpl), + Placeholders: tmpl.Placeholders, + } +} + +func emailTemplateUpdatedAt(tmpl service.NotificationEmailTemplate) string { + if tmpl.UpdatedAt == nil { + return "" + } + return tmpl.UpdatedAt.Format("2006-01-02T15:04:05Z07:00") +} + +func emailTemplatePlaceholderUnion(events []service.NotificationEmailEventInfo) []string { + seen := make(map[string]struct{}) + placeholders := make([]string, 0) + for _, event := range events { + for _, placeholder := range event.Placeholders { + if _, ok := seen[placeholder]; ok { + continue + } + seen[placeholder] = struct{}{} + placeholders = append(placeholders, placeholder) + } + } + return placeholders +} diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index fb09faf7..7fdc87f7 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -374,6 +374,62 @@ type OpenAIFastPolicySettings struct { Rules []OpenAIFastPolicyRule `json:"rules"` } +// EmailTemplateEventOption describes an editable notification email event. +type EmailTemplateEventOption struct { + Value string `json:"value"` + Label string `json:"label,omitempty"` + Description string `json:"description,omitempty"` +} + +// EmailTemplateSummary is shown in the admin email template list. +type EmailTemplateSummary struct { + Event string `json:"event"` + Locale string `json:"locale"` + Subject string `json:"subject"` + IsCustom bool `json:"is_custom,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// EmailTemplateListResponse is returned by GET /admin/settings/email-templates. +type EmailTemplateListResponse struct { + Events []EmailTemplateEventOption `json:"events"` + Locales []string `json:"locales"` + Templates []EmailTemplateSummary `json:"templates,omitempty"` + Placeholders []string `json:"placeholders,omitempty"` +} + +// EmailTemplateDetail is returned for a specific event/locale template. +type EmailTemplateDetail struct { + Event string `json:"event"` + Locale string `json:"locale"` + Subject string `json:"subject"` + HTML string `json:"html"` + IsCustom bool `json:"is_custom,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Placeholders []string `json:"placeholders,omitempty"` +} + +// UpdateEmailTemplateRequest updates a template override. +type UpdateEmailTemplateRequest struct { + Subject string `json:"subject"` + HTML string `json:"html"` +} + +// PreviewEmailTemplateRequest previews a template without saving it. +type PreviewEmailTemplateRequest struct { + Event string `json:"event"` + Locale string `json:"locale"` + Subject string `json:"subject"` + HTML string `json:"html"` + Variables map[string]string `json:"variables,omitempty"` +} + +// EmailTemplatePreviewResponse is the rendered preview payload. +type EmailTemplatePreviewResponse struct { + Subject string `json:"subject"` + HTML string `json:"html"` +} + // ParseCustomMenuItems parses a JSON string into a slice of CustomMenuItem. // Returns empty slice on empty/invalid input. func ParseCustomMenuItems(raw string) []CustomMenuItem { diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 92e2f5b6..717c5e49 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -421,6 +421,11 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) { adminSettings.PUT("", h.Admin.Setting.UpdateSettings) adminSettings.POST("/test-smtp", h.Admin.Setting.TestSMTPConnection) adminSettings.POST("/send-test-email", h.Admin.Setting.SendTestEmail) + adminSettings.GET("/email-templates", h.Admin.Setting.ListEmailTemplates) + adminSettings.POST("/email-template-preview", h.Admin.Setting.PreviewEmailTemplate) + adminSettings.GET("/email-templates/:event/:locale", h.Admin.Setting.GetEmailTemplate) + adminSettings.PUT("/email-templates/:event/:locale", h.Admin.Setting.UpdateEmailTemplate) + adminSettings.POST("/email-templates/:event/:locale/restore-official", h.Admin.Setting.RestoreOfficialEmailTemplate) // Admin API Key 管理 adminSettings.GET("/admin-api-key", h.Admin.Setting.GetAdminAPIKey) adminSettings.POST("/admin-api-key/regenerate", h.Admin.Setting.RegenerateAdminAPIKey)