feat(kol): dashboard service + API
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||
)
|
||||
|
||||
type DashboardOverviewResponse struct {
|
||||
TotalSubscriberTenants int64 `json:"total_subscriber_tenants"`
|
||||
MonthNewSubscriptions int64 `json:"month_new_subscriptions"`
|
||||
TotalUsage int64 `json:"total_usage"`
|
||||
MonthUsage int64 `json:"month_usage"`
|
||||
FailureCount int64 `json:"failure_count"`
|
||||
FailureRate float64 `json:"failure_rate"`
|
||||
}
|
||||
|
||||
type DashboardPackageStat struct {
|
||||
PackageID int64 `json:"package_id"`
|
||||
PackageName string `json:"package_name"`
|
||||
SubscriberTenants int64 `json:"subscriber_tenants"`
|
||||
UsageCount int64 `json:"usage_count"`
|
||||
FailureRate float64 `json:"failure_rate"`
|
||||
}
|
||||
|
||||
type DashboardTrendPoint struct {
|
||||
Date string `json:"date"`
|
||||
Subscriptions int64 `json:"subscriptions"`
|
||||
Usages int64 `json:"usages"`
|
||||
}
|
||||
|
||||
type KolDashboardService struct {
|
||||
profileSvc *KolProfileService
|
||||
pkgRepo repository.KolPackageRepository
|
||||
subRepo repository.KolSubscriptionRepository
|
||||
usageRepo repository.KolUsageRepository
|
||||
}
|
||||
|
||||
func NewKolDashboardService(
|
||||
profileSvc *KolProfileService,
|
||||
pkgRepo repository.KolPackageRepository,
|
||||
subRepo repository.KolSubscriptionRepository,
|
||||
usageRepo repository.KolUsageRepository,
|
||||
) *KolDashboardService {
|
||||
return &KolDashboardService{
|
||||
profileSvc: profileSvc,
|
||||
pkgRepo: pkgRepo,
|
||||
subRepo: subRepo,
|
||||
usageRepo: usageRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *KolDashboardService) Overview(ctx context.Context, actor auth.Actor) (*DashboardOverviewResponse, error) {
|
||||
profile, err := s.profileSvc.RequireActiveKol(ctx, actor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
packageIDs, err := s.pkgRepo.ListPackageIDsByProfile(ctx, actor.TenantID, profile.ID)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50120, "kol_dashboard_package_query_failed", err.Error())
|
||||
}
|
||||
if len(packageIDs) == 0 {
|
||||
return &DashboardOverviewResponse{}, nil
|
||||
}
|
||||
|
||||
overview, err := s.usageRepo.DashboardOverview(ctx, packageIDs)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50121, "kol_dashboard_overview_query_failed", err.Error())
|
||||
}
|
||||
|
||||
totalUsage := maxDashboardBase(overview.TotalUsage)
|
||||
return &DashboardOverviewResponse{
|
||||
TotalSubscriberTenants: overview.TotalSubs,
|
||||
MonthNewSubscriptions: overview.MonthSubs,
|
||||
TotalUsage: overview.TotalUsage,
|
||||
MonthUsage: overview.MonthUsage,
|
||||
FailureCount: overview.FailureCount,
|
||||
FailureRate: float64(overview.FailureCount) / float64(totalUsage),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *KolDashboardService) PerPackage(ctx context.Context, actor auth.Actor) ([]DashboardPackageStat, error) {
|
||||
profile, err := s.profileSvc.RequireActiveKol(ctx, actor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
packages, err := s.pkgRepo.ListByProfile(ctx, actor.TenantID, profile.ID)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50120, "kol_dashboard_package_query_failed", err.Error())
|
||||
}
|
||||
if len(packages) == 0 {
|
||||
return []DashboardPackageStat{}, nil
|
||||
}
|
||||
|
||||
packageIDs := make([]int64, 0, len(packages))
|
||||
for _, pkg := range packages {
|
||||
packageIDs = append(packageIDs, pkg.ID)
|
||||
}
|
||||
|
||||
usageStats, err := s.usageRepo.PerPackageStats(ctx, packageIDs)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50122, "kol_dashboard_package_stats_query_failed", err.Error())
|
||||
}
|
||||
|
||||
usageByPackage := make(map[int64]repository.KolUsagePackageStats, len(usageStats))
|
||||
for _, stat := range usageStats {
|
||||
usageByPackage[stat.PackageID] = stat
|
||||
}
|
||||
|
||||
result := make([]DashboardPackageStat, 0, len(packages))
|
||||
for _, pkg := range packages {
|
||||
subscribers, err := s.subRepo.ListActiveSubscribersForPackage(ctx, pkg.ID)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50123, "kol_dashboard_subscriber_query_failed", err.Error())
|
||||
}
|
||||
|
||||
subscriberTenants := uniqueSubscriberTenants(subscribers)
|
||||
usage := usageByPackage[pkg.ID]
|
||||
result = append(result, DashboardPackageStat{
|
||||
PackageID: pkg.ID,
|
||||
PackageName: pkg.Name,
|
||||
SubscriberTenants: subscriberTenants,
|
||||
UsageCount: usage.Total,
|
||||
FailureRate: float64(usage.Failed) / float64(maxDashboardBase(usage.Total)),
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *KolDashboardService) Trend(ctx context.Context, actor auth.Actor, periodDays int) ([]DashboardTrendPoint, error) {
|
||||
profile, err := s.profileSvc.RequireActiveKol(ctx, actor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
packageIDs, err := s.pkgRepo.ListPackageIDsByProfile(ctx, actor.TenantID, profile.ID)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50120, "kol_dashboard_package_query_failed", err.Error())
|
||||
}
|
||||
if len(packageIDs) == 0 {
|
||||
return []DashboardTrendPoint{}, nil
|
||||
}
|
||||
|
||||
points, err := s.usageRepo.DashboardTrend(ctx, packageIDs, periodDays)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50124, "kol_dashboard_trend_query_failed", err.Error())
|
||||
}
|
||||
|
||||
result := make([]DashboardTrendPoint, 0, len(points))
|
||||
for _, point := range points {
|
||||
result = append(result, DashboardTrendPoint{
|
||||
Date: point.Day.Format("2006-01-02"),
|
||||
Subscriptions: point.Subscriptions,
|
||||
Usages: point.Usages,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func maxDashboardBase(total int64) int64 {
|
||||
if total <= 0 {
|
||||
return 1
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func uniqueSubscriberTenants(subscribers []repository.KolSubscription) int64 {
|
||||
if len(subscribers) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
seen := make(map[int64]struct{}, len(subscribers))
|
||||
for _, subscriber := range subscribers {
|
||||
seen[subscriber.TenantID] = struct{}{}
|
||||
}
|
||||
return int64(len(seen))
|
||||
}
|
||||
|
||||
func ValidateKolDashboardPeriodDays(periodDays int) error {
|
||||
switch periodDays {
|
||||
case 7, 30, 90:
|
||||
return nil
|
||||
default:
|
||||
return response.ErrBadRequest(40073, "kol_dashboard_period_invalid", "period_days must be one of 7, 30, 90")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/bootstrap"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/app"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||
)
|
||||
|
||||
type KolDashboardHandler struct {
|
||||
svc *app.KolDashboardService
|
||||
}
|
||||
|
||||
func NewKolDashboardHandler(a *bootstrap.App) *KolDashboardHandler {
|
||||
profileSvc := app.NewKolProfileService(a.KolProfiles)
|
||||
return &KolDashboardHandler{
|
||||
svc: app.NewKolDashboardService(
|
||||
profileSvc,
|
||||
a.KolPackages,
|
||||
a.KolSubscriptions,
|
||||
repository.NewKolUsageRepository(a.DB),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *KolDashboardHandler) Overview(c *gin.Context) {
|
||||
actor, ok := actorFromRequest(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := h.svc.Overview(c.Request.Context(), actor)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *KolDashboardHandler) Packages(c *gin.Context) {
|
||||
actor, ok := actorFromRequest(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := h.svc.PerPackage(c.Request.Context(), actor)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *KolDashboardHandler) Trend(c *gin.Context) {
|
||||
actor, ok := actorFromRequest(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
periodDays := parseKolDashboardPeriodDays(c.Query("period_days"))
|
||||
if err := app.ValidateKolDashboardPeriodDays(periodDays); err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := h.svc.Trend(c.Request.Context(), actor, periodDays)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func parseKolDashboardPeriodDays(value string) int {
|
||||
if value == "" {
|
||||
return 7
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
@@ -74,6 +74,12 @@ func RegisterRoutes(app *bootstrap.App) {
|
||||
kolManage.POST("/assist", kolManageHandler.AssistSubmit)
|
||||
kolManage.GET("/assist/:id", kolManageHandler.AssistGet)
|
||||
|
||||
kolDash := protected.Group("/tenant/kol/dashboard")
|
||||
kolDashboardHandler := NewKolDashboardHandler(app)
|
||||
kolDash.GET("/overview", kolDashboardHandler.Overview)
|
||||
kolDash.GET("/packages", kolDashboardHandler.Packages)
|
||||
kolDash.GET("/trend", kolDashboardHandler.Trend)
|
||||
|
||||
kolMarketplace := protected.Group("/tenant/kol/marketplace")
|
||||
kolMarketplaceHandler := NewKolMarketplaceHandler(app)
|
||||
kolMarketplace.GET("/packages", kolMarketplaceHandler.ListPackages)
|
||||
|
||||
Reference in New Issue
Block a user