155 lines
5.4 KiB
Go
155 lines
5.4 KiB
Go
// 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
|
||
}
|