暂存
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m32s

This commit is contained in:
2026-08-06 09:35:00 +08:00
parent 8659dfc658
commit 88cc5e96ec
75 changed files with 6520 additions and 513 deletions

View File

@@ -0,0 +1,350 @@
package auditarchive
import (
"compress/gzip"
"context"
"crypto/sha256"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
type integrationArchiveFile struct {
path string
recordCount int64
uncompressedBytes int64
compressedBytes int64
sha256 string
}
type integrationArchiveManifest struct {
SchemaVersion string `json:"schema_version"`
Source string `json:"source"`
ArchiveDate string `json:"archive_date"`
Timezone string `json:"timezone"`
RangeStart time.Time `json:"range_start"`
RangeEnd time.Time `json:"range_end"`
InstanceID string `json:"instance_id"`
RecordCount int64 `json:"record_count"`
UncompressedBytes int64 `json:"uncompressed_bytes"`
CompressedBytes int64 `json:"compressed_bytes"`
ObjectKey string `json:"object_key"`
SHA256 string `json:"sha256"`
Revision int `json:"revision"`
GeneratedAt time.Time `json:"generated_at"`
Status string `json:"status"`
Final bool `json:"final"`
}
// ArchivePreviousIntegrationDay 归档 Asia/Shanghai 前一完整自然日的 Integration Log 创建日快照。
func (s *Service) ArchivePreviousIntegrationDay(ctx context.Context) error {
now := time.Now().In(s.location)
return s.ArchiveIntegrationDate(ctx, now.AddDate(0, 0, -1))
}
// ArchiveIntegrationDate 归档指定 Asia/Shanghai 自然日的 Integration Log 创建日快照。
func (s *Service) ArchiveIntegrationDate(ctx context.Context, archiveDate time.Time) error {
return s.archiveIntegrationDate(ctx, archiveDate, false)
}
// FinalizePreviousIntegrationMonth 复核并终结上一个完整自然月的 Integration Log 归档。
func (s *Service) FinalizePreviousIntegrationMonth(ctx context.Context) error {
now := time.Now().In(s.location)
return s.FinalizeIntegrationMonth(ctx, now.AddDate(0, -1, 0))
}
// FinalizeIntegrationMonth 逐日复核指定完整自然月,并为变化内容创建最终 revision。
func (s *Service) FinalizeIntegrationMonth(ctx context.Context, month time.Time) error {
monthStart := time.Date(month.In(s.location).Year(), month.In(s.location).Month(), 1, 0, 0, 0, 0, s.location)
currentMonth := time.Now().In(s.location)
currentMonthStart := time.Date(currentMonth.Year(), currentMonth.Month(), 1, 0, 0, 0, 0, s.location)
if !monthStart.Before(currentMonthStart) {
return fmt.Errorf("只能终结已经结束的 Integration Log 完整自然月")
}
for date := monthStart; date.Before(monthStart.AddDate(0, 1, 0)); date = date.AddDate(0, 0, 1) {
if err := s.archiveIntegrationDate(ctx, date, true); err != nil {
return fmt.Errorf("终结 %s Integration Log 归档失败: %w", date.Format(time.DateOnly), err)
}
}
return nil
}
func (s *Service) archiveIntegrationDate(ctx context.Context, archiveDate time.Time, final bool) error {
if s.db == nil || s.store == nil {
return fmt.Errorf("Integration Log 归档数据库或对象存储未配置")
}
start := time.Date(archiveDate.In(s.location).Year(), archiveDate.In(s.location).Month(), archiveDate.In(s.location).Day(), 0, 0, 0, 0, s.location)
end := start.AddDate(0, 0, 1)
today := time.Now().In(s.location)
if end.After(time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, s.location)) {
return fmt.Errorf("Integration Log 只能归档已经结束的完整自然日")
}
run, err := s.ensureIntegrationRun(ctx, start, end)
if err != nil {
return err
}
file, err := s.buildIntegrationArchiveFile(ctx, start, end)
if err != nil {
return err
}
defer os.Remove(file.path)
if final {
pending, pendingErr := s.integrationPendingCount(ctx, start, end)
if pendingErr != nil {
return pendingErr
}
if pending > 0 {
_ = s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).Where("id = ?", run.ID).
Updates(map[string]any{"is_final": false, "error_summary": "存在 pending Integration Log无法形成最终归档", "updated_at": time.Now()}).Error
return fmt.Errorf("仍有 %d 条 pending Integration Log无法形成最终归档", pending)
}
}
if run.Status == constants.ArchiveStatusSuccess && run.RecordCount == file.recordCount && run.SHA256 == file.sha256 {
valid, validateErr := s.validateIntegrationRun(ctx, run)
if validateErr == nil && valid && (!final || run.IsFinal) {
return nil
}
}
acquired, err := s.acquireIntegrationRun(ctx, run)
if err != nil {
return err
}
if !acquired {
return fmt.Errorf("Integration Log 归档任务正在执行")
}
if err := s.uploadIntegrationArchive(ctx, run, file, final); err != nil {
s.markFailed(ctx, run.ID, err)
return err
}
return nil
}
func (s *Service) ensureIntegrationRun(ctx context.Context, start, end time.Time) (*model.LogArchiveRun, error) {
run := model.LogArchiveRun{
Source: constants.IntegrationArchiveSource, ArchiveDate: start, InstanceID: s.instanceID,
SchemaVersion: constants.IntegrationArchiveSchemaVersion, Revision: 1,
Status: constants.ArchiveStatusPending, RangeStart: start, RangeEnd: end,
}
result := s.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "source"}, {Name: "archive_date"}, {Name: "instance_id"}, {Name: "schema_version"}},
DoNothing: true,
}).Create(&run)
if result.Error != nil {
return nil, fmt.Errorf("创建 Integration Log 归档账本失败: %w", result.Error)
}
if result.RowsAffected == 0 {
if err := s.db.WithContext(ctx).Where(
"source = ? AND archive_date = ? AND instance_id = ? AND schema_version = ?",
constants.IntegrationArchiveSource, start, s.instanceID, constants.IntegrationArchiveSchemaVersion,
).First(&run).Error; err != nil {
return nil, fmt.Errorf("读取 Integration Log 归档账本失败: %w", err)
}
}
return &run, nil
}
func (s *Service) acquireIntegrationRun(ctx context.Context, run *model.LogArchiveRun) (bool, error) {
revision := run.Revision
if run.Status != constants.ArchiveStatusPending {
revision++
}
now := time.Now()
result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("id = ? AND (status <> ? OR updated_at < ?)", run.ID, constants.ArchiveStatusRunning, now.Add(-3*time.Hour)).
Updates(map[string]any{
"status": constants.ArchiveStatusRunning, "revision": revision, "is_final": false,
"attempt_count": gorm.Expr("attempt_count + 1"), "error_summary": "", "completed_at": nil, "updated_at": now,
})
if result.Error != nil {
return false, fmt.Errorf("锁定 Integration Log 归档任务失败: %w", result.Error)
}
if result.RowsAffected == 0 {
return false, nil
}
run.Revision = revision
return true, nil
}
func (s *Service) buildIntegrationArchiveFile(ctx context.Context, start, end time.Time) (*integrationArchiveFile, error) {
temp, err := os.CreateTemp("", "integration-logs-*.jsonl.gz")
if err != nil {
return nil, fmt.Errorf("创建 Integration Log 归档临时文件失败: %w", err)
}
path := temp.Name()
failed := true
defer func() {
_ = temp.Close()
if failed {
_ = os.Remove(path)
}
}()
hasher := sha256.New()
gzipWriter := gzip.NewWriter(io.MultiWriter(temp, hasher))
result := &integrationArchiveFile{path: path}
var lastID uint
for {
var logs []model.IntegrationLog
if err := s.db.WithContext(ctx).Where("created_at >= ? AND created_at < ? AND id > ?", start, end, lastID).
Order("id ASC").Limit(archivePageSize).Find(&logs).Error; err != nil {
return nil, fmt.Errorf("读取 Integration Log 归档记录失败: %w", err)
}
if len(logs) == 0 {
break
}
for i := range logs {
line, marshalErr := sonic.Marshal(logs[i])
if marshalErr != nil {
return nil, fmt.Errorf("序列化 Integration Log 归档记录失败: %w", marshalErr)
}
line = append(line, '\n')
if _, writeErr := gzipWriter.Write(line); writeErr != nil {
return nil, fmt.Errorf("写入 Integration Log 归档压缩流失败: %w", writeErr)
}
result.recordCount++
result.uncompressedBytes += int64(len(line))
}
lastID = logs[len(logs)-1].ID
}
if err := gzipWriter.Close(); err != nil {
return nil, fmt.Errorf("关闭 Integration Log 归档压缩流失败: %w", err)
}
if err := temp.Close(); err != nil {
return nil, fmt.Errorf("关闭 Integration Log 归档临时文件失败: %w", err)
}
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("读取 Integration Log 归档临时文件信息失败: %w", err)
}
result.compressedBytes = info.Size()
result.sha256 = fmt.Sprintf("%x", hasher.Sum(nil))
failed = false
return result, nil
}
func (s *Service) uploadIntegrationArchive(ctx context.Context, run *model.LogArchiveRun, file *integrationArchiveFile, final bool) error {
count, err := s.integrationRecordCount(ctx, run.RangeStart, run.RangeEnd)
if err != nil {
return err
}
if count != file.recordCount {
return fmt.Errorf("Integration Log 归档生成期间记录数量发生变化")
}
objectKey, manifestKey := integrationObjectKeys(run.RangeStart, run.Revision)
metadata := integrationArchiveMetadata(file, run, final)
reader, err := os.Open(file.path)
if err != nil {
return fmt.Errorf("打开 Integration Log 归档临时文件失败: %w", err)
}
uploadErr := s.store.UploadWithMetadata(ctx, objectKey, reader, "application/gzip", metadata)
closeErr := reader.Close()
if uploadErr != nil {
return fmt.Errorf("上传 Integration Log 归档对象失败: %w", uploadErr)
}
if closeErr != nil {
return fmt.Errorf("关闭 Integration Log 归档临时文件失败: %w", closeErr)
}
if err := s.verifyObject(ctx, objectKey, file.compressedBytes, metadata); err != nil {
return err
}
generatedAt := time.Now().In(s.location)
manifest := integrationArchiveManifest{
SchemaVersion: constants.IntegrationArchiveSchemaVersion, Source: constants.IntegrationArchiveSource,
ArchiveDate: run.RangeStart.In(s.location).Format(time.DateOnly), Timezone: constants.AuditArchiveTimezone,
RangeStart: run.RangeStart, RangeEnd: run.RangeEnd, InstanceID: s.instanceID,
RecordCount: file.recordCount, UncompressedBytes: file.uncompressedBytes, CompressedBytes: file.compressedBytes,
ObjectKey: objectKey, SHA256: file.sha256, Revision: run.Revision, GeneratedAt: generatedAt,
Status: constants.ArchiveStatusSuccess, Final: final,
}
manifestBytes, err := sonic.Marshal(manifest)
if err != nil {
return fmt.Errorf("序列化 Integration Log 归档清单失败: %w", err)
}
manifestMetadata := map[string]string{
"source": constants.IntegrationArchiveSource, "data-sha256": file.sha256,
"revision": strconv.Itoa(run.Revision), "final": strconv.FormatBool(final),
}
if err := s.store.UploadWithMetadata(ctx, manifestKey, strings.NewReader(string(manifestBytes)), "application/json", manifestMetadata); err != nil {
return fmt.Errorf("上传 Integration Log 归档清单失败: %w", err)
}
if err := s.verifyObject(ctx, manifestKey, int64(len(manifestBytes)), manifestMetadata); err != nil {
return err
}
completedAt := time.Now()
return s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).Where("id = ?", run.ID).Updates(map[string]any{
"status": constants.ArchiveStatusSuccess, "is_final": final,
"object_key": objectKey, "manifest_key": manifestKey, "record_count": file.recordCount,
"uncompressed_bytes": file.uncompressedBytes, "compressed_bytes": file.compressedBytes,
"sha256": file.sha256, "generated_at": generatedAt, "completed_at": completedAt, "updated_at": completedAt,
}).Error
}
func (s *Service) validateIntegrationRun(ctx context.Context, run *model.LogArchiveRun) (bool, error) {
metadata := map[string]string{
"sha256": run.SHA256, "record-count": strconv.FormatInt(run.RecordCount, 10),
"revision": strconv.Itoa(run.Revision), "final": strconv.FormatBool(run.IsFinal),
}
if err := s.verifyObject(ctx, run.ObjectKey, run.CompressedBytes, metadata); err != nil {
return false, nil
}
manifestMetadata := map[string]string{
"source": constants.IntegrationArchiveSource, "data-sha256": run.SHA256,
"revision": strconv.Itoa(run.Revision), "final": strconv.FormatBool(run.IsFinal),
}
if err := s.verifyObject(ctx, run.ManifestKey, -1, manifestMetadata); err != nil {
return false, nil
}
return true, nil
}
func (s *Service) integrationRecordCount(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 < ?", start, end).Count(&count).Error; err != nil {
return 0, fmt.Errorf("统计 Integration Log 归档记录失败: %w", err)
}
return count, nil
}
func (s *Service) integrationPendingCount(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("统计 pending Integration Log 失败: %w", err)
}
return count, nil
}
func integrationObjectKeys(date time.Time, revision int) (string, string) {
prefix := fmt.Sprintf("audit-archive/v1/%04d/%02d/%02d", date.Year(), date.Month(), date.Day())
name := fmt.Sprintf("integration-logs-%s-r%d", date.Format(time.DateOnly), revision)
return prefix + "/" + name + ".jsonl.gz", prefix + "/" + name + ".manifest.json"
}
func integrationArchiveMetadata(file *integrationArchiveFile, run *model.LogArchiveRun, final bool) map[string]string {
return map[string]string{
"schema-version": constants.IntegrationArchiveSchemaVersion,
"source": constants.IntegrationArchiveSource,
"archive-date": run.RangeStart.Format(time.DateOnly),
"timezone": constants.AuditArchiveTimezone,
"record-count": strconv.FormatInt(file.recordCount, 10),
"sha256": file.sha256,
"revision": strconv.Itoa(run.Revision),
"final": strconv.FormatBool(final),
}
}

View File

@@ -0,0 +1,542 @@
package auditarchive
import (
"context"
"crypto/sha256"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
const maxManifestBytes = 1024 * 1024
// RetentionAudit 描述月度物理清理的统一审计事实。
type RetentionAudit struct {
EventID string
Month string
Summary string
Result string
ErrorSummary string
RangeStart time.Time
RangeEnd time.Time
EventCount int64
ResourceCount int64
IntegrationCount int64
ManifestKeys []string
DurationMS int64
}
// RetentionResult 是月度留存清理的结构化执行结果。
type RetentionResult struct {
Month string
EventCount int64
ResourceCount int64
IntegrationCount int64
ManifestKeys []string
Duration time.Duration
}
type retentionRuns struct {
audit []*model.LogArchiveRun
integration []*model.LogArchiveRun
}
// CleanupPreviousMonth 校验并物理清理上一个完整自然月的在线审计日志。
func (s *Service) CleanupPreviousMonth(ctx context.Context) (RetentionResult, error) {
now := time.Now().In(s.location)
return s.CleanupMonth(ctx, now.AddDate(0, -1, 0))
}
// CleanupMonth 校验归档硬门禁后按固定顺序物理清理指定完整自然月。
func (s *Service) CleanupMonth(ctx context.Context, month time.Time) (result RetentionResult, cleanupErr error) {
if s.db == nil || s.store == nil || s.audit == nil {
return result, fmt.Errorf("日志留存清理数据库、对象存储或审计 Writer 未配置")
}
start, end, err := s.retentionMonthRange(month)
if err != nil {
return result, err
}
startedAt := time.Now()
result.Month = start.Format("2006-01")
cleanupErr = s.executeRetention(ctx, start, end, &result)
result.Duration = time.Since(startedAt)
if auditErr := s.recordRetentionAudit(ctx, start, end, result, cleanupErr); auditErr != nil {
if cleanupErr != nil {
return result, fmt.Errorf("%w记录留存清理失败审计失败: %v", cleanupErr, auditErr)
}
return result, fmt.Errorf("记录留存清理成功审计失败: %w", auditErr)
}
return result, cleanupErr
}
func (s *Service) retentionMonthRange(month time.Time) (time.Time, time.Time, error) {
start := time.Date(month.In(s.location).Year(), month.In(s.location).Month(), 1, 0, 0, 0, 0, s.location)
end := start.AddDate(0, 1, 0)
now := time.Now().In(s.location)
currentMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, s.location)
if !end.Before(currentMonth) && !end.Equal(currentMonth) {
return time.Time{}, time.Time{}, fmt.Errorf("只能清理已经结束的完整自然月")
}
return start, end, nil
}
func (s *Service) executeRetention(ctx context.Context, start, end time.Time, result *RetentionResult) error {
started, err := s.retentionCleanupStarted(ctx, start, end)
if err != nil {
return err
}
if !started {
lastDay := end.AddDate(0, 0, -1)
if err := s.ArchiveDate(ctx, lastDay); err != nil {
return fmt.Errorf("完成上月最后一天 Audit 归档失败: %w", err)
}
if err := s.ArchiveIntegrationDate(ctx, lastDay); err != nil {
return fmt.Errorf("完成上月最后一天 Integration Log 归档失败: %w", err)
}
if err := s.FinalizeIntegrationMonth(ctx, start); err != nil {
return err
}
}
runs, err := s.loadRetentionRuns(ctx, start, end)
if err != nil {
return err
}
if err := s.validateRetentionRuns(ctx, start, end, runs); err != nil {
return err
}
summarizeRetentionRuns(runs, result)
if err := s.cleanupAuditMonth(ctx, start, end, runs.audit); err != nil {
return err
}
return s.cleanupIntegrationMonth(ctx, start, end, runs.integration)
}
func (s *Service) retentionCleanupStarted(ctx context.Context, start, end time.Time) (bool, error) {
var count int64
err := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("archive_date >= ? AND archive_date < ? AND instance_id = ? AND cleanup_started_at IS NOT NULL",
start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID).
Count(&count).Error
if err != nil {
return false, fmt.Errorf("读取月度清理断点失败: %w", err)
}
return count > 0, nil
}
func (s *Service) loadRetentionRuns(ctx context.Context, start, end time.Time) (retentionRuns, error) {
var rows []model.LogArchiveRun
err := s.db.WithContext(ctx).Where(
"source IN ? AND archive_date >= ? AND archive_date < ? AND instance_id = ?",
[]string{constants.AuditArchiveSource, constants.IntegrationArchiveSource}, start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID,
).Order("archive_date ASC, source ASC").Find(&rows).Error
if err != nil {
return retentionRuns{}, fmt.Errorf("读取月度归档账本失败: %w", err)
}
days := int(end.Sub(start).Hours() / 24)
if len(rows) != days*2 {
return retentionRuns{}, fmt.Errorf("月度归档账本缺日:期望 %d 条,实际 %d 条", days*2, len(rows))
}
runs := retentionRuns{audit: make([]*model.LogArchiveRun, 0, days), integration: make([]*model.LogArchiveRun, 0, days)}
for index := range rows {
run := &rows[index]
switch run.Source {
case constants.AuditArchiveSource:
runs.audit = append(runs.audit, run)
case constants.IntegrationArchiveSource:
runs.integration = append(runs.integration, run)
}
}
if len(runs.audit) != days || len(runs.integration) != days {
return retentionRuns{}, fmt.Errorf("月度 Audit 或 Integration 归档账本不完整")
}
return runs, nil
}
func (s *Service) validateRetentionRuns(ctx context.Context, start, end time.Time, runs retentionRuns) error {
if err := validateCleanupLedgerState(runs.audit); err != nil {
return fmt.Errorf("Audit 清理断点非法: %w", err)
}
if err := validateCleanupLedgerState(runs.integration); err != nil {
return fmt.Errorf("Integration 清理断点非法: %w", err)
}
for index := range runs.audit {
date := start.AddDate(0, 0, index)
if err := s.validateAuditRetentionDay(ctx, date, runs.audit[index]); err != nil {
return fmt.Errorf("%s Audit 清理门禁失败: %w", date.Format(time.DateOnly), err)
}
if err := s.validateIntegrationRetentionDay(ctx, date, runs.integration[index]); err != nil {
return fmt.Errorf("%s Integration 清理门禁失败: %w", date.Format(time.DateOnly), err)
}
}
return nil
}
func validateCleanupLedgerState(runs []*model.LogArchiveRun) error {
started, cleaned := 0, 0
for _, run := range runs {
if run.CleanupStartedAt != nil {
started++
}
if run.CleanedAt != nil {
cleaned++
}
}
if started != 0 && started != len(runs) {
return fmt.Errorf("清理开始断点不是整月原子状态")
}
if cleaned != 0 && cleaned != len(runs) {
return fmt.Errorf("清理完成断点不是整月原子状态")
}
if cleaned > 0 && started == 0 {
return fmt.Errorf("清理完成但缺少开始断点")
}
return 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
}
if run.CleanedAt != nil {
if count != 0 {
return fmt.Errorf("已标记清理完成但数据库仍有 %d 条记录", count)
}
return nil
}
if run.CleanupStartedAt != nil {
if count > run.RecordCount {
return fmt.Errorf("续跑窗口记录数超过最终归档数量")
}
return 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 {
if events != 0 || resources != 0 {
return fmt.Errorf("已标记清理完成但数据库仍有事件或资源")
}
return nil
}
if run.CleanupStartedAt != nil {
if events > run.EventCount || resources > run.ResourceCount {
return fmt.Errorf("续跑窗口数量超过已归档数量")
}
return nil
}
if 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 summarizeRetentionRuns(runs retentionRuns, result *RetentionResult) {
result.ManifestKeys = make([]string, 0, len(runs.audit)+len(runs.integration))
for _, run := range runs.audit {
result.EventCount += run.EventCount
result.ResourceCount += run.ResourceCount
result.ManifestKeys = append(result.ManifestKeys, run.ManifestKey)
}
for _, run := range runs.integration {
result.IntegrationCount += run.RecordCount
result.ManifestKeys = append(result.ManifestKeys, run.ManifestKey)
}
}
func (s *Service) cleanupAuditMonth(ctx context.Context, start, end time.Time, runs []*model.LogArchiveRun) error {
if allRunsCleaned(runs) {
return nil
}
if err := s.markCleanupStarted(ctx, constants.AuditArchiveSource, start, end); err != nil {
return err
}
if err := s.deleteAuditResources(ctx, start, end); err != nil {
return err
}
if err := s.deleteAuditEvents(ctx, start, end); err != nil {
return err
}
return s.markCleaned(ctx, constants.AuditArchiveSource, start, end)
}
func (s *Service) cleanupIntegrationMonth(ctx context.Context, start, end time.Time, runs []*model.LogArchiveRun) error {
if allRunsCleaned(runs) {
return nil
}
if err := s.markCleanupStarted(ctx, constants.IntegrationArchiveSource, start, end); err != nil {
return err
}
for {
subquery := s.db.Model(&model.IntegrationLog{}).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.IntegrationLog{})
if deleted.Error != nil {
return fmt.Errorf("分批物理删除 Integration Log 失败: %w", deleted.Error)
}
if deleted.RowsAffected == 0 {
break
}
}
return s.markCleaned(ctx, constants.IntegrationArchiveSource, start, end)
}
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, start, end time.Time) error {
now := time.Now()
result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("source = ? AND archive_date >= ? AND archive_date < ? AND instance_id = ? AND cleanup_started_at IS NULL",
source, start.Format(time.DateOnly), end.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 s.validateCleanupMarkerCount(ctx, source, start, end, "cleanup_started_at IS NOT NULL", "开始")
}
func (s *Service) markCleaned(ctx context.Context, source string, start, end time.Time) error {
now := time.Now()
result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("source = ? AND archive_date >= ? AND archive_date < ? AND instance_id = ? AND cleanup_started_at IS NOT NULL",
source, start.Format(time.DateOnly), end.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 s.validateCleanupMarkerCount(ctx, source, start, end, "cleaned_at IS NOT NULL", "完成")
}
func (s *Service) validateCleanupMarkerCount(ctx context.Context, source string, start, end time.Time, marker, label string) error {
var count int64
err := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("source = ? AND archive_date >= ? AND archive_date < ? AND instance_id = ? AND "+marker,
source, start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID).
Count(&count).Error
if err != nil {
return fmt.Errorf("复核月度清理%s断点失败: %w", label, err)
}
expected := int64(end.Sub(start).Hours() / 24)
if count != expected {
return fmt.Errorf("月度清理%s断点不完整期望 %d 条,实际 %d 条", label, expected, count)
}
return nil
}
func allRunsCleaned(runs []*model.LogArchiveRun) bool {
return len(runs) > 0 && runs[0].CleanedAt != nil
}
func (s *Service) recordRetentionAudit(ctx context.Context, start, end time.Time, result RetentionResult, cleanupErr error) error {
audit := RetentionAudit{
Month: result.Month, RangeStart: start, RangeEnd: end,
EventCount: result.EventCount, ResourceCount: result.ResourceCount,
IntegrationCount: result.IntegrationCount, ManifestKeys: result.ManifestKeys,
DurationMS: result.Duration.Milliseconds(), Result: constants.AuditResultSuccess,
Summary: "完成已归档在线日志月度物理清理",
EventID: "evt_retention_" + strings.ReplaceAll(result.Month, "-", "_"),
}
if cleanupErr != nil {
audit.EventID = ""
audit.Result = constants.AuditResultFailed
audit.Summary = "已归档在线日志月度物理清理失败"
audit.ErrorSummary = truncateRetentionError(cleanupErr)
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.audit.WriteRetentionCleanup(ctx, tx, audit)
})
}
func truncateRetentionError(err error) string {
value := []rune(err.Error())
if len(value) > 500 {
value = value[:500]
}
return string(value)
}

View File

@@ -0,0 +1,412 @@
// Package auditarchive 实现统一审计每日冷归档用例。
package auditarchive
import (
"compress/gzip"
"context"
"crypto/sha256"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/storage"
)
const archivePageSize = 1000
// ObjectStore 是每日归档需要的最小对象存储能力。
type ObjectStore interface {
UploadWithMetadata(context.Context, string, io.Reader, string, map[string]string) error
Stat(context.Context, string) (*storage.ObjectMetadata, error)
Download(context.Context, string) (io.ReadCloser, error)
}
// RetentionAuditWriter 记录月度留存清理的系统审计事实。
type RetentionAuditWriter interface {
WriteRetentionCleanup(context.Context, *gorm.DB, RetentionAudit) error
}
// Service 编排审计归档生成、上传、复核和幂等账本更新。
type Service struct {
db *gorm.DB
store ObjectStore
audit RetentionAuditWriter
instanceID string
location *time.Location
}
// Manifest 是归档对象的完整性清单。
type Manifest struct {
SchemaVersion string `json:"schema_version"`
Source string `json:"source"`
ArchiveDate string `json:"archive_date"`
Timezone string `json:"timezone"`
RangeStart time.Time `json:"range_start"`
RangeEnd time.Time `json:"range_end"`
InstanceID string `json:"instance_id"`
EventCount int64 `json:"event_count"`
ResourceCount int64 `json:"resource_count"`
UncompressedBytes int64 `json:"uncompressed_bytes"`
CompressedBytes int64 `json:"compressed_bytes"`
ObjectKey string `json:"object_key"`
SHA256 string `json:"sha256"`
Revision int `json:"revision"`
GeneratedAt time.Time `json:"generated_at"`
Status string `json:"status"`
}
type archiveLine struct {
Event model.AuditEvent `json:"event"`
Resources []model.AuditEventResource `json:"resources"`
}
type archiveFile struct {
path string
eventCount int64
resourceCount int64
uncompressedBytes int64
compressedBytes int64
sha256 string
}
// NewService 创建统一审计每日冷归档服务。
func NewService(db *gorm.DB, store ObjectStore, instanceID string, audit ...RetentionAuditWriter) (*Service, error) {
location, err := time.LoadLocation(constants.AuditArchiveTimezone)
if err != nil {
return nil, fmt.Errorf("加载审计归档时区失败: %w", err)
}
if strings.TrimSpace(instanceID) == "" {
instanceID = "audit-archive"
}
var auditWriter RetentionAuditWriter
if len(audit) > 0 {
auditWriter = audit[0]
}
return &Service{db: db, store: store, audit: auditWriter, instanceID: instanceID, location: location}, nil
}
// ArchivePreviousDay 归档 Asia/Shanghai 前一完整自然日。
func (s *Service) ArchivePreviousDay(ctx context.Context) error {
now := time.Now().In(s.location)
return s.ArchiveDate(ctx, now.AddDate(0, 0, -1))
}
// ArchiveDate 归档指定 Asia/Shanghai 自然日。
func (s *Service) ArchiveDate(ctx context.Context, archiveDate time.Time) error {
if s.db == nil || s.store == nil {
return fmt.Errorf("审计归档数据库或对象存储未配置")
}
start := time.Date(archiveDate.In(s.location).Year(), archiveDate.In(s.location).Month(), archiveDate.In(s.location).Day(), 0, 0, 0, 0, s.location)
end := start.AddDate(0, 0, 1)
today := time.Now().In(s.location)
todayStart := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, s.location)
if end.After(todayStart) {
return fmt.Errorf("统一审计只能归档已经结束的完整自然日")
}
run, err := s.ensureRun(ctx, start, end)
if err != nil {
return err
}
if run.Status == constants.ArchiveStatusSuccess {
valid, validateErr := s.validateSuccessfulRun(ctx, run)
if validateErr == nil && valid {
return nil
}
}
acquired, err := s.acquireRun(ctx, run)
if err != nil || !acquired {
return err
}
if err := s.execute(ctx, run); err != nil {
s.markFailed(ctx, run.ID, err)
return err
}
return nil
}
func (s *Service) ensureRun(ctx context.Context, start, end time.Time) (*model.LogArchiveRun, error) {
run := model.LogArchiveRun{
Source: constants.AuditArchiveSource, ArchiveDate: start, InstanceID: s.instanceID,
SchemaVersion: constants.AuditArchiveSchemaVersion, Revision: 1,
Status: constants.ArchiveStatusPending, RangeStart: start, RangeEnd: end,
}
result := s.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "source"}, {Name: "archive_date"}, {Name: "instance_id"}, {Name: "schema_version"}},
DoNothing: true,
}).Create(&run)
if result.Error != nil {
return nil, fmt.Errorf("创建审计归档账本失败: %w", result.Error)
}
if result.RowsAffected == 0 {
if err := s.db.WithContext(ctx).Where(
"source = ? AND archive_date = ? AND instance_id = ? AND schema_version = ?",
constants.AuditArchiveSource, start, s.instanceID, constants.AuditArchiveSchemaVersion,
).First(&run).Error; err != nil {
return nil, fmt.Errorf("读取审计归档账本失败: %w", err)
}
}
return &run, nil
}
func (s *Service) acquireRun(ctx context.Context, run *model.LogArchiveRun) (bool, error) {
revision := run.Revision
if run.Status == constants.ArchiveStatusFailed || run.Status == constants.ArchiveStatusSuccess || run.Status == constants.ArchiveStatusRunning {
revision++
}
now := time.Now()
staleBefore := now.Add(-3 * time.Hour)
result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("id = ? AND (status <> ? OR updated_at < ?)", run.ID, constants.ArchiveStatusRunning, staleBefore).
Updates(map[string]any{
"status": constants.ArchiveStatusRunning, "revision": revision,
"attempt_count": gorm.Expr("attempt_count + 1"), "error_summary": "",
"completed_at": nil, "updated_at": now,
})
if result.Error != nil {
return false, fmt.Errorf("锁定审计归档任务失败: %w", result.Error)
}
if result.RowsAffected == 0 {
return false, nil
}
run.Revision = revision
run.Status = constants.ArchiveStatusRunning
return true, nil
}
func (s *Service) execute(ctx context.Context, run *model.LogArchiveRun) error {
file, err := s.buildArchiveFile(ctx, run.RangeStart, run.RangeEnd)
if err != nil {
return err
}
defer os.Remove(file.path)
dbEvents, dbResources, err := s.databaseCounts(ctx, run.RangeStart, run.RangeEnd)
if err != nil {
return err
}
if dbEvents != file.eventCount || dbResources != file.resourceCount {
return fmt.Errorf("审计归档生成期间数据数量发生变化")
}
objectKey, manifestKey := objectKeys(run.RangeStart, run.Revision)
metadata := archiveMetadata(file, run)
reader, err := os.Open(file.path)
if err != nil {
return fmt.Errorf("打开审计归档临时文件失败: %w", err)
}
uploadErr := s.store.UploadWithMetadata(ctx, objectKey, reader, "application/gzip", metadata)
closeErr := reader.Close()
if uploadErr != nil {
return fmt.Errorf("上传审计归档对象失败: %w", uploadErr)
}
if closeErr != nil {
return fmt.Errorf("关闭审计归档临时文件失败: %w", closeErr)
}
if err := s.verifyObject(ctx, objectKey, file.compressedBytes, metadata); err != nil {
return err
}
generatedAt := time.Now().In(s.location)
manifest := Manifest{
SchemaVersion: constants.AuditArchiveSchemaVersion, Source: constants.AuditArchiveSource,
ArchiveDate: run.RangeStart.In(s.location).Format(time.DateOnly), Timezone: constants.AuditArchiveTimezone,
RangeStart: run.RangeStart, RangeEnd: run.RangeEnd, InstanceID: s.instanceID,
EventCount: file.eventCount, ResourceCount: file.resourceCount,
UncompressedBytes: file.uncompressedBytes, CompressedBytes: file.compressedBytes,
ObjectKey: objectKey, SHA256: file.sha256, Revision: run.Revision,
GeneratedAt: generatedAt, Status: constants.ArchiveStatusSuccess,
}
manifestBytes, err := sonic.Marshal(manifest)
if err != nil {
return fmt.Errorf("序列化审计归档清单失败: %w", err)
}
manifestMetadata := map[string]string{"source": constants.AuditArchiveSource, "data-sha256": file.sha256, "revision": strconv.Itoa(run.Revision)}
if err := s.store.UploadWithMetadata(ctx, manifestKey, strings.NewReader(string(manifestBytes)), "application/json", manifestMetadata); err != nil {
return fmt.Errorf("上传审计归档清单失败: %w", err)
}
if err := s.verifyObject(ctx, manifestKey, int64(len(manifestBytes)), manifestMetadata); err != nil {
return err
}
completedAt := time.Now()
updates := map[string]any{
"status": constants.ArchiveStatusSuccess, "object_key": objectKey, "manifest_key": manifestKey,
"event_count": file.eventCount, "resource_count": file.resourceCount,
"uncompressed_bytes": file.uncompressedBytes, "compressed_bytes": file.compressedBytes,
"sha256": file.sha256, "generated_at": generatedAt, "completed_at": completedAt,
"updated_at": completedAt,
}
if err := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).Where("id = ?", run.ID).Updates(updates).Error; err != nil {
return fmt.Errorf("更新审计归档成功账本失败: %w", err)
}
return nil
}
func (s *Service) buildArchiveFile(ctx context.Context, start, end time.Time) (*archiveFile, error) {
temp, err := os.CreateTemp("", "audit-events-*.jsonl.gz")
if err != nil {
return nil, fmt.Errorf("创建审计归档临时文件失败: %w", err)
}
path := temp.Name()
failed := true
defer func() {
_ = temp.Close()
if failed {
_ = os.Remove(path)
}
}()
hasher := sha256.New()
gzipWriter := gzip.NewWriter(io.MultiWriter(temp, hasher))
result := &archiveFile{path: path}
var lastID uint
for {
var events []model.AuditEvent
if err := s.db.WithContext(ctx).Where("created_at >= ? AND created_at < ? AND id > ?", start, end, lastID).
Order("id ASC").Limit(archivePageSize).Find(&events).Error; err != nil {
return nil, fmt.Errorf("读取审计归档事件失败: %w", err)
}
if len(events) == 0 {
break
}
ids := make([]uint, 0, len(events))
for i := range events {
ids = append(ids, events[i].ID)
}
var resources []model.AuditEventResource
if err := s.db.WithContext(ctx).Where("audit_event_id IN ?", ids).
Order("audit_event_id ASC, sort_order ASC, id ASC").Find(&resources).Error; err != nil {
return nil, fmt.Errorf("读取审计归档资源失败: %w", err)
}
grouped := make(map[uint][]model.AuditEventResource, len(events))
for i := range resources {
resource := resources[i]
grouped[resource.AuditEventID] = append(grouped[resource.AuditEventID], resource)
}
for i := range events {
eventResources := grouped[events[i].ID]
if eventResources == nil {
eventResources = []model.AuditEventResource{}
}
line, marshalErr := sonic.Marshal(archiveLine{Event: events[i], Resources: eventResources})
if marshalErr != nil {
return nil, fmt.Errorf("序列化审计归档事件失败: %w", marshalErr)
}
line = append(line, '\n')
if _, writeErr := gzipWriter.Write(line); writeErr != nil {
return nil, fmt.Errorf("写入审计归档压缩流失败: %w", writeErr)
}
result.eventCount++
result.resourceCount += int64(len(grouped[events[i].ID]))
result.uncompressedBytes += int64(len(line))
}
lastID = events[len(events)-1].ID
}
if err := gzipWriter.Close(); err != nil {
return nil, fmt.Errorf("关闭审计归档压缩流失败: %w", err)
}
if err := temp.Close(); err != nil {
return nil, fmt.Errorf("关闭审计归档临时文件失败: %w", err)
}
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("读取审计归档临时文件信息失败: %w", err)
}
result.compressedBytes = info.Size()
result.sha256 = fmt.Sprintf("%x", hasher.Sum(nil))
failed = false
return result, nil
}
func (s *Service) databaseCounts(ctx context.Context, start, end time.Time) (int64, int64, error) {
var eventCount int64
if err := s.db.WithContext(ctx).Model(&model.AuditEvent{}).
Where("created_at >= ? AND created_at < ?", start, end).Count(&eventCount).Error; err != nil {
return 0, 0, fmt.Errorf("统计审计归档事件失败: %w", err)
}
var resourceCount int64
subquery := s.db.Model(&model.AuditEvent{}).Select("id").Where("created_at >= ? AND created_at < ?", start, end)
if err := s.db.WithContext(ctx).Model(&model.AuditEventResource{}).
Where("audit_event_id IN (?)", subquery).Count(&resourceCount).Error; err != nil {
return 0, 0, fmt.Errorf("统计审计归档资源失败: %w", err)
}
return eventCount, resourceCount, nil
}
func (s *Service) validateSuccessfulRun(ctx context.Context, run *model.LogArchiveRun) (bool, error) {
events, resources, err := s.databaseCounts(ctx, run.RangeStart, run.RangeEnd)
if err != nil || events != run.EventCount || resources != run.ResourceCount {
return false, err
}
metadata := map[string]string{
"sha256": run.SHA256, "event-count": strconv.FormatInt(run.EventCount, 10),
"resource-count": strconv.FormatInt(run.ResourceCount, 10), "revision": strconv.Itoa(run.Revision),
}
if err := s.verifyObject(ctx, run.ObjectKey, run.CompressedBytes, metadata); err != nil {
return false, nil
}
manifestMetadata := map[string]string{
"source": constants.AuditArchiveSource, "data-sha256": run.SHA256, "revision": strconv.Itoa(run.Revision),
}
if err := s.verifyObject(ctx, run.ManifestKey, -1, manifestMetadata); err != nil {
return false, nil
}
return true, nil
}
func (s *Service) verifyObject(ctx context.Context, key string, expectedSize int64, expectedMetadata map[string]string) error {
object, err := s.store.Stat(ctx, key)
if err != nil {
return fmt.Errorf("复核归档对象 metadata 失败: %w", err)
}
if expectedSize >= 0 && object.Size != expectedSize {
return fmt.Errorf("归档对象大小复核失败")
}
for name, value := range expectedMetadata {
if object.Metadata[strings.ToLower(name)] != value {
return fmt.Errorf("归档对象 metadata 字段 %s 复核失败", name)
}
}
return nil
}
func (s *Service) markFailed(ctx context.Context, runID uint, archiveErr error) {
summary := []rune(archiveErr.Error())
if len(summary) > 500 {
summary = summary[:500]
}
failedCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
_ = s.db.WithContext(failedCtx).Model(&model.LogArchiveRun{}).Where("id = ?", runID).Updates(map[string]any{
"status": constants.ArchiveStatusFailed, "error_summary": string(summary), "updated_at": time.Now(),
}).Error
}
func objectKeys(date time.Time, revision int) (string, string) {
prefix := fmt.Sprintf("audit-archive/v1/%04d/%02d/%02d", date.Year(), date.Month(), date.Day())
name := fmt.Sprintf("audit-events-%s-r%d", date.Format(time.DateOnly), revision)
return prefix + "/" + name + ".jsonl.gz", prefix + "/" + name + ".manifest.json"
}
func archiveMetadata(file *archiveFile, run *model.LogArchiveRun) map[string]string {
return map[string]string{
"schema-version": constants.AuditArchiveSchemaVersion,
"source": constants.AuditArchiveSource,
"archive-date": run.RangeStart.Format(time.DateOnly),
"timezone": constants.AuditArchiveTimezone,
"event-count": strconv.FormatInt(file.eventCount, 10),
"resource-count": strconv.FormatInt(file.resourceCount, 10),
"sha256": file.sha256,
"revision": strconv.Itoa(run.Revision),
}
}

View File

@@ -84,7 +84,7 @@ func (s *Service) RecordCarrierCallbackFailure(ctx context.Context, card *model.
}
s.auditWriter.WriteCardStateFailure(ctx, StateAudit{
ActionCode: constants.AuditActionIotCardRealnameCallbackSynced,
Summary: "运营商回调同步 IoT 卡实名状态失败", Card: card, IntegrationID: integrationID,
Summary: "运营商回调同步 IoT 卡实名状态失败", Card: card, IntegrationID: integrationID,
}, businessErr)
}
@@ -94,6 +94,7 @@ func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.R
return domain.RealnameDecision{}, errors.New(errors.CodeInternalError, "卡实名观测能力未完整配置")
}
var decision domain.RealnameDecision
var auditedCard *model.IotCard
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var card model.IotCard
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil {
@@ -102,6 +103,7 @@ func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.R
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡失败")
}
auditedCard = &card
nextDecision, decisionErr := domain.ApplyRealname(domain.CardRealnameSnapshot{
CardID: card.ID, Status: card.RealNameStatus, FirstRealnameAt: card.FirstRealnameAt,
ReversalCount: card.RealnameReversalCount, ReversalStartedAt: card.RealnameReversalStartedAt,
@@ -185,11 +187,26 @@ func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.R
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: constants.AuditActionIotCardRealnameCallbackSynced,
Summary: "运营商回调同步 IoT 卡实名状态",
Card: &card,
ActionCode: constants.AuditActionIotCardRealnameCallbackSynced,
Summary: "运营商回调同步 IoT 卡实名状态",
Card: &card,
IntegrationID: observation.Metadata.ObservationID,
BeforeData: map[string]any{"real_name_status": card.RealNameStatus, "first_realname_at": card.FirstRealnameAt},
BeforeData: map[string]any{"real_name_status": card.RealNameStatus, "first_realname_at": card.FirstRealnameAt},
AfterData: map[string]any{
"real_name_status": decision.AfterStatus, "first_realname_at": firstRealnameAfter(card.FirstRealnameAt, observation.Metadata.ObservedAt, decision.FirstVerified),
},
}); err != nil {
return err
}
} else if workerObservationAudited(ctx) && decision.StatusChanged {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerRealnameSynced,
Summary: "Worker 同步 IoT 卡实名事实", Card: &card,
IntegrationID: observation.Metadata.ObservationID,
BeforeData: map[string]any{"real_name_status": card.RealNameStatus, "first_realname_at": card.FirstRealnameAt},
AfterData: map[string]any{
"real_name_status": decision.AfterStatus, "first_realname_at": firstRealnameAfter(card.FirstRealnameAt, observation.Metadata.ObservedAt, decision.FirstVerified),
},
@@ -200,6 +217,13 @@ func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.R
return nil
})
if err != nil {
if workerObservationAudited(ctx) && auditedCard != nil && s.auditWriter != nil {
s.auditWriter.WriteCardStateFailure(ctx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerRealnameSynced,
Summary: "Worker 同步 IoT 卡实名事实失败", Card: auditedCard,
IntegrationID: observation.Metadata.ObservationID,
}, err)
}
return domain.RealnameDecision{}, err
}
if s.cache != nil {
@@ -226,6 +250,11 @@ func manualRefreshAuditAction(ctx context.Context) (string, bool) {
}
}
func workerObservationAudited(ctx context.Context) bool {
linkage := auditcontext.From(ctx)
return linkage.ActorKind == constants.AuditActorSystemTask && linkage.Source == constants.AuditSourceWorker
}
func realnameChangedEventID(cardID uint, observationID string) string {
prefix := "card-realname:"
digest := sha256.Sum256([]byte(strconv.FormatUint(uint64(cardID), 10) + ":" + observationID + ":changed"))

View File

@@ -33,6 +33,7 @@ func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domai
return domain.NetworkDecision{}, errors.New(errors.CodeInternalError, "卡网络观测能力未完整配置")
}
var decision domain.NetworkDecision
var auditedCard *model.IotCard
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var card model.IotCard
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil {
@@ -41,6 +42,7 @@ func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domai
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡网络事实失败")
}
auditedCard = &card
nextDecision, decisionErr := domain.ApplyNetwork(domain.CardNetworkSnapshot{
CardID: card.ID, NetworkStatus: card.NetworkStatus, StopReason: card.StopReason,
IsStandalone: card.IsStandalone, EnablePolling: card.EnablePolling,
@@ -117,10 +119,46 @@ func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domai
}); err != nil {
return err
}
} else if workerObservationAudited(ctx) && stateChanged {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
enablePolling := card.EnablePolling
if decision.StopPolling {
enablePolling = false
}
gatewayIMEI := card.GatewayCardIMEI
if decision.UpdateIMEI {
gatewayIMEI = decision.GatewayIMEI
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerNetworkSynced,
Summary: "Worker 同步 IoT 卡网络事实", Card: &card,
IntegrationID: observation.Metadata.ObservationID,
BeforeData: map[string]any{
"network_status": card.NetworkStatus, "stop_reason": card.StopReason,
"gateway_extend": card.GatewayExtend, "gateway_card_imei": card.GatewayCardIMEI,
"enable_polling": card.EnablePolling,
},
AfterData: map[string]any{
"network_status": decision.AfterStatus, "stop_reason": decision.StopReason,
"gateway_extend": decision.GatewayExtend, "gateway_card_imei": gatewayIMEI,
"enable_polling": enablePolling,
},
}); err != nil {
return err
}
}
return nil
})
if err != nil {
if workerObservationAudited(ctx) && auditedCard != nil && s.auditWriter != nil {
s.auditWriter.WriteCardStateFailure(ctx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerNetworkSynced,
Summary: "Worker 同步 IoT 卡网络事实失败", Card: auditedCard,
IntegrationID: observation.Metadata.ObservationID,
}, err)
}
return domain.NetworkDecision{}, err
}
if s.cache != nil {

View File

@@ -7,6 +7,7 @@ import (
"github.com/google/uuid"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
@@ -21,6 +22,7 @@ type SeriesRequest struct {
Source string `json:"source"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
ParentEventID string `json:"parent_event_id,omitempty"`
}
// DeviceCardsSeriesRequest 描述需要在后台展开设备有效绑定卡的观测请求。
@@ -59,6 +61,7 @@ type SeriesTaskPayload struct {
Source string `json:"source"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
ParentEventID string `json:"parent_event_id,omitempty"`
}
// RunResult 描述一次实际 Gateway 请求及公共观测应用结果。
@@ -135,7 +138,7 @@ func (s *SeriesTrigger) trigger(ctx context.Context, request SeriesRequest, cand
if s == nil || s.coordinator == nil || s.scheduler == nil || s.logger == nil {
return "", false, errors.New(errors.CodeInternalError, "卡观测序列触发能力未完整配置")
}
request = normalizeSeriesTrace(request)
request = normalizeSeriesTrace(ctx, request)
if err := validateSeriesRequest(request); err != nil {
return "", false, err
}
@@ -171,6 +174,7 @@ func (s *SeriesTrigger) trigger(ctx context.Context, request SeriesRequest, cand
Scene: originalRequest.Scene, ResourceType: originalRequest.ResourceType, ResourceID: originalRequest.ResourceID,
SyncType: originalRequest.SyncType, ExpectedValue: originalRequest.ExpectedValue, Source: originalRequest.Source,
RequestID: originalRequest.RequestID, CorrelationID: originalRequest.CorrelationID,
ParentEventID: originalRequest.ParentEventID,
}
if err := s.scheduler.Enqueue(ctx, payload); err != nil {
s.coordinator.ReleaseSchedule(ctx, seriesID, attempt)
@@ -303,12 +307,24 @@ func validateSeriesPayload(payload SeriesTaskPayload) error {
return validateSeriesRequest(SeriesRequest{
Scene: payload.Scene, ResourceType: payload.ResourceType, ResourceID: payload.ResourceID,
SyncType: payload.SyncType, Source: payload.Source, RequestID: payload.RequestID, CorrelationID: payload.CorrelationID,
ParentEventID: payload.ParentEventID,
})
}
func normalizeSeriesTrace(request SeriesRequest) SeriesRequest {
func normalizeSeriesTrace(ctx context.Context, request SeriesRequest) SeriesRequest {
linkage := auditcontext.From(ctx)
request.RequestID = strings.TrimSpace(request.RequestID)
request.CorrelationID = strings.TrimSpace(request.CorrelationID)
request.ParentEventID = strings.TrimSpace(request.ParentEventID)
if request.RequestID == "" {
request.RequestID = strings.TrimSpace(linkage.RequestID)
}
if request.CorrelationID == "" {
request.CorrelationID = strings.TrimSpace(linkage.CorrelationID)
}
if request.ParentEventID == "" {
request.ParentEventID = strings.TrimSpace(linkage.ParentEventID)
}
if request.RequestID == "" && request.CorrelationID == "" {
traceID := uuid.NewString()
request.RequestID = traceID

View File

@@ -32,6 +32,7 @@ func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domai
return domain.TrafficDecision{}, errors.New(errors.CodeInternalError, "卡流量观测能力未完整配置")
}
var decision domain.TrafficDecision
var auditedCard *model.IotCard
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var card model.IotCard
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil {
@@ -40,6 +41,7 @@ func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domai
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡流量事实失败")
}
auditedCard = &card
nextDecision, decisionErr := domain.ApplyTraffic(domain.CardTrafficSnapshot{
CardID: card.ID, DataUsageMB: card.DataUsageMB, CurrentMonthUsageMB: card.CurrentMonthUsageMB,
CurrentMonthStartDate: card.CurrentMonthStartDate, LastMonthTotalMB: card.LastMonthTotalMB,
@@ -99,10 +101,38 @@ func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domai
}); err != nil {
return err
}
} else if workerObservationAudited(ctx) && stateChanged {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerTrafficSynced,
Summary: "Worker 同步 IoT 卡流量事实", Card: &card,
IntegrationID: observation.Metadata.ObservationID,
BeforeData: map[string]any{
"data_usage_mb": card.DataUsageMB, "current_month_usage_mb": card.CurrentMonthUsageMB,
"current_month_start_date": card.CurrentMonthStartDate, "last_month_total_mb": card.LastMonthTotalMB,
"last_gateway_reading_mb": card.LastGatewayReadingMB,
},
AfterData: map[string]any{
"data_usage_mb": decision.DataUsageMB, "current_month_usage_mb": decision.CurrentMonthUsageMB,
"current_month_start_date": decision.CurrentMonthStartDate, "last_month_total_mb": decision.LastMonthTotalMB,
"last_gateway_reading_mb": decision.LastGatewayReadingMB, "increment_mb": decision.IncrementMB,
},
}); err != nil {
return err
}
}
return nil
})
if err != nil {
if workerObservationAudited(ctx) && auditedCard != nil && s.auditWriter != nil {
s.auditWriter.WriteCardStateFailure(ctx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerTrafficSynced,
Summary: "Worker 同步 IoT 卡流量事实失败", Card: auditedCard,
IntegrationID: observation.Metadata.ObservationID,
}, err)
}
return domain.TrafficDecision{}, err
}
if s.cache != nil {