accounts/internal/service/user_metrics.go
Haitao Pan ee6e1a6363 feat: move account service to repo root
# Conflicts:
#	account/Makefile
#	account/go.mod
#	docs/account-admin-settings.md
#	docs/account-svc-plus.md
2026-01-16 16:15:23 +08:00

261 lines
6.6 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"sort"
"time"
)
var (
// ErrUserRepositoryNotConfigured is returned when the metrics service lacks
// a user repository dependency.
ErrUserRepositoryNotConfigured = errors.New("user repository is not configured")
// ErrSubscriptionProviderNotConfigured is returned when subscription data is
// required but the dependency has not been provided.
ErrSubscriptionProviderNotConfigured = errors.New("subscription provider is not configured")
)
// UserRepository provides access to persisted user records required for
// metrics aggregation.
type UserRepository interface {
ListUsers(ctx context.Context) ([]UserRecord, error)
}
// SubscriptionProvider exposes subscription status information for a set of
// users.
type SubscriptionProvider interface {
FetchSubscriptionStates(ctx context.Context, userIDs []string) (map[string]SubscriptionState, error)
}
// UserRecord represents the minimal attributes required to compute metrics for
// a user population.
type UserRecord struct {
ID string
CreatedAt time.Time
Active bool
}
// SubscriptionState describes the subscription attributes associated with a
// user.
type SubscriptionState struct {
Active bool
ExpiresAt *time.Time
}
// UserMetricsService aggregates user population metrics such as totals and
// activity trends across configurable time windows.
type UserMetricsService struct {
Users UserRepository
Subscriptions SubscriptionProvider
DailyPeriods int
WeeklyPeriods int
now func() time.Time
}
const (
defaultDailyPeriods = 30
defaultWeeklyPeriods = 12
)
// UserMetrics captures the aggregated metrics returned to API consumers.
type UserMetrics struct {
Overview MetricsOverview `json:"overview"`
Series MetricsSeries `json:"series"`
}
// UserMetricsProvider defines the behaviour expected from metrics aggregators.
type UserMetricsProvider interface {
Compute(ctx context.Context) (UserMetrics, error)
}
// MetricsOverview contains headline statistics for the user population.
type MetricsOverview struct {
TotalUsers int `json:"totalUsers"`
ActiveUsers int `json:"activeUsers"`
SubscribedUsers int `json:"subscribedUsers"`
NewUsersLast24h int `json:"newUsersLast24h"`
}
// MetricsSeries contains chronological breakdowns of user statistics.
type MetricsSeries struct {
Daily []MetricsPoint `json:"daily"`
Weekly []MetricsPoint `json:"weekly"`
}
// MetricsPoint represents aggregated counts for a specific period.
type MetricsPoint struct {
Period string `json:"period"`
Total int `json:"total"`
Active int `json:"active"`
Subscribed int `json:"subscribed"`
}
// Compute aggregates the metrics using the configured repositories.
func (s *UserMetricsService) Compute(ctx context.Context) (UserMetrics, error) {
if s == nil {
return UserMetrics{}, ErrUserRepositoryNotConfigured
}
if s.Users == nil {
return UserMetrics{}, ErrUserRepositoryNotConfigured
}
if s.Subscriptions == nil {
return UserMetrics{}, ErrSubscriptionProviderNotConfigured
}
dailyPeriods := s.DailyPeriods
if dailyPeriods <= 0 {
dailyPeriods = defaultDailyPeriods
}
weeklyPeriods := s.WeeklyPeriods
if weeklyPeriods <= 0 {
weeklyPeriods = defaultWeeklyPeriods
}
now := s.nowFn()
startOfToday := truncateToDay(now)
dailyStart := startOfToday.AddDate(0, 0, -(dailyPeriods - 1))
weekStart := truncateToWeek(now)
weeklyStart := weekStart.AddDate(0, 0, -7*(weeklyPeriods-1))
users, err := s.Users.ListUsers(ctx)
if err != nil {
return UserMetrics{}, err
}
userIDs := make([]string, 0, len(users))
for _, user := range users {
if user.ID != "" {
userIDs = append(userIDs, user.ID)
}
}
subscriptionStates := make(map[string]SubscriptionState)
if len(userIDs) > 0 {
states, err := s.Subscriptions.FetchSubscriptionStates(ctx, userIDs)
if err != nil {
return UserMetrics{}, err
}
if states != nil {
subscriptionStates = states
}
}
overview := MetricsOverview{}
dailySeries := make([]MetricsPoint, dailyPeriods)
weeklySeries := make([]MetricsPoint, weeklyPeriods)
dailyIndex := make(map[string]*MetricsPoint, dailyPeriods)
for i := 0; i < dailyPeriods; i++ {
periodStart := dailyStart.AddDate(0, 0, i)
label := periodStart.Format("2006-01-02")
dailySeries[i] = MetricsPoint{Period: label}
dailyIndex[label] = &dailySeries[i]
}
weeklyIndex := make(map[string]*MetricsPoint, weeklyPeriods)
for i := 0; i < weeklyPeriods; i++ {
periodStart := weeklyStart.AddDate(0, 0, i*7)
year, week := periodStart.ISOWeek()
label := fmt.Sprintf("%04d-W%02d", year, week)
weeklySeries[i] = MetricsPoint{Period: label}
weeklyIndex[label] = &weeklySeries[i]
}
newUserThreshold := now.Add(-24 * time.Hour)
for _, user := range users {
overview.TotalUsers++
subscribed := false
if state, ok := subscriptionStates[user.ID]; ok {
subscribed = state.Active
}
if user.Active {
overview.ActiveUsers++
}
if subscribed {
overview.SubscribedUsers++
}
if !user.CreatedAt.IsZero() && !user.CreatedAt.Before(newUserThreshold) {
overview.NewUsersLast24h++
}
if user.CreatedAt.IsZero() {
continue
}
createdAtUTC := user.CreatedAt.UTC()
day := truncateToDay(createdAtUTC)
if !day.Before(dailyStart) && !day.After(startOfToday) {
label := day.Format("2006-01-02")
if point, ok := dailyIndex[label]; ok {
point.Total++
if user.Active {
point.Active++
}
if subscribed {
point.Subscribed++
}
}
}
week := truncateToWeek(createdAtUTC)
if !week.Before(weeklyStart) && !week.After(weekStart) {
year, weekNumber := week.ISOWeek()
label := fmt.Sprintf("%04d-W%02d", year, weekNumber)
if point, ok := weeklyIndex[label]; ok {
point.Total++
if user.Active {
point.Active++
}
if subscribed {
point.Subscribed++
}
}
}
}
// Ensure chronological order.
sort.SliceStable(dailySeries, func(i, j int) bool {
return dailySeries[i].Period < dailySeries[j].Period
})
sort.SliceStable(weeklySeries, func(i, j int) bool {
return weeklySeries[i].Period < weeklySeries[j].Period
})
metrics := UserMetrics{
Overview: overview,
Series: MetricsSeries{
Daily: dailySeries,
Weekly: weeklySeries,
},
}
return metrics, nil
}
func (s *UserMetricsService) nowFn() time.Time {
if s != nil && s.now != nil {
return s.now()
}
return time.Now().UTC()
}
func truncateToDay(t time.Time) time.Time {
t = t.UTC()
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
}
func truncateToWeek(t time.Time) time.Time {
t = truncateToDay(t)
weekday := int(t.Weekday())
if weekday == 0 {
weekday = 7
}
return t.AddDate(0, 0, -(weekday - 1))
}