All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m23s
新增 000225 迁移:运营弹窗配置表 tb_h5_popup_configuration(页面/范围/优先级/频率/受控动作/启停/有效期/版本) 与 tb_notification 可空 JSONB 列 popup_snapshot。 新增通知直建窄接口 DirectWriter.CreateOrGetPersonal:与 Outbox 消费共用 prepareDelivery 的渲染、 展示期与 CreateIdempotent 规则,冲突时回查返回既有行;同步扩展个人通知查询与已读两处类型白名单, 并按个人客户入口补齐投递审计来源。 新增 H5 候选与风险换卡:GET /api/c/v1/popup-candidates 先判风险资格(广电卡 + 风险停机 + 无活动物流换货单),命中只返回风险候选;未命中再按时间/启停/页面/店铺/设备类型/卡类型范围/频率 匹配运营配置。POST /api/c/v1/risk-exchanges/:asset_id/address 锁资产行后幂等创建待发货物流换货单, 首次地址锁定,不沿用资产级群发通知。 新增后台运营弹窗配置 CRUD 与启停(仅超级管理员与平台账号),更新递增版本并刷新最近更新时间, 标题与正文统一拒绝 URL 与前端路由,全部写操作记录操作者、前后值、版本与时间。 同步 OpenAPI(cmd/gendocs、cmd/api/docs.go、pkg/openapi/handlers.go)与参数校验中文提示共用实现。
227 lines
9.3 KiB
Go
227 lines
9.3 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: "套餐{{.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: {},
|
||
},
|
||
},
|
||
}}
|
||
}
|
||
|
||
// 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
|
||
}
|