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
551 lines
19 KiB
Go
551 lines
19 KiB
Go
// Package operationsreport 提供运营报表(设备激活情况与套餐续费情况)的只读投影。
|
||
//
|
||
// 查询只读三张日报快照表,不回查设备、卡、套餐使用等实时事实;
|
||
// 累计类指标一律取所选结束日的快照,结束日无快照时不回退更早快照;
|
||
// 时间筛选复用统一时间筛选契约的严格解析器(带时区的 RFC3339 秒级、闭区间),
|
||
// 快照日期按「零点(+08:00)落入区间」判定。
|
||
package operationsreport
|
||
|
||
import (
|
||
"context"
|
||
"sort"
|
||
"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/internal/model/dto"
|
||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||
"github.com/break/junhong_cmp_fiber/pkg/utils"
|
||
)
|
||
|
||
// Query 查询运营报表日报快照。
|
||
type Query struct {
|
||
db *gorm.DB
|
||
}
|
||
|
||
// NewQuery 创建运营报表查询。
|
||
func NewQuery(db *gorm.DB) *Query {
|
||
return &Query{db: db}
|
||
}
|
||
|
||
// ActivationSummary 查询设备激活情况汇总。
|
||
//
|
||
// 累计类指标取所选结束日的快照;新增激活数按结束日与基期累计之差计算;
|
||
// 未选择分组维度时只返回一行「全部」,与头行合计完全一致。
|
||
func (q *Query) ActivationSummary(ctx context.Context, request dto.OperationsActivationSummaryRequest) (*dto.OperationsActivationSummaryResponse, error) {
|
||
if err := q.ready(ctx); err != nil {
|
||
return nil, err
|
||
}
|
||
start, end, err := utils.ParseTimeRange(request.StartTime, request.EndTime)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
dimension, groupName, err := resolveGroupDimension(request.GroupBy, domainreport.ActivationDimensionName)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
days, err := q.matchedDays(ctx, lowerDay(start), upperDay(end))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
response := &dto.OperationsActivationSummaryResponse{
|
||
SnapshotDates: formatDays(days),
|
||
GroupBy: dimension,
|
||
GroupName: groupName,
|
||
Items: []dto.OperationsActivationSummaryItem{},
|
||
}
|
||
endDay, hasSelectedEnd := selectedEndDay(end, days)
|
||
if !hasSelectedEnd {
|
||
return response, nil
|
||
}
|
||
heads, err := q.headRows(ctx, []time.Time{endDay})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
head, ok := heads[dayKey(endDay)]
|
||
if !ok {
|
||
// 所选结束日无快照:累计类与派生指标一律为空、分组行为空集,绝不就近回退更早快照。
|
||
return response, nil
|
||
}
|
||
response.HasSnapshot = true
|
||
|
||
baseDay, hasBase := summaryBaseDay(start, days)
|
||
var base *activationMetrics
|
||
if hasBase {
|
||
baseHeads, err := q.headRows(ctx, []time.Time{baseDay})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if row, exists := baseHeads[dayKey(baseDay)]; exists {
|
||
metrics := metricsFromHead(row)
|
||
base = &metrics
|
||
}
|
||
}
|
||
|
||
// 可见店铺范围(SubordinateShopIDs)只在代理账号上计算,而本能力只放行超管与平台账号,
|
||
// 因此 scope 恒为空:Totals 取头行即等于分组行之和;若未来放开给带店铺范围的账号,
|
||
// Totals 与导出合计行都必须同步收敛到按 scope 的聚合,不能继续读全库头行。
|
||
endMetrics := metricsFromHead(head)
|
||
totals := endMetrics.item(domainreport.DimensionAll, endDay)
|
||
totals.NewActivatedDeviceCount = newActivatedDeviceCount(endMetrics.ActivatedDeviceCount, base)
|
||
response.Totals = &totals
|
||
|
||
if dimension == "" && len(snapshotScope(ctx)) == 0 {
|
||
response.Items = append(response.Items, totals)
|
||
return response, nil
|
||
}
|
||
|
||
groups, err := q.activationDayGroups(ctx, []time.Time{endDay}, dimension, snapshotScope(ctx))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var baseGroups map[groupKey]activationMetrics
|
||
if base != nil {
|
||
baseDayGroups, err := q.activationDayGroups(ctx, []time.Time{baseDay}, dimension, snapshotScope(ctx))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
baseGroups = baseDayGroups[dayKey(baseDay)]
|
||
}
|
||
endGroups := groups[dayKey(endDay)]
|
||
items := make([]dto.OperationsActivationSummaryItem, 0, len(endGroups))
|
||
for key, metrics := range endGroups {
|
||
item := metrics.item(key.value, endDay)
|
||
baseMetrics, hasBaseMetrics := baseGroups[key]
|
||
item.NewActivatedDeviceCount = newActivatedDeviceCount(metrics.ActivatedDeviceCount,
|
||
activationMetricsPointer(baseMetrics, hasBaseMetrics))
|
||
items = append(items, item)
|
||
}
|
||
sortActivationItems(items)
|
||
response.Items = items
|
||
return response, nil
|
||
}
|
||
|
||
// ActivationTrend 查询设备激活情况趋势。
|
||
//
|
||
// 按日每个快照日一点,按月每个自然月一点;累计类指标取该期最后一个有快照日的快照值;
|
||
// 新增激活数取相邻期同口径之差,任一侧无快照时为空;无快照的期不出现。
|
||
func (q *Query) ActivationTrend(ctx context.Context, request dto.OperationsActivationTrendRequest) (*dto.OperationsActivationTrendResponse, error) {
|
||
if err := q.ready(ctx); err != nil {
|
||
return nil, err
|
||
}
|
||
granularity, ok := domainreport.NormalizeGranularity(request.Granularity)
|
||
if !ok {
|
||
return nil, errors.New(errors.CodeInvalidParam, "granularity 只能为 day 或 month")
|
||
}
|
||
start, end, err := utils.ParseTimeRange(request.StartTime, request.EndTime)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
dimension, groupName, err := resolveGroupDimension(request.GroupBy, domainreport.ActivationDimensionName)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
periods, err := q.activationPeriods(ctx, granularity, start, end)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
response := &dto.OperationsActivationTrendResponse{
|
||
Granularity: granularity,
|
||
GroupBy: dimension,
|
||
GroupName: groupName,
|
||
Points: []dto.OperationsActivationTrendPoint{},
|
||
}
|
||
if len(periods) == 0 {
|
||
return response, nil
|
||
}
|
||
|
||
scope := snapshotScope(ctx)
|
||
pointDays := make([]time.Time, 0, len(periods)*2)
|
||
seen := make(map[string]struct{}, len(periods)*2)
|
||
baseDayOfPeriod := make(map[string]time.Time, len(periods))
|
||
for _, period := range periods {
|
||
pointDays = appendUniqueDay(pointDays, seen, period.RepresentativeDay)
|
||
}
|
||
previousDays, err := q.previousPeriodRepresentatives(ctx, granularity, periods)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for _, period := range periods {
|
||
if base, exists := previousDays[period.Key]; exists {
|
||
baseDayOfPeriod[period.Key] = base
|
||
pointDays = appendUniqueDay(pointDays, seen, base)
|
||
}
|
||
}
|
||
|
||
metricsByDay := make(map[string]map[groupKey]activationMetrics, len(pointDays))
|
||
if dimension == "" && len(scope) == 0 {
|
||
heads, err := q.headRows(ctx, pointDays)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for _, day := range pointDays {
|
||
head, exists := heads[dayKey(day)]
|
||
if !exists {
|
||
continue
|
||
}
|
||
metricsByDay[dayKey(day)] = map[groupKey]activationMetrics{
|
||
{value: domainreport.DimensionAll}: metricsFromHead(head),
|
||
}
|
||
}
|
||
} else {
|
||
metricsByDay, err = q.activationDayGroups(ctx, pointDays, dimension, scope)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
for _, period := range periods {
|
||
element, exists := metricsByDay[dayKey(period.RepresentativeDay)]
|
||
if !exists {
|
||
continue
|
||
}
|
||
baseElement := metricsByDay[dayKey(baseDayOfPeriod[period.Key])]
|
||
keys := sortedMetricsKeys(element)
|
||
for _, key := range keys {
|
||
metrics := element[key]
|
||
item := metrics.item(key.value, period.RepresentativeDay)
|
||
baseMetrics, hasBaseMetrics := baseElement[key]
|
||
item.NewActivatedDeviceCount = newActivatedDeviceCount(metrics.ActivatedDeviceCount,
|
||
activationMetricsPointer(baseMetrics, hasBaseMetrics))
|
||
response.Points = append(response.Points, dto.OperationsActivationTrendPoint{
|
||
Period: period.Key,
|
||
OperationsActivationSummaryItem: item,
|
||
})
|
||
}
|
||
}
|
||
return response, nil
|
||
}
|
||
|
||
// RenewalSummary 查询套餐续费情况汇总。
|
||
//
|
||
// 到期与续费都是统计期内的流式指标,按资产去重;同一资产在统计期内多次到期各计一次;
|
||
// 续费资产集合是到期资产集合的子集,续费率不超过 100% 由构造保证。
|
||
func (q *Query) RenewalSummary(ctx context.Context, request dto.OperationsRenewalSummaryRequest) (*dto.OperationsRenewalSummaryResponse, error) {
|
||
if err := q.ready(ctx); err != nil {
|
||
return nil, err
|
||
}
|
||
start, end, err := utils.ParseTimeRange(request.StartTime, request.EndTime)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
dimension, groupName, err := resolveGroupDimension(request.GroupBy, domainreport.RenewalDimensionName)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
days, err := q.matchedDays(ctx, lowerDay(start), upperDay(end))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
response := &dto.OperationsRenewalSummaryResponse{
|
||
SnapshotDates: formatDays(days),
|
||
GroupBy: dimension,
|
||
GroupName: groupName,
|
||
Items: []dto.OperationsRenewalSummaryItem{},
|
||
}
|
||
endDay, hasSelectedEnd := selectedEndDay(end, days)
|
||
if !hasSelectedEnd {
|
||
return response, nil
|
||
}
|
||
heads, err := q.headRows(ctx, []time.Time{endDay})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
head, ok := heads[dayKey(endDay)]
|
||
if !ok {
|
||
// 所选结束日无快照:全部指标为空且分组行为空集,绝不就近回退更早快照。
|
||
return response, nil
|
||
}
|
||
response.HasSnapshot = true
|
||
|
||
scope := snapshotScope(ctx)
|
||
if len(days) == 1 && dimension == "" && len(scope) == 0 {
|
||
// 单日且不分组时直接用头行作为合计,读取量为 O(1)。
|
||
item := renewalMetricsFromHead(head).item(domainreport.DimensionAll)
|
||
response.Totals = &item
|
||
response.Items = append(response.Items, item)
|
||
return response, nil
|
||
}
|
||
|
||
lower, upper := days[0], endDay
|
||
groups, err := q.renewalsAggregate(ctx, &lower, &upper, "", dimension, scope)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
totalItem := groups[""][groupKey{value: domainreport.DimensionAll}].item(domainreport.DimensionAll)
|
||
response.Totals = &totalItem
|
||
if dimension == "" {
|
||
response.Items = append(response.Items, totalItem)
|
||
return response, nil
|
||
}
|
||
items := make([]dto.OperationsRenewalSummaryItem, 0, len(groups[""]))
|
||
for key, metrics := range groups[""] {
|
||
items = append(items, metrics.item(key.value))
|
||
}
|
||
sortRenewalItems(items)
|
||
response.Items = items
|
||
return response, nil
|
||
}
|
||
|
||
// RenewalTrend 查询套餐续费情况趋势。
|
||
//
|
||
// 按日每个快照日一点,按月每个自然月一点;到期与续费为期内按资产去重的流式指标;
|
||
// 无快照的期不出现。
|
||
func (q *Query) RenewalTrend(ctx context.Context, request dto.OperationsRenewalTrendRequest) (*dto.OperationsRenewalTrendResponse, error) {
|
||
if err := q.ready(ctx); err != nil {
|
||
return nil, err
|
||
}
|
||
granularity, ok := domainreport.NormalizeGranularity(request.Granularity)
|
||
if !ok {
|
||
return nil, errors.New(errors.CodeInvalidParam, "granularity 只能为 day 或 month")
|
||
}
|
||
start, end, err := utils.ParseTimeRange(request.StartTime, request.EndTime)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
dimension, groupName, err := resolveGroupDimension(request.GroupBy, domainreport.RenewalDimensionName)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
response := &dto.OperationsRenewalTrendResponse{
|
||
Granularity: granularity,
|
||
GroupBy: dimension,
|
||
GroupName: groupName,
|
||
Points: []dto.OperationsRenewalTrendPoint{},
|
||
}
|
||
lower, upper := lowerDay(start), upperDay(end)
|
||
if lower != nil && upper != nil && lower.After(*upper) {
|
||
return response, nil
|
||
}
|
||
groups, err := q.renewalsAggregate(ctx, lower, upper, periodExpr(granularity), dimension, snapshotScope(ctx))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
periodKeys := make([]string, 0, len(groups))
|
||
for key := range groups {
|
||
periodKeys = append(periodKeys, key)
|
||
}
|
||
sort.Strings(periodKeys)
|
||
for _, periodKey := range periodKeys {
|
||
element := groups[periodKey]
|
||
keys := sortedMetricsKeys(element)
|
||
for _, key := range keys {
|
||
response.Points = append(response.Points, dto.OperationsRenewalTrendPoint{
|
||
Period: periodKey,
|
||
OperationsRenewalSummaryItem: element[key].item(key.value),
|
||
})
|
||
}
|
||
}
|
||
return response, nil
|
||
}
|
||
|
||
// ready 校验查询已装配,并要求调用者为超级管理员或平台账号;其他账号统一按资源不可见处理。
|
||
func (q *Query) ready(ctx context.Context) error {
|
||
if q == nil || q.db == nil {
|
||
return errors.New(errors.CodeServiceUnavailable, "运营报表查询尚未配置")
|
||
}
|
||
userType := middleware.GetUserTypeFromContext(ctx)
|
||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// snapshotScope 返回请求人的可见店铺范围;为空表示不受限。
|
||
func snapshotScope(ctx context.Context) []uint {
|
||
return middleware.GetSubordinateShopIDs(ctx)
|
||
}
|
||
|
||
// matchedDays 返回闭区间内实际命中的快照日期(升序);两端缺省表示该端不限。
|
||
func (q *Query) matchedDays(ctx context.Context, lower, upper *time.Time) ([]time.Time, error) {
|
||
query := q.db.WithContext(ctx).Table("tb_operations_report_snapshot").Select("snapshot_date")
|
||
if lower != nil {
|
||
query = query.Where("snapshot_date >= ?::date", domainreport.FormatSnapshotDay(*lower))
|
||
}
|
||
if upper != nil {
|
||
query = query.Where("snapshot_date <= ?::date", domainreport.FormatSnapshotDay(*upper))
|
||
}
|
||
var rows []time.Time
|
||
if err := query.Order("snapshot_date ASC").Scan(&rows).Error; err != nil {
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询运营报表快照日期失败")
|
||
}
|
||
// DATE 列读回是 UTC 零点,统一归一为上海自然日零点,
|
||
// 使一切跨日比较与期归属使用同一时间表示(避免 UTC 零点与东八区零点相差 8 小时)。
|
||
days := make([]time.Time, 0, len(rows))
|
||
for _, row := range rows {
|
||
days = append(days, domainreport.SnapshotDay(row))
|
||
}
|
||
return days, nil
|
||
}
|
||
|
||
// headRows 批量读取指定快照日的头行,键为 yyyy-MM-dd。
|
||
func (q *Query) headRows(ctx context.Context, days []time.Time) (map[string]model.OperationsReportSnapshot, error) {
|
||
result := make(map[string]model.OperationsReportSnapshot, len(days))
|
||
if len(days) == 0 {
|
||
return result, nil
|
||
}
|
||
var rows []model.OperationsReportSnapshot
|
||
if err := q.db.WithContext(ctx).Model(&model.OperationsReportSnapshot{}).
|
||
Where("snapshot_date IN ?", uniqueDays(days)).
|
||
Find(&rows).Error; err != nil {
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询运营报表快照头行失败")
|
||
}
|
||
for _, row := range rows {
|
||
result[domainreport.FormatSnapshotDay(row.SnapshotDate)] = row
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// uniqueDays 去除重复快照日后返回。
|
||
func uniqueDays(days []time.Time) []time.Time {
|
||
seen := make(map[string]struct{}, len(days))
|
||
result := make([]time.Time, 0, len(days))
|
||
for _, day := range days {
|
||
key := dayKey(day)
|
||
if _, exists := seen[key]; exists {
|
||
continue
|
||
}
|
||
seen[key] = struct{}{}
|
||
result = append(result, day)
|
||
}
|
||
return result
|
||
}
|
||
|
||
// lowerDay 把区间起点换算为最早入选的快照日期;起点缺省表示不限。
|
||
func lowerDay(start *time.Time) *time.Time {
|
||
if start == nil {
|
||
return nil
|
||
}
|
||
day := domainreport.LowerBoundDay(*start)
|
||
return &day
|
||
}
|
||
|
||
// upperDay 把区间终点换算为最晚入选的快照日期;终点缺省表示不限。
|
||
func upperDay(end *time.Time) *time.Time {
|
||
if end == nil {
|
||
return nil
|
||
}
|
||
day := domainreport.UpperBoundDay(*end)
|
||
return &day
|
||
}
|
||
|
||
// selectedEndDay 返回累计类指标必须取用的「所选结束日」。
|
||
//
|
||
// 传了 end_time 时一律取落界规则给出的那一天(domain.UpperBoundDay,即该时刻所在上海自然日),
|
||
// 该日无快照就按无快照作答,绝不就近回退到区间内更早的快照;
|
||
// 未传 end_time 时才退化为区间内最后一个命中快照日。
|
||
func selectedEndDay(end *time.Time, days []time.Time) (time.Time, bool) {
|
||
// 区间内没有任何命中快照日时不得再往前走:同日亚日区间(起点晚于该日零点)下
|
||
// 该日零点并不落在区间内,该日不得入选,否则 RenewalSummary 会取空切片的 days[0]。
|
||
if len(days) == 0 {
|
||
return time.Time{}, false
|
||
}
|
||
if end != nil {
|
||
return domainreport.UpperBoundDay(*end), true
|
||
}
|
||
return days[len(days)-1], true
|
||
}
|
||
|
||
// summaryBaseDay 返回汇总查询的基期快照日:传入区间起点时按「起始日的前一自然日」取值,
|
||
// 未传起点时取结束日之前最近的一个快照日。基期无快照时新增类指标为空。
|
||
func summaryBaseDay(start *time.Time, days []time.Time) (time.Time, bool) {
|
||
if len(days) == 0 {
|
||
return time.Time{}, false
|
||
}
|
||
endDay := days[len(days)-1]
|
||
if start != nil {
|
||
return domainreport.LowerBoundDay(*start).AddDate(0, 0, -1), true
|
||
}
|
||
for index := len(days) - 1; index >= 0; index-- {
|
||
if days[index].Before(endDay) {
|
||
return days[index], true
|
||
}
|
||
}
|
||
return time.Time{}, false
|
||
}
|
||
|
||
// resolveGroupDimension 校验并归一分组维度。
|
||
// 未选择维度时分组列为「全部」;维度不受支持时按参数非法拒绝。
|
||
func resolveGroupDimension(code string, resolve func(string) (string, bool)) (string, string, error) {
|
||
if code == "" {
|
||
return "", domainreport.DimensionAll, nil
|
||
}
|
||
name, ok := resolve(code)
|
||
if !ok {
|
||
return "", "", errors.New(errors.CodeInvalidParam, "不支持的分组维度 "+code)
|
||
}
|
||
return code, name, nil
|
||
}
|
||
|
||
// newActivatedDeviceCount 计算新增激活数:结束日累计减去基期累计。
|
||
// 基期缺失或基期无快照时为空;不做零下限截断,实名逆转导致的新增为负数如实返回。
|
||
func newActivatedDeviceCount(current int64, base *activationMetrics) *int64 {
|
||
if base == nil {
|
||
return nil
|
||
}
|
||
value := current - base.ActivatedDeviceCount
|
||
return &value
|
||
}
|
||
|
||
// activationMetricsPointer 按存在性返回指标指针,用于区分「基期无该分组」与「基期该分组为零」。
|
||
func activationMetricsPointer(metrics activationMetrics, exists bool) *activationMetrics {
|
||
if !exists {
|
||
return nil
|
||
}
|
||
return &metrics
|
||
}
|
||
|
||
// formatDays 输出快照日期文本集合。
|
||
func formatDays(days []time.Time) []string {
|
||
result := make([]string, 0, len(days))
|
||
for _, day := range days {
|
||
result = append(result, dayKey(day))
|
||
}
|
||
return result
|
||
}
|
||
|
||
// dayKey 把快照日归一为上海自然日的 yyyy-MM-dd 文本。
|
||
// 查询侧的快照日一律归一为上海自然日零点,因此这里按上海自然日取键。
|
||
func dayKey(day time.Time) string {
|
||
return domainreport.FormatSnapshotDay(day)
|
||
}
|
||
|
||
// appendUniqueDay 按日期键去重追加。
|
||
func appendUniqueDay(target []time.Time, seen map[string]struct{}, day time.Time) []time.Time {
|
||
key := dayKey(day)
|
||
if _, exists := seen[key]; exists {
|
||
return target
|
||
}
|
||
seen[key] = struct{}{}
|
||
return append(target, day)
|
||
}
|
||
|
||
// sortedMetricsKeys 按分组值排序分组键,保证行序可复现。
|
||
func sortedMetricsKeys[T any](element map[groupKey]T) []groupKey {
|
||
keys := make([]groupKey, 0, len(element))
|
||
for key := range element {
|
||
keys = append(keys, key)
|
||
}
|
||
sort.Slice(keys, func(i, j int) bool {
|
||
if keys[i].value != keys[j].value {
|
||
return keys[i].value < keys[j].value
|
||
}
|
||
return keys[i].identity < keys[j].identity
|
||
})
|
||
return keys
|
||
}
|
||
|
||
// sortActivationItems 按分组值与维度名排序激活情况分组行。
|
||
func sortActivationItems(items []dto.OperationsActivationSummaryItem) {
|
||
sort.Slice(items, func(i, j int) bool { return items[i].GroupValue < items[j].GroupValue })
|
||
}
|
||
|
||
// sortRenewalItems 按分组值排序套餐续费分组行。
|
||
func sortRenewalItems(items []dto.OperationsRenewalSummaryItem) {
|
||
sort.Slice(items, func(i, j int) bool { return items[i].GroupValue < items[j].GroupValue })
|
||
}
|