package operationsreport import ( "context" "time" "gorm.io/gorm" domainreport "github.com/break/junhong_cmp_fiber/internal/domain/operationsreport" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/errors" ) const ( // activationInsertBatchSize 是设备粒度激活行的批量写入行数。 activationInsertBatchSize = 500 // renewalInsertBatchSize 是到期事件粒度续费行的批量写入行数。 renewalInsertBatchSize = 500 ) // SnapshotStore 是运营报表日报快照的整日替换写入适配。 // // 三张表都不设软删除列,因此删除是物理删除;整日替换与「快照日期唯一」不冲突。 // 一切跨日比较都使用显式日期参数(snapshot_date = ?::date),不把 time.Time 直接与 DATE 列比较。 type SnapshotStore struct { db *gorm.DB } // NewSnapshotStore 创建运营报表日报快照写入适配。 func NewSnapshotStore(db *gorm.DB) *SnapshotStore { return &SnapshotStore{db: db} } // ReplaceDay 在单事务内先删除该日三张快照表的全部行,再整日写入头行与两类明细行,最后校验不变量。 // // 幂等:同一日期重复执行的结果等于最后一次执行的结果,不产生重复行或第二套口径; // 任一步失败整体回滚,使该日不残留部分口径(失败交既有任务重试,重试沿用同一目标日期)。 func (s *SnapshotStore) ReplaceDay(ctx context.Context, snapshotDate time.Time, snapshot model.OperationsReportSnapshot, activations []model.OperationsReportActivationRow, renewals []model.OperationsReportRenewalRow) error { day := domainreport.FormatSnapshotDay(snapshotDate) return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := deleteDay(ctx, tx, day); err != nil { return err } if err := tx.Create(&snapshot).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "写入运营报表快照头行失败") } if len(activations) > 0 { if err := tx.CreateInBatches(activations, activationInsertBatchSize).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "写入运营报表设备激活快照行失败") } } if len(renewals) > 0 { if err := tx.CreateInBatches(renewals, renewalInsertBatchSize).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "写入运营报表套餐续费快照行失败") } } return verifyDay(ctx, tx, day, snapshot) }) } // deleteDay 物理删除该日的三张快照表全部行(整日替换的前半段)。 func deleteDay(ctx context.Context, tx *gorm.DB, day string) error { if err := tx.WithContext(ctx). Where("snapshot_date = ?::date", day). Delete(&model.OperationsReportSnapshot{}).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "删除运营报表快照头行失败") } if err := tx.WithContext(ctx). Where("snapshot_date = ?::date", day). Delete(&model.OperationsReportActivationRow{}).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "删除运营报表设备激活快照行失败") } if err := tx.WithContext(ctx). Where("snapshot_date = ?::date", day). Delete(&model.OperationsReportRenewalRow{}).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "删除运营报表套餐续费快照行失败") } return nil } // activationAggregate 是激活明细行的库内汇总,用于独立复核头行值。 type activationAggregate struct { PurchasedDeviceCount int64 `gorm:"column:purchased_device_count"` ActivatedDeviceCount int64 `gorm:"column:activated_device_count"` OnlineDeviceCount int64 `gorm:"column:online_device_count"` ActiveDeviceCount int64 `gorm:"column:active_device_count"` TotalRealTrafficMB float64 `gorm:"column:total_real_traffic_mb"` } // renewalAggregate 是续费明细行的库内去重汇总,用于独立复核头行值。 type renewalAggregate struct { DueAssets int64 `gorm:"column:due_assets"` RenewedAssets int64 `gorm:"column:renewed_assets"` } // verifyDay 在写入后从库里重新汇总,校验两条可验证不变量。 // // ① 分组行之和等于头行值:结构化分组不改变总和,因此该不变量在 SQL 层等价于 // 「全部明细行的指标之和等于头行值」,只要成立,任一受支持维度下的分组行之和必然等于头行值。 // ② 续费资产数不大于到期资产数:续费资产集合是到期资产集合的子集,续费率不超过 100% 由构造保证。 // 任一不变量不成立即返回错误,由外层事务整体回滚。 func verifyDay(ctx context.Context, tx *gorm.DB, day string, snapshot model.OperationsReportSnapshot) error { var activation activationAggregate if err := tx.WithContext(ctx).Table("tb_operations_report_activation_row"). Select(`COALESCE(SUM(CASE WHEN purchased THEN 1 ELSE 0 END), 0) AS purchased_device_count, COALESCE(SUM(CASE WHEN realnamed THEN 1 ELSE 0 END), 0) AS activated_device_count, COALESCE(SUM(CASE WHEN online THEN 1 ELSE 0 END), 0) AS online_device_count, COALESCE(SUM(CASE WHEN active THEN 1 ELSE 0 END), 0) AS active_device_count, COALESCE(SUM(real_traffic_mb), 0)::float8 AS total_real_traffic_mb`). Where("snapshot_date = ?::date", day). Scan(&activation).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "复核运营报表设备激活快照行失败") } if activation.PurchasedDeviceCount != snapshot.PurchasedDeviceCount || activation.ActivatedDeviceCount != snapshot.ActivatedDeviceCount || activation.OnlineDeviceCount != snapshot.OnlineDeviceCount || activation.ActiveDeviceCount != snapshot.ActiveDeviceCount || domainreport.Round2(activation.TotalRealTrafficMB) != domainreport.Round2(snapshot.TotalRealTrafficMB) { return errors.New(errors.CodeInternalError, "运营报表快照头行与设备激活分组行不一致,本次生成已回滚") } var renewal renewalAggregate if err := tx.WithContext(ctx).Table("tb_operations_report_renewal_row"). Select(`COUNT(DISTINCT asset_type || ':' || asset_id) AS due_assets, COUNT(DISTINCT asset_type || ':' || asset_id) FILTER (WHERE renewed) AS renewed_assets`). Where("snapshot_date = ?::date", day). Scan(&renewal).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "复核运营报表套餐续费快照行失败") } if renewal.DueAssets != snapshot.RenewalDueAssetCount || renewal.RenewedAssets != snapshot.RenewalRenewedAssetCount { return errors.New(errors.CodeInternalError, "运营报表快照头行与套餐续费分组行不一致,本次生成已回滚") } if renewal.RenewedAssets > renewal.DueAssets { return errors.New(errors.CodeInternalError, "运营报表快照的续费资产数超过到期资产数,本次生成已回滚") } return nil }