Files
junhong_cmp_fiber/internal/infrastructure/notification/registry.go
break d52be16802
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m15s
feat(资产钱包自动续费): 新增全局配置、每日扫描续购与可靠复机
- 新增单行配置表 tb_asset_auto_renewal_config 与尝试记录表 tb_asset_auto_renewal_attempt(迁移 000229/000230)
- 每日按上海自然日扫描,窗口内以同一资产钱包可用余额续购当前主套餐,资金/订单/套餐/审计同一事务闭合
- 唯一键保证每资产每日至多一次尝试,占位中断由后续扫描收敛,当日不重试
- 四类失败原因向客户与店铺各投递每日至多一条站内通知,并注册通知类型与个人客户白名单
- 续费成功后按条件经 Outbox 可靠投递复机,新增恢复扫描只查询回填,不使用即发即弃调用
- 配置读写仅超级管理员与平台账号,保存记录操作者、前后值快照并登记统一审计
- tasks 7.1–7.15 全部验证通过(本机隔离 PostgreSQL/Redis,零外部渠道调用)
2026-09-17 16:39:26 +08:00

261 lines
11 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: "套餐{{.expiry_status}}",
BodyTemplate: "资产 {{.asset_identifier}} 的套餐{{.expiry_status}},到期日期:{{.expiry_date}}。",
TemplateFields: map[string]struct{}{"asset_identifier": {}, "expiry_status": {}, "expiry_date": {}},
RecipientKinds: map[string]struct{}{
constants.NotificationRecipientKindAccount: {},
constants.NotificationRecipientKindPersonalCustomer: {},
},
AllowedRefTypes: map[string]struct{}{
constants.NotificationRefTypePackage: {},
constants.NotificationRefTypeAsset: {},
constants.NotificationRefTypeExpiringAsset: {},
},
},
constants.NotificationTypeAgentRechargeCompleted: {
Type: constants.NotificationTypeAgentRechargeCompleted, Category: constants.NotificationCategorySystem,
Severity: constants.NotificationSeverityInfo,
TitleTemplate: "店铺充值已入账",
BodyTemplate: "店铺「{{.shop_name}}」充值 {{.amount}} 已成功入账。",
TemplateFields: map[string]struct{}{"shop_name": {}, "amount": {}},
RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindAccount: {}},
AllowedRefTypes: map[string]struct{}{
constants.NotificationRefTypeAgentRecharge: {},
},
},
constants.NotificationTypeRefundCompleted: {
Type: constants.NotificationTypeRefundCompleted, Category: constants.NotificationCategorySystem,
Severity: constants.NotificationSeverityInfo,
TitleTemplate: "店铺退款已完成",
BodyTemplate: "店铺退款已完成,请进入退款详情查看。",
TemplateFields: map[string]struct{}{},
RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindAccount: {}},
AllowedRefTypes: map[string]struct{}{
constants.NotificationRefTypeRefund: {},
},
},
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: {},
},
},
// 风险换卡弹窗:内容固定,不含任何配置信息;资源引用只指向旧资产。
constants.NotificationTypeH5PopupRiskExchange: {
Type: constants.NotificationTypeH5PopupRiskExchange, Category: constants.NotificationCategorySystem,
Severity: constants.NotificationSeverityWarning,
TitleTemplate: "换卡地址待填写",
BodyTemplate: "您的广电卡已被运营商风险停机,请填写收货地址以便寄送新卡。",
TemplateFields: map[string]struct{}{},
RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindPersonalCustomer: {}},
AllowedRefTypes: map[string]struct{}{
constants.NotificationRefTypeAsset: {},
},
},
// 运营弹窗:标题与正文由运营配置在投放时冻结,禁止 HTML 与 URL资源引用只指向当前资产。
constants.NotificationTypeH5PopupOperation: {
Type: constants.NotificationTypeH5PopupOperation, Category: constants.NotificationCategorySystem,
Severity: constants.NotificationSeverityInfo,
TitleTemplate: "{{.title}}",
BodyTemplate: "{{.content}}",
TemplateFields: map[string]struct{}{"title": {}, "content": {}},
RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindPersonalCustomer: {}},
AllowedRefTypes: map[string]struct{}{
constants.NotificationRefTypeAsset: {},
},
},
// 套餐真流量达量预警:类别沿用 expiry接收人只允许触发时冻结的平台业务员账号
// 资源引用只指向预警详情,正文不含任何 URL 或前端路由。
constants.NotificationTypePackageTrafficAlert: {
Type: constants.NotificationTypePackageTrafficAlert, Category: constants.NotificationCategoryExpiry,
Severity: constants.NotificationSeverityWarning,
TitleTemplate: "套餐真流量达量预警",
BodyTemplate: "资产 {{.asset_identifier}} 的套餐 {{.package_name}} 真流量已用 {{.usage_percent}}%,达到预警阈值 {{.threshold_percent}}%。",
TemplateFields: map[string]struct{}{
"asset_identifier": {}, "package_name": {}, "usage_percent": {}, "threshold_percent": {},
},
RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindAccount: {}},
AllowedRefTypes: map[string]struct{}{
constants.NotificationRefTypePackageTrafficAlert: {},
},
},
// 资产钱包自动续费失败:类别沿用 expiry接收人含店铺业务员账号与个人客户
// 资源引用只指向失败资产,正文不含任何 URL 或前端路由。
constants.NotificationTypeAssetAutoRenewalFailed: {
Type: constants.NotificationTypeAssetAutoRenewalFailed, Category: constants.NotificationCategoryExpiry,
Severity: constants.NotificationSeverityWarning,
TitleTemplate: "套餐自动续费未完成",
BodyTemplate: "资产 {{.asset_identifier}} 的套餐 {{.package_name}} 自动续费未完成,原因:{{.failure_reason}},最终到期日期:{{.expiry_date}}。",
TemplateFields: map[string]struct{}{
"asset_identifier": {}, "package_name": {}, "failure_reason": {}, "expiry_date": {},
},
RecipientKinds: map[string]struct{}{
constants.NotificationRecipientKindAccount: {},
constants.NotificationRecipientKindPersonalCustomer: {},
},
AllowedRefTypes: map[string]struct{}{
constants.NotificationRefTypeIotCard: {},
constants.NotificationRefTypeDevice: {},
},
},
}}
}
// 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
}