feat(ops): use 'reset' quota status for plan-usage reset and invalidate quota cache
When ops staff reset a tenant's current-plan usage, the existing 'refunded' terminal status conflated user-initiated refunds with ops-driven resets, and tenants still saw the old balance in the top-nav chip until the quota-summary cache TTL expired. - Introduce a dedicated 'reset' status for quota_reservations, separate from 'refunded'. Both the initial and an incremental migration widen the CHECK constraint to allow it; the down migration folds 'reset' rows back into 'refunded' before tightening the constraint. - resetQuotaReservations / resetAIPointReservations now write 'reset' without touching refunded_amount, and stop cascading into ai_point_usage_logs (carried no audit value for ops resets). - AdminUserService gains an optional cache dependency and calls invalidateQuotaSummaryCache(tenant_id) immediately after a successful reset so the tenant's top-nav chip refreshes on the next request without waiting on TTL. - Wire the existing appCache into AdminUserService at boot. - Drop the now-unused ai_point_usage_logs field from the reset response and from the ops-web AdminUsersView type. - Add a unit test covering the cache-key invalidation contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"github.com/geo-platform/tenant-api/internal/ops/domain"
|
||||
"github.com/geo-platform/tenant-api/internal/ops/repository"
|
||||
sharedauth "github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
)
|
||||
|
||||
@@ -38,6 +40,7 @@ type AdminUserService struct {
|
||||
users *repository.AdminUserRepository
|
||||
audits *AuditService
|
||||
loginGuard *sharedauth.LoginGuard
|
||||
cache sharedcache.Cache
|
||||
configMu sync.RWMutex
|
||||
defaultPlanCode string
|
||||
}
|
||||
@@ -59,6 +62,11 @@ func (s *AdminUserService) WithLoginGuard(g *sharedauth.LoginGuard) *AdminUserSe
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *AdminUserService) WithCache(c sharedcache.Cache) *AdminUserService {
|
||||
s.cache = c
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *AdminUserService) UpdateDefaultPlanCode(defaultPlanCode string) {
|
||||
if s == nil {
|
||||
return
|
||||
@@ -167,7 +175,6 @@ type ResetPlanUsageView struct {
|
||||
ArticleAmount int64 `json:"article_amount"`
|
||||
AIPointReservations int64 `json:"ai_point_reservations"`
|
||||
AIPointsAmount int64 `json:"ai_points_amount"`
|
||||
AIPointUsageLogs int64 `json:"ai_point_usage_logs"`
|
||||
ArticleWindowStart time.Time `json:"article_window_start"`
|
||||
ArticleWindowEnd time.Time `json:"article_window_end"`
|
||||
AIPointsWindowStart time.Time `json:"ai_points_window_start"`
|
||||
@@ -489,12 +496,12 @@ func (s *AdminUserService) ResetCurrentPlanUsage(ctx context.Context, actor *Act
|
||||
ArticleAmount: reset.ArticleAmount,
|
||||
AIPointReservations: reset.AIPointReservations,
|
||||
AIPointsAmount: reset.AIPointsAmount,
|
||||
AIPointUsageLogs: reset.AIPointUsageLogs,
|
||||
ArticleWindowStart: reset.ArticleWindowStart,
|
||||
ArticleWindowEnd: reset.ArticleWindowEnd,
|
||||
AIPointsWindowStart: reset.AIPointsWindowStart,
|
||||
AIPointsWindowEnd: reset.AIPointsWindowEnd,
|
||||
}
|
||||
s.invalidateQuotaSummaryCache(ctx, reset.TenantID)
|
||||
|
||||
if actor != nil {
|
||||
_ = s.audits.Append(ctx, actor.audit(ActionAdminUserResetUsage, "admin_user", id, map[string]any{
|
||||
@@ -504,7 +511,6 @@ func (s *AdminUserService) ResetCurrentPlanUsage(ctx context.Context, actor *Act
|
||||
"article_amount": reset.ArticleAmount,
|
||||
"ai_point_reservations": reset.AIPointReservations,
|
||||
"ai_points_amount": reset.AIPointsAmount,
|
||||
"ai_point_usage_logs": reset.AIPointUsageLogs,
|
||||
"article_window_start": reset.ArticleWindowStart,
|
||||
"article_window_end": reset.ArticleWindowEnd,
|
||||
"ai_points_window_start": reset.AIPointsWindowStart,
|
||||
@@ -518,6 +524,13 @@ func (s *AdminUserService) ResetCurrentPlanUsage(ctx context.Context, actor *Act
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AdminUserService) invalidateQuotaSummaryCache(ctx context.Context, tenantID int64) {
|
||||
if s == nil || s.cache == nil || tenantID <= 0 {
|
||||
return
|
||||
}
|
||||
_ = s.cache.Delete(ctx, fmt.Sprintf("workspace:quota_summary:%d", tenantID))
|
||||
}
|
||||
|
||||
func (s *AdminUserService) ChangeRole(ctx context.Context, actor *Actor, id int64, role string) (*AdminUserView, error) {
|
||||
normalizedRole := strings.TrimSpace(role)
|
||||
if !isValidTenantRole(normalizedRole) {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAdminUserInvalidateQuotaSummaryCache(t *testing.T) {
|
||||
cache := &recordingCache{}
|
||||
svc := NewAdminUserService(nil, nil, "").WithCache(cache)
|
||||
|
||||
svc.invalidateQuotaSummaryCache(context.Background(), 42)
|
||||
|
||||
if want := []string{"workspace:quota_summary:42"}; !reflect.DeepEqual(cache.deletedKeys, want) {
|
||||
t.Fatalf("deleted keys = %v, want %v", cache.deletedKeys, want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user