// 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 }) }