Files
junhong_cmp_fiber/internal/infrastructure/notification/registry.go
break 73f5125d3d
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m26s
七月迭代短暂完结,还有很多后端的关键东西没有弄,这是一版赶时间做的东西
2026-07-25 17:06:58 +08:00

177 lines
6.6 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: {},
},
},
constants.NotificationTypeExchangeShippingCreated: {
Type: constants.NotificationTypeExchangeShippingCreated, Category: constants.NotificationCategorySystem,
Severity: constants.NotificationSeverityInfo,
TitleTemplate: "换货申请待处理",
BodyTemplate: "您有一条物流换货申请待处理,请及时填写收货信息。",
TemplateFields: map[string]struct{}{},
RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindPersonalCustomer: {}},
AllowedRefTypes: map[string]struct{}{
constants.NotificationRefTypeAsset: {},
},
},
constants.NotificationTypeAgentMainWalletLowBalance: {
Type: constants.NotificationTypeAgentMainWalletLowBalance, Category: constants.NotificationCategorySystem,
Severity: constants.NotificationSeverityWarning,
TitleTemplate: "店铺主钱包余额不足",
BodyTemplate: "店铺主钱包余额低于 100 元,请及时关注。",
TemplateFields: map[string]struct{}{},
RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindAccount: {}},
AllowedRefTypes: map[string]struct{}{
constants.NotificationRefTypeShopFund: {},
},
},
}}
}
// 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
}