暂存一下,防止丢失
This commit is contained in:
72
internal/infrastructure/notification/cleanup.go
Normal file
72
internal/infrastructure/notification/cleanup.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// CleanupService 按通知类别的保留期限分批删除过期通知事实。
|
||||
type CleanupService struct {
|
||||
db *gorm.DB
|
||||
logger *zap.Logger
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewCleanupService 创建通知保留清理服务。
|
||||
func NewCleanupService(db *gorm.DB, logger *zap.Logger) *CleanupService {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &CleanupService{db: db, logger: logger, now: time.Now}
|
||||
}
|
||||
|
||||
// Run 按类别、创建时间和稳定主键执行有界分批清理。
|
||||
func (s *CleanupService) Run(ctx context.Context) error {
|
||||
policies := []struct {
|
||||
category string
|
||||
days int
|
||||
}{
|
||||
{constants.NotificationCategoryApproval, constants.NotificationApprovalRetentionDays},
|
||||
{constants.NotificationCategoryExpiry, constants.NotificationExpiryRetentionDays},
|
||||
{constants.NotificationCategorySync, constants.NotificationSyncRetentionDays},
|
||||
{constants.NotificationCategorySystem, constants.NotificationSystemRetentionDays},
|
||||
}
|
||||
for _, policy := range policies {
|
||||
deleted, err := s.cleanupCategory(ctx, policy.category, s.now().UTC().AddDate(0, 0, -policy.days))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Info("站内通知保留清理完成",
|
||||
zap.String("category", policy.category), zap.Int64("deleted_count", deleted))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CleanupService) cleanupCategory(ctx context.Context, category string, cutoff time.Time) (int64, error) {
|
||||
var total int64
|
||||
for batch := 0; batch < constants.NotificationCleanupMaxBatches; batch++ {
|
||||
result := s.db.WithContext(ctx).Exec(`WITH candidates AS (
|
||||
SELECT id FROM tb_notification
|
||||
WHERE category = ? AND created_at < ?
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT ?
|
||||
)
|
||||
DELETE FROM tb_notification AS notification
|
||||
USING candidates
|
||||
WHERE notification.id = candidates.id`, category, cutoff, constants.NotificationCleanupBatchSize)
|
||||
if result.Error != nil {
|
||||
return total, errors.Wrap(errors.CodeDatabaseError, result.Error, "清理站内通知失败")
|
||||
}
|
||||
total += result.RowsAffected
|
||||
if result.RowsAffected < constants.NotificationCleanupBatchSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
100
internal/infrastructure/notification/recipient_resolver.go
Normal file
100
internal/infrastructure/notification/recipient_resolver.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
shopapp "github.com/break/junhong_cmp_fiber/internal/application/shop"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// DynamicRecipientResolver 按稳定账号、平台角色或店铺解析当前可用后台账号。
|
||||
type DynamicRecipientResolver struct {
|
||||
db *gorm.DB
|
||||
shopResolver shopapp.NotificationRecipientResolver
|
||||
}
|
||||
|
||||
// NewDynamicRecipientResolver 创建后台通知动态接收人解析 Adapter。
|
||||
func NewDynamicRecipientResolver(db *gorm.DB, shopResolver shopapp.NotificationRecipientResolver) *DynamicRecipientResolver {
|
||||
return &DynamicRecipientResolver{db: db, shopResolver: shopResolver}
|
||||
}
|
||||
|
||||
// Resolve 根据受控目标类型返回去重且按账号 ID 排序的当前可用接收人。
|
||||
func (r *DynamicRecipientResolver) Resolve(ctx context.Context, targetKind string, targetID uint) ([]uint, error) {
|
||||
if targetID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
var ids []uint
|
||||
var err error
|
||||
switch targetKind {
|
||||
case constants.NotificationTargetKindAccount:
|
||||
ids, err = r.resolveAccount(ctx, targetID)
|
||||
case constants.NotificationTargetKindPlatformRole:
|
||||
ids, err = r.resolvePlatformRole(ctx, targetID)
|
||||
case constants.NotificationTargetKindShop:
|
||||
if r.shopResolver == nil {
|
||||
return nil, errors.New(errors.CodeInternalError, "店铺通知接收人解析器未配置")
|
||||
}
|
||||
ids, err = r.shopResolver.ResolveNotificationRecipients(ctx, targetID)
|
||||
default:
|
||||
return nil, errors.New(errors.CodeInvalidParam, "通知接收目标类型不受支持")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return uniqueSortedIDs(ids), nil
|
||||
}
|
||||
|
||||
func (r *DynamicRecipientResolver) resolveAccount(ctx context.Context, accountID uint) ([]uint, error) {
|
||||
var account model.Account
|
||||
err := r.db.WithContext(ctx).Select("id").
|
||||
Where("id = ? AND status = ?", accountID, constants.StatusEnabled).Take(&account).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return []uint{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通知申请人失败")
|
||||
}
|
||||
return []uint{account.ID}, nil
|
||||
}
|
||||
|
||||
func (r *DynamicRecipientResolver) resolvePlatformRole(ctx context.Context, roleID uint) ([]uint, error) {
|
||||
var accounts []model.Account
|
||||
err := r.db.WithContext(ctx).Model(&model.Account{}).
|
||||
Select("tb_account.id").
|
||||
Joins("JOIN tb_account_role ar ON ar.account_id = tb_account.id AND ar.deleted_at IS NULL AND ar.status = ?", constants.StatusEnabled).
|
||||
Joins("JOIN tb_role r ON r.id = ar.role_id AND r.deleted_at IS NULL AND r.status = ? AND r.role_type = ?",
|
||||
constants.StatusEnabled, constants.RoleTypePlatform).
|
||||
Where("ar.role_id = ? AND tb_account.status = ? AND tb_account.user_type IN ?",
|
||||
roleID, constants.StatusEnabled, []int{constants.UserTypeSuperAdmin, constants.UserTypePlatform}).
|
||||
Find(&accounts).Error
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询平台角色通知接收人失败")
|
||||
}
|
||||
ids := make([]uint, 0, len(accounts))
|
||||
for _, account := range accounts {
|
||||
ids = append(ids, account.ID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func uniqueSortedIDs(ids []uint) []uint {
|
||||
seen := make(map[uint]struct{}, len(ids))
|
||||
result := make([]uint, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
result = append(result, id)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i] < result[j] })
|
||||
return result
|
||||
}
|
||||
154
internal/infrastructure/notification/registry.go
Normal file
154
internal/infrastructure/notification/registry.go
Normal file
@@ -0,0 +1,154 @@
|
||||
// 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: {},
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
48
internal/infrastructure/notification/repository.go
Normal file
48
internal/infrastructure/notification/repository.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// Repository 提供站内通知幂等写入能力。
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewRepository 创建站内通知持久化 Adapter。
|
||||
func NewRepository(db *gorm.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
// CreateIdempotent 以事件、接收人类型和接收人 ID 唯一键幂等写入通知。
|
||||
func (r *Repository) CreateIdempotent(ctx context.Context, notification *model.Notification) (bool, error) {
|
||||
result := r.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "event_id"}, {Name: "recipient_kind"}, {Name: "recipient_id"}},
|
||||
DoNothing: true,
|
||||
}).Create(notification)
|
||||
return result.RowsAffected == 1, result.Error
|
||||
}
|
||||
|
||||
// IsActiveAccount 判断明确后台账号是否仍启用且未软删除。
|
||||
func (r *Repository) IsActiveAccount(ctx context.Context, accountID uint) (bool, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).Model(&model.Account{}).
|
||||
Where("id = ? AND status = ?", accountID, constants.StatusEnabled).
|
||||
Count(&count).Error
|
||||
return count == 1, err
|
||||
}
|
||||
|
||||
// IsActivePersonalCustomer 判断明确个人客户是否仍启用且未软删除。
|
||||
func (r *Repository) IsActivePersonalCustomer(ctx context.Context, customerID uint) (bool, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).Model(&model.PersonalCustomer{}).
|
||||
Where("id = ? AND status = ?", customerID, constants.StatusEnabled).
|
||||
Count(&count).Error
|
||||
return count == 1, err
|
||||
}
|
||||
Reference in New Issue
Block a user