package auditarchive import ( "context" "crypto/sha256" "fmt" "io" "os" "strconv" "time" "github.com/bytedance/sonic" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/constants" ) const maxManifestBytes = 1024 * 1024 // RetentionAudit 保留系统审计 Writer 的输入兼容类型。 type RetentionAudit struct { EventID, Month, Summary, Result, ErrorSummary string RangeStart, RangeEnd time.Time EventCount, ResourceCount, IntegrationCount int64 ManifestKeys []string DurationMS int64 } // RetentionResult 是单日留存处理结果。 type RetentionResult struct { ArchiveDate string EventCount int64 ResourceCount int64 IntegrationCount int64 EstimatedBatches int64 ManifestKeys []string Duration time.Duration } // RetentionBlockedError 描述阻断日期推进的安全上下文。 type RetentionBlockedError struct { ArchiveDate string Source string Err error } func (e *RetentionBlockedError) Error() string { return e.ArchiveDate + " " + e.Source + ": " + e.Err.Error() } func (e *RetentionBlockedError) Unwrap() error { return e.Err } // RetainPendingDays 每次最多处理一个最早待处理的上海自然日,避免单任务跨历史日期长时间占用数据库。 func (s *Service) RetainPendingDays(ctx context.Context, cleanup bool) ([]RetentionResult, error) { if s.db == nil || s.store == nil { return nil, fmt.Errorf("日志日留存数据库或对象存储未配置") } start, end, err := s.pendingRetentionRange(ctx) if err != nil { return nil, err } if !start.Before(end) { return nil, nil } result, err := s.retainDate(ctx, start, cleanup) if err != nil { return nil, &RetentionBlockedError{ArchiveDate: start.Format(time.DateOnly), Source: constants.AuditArchiveSource, Err: err} } return []RetentionResult{result}, nil } // RetainDate 校验并按需清理指定已结束自然日,供受控演练使用。 func (s *Service) RetainDate(ctx context.Context, date time.Time, cleanup bool) (RetentionResult, error) { return s.retainDate(ctx, date, cleanup) } func (s *Service) pendingRetentionRange(ctx context.Context) (time.Time, time.Time, error) { today := time.Now().In(s.location) end := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, s.location) var earliest *time.Time for _, item := range []struct{ table, column, condition string }{ {"tb_audit_event", "created_at", ""}, {"tb_integration_log", "created_at", "result <> 'pending'"}, } { query := s.db.WithContext(ctx).Table(item.table).Select("MIN(" + item.column + ")") if item.condition != "" { query = query.Where(item.condition) } var value *time.Time if err := query.Scan(&value).Error; err != nil { return time.Time{}, time.Time{}, fmt.Errorf("查询日留存起点失败: %w", err) } if value != nil && (earliest == nil || value.Before(*earliest)) { local := value.In(s.location) earliest = &local } } if earliest == nil { return end, end, nil } start := time.Date(earliest.Year(), earliest.Month(), earliest.Day(), 0, 0, 0, 0, s.location) return start, end, nil } func (s *Service) retainDate(ctx context.Context, date time.Time, cleanup bool) (RetentionResult, error) { startedAt := time.Now() auditResult, err := s.retainAuditDate(ctx, date, cleanup) if err != nil { return RetentionResult{}, err } integrationResult, err := s.retainIntegrationDate(ctx, date, cleanup) if err != nil { return RetentionResult{}, err } result := RetentionResult{ArchiveDate: date.Format(time.DateOnly), EventCount: auditResult.EventCount, ResourceCount: auditResult.ResourceCount, IntegrationCount: integrationResult.IntegrationCount, ManifestKeys: append(auditResult.ManifestKeys, integrationResult.ManifestKeys...)} result.EstimatedBatches = estimatedRetentionBatches(result) result.Duration = time.Since(startedAt) return result, nil } func (s *Service) retainAuditDate(ctx context.Context, date time.Time, cleanup bool) (RetentionResult, error) { run, err := s.retentionRun(ctx, date, constants.AuditArchiveSource) if err != nil { return RetentionResult{}, err } if run == nil || run.Status != constants.ArchiveStatusSuccess { if err := s.ArchiveDate(ctx, date); err != nil { return RetentionResult{}, fmt.Errorf("Audit 归档失败: %w", err) } run, err = s.retentionRun(ctx, date, constants.AuditArchiveSource) if err != nil { return RetentionResult{}, err } } if err := s.validateAuditRetentionDay(ctx, date, run); err != nil { return RetentionResult{}, fmt.Errorf("Audit 完整性校验失败: %w", err) } result := RetentionResult{ArchiveDate: date.Format(time.DateOnly), EventCount: run.EventCount, ResourceCount: run.ResourceCount, ManifestKeys: []string{run.ManifestKey}} if cleanup { if err := s.cleanupAuditDate(ctx, date, run); err != nil { return result, err } } return result, nil } func (s *Service) retainIntegrationDate(ctx context.Context, date time.Time, cleanup bool) (RetentionResult, error) { run, err := s.retentionRun(ctx, date, constants.IntegrationArchiveSource) if err != nil { return RetentionResult{}, err } if run == nil || run.Status != constants.ArchiveStatusSuccess { if err := s.ArchiveIntegrationDate(ctx, date); err != nil { return RetentionResult{}, fmt.Errorf("Integration Log 归档失败: %w", err) } } if err := s.FinalizeIntegrationDate(ctx, date); err != nil { return RetentionResult{}, fmt.Errorf("Integration Log 最终归档失败: %w", err) } run, err = s.retentionRun(ctx, date, constants.IntegrationArchiveSource) if err != nil { return RetentionResult{}, err } if err := s.validateIntegrationRetentionDay(ctx, date, run); err != nil { return RetentionResult{}, fmt.Errorf("Integration Log 完整性校验失败: %w", err) } result := RetentionResult{ArchiveDate: date.Format(time.DateOnly), IntegrationCount: run.RecordCount, ManifestKeys: []string{run.ManifestKey}} if cleanup { if err := s.cleanupIntegrationDate(ctx, date, run); err != nil { return result, err } } return result, nil } func (s *Service) retentionRun(ctx context.Context, date time.Time, source string) (*model.LogArchiveRun, error) { var runs []model.LogArchiveRun if err := s.db.WithContext(ctx).Where("source = ? AND archive_date = ? AND instance_id = ?", source, date.Format(time.DateOnly), s.instanceID).Find(&runs).Error; err != nil { return nil, fmt.Errorf("读取日归档账本失败: %w", err) } if len(runs) == 0 { return nil, nil } return &runs[0], nil } func (s *Service) validateAuditRetentionDay(ctx context.Context, date time.Time, run *model.LogArchiveRun) error { if err := validateRunBase(run, date, constants.AuditArchiveSchemaVersion, false); err != nil { return err } if err := s.validateAuditManifest(ctx, run); err != nil { return err } events, resources, err := s.databaseCounts(ctx, run.RangeStart, run.RangeEnd) if err != nil { return err } return validateRemainingCounts(run, events, resources) } func (s *Service) validateIntegrationRetentionDay(ctx context.Context, date time.Time, run *model.LogArchiveRun) error { if err := validateRunBase(run, date, constants.IntegrationArchiveSchemaVersion, true); err != nil { return err } if err := s.validateIntegrationManifest(ctx, run); err != nil { return err } count, err := s.integrationRecordCount(ctx, run.RangeStart, run.RangeEnd) if err != nil { return err } terminalCount, err := s.integrationTerminalCount(ctx, run.RangeStart, run.RangeEnd) if err != nil { return err } if run.CleanedAt != nil && terminalCount != 0 { return fmt.Errorf("已标记清理完成但数据库仍有 %d 条终态记录", terminalCount) } if run.CleanupStartedAt != nil && count > run.RecordCount { return fmt.Errorf("续跑窗口记录数超过最终归档数量") } if terminalCount > 0 && run.CleanupStartedAt == nil { file, err := s.buildIntegrationArchiveFile(ctx, run.RangeStart, run.RangeEnd) if err != nil { return err } defer os.Remove(file.path) if file.recordCount != run.RecordCount || file.sha256 != run.SHA256 { return fmt.Errorf("数据库当前 Integration 内容与最终 revision 不一致") } } return nil } func validateRunBase(run *model.LogArchiveRun, date time.Time, schema string, final bool) error { if run.Status != constants.ArchiveStatusSuccess || run.SchemaVersion != schema { return fmt.Errorf("归档状态或 schema version 不符合清理要求") } if run.ArchiveDate.Format(time.DateOnly) != date.Format(time.DateOnly) || !run.RangeStart.Equal(date) || !run.RangeEnd.Equal(date.AddDate(0, 0, 1)) { return fmt.Errorf("归档日期或半开时间范围不一致") } if final && !run.IsFinal { return fmt.Errorf("Integration 最终 revision 尚未形成") } if run.ObjectKey == "" || run.ManifestKey == "" || run.SHA256 == "" { return fmt.Errorf("归档对象、manifest 或 SHA-256 缺失") } return nil } func validateRemainingCounts(run *model.LogArchiveRun, events, resources int64) error { if run.CleanedAt != nil && (events != 0 || resources != 0) { return fmt.Errorf("已标记清理完成但数据库仍有事件或资源") } if run.CleanupStartedAt != nil && (events > run.EventCount || resources > run.ResourceCount) { return fmt.Errorf("续跑窗口数量超过已归档数量") } if run.CleanupStartedAt == nil && (events != run.EventCount || resources != run.ResourceCount) { return fmt.Errorf("数据库事件或资源数量与 manifest 不一致") } return nil } func (s *Service) validateAuditManifest(ctx context.Context, run *model.LogArchiveRun) error { var manifest Manifest if err := s.readManifest(ctx, run.ManifestKey, &manifest); err != nil { return err } if manifest.Source != run.Source || manifest.SchemaVersion != run.SchemaVersion || manifest.Status != constants.ArchiveStatusSuccess || manifest.ArchiveDate != run.ArchiveDate.Format(time.DateOnly) || manifest.Timezone != constants.AuditArchiveTimezone || manifest.InstanceID != run.InstanceID || !manifest.RangeStart.Equal(run.RangeStart) || !manifest.RangeEnd.Equal(run.RangeEnd) || manifest.EventCount != run.EventCount || manifest.ResourceCount != run.ResourceCount || manifest.CompressedBytes != run.CompressedBytes || manifest.ObjectKey != run.ObjectKey || manifest.SHA256 != run.SHA256 || manifest.Revision != run.Revision { return fmt.Errorf("Audit manifest 与 ledger 不一致") } if err := s.verifyObject(ctx, run.ManifestKey, -1, map[string]string{"source": constants.AuditArchiveSource, "data-sha256": run.SHA256, "revision": strconv.Itoa(run.Revision)}); err != nil { return err } return s.verifyRetentionObject(ctx, run, false) } func (s *Service) validateIntegrationManifest(ctx context.Context, run *model.LogArchiveRun) error { var manifest integrationArchiveManifest if err := s.readManifest(ctx, run.ManifestKey, &manifest); err != nil { return err } if manifest.Source != run.Source || manifest.SchemaVersion != run.SchemaVersion || manifest.Status != constants.ArchiveStatusSuccess || !manifest.Final || manifest.ArchiveDate != run.ArchiveDate.Format(time.DateOnly) || manifest.Timezone != constants.AuditArchiveTimezone || manifest.InstanceID != run.InstanceID || !manifest.RangeStart.Equal(run.RangeStart) || !manifest.RangeEnd.Equal(run.RangeEnd) || manifest.RecordCount != run.RecordCount || manifest.CompressedBytes != run.CompressedBytes || manifest.ObjectKey != run.ObjectKey || manifest.SHA256 != run.SHA256 || manifest.Revision != run.Revision { return fmt.Errorf("Integration manifest 与最终 ledger 不一致") } if err := s.verifyObject(ctx, run.ManifestKey, -1, map[string]string{"source": constants.IntegrationArchiveSource, "data-sha256": run.SHA256, "revision": strconv.Itoa(run.Revision), "final": "true"}); err != nil { return err } return s.verifyRetentionObject(ctx, run, true) } func (s *Service) readManifest(ctx context.Context, key string, target any) error { object, err := s.store.Stat(ctx, key) if err != nil { return fmt.Errorf("读取 manifest metadata 失败: %w", err) } reader, err := s.store.Download(ctx, key) if err != nil { return fmt.Errorf("下载 manifest 失败: %w", err) } data, readErr := io.ReadAll(io.LimitReader(reader, maxManifestBytes+1)) closeErr := reader.Close() if readErr != nil { return fmt.Errorf("读取 manifest 失败: %w", readErr) } if closeErr != nil { return fmt.Errorf("关闭 manifest 对象失败: %w", closeErr) } if len(data) > maxManifestBytes || int64(len(data)) != object.Size { return fmt.Errorf("manifest 大小非法或不完整") } if err := sonic.Unmarshal(data, target); err != nil { return fmt.Errorf("解析 manifest 失败: %w", err) } return nil } func (s *Service) verifyRetentionObject(ctx context.Context, run *model.LogArchiveRun, final bool) error { metadata := map[string]string{"schema-version": run.SchemaVersion, "source": run.Source, "archive-date": run.RangeStart.Format(time.DateOnly), "timezone": constants.AuditArchiveTimezone, "sha256": run.SHA256, "revision": strconv.Itoa(run.Revision)} if run.Source == constants.AuditArchiveSource { metadata["event-count"] = strconv.FormatInt(run.EventCount, 10) metadata["resource-count"] = strconv.FormatInt(run.ResourceCount, 10) } else { metadata["record-count"] = strconv.FormatInt(run.RecordCount, 10) metadata["final"] = strconv.FormatBool(final) } if err := s.verifyObject(ctx, run.ObjectKey, run.CompressedBytes, metadata); err != nil { return err } reader, err := s.store.Download(ctx, run.ObjectKey) if err != nil { return fmt.Errorf("下载归档对象复核 SHA-256 失败: %w", err) } hasher := sha256.New() written, copyErr := io.Copy(hasher, reader) closeErr := reader.Close() if copyErr != nil { return fmt.Errorf("读取归档对象复核 SHA-256 失败: %w", copyErr) } if closeErr != nil { return fmt.Errorf("关闭归档对象失败: %w", closeErr) } if written != run.CompressedBytes || fmt.Sprintf("%x", hasher.Sum(nil)) != run.SHA256 { return fmt.Errorf("归档对象大小或 SHA-256 复核失败") } return nil } func estimatedRetentionBatches(result RetentionResult) int64 { batch := int64(constants.AuditRetentionDeleteBatchSize) return (result.EventCount+batch-1)/batch + (result.ResourceCount+batch-1)/batch + (result.IntegrationCount+batch-1)/batch } func (s *Service) cleanupAuditDate(ctx context.Context, date time.Time, run *model.LogArchiveRun) error { if run.CleanedAt != nil { return nil } if err := s.markCleanupStarted(ctx, constants.AuditArchiveSource, date); err != nil { return err } if err := s.deleteAuditResources(ctx, date, date.AddDate(0, 0, 1)); err != nil { return err } if err := s.deleteAuditEvents(ctx, date, date.AddDate(0, 0, 1)); err != nil { return err } return s.markCleaned(ctx, constants.AuditArchiveSource, date) } func (s *Service) cleanupIntegrationDate(ctx context.Context, date time.Time, run *model.LogArchiveRun) error { terminalCount, err := s.integrationTerminalCount(ctx, date, date.AddDate(0, 0, 1)) if err != nil { return err } if run.CleanedAt != nil && terminalCount == 0 { return nil } if err := s.markCleanupStarted(ctx, constants.IntegrationArchiveSource, date); err != nil { return err } end := date.AddDate(0, 0, 1) for { subquery := s.db.Model(&model.IntegrationLog{}).Select("id").Where("created_at >= ? AND created_at < ? AND result <> ?", date, end, constants.IntegrationResultPending).Order("id ASC").Limit(constants.AuditRetentionDeleteBatchSize) deleted := s.db.WithContext(ctx).Where("id IN (?)", subquery).Delete(&model.IntegrationLog{}) if deleted.Error != nil { return fmt.Errorf("分批物理删除 Integration Log 失败: %w", deleted.Error) } if deleted.RowsAffected == 0 { break } } return s.markCleaned(ctx, constants.IntegrationArchiveSource, date) } func (s *Service) integrationTerminalCount(ctx context.Context, start, end time.Time) (int64, error) { var count int64 if err := s.db.WithContext(ctx).Model(&model.IntegrationLog{}). Where("created_at >= ? AND created_at < ? AND result <> ?", start, end, constants.IntegrationResultPending). Count(&count).Error; err != nil { return 0, fmt.Errorf("统计终态 Integration Log 失败: %w", err) } return count, nil } func (s *Service) deleteAuditResources(ctx context.Context, start, end time.Time) error { for { subquery := s.db.Model(&model.AuditEventResource{}).Select("tb_audit_event_resource.id").Joins("JOIN tb_audit_event ON tb_audit_event.id = tb_audit_event_resource.audit_event_id").Where("tb_audit_event.created_at >= ? AND tb_audit_event.created_at < ?", start, end).Order("tb_audit_event_resource.id ASC").Limit(constants.AuditRetentionDeleteBatchSize) deleted := s.db.WithContext(ctx).Where("id IN (?)", subquery).Delete(&model.AuditEventResource{}) if deleted.Error != nil { return fmt.Errorf("分批物理删除 Audit Event Resource 失败: %w", deleted.Error) } if deleted.RowsAffected == 0 { return nil } } } func (s *Service) deleteAuditEvents(ctx context.Context, start, end time.Time) error { for { subquery := s.db.Model(&model.AuditEvent{}).Select("id").Where("created_at >= ? AND created_at < ?", start, end).Order("id ASC").Limit(constants.AuditRetentionDeleteBatchSize) deleted := s.db.WithContext(ctx).Where("id IN (?)", subquery).Delete(&model.AuditEvent{}) if deleted.Error != nil { return fmt.Errorf("分批物理删除 Audit Event 失败: %w", deleted.Error) } if deleted.RowsAffected == 0 { return nil } } } func (s *Service) markCleanupStarted(ctx context.Context, source string, date time.Time) error { now := time.Now() result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).Where("source = ? AND archive_date = ? AND instance_id = ? AND cleanup_started_at IS NULL", source, date.Format(time.DateOnly), s.instanceID).Updates(map[string]any{"cleanup_started_at": now, "updated_at": now}) if result.Error != nil { return fmt.Errorf("记录日清理开始断点失败: %w", result.Error) } return nil } func (s *Service) markCleaned(ctx context.Context, source string, date time.Time) error { now := time.Now() result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).Where("source = ? AND archive_date = ? AND instance_id = ? AND cleanup_started_at IS NOT NULL", source, date.Format(time.DateOnly), s.instanceID).Updates(map[string]any{"cleaned_at": now, "updated_at": now}) if result.Error != nil { return fmt.Errorf("记录日清理完成断点失败: %w", result.Error) } return nil }