收口审计治理与套餐任务进展
Constraint: 在线热修前必须保存当前迭代分支全部有效代码进展 Confidence: medium Scope-risk: broad Directive: 后续修改需保持审计事件与业务事务边界一致 Tested: git diff --cached --check Not-tested: 未运行全量测试,提交用于切换分支前保存既有工作
This commit is contained in:
@@ -2,28 +2,39 @@ package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"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
|
||||
db *gorm.DB
|
||||
logger *zap.Logger
|
||||
auditWriter *audit.Writer
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewCleanupService 创建通知保留清理服务。
|
||||
func NewCleanupService(db *gorm.DB, logger *zap.Logger) *CleanupService {
|
||||
func NewCleanupService(db *gorm.DB, logger *zap.Logger, auditWriters ...*audit.Writer) *CleanupService {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &CleanupService{db: db, logger: logger, now: time.Now}
|
||||
service := &CleanupService{db: db, logger: logger, now: time.Now}
|
||||
if len(auditWriters) > 0 {
|
||||
service.auditWriter = auditWriters[0]
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
// Run 按类别、创建时间和稳定主键执行有界分批清理。
|
||||
@@ -49,24 +60,74 @@ func (s *CleanupService) Run(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (s *CleanupService) cleanupCategory(ctx context.Context, category string, cutoff time.Time) (int64, error) {
|
||||
if s.auditWriter == nil {
|
||||
return 0, errors.New(errors.CodeInvalidStatus, "通知清理统一审计接缝未配置")
|
||||
}
|
||||
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, "清理站内通知失败")
|
||||
var deleted []*model.Notification
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("category = ? AND created_at < ?", category, cutoff).
|
||||
Order("created_at ASC, id ASC").Limit(constants.NotificationCleanupBatchSize).Find(&deleted).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询待清理站内通知失败")
|
||||
}
|
||||
if len(deleted) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]uint, 0, len(deleted))
|
||||
for _, notification := range deleted {
|
||||
ids = append(ids, notification.ID)
|
||||
}
|
||||
result := tx.WithContext(ctx).Where("id IN ?", ids).Delete(&model.Notification{})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "清理站内通知失败")
|
||||
}
|
||||
if result.RowsAffected != int64(len(deleted)) {
|
||||
return errors.New(errors.CodeInvalidStatus, "通知清理数量发生并发变化")
|
||||
}
|
||||
return s.appendCleanupAudit(ctx, tx, category, cutoff, deleted)
|
||||
}); err != nil {
|
||||
return total, err
|
||||
}
|
||||
total += result.RowsAffected
|
||||
if result.RowsAffected < constants.NotificationCleanupBatchSize {
|
||||
total += int64(len(deleted))
|
||||
if len(deleted) < constants.NotificationCleanupBatchSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (s *CleanupService) appendCleanupAudit(ctx context.Context, tx *gorm.DB, category string, cutoff time.Time, notifications []*model.Notification) error {
|
||||
firstID, lastID := notifications[0].ID, notifications[len(notifications)-1].ID
|
||||
key := fmt.Sprintf("%s:%s:%d:%d", category, cutoff.UTC().Format(time.RFC3339), firstID, lastID)
|
||||
rootID := "evt_" + uuid.NewSHA1(uuid.NameSpaceOID, []byte("notification-cleanup:"+key)).String()
|
||||
children := make([]audit.AppendInput, 0, len(notifications))
|
||||
for _, notification := range notifications {
|
||||
children = append(children, audit.AppendInput{
|
||||
EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "cleanup"),
|
||||
ActionCode: constants.AuditActionNotificationCleanupItem, Summary: "清理单条过期通知",
|
||||
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
|
||||
Resources: []audit.ResourceInput{audit.NotificationResource(notification,
|
||||
constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget,
|
||||
map[string]any{"exists": true}, map[string]any{"deleted": true})},
|
||||
})
|
||||
}
|
||||
return s.auditWriter.AppendBatch(ctx, tx, audit.BatchInput{
|
||||
Root: audit.AppendInput{
|
||||
EventID: rootID, ActionCode: constants.AuditActionNotificationCleanup, Summary: "清理过期通知",
|
||||
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
|
||||
BatchTotal: len(notifications), SuccessCount: len(notifications),
|
||||
Resources: []audit.ResourceInput{{
|
||||
Type: constants.AuditResourceNotificationCleanupBatch, Key: rootID, DisplayName: "通知清理批次",
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBatchTask,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"category": category, "cutoff": cutoff.UTC(), "deleted_count": len(notifications),
|
||||
"first_id": firstID, "last_id": lastID,
|
||||
}, SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
}},
|
||||
Metadata: map[string]any{"first_id": strconv.FormatUint(uint64(firstID), 10), "last_id": strconv.FormatUint(uint64(lastID), 10)},
|
||||
},
|
||||
Children: children,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ func NewRepository(db *gorm.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
// DB 返回通知 Repository 使用的数据库连接。
|
||||
func (r *Repository) DB() *gorm.DB { return r.db }
|
||||
|
||||
// WithTx 返回绑定指定事务的通知 Repository。
|
||||
func (r *Repository) WithTx(tx *gorm.DB) *Repository { return &Repository{db: tx} }
|
||||
|
||||
// CreateIdempotent 以事件、接收人类型和接收人 ID 唯一键幂等写入通知。
|
||||
func (r *Repository) CreateIdempotent(ctx context.Context, notification *model.Notification) (bool, error) {
|
||||
result := r.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
|
||||
Reference in New Issue
Block a user