All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m30s
117 lines
4.0 KiB
Go
117 lines
4.0 KiB
Go
// Package retention 提供在线审计查询的统一留存边界。
|
|
package retention
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
|
)
|
|
|
|
// Source 表示受在线留存边界约束的数据源。
|
|
type Source string
|
|
|
|
const (
|
|
// SourceAudit 表示统一审计事件。
|
|
SourceAudit Source = constants.AuditArchiveSource
|
|
// SourceIntegration 表示外部交互日志。
|
|
SourceIntegration Source = constants.IntegrationArchiveSource
|
|
)
|
|
|
|
// Info 是查询响应公开的在线留存边界。
|
|
type Info struct {
|
|
OnlineFrom time.Time `json:"online_from" description:"当前可在线查询的最早时间"`
|
|
ArchivedBefore *time.Time `json:"archived_before" description:"早于该时间的数据已归档;尚未清理时为空"`
|
|
Timezone string `json:"timezone" description:"留存自然日时区"`
|
|
}
|
|
|
|
// Load 从归档账本读取已完成物理清理的数据边界。
|
|
func Load(ctx context.Context, db *gorm.DB, sources ...Source) (Info, error) {
|
|
location, err := time.LoadLocation(constants.AuditArchiveTimezone)
|
|
if err != nil {
|
|
return Info{}, errors.Wrap(errors.CodeInternalError, err, "加载审计留存时区失败")
|
|
}
|
|
now := time.Now().In(location)
|
|
info := Info{OnlineFrom: time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, location), Timezone: constants.AuditArchiveTimezone}
|
|
for _, source := range sources {
|
|
boundary, cleaned, err := sourceBoundary(ctx, db, source, location)
|
|
if err != nil {
|
|
return Info{}, err
|
|
}
|
|
if boundary.Before(info.OnlineFrom) && info.ArchivedBefore == nil {
|
|
info.OnlineFrom = boundary
|
|
}
|
|
if cleaned && (info.ArchivedBefore == nil || boundary.After(*info.ArchivedBefore)) {
|
|
value := boundary
|
|
info.ArchivedBefore = &value
|
|
info.OnlineFrom = boundary
|
|
}
|
|
}
|
|
return info, nil
|
|
}
|
|
|
|
func sourceBoundary(ctx context.Context, db *gorm.DB, source Source, location *time.Location) (time.Time, bool, error) {
|
|
var cleanedEnd sql.NullTime
|
|
if err := db.WithContext(ctx).Model(&model.LogArchiveRun{}).
|
|
Where("source = ? AND cleaned_at IS NOT NULL", source).
|
|
Select("MAX(range_end)").Scan(&cleanedEnd).Error; err != nil {
|
|
return time.Time{}, false, errors.Wrap(errors.CodeDatabaseError, err, "查询审计留存清理边界失败")
|
|
}
|
|
if cleanedEnd.Valid {
|
|
return cleanedEnd.Time.In(location), true, nil
|
|
}
|
|
|
|
var earliest sql.NullTime
|
|
table, column := "tb_audit_event", "occurred_at"
|
|
if source == SourceIntegration {
|
|
table, column = "tb_integration_log", "created_at"
|
|
}
|
|
if err := db.WithContext(ctx).Table(table).Select("MIN(" + column + ")").Scan(&earliest).Error; err != nil {
|
|
return time.Time{}, false, errors.Wrap(errors.CodeDatabaseError, err, "查询审计在线数据边界失败")
|
|
}
|
|
if earliest.Valid {
|
|
return earliest.Time.In(location), false, nil
|
|
}
|
|
now := time.Now().In(location)
|
|
return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, location), false, nil
|
|
}
|
|
|
|
// NormalizeRange 将缺省范围收敛到在线窗口,并拒绝归档或跨边界查询。
|
|
func NormalizeRange(info Info, from, to *time.Time, maxRange ...time.Duration) (*time.Time, *time.Time, error) {
|
|
explicitFrom := from != nil
|
|
if from != nil && info.ArchivedBefore != nil && from.Before(info.OnlineFrom) {
|
|
return nil, nil, archivedError(info)
|
|
}
|
|
if to != nil && info.ArchivedBefore != nil && !to.After(info.OnlineFrom) {
|
|
return nil, nil, archivedError(info)
|
|
}
|
|
if from == nil {
|
|
value := info.OnlineFrom
|
|
from = &value
|
|
}
|
|
if to == nil {
|
|
value := time.Now()
|
|
to = &value
|
|
}
|
|
if len(maxRange) > 0 && maxRange[0] > 0 && to.Sub(*from) > maxRange[0] {
|
|
if explicitFrom {
|
|
return nil, nil, errors.New(errors.CodeInvalidParam)
|
|
}
|
|
value := to.Add(-maxRange[0])
|
|
from = &value
|
|
}
|
|
if !from.Before(*to) {
|
|
return nil, nil, errors.New(errors.CodeInvalidParam)
|
|
}
|
|
return from, to, nil
|
|
}
|
|
|
|
func archivedError(info Info) error {
|
|
return errors.NewWithData(errors.CodeAuditDataArchived, map[string]any{"retention": info})
|
|
}
|