From 1aad002a3e8bc03e134d36cbb975e3d43a27ed56 Mon Sep 17 00:00:00 2001 From: liangxu Date: Tue, 12 May 2026 01:55:29 +0800 Subject: [PATCH] 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) --- apps/ops-web/src/views/AdminUsersView.vue | 1 - server/cmd/ops-api/main.go | 4 +- server/internal/ops/app/admin_user.go | 19 +++++++-- .../internal/ops/app/admin_user_cache_test.go | 18 +++++++++ server/internal/ops/repository/admin_user.go | 40 +++++-------------- ...331100007_create_quota_reservations.up.sql | 4 +- ...dd_quota_reservation_reset_status.down.sql | 12 ++++++ ..._add_quota_reservation_reset_status.up.sql | 6 +++ 8 files changed, 69 insertions(+), 35 deletions(-) create mode 100644 server/internal/ops/app/admin_user_cache_test.go create mode 100644 server/migrations/20260512130000_add_quota_reservation_reset_status.down.sql create mode 100644 server/migrations/20260512130000_add_quota_reservation_reset_status.up.sql diff --git a/apps/ops-web/src/views/AdminUsersView.vue b/apps/ops-web/src/views/AdminUsersView.vue index cbe723f..56652ef 100644 --- a/apps/ops-web/src/views/AdminUsersView.vue +++ b/apps/ops-web/src/views/AdminUsersView.vue @@ -360,7 +360,6 @@ interface ResetPlanUsageResult { article_amount: number ai_point_reservations: number ai_points_amount: number - ai_point_usage_logs: number } } diff --git a/server/cmd/ops-api/main.go b/server/cmd/ops-api/main.go index 9f7377b..f7699d8 100644 --- a/server/cmd/ops-api/main.go +++ b/server/cmd/ops-api/main.go @@ -95,7 +95,9 @@ func main() { authSvc := app.NewAuthService(accountsRepo, auditSvc, issuer, loginGuard) accountSvc := app.NewAccountService(accountsRepo, auditSvc) 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) kolSubscriptionSvc := app.NewKolSubscriptionService(kolSubscriptionsRepo, auditSvc).WithCache(appCache) siteDomainMappingSvc := app.NewSiteDomainMappingService(siteDomainMappingsRepo, auditSvc) diff --git a/server/internal/ops/app/admin_user.go b/server/internal/ops/app/admin_user.go index 40fa975..b574b4e 100644 --- a/server/internal/ops/app/admin_user.go +++ b/server/internal/ops/app/admin_user.go @@ -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) { diff --git a/server/internal/ops/app/admin_user_cache_test.go b/server/internal/ops/app/admin_user_cache_test.go new file mode 100644 index 0000000..b405018 --- /dev/null +++ b/server/internal/ops/app/admin_user_cache_test.go @@ -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) + } +} diff --git a/server/internal/ops/repository/admin_user.go b/server/internal/ops/repository/admin_user.go index 2a87e77..c6ea88c 100644 --- a/server/internal/ops/repository/admin_user.go +++ b/server/internal/ops/repository/admin_user.go @@ -91,7 +91,6 @@ type ResetPlanUsageResult struct { ArticleAmount int64 AIPointReservations int64 AIPointsAmount int64 - AIPointUsageLogs int64 ArticleWindowStart time.Time ArticleWindowEnd time.Time AIPointsWindowStart time.Time @@ -576,13 +575,12 @@ LIMIT 1`, userID).Scan(&tenantID); err != nil { } 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 { return nil, ResetPlanUsageResult{}, err } result.AIPointReservations = count result.AIPointsAmount = amount - result.AIPointUsageLogs = logCount } 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, ` WITH reset AS ( UPDATE quota_reservations - SET status = 'refunded', - refunded_amount = reserved_amount, + SET status = 'reset', updated_at = NOW() WHERE tenant_id = $1 AND quota_type = $2 - AND status IN ('pending', 'confirmed') + AND status = 'confirmed' AND created_at >= $3 AND created_at < $4 RETURNING reserved_amount @@ -622,43 +619,28 @@ FROM reset`, tenantID, quotaType, startAt.UTC(), endAt.UTC()) 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, ` WITH reset AS ( UPDATE quota_reservations - SET status = 'refunded', - refunded_amount = reserved_amount, + SET status = 'reset', updated_at = NOW() WHERE tenant_id = $1 AND quota_type = 'ai_points' - AND status IN ('pending', 'confirmed') + AND status = 'confirmed' AND created_at >= $2 AND created_at < $3 - RETURNING id, 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 + RETURNING reserved_amount ) SELECT (SELECT COUNT(*) FROM reset)::BIGINT, - COALESCE((SELECT SUM(reserved_amount) FROM reset), 0)::BIGINT, - (SELECT COUNT(*) FROM logs)::BIGINT`, tenantID, startAt.UTC(), endAt.UTC()) + COALESCE((SELECT SUM(reserved_amount) FROM reset), 0)::BIGINT`, tenantID, startAt.UTC(), endAt.UTC()) var reservationCount int64 var amount int64 - var logCount int64 - if err := row.Scan(&reservationCount, &amount, &logCount); err != nil { - return 0, 0, 0, err + if err := row.Scan(&reservationCount, &amount); err != nil { + return 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) { diff --git a/server/migrations/20260331100007_create_quota_reservations.up.sql b/server/migrations/20260331100007_create_quota_reservations.up.sql index c1b8727..d8b060f 100644 --- a/server/migrations/20260331100007_create_quota_reservations.up.sql +++ b/server/migrations/20260331100007_create_quota_reservations.up.sql @@ -10,6 +10,8 @@ CREATE TABLE quota_reservations ( status VARCHAR(20) NOT NULL DEFAULT 'pending', expire_at TIMESTAMPTZ NOT NULL, 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); diff --git a/server/migrations/20260512130000_add_quota_reservation_reset_status.down.sql b/server/migrations/20260512130000_add_quota_reservation_reset_status.down.sql new file mode 100644 index 0000000..87de24c --- /dev/null +++ b/server/migrations/20260512130000_add_quota_reservation_reset_status.down.sql @@ -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')); diff --git a/server/migrations/20260512130000_add_quota_reservation_reset_status.up.sql b/server/migrations/20260512130000_add_quota_reservation_reset_status.up.sql new file mode 100644 index 0000000..cc3384b --- /dev/null +++ b/server/migrations/20260512130000_add_quota_reservation_reset_status.up.sql @@ -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'));