This commit is contained in:
350
internal/application/auditarchive/integration.go
Normal file
350
internal/application/auditarchive/integration.go
Normal 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),
|
||||
}
|
||||
}
|
||||
542
internal/application/auditarchive/retention.go
Normal file
542
internal/application/auditarchive/retention.go
Normal 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)
|
||||
}
|
||||
412
internal/application/auditarchive/service.go
Normal file
412
internal/application/auditarchive/service.go
Normal 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),
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user