// 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 }