feat(运营报表): AUG26-015 设备激活与套餐续费日报快照、查询趋势与受控导出
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
This commit is contained in:
2026-09-18 09:42:28 +08:00
parent 6333f4ad13
commit 5e78809b93
46 changed files with 5580 additions and 101 deletions

View File

@@ -0,0 +1,375 @@
package operationsreport
import (
"context"
"strconv"
"strings"
"time"
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/errors"
)
// groupKey 是一个分组行在结果中的唯一键value 是展示值identity 是实体键。
// 同一展示值下的不同实体不会被合并(例如两个店铺同名为「未设置」仍各占一行)。
type groupKey struct {
value string
identity string
}
// activationMetrics 是一行(头行或分组聚合)的激活指标原始值。
type activationMetrics struct {
PurchasedDeviceCount int64
ActivatedDeviceCount int64
OnlineDeviceCount int64
ActiveDeviceCount int64
TotalRealTrafficMB float64
}
// metricsFromHead 由快照头行构造激活指标原始值。
func metricsFromHead(head model.OperationsReportSnapshot) activationMetrics {
return activationMetrics{
PurchasedDeviceCount: head.PurchasedDeviceCount,
ActivatedDeviceCount: head.ActivatedDeviceCount,
OnlineDeviceCount: head.OnlineDeviceCount,
ActiveDeviceCount: head.ActiveDeviceCount,
TotalRealTrafficMB: head.TotalRealTrafficMB,
}
}
// item 按报表口径组装一行激活指标。
// 分母为零的比率与卡均为空值;累计用量按 1 GB = 1024 MB 折算;
// 预测卡均的当月口径取所选结束日所在上海自然月。
func (m activationMetrics) item(groupValue string, endDay time.Time) dto.OperationsActivationSummaryItem {
purchased := m.PurchasedDeviceCount
activated := m.ActivatedDeviceCount
online := m.OnlineDeviceCount
active := m.ActiveDeviceCount
item := dto.OperationsActivationSummaryItem{
GroupValue: groupValue,
PurchasedDeviceCount: &purchased,
ActivatedDeviceCount: &activated,
OnlineDeviceCount: &online,
ActiveDeviceCount: &active,
}
if rate, ok := domainreport.Ratio(activated, purchased); ok {
item.ActivationRate = &rate
}
trafficGB := domainreport.Round2(m.TotalRealTrafficMB / domainreport.MBPerGB)
item.TotalRealTrafficGB = &trafficGB
if average, ok := domainreport.CardAverageGB(m.TotalRealTrafficMB, online); ok {
item.PerUserAverageGB = &average
}
if forecast, ok := domainreport.ForecastCardAverageGB(m.TotalRealTrafficMB, online, endDay); ok {
item.ForecastAverageIncludingZeroGB = &forecast
}
if forecast, ok := domainreport.ForecastCardAverageGB(m.TotalRealTrafficMB, active, endDay); ok {
item.ForecastAverageExcludingZeroGB = &forecast
}
return item
}
// renewalMetrics 是一组(头行或分组聚合)的到期与续费资产数。
type renewalMetrics struct {
DueAssets int64
RenewedAssets int64
}
// renewal 按报表口径组装一行续费指标。
// 续费率分母为零时为空值;新增未续费数为到期数减续费数,不小于零。
func (m renewalMetrics) item(groupValue string) dto.OperationsRenewalSummaryItem {
due := m.DueAssets
renewed := m.RenewedAssets
unrenewed := due - renewed
if unrenewed < 0 {
unrenewed = 0
}
item := dto.OperationsRenewalSummaryItem{
GroupValue: groupValue,
DueAssetCount: &due,
RenewedAssetCount: &renewed,
NewUnrenewedAssetCount: &unrenewed,
}
if rate, ok := domainreport.RenewalRate(renewed, due); ok {
item.RenewalRate = &rate
}
return item
}
// renewalMetricsFromHead 由快照头行构造续费指标原始值。
func renewalMetricsFromHead(head model.OperationsReportSnapshot) renewalMetrics {
return renewalMetrics{DueAssets: head.RenewalDueAssetCount, RenewedAssets: head.RenewalRenewedAssetCount}
}
// aggregateKeyRow 是一次分组聚合查询的原始投影。
// 只填充当前维度与当前报表实际选择的列,其余列保持零值。
type aggregateKeyRow struct {
Period string `gorm:"column:period"`
SnapshotDay string `gorm:"column:snapshot_day"`
DeviceName string `gorm:"column:device_name"`
DeviceModel string `gorm:"column:device_model"`
Manufacturer string `gorm:"column:manufacturer"`
BusinessUserGroupID *uint `gorm:"column:business_user_group_id"`
BusinessUserGroupName string `gorm:"column:business_user_group_name"`
AgentAccountID *uint `gorm:"column:agent_account_id"`
AgentAccountName string `gorm:"column:agent_account_name"`
RootShopID *uint `gorm:"column:root_shop_id"`
RootShopName string `gorm:"column:root_shop_name"`
ShopID *uint `gorm:"column:shop_id"`
ShopName string `gorm:"column:shop_name"`
BusinessOwnerAccountID *uint `gorm:"column:business_owner_account_id"`
BusinessOwnerName string `gorm:"column:business_owner_name"`
SeriesID *uint `gorm:"column:series_id"`
SeriesName string `gorm:"column:series_name"`
PackageName string `gorm:"column:package_name"`
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"`
DueAssets int64 `gorm:"column:due_assets"`
RenewedAssets int64 `gorm:"column:renewed_assets"`
}
// activationKeyColumns 返回激活情况维度在快照行上的分组键列。
// 实体 ID 列一并进入分组键,避免同展示名不同实体被合并。
func activationKeyColumns(dimension string) []string {
switch dimension {
case domainreport.DimensionDeviceName:
return []string{"device_name"}
case domainreport.DimensionDeviceModel:
return []string{"device_model"}
case domainreport.DimensionManufacturer:
return []string{"manufacturer"}
case domainreport.DimensionBusinessUserGroup:
return []string{"business_user_group_id", "business_user_group_name"}
case domainreport.DimensionAgent:
return []string{"agent_account_id", "agent_account_name", "root_shop_id", "root_shop_name"}
case domainreport.DimensionShop:
return []string{"shop_id", "shop_name"}
case domainreport.DimensionBusinessOwner:
return []string{"business_owner_account_id", "business_owner_name"}
default:
return nil
}
}
// renewalKeyColumns 返回套餐续费维度在快照行上的分组键列。
func renewalKeyColumns(dimension string) []string {
switch dimension {
case domainreport.DimensionPackageSeries:
return []string{"series_id", "series_name"}
case domainreport.DimensionPackageName:
return []string{"package_name"}
case domainreport.DimensionBusinessUserGroup:
return []string{"business_user_group_id", "business_user_group_name"}
case domainreport.DimensionAgent:
return []string{"agent_account_id", "agent_account_name", "root_shop_id", "root_shop_name"}
case domainreport.DimensionShop:
return []string{"shop_id", "shop_name"}
case domainreport.DimensionBusinessOwner:
return []string{"business_owner_account_id", "business_owner_name"}
default:
return nil
}
}
// groupKeyFor 由聚合行与维度编码推导分组键。
//
// 「代理」维度映射为单一取值:优先取代理账号(归属一级代理店铺的 user_type=3 账号),
// 账号缺失时退回一级代理店铺名称;两者都缺失时为固定占位。
func groupKeyFor(dimension string, row aggregateKeyRow) groupKey {
switch dimension {
case domainreport.DimensionDeviceName:
return textKey("device_name", row.DeviceName)
case domainreport.DimensionDeviceModel:
return textKey("device_model", row.DeviceModel)
case domainreport.DimensionManufacturer:
return textKey("manufacturer", row.Manufacturer)
case domainreport.DimensionBusinessUserGroup:
return idKey("business_user_group", formatOptionalUint(row.BusinessUserGroupID), row.BusinessUserGroupName)
case domainreport.DimensionAgent:
if row.AgentAccountID != nil {
return groupKey{
value: domainreport.TextOrPlaceholder(row.AgentAccountName),
identity: "agent_account:" + strconv.FormatUint(uint64(*row.AgentAccountID), 10),
}
}
if row.RootShopID != nil {
return groupKey{
value: domainreport.TextOrPlaceholder(row.RootShopName),
identity: "agent_shop:" + strconv.FormatUint(uint64(*row.RootShopID), 10),
}
}
return groupKey{value: domainreport.PlaceholderUnset, identity: "agent:none"}
case domainreport.DimensionShop:
return idKey("shop", formatOptionalUint(row.ShopID), row.ShopName)
case domainreport.DimensionBusinessOwner:
return idKey("business_owner", formatOptionalUint(row.BusinessOwnerAccountID), row.BusinessOwnerName)
case domainreport.DimensionPackageSeries:
return idKey("package_series", formatOptionalUint(row.SeriesID), row.SeriesName)
case domainreport.DimensionPackageName:
return textKey("package_name", row.PackageName)
default:
return groupKey{value: domainreport.DimensionAll}
}
}
// textKey 以文本值本身作为实体键。
func textKey(prefix, value string) groupKey {
return groupKey{value: domainreport.TextOrPlaceholder(value), identity: prefix + ":" + value}
}
// idKey 以实体 ID 作为实体键ID 缺失时退回文本值。
func idKey(prefix, id, value string) groupKey {
if id == "" {
return groupKey{value: domainreport.TextOrPlaceholder(value), identity: prefix + ":name:" + value}
}
return groupKey{value: domainreport.TextOrPlaceholder(value), identity: prefix + ":" + id}
}
// activationDayGroups 按(快照日,分组维度)聚合激活指标。
// 只读设备激活快照行,不回查实时事实;数据范围按快照行的店铺列施加。
func (q *Query) activationDayGroups(ctx context.Context, days []time.Time, dimension string,
scope []uint) (map[string]map[groupKey]activationMetrics, error) {
result := make(map[string]map[groupKey]activationMetrics, len(days))
if len(days) == 0 {
return result, nil
}
selects := []string{"to_char(snapshot_date, 'YYYY-MM-DD') AS snapshot_day"}
groupColumns := []string{"snapshot_date"}
for _, column := range activationKeyColumns(dimension) {
selects = append(selects, column)
groupColumns = append(groupColumns, column)
}
selects = append(selects, activationAggregateSelects()...)
query := q.db.WithContext(ctx).Table("tb_operations_report_activation_row").
Select(strings.Join(selects, ", ")).
Where("snapshot_date IN ?", uniqueDays(days)).
Group(strings.Join(groupColumns, ", "))
if len(scope) > 0 {
query = query.Where("shop_id IN ?", scope)
}
var rows []aggregateKeyRow
if err := query.Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "聚合运营报表设备激活快照行失败")
}
for _, row := range rows {
day := row.SnapshotDay
if _, exists := result[day]; !exists {
result[day] = make(map[groupKey]activationMetrics)
}
result[day][groupKeyFor(dimension, row)] = activationMetrics{
PurchasedDeviceCount: row.PurchasedDeviceCount,
ActivatedDeviceCount: row.ActivatedDeviceCount,
OnlineDeviceCount: row.OnlineDeviceCount,
ActiveDeviceCount: row.ActiveDeviceCount,
TotalRealTrafficMB: row.TotalRealTrafficMB,
}
}
return result, nil
}
// activationAggregateSelects 返回激活指标的聚合表达式。
func activationAggregateSelects() []string {
return []string{
"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",
}
}
// renewalsAggregate 聚合续费指标(到期与续费均按资产去重)。
//
// periodExpr 为空表示整段合计(汇总查询);否则按其分组,键与 domain.FormatPeriod 一致。
// 期内的资产去重在 SQL 层完成,因此按月趋势的同一资产多次到期只计一次。
func (q *Query) renewalsAggregate(ctx context.Context, lower, upper *time.Time, periodExpr, dimension string,
scope []uint) (map[string]map[groupKey]renewalMetrics, error) {
selects := make([]string, 0, 4)
groupColumns := make([]string, 0, 4)
if periodExpr != "" {
selects = append(selects, periodExpr+" AS period")
groupColumns = append(groupColumns, periodExpr)
}
for _, column := range renewalKeyColumns(dimension) {
selects = append(selects, column)
groupColumns = append(groupColumns, column)
}
selects = append(selects,
"COUNT(DISTINCT asset_type || ':' || asset_id) AS due_assets",
"COUNT(DISTINCT asset_type || ':' || asset_id) FILTER (WHERE renewed) AS renewed_assets")
query := q.db.WithContext(ctx).Table("tb_operations_report_renewal_row").Select(strings.Join(selects, ", "))
if lower != nil {
query = query.Where("snapshot_date >= ?::date", domainreport.FormatSnapshotDay(*lower))
}
if upper != nil {
query = query.Where("snapshot_date <= ?::date", domainreport.FormatSnapshotDay(*upper))
}
if len(scope) > 0 {
query = query.Where("shop_id IN ?", scope)
}
if len(groupColumns) > 0 {
query = query.Group(strings.Join(groupColumns, ", "))
}
var rows []aggregateKeyRow
if err := query.Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "聚合运营报表套餐续费快照行失败")
}
result := make(map[string]map[groupKey]renewalMetrics, len(rows))
if periodExpr == "" {
result[""] = make(map[groupKey]renewalMetrics)
}
for _, row := range rows {
key := groupKeyFor(dimension, row)
if !rowHasGroup(row, dimension) {
key = groupKey{value: domainreport.DimensionAll}
}
if _, exists := result[row.Period]; !exists {
result[row.Period] = make(map[groupKey]renewalMetrics)
}
result[row.Period][key] = renewalMetrics{DueAssets: row.DueAssets, RenewedAssets: row.RenewedAssets}
}
return result, nil
}
// rowHasGroup 判断聚合行是否携带分组维度取值(未分组时为 false
func rowHasGroup(row aggregateKeyRow, dimension string) bool {
switch dimension {
case domainreport.DimensionPackageSeries:
return row.SeriesID != nil || row.SeriesName != ""
case domainreport.DimensionPackageName:
return row.PackageName != ""
case domainreport.DimensionBusinessUserGroup:
return row.BusinessUserGroupID != nil || row.BusinessUserGroupName != ""
case domainreport.DimensionAgent:
return row.AgentAccountID != nil || row.RootShopID != nil || row.RootShopName != ""
case domainreport.DimensionShop:
return row.ShopID != nil || row.ShopName != ""
case domainreport.DimensionBusinessOwner:
return row.BusinessOwnerAccountID != nil || row.BusinessOwnerName != ""
default:
return false
}
}
// formatOptionalUint 输出可空 ID 的文本形式。
func formatOptionalUint(value *uint) string {
if value == nil {
return ""
}
return strconv.FormatUint(uint64(*value), 10)
}
// periodExpr 返回趋势期在 SQL 中的表达式,键与 domain.FormatPeriod 一致。
func periodExpr(granularity string) string {
if granularity == domainreport.GranularityMonth {
return "to_char(snapshot_date, 'YYYY-MM')"
}
return "to_char(snapshot_date, 'YYYY-MM-DD')"
}

View File

@@ -0,0 +1,141 @@
package operationsreport
import (
"context"
"time"
domainreport "github.com/break/junhong_cmp_fiber/internal/domain/operationsreport"
)
// trendPeriod 是趋势的一个期:期标识、期末快照日与(按月粒度时)期内全部快照日。
type trendPeriod struct {
Key string
RepresentativeDay time.Time
Days []time.Time
}
// activationPeriods 返回激活情况趋势的期序列(升序)。
//
// 按日粒度的期是该区间内的每个快照日;按月粒度的期是每个自然月,期末取该月最后一个快照日。
// 无快照的期不出现在结果中。
func (q *Query) activationPeriods(ctx context.Context, granularity string, start, end *time.Time) ([]trendPeriod, error) {
if granularity == domainreport.GranularityDay {
days, err := q.matchedDays(ctx, lowerDay(start), upperDay(end))
if err != nil {
return nil, err
}
periods := make([]trendPeriod, 0, len(days))
for _, day := range days {
periods = append(periods, trendPeriod{
Key: domainreport.FormatPeriod(granularity, day),
RepresentativeDay: day,
Days: []time.Time{day},
})
}
return periods, nil
}
lower, upper := monthSpan(start, end)
days, err := q.matchedDays(ctx, lower, upper)
if err != nil {
return nil, err
}
return groupMonthPeriods(granularity, days, lowerDay(start), upperDay(end)), nil
}
// previousPeriodRepresentatives 返回每个期的前一期期末快照日;前一期无快照的期不出现在结果中。
//
// 前一期指日历上的上一自然日或上一自然月,即使它早于请求区间起点也必须读取:
// 新增类指标按「期末 前一期期末」计算,任一侧无快照时为空。
func (q *Query) previousPeriodRepresentatives(ctx context.Context, granularity string,
periods []trendPeriod) (map[string]time.Time, error) {
result := make(map[string]time.Time, len(periods))
if len(periods) == 0 {
return result, nil
}
earliest := domainreport.PeriodOf(granularity, periods[0].RepresentativeDay)
for _, period := range periods {
start := domainreport.PeriodOf(granularity, period.RepresentativeDay)
if start.Before(earliest) {
earliest = start
}
}
searchFrom := domainreport.PreviousPeriodStart(granularity, earliest)
days, err := q.matchedDays(ctx, &searchFrom, nil)
if err != nil {
return nil, err
}
for _, period := range periods {
periodStart := domainreport.PeriodOf(granularity, period.RepresentativeDay)
previousStart := domainreport.PreviousPeriodStart(granularity, periodStart)
previousEnd := previousStart
if granularity == domainreport.GranularityMonth {
previousEnd = endOfMonth(previousStart)
}
if representative, ok := lastDayWithin(days, previousStart, previousEnd); ok {
result[period.Key] = representative
}
}
return result, nil
}
// monthSpan 把区间换算为覆盖整月的日期范围,供按月趋势读取所需的快照日集合。
func monthSpan(start, end *time.Time) (*time.Time, *time.Time) {
var lower, upper *time.Time
if start != nil {
day := domainreport.LowerBoundDay(*start)
first := domainreport.PeriodOf(domainreport.GranularityMonth, day)
lower = &first
}
if end != nil {
day := domainreport.UpperBoundDay(*end)
last := endOfMonth(domainreport.PeriodOf(domainreport.GranularityMonth, day))
upper = &last
}
return lower, upper
}
// groupMonthPeriods 把快照日按月聚合为趋势期。
// 期末快照日落在请求区间之外的月份不作为期出现(其快照日仅用于推断前一期)。
func groupMonthPeriods(granularity string, days []time.Time, lower, upper *time.Time) []trendPeriod {
order := make([]string, 0, len(days))
buckets := make(map[string][]time.Time, len(days))
for _, day := range days {
key := domainreport.FormatPeriod(granularity, day)
if _, exists := buckets[key]; !exists {
order = append(order, key)
}
buckets[key] = append(buckets[key], day)
}
periods := make([]trendPeriod, 0, len(order))
for _, key := range order {
bucket := buckets[key]
representative := bucket[len(bucket)-1]
if lower != nil && representative.Before(*lower) {
continue
}
if upper != nil && representative.After(*upper) {
continue
}
periods = append(periods, trendPeriod{Key: key, RepresentativeDay: representative, Days: bucket})
}
return periods
}
// endOfMonth 返回该月最后一天(上海自然日)。
func endOfMonth(monthStart time.Time) time.Time {
return time.Date(monthStart.Year(), monthStart.Month()+1, 0, 0, 0, 0, 0, domainreport.ShanghaiLocation())
}
// lastDayWithin 返回闭区间内最后一个存在的快照日。
func lastDayWithin(days []time.Time, from, to time.Time) (time.Time, bool) {
var found time.Time
ok := false
for _, day := range days {
if day.Before(from) || day.After(to) {
continue
}
found = day
ok = true
}
return found, ok
}

View File

@@ -0,0 +1,550 @@
// 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 })
}