Files
junhong_cmp_fiber/internal/infrastructure/notification/registry.go
2026-07-24 16:07:18 +08:00

155 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package notification 提供站内通知模板注册、渲染和 PostgreSQL 持久化 Adapter。
package notification
import (
"bytes"
"errors"
"regexp"
"strings"
"text/template"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
var (
htmlTagPattern = regexp.MustCompile(`(?i)<\s*/?\s*[a-z][^>]*>`)
sensitiveTextPattern = regexp.MustCompile(`(?i)(password|operation_password|token|secret|credential|media_id|密码|令牌|密钥)\s*[:=]\s*\S+`)
longURLPattern = regexp.MustCompile(`(?i)https?://\S+`)
)
// Definition 定义一个受控通知类型的类别、级别、模板和允许资源引用。
type Definition struct {
Type string
Category string
Severity string
TitleTemplate string
BodyTemplate string
TemplateFields map[string]struct{}
RecipientKinds map[string]struct{}
AllowedRefTypes map[string]struct{}
}
// Rendered 是模板渲染后的纯文本快照。
type Rendered struct {
Category string
Type string
Severity string
Title string
Body string
}
// Registry 保存代码内受控通知类型注册关系。
type Registry struct {
definitions map[string]Definition
}
// NewRegistry 创建内置受控通知类型注册表。
func NewRegistry() *Registry {
return &Registry{definitions: map[string]Definition{
constants.NotificationTypeSystemNotice: {
Type: constants.NotificationTypeSystemNotice, Category: constants.NotificationCategorySystem,
Severity: constants.NotificationSeverityInfo,
TitleTemplate: "系统通知",
BodyTemplate: "有一项系统事项需要处理,请进入对应页面查看。",
TemplateFields: map[string]struct{}{},
RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindAccount: {}},
AllowedRefTypes: map[string]struct{}{
constants.NotificationRefTypeSystemConfig: {},
constants.NotificationRefTypeIntegrationLog: {},
},
},
constants.NotificationTypePackageExpiring: {
Type: constants.NotificationTypePackageExpiring, Category: constants.NotificationCategoryExpiry,
Severity: constants.NotificationSeverityWarning,
TitleTemplate: "套餐即将到期",
BodyTemplate: "您的套餐即将到期,请及时查看并处理。",
TemplateFields: map[string]struct{}{},
RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindPersonalCustomer: {}},
AllowedRefTypes: map[string]struct{}{
constants.NotificationRefTypePackage: {},
constants.NotificationRefTypeAsset: {},
},
},
}}
}
// Render 校验注册类型、模板字段、资源引用与敏感内容后生成纯文本快照。
func (r *Registry) Render(notificationType string, data map[string]string, refType, recipientKind string) (Rendered, error) {
definition, exists := r.definitions[notificationType]
if !exists {
return Rendered{}, errors.New("通知类型未注册")
}
if _, allowed := definition.RecipientKinds[recipientKind]; !allowed {
return Rendered{}, errors.New("通知类型未向当前接收人开放")
}
if err := validateTemplateData(data, definition.TemplateFields); err != nil {
return Rendered{}, err
}
if refType != "" {
if _, allowed := definition.AllowedRefTypes[refType]; !allowed {
return Rendered{}, errors.New("通知资源类型未注册")
}
}
title, err := executeTemplate("通知标题", definition.TitleTemplate, data)
if err != nil {
return Rendered{}, err
}
body, err := executeTemplate("通知正文", definition.BodyTemplate, data)
if err != nil {
return Rendered{}, err
}
if title == "" || body == "" {
return Rendered{}, errors.New("通知模板字段为空")
}
if len([]rune(title)) > constants.NotificationMaxTitleLength {
return Rendered{}, errors.New("通知标题超过长度限制")
}
if len([]rune(body)) > constants.NotificationMaxBodyLength {
return Rendered{}, errors.New("通知正文超过长度限制")
}
if htmlTagPattern.MatchString(title) || htmlTagPattern.MatchString(body) {
return Rendered{}, errors.New("通知正文禁止包含 HTML")
}
if sensitiveTextPattern.MatchString(title) || sensitiveTextPattern.MatchString(body) || longURLPattern.MatchString(title) || longURLPattern.MatchString(body) {
return Rendered{}, errors.New("通知正文包含禁止的敏感内容")
}
return Rendered{
Category: definition.Category, Type: definition.Type, Severity: definition.Severity,
Title: title, Body: body,
}, nil
}
func executeTemplate(name, source string, data map[string]string) (string, error) {
tmpl, err := template.New(name).Option("missingkey=error").Parse(source)
if err != nil {
return "", err
}
var buffer bytes.Buffer
if err := tmpl.Execute(&buffer, data); err != nil {
return "", errors.New("通知模板字段缺失")
}
return strings.TrimSpace(buffer.String()), nil
}
func validateTemplateData(data map[string]string, fields map[string]struct{}) error {
if data == nil && len(fields) > 0 {
return errors.New("通知模板数据不能为空")
}
for key := range data {
normalized := strings.ToLower(strings.TrimSpace(key))
switch normalized {
case "password", "operation_password", "token", "secret", "credential", "id_card", "callback", "media_id", "url":
return errors.New("通知模板数据包含禁止字段")
}
if _, allowed := fields[normalized]; !allowed {
return errors.New("通知模板数据包含未注册字段")
}
}
for field := range fields {
if strings.TrimSpace(data[field]) == "" {
return errors.New("通知模板必填字段缺失")
}
}
return nil
}