Files
junhong_cmp_fiber/internal/infrastructure/operationsreport/snapshot_store.go
break 5e78809b93
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 15m33s
feat(运营报表): AUG26-015 设备激活与套餐续费日报快照、查询趋势与受控导出
- 新增成对迁移 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
2026-09-18 09:42:28 +08:00

144 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}