Files
orris/internal/application/forward/usecases/getsubscriptionforwardusage.go
orris-inc 9beb449393 fix: unify subscription traffic cycle window and reduce log noise
Replace direct uses of sub.CurrentPeriodStart/End with
subscription.ResolveTrafficPeriod across the quota cache, forward usage
endpoints, and DTOs. For calendar_month plans these windows diverge,
causing the node hub real-time enforcer to query usage over the wrong
window after a calendar reset and forward usage endpoints to return
mis-aligned traffic figures. SubscriptionDTO and DashboardSubscriptionDTO
now expose currentTrafficCycleStart/End plus the upload/download
breakdown, so the frontend pairs figures with the matching window and
skips a redundant traffic-stats request.

Also demote context.Canceled / DeadlineExceeded inside Errorw/Error to
WARN. Repository read paths previously logged client cancellations as
ERRORs (e.g. dashboard 30s timeout produced an ERROR per repo on the
call chain), which polluted alerts.
2026-04-29 14:06:20 +08:00

212 lines
7.7 KiB
Go

package usecases
import (
"context"
"fmt"
"time"
"github.com/orris-inc/orris/internal/domain/forward"
"github.com/orris-inc/orris/internal/domain/subscription"
"github.com/orris-inc/orris/internal/infrastructure/cache"
"github.com/orris-inc/orris/internal/shared/biztime"
"github.com/orris-inc/orris/internal/shared/errors"
"github.com/orris-inc/orris/internal/shared/logger"
)
// GetSubscriptionForwardUsageQuery represents the input for getting subscription forward usage.
type GetSubscriptionForwardUsageQuery struct {
SubscriptionID uint
}
// GetSubscriptionForwardUsageResult represents the subscription's forward rule usage and quota information.
type GetSubscriptionForwardUsageResult struct {
RuleCount int `json:"rule_count"`
RuleLimit int `json:"rule_limit"`
TrafficUsed uint64 `json:"traffic_used"` // in bytes
TrafficLimit uint64 `json:"traffic_limit"` // in bytes, 0 means unlimited
AllowedTypes []string `json:"allowed_types"`
}
// GetSubscriptionForwardUsageUseCase handles getting subscription forward usage.
type GetSubscriptionForwardUsageUseCase struct {
repo forward.RuleQuerier
subscriptionRepo subscription.SubscriptionRepository
planRepo subscription.PlanRepository
usageRepo subscription.SubscriptionUsageRepository // Legacy, kept for compatibility
usageStatsRepo subscription.SubscriptionUsageStatsRepository // For historical traffic data (>24h ago)
hourlyCache cache.HourlyTrafficCache // For recent traffic data (last 24h)
logger logger.Interface
}
// NewGetSubscriptionForwardUsageUseCase creates a new GetSubscriptionForwardUsageUseCase.
func NewGetSubscriptionForwardUsageUseCase(
repo forward.RuleQuerier,
subscriptionRepo subscription.SubscriptionRepository,
planRepo subscription.PlanRepository,
usageRepo subscription.SubscriptionUsageRepository,
usageStatsRepo subscription.SubscriptionUsageStatsRepository,
hourlyCache cache.HourlyTrafficCache,
logger logger.Interface,
) *GetSubscriptionForwardUsageUseCase {
return &GetSubscriptionForwardUsageUseCase{
repo: repo,
subscriptionRepo: subscriptionRepo,
planRepo: planRepo,
usageRepo: usageRepo,
usageStatsRepo: usageStatsRepo,
hourlyCache: hourlyCache,
logger: logger,
}
}
// Execute retrieves forward rule usage statistics for a specific subscription.
func (uc *GetSubscriptionForwardUsageUseCase) Execute(ctx context.Context, query GetSubscriptionForwardUsageQuery) (*GetSubscriptionForwardUsageResult, error) {
uc.logger.Debugw("executing get subscription forward usage use case", "subscription_id", query.SubscriptionID)
// Validate subscription ID
if query.SubscriptionID == 0 {
return nil, errors.NewValidationError("subscription_id is required")
}
// Get the subscription
sub, err := uc.subscriptionRepo.GetByID(ctx, query.SubscriptionID)
if err != nil {
uc.logger.Errorw("failed to get subscription", "subscription_id", query.SubscriptionID, "error", err)
return nil, fmt.Errorf("failed to get subscription: %w", err)
}
if sub == nil {
return nil, errors.NewNotFoundError("subscription", fmt.Sprintf("%d", query.SubscriptionID))
}
// Get the subscription's plan
plan, err := uc.planRepo.GetByID(ctx, sub.PlanID())
if err != nil {
uc.logger.Errorw("failed to get plan", "plan_id", sub.PlanID(), "error", err)
return nil, fmt.Errorf("failed to get plan: %w", err)
}
if plan == nil {
return nil, errors.NewNotFoundError("plan", fmt.Sprintf("%d", sub.PlanID()))
}
// Check if plan is forward type
if !plan.PlanType().IsForward() {
uc.logger.Warnw("subscription plan does not support forward rules",
"subscription_id", query.SubscriptionID,
"plan_type", plan.PlanType().String(),
)
return nil, errors.NewValidationError("subscription plan does not support forward rules")
}
planFeatures := plan.Features()
if planFeatures == nil {
return nil, errors.NewValidationError("plan features not configured")
}
// Get rule limit
ruleLimit, err := planFeatures.GetRuleLimit()
if err != nil {
uc.logger.Warnw("failed to get rule limit", "subscription_id", query.SubscriptionID, "error", err)
ruleLimit = 0 // Default to unlimited on error
}
// Get traffic limit
trafficLimit, err := planFeatures.GetTrafficLimit()
if err != nil {
uc.logger.Warnw("failed to get traffic limit", "subscription_id", query.SubscriptionID, "error", err)
trafficLimit = 0 // Default to unlimited on error
}
// Get allowed rule types
allowedTypes, err := planFeatures.GetRuleTypes()
if err != nil {
uc.logger.Warnw("failed to get rule types", "subscription_id", query.SubscriptionID, "error", err)
allowedTypes = []string{"direct", "entry", "chain", "direct_chain"} // Default to all types on error
}
// Empty means all types allowed
if len(allowedTypes) == 0 {
allowedTypes = []string{"direct", "entry", "chain", "direct_chain"}
}
// Count current rules for this subscription
ruleCount, err := uc.repo.CountBySubscriptionID(ctx, query.SubscriptionID)
if err != nil {
uc.logger.Errorw("failed to count subscription forward rules", "subscription_id", query.SubscriptionID, "error", err)
return nil, fmt.Errorf("failed to get rule count: %w", err)
}
// Query traffic usage from Redis (recent) and MySQL stats (historical).
// Use the resolved *traffic cycle* (calendar_month or billing_cycle) so
// the displayed traffic_used matches what quota enforcement counts.
var trafficUsed uint64
cycle := subscription.ResolveTrafficPeriod(plan, sub)
periodStart := cycle.Start
periodEnd := biztime.EndOfDayUTC(cycle.End)
now := biztime.NowUTC()
// Use start of yesterday's business day as batch/speed boundary (Lambda architecture)
// MySQL: complete days before yesterday; Redis: yesterday + today (within 48h TTL)
recentBoundary := biztime.StartOfDayUTC(now.AddDate(0, 0, -1))
subscriptionIDs := []uint{query.SubscriptionID}
resourceType := string(subscription.ResourceTypeForwardRule)
// Determine time boundaries for recent data (yesterday + today from Redis)
recentFrom := periodStart
if recentFrom.Before(recentBoundary) {
recentFrom = recentBoundary
}
// Get recent traffic from Redis (yesterday + today)
if recentFrom.Before(periodEnd) && recentFrom.Before(now) {
recentTo := periodEnd
if recentTo.After(now) {
recentTo = now
}
recentTraffic, err := uc.hourlyCache.GetTotalTrafficBySubscriptionIDs(
ctx, subscriptionIDs, resourceType, recentFrom, recentTo,
)
if err != nil {
uc.logger.Warnw("failed to get recent traffic from Redis", "subscription_id", query.SubscriptionID, "error", err)
} else {
for _, t := range recentTraffic {
trafficUsed += t.Total
}
}
}
// Get historical traffic from MySQL stats (complete days before yesterday)
if periodStart.Before(recentBoundary) {
historicalTo := recentBoundary.Add(-time.Second)
if historicalTo.After(periodEnd) {
historicalTo = periodEnd
}
historicalTraffic, err := uc.usageStatsRepo.GetTotalBySubscriptionIDs(
ctx, subscriptionIDs, &resourceType, subscription.GranularityDaily, periodStart, historicalTo,
)
if err != nil {
uc.logger.Warnw("failed to get historical traffic from stats", "subscription_id", query.SubscriptionID, "error", err)
} else if historicalTraffic != nil {
trafficUsed += historicalTraffic.Total
}
}
result := &GetSubscriptionForwardUsageResult{
RuleCount: int(ruleCount),
RuleLimit: ruleLimit,
TrafficUsed: trafficUsed,
TrafficLimit: trafficLimit,
AllowedTypes: allowedTypes,
}
uc.logger.Debugw("subscription forward usage retrieved successfully",
"subscription_id", query.SubscriptionID,
"rule_count", ruleCount,
"rule_limit", ruleLimit,
"traffic_used", trafficUsed,
"traffic_limit", trafficLimit,
"allowed_types", allowedTypes,
)
return result, nil
}