新增 000228 迁移:规则表 tb_package_traffic_alert_rule(每套餐商品至多一条,无软删除,package_id 非部分唯一约束)、达量预警快照表 tb_package_traffic_alert(以主套餐使用记录 + 阈值快照为唯一键, 触发时冻结用量、额度、比例、阈值、到期时间、归属与资产快照),并为 tb_package_usage 新增扫描 范围部分索引 idx_package_usage_alert_scope;down 在预警表存在数据时阻断回滚。 新增规则维护接口 GET/POST/PUT /api/admin/package-traffic-alert-rules(仅超级管理员与平台账号): 创建校验套餐存在且真流量额度大于零,阈值为 1%~100% 的两位小数;修改只影响后续扫描,不回填也 不改写既有预警快照;全部写操作记录操作者、前后值与时间。 新增每日 06:00(Asia/Shanghai)扫描任务 package:traffic:alert:scan,与套餐临期扫描共用 data_cleanup 队列:按资产汇总当前有效套餐的真流量,分子取使用记录真已用量、分母取使用记录真总量快照,命中 主套餐规则阈值时在同一事务创建预警与可靠通知事件;重复执行以唯一冲突视为已处理,不重复投递, 不建停机锁、不调用运营商。 新增预警列表、详情与异步导出 GET /api/admin/package-traffic-alerts、GET /api/admin/package-traffic-alerts/:id、 POST /api/admin/package-traffic-alerts/export,列表与详情一律读冻结快照;新增通知类型 package.traffic.alert 与受控目标 package_traffic_alert_detail,目标解析仅对超级管理员与平台账号 返回可跳转,越权与不存在统一按资源不可见处理。 同步 OpenAPI(cmd/gendocs、cmd/api/docs.go、pkg/openapi/handlers.go)、审计动作与资源注册、上下文 健康检查证据;归档变更并同步 package-traffic-alert 主 Spec。
242 lines
10 KiB
Go
242 lines
10 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: {},
|
||
},
|
||
},
|
||
// 套餐真流量达量预警:类别沿用 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: {},
|
||
},
|
||
},
|
||
}}
|
||
}
|
||
|
||
// 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
|
||
}
|