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:
@@ -360,7 +360,6 @@ interface ResetPlanUsageResult {
|
|||||||
article_amount: number
|
article_amount: number
|
||||||
ai_point_reservations: number
|
ai_point_reservations: number
|
||||||
ai_points_amount: number
|
ai_points_amount: number
|
||||||
ai_point_usage_logs: number
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,9 @@ func main() {
|
|||||||
authSvc := app.NewAuthService(accountsRepo, auditSvc, issuer, loginGuard)
|
authSvc := app.NewAuthService(accountsRepo, auditSvc, issuer, loginGuard)
|
||||||
accountSvc := app.NewAccountService(accountsRepo, auditSvc)
|
accountSvc := app.NewAccountService(accountsRepo, auditSvc)
|
||||||
tenantLoginGuard := sharedauth.NewLoginGuard(rdb, sharedauth.DefaultLoginGuardConfig("tenant"))
|
tenantLoginGuard := sharedauth.NewLoginGuard(rdb, sharedauth.DefaultLoginGuardConfig("tenant"))
|
||||||
adminUserSvc := app.NewAdminUserService(adminUsersRepo, auditSvc, cfg.AdminUsers.DefaultPlanCode).WithLoginGuard(tenantLoginGuard)
|
adminUserSvc := app.NewAdminUserService(adminUsersRepo, auditSvc, cfg.AdminUsers.DefaultPlanCode).
|
||||||
|
WithLoginGuard(tenantLoginGuard).
|
||||||
|
WithCache(appCache)
|
||||||
jobSvc := app.NewJobService(jobsRepo, auditSvc, rabbitMQ)
|
jobSvc := app.NewJobService(jobsRepo, auditSvc, rabbitMQ)
|
||||||
kolSubscriptionSvc := app.NewKolSubscriptionService(kolSubscriptionsRepo, auditSvc).WithCache(appCache)
|
kolSubscriptionSvc := app.NewKolSubscriptionService(kolSubscriptionsRepo, auditSvc).WithCache(appCache)
|
||||||
siteDomainMappingSvc := app.NewSiteDomainMappingService(siteDomainMappingsRepo, auditSvc)
|
siteDomainMappingSvc := app.NewSiteDomainMappingService(siteDomainMappingsRepo, auditSvc)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package app
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -15,6 +16,7 @@ import (
|
|||||||
"github.com/geo-platform/tenant-api/internal/ops/domain"
|
"github.com/geo-platform/tenant-api/internal/ops/domain"
|
||||||
"github.com/geo-platform/tenant-api/internal/ops/repository"
|
"github.com/geo-platform/tenant-api/internal/ops/repository"
|
||||||
sharedauth "github.com/geo-platform/tenant-api/internal/shared/auth"
|
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"
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,6 +40,7 @@ type AdminUserService struct {
|
|||||||
users *repository.AdminUserRepository
|
users *repository.AdminUserRepository
|
||||||
audits *AuditService
|
audits *AuditService
|
||||||
loginGuard *sharedauth.LoginGuard
|
loginGuard *sharedauth.LoginGuard
|
||||||
|
cache sharedcache.Cache
|
||||||
configMu sync.RWMutex
|
configMu sync.RWMutex
|
||||||
defaultPlanCode string
|
defaultPlanCode string
|
||||||
}
|
}
|
||||||
@@ -59,6 +62,11 @@ func (s *AdminUserService) WithLoginGuard(g *sharedauth.LoginGuard) *AdminUserSe
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AdminUserService) WithCache(c sharedcache.Cache) *AdminUserService {
|
||||||
|
s.cache = c
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
func (s *AdminUserService) UpdateDefaultPlanCode(defaultPlanCode string) {
|
func (s *AdminUserService) UpdateDefaultPlanCode(defaultPlanCode string) {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return
|
return
|
||||||
@@ -167,7 +175,6 @@ type ResetPlanUsageView struct {
|
|||||||
ArticleAmount int64 `json:"article_amount"`
|
ArticleAmount int64 `json:"article_amount"`
|
||||||
AIPointReservations int64 `json:"ai_point_reservations"`
|
AIPointReservations int64 `json:"ai_point_reservations"`
|
||||||
AIPointsAmount int64 `json:"ai_points_amount"`
|
AIPointsAmount int64 `json:"ai_points_amount"`
|
||||||
AIPointUsageLogs int64 `json:"ai_point_usage_logs"`
|
|
||||||
ArticleWindowStart time.Time `json:"article_window_start"`
|
ArticleWindowStart time.Time `json:"article_window_start"`
|
||||||
ArticleWindowEnd time.Time `json:"article_window_end"`
|
ArticleWindowEnd time.Time `json:"article_window_end"`
|
||||||
AIPointsWindowStart time.Time `json:"ai_points_window_start"`
|
AIPointsWindowStart time.Time `json:"ai_points_window_start"`
|
||||||
@@ -489,12 +496,12 @@ func (s *AdminUserService) ResetCurrentPlanUsage(ctx context.Context, actor *Act
|
|||||||
ArticleAmount: reset.ArticleAmount,
|
ArticleAmount: reset.ArticleAmount,
|
||||||
AIPointReservations: reset.AIPointReservations,
|
AIPointReservations: reset.AIPointReservations,
|
||||||
AIPointsAmount: reset.AIPointsAmount,
|
AIPointsAmount: reset.AIPointsAmount,
|
||||||
AIPointUsageLogs: reset.AIPointUsageLogs,
|
|
||||||
ArticleWindowStart: reset.ArticleWindowStart,
|
ArticleWindowStart: reset.ArticleWindowStart,
|
||||||
ArticleWindowEnd: reset.ArticleWindowEnd,
|
ArticleWindowEnd: reset.ArticleWindowEnd,
|
||||||
AIPointsWindowStart: reset.AIPointsWindowStart,
|
AIPointsWindowStart: reset.AIPointsWindowStart,
|
||||||
AIPointsWindowEnd: reset.AIPointsWindowEnd,
|
AIPointsWindowEnd: reset.AIPointsWindowEnd,
|
||||||
}
|
}
|
||||||
|
s.invalidateQuotaSummaryCache(ctx, reset.TenantID)
|
||||||
|
|
||||||
if actor != nil {
|
if actor != nil {
|
||||||
_ = s.audits.Append(ctx, actor.audit(ActionAdminUserResetUsage, "admin_user", id, map[string]any{
|
_ = 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,
|
"article_amount": reset.ArticleAmount,
|
||||||
"ai_point_reservations": reset.AIPointReservations,
|
"ai_point_reservations": reset.AIPointReservations,
|
||||||
"ai_points_amount": reset.AIPointsAmount,
|
"ai_points_amount": reset.AIPointsAmount,
|
||||||
"ai_point_usage_logs": reset.AIPointUsageLogs,
|
|
||||||
"article_window_start": reset.ArticleWindowStart,
|
"article_window_start": reset.ArticleWindowStart,
|
||||||
"article_window_end": reset.ArticleWindowEnd,
|
"article_window_end": reset.ArticleWindowEnd,
|
||||||
"ai_points_window_start": reset.AIPointsWindowStart,
|
"ai_points_window_start": reset.AIPointsWindowStart,
|
||||||
@@ -518,6 +524,13 @@ func (s *AdminUserService) ResetCurrentPlanUsage(ctx context.Context, actor *Act
|
|||||||
}, nil
|
}, 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) {
|
func (s *AdminUserService) ChangeRole(ctx context.Context, actor *Actor, id int64, role string) (*AdminUserView, error) {
|
||||||
normalizedRole := strings.TrimSpace(role)
|
normalizedRole := strings.TrimSpace(role)
|
||||||
if !isValidTenantRole(normalizedRole) {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,7 +91,6 @@ type ResetPlanUsageResult struct {
|
|||||||
ArticleAmount int64
|
ArticleAmount int64
|
||||||
AIPointReservations int64
|
AIPointReservations int64
|
||||||
AIPointsAmount int64
|
AIPointsAmount int64
|
||||||
AIPointUsageLogs int64
|
|
||||||
ArticleWindowStart time.Time
|
ArticleWindowStart time.Time
|
||||||
ArticleWindowEnd time.Time
|
ArticleWindowEnd time.Time
|
||||||
AIPointsWindowStart time.Time
|
AIPointsWindowStart time.Time
|
||||||
@@ -576,13 +575,12 @@ LIMIT 1`, userID).Scan(&tenantID); err != nil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if aiEnd.After(aiStart) {
|
if aiEnd.After(aiStart) {
|
||||||
count, amount, logCount, err := resetAIPointReservations(ctx, tx, tenantID, aiStart, aiEnd)
|
count, amount, err := resetAIPointReservations(ctx, tx, tenantID, aiStart, aiEnd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ResetPlanUsageResult{}, err
|
return nil, ResetPlanUsageResult{}, err
|
||||||
}
|
}
|
||||||
result.AIPointReservations = count
|
result.AIPointReservations = count
|
||||||
result.AIPointsAmount = amount
|
result.AIPointsAmount = amount
|
||||||
result.AIPointUsageLogs = logCount
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
if err := tx.Commit(ctx); err != nil {
|
||||||
@@ -600,12 +598,11 @@ func resetQuotaReservations(ctx context.Context, tx pgx.Tx, tenantID int64, quot
|
|||||||
row := tx.QueryRow(ctx, `
|
row := tx.QueryRow(ctx, `
|
||||||
WITH reset AS (
|
WITH reset AS (
|
||||||
UPDATE quota_reservations
|
UPDATE quota_reservations
|
||||||
SET status = 'refunded',
|
SET status = 'reset',
|
||||||
refunded_amount = reserved_amount,
|
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE tenant_id = $1
|
WHERE tenant_id = $1
|
||||||
AND quota_type = $2
|
AND quota_type = $2
|
||||||
AND status IN ('pending', 'confirmed')
|
AND status = 'confirmed'
|
||||||
AND created_at >= $3
|
AND created_at >= $3
|
||||||
AND created_at < $4
|
AND created_at < $4
|
||||||
RETURNING reserved_amount
|
RETURNING reserved_amount
|
||||||
@@ -622,43 +619,28 @@ FROM reset`, tenantID, quotaType, startAt.UTC(), endAt.UTC())
|
|||||||
return count, amount, nil
|
return count, amount, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resetAIPointReservations(ctx context.Context, tx pgx.Tx, tenantID int64, startAt, endAt time.Time) (int64, int64, int64, error) {
|
func resetAIPointReservations(ctx context.Context, tx pgx.Tx, tenantID int64, startAt, endAt time.Time) (int64, int64, error) {
|
||||||
row := tx.QueryRow(ctx, `
|
row := tx.QueryRow(ctx, `
|
||||||
WITH reset AS (
|
WITH reset AS (
|
||||||
UPDATE quota_reservations
|
UPDATE quota_reservations
|
||||||
SET status = 'refunded',
|
SET status = 'reset',
|
||||||
refunded_amount = reserved_amount,
|
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE tenant_id = $1
|
WHERE tenant_id = $1
|
||||||
AND quota_type = 'ai_points'
|
AND quota_type = 'ai_points'
|
||||||
AND status IN ('pending', 'confirmed')
|
AND status = 'confirmed'
|
||||||
AND created_at >= $2
|
AND created_at >= $2
|
||||||
AND created_at < $3
|
AND created_at < $3
|
||||||
RETURNING id, reserved_amount
|
RETURNING reserved_amount
|
||||||
),
|
|
||||||
logs AS (
|
|
||||||
UPDATE ai_point_usage_logs l
|
|
||||||
SET status = 'refunded',
|
|
||||||
error_message = 'ops reset current plan usage',
|
|
||||||
completed_at = COALESCE(l.completed_at, NOW()),
|
|
||||||
updated_at = NOW()
|
|
||||||
FROM reset r
|
|
||||||
WHERE l.tenant_id = $1
|
|
||||||
AND l.quota_reservation_id = r.id
|
|
||||||
AND l.status IN ('pending', 'completed')
|
|
||||||
RETURNING l.id
|
|
||||||
)
|
)
|
||||||
SELECT (SELECT COUNT(*) FROM reset)::BIGINT,
|
SELECT (SELECT COUNT(*) FROM reset)::BIGINT,
|
||||||
COALESCE((SELECT SUM(reserved_amount) FROM reset), 0)::BIGINT,
|
COALESCE((SELECT SUM(reserved_amount) FROM reset), 0)::BIGINT`, tenantID, startAt.UTC(), endAt.UTC())
|
||||||
(SELECT COUNT(*) FROM logs)::BIGINT`, tenantID, startAt.UTC(), endAt.UTC())
|
|
||||||
|
|
||||||
var reservationCount int64
|
var reservationCount int64
|
||||||
var amount int64
|
var amount int64
|
||||||
var logCount int64
|
if err := row.Scan(&reservationCount, &amount); err != nil {
|
||||||
if err := row.Scan(&reservationCount, &amount, &logCount); err != nil {
|
return 0, 0, err
|
||||||
return 0, 0, 0, err
|
|
||||||
}
|
}
|
||||||
return reservationCount, amount, logCount, nil
|
return reservationCount, amount, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *AdminUserRepository) ChangeRole(ctx context.Context, userID int64, tenantRole, workspaceRole string) (*AdminUserRecord, error) {
|
func (r *AdminUserRepository) ChangeRole(ctx context.Context, userID int64, tenantRole, workspaceRole string) (*AdminUserRecord, error) {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ CREATE TABLE quota_reservations (
|
|||||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
expire_at TIMESTAMPTZ NOT NULL,
|
expire_at TIMESTAMPTZ NOT NULL,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT chk_quota_reservation_status
|
||||||
|
CHECK (status IN ('pending', 'confirmed', 'refunded', 'reset'))
|
||||||
);
|
);
|
||||||
CREATE INDEX idx_quota_reservation_tenant_status ON quota_reservations(tenant_id, status);
|
CREATE INDEX idx_quota_reservation_tenant_status ON quota_reservations(tenant_id, status);
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
UPDATE quota_reservations
|
||||||
|
SET status = 'refunded',
|
||||||
|
refunded_amount = reserved_amount,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE status = 'reset';
|
||||||
|
|
||||||
|
ALTER TABLE quota_reservations
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_quota_reservation_status;
|
||||||
|
|
||||||
|
ALTER TABLE quota_reservations
|
||||||
|
ADD CONSTRAINT chk_quota_reservation_status
|
||||||
|
CHECK (status IN ('pending', 'confirmed', 'refunded'));
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE quota_reservations
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_quota_reservation_status;
|
||||||
|
|
||||||
|
ALTER TABLE quota_reservations
|
||||||
|
ADD CONSTRAINT chk_quota_reservation_status
|
||||||
|
CHECK (status IN ('pending', 'confirmed', 'refunded', 'reset'));
|
||||||
Reference in New Issue
Block a user