All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 15m33s
- 新增成对迁移 000231 与三张快照表:日级头行、设备粒度激活行、到期事件粒度续费行,以快照日期为唯一键 - 新增每日 03:30(Asia/Shanghai)日报快照生成任务与幂等整日替换,失败重试沿用同一目标日 - 新增六条受控入口:两张报表的汇总、日/月趋势与受控导出,配套查询层只读快照事实 - 新增 operations_activation 与 operations_renewal 两个导出场景,创建期冻结筛选与可见店铺范围、派发期冻结表头、执行期只按冻结值复核资格 - 采购数量口径按系统内未删除设备数实施并在 PRD 标注,附实测差额依据 - 同步证据链 requirement-evidence.json 与入口能力矩阵、ARCHITECTURE 与验证记录 - 归档 change add-operations-reports 并新建主 Spec openspec/specs/operations-report/spec.md
144 lines
6.7 KiB
Go
144 lines
6.7 KiB
Go
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
|
||
}
|