From 6fe0ee59791c71979efa0f8a57c7edb79a53259b Mon Sep 17 00:00:00 2001 From: John Doe Date: Sun, 1 Mar 2026 03:02:27 +0300 Subject: [PATCH 1/8] feat: add costResetAt for soft user limit reset without deleting data Add costResetAt timestamp column to users table that clips all cost calculations to start from reset time instead of all-time. This enables admins to reset a user's rate limits without destroying historical usage data (messageRequest/usageLedger rows are preserved). Key changes: - Schema: new cost_reset_at column on users table - Repository: costResetAt propagated through all select queries, key validation, and statistics aggregation (with per-user batch support) - Rate limiting: all 12 proxy guard checks pass costResetAt; service and lease layers clip time windows accordingly - Auth cache: hydrate costResetAt from Redis cache as Date; invalidate auth cache on reset to avoid stale costResetAt - Actions: resetUserLimitsOnly sets costResetAt + clears cost cache; getUserLimitUsage/getUserAllLimitUsage/getKeyLimitUsage/getMyQuota clip time ranges by costResetAt - UI: edit-user-dialog with separate Reset Limits Only (amber) vs Reset All Statistics (red) with confirmation dialogs - i18n: all 5 languages (en, zh-CN, zh-TW, ja, ru) - Tests: 10 unit tests for resetUserLimitsOnly Co-Authored-By: Claude Opus 4.6 --- drizzle/0078_tearful_punisher.sql | 1 + drizzle/meta/0078_snapshot.json | 3914 +++++++++++++++++ drizzle/meta/_journal.json | 7 + messages/en/dashboard.json | 14 + messages/ja/dashboard.json | 14 + messages/ru/dashboard.json | 14 + messages/zh-CN/dashboard.json | 14 + messages/zh-TW/dashboard.json | 14 + src/actions/keys.ts | 23 +- src/actions/my-usage.ts | 45 +- src/actions/users.ts | 151 +- .../_components/user/edit-user-dialog.tsx | 189 +- .../[locale]/dashboard/quotas/users/page.tsx | 24 +- src/app/v1/_lib/proxy/rate-limit-guard.ts | 13 +- src/drizzle/schema.ts | 1 + src/lib/rate-limit/lease-service.ts | 7 +- src/lib/rate-limit/service.ts | 97 +- src/lib/security/api-key-auth-cache.ts | 2 + src/repository/_shared/transformers.ts | 1 + src/repository/key.ts | 3 + src/repository/statistics.ts | 172 +- src/repository/user.ts | 5 + src/types/user.ts | 2 + .../actions/users-reset-limits-only.test.ts | 251 ++ 24 files changed, 4834 insertions(+), 144 deletions(-) create mode 100644 drizzle/0078_tearful_punisher.sql create mode 100644 drizzle/meta/0078_snapshot.json create mode 100644 tests/unit/actions/users-reset-limits-only.test.ts diff --git a/drizzle/0078_tearful_punisher.sql b/drizzle/0078_tearful_punisher.sql new file mode 100644 index 000000000..3d6122c9c --- /dev/null +++ b/drizzle/0078_tearful_punisher.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD COLUMN "cost_reset_at" timestamp with time zone; \ No newline at end of file diff --git a/drizzle/meta/0078_snapshot.json b/drizzle/meta/0078_snapshot.json new file mode 100644 index 000000000..c115d513a --- /dev/null +++ b/drizzle/meta/0078_snapshot.json @@ -0,0 +1,3914 @@ +{ + "id": "e7b57b9b-e77f-4e34-afaf-e78786224b4f", + "prevId": "22eb3652-56d7-4a04-9845-5fa18210ef90", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_key": { + "name": "idx_keys_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_cause": { + "name": "error_cause", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_created_at_cost_stats": { + "name": "idx_message_request_user_created_at_cost_stats", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_created_at_active": { + "name": "idx_message_request_provider_created_at_active", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id_prefix": { + "name": "idx_message_request_session_id_prefix", + "columns": [ + { + "expression": "\"session_id\" varchar_pattern_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_created_at_id": { + "name": "idx_message_request_key_created_at_id", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_model_active": { + "name": "idx_message_request_key_model_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_endpoint_active": { + "name": "idx_message_request_key_endpoint_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"endpoint\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at_id_active": { + "name": "idx_message_request_created_at_id_active", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_model_active": { + "name": "idx_message_request_model_active", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_status_code_active": { + "name": "idx_message_request_status_code_active", + "columns": [ + { + "expression": "status_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_last_active": { + "name": "idx_message_request_key_last_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_cost_active": { + "name": "idx_message_request_key_cost_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_user_info": { + "name": "idx_message_request_session_user_info", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'litellm'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_source": { + "name": "idx_model_prices_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "cache_hit_rate_alert_enabled": { + "name": "cache_hit_rate_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cache_hit_rate_alert_webhook": { + "name": "cache_hit_rate_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cache_hit_rate_alert_window_mode": { + "name": "cache_hit_rate_alert_window_mode", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "cache_hit_rate_alert_check_interval": { + "name": "cache_hit_rate_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cache_hit_rate_alert_historical_lookback_days": { + "name": "cache_hit_rate_alert_historical_lookback_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 7 + }, + "cache_hit_rate_alert_min_eligible_requests": { + "name": "cache_hit_rate_alert_min_eligible_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 20 + }, + "cache_hit_rate_alert_min_eligible_tokens": { + "name": "cache_hit_rate_alert_min_eligible_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cache_hit_rate_alert_abs_min": { + "name": "cache_hit_rate_alert_abs_min", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "cache_hit_rate_alert_drop_rel": { + "name": "cache_hit_rate_alert_drop_rel", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.3'" + }, + "cache_hit_rate_alert_drop_abs": { + "name": "cache_hit_rate_alert_drop_abs", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.1'" + }, + "cache_hit_rate_alert_cooldown_minutes": { + "name": "cache_hit_rate_alert_cooldown_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cache_hit_rate_alert_top_n": { + "name": "cache_hit_rate_alert_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoint_probe_logs": { + "name": "provider_endpoint_probe_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_endpoint_probe_logs_endpoint_created_at": { + "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoint_probe_logs_created_at": { + "name": "idx_provider_endpoint_probe_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { + "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", + "tableFrom": "provider_endpoint_probe_logs", + "tableTo": "provider_endpoints", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoints": { + "name": "provider_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vendor_id": { + "name": "vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_probed_at": { + "name": "last_probed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_ok": { + "name": "last_probe_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_probe_status_code": { + "name": "last_probe_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_latency_ms": { + "name": "last_probe_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_type": { + "name": "last_probe_error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_message": { + "name": "last_probe_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uniq_provider_endpoints_vendor_type_url": { + "name": "uniq_provider_endpoints_vendor_type_url", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_vendor_type": { + "name": "idx_provider_endpoints_vendor_type", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_enabled": { + "name": "idx_provider_endpoints_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_pick_enabled": { + "name": "idx_provider_endpoints_pick_enabled", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_created_at": { + "name": "idx_provider_endpoints_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_deleted_at": { + "name": "idx_provider_endpoints_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoints_vendor_id_provider_vendors_id_fk": { + "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", + "tableFrom": "provider_endpoints", + "tableTo": "provider_vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_vendors": { + "name": "provider_vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "website_domain": { + "name": "website_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_vendors_website_domain": { + "name": "uniq_provider_vendors_website_domain", + "columns": [ + { + "expression": "website_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_vendors_created_at": { + "name": "idx_provider_vendors_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_vendor_id": { + "name": "provider_vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "group_priorities": { + "name": "group_priorities", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "active_time_start": { + "name": "active_time_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "active_time_end": { + "name": "active_time_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "swap_cache_ttl_billing": { + "name": "swap_cache_ttl_billing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_effort_preference": { + "name": "codex_reasoning_effort_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_summary_preference": { + "name": "codex_reasoning_summary_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_text_verbosity_preference": { + "name": "codex_text_verbosity_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_parallel_tool_calls_preference": { + "name": "codex_parallel_tool_calls_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "anthropic_max_tokens_preference": { + "name": "anthropic_max_tokens_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_thinking_budget_preference": { + "name": "anthropic_thinking_budget_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_adaptive_thinking": { + "name": "anthropic_adaptive_thinking", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "gemini_google_search_preference": { + "name": "gemini_google_search_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type_url_active": { + "name": "idx_providers_vendor_type_url_active", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type": { + "name": "idx_providers_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_enabled_vendor_type": { + "name": "idx_providers_enabled_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL AND \"providers\".\"is_enabled\" = true AND \"providers\".\"provider_vendor_id\" IS NOT NULL AND \"providers\".\"provider_vendor_id\" > 0", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "providers_provider_vendor_id_provider_vendors_id_fk": { + "name": "providers_provider_vendor_id_provider_vendors_id_fk", + "tableFrom": "providers", + "tableTo": "provider_vendors", + "columnsFrom": [ + "provider_vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_budget_rectifier": { + "name": "enable_thinking_budget_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_billing_header_rectifier": { + "name": "enable_billing_header_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_codex_session_id_completion": { + "name": "enable_codex_session_id_completion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_claude_metadata_user_id_injection": { + "name": "enable_claude_metadata_user_id_injection", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_fixer": { + "name": "enable_response_fixer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_fixer_config": { + "name": "response_fixer_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb" + }, + "quota_db_refresh_interval_seconds": { + "name": "quota_db_refresh_interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "quota_lease_percent_5h": { + "name": "quota_lease_percent_5h", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_daily": { + "name": "quota_lease_percent_daily", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_weekly": { + "name": "quota_lease_percent_weekly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_monthly": { + "name": "quota_lease_percent_monthly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_cap_usd": { + "name": "quota_lease_cap_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "final_provider_id": { + "name": "final_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_success": { + "name": "is_success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_usage_ledger_request_id": { + "name": "idx_usage_ledger_request_id", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_user_created_at": { + "name": "idx_usage_ledger_user_created_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_created_at": { + "name": "idx_usage_ledger_key_created_at", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_provider_created_at": { + "name": "idx_usage_ledger_provider_created_at", + "columns": [ + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_created_at_minute": { + "name": "idx_usage_ledger_created_at_minute", + "columns": [ + { + "expression": "date_trunc('minute', \"created_at\" AT TIME ZONE 'UTC')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_created_at_desc_id": { + "name": "idx_usage_ledger_created_at_desc_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_session_id": { + "name": "idx_usage_ledger_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"session_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_model": { + "name": "idx_usage_ledger_model", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_cost": { + "name": "idx_usage_ledger_key_cost", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_user_cost_cover": { + "name": "idx_usage_ledger_user_cost_cover", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_provider_cost_cover": { + "name": "idx_usage_ledger_provider_cost_cover", + "columns": [ + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "cost_reset_at": { + "name": "cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_active_role_sort": { + "name": "idx_users_active_role_sort", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_enabled_expires_at": { + "name": "idx_users_enabled_expires_at", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_tags_gin": { + "name": "idx_users_tags_gin", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_users_created_at": { + "name": "idx_users_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_targets": { + "name": "webhook_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "webhook_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "webhook_url": { + "name": "webhook_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "telegram_bot_token": { + "name": "telegram_bot_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "dingtalk_secret": { + "name": "dingtalk_secret", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "custom_template": { + "name": "custom_template", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_result": { + "name": "last_test_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.daily_reset_mode": { + "name": "daily_reset_mode", + "schema": "public", + "values": [ + "fixed", + "rolling" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "circuit_breaker", + "daily_leaderboard", + "cost_alert", + "cache_hit_rate_alert" + ] + }, + "public.webhook_provider_type": { + "name": "webhook_provider_type", + "schema": "public", + "values": [ + "wechat", + "feishu", + "dingtalk", + "telegram", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index fb7b5a646..fc72b6246 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -547,6 +547,13 @@ "when": 1772219877045, "tag": "0077_nappy_giant_man", "breakpoints": true + }, + { + "idx": 78, + "version": "7", + "when": 1772318059170, + "tag": "0078_tearful_punisher", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 15195b0c5..f2ef39736 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -1515,6 +1515,20 @@ "deleteFailed": "Failed to delete user", "userDeleted": "User has been deleted", "saving": "Saving...", + "resetSection": { + "title": "Reset Data" + }, + "resetLimits": { + "title": "Reset Limits", + "description": "Reset accumulated cost counters for all limits. Request logs and statistics are preserved.", + "button": "Reset Limits", + "confirmTitle": "Reset Limits Only?", + "confirmDescription": "This will reset all accumulated cost counters (5h, daily, weekly, monthly, total) to zero. Request logs and usage statistics will be preserved.", + "confirm": "Yes, Reset Limits", + "loading": "Resetting...", + "error": "Failed to reset limits", + "success": "All limits have been reset" + }, "resetData": { "title": "Reset Statistics", "description": "Delete all request logs and usage data for this user. This action is irreversible.", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 1555e52af..a7b375963 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -1493,6 +1493,20 @@ "deleteFailed": "ユーザーの削除に失敗しました", "userDeleted": "ユーザーが削除されました", "saving": "保存しています...", + "resetSection": { + "title": "データリセット" + }, + "resetLimits": { + "title": "制限のリセット", + "description": "全ての制限の累積コストカウンターをリセットします。リクエストログと統計データは保持されます。", + "button": "制限をリセット", + "confirmTitle": "制限のみリセットしますか?", + "confirmDescription": "全ての累積コストカウンター(5時間、日次、週次、月次、合計)がゼロにリセットされます。リクエストログと利用統計は保持されます。", + "confirm": "はい、リセットする", + "loading": "リセット中...", + "error": "制限のリセットに失敗しました", + "success": "全ての制限がリセットされました" + }, "resetData": { "title": "統計リセット", "description": "このユーザーのすべてのリクエストログと使用データを削除します。この操作は元に戻せません。", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 64a2c8211..9134a454b 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -1498,6 +1498,20 @@ "deleteFailed": "Не удалось удалить пользователя", "userDeleted": "Пользователь удален", "saving": "Сохранение...", + "resetSection": { + "title": "Сброс данных" + }, + "resetLimits": { + "title": "Сброс лимитов", + "description": "Сбросить накопленные счетчики расходов для всех лимитов. Логи запросов и статистика сохраняются.", + "button": "Сбросить лимиты", + "confirmTitle": "Сбросить только лимиты?", + "confirmDescription": "Все накопленные счетчики расходов (5ч, дневной, недельный, месячный, общий) будут обнулены. Логи запросов и статистика использования сохранятся.", + "confirm": "Да, сбросить лимиты", + "loading": "Сброс...", + "error": "Не удалось сбросить лимиты", + "success": "Все лимиты сброшены" + }, "resetData": { "title": "Сброс статистики", "description": "Удалить все логи запросов и данные использования для этого пользователя. Это действие необратимо.", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 6743b6857..f7e328586 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -1516,6 +1516,20 @@ "deleteFailed": "删除用户失败", "userDeleted": "用户已删除", "saving": "保存中...", + "resetSection": { + "title": "数据重置" + }, + "resetLimits": { + "title": "重置限额", + "description": "重置所有限额的累计消费计数器。请求日志和统计数据将被保留。", + "button": "重置限额", + "confirmTitle": "仅重置限额?", + "confirmDescription": "这将把所有累计消费计数器(5小时、每日、每周、每月、总计)归零。请求日志和使用统计将被保留。", + "confirm": "是的,重置限额", + "loading": "正在重置...", + "error": "重置限额失败", + "success": "所有限额已重置" + }, "resetData": { "title": "重置统计", "description": "删除该用户的所有请求日志和使用数据。此操作不可逆。", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index e28e8eb07..e2a5bef75 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -1501,6 +1501,20 @@ "deleteFailed": "刪除使用者失敗", "userDeleted": "使用者已刪除", "saving": "儲存中...", + "resetSection": { + "title": "資料重設" + }, + "resetLimits": { + "title": "重設限額", + "description": "重設所有限額的累計消費計數器。請求日誌和統計資料將被保留。", + "button": "重設限額", + "confirmTitle": "僅重設限額?", + "confirmDescription": "這將把所有累計消費計數器(5小時、每日、每週、每月、總計)歸零。請求日誌和使用統計將被保留。", + "confirm": "是的,重設限額", + "loading": "正在重設...", + "error": "重設限額失敗", + "success": "所有限額已重設" + }, "resetData": { "title": "重置統計", "description": "刪除該使用者的所有請求日誌和使用資料。此操作不可逆。", diff --git a/src/actions/keys.ts b/src/actions/keys.ts index d329c138f..c5c87ef2a 100644 --- a/src/actions/keys.ts +++ b/src/actions/keys.ts @@ -733,6 +733,15 @@ export async function getKeyLimitUsage(keyId: number): Promise< result.userLimitConcurrentSessions ?? null ); + // Load owning user to get costResetAt for limits-only reset + const { findUserById } = await import("@/repository/user"); + const ownerUser = await findUserById(key.userId); + const costResetAt = ownerUser?.costResetAt ?? null; + + // Clip time range start by costResetAt (for limits-only reset) + const clipStart = (start: Date): Date => + costResetAt && costResetAt > start ? costResetAt : start; + // Calculate time ranges using Key's dailyResetTime/dailyResetMode configuration const keyDailyTimeRange = await getTimeRangeForPeriodWithMode( "daily", @@ -748,11 +757,15 @@ export async function getKeyLimitUsage(keyId: number): Promise< // 获取金额消费(使用 DB direct,与 my-usage.ts 保持一致) const [cost5h, costDaily, costWeekly, costMonthly, totalCost, concurrentSessions] = await Promise.all([ - sumKeyCostInTimeRange(keyId, range5h.startTime, range5h.endTime), - sumKeyCostInTimeRange(keyId, keyDailyTimeRange.startTime, keyDailyTimeRange.endTime), - sumKeyCostInTimeRange(keyId, rangeWeekly.startTime, rangeWeekly.endTime), - sumKeyCostInTimeRange(keyId, rangeMonthly.startTime, rangeMonthly.endTime), - sumKeyTotalCost(key.key), + sumKeyCostInTimeRange(keyId, clipStart(range5h.startTime), range5h.endTime), + sumKeyCostInTimeRange( + keyId, + clipStart(keyDailyTimeRange.startTime), + keyDailyTimeRange.endTime + ), + sumKeyCostInTimeRange(keyId, clipStart(rangeWeekly.startTime), rangeWeekly.endTime), + sumKeyCostInTimeRange(keyId, clipStart(rangeMonthly.startTime), rangeMonthly.endTime), + sumKeyTotalCost(key.key, 365, costResetAt), SessionTracker.getKeySessionCount(keyId), ]); diff --git a/src/actions/my-usage.ts b/src/actions/my-usage.ts index a450f7603..8fa880353 100644 --- a/src/actions/my-usage.ts +++ b/src/actions/my-usage.ts @@ -242,6 +242,29 @@ export async function getMyQuota(): Promise> { const rangeWeekly = await getTimeRangeForPeriod("weekly"); const rangeMonthly = await getTimeRangeForPeriod("monthly"); + // Clip time range starts by costResetAt (for limits-only reset) + const costResetAt = user.costResetAt ?? null; + const clipStart = (start: Date): Date => + costResetAt && costResetAt > start ? costResetAt : start; + + const clippedRange5h = { startTime: clipStart(range5h.startTime), endTime: range5h.endTime }; + const clippedRangeWeekly = { + startTime: clipStart(rangeWeekly.startTime), + endTime: rangeWeekly.endTime, + }; + const clippedRangeMonthly = { + startTime: clipStart(rangeMonthly.startTime), + endTime: rangeMonthly.endTime, + }; + const clippedKeyDaily = { + startTime: clipStart(keyDailyTimeRange.startTime), + endTime: keyDailyTimeRange.endTime, + }; + const clippedUserDaily = { + startTime: clipStart(userDailyTimeRange.startTime), + endTime: userDailyTimeRange.endTime, + }; + const effectiveKeyConcurrentLimit = resolveKeyConcurrentSessionLimit( key.limitConcurrentSessions ?? 0, user.limitConcurrentSessions ?? null @@ -252,24 +275,26 @@ export async function getMyQuota(): Promise> { sumKeyQuotaCostsById( key.id, { - range5h, - rangeDaily: keyDailyTimeRange, - rangeWeekly, - rangeMonthly, + range5h: clippedRange5h, + rangeDaily: clippedKeyDaily, + rangeWeekly: clippedRangeWeekly, + rangeMonthly: clippedRangeMonthly, }, - ALL_TIME_MAX_AGE_DAYS + ALL_TIME_MAX_AGE_DAYS, + costResetAt ), SessionTracker.getKeySessionCount(key.id), // User 配额:直接查 DB sumUserQuotaCosts( user.id, { - range5h, - rangeDaily: userDailyTimeRange, - rangeWeekly, - rangeMonthly, + range5h: clippedRange5h, + rangeDaily: clippedUserDaily, + rangeWeekly: clippedRangeWeekly, + rangeMonthly: clippedRangeMonthly, }, - ALL_TIME_MAX_AGE_DAYS + ALL_TIME_MAX_AGE_DAYS, + costResetAt ), getUserConcurrentSessions(user.id), ]); diff --git a/src/actions/users.ts b/src/actions/users.ts index 768e7ec62..1886b5b95 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -272,6 +272,7 @@ export async function getUsers(): Promise { limitWeeklyUsd: user.limitWeeklyUsd ?? null, limitMonthlyUsd: user.limitMonthlyUsd ?? null, limitTotalUsd: user.limitTotalUsd ?? null, + costResetAt: user.costResetAt ?? null, limitConcurrentSessions: user.limitConcurrentSessions ?? null, dailyResetMode: user.dailyResetMode, dailyResetTime: user.dailyResetTime, @@ -339,6 +340,7 @@ export async function getUsers(): Promise { limitWeeklyUsd: user.limitWeeklyUsd ?? null, limitMonthlyUsd: user.limitMonthlyUsd ?? null, limitTotalUsd: user.limitTotalUsd ?? null, + costResetAt: user.costResetAt ?? null, limitConcurrentSessions: user.limitConcurrentSessions ?? null, dailyResetMode: user.dailyResetMode, dailyResetTime: user.dailyResetTime, @@ -543,6 +545,7 @@ export async function getUsersBatch( limitWeeklyUsd: user.limitWeeklyUsd ?? null, limitMonthlyUsd: user.limitMonthlyUsd ?? null, limitTotalUsd: user.limitTotalUsd ?? null, + costResetAt: user.costResetAt ?? null, limitConcurrentSessions: user.limitConcurrentSessions ?? null, dailyResetMode: user.dailyResetMode, dailyResetTime: user.dailyResetTime, @@ -606,6 +609,7 @@ export async function getUsersBatch( limitWeeklyUsd: user.limitWeeklyUsd ?? null, limitMonthlyUsd: user.limitMonthlyUsd ?? null, limitTotalUsd: user.limitTotalUsd ?? null, + costResetAt: user.costResetAt ?? null, limitConcurrentSessions: user.limitConcurrentSessions ?? null, dailyResetMode: user.dailyResetMode, dailyResetTime: user.dailyResetTime, @@ -693,6 +697,7 @@ export async function getUsersBatchCore( limitWeeklyUsd: user.limitWeeklyUsd ?? null, limitMonthlyUsd: user.limitMonthlyUsd ?? null, limitTotalUsd: user.limitTotalUsd ?? null, + costResetAt: user.costResetAt ?? null, limitConcurrentSessions: user.limitConcurrentSessions ?? null, dailyResetMode: user.dailyResetMode, dailyResetTime: user.dailyResetTime, @@ -1552,7 +1557,9 @@ export async function getUserLimitUsage(userId: number): Promise< resetTime, resetMode ); - const dailyCost = await sumUserCostInTimeRange(userId, startTime, endTime); + const effectiveStart = + user.costResetAt && user.costResetAt > startTime ? user.costResetAt : startTime; + const dailyCost = await sumUserCostInTimeRange(userId, effectiveStart, endTime); const resetInfo = await getResetInfoWithMode("daily", resetTime, resetMode); const resetAt = resetInfo.resetAt; @@ -1758,14 +1765,18 @@ export async function getUserAllLimitUsage(userId: number): Promise< const rangeWeekly = await getTimeRangeForPeriod("weekly"); const rangeMonthly = await getTimeRangeForPeriod("monthly"); + // Clip time range start by costResetAt (for limits-only reset) + const clipStart = (start: Date): Date => + user.costResetAt && user.costResetAt > start ? user.costResetAt : start; + // 并行查询各时间范围的消费 // Note: sumUserTotalCost uses ALL_TIME_MAX_AGE_DAYS for all-time semantics const [usage5h, usageDaily, usageWeekly, usageMonthly, usageTotal] = await Promise.all([ - sumUserCostInTimeRange(userId, range5h.startTime, range5h.endTime), - sumUserCostInTimeRange(userId, rangeDaily.startTime, rangeDaily.endTime), - sumUserCostInTimeRange(userId, rangeWeekly.startTime, rangeWeekly.endTime), - sumUserCostInTimeRange(userId, rangeMonthly.startTime, rangeMonthly.endTime), - sumUserTotalCost(userId, ALL_TIME_MAX_AGE_DAYS), + sumUserCostInTimeRange(userId, clipStart(range5h.startTime), range5h.endTime), + sumUserCostInTimeRange(userId, clipStart(rangeDaily.startTime), rangeDaily.endTime), + sumUserCostInTimeRange(userId, clipStart(rangeWeekly.startTime), rangeWeekly.endTime), + sumUserCostInTimeRange(userId, clipStart(rangeMonthly.startTime), rangeMonthly.endTime), + sumUserTotalCost(userId, ALL_TIME_MAX_AGE_DAYS, user.costResetAt), ]); return { @@ -1786,6 +1797,130 @@ export async function getUserAllLimitUsage(userId: number): Promise< } } +/** + * Reset user cost limits only (without deleting logs or statistics). + * Sets costResetAt = NOW() so all cost calculations start fresh. + * Logs, statistics, and usage_ledger remain intact. + * + * Admin only. + */ +export async function resetUserLimitsOnly(userId: number): Promise { + try { + const tError = await getTranslations("errors"); + + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { + ok: false, + error: tError("PERMISSION_DENIED"), + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + const user = await findUserById(userId); + if (!user) { + return { ok: false, error: tError("USER_NOT_FOUND"), errorCode: ERROR_CODES.NOT_FOUND }; + } + + // Get user's keys + const keys = await findKeyList(userId); + const keyIds = keys.map((k) => k.id); + const keyHashes = keys.map((k) => k.key); + + // Set costResetAt on user so all cost calculations start fresh + await db.update(usersTable).set({ costResetAt: new Date() }).where(eq(usersTable.id, userId)); + + // Invalidate auth cache so the new costResetAt is picked up immediately + const { invalidateCachedUser } = await import("@/lib/security/api-key-auth-cache"); + await invalidateCachedUser(userId).catch(() => {}); + + // Clear Redis cost cache (but NOT active sessions, NOT DB logs) + const { getRedisClient } = await import("@/lib/redis"); + const { scanPattern } = await import("@/lib/redis/scan-helper"); + const redis = getRedisClient(); + + if (redis && redis.status === "ready") { + try { + const startTime = Date.now(); + + // Scan all cost patterns in parallel + const scanResults = await Promise.all([ + ...keyIds.map((keyId) => + scanPattern(redis, `key:${keyId}:cost_*`).catch((err) => { + logger.warn("Failed to scan key cost pattern", { keyId, error: err }); + return []; + }) + ), + scanPattern(redis, `user:${userId}:cost_*`).catch((err) => { + logger.warn("Failed to scan user cost pattern", { userId, error: err }); + return []; + }), + // Total cost cache keys (with optional resetAt suffix) + scanPattern(redis, `total_cost:user:${userId}`).catch(() => []), + scanPattern(redis, `total_cost:user:${userId}:*`).catch(() => []), + ...keyHashes.map((keyHash) => + scanPattern(redis, `total_cost:key:${keyHash}`).catch(() => []) + ), + ...keyHashes.map((keyHash) => + scanPattern(redis, `total_cost:key:${keyHash}:*`).catch(() => []) + ), + // Lease cache keys (budget slices cached by LeaseService) + ...keyIds.map((keyId) => scanPattern(redis, `lease:key:${keyId}:*`).catch(() => [])), + scanPattern(redis, `lease:user:${userId}:*`).catch(() => []), + ]); + + const allCostKeys = scanResults.flat(); + + if (allCostKeys.length > 0) { + // Batch delete via pipeline + const pipeline = redis.pipeline(); + for (const key of allCostKeys) { + pipeline.del(key); + } + + const results = await pipeline.exec(); + + // Check for errors + const errors = results?.filter(([err]) => err); + if (errors && errors.length > 0) { + logger.warn("Some Redis deletes failed during user limits reset", { + errorCount: errors.length, + userId, + }); + } + } + + const duration = Date.now() - startTime; + logger.info("Reset user limits only - Redis cost cache cleared", { + userId, + keyCount: keyIds.length, + costKeysDeleted: allCostKeys.length, + durationMs: duration, + }); + } catch (error) { + logger.error("Failed to clear Redis cache during user limits reset", { + userId, + error: error instanceof Error ? error.message : String(error), + }); + // Continue execution - costResetAt already set in DB + } + } + + logger.info("Reset user limits only (costResetAt set)", { userId, keyCount: keyIds.length }); + revalidatePath("/dashboard/users"); + + return { ok: true }; + } catch (error) { + logger.error("Failed to reset user limits:", error); + const tError = await getTranslations("errors"); + return { + ok: false, + error: tError("OPERATION_FAILED"), + errorCode: ERROR_CODES.OPERATION_FAILED, + }; + } +} + /** * Reset ALL user statistics (logs + Redis cache + sessions) * This is IRREVERSIBLE - deletes all messageRequest logs for the user @@ -1820,6 +1955,10 @@ export async function resetUserAllStatistics(userId: number): Promise {}); + // 2. Clear Redis cache const { getRedisClient } = await import("@/lib/redis"); const { scanPattern } = await import("@/lib/redis/scan-helper"); diff --git a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx index 5a0fcf9bb..7886e7f6c 100644 --- a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx @@ -1,13 +1,19 @@ "use client"; import { useQueryClient } from "@tanstack/react-query"; -import { Loader2, Trash2, UserCog } from "lucide-react"; +import { Loader2, RotateCcw, Trash2, UserCog } from "lucide-react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useMemo, useState, useTransition } from "react"; import { toast } from "sonner"; import { z } from "zod"; -import { editUser, removeUser, resetUserAllStatistics, toggleUserEnabled } from "@/actions/users"; +import { + editUser, + removeUser, + resetUserAllStatistics, + resetUserLimitsOnly, + toggleUserEnabled, +} from "@/actions/users"; import { AlertDialog, AlertDialogAction, @@ -87,6 +93,8 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr const [isPending, startTransition] = useTransition(); const [isResettingAll, setIsResettingAll] = useState(false); const [resetAllDialogOpen, setResetAllDialogOpen] = useState(false); + const [isResettingLimits, setIsResettingLimits] = useState(false); + const [resetLimitsDialogOpen, setResetLimitsDialogOpen] = useState(false); // Always show providerGroup field in edit mode const userEditTranslations = useUserTranslations({ showProviderGroup: true }); @@ -243,6 +251,25 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr } }; + const handleResetLimitsOnly = async () => { + setIsResettingLimits(true); + try { + const res = await resetUserLimitsOnly(user.id); + if (!res.ok) { + toast.error(res.error || t("editDialog.resetLimits.error")); + return; + } + toast.success(t("editDialog.resetLimits.success")); + setResetLimitsDialogOpen(false); + window.location.reload(); + } catch (error) { + console.error("[EditUserDialog] reset limits only failed", error); + toast.error(t("editDialog.resetLimits.error")); + } finally { + setIsResettingLimits(false); + } + }; + return (
@@ -291,55 +318,119 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr /> {/* Reset Data Section - Admin Only */} -
-
-
-

- {t("editDialog.resetData.title")} -

-

- {t("editDialog.resetData.description")} -

-
+
+

{t("editDialog.resetSection.title")}

- - - - - - - {t("editDialog.resetData.confirmTitle")} - - {t("editDialog.resetData.confirmDescription")} - - - - - {tCommon("cancel")} - - { - e.preventDefault(); - handleResetAllStatistics(); - }} - disabled={isResettingAll} - className={cn(buttonVariants({ variant: "destructive" }))} + {/* Reset Limits Only - Less destructive (amber) */} +
+
+
+

+ {t("editDialog.resetLimits.title")} +

+

+ {t("editDialog.resetLimits.description")} +

+
+ + + + + + + + + {t("editDialog.resetLimits.confirmTitle")} + + + {t("editDialog.resetLimits.confirmDescription")} + + + + + {tCommon("cancel")} + + { + e.preventDefault(); + handleResetLimitsOnly(); + }} + disabled={isResettingLimits} + className="bg-amber-600 text-white hover:bg-amber-700" + > + {isResettingLimits ? ( + <> + + {t("editDialog.resetLimits.loading")} + + ) : ( + t("editDialog.resetLimits.confirm") + )} + + + + +
+
+ + {/* Reset All Statistics - Destructive (red) */} +
+
+
+

+ {t("editDialog.resetData.title")} +

+

+ {t("editDialog.resetData.description")} +

+
+ + + + + + + + {t("editDialog.resetData.confirmTitle")} + + {t("editDialog.resetData.confirmDescription")} + + + + + {tCommon("cancel")} + + { + e.preventDefault(); + handleResetAllStatistics(); + }} + disabled={isResettingAll} + className={cn(buttonVariants({ variant: "destructive" }))} + > + {isResettingAll ? ( + <> + + {t("editDialog.resetData.loading")} + + ) : ( + t("editDialog.resetData.confirm") + )} + + + + +
diff --git a/src/app/[locale]/dashboard/quotas/users/page.tsx b/src/app/[locale]/dashboard/quotas/users/page.tsx index 5a85248b1..80cb6e06d 100644 --- a/src/app/[locale]/dashboard/quotas/users/page.tsx +++ b/src/app/[locale]/dashboard/quotas/users/page.tsx @@ -21,11 +21,31 @@ async function getUsersWithQuotas(): Promise { const allUserIds = users.map((u) => u.id); const allKeyIds = users.flatMap((u) => u.keys.map((k) => k.id)); + // Build resetAt maps for users with cost reset timestamps + const userResetAtMap = new Map(); + const keyResetAtMap = new Map(); + for (const u of users) { + if (u.costResetAt) { + userResetAtMap.set(u.id, u.costResetAt); + for (const k of u.keys) { + keyResetAtMap.set(k.id, u.costResetAt); + } + } + } + // 3 queries total instead of N+M individual SUM queries const [quotaResults, userCostMap, keyCostMap] = await Promise.all([ Promise.all(users.map((u) => getUserLimitUsage(u.id))), - sumUserTotalCostBatch(allUserIds), - sumKeyTotalCostBatchByIds(allKeyIds), + sumUserTotalCostBatch( + allUserIds, + undefined, + userResetAtMap.size > 0 ? userResetAtMap : undefined + ), + sumKeyTotalCostBatchByIds( + allKeyIds, + undefined, + keyResetAtMap.size > 0 ? keyResetAtMap : undefined + ), ]); return users.map((user, idx) => { diff --git a/src/app/v1/_lib/proxy/rate-limit-guard.ts b/src/app/v1/_lib/proxy/rate-limit-guard.ts index 36f54f06a..92c333723 100644 --- a/src/app/v1/_lib/proxy/rate-limit-guard.ts +++ b/src/app/v1/_lib/proxy/rate-limit-guard.ts @@ -64,7 +64,7 @@ export class ProxyRateLimitGuard { key.id, "key", key.limitTotalUsd ?? null, - { keyHash: key.key } + { keyHash: key.key, resetAt: user.costResetAt } ); if (!keyTotalCheck.allowed) { @@ -94,7 +94,8 @@ export class ProxyRateLimitGuard { const userTotalCheck = await RateLimitService.checkTotalCostLimit( user.id, "user", - user.limitTotalUsd ?? null + user.limitTotalUsd ?? null, + { resetAt: user.costResetAt } ); if (!userTotalCheck.allowed) { @@ -229,6 +230,7 @@ export class ProxyRateLimitGuard { limit_daily_usd: null, // 仅检查 5h limit_weekly_usd: null, limit_monthly_usd: null, + cost_reset_at: user.costResetAt ?? null, }); if (!key5hCheck.allowed) { @@ -265,6 +267,7 @@ export class ProxyRateLimitGuard { limit_daily_usd: null, limit_weekly_usd: null, limit_monthly_usd: null, + cost_reset_at: user.costResetAt ?? null, }); if (!user5hCheck.allowed) { @@ -303,6 +306,7 @@ export class ProxyRateLimitGuard { daily_reset_time: key.dailyResetTime, limit_weekly_usd: null, limit_monthly_usd: null, + cost_reset_at: user.costResetAt ?? null, }); if (!keyDailyCheck.allowed) { @@ -376,6 +380,7 @@ export class ProxyRateLimitGuard { daily_reset_mode: user.dailyResetMode, limit_weekly_usd: null, limit_monthly_usd: null, + cost_reset_at: user.costResetAt ?? null, }); if (!userDailyCheck.allowed) { @@ -450,6 +455,7 @@ export class ProxyRateLimitGuard { limit_daily_usd: null, limit_weekly_usd: key.limitWeeklyUsd, limit_monthly_usd: null, + cost_reset_at: user.costResetAt ?? null, }); if (!keyWeeklyCheck.allowed) { @@ -484,6 +490,7 @@ export class ProxyRateLimitGuard { limit_daily_usd: null, limit_weekly_usd: user.limitWeeklyUsd ?? null, limit_monthly_usd: null, + cost_reset_at: user.costResetAt ?? null, }); if (!userWeeklyCheck.allowed) { @@ -520,6 +527,7 @@ export class ProxyRateLimitGuard { limit_daily_usd: null, limit_weekly_usd: null, limit_monthly_usd: key.limitMonthlyUsd, + cost_reset_at: user.costResetAt ?? null, }); if (!keyMonthlyCheck.allowed) { @@ -556,6 +564,7 @@ export class ProxyRateLimitGuard { limit_daily_usd: null, limit_weekly_usd: null, limit_monthly_usd: user.limitMonthlyUsd ?? null, + cost_reset_at: user.costResetAt ?? null, }); if (!userMonthlyCheck.allowed) { diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index 60a1f8e17..790e9040f 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -51,6 +51,7 @@ export const users = pgTable('users', { limitWeeklyUsd: numeric('limit_weekly_usd', { precision: 10, scale: 2 }), limitMonthlyUsd: numeric('limit_monthly_usd', { precision: 10, scale: 2 }), limitTotalUsd: numeric('limit_total_usd', { precision: 10, scale: 2 }), + costResetAt: timestamp('cost_reset_at', { withTimezone: true }), limitConcurrentSessions: integer('limit_concurrent_sessions'), // Daily quota reset mode (fixed: reset at specific time, rolling: 24h window) diff --git a/src/lib/rate-limit/lease-service.ts b/src/lib/rate-limit/lease-service.ts index 6ee2310c9..9c5d1c4c7 100644 --- a/src/lib/rate-limit/lease-service.ts +++ b/src/lib/rate-limit/lease-service.ts @@ -43,6 +43,7 @@ export interface GetCostLeaseParams { limitAmount: number; resetTime?: string; resetMode?: DailyResetMode; + costResetAt?: Date | null; } /** @@ -165,11 +166,15 @@ export class LeaseService { // Calculate time range for DB query const { startTime, endTime } = await getLeaseTimeRange(window, resetTime, resetMode); + // Clip startTime forward if costResetAt is more recent (limits-only reset) + const effectiveStartTime = + params.costResetAt && params.costResetAt > startTime ? params.costResetAt : startTime; + // Query DB for current usage const currentUsage = await LeaseService.queryDbUsage( entityType, entityId, - startTime, + effectiveStartTime, endTime ); diff --git a/src/lib/rate-limit/service.ts b/src/lib/rate-limit/service.ts index 98b597788..6971be396 100644 --- a/src/lib/rate-limit/service.ts +++ b/src/lib/rate-limit/service.ts @@ -165,6 +165,7 @@ export class RateLimitService { daily_reset_mode?: DailyResetMode; limit_weekly_usd: number | null; limit_monthly_usd: number | null; + cost_reset_at?: Date | null; } ): Promise<{ allowed: boolean; reason?: string }> { const normalizedDailyReset = normalizeResetTime(limits.daily_reset_time); @@ -214,7 +215,12 @@ export class RateLimitService { logger.info( `[RateLimit] Cache miss for ${type}:${id}:cost_5h, querying database` ); - return await RateLimitService.checkCostLimitsFromDatabase(id, type, costLimits); + return await RateLimitService.checkCostLimitsFromDatabase( + id, + type, + costLimits, + limits.cost_reset_at + ); } } } catch (error) { @@ -222,7 +228,12 @@ export class RateLimitService { "[RateLimit] 5h rolling window query failed, fallback to database:", error ); - return await RateLimitService.checkCostLimitsFromDatabase(id, type, costLimits); + return await RateLimitService.checkCostLimitsFromDatabase( + id, + type, + costLimits, + limits.cost_reset_at + ); } } else if (limit.period === "daily" && limit.resetMode === "rolling") { // daily 滚动窗口:使用 ZSET + Lua 脚本 @@ -246,7 +257,12 @@ export class RateLimitService { logger.info( `[RateLimit] Cache miss for ${type}:${id}:cost_daily_rolling, querying database` ); - return await RateLimitService.checkCostLimitsFromDatabase(id, type, costLimits); + return await RateLimitService.checkCostLimitsFromDatabase( + id, + type, + costLimits, + limits.cost_reset_at + ); } } } catch (error) { @@ -254,7 +270,12 @@ export class RateLimitService { "[RateLimit] Daily rolling window query failed, fallback to database:", error ); - return await RateLimitService.checkCostLimitsFromDatabase(id, type, costLimits); + return await RateLimitService.checkCostLimitsFromDatabase( + id, + type, + costLimits, + limits.cost_reset_at + ); } } else { // daily fixed/周/月使用普通 GET @@ -267,7 +288,12 @@ export class RateLimitService { logger.info( `[RateLimit] Cache miss for ${type}:${id}:cost_${periodKey}, querying database` ); - return await RateLimitService.checkCostLimitsFromDatabase(id, type, costLimits); + return await RateLimitService.checkCostLimitsFromDatabase( + id, + type, + costLimits, + limits.cost_reset_at + ); } current = parseFloat((value as string) || "0"); @@ -287,10 +313,20 @@ export class RateLimitService { // Slow Path: Redis 不可用,降级到数据库 logger.warn(`[RateLimit] Redis unavailable, checking ${type} cost limits from database`); - return await RateLimitService.checkCostLimitsFromDatabase(id, type, costLimits); + return await RateLimitService.checkCostLimitsFromDatabase( + id, + type, + costLimits, + limits.cost_reset_at + ); } catch (error) { logger.error("[RateLimit] Check failed, fallback to database:", error); - return await RateLimitService.checkCostLimitsFromDatabase(id, type, costLimits); + return await RateLimitService.checkCostLimitsFromDatabase( + id, + type, + costLimits, + limits.cost_reset_at + ); } } @@ -311,17 +347,18 @@ export class RateLimitService { try { let current = 0; const cacheKey = (() => { + const resetAtSuffix = + options?.resetAt instanceof Date && !Number.isNaN(options.resetAt.getTime()) + ? `:${options.resetAt.getTime()}` + : ""; if (entityType === "key") { - return `total_cost:key:${options?.keyHash}`; + return `total_cost:key:${options?.keyHash}${resetAtSuffix}`; } if (entityType === "user") { - return `total_cost:user:${entityId}`; + return `total_cost:user:${entityId}${resetAtSuffix}`; } - const resetAtMs = - options?.resetAt instanceof Date && !Number.isNaN(options.resetAt.getTime()) - ? options.resetAt.getTime() - : "none"; - return `total_cost:provider:${entityId}:${resetAtMs}`; + const resetAtMs = resetAtSuffix || ":none"; + return `total_cost:provider:${entityId}${resetAtMs}`; })(); const cacheTtl = 300; // 5 minutes @@ -339,9 +376,9 @@ export class RateLimitService { logger.warn("[RateLimit] Missing key hash for total cost check, skip enforcement"); return { allowed: true }; } - current = await sumKeyTotalCost(options.keyHash); + current = await sumKeyTotalCost(options.keyHash, 365, options?.resetAt); } else if (entityType === "user") { - current = await sumUserTotalCost(entityId); + current = await sumUserTotalCost(entityId, 365, options?.resetAt); } else { current = await sumProviderTotalCost(entityId, options?.resetAt ?? null); } @@ -357,9 +394,9 @@ export class RateLimitService { if (!options?.keyHash) { return { allowed: true }; } - current = await sumKeyTotalCost(options.keyHash); + current = await sumKeyTotalCost(options.keyHash, 365, options?.resetAt); } else if (entityType === "user") { - current = await sumUserTotalCost(entityId); + current = await sumUserTotalCost(entityId, 365, options?.resetAt); } else { current = await sumProviderTotalCost(entityId, options?.resetAt ?? null); } @@ -371,9 +408,9 @@ export class RateLimitService { logger.warn("[RateLimit] Missing key hash for total cost check, skip enforcement"); return { allowed: true }; } - current = await sumKeyTotalCost(options.keyHash); + current = await sumKeyTotalCost(options.keyHash, 365, options?.resetAt); } else if (entityType === "user") { - current = await sumUserTotalCost(entityId); + current = await sumUserTotalCost(entityId, 365, options?.resetAt); } else { current = await sumProviderTotalCost(entityId, options?.resetAt ?? null); } @@ -401,7 +438,8 @@ export class RateLimitService { private static async checkCostLimitsFromDatabase( id: number, type: "key" | "provider" | "user", - costLimits: CostLimit[] + costLimits: CostLimit[], + costResetAt?: Date | null ): Promise<{ allowed: boolean; reason?: string }> { const { findKeyCostEntriesInTimeRange, @@ -422,6 +460,9 @@ export class RateLimitService { limit.resetMode ); + // Clip startTime forward if costResetAt is more recent + const effectiveStartTime = costResetAt && costResetAt > startTime ? costResetAt : startTime; + // 查询数据库 let current = 0; let costEntries: Array<{ @@ -436,13 +477,13 @@ export class RateLimitService { if (isRollingWindow) { switch (type) { case "key": - costEntries = await findKeyCostEntriesInTimeRange(id, startTime, endTime); + costEntries = await findKeyCostEntriesInTimeRange(id, effectiveStartTime, endTime); break; case "provider": - costEntries = await findProviderCostEntriesInTimeRange(id, startTime, endTime); + costEntries = await findProviderCostEntriesInTimeRange(id, effectiveStartTime, endTime); break; case "user": - costEntries = await findUserCostEntriesInTimeRange(id, startTime, endTime); + costEntries = await findUserCostEntriesInTimeRange(id, effectiveStartTime, endTime); break; default: costEntries = []; @@ -452,13 +493,13 @@ export class RateLimitService { } else { switch (type) { case "key": - current = await sumKeyCostInTimeRange(id, startTime, endTime); + current = await sumKeyCostInTimeRange(id, effectiveStartTime, endTime); break; case "provider": - current = await sumProviderCostInTimeRange(id, startTime, endTime); + current = await sumProviderCostInTimeRange(id, effectiveStartTime, endTime); break; case "user": - current = await sumUserCostInTimeRange(id, startTime, endTime); + current = await sumUserCostInTimeRange(id, effectiveStartTime, endTime); break; default: current = 0; @@ -1424,6 +1465,7 @@ export class RateLimitService { daily_reset_mode?: DailyResetMode; limit_weekly_usd: number | null; limit_monthly_usd: number | null; + cost_reset_at?: Date | null; } ): Promise<{ allowed: boolean; reason?: string; failOpen?: boolean }> { const normalizedDailyReset = normalizeResetTime(limits.daily_reset_time); @@ -1479,6 +1521,7 @@ export class RateLimitService { limitAmount: check.limit, resetTime: check.resetTime, resetMode: check.resetMode, + costResetAt: limits.cost_reset_at, }); // Fail-open if lease retrieval failed diff --git a/src/lib/security/api-key-auth-cache.ts b/src/lib/security/api-key-auth-cache.ts index 66fcc7027..73822cd2d 100644 --- a/src/lib/security/api-key-auth-cache.ts +++ b/src/lib/security/api-key-auth-cache.ts @@ -169,6 +169,7 @@ function hydrateUserFromCache(payload: CachedUserPayloadV1): User | null { const expiresAt = parseOptionalDate(user.expiresAt); const deletedAt = parseOptionalDate(user.deletedAt); + const costResetAt = parseOptionalDate(user.costResetAt); if (user.expiresAt != null && !expiresAt) return null; if (user.deletedAt != null && !deletedAt) return null; @@ -178,6 +179,7 @@ function hydrateUserFromCache(payload: CachedUserPayloadV1): User | null { updatedAt, expiresAt: expiresAt === undefined ? undefined : expiresAt, deletedAt: deletedAt === undefined ? undefined : deletedAt, + costResetAt: costResetAt === undefined ? undefined : costResetAt, } as User; } diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts index 562ee92d2..dfda5a78d 100644 --- a/src/repository/_shared/transformers.ts +++ b/src/repository/_shared/transformers.ts @@ -47,6 +47,7 @@ export function toUser(dbUser: any): User { dailyResetTime: dbUser?.dailyResetTime ?? "00:00", isEnabled: dbUser?.isEnabled ?? true, expiresAt: dbUser?.expiresAt ? new Date(dbUser.expiresAt) : null, + costResetAt: dbUser?.costResetAt ? new Date(dbUser.costResetAt) : null, allowedClients: dbUser?.allowedClients ?? [], blockedClients: dbUser?.blockedClients ?? [], allowedModels: dbUser?.allowedModels ?? [], diff --git a/src/repository/key.ts b/src/repository/key.ts index 15f03abc7..62b0383e2 100644 --- a/src/repository/key.ts +++ b/src/repository/key.ts @@ -546,6 +546,7 @@ export async function validateApiKeyAndGetUser( limitWeeklyUsd: users.limitWeeklyUsd, limitMonthlyUsd: users.limitMonthlyUsd, limitTotalUsd: users.limitTotalUsd, + costResetAt: users.costResetAt, limitConcurrentSessions: users.limitConcurrentSessions, dailyResetMode: users.dailyResetMode, dailyResetTime: users.dailyResetTime, @@ -609,6 +610,7 @@ export async function validateApiKeyAndGetUser( userLimitWeeklyUsd: users.limitWeeklyUsd, userLimitMonthlyUsd: users.limitMonthlyUsd, userLimitTotalUsd: users.limitTotalUsd, + userCostResetAt: users.costResetAt, userLimitConcurrentSessions: users.limitConcurrentSessions, userDailyResetMode: users.dailyResetMode, userDailyResetTime: users.dailyResetTime, @@ -650,6 +652,7 @@ export async function validateApiKeyAndGetUser( limitWeeklyUsd: row.userLimitWeeklyUsd, limitMonthlyUsd: row.userLimitMonthlyUsd, limitTotalUsd: row.userLimitTotalUsd, + costResetAt: row.userCostResetAt, limitConcurrentSessions: row.userLimitConcurrentSessions, dailyResetMode: row.userDailyResetMode, dailyResetTime: row.userDailyResetTime, diff --git a/src/repository/statistics.ts b/src/repository/statistics.ts index 7df64e5d0..1298bef08 100644 --- a/src/repository/statistics.ts +++ b/src/repository/statistics.ts @@ -463,11 +463,18 @@ export async function sumUserCostToday(userId: number): Promise { * @param keyHash - API Key hash * @param maxAgeDays - Max query days (default 365). Use Infinity for all-time. */ -export async function sumKeyTotalCost(keyHash: string, maxAgeDays: number = 365): Promise { +export async function sumKeyTotalCost( + keyHash: string, + maxAgeDays: number = 365, + resetAt?: Date | null +): Promise { const conditions = [eq(usageLedger.key, keyHash), LEDGER_BILLING_CONDITION]; - // Finite positive maxAgeDays adds a date filter; Infinity/0/negative means all-time - if (Number.isFinite(maxAgeDays) && maxAgeDays > 0) { + // resetAt takes priority: only count costs after the reset timestamp + if (resetAt instanceof Date && !Number.isNaN(resetAt.getTime())) { + conditions.push(gte(usageLedger.createdAt, resetAt)); + } else if (Number.isFinite(maxAgeDays) && maxAgeDays > 0) { + // Finite positive maxAgeDays adds a date filter; Infinity/0/negative means all-time const cutoffDate = new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000); conditions.push(gte(usageLedger.createdAt, cutoffDate)); } @@ -485,11 +492,18 @@ export async function sumKeyTotalCost(keyHash: string, maxAgeDays: number = 365) * @param userId - User ID * @param maxAgeDays - Max query days (default 365). Use Infinity for all-time. */ -export async function sumUserTotalCost(userId: number, maxAgeDays: number = 365): Promise { +export async function sumUserTotalCost( + userId: number, + maxAgeDays: number = 365, + resetAt?: Date | null +): Promise { const conditions = [eq(usageLedger.userId, userId), LEDGER_BILLING_CONDITION]; - // Finite positive maxAgeDays adds a date filter; Infinity/0/negative means all-time - if (Number.isFinite(maxAgeDays) && maxAgeDays > 0) { + // resetAt takes priority: only count costs after the reset timestamp + if (resetAt instanceof Date && !Number.isNaN(resetAt.getTime())) { + conditions.push(gte(usageLedger.createdAt, resetAt)); + } else if (Number.isFinite(maxAgeDays) && maxAgeDays > 0) { + // Finite positive maxAgeDays adds a date filter; Infinity/0/negative means all-time const cutoffDate = new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000); conditions.push(gte(usageLedger.createdAt, cutoffDate)); } @@ -510,28 +524,55 @@ export async function sumUserTotalCost(userId: number, maxAgeDays: number = 365) */ export async function sumUserTotalCostBatch( userIds: number[], - maxAgeDays: number = 365 + maxAgeDays: number = 365, + resetAtMap?: Map ): Promise> { const result = new Map(); if (userIds.length === 0) return result; + for (const id of userIds) result.set(id, 0); - const conditions: SQL[] = [inArray(usageLedger.userId, userIds), LEDGER_BILLING_CONDITION]; - if (Number.isFinite(maxAgeDays) && maxAgeDays > 0) { - const cutoffDate = new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000); - conditions.push(gte(usageLedger.createdAt, cutoffDate)); + // Split users: those with costResetAt need individual queries + const resetUserIds: number[] = []; + const batchUserIds: number[] = []; + for (const id of userIds) { + if (resetAtMap?.has(id)) { + resetUserIds.push(id); + } else { + batchUserIds.push(id); + } } - const rows = await db - .select({ - userId: usageLedger.userId, - total: sql`COALESCE(SUM(${usageLedger.costUsd}), 0)`, - }) - .from(usageLedger) - .where(and(...conditions)) - .groupBy(usageLedger.userId); + // Individual queries for users with costResetAt + if (resetUserIds.length > 0) { + const resetResults = await Promise.all( + resetUserIds.map(async (id) => ({ + id, + total: await sumUserTotalCost(id, maxAgeDays, resetAtMap!.get(id)), + })) + ); + for (const { id, total } of resetResults) result.set(id, total); + } + + // Batch query for users without costResetAt + if (batchUserIds.length > 0) { + const conditions: SQL[] = [inArray(usageLedger.userId, batchUserIds), LEDGER_BILLING_CONDITION]; + if (Number.isFinite(maxAgeDays) && maxAgeDays > 0) { + const cutoffDate = new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000); + conditions.push(gte(usageLedger.createdAt, cutoffDate)); + } + + const rows = await db + .select({ + userId: usageLedger.userId, + total: sql`COALESCE(SUM(${usageLedger.costUsd}), 0)`, + }) + .from(usageLedger) + .where(and(...conditions)) + .groupBy(usageLedger.userId); + + for (const row of rows) result.set(row.userId, Number(row.total || 0)); + } - for (const id of userIds) result.set(id, 0); - for (const row of rows) result.set(row.userId, Number(row.total || 0)); return result; } @@ -545,7 +586,8 @@ export async function sumUserTotalCostBatch( */ export async function sumKeyTotalCostBatchByIds( keyIds: number[], - maxAgeDays: number = 365 + maxAgeDays: number = 365, + resetAtMap?: Map ): Promise> { const result = new Map(); if (keyIds.length === 0) return result; @@ -558,29 +600,59 @@ export async function sumKeyTotalCostBatchByIds( .where(inArray(keys.id, keyIds)); const keyStringToId = new Map(keyMappings.map((k) => [k.key, k.id])); + const idToKeyString = new Map(keyMappings.map((k) => [k.id, k.key])); const keyStrings = keyMappings.map((k) => k.key); if (keyStrings.length === 0) return result; - // Step 2: Aggregate on usage_ledger directly (hits idx_usage_ledger_key_cost) - const conditions: SQL[] = [inArray(usageLedger.key, keyStrings), LEDGER_BILLING_CONDITION]; - if (Number.isFinite(maxAgeDays) && maxAgeDays > 0) { - const cutoffDate = new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000); - conditions.push(gte(usageLedger.createdAt, cutoffDate)); + // Split keys: those with costResetAt need individual queries + const resetKeyIds: number[] = []; + const batchKeyStrings: string[] = []; + for (const mapping of keyMappings) { + if (resetAtMap?.has(mapping.id)) { + resetKeyIds.push(mapping.id); + } else { + batchKeyStrings.push(mapping.key); + } } - const rows = await db - .select({ - key: usageLedger.key, - total: sql`COALESCE(SUM(${usageLedger.costUsd}), 0)`, - }) - .from(usageLedger) - .where(and(...conditions)) - .groupBy(usageLedger.key); + // Individual queries for keys with costResetAt + if (resetKeyIds.length > 0) { + const resetResults = await Promise.all( + resetKeyIds.map(async (id) => { + const keyString = idToKeyString.get(id); + if (!keyString) return { id, total: 0 }; + return { + id, + total: await sumKeyTotalCost(keyString, maxAgeDays, resetAtMap!.get(id)), + }; + }) + ); + for (const { id, total } of resetResults) result.set(id, total); + } - for (const row of rows) { - const keyId = keyStringToId.get(row.key); - if (keyId !== undefined) result.set(keyId, Number(row.total || 0)); + // Step 2: Batch aggregate for keys without costResetAt + if (batchKeyStrings.length > 0) { + const conditions: SQL[] = [inArray(usageLedger.key, batchKeyStrings), LEDGER_BILLING_CONDITION]; + if (Number.isFinite(maxAgeDays) && maxAgeDays > 0) { + const cutoffDate = new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000); + conditions.push(gte(usageLedger.createdAt, cutoffDate)); + } + + const rows = await db + .select({ + key: usageLedger.key, + total: sql`COALESCE(SUM(${usageLedger.costUsd}), 0)`, + }) + .from(usageLedger) + .where(and(...conditions)) + .groupBy(usageLedger.key); + + for (const row of rows) { + const keyId = keyStringToId.get(row.key); + if (keyId !== undefined) result.set(keyId, Number(row.total || 0)); + } } + return result; } @@ -692,12 +764,20 @@ interface QuotaCostSummary { export async function sumUserQuotaCosts( userId: number, ranges: QuotaCostRanges, - maxAgeDays: number = 365 + maxAgeDays: number = 365, + resetAt?: Date | null ): Promise { - const cutoffDate = + const maxAgeCutoff = Number.isFinite(maxAgeDays) && maxAgeDays > 0 ? new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000) : null; + // Use the more recent of maxAgeCutoff and resetAt + const cutoffDate = + resetAt instanceof Date && !Number.isNaN(resetAt.getTime()) + ? maxAgeCutoff && maxAgeCutoff > resetAt + ? maxAgeCutoff + : resetAt + : maxAgeCutoff; const scanStart = cutoffDate ? new Date( @@ -757,17 +837,25 @@ export async function sumUserQuotaCosts( export async function sumKeyQuotaCostsById( keyId: number, ranges: QuotaCostRanges, - maxAgeDays: number = 365 + maxAgeDays: number = 365, + resetAt?: Date | null ): Promise { const keyString = await getKeyStringByIdCached(keyId); if (!keyString) { return { cost5h: 0, costDaily: 0, costWeekly: 0, costMonthly: 0, costTotal: 0 }; } - const cutoffDate = + const maxAgeCutoff = Number.isFinite(maxAgeDays) && maxAgeDays > 0 ? new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000) : null; + // Use the more recent of maxAgeCutoff and resetAt + const cutoffDate = + resetAt instanceof Date && !Number.isNaN(resetAt.getTime()) + ? maxAgeCutoff && maxAgeCutoff > resetAt + ? maxAgeCutoff + : resetAt + : maxAgeCutoff; const scanStart = cutoffDate ? new Date( diff --git a/src/repository/user.ts b/src/repository/user.ts index d031e6d48..5290b7b90 100644 --- a/src/repository/user.ts +++ b/src/repository/user.ts @@ -79,6 +79,7 @@ export async function createUser(userData: CreateUserData): Promise { limitWeeklyUsd: users.limitWeeklyUsd, limitMonthlyUsd: users.limitMonthlyUsd, limitTotalUsd: users.limitTotalUsd, + costResetAt: users.costResetAt, limitConcurrentSessions: users.limitConcurrentSessions, dailyResetMode: users.dailyResetMode, dailyResetTime: users.dailyResetTime, @@ -112,6 +113,7 @@ export async function findUserList(limit: number = 50, offset: number = 0): Prom limitWeeklyUsd: users.limitWeeklyUsd, limitMonthlyUsd: users.limitMonthlyUsd, limitTotalUsd: users.limitTotalUsd, + costResetAt: users.costResetAt, limitConcurrentSessions: users.limitConcurrentSessions, dailyResetMode: users.dailyResetMode, dailyResetTime: users.dailyResetTime, @@ -358,6 +360,7 @@ export async function findUserListBatch( limitWeeklyUsd: users.limitWeeklyUsd, limitMonthlyUsd: users.limitMonthlyUsd, limitTotalUsd: users.limitTotalUsd, + costResetAt: users.costResetAt, limitConcurrentSessions: users.limitConcurrentSessions, dailyResetMode: users.dailyResetMode, dailyResetTime: users.dailyResetTime, @@ -416,6 +419,7 @@ export async function findUserById(id: number): Promise { limitWeeklyUsd: users.limitWeeklyUsd, limitMonthlyUsd: users.limitMonthlyUsd, limitTotalUsd: users.limitTotalUsd, + costResetAt: users.costResetAt, limitConcurrentSessions: users.limitConcurrentSessions, dailyResetMode: users.dailyResetMode, dailyResetTime: users.dailyResetTime, @@ -511,6 +515,7 @@ export async function updateUser(id: number, userData: UpdateUserData): Promise< limitWeeklyUsd: users.limitWeeklyUsd, limitMonthlyUsd: users.limitMonthlyUsd, limitTotalUsd: users.limitTotalUsd, + costResetAt: users.costResetAt, limitConcurrentSessions: users.limitConcurrentSessions, dailyResetMode: users.dailyResetMode, dailyResetTime: users.dailyResetTime, diff --git a/src/types/user.ts b/src/types/user.ts index 7a1307c76..fd47995af 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -18,6 +18,7 @@ export interface User { limitWeeklyUsd?: number; // 周消费上限(美元) limitMonthlyUsd?: number; // 月消费上限(美元) limitTotalUsd?: number | null; // 总消费上限(美元) + costResetAt?: Date | null; // Cost reset timestamp for limits-only reset limitConcurrentSessions?: number; // 并发 Session 上限 // Daily quota reset mode dailyResetMode: "fixed" | "rolling"; // 每日限额重置模式 @@ -150,6 +151,7 @@ export interface UserDisplay { limitWeeklyUsd?: number | null; limitMonthlyUsd?: number | null; limitTotalUsd?: number | null; + costResetAt?: Date | null; // Cost reset timestamp for limits-only reset limitConcurrentSessions?: number | null; // Daily quota reset mode dailyResetMode?: "fixed" | "rolling"; diff --git a/tests/unit/actions/users-reset-limits-only.test.ts b/tests/unit/actions/users-reset-limits-only.test.ts new file mode 100644 index 000000000..e1b5c7aa9 --- /dev/null +++ b/tests/unit/actions/users-reset-limits-only.test.ts @@ -0,0 +1,251 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { ERROR_CODES } from "@/lib/utils/error-messages"; + +// Mock getSession +const getSessionMock = vi.fn(); +vi.mock("@/lib/auth", () => ({ + getSession: getSessionMock, +})); + +// Mock next-intl +const getTranslationsMock = vi.fn(async () => (key: string) => key); +vi.mock("next-intl/server", () => ({ + getTranslations: getTranslationsMock, + getLocale: vi.fn(async () => "en"), +})); + +// Mock next/cache +const revalidatePathMock = vi.fn(); +vi.mock("next/cache", () => ({ + revalidatePath: revalidatePathMock, +})); + +// Mock repository/user +const findUserByIdMock = vi.fn(); +vi.mock("@/repository/user", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + findUserById: findUserByIdMock, + }; +}); + +// Mock repository/key +const findKeyListMock = vi.fn(); +vi.mock("@/repository/key", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + findKeyList: findKeyListMock, + }; +}); + +// Mock drizzle db - need update().set().where() chain +const dbUpdateWhereMock = vi.fn(); +const dbUpdateSetMock = vi.fn(() => ({ where: dbUpdateWhereMock })); +const dbUpdateMock = vi.fn(() => ({ set: dbUpdateSetMock })); +const dbDeleteWhereMock = vi.fn(); +const dbDeleteMock = vi.fn(() => ({ where: dbDeleteWhereMock })); +vi.mock("@/drizzle/db", () => ({ + db: { + update: dbUpdateMock, + delete: dbDeleteMock, + }, +})); + +// Mock logger +const loggerMock = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +}; +vi.mock("@/lib/logger", () => ({ + logger: loggerMock, +})); + +// Mock Redis +const redisPipelineMock = { + del: vi.fn().mockReturnThis(), + exec: vi.fn(), +}; +const redisMock = { + status: "ready", + pipeline: vi.fn(() => redisPipelineMock), +}; +const getRedisClientMock = vi.fn(() => redisMock); +vi.mock("@/lib/redis", () => ({ + getRedisClient: getRedisClientMock, +})); + +// Mock scanPattern +const scanPatternMock = vi.fn(); +vi.mock("@/lib/redis/scan-helper", () => ({ + scanPattern: scanPatternMock, +})); + +describe("resetUserLimitsOnly", () => { + beforeEach(() => { + vi.clearAllMocks(); + redisMock.status = "ready"; + redisPipelineMock.exec.mockResolvedValue([]); + dbUpdateWhereMock.mockResolvedValue(undefined); + }); + + test("should return PERMISSION_DENIED for non-admin user", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "user" } }); + + const { resetUserLimitsOnly } = await import("@/actions/users"); + const result = await resetUserLimitsOnly(123); + + expect(result.ok).toBe(false); + expect(result.errorCode).toBe(ERROR_CODES.PERMISSION_DENIED); + expect(findUserByIdMock).not.toHaveBeenCalled(); + }); + + test("should return PERMISSION_DENIED when no session", async () => { + getSessionMock.mockResolvedValue(null); + + const { resetUserLimitsOnly } = await import("@/actions/users"); + const result = await resetUserLimitsOnly(123); + + expect(result.ok).toBe(false); + expect(result.errorCode).toBe(ERROR_CODES.PERMISSION_DENIED); + }); + + test("should return NOT_FOUND for non-existent user", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findUserByIdMock.mockResolvedValue(null); + + const { resetUserLimitsOnly } = await import("@/actions/users"); + const result = await resetUserLimitsOnly(999); + + expect(result.ok).toBe(false); + expect(result.errorCode).toBe(ERROR_CODES.NOT_FOUND); + expect(dbUpdateMock).not.toHaveBeenCalled(); + }); + + test("should set costResetAt and clear Redis cost cache", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" }); + findKeyListMock.mockResolvedValue([ + { id: 1, key: "sk-hash-1" }, + { id: 2, key: "sk-hash-2" }, + ]); + scanPatternMock.mockResolvedValue(["key:1:cost_daily", "user:123:cost_weekly"]); + redisPipelineMock.exec.mockResolvedValue([]); + + const { resetUserLimitsOnly } = await import("@/actions/users"); + const result = await resetUserLimitsOnly(123); + + expect(result.ok).toBe(true); + // costResetAt set via db.update + expect(dbUpdateMock).toHaveBeenCalled(); + expect(dbUpdateSetMock).toHaveBeenCalledWith( + expect.objectContaining({ costResetAt: expect.any(Date) }) + ); + expect(dbUpdateWhereMock).toHaveBeenCalled(); + // Redis cost keys scanned and deleted + expect(scanPatternMock).toHaveBeenCalled(); + expect(redisMock.pipeline).toHaveBeenCalled(); + expect(redisPipelineMock.del).toHaveBeenCalled(); + expect(redisPipelineMock.exec).toHaveBeenCalled(); + // Revalidate path + expect(revalidatePathMock).toHaveBeenCalledWith("/dashboard/users"); + // No DB deletes (messageRequest/usageLedger must NOT be deleted) + expect(dbDeleteMock).not.toHaveBeenCalled(); + }); + + test("should NOT delete messageRequest or usageLedger rows", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" }); + findKeyListMock.mockResolvedValue([{ id: 1, key: "sk-hash-1" }]); + scanPatternMock.mockResolvedValue([]); + + const { resetUserLimitsOnly } = await import("@/actions/users"); + await resetUserLimitsOnly(123); + + // Core assertion: db.delete must never be called + expect(dbDeleteMock).not.toHaveBeenCalled(); + expect(dbDeleteWhereMock).not.toHaveBeenCalled(); + }); + + test("should succeed when Redis is not ready", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" }); + findKeyListMock.mockResolvedValue([{ id: 1, key: "sk-hash-1" }]); + redisMock.status = "connecting"; + + const { resetUserLimitsOnly } = await import("@/actions/users"); + const result = await resetUserLimitsOnly(123); + + expect(result.ok).toBe(true); + // costResetAt still set in DB + expect(dbUpdateMock).toHaveBeenCalled(); + // Redis pipeline NOT called + expect(redisMock.pipeline).not.toHaveBeenCalled(); + }); + + test("should succeed with warning when Redis has partial failures", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" }); + findKeyListMock.mockResolvedValue([{ id: 1, key: "sk-hash-1" }]); + scanPatternMock.mockResolvedValue(["key:1:cost_daily"]); + redisPipelineMock.exec.mockResolvedValue([ + [null, 1], + [new Error("Connection reset"), null], + ]); + + const { resetUserLimitsOnly } = await import("@/actions/users"); + const result = await resetUserLimitsOnly(123); + + expect(result.ok).toBe(true); + expect(loggerMock.warn).toHaveBeenCalledWith( + "Some Redis deletes failed during user limits reset", + expect.objectContaining({ errorCount: 1, userId: 123 }) + ); + }); + + test("should succeed when pipeline.exec throws", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" }); + findKeyListMock.mockResolvedValue([{ id: 1, key: "sk-hash-1" }]); + scanPatternMock.mockResolvedValue(["key:1:cost_daily"]); + redisPipelineMock.exec.mockRejectedValue(new Error("Pipeline failed")); + + const { resetUserLimitsOnly } = await import("@/actions/users"); + const result = await resetUserLimitsOnly(123); + + expect(result.ok).toBe(true); + expect(loggerMock.error).toHaveBeenCalledWith( + "Failed to clear Redis cache during user limits reset", + expect.objectContaining({ userId: 123 }) + ); + }); + + test("should return OPERATION_FAILED on unexpected error", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findUserByIdMock.mockRejectedValue(new Error("Database connection failed")); + + const { resetUserLimitsOnly } = await import("@/actions/users"); + const result = await resetUserLimitsOnly(123); + + expect(result.ok).toBe(false); + expect(result.errorCode).toBe(ERROR_CODES.OPERATION_FAILED); + expect(loggerMock.error).toHaveBeenCalled(); + }); + + test("should handle user with no keys", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" }); + findKeyListMock.mockResolvedValue([]); + scanPatternMock.mockResolvedValue([]); + + const { resetUserLimitsOnly } = await import("@/actions/users"); + const result = await resetUserLimitsOnly(123); + + expect(result.ok).toBe(true); + expect(dbUpdateMock).toHaveBeenCalled(); + // No DB deletes + expect(dbDeleteMock).not.toHaveBeenCalled(); + }); +}); From 744d6c7b58a57896cabf299dd24b4e3845c21495 Mon Sep 17 00:00:00 2001 From: John Doe Date: Sun, 1 Mar 2026 15:16:20 +0300 Subject: [PATCH 2/8] fix: harden costResetAt handling -- repo layer, DRY Redis cleanup, Date validation - Extract resetUserCostResetAt repo function with updatedAt + auth cache invalidation - Extract clearUserCostCache helper to deduplicate Redis cleanup between reset functions - Use instanceof Date checks in lease-service and my-usage for costResetAt validation - Remove dead hasActiveSessions variable in cost-cache-cleanup Co-Authored-By: Claude Opus 4.6 --- src/actions/my-usage.ts | 2 +- src/actions/users.ts | 171 ++++-------------- src/lib/rate-limit/lease-service.ts | 2 +- src/lib/redis/cost-cache-cleanup.ts | 107 +++++++++++ src/repository/user.ts | 16 ++ .../actions/users-reset-limits-only.test.ts | 21 +-- 6 files changed, 170 insertions(+), 149 deletions(-) create mode 100644 src/lib/redis/cost-cache-cleanup.ts diff --git a/src/actions/my-usage.ts b/src/actions/my-usage.ts index 8fa880353..9b5f7e439 100644 --- a/src/actions/my-usage.ts +++ b/src/actions/my-usage.ts @@ -245,7 +245,7 @@ export async function getMyQuota(): Promise> { // Clip time range starts by costResetAt (for limits-only reset) const costResetAt = user.costResetAt ?? null; const clipStart = (start: Date): Date => - costResetAt && costResetAt > start ? costResetAt : start; + costResetAt instanceof Date && costResetAt > start ? costResetAt : start; const clippedRange5h = { startTime: clipStart(range5h.startTime), endTime: range5h.endTime }; const clippedRangeWeekly = { diff --git a/src/actions/users.ts b/src/actions/users.ts index 1886b5b95..ac921bbf0 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -32,6 +32,7 @@ import { findUserListBatch, getAllUserProviderGroups as getAllUserProviderGroupsRepository, getAllUserTags as getAllUserTagsRepository, + resetUserCostResetAt, searchUsersForFilter as searchUsersForFilterRepository, updateUser, } from "@/repository/user"; @@ -1828,82 +1829,26 @@ export async function resetUserLimitsOnly(userId: number): Promise const keyHashes = keys.map((k) => k.key); // Set costResetAt on user so all cost calculations start fresh - await db.update(usersTable).set({ costResetAt: new Date() }).where(eq(usersTable.id, userId)); - - // Invalidate auth cache so the new costResetAt is picked up immediately - const { invalidateCachedUser } = await import("@/lib/security/api-key-auth-cache"); - await invalidateCachedUser(userId).catch(() => {}); + // Uses repo function which also sets updatedAt and invalidates auth cache + await resetUserCostResetAt(userId, new Date()); // Clear Redis cost cache (but NOT active sessions, NOT DB logs) - const { getRedisClient } = await import("@/lib/redis"); - const { scanPattern } = await import("@/lib/redis/scan-helper"); - const redis = getRedisClient(); - - if (redis && redis.status === "ready") { - try { - const startTime = Date.now(); - - // Scan all cost patterns in parallel - const scanResults = await Promise.all([ - ...keyIds.map((keyId) => - scanPattern(redis, `key:${keyId}:cost_*`).catch((err) => { - logger.warn("Failed to scan key cost pattern", { keyId, error: err }); - return []; - }) - ), - scanPattern(redis, `user:${userId}:cost_*`).catch((err) => { - logger.warn("Failed to scan user cost pattern", { userId, error: err }); - return []; - }), - // Total cost cache keys (with optional resetAt suffix) - scanPattern(redis, `total_cost:user:${userId}`).catch(() => []), - scanPattern(redis, `total_cost:user:${userId}:*`).catch(() => []), - ...keyHashes.map((keyHash) => - scanPattern(redis, `total_cost:key:${keyHash}`).catch(() => []) - ), - ...keyHashes.map((keyHash) => - scanPattern(redis, `total_cost:key:${keyHash}:*`).catch(() => []) - ), - // Lease cache keys (budget slices cached by LeaseService) - ...keyIds.map((keyId) => scanPattern(redis, `lease:key:${keyId}:*`).catch(() => [])), - scanPattern(redis, `lease:user:${userId}:*`).catch(() => []), - ]); - - const allCostKeys = scanResults.flat(); - - if (allCostKeys.length > 0) { - // Batch delete via pipeline - const pipeline = redis.pipeline(); - for (const key of allCostKeys) { - pipeline.del(key); - } - - const results = await pipeline.exec(); - - // Check for errors - const errors = results?.filter(([err]) => err); - if (errors && errors.length > 0) { - logger.warn("Some Redis deletes failed during user limits reset", { - errorCount: errors.length, - userId, - }); - } - } - - const duration = Date.now() - startTime; + try { + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const cacheResult = await clearUserCostCache({ userId, keyIds, keyHashes }); + if (cacheResult) { logger.info("Reset user limits only - Redis cost cache cleared", { userId, keyCount: keyIds.length, - costKeysDeleted: allCostKeys.length, - durationMs: duration, - }); - } catch (error) { - logger.error("Failed to clear Redis cache during user limits reset", { - userId, - error: error instanceof Error ? error.message : String(error), + ...cacheResult, }); - // Continue execution - costResetAt already set in DB } + } catch (error) { + logger.error("Failed to clear Redis cache during user limits reset", { + userId, + error: error instanceof Error ? error.message : String(error), + }); + // Continue execution - costResetAt already set in DB } logger.info("Reset user limits only (costResetAt set)", { userId, keyCount: keyIds.length }); @@ -1948,6 +1893,7 @@ export async function resetUserAllStatistics(userId: number): Promise k.id); + const keyHashes = keys.map((k) => k.key); // 1. Delete all messageRequest logs for this user await db.delete(messageRequest).where(eq(messageRequest.userId, userId)); @@ -1955,78 +1901,31 @@ export async function resetUserAllStatistics(userId: number): Promise {}); - - // 2. Clear Redis cache - const { getRedisClient } = await import("@/lib/redis"); - const { scanPattern } = await import("@/lib/redis/scan-helper"); - const { getKeyActiveSessionsKey, getUserActiveSessionsKey } = await import( - "@/lib/redis/active-session-keys" - ); - const redis = getRedisClient(); - - if (redis && redis.status === "ready") { - try { - const startTime = Date.now(); - - // Scan all patterns in parallel - const scanResults = await Promise.all([ - ...keyIds.map((keyId) => - scanPattern(redis, `key:${keyId}:cost_*`).catch((err) => { - logger.warn("Failed to scan key cost pattern", { keyId, error: err }); - return []; - }) - ), - scanPattern(redis, `user:${userId}:cost_*`).catch((err) => { - logger.warn("Failed to scan user cost pattern", { userId, error: err }); - return []; - }), - ]); - - const allCostKeys = scanResults.flat(); - - // Batch delete via pipeline - const pipeline = redis.pipeline(); - - // Active sessions - for (const keyId of keyIds) { - pipeline.del(getKeyActiveSessionsKey(keyId)); - } - pipeline.del(getUserActiveSessionsKey(userId)); - - // Cost keys - for (const key of allCostKeys) { - pipeline.del(key); - } - - const results = await pipeline.exec(); - - // Check for errors - const errors = results?.filter(([err]) => err); - if (errors && errors.length > 0) { - logger.warn("Some Redis deletes failed during user statistics reset", { - errorCount: errors.length, - userId, - }); - } - - const duration = Date.now() - startTime; + // Clear costResetAt since all data is wiped (also invalidates auth cache) + await resetUserCostResetAt(userId, null); + + // 2. Clear Redis cache (cost keys + active sessions) + try { + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const cacheResult = await clearUserCostCache({ + userId, + keyIds, + keyHashes, + includeActiveSessions: true, + }); + if (cacheResult) { logger.info("Reset user statistics - Redis cache cleared", { userId, keyCount: keyIds.length, - costKeysDeleted: allCostKeys.length, - activeSessionsDeleted: keyIds.length + 1, - durationMs: duration, - }); - } catch (error) { - logger.error("Failed to clear Redis cache during user statistics reset", { - userId, - error: error instanceof Error ? error.message : String(error), + ...cacheResult, }); - // Continue execution - DB logs already deleted } + } catch (error) { + logger.error("Failed to clear Redis cache during user statistics reset", { + userId, + error: error instanceof Error ? error.message : String(error), + }); + // Continue execution - DB logs already deleted } logger.info("Reset all user statistics", { userId, keyCount: keyIds.length }); diff --git a/src/lib/rate-limit/lease-service.ts b/src/lib/rate-limit/lease-service.ts index 9c5d1c4c7..3db11e1fc 100644 --- a/src/lib/rate-limit/lease-service.ts +++ b/src/lib/rate-limit/lease-service.ts @@ -168,7 +168,7 @@ export class LeaseService { // Clip startTime forward if costResetAt is more recent (limits-only reset) const effectiveStartTime = - params.costResetAt && params.costResetAt > startTime ? params.costResetAt : startTime; + params.costResetAt instanceof Date && params.costResetAt > startTime ? params.costResetAt : startTime; // Query DB for current usage const currentUsage = await LeaseService.queryDbUsage( diff --git a/src/lib/redis/cost-cache-cleanup.ts b/src/lib/redis/cost-cache-cleanup.ts new file mode 100644 index 000000000..18cb2f73a --- /dev/null +++ b/src/lib/redis/cost-cache-cleanup.ts @@ -0,0 +1,107 @@ +import { logger } from "@/lib/logger"; +import { getRedisClient } from "@/lib/redis"; +import { getKeyActiveSessionsKey, getUserActiveSessionsKey } from "@/lib/redis/active-session-keys"; +import { scanPattern } from "@/lib/redis/scan-helper"; + +export interface ClearUserCostCacheOptions { + userId: number; + keyIds: number[]; + keyHashes: string[]; + includeActiveSessions?: boolean; +} + +export interface ClearUserCostCacheResult { + costKeysDeleted: number; + activeSessionsDeleted: number; + durationMs: number; +} + +/** + * Scan and delete all Redis cost-cache keys for a user and their API keys. + * + * Covers: cost counters, total cost cache, lease budget slices, + * and optionally active session ZSETs. + * + * Returns null if Redis is not ready. Never throws -- logs errors internally. + */ +export async function clearUserCostCache( + options: ClearUserCostCacheOptions, +): Promise { + const { userId, keyIds, keyHashes, includeActiveSessions = false } = options; + + const redis = getRedisClient(); + if (!redis || redis.status !== "ready") { + return null; + } + + const startTime = Date.now(); + + // Scan all cost patterns in parallel + const scanResults = await Promise.all([ + ...keyIds.map((keyId) => + scanPattern(redis, `key:${keyId}:cost_*`).catch((err) => { + logger.warn("Failed to scan key cost pattern", { keyId, error: err }); + return []; + }), + ), + scanPattern(redis, `user:${userId}:cost_*`).catch((err) => { + logger.warn("Failed to scan user cost pattern", { userId, error: err }); + return []; + }), + // Total cost cache keys (with optional resetAt suffix) + scanPattern(redis, `total_cost:user:${userId}`).catch(() => []), + scanPattern(redis, `total_cost:user:${userId}:*`).catch(() => []), + ...keyHashes.map((keyHash) => scanPattern(redis, `total_cost:key:${keyHash}`).catch(() => [])), + ...keyHashes.map((keyHash) => + scanPattern(redis, `total_cost:key:${keyHash}:*`).catch(() => []), + ), + // Lease cache keys (budget slices cached by LeaseService) + ...keyIds.map((keyId) => scanPattern(redis, `lease:key:${keyId}:*`).catch(() => [])), + scanPattern(redis, `lease:user:${userId}:*`).catch(() => []), + ]); + + const allCostKeys = scanResults.flat(); + let activeSessionsDeleted = 0; + + // Only create pipeline if there is work to do + if (allCostKeys.length === 0 && !includeActiveSessions) { + return { + costKeysDeleted: 0, + activeSessionsDeleted: 0, + durationMs: Date.now() - startTime, + }; + } + + const pipeline = redis.pipeline(); + + // Active sessions (only for full statistics reset) + if (includeActiveSessions) { + for (const keyId of keyIds) { + pipeline.del(getKeyActiveSessionsKey(keyId)); + } + pipeline.del(getUserActiveSessionsKey(userId)); + activeSessionsDeleted = keyIds.length + 1; + } + + // Cost keys + for (const key of allCostKeys) { + pipeline.del(key); + } + + const results = await pipeline.exec(); + + // Check for pipeline errors + const errors = results?.filter(([err]) => err); + if (errors && errors.length > 0) { + logger.warn("Some Redis deletes failed during cost cache cleanup", { + errorCount: errors.length, + userId, + }); + } + + return { + costKeysDeleted: allCostKeys.length, + activeSessionsDeleted, + durationMs: Date.now() - startTime, + }; +} diff --git a/src/repository/user.ts b/src/repository/user.ts index 5290b7b90..a0ab2141b 100644 --- a/src/repository/user.ts +++ b/src/repository/user.ts @@ -546,6 +546,22 @@ export async function deleteUser(id: number): Promise { return result.length > 0; } +export async function resetUserCostResetAt( + userId: number, + resetAt: Date | null, +): Promise { + const result = await db + .update(users) + .set({ costResetAt: resetAt, updatedAt: new Date() }) + .where(and(eq(users.id, userId), isNull(users.deletedAt))) + .returning({ id: users.id }); + + if (result.length > 0) { + await invalidateCachedUser(userId).catch(() => {}); + } + return result.length > 0; +} + /** * Mark an expired user as disabled (idempotent operation) * Only updates if the user is currently enabled diff --git a/tests/unit/actions/users-reset-limits-only.test.ts b/tests/unit/actions/users-reset-limits-only.test.ts index e1b5c7aa9..d6ac1b6b9 100644 --- a/tests/unit/actions/users-reset-limits-only.test.ts +++ b/tests/unit/actions/users-reset-limits-only.test.ts @@ -22,11 +22,13 @@ vi.mock("next/cache", () => ({ // Mock repository/user const findUserByIdMock = vi.fn(); +const resetUserCostResetAtMock = vi.fn(); vi.mock("@/repository/user", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, findUserById: findUserByIdMock, + resetUserCostResetAt: resetUserCostResetAtMock, }; }); @@ -89,6 +91,7 @@ describe("resetUserLimitsOnly", () => { redisMock.status = "ready"; redisPipelineMock.exec.mockResolvedValue([]); dbUpdateWhereMock.mockResolvedValue(undefined); + resetUserCostResetAtMock.mockResolvedValue(true); }); test("should return PERMISSION_DENIED for non-admin user", async () => { @@ -121,7 +124,7 @@ describe("resetUserLimitsOnly", () => { expect(result.ok).toBe(false); expect(result.errorCode).toBe(ERROR_CODES.NOT_FOUND); - expect(dbUpdateMock).not.toHaveBeenCalled(); + expect(resetUserCostResetAtMock).not.toHaveBeenCalled(); }); test("should set costResetAt and clear Redis cost cache", async () => { @@ -138,12 +141,8 @@ describe("resetUserLimitsOnly", () => { const result = await resetUserLimitsOnly(123); expect(result.ok).toBe(true); - // costResetAt set via db.update - expect(dbUpdateMock).toHaveBeenCalled(); - expect(dbUpdateSetMock).toHaveBeenCalledWith( - expect.objectContaining({ costResetAt: expect.any(Date) }) - ); - expect(dbUpdateWhereMock).toHaveBeenCalled(); + // costResetAt set via repository function + expect(resetUserCostResetAtMock).toHaveBeenCalledWith(123, expect.any(Date)); // Redis cost keys scanned and deleted expect(scanPatternMock).toHaveBeenCalled(); expect(redisMock.pipeline).toHaveBeenCalled(); @@ -179,8 +178,8 @@ describe("resetUserLimitsOnly", () => { const result = await resetUserLimitsOnly(123); expect(result.ok).toBe(true); - // costResetAt still set in DB - expect(dbUpdateMock).toHaveBeenCalled(); + // costResetAt still set via repo function + expect(resetUserCostResetAtMock).toHaveBeenCalledWith(123, expect.any(Date)); // Redis pipeline NOT called expect(redisMock.pipeline).not.toHaveBeenCalled(); }); @@ -200,7 +199,7 @@ describe("resetUserLimitsOnly", () => { expect(result.ok).toBe(true); expect(loggerMock.warn).toHaveBeenCalledWith( - "Some Redis deletes failed during user limits reset", + "Some Redis deletes failed during cost cache cleanup", expect.objectContaining({ errorCount: 1, userId: 123 }) ); }); @@ -244,7 +243,7 @@ describe("resetUserLimitsOnly", () => { const result = await resetUserLimitsOnly(123); expect(result.ok).toBe(true); - expect(dbUpdateMock).toHaveBeenCalled(); + expect(resetUserCostResetAtMock).toHaveBeenCalledWith(123, expect.any(Date)); // No DB deletes expect(dbDeleteMock).not.toHaveBeenCalled(); }); From c4f8b6041d75d0834bb167a4ad72d07ab8f3ffee Mon Sep 17 00:00:00 2001 From: John Doe Date: Sun, 1 Mar 2026 18:14:52 +0300 Subject: [PATCH 3/8] fix: unify costResetAt guards to instanceof Date, add last-reset badge in user edit dialog R3: Replace truthiness checks with `instanceof Date` in 3 places (users.ts clipStart, quotas page). R4: Show last reset timestamp in edit-user-dialog Reset Limits section (5 langs). Add 47 unit tests covering costResetAt across key-quota, redis cleanup, statistics, and auth cache. Co-Authored-By: Claude Opus 4.6 --- messages/en/dashboard.json | 3 +- messages/ja/dashboard.json | 3 +- messages/ru/dashboard.json | 3 +- messages/zh-CN/dashboard.json | 3 +- messages/zh-TW/dashboard.json | 3 +- src/actions/users.ts | 4 +- .../_components/user/edit-user-dialog.tsx | 13 +- .../[locale]/dashboard/quotas/users/page.tsx | 2 +- .../unit/actions/key-quota-cost-reset.test.ts | 241 ++++++++++++++++ .../unit/lib/redis/cost-cache-cleanup.test.ts | 261 ++++++++++++++++++ .../api-key-auth-cache-reset-at.test.ts | 195 +++++++++++++ .../repository/statistics-reset-at.test.ts | 258 +++++++++++++++++ 12 files changed, 980 insertions(+), 9 deletions(-) create mode 100644 tests/unit/actions/key-quota-cost-reset.test.ts create mode 100644 tests/unit/lib/redis/cost-cache-cleanup.test.ts create mode 100644 tests/unit/lib/security/api-key-auth-cache-reset-at.test.ts create mode 100644 tests/unit/repository/statistics-reset-at.test.ts diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index f2ef39736..62fcadad0 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -1527,7 +1527,8 @@ "confirm": "Yes, Reset Limits", "loading": "Resetting...", "error": "Failed to reset limits", - "success": "All limits have been reset" + "success": "All limits have been reset", + "lastResetAt": "Last reset: {date}" }, "resetData": { "title": "Reset Statistics", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index a7b375963..2c2339993 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -1505,7 +1505,8 @@ "confirm": "はい、リセットする", "loading": "リセット中...", "error": "制限のリセットに失敗しました", - "success": "全ての制限がリセットされました" + "success": "全ての制限がリセットされました", + "lastResetAt": "前回のリセット: {date}" }, "resetData": { "title": "統計リセット", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 9134a454b..de73b496c 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -1510,7 +1510,8 @@ "confirm": "Да, сбросить лимиты", "loading": "Сброс...", "error": "Не удалось сбросить лимиты", - "success": "Все лимиты сброшены" + "success": "Все лимиты сброшены", + "lastResetAt": "Последний сброс: {date}" }, "resetData": { "title": "Сброс статистики", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index f7e328586..89ce6bde0 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -1528,7 +1528,8 @@ "confirm": "是的,重置限额", "loading": "正在重置...", "error": "重置限额失败", - "success": "所有限额已重置" + "success": "所有限额已重置", + "lastResetAt": "上次重置: {date}" }, "resetData": { "title": "重置统计", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index e2a5bef75..a8c120241 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -1513,7 +1513,8 @@ "confirm": "是的,重設限額", "loading": "正在重設...", "error": "重設限額失敗", - "success": "所有限額已重設" + "success": "所有限額已重設", + "lastResetAt": "上次重設: {date}" }, "resetData": { "title": "重置統計", diff --git a/src/actions/users.ts b/src/actions/users.ts index ac921bbf0..c0c8eb608 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -1559,7 +1559,7 @@ export async function getUserLimitUsage(userId: number): Promise< resetMode ); const effectiveStart = - user.costResetAt && user.costResetAt > startTime ? user.costResetAt : startTime; + user.costResetAt instanceof Date && user.costResetAt > startTime ? user.costResetAt : startTime; const dailyCost = await sumUserCostInTimeRange(userId, effectiveStart, endTime); const resetInfo = await getResetInfoWithMode("daily", resetTime, resetMode); const resetAt = resetInfo.resetAt; @@ -1768,7 +1768,7 @@ export async function getUserAllLimitUsage(userId: number): Promise< // Clip time range start by costResetAt (for limits-only reset) const clipStart = (start: Date): Date => - user.costResetAt && user.costResetAt > start ? user.costResetAt : start; + user.costResetAt instanceof Date && user.costResetAt > start ? user.costResetAt : start; // 并行查询各时间范围的消费 // Note: sumUserTotalCost uses ALL_TIME_MAX_AGE_DAYS for all-time semantics diff --git a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx index 7886e7f6c..463016ea4 100644 --- a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx @@ -3,7 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { Loader2, RotateCcw, Trash2, UserCog } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { useMemo, useState, useTransition } from "react"; import { toast } from "sonner"; import { z } from "zod"; @@ -90,6 +90,7 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr const queryClient = useQueryClient(); const t = useTranslations("dashboard.userManagement"); const tCommon = useTranslations("common"); + const locale = useLocale(); const [isPending, startTransition] = useTransition(); const [isResettingAll, setIsResettingAll] = useState(false); const [resetAllDialogOpen, setResetAllDialogOpen] = useState(false); @@ -331,6 +332,16 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr

{t("editDialog.resetLimits.description")}

+ {user.costResetAt && ( +

+ {t("editDialog.resetLimits.lastResetAt", { + date: new Intl.DateTimeFormat(locale, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(user.costResetAt)), + })} +

+ )}
diff --git a/src/app/[locale]/dashboard/quotas/users/page.tsx b/src/app/[locale]/dashboard/quotas/users/page.tsx index 80cb6e06d..eb170138c 100644 --- a/src/app/[locale]/dashboard/quotas/users/page.tsx +++ b/src/app/[locale]/dashboard/quotas/users/page.tsx @@ -25,7 +25,7 @@ async function getUsersWithQuotas(): Promise { const userResetAtMap = new Map(); const keyResetAtMap = new Map(); for (const u of users) { - if (u.costResetAt) { + if (u.costResetAt instanceof Date) { userResetAtMap.set(u.id, u.costResetAt); for (const k of u.keys) { keyResetAtMap.set(k.id, u.costResetAt); diff --git a/tests/unit/actions/key-quota-cost-reset.test.ts b/tests/unit/actions/key-quota-cost-reset.test.ts new file mode 100644 index 000000000..2b5f14b49 --- /dev/null +++ b/tests/unit/actions/key-quota-cost-reset.test.ts @@ -0,0 +1,241 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { ERROR_CODES } from "@/lib/utils/error-messages"; + +// Mock getSession +const getSessionMock = vi.fn(); +vi.mock("@/lib/auth", () => ({ + getSession: getSessionMock, +})); + +// Mock next-intl +vi.mock("next-intl/server", () => ({ + getTranslations: vi.fn(async () => (key: string) => key), + getLocale: vi.fn(async () => "en"), +})); + +// Mock getSystemSettings +const getSystemSettingsMock = vi.fn(); +vi.mock("@/repository/system-config", () => ({ + getSystemSettings: getSystemSettingsMock, +})); + +// Mock statistics +const sumKeyCostInTimeRangeMock = vi.fn(); +const sumKeyTotalCostMock = vi.fn(); +vi.mock("@/repository/statistics", () => ({ + sumKeyCostInTimeRange: sumKeyCostInTimeRangeMock, + sumKeyTotalCost: sumKeyTotalCostMock, +})); + +// Mock time-utils +const getTimeRangeForPeriodWithModeMock = vi.fn(); +const getTimeRangeForPeriodMock = vi.fn(); +vi.mock("@/lib/rate-limit/time-utils", () => ({ + getTimeRangeForPeriodWithMode: getTimeRangeForPeriodWithModeMock, + getTimeRangeForPeriod: getTimeRangeForPeriodMock, +})); + +// Mock SessionTracker +const getKeySessionCountMock = vi.fn(); +vi.mock("@/lib/session-tracker", () => ({ + SessionTracker: { getKeySessionCount: getKeySessionCountMock }, +})); + +// Mock resolveKeyConcurrentSessionLimit +vi.mock("@/lib/rate-limit/concurrent-session-limit", () => ({ + resolveKeyConcurrentSessionLimit: vi.fn(() => 0), +})); + +// Mock logger +vi.mock("@/lib/logger", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +// Mock drizzle db - need select().from().leftJoin().where().limit() chain +const dbLimitMock = vi.fn(); +const dbWhereMock = vi.fn(() => ({ limit: dbLimitMock })); +const dbLeftJoinMock = vi.fn(() => ({ where: dbWhereMock })); +const dbFromMock = vi.fn(() => ({ leftJoin: dbLeftJoinMock })); +const dbSelectMock = vi.fn(() => ({ from: dbFromMock })); +vi.mock("@/drizzle/db", () => ({ + db: { select: dbSelectMock }, +})); + +// Common date fixtures +const NOW = new Date("2026-03-01T12:00:00Z"); +const FIVE_HOURS_AGO = new Date("2026-03-01T07:00:00Z"); +const DAILY_START = new Date("2026-03-01T00:00:00Z"); +const WEEKLY_START = new Date("2026-02-23T00:00:00Z"); +const MONTHLY_START = new Date("2026-02-01T00:00:00Z"); + +function makeTimeRange(startTime: Date, endTime: Date = NOW) { + return { startTime, endTime }; +} + +const DEFAULT_KEY_ROW = { + id: 42, + key: "sk-test-key-hash", + name: "Test Key", + userId: 10, + isEnabled: true, + dailyResetTime: "00:00", + dailyResetMode: "fixed", + limit5hUsd: "10.00", + limitDailyUsd: "20.00", + limitWeeklyUsd: "50.00", + limitMonthlyUsd: "100.00", + limitTotalUsd: "500.00", + limitConcurrentSessions: 0, + deletedAt: null, +}; + +function setupTimeRangeMocks() { + getTimeRangeForPeriodWithModeMock.mockResolvedValue(makeTimeRange(DAILY_START)); + getTimeRangeForPeriodMock.mockImplementation(async (period: string) => { + switch (period) { + case "5h": + return makeTimeRange(FIVE_HOURS_AGO); + case "weekly": + return makeTimeRange(WEEKLY_START); + case "monthly": + return makeTimeRange(MONTHLY_START); + default: + return makeTimeRange(DAILY_START); + } + }); +} + +function setupDefaultMocks(costResetAt: Date | null = null) { + getSessionMock.mockResolvedValue({ user: { id: 10, role: "user" } }); + getSystemSettingsMock.mockResolvedValue({ currencyDisplay: "USD" }); + dbLimitMock.mockResolvedValue([ + { + key: DEFAULT_KEY_ROW, + userLimitConcurrentSessions: null, + userCostResetAt: costResetAt, + }, + ]); + setupTimeRangeMocks(); + sumKeyCostInTimeRangeMock.mockResolvedValue(1.5); + sumKeyTotalCostMock.mockResolvedValue(10.0); + getKeySessionCountMock.mockResolvedValue(2); +} + +describe("getKeyQuotaUsage costResetAt clipping", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("user with costResetAt -- period costs use clipped startTime", async () => { + // costResetAt is 2 hours ago -- should clip 5h range (7h ago) but not daily (midnight) + const costResetAt = new Date("2026-03-01T10:00:00Z"); + setupDefaultMocks(costResetAt); + + const { getKeyQuotaUsage } = await import("@/actions/key-quota"); + const result = await getKeyQuotaUsage(42); + + expect(result.ok).toBe(true); + + // 5h range start (7h ago) < costResetAt (2h ago) => clipped to costResetAt + expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledWith(42, costResetAt, NOW); + // daily start (midnight) < costResetAt (10:00) => clipped to costResetAt + expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledWith(42, costResetAt, NOW); + // weekly/monthly starts are way before costResetAt => also clipped + expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledTimes(4); + + // sumKeyTotalCost receives costResetAt as 3rd argument + expect(sumKeyTotalCostMock).toHaveBeenCalledWith("sk-test-key-hash", 365, costResetAt); + }); + + test("user without costResetAt (null) -- original time ranges unchanged", async () => { + setupDefaultMocks(null); + + const { getKeyQuotaUsage } = await import("@/actions/key-quota"); + const result = await getKeyQuotaUsage(42); + + expect(result.ok).toBe(true); + + // 5h: original start used (no clipping) + expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledWith(42, FIVE_HOURS_AGO, NOW); + // daily: original start + expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledWith(42, DAILY_START, NOW); + // weekly + expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledWith(42, WEEKLY_START, NOW); + // monthly + expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledWith(42, MONTHLY_START, NOW); + // total cost: null costResetAt + expect(sumKeyTotalCostMock).toHaveBeenCalledWith("sk-test-key-hash", 365, null); + }); + + test("costResetAt older than all period starts -- no clipping effect", async () => { + // costResetAt is 1 year ago, older than even monthly start + const costResetAt = new Date("2025-01-01T00:00:00Z"); + setupDefaultMocks(costResetAt); + + const { getKeyQuotaUsage } = await import("@/actions/key-quota"); + const result = await getKeyQuotaUsage(42); + + expect(result.ok).toBe(true); + + // clipStart returns original start because costResetAt < start + expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledWith(42, FIVE_HOURS_AGO, NOW); + expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledWith(42, DAILY_START, NOW); + expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledWith(42, WEEKLY_START, NOW); + expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledWith(42, MONTHLY_START, NOW); + // total still receives costResetAt (sumKeyTotalCost handles it internally) + expect(sumKeyTotalCostMock).toHaveBeenCalledWith("sk-test-key-hash", 365, costResetAt); + }); + + test("costResetAt in the middle of daily range -- clips daily correctly", async () => { + // costResetAt is 6AM today -- after daily start (midnight) but before now (noon) + const costResetAt = new Date("2026-03-01T06:00:00Z"); + setupDefaultMocks(costResetAt); + + const { getKeyQuotaUsage } = await import("@/actions/key-quota"); + const result = await getKeyQuotaUsage(42); + + expect(result.ok).toBe(true); + + // Daily start (midnight) < costResetAt (6AM) => clipped + // Check the second call (daily) uses costResetAt + const calls = sumKeyCostInTimeRangeMock.mock.calls; + // 5h call: 7AM > 6AM => 5h start is AFTER costResetAt, so original 5h start used + expect(calls[0]).toEqual([42, FIVE_HOURS_AGO, NOW]); + // daily call: midnight < 6AM => clipped to costResetAt + expect(calls[1]).toEqual([42, costResetAt, NOW]); + // weekly: before costResetAt => clipped + expect(calls[2]).toEqual([42, costResetAt, NOW]); + // monthly: before costResetAt => clipped + expect(calls[3]).toEqual([42, costResetAt, NOW]); + }); + + test("permission denied for non-owner non-admin", async () => { + getSessionMock.mockResolvedValue({ user: { id: 99, role: "user" } }); + getSystemSettingsMock.mockResolvedValue({ currencyDisplay: "USD" }); + dbLimitMock.mockResolvedValue([ + { + key: { ...DEFAULT_KEY_ROW, userId: 10 }, + userLimitConcurrentSessions: null, + userCostResetAt: null, + }, + ]); + + const { getKeyQuotaUsage } = await import("@/actions/key-quota"); + const result = await getKeyQuotaUsage(42); + + expect(result.ok).toBe(false); + expect(result.errorCode).toBe(ERROR_CODES.PERMISSION_DENIED); + expect(sumKeyCostInTimeRangeMock).not.toHaveBeenCalled(); + }); + + test("key not found", async () => { + getSessionMock.mockResolvedValue({ user: { id: 10, role: "admin" } }); + dbLimitMock.mockResolvedValue([]); + + const { getKeyQuotaUsage } = await import("@/actions/key-quota"); + const result = await getKeyQuotaUsage(999); + + expect(result.ok).toBe(false); + expect(result.errorCode).toBe(ERROR_CODES.NOT_FOUND); + }); +}); diff --git a/tests/unit/lib/redis/cost-cache-cleanup.test.ts b/tests/unit/lib/redis/cost-cache-cleanup.test.ts new file mode 100644 index 000000000..0fb80c652 --- /dev/null +++ b/tests/unit/lib/redis/cost-cache-cleanup.test.ts @@ -0,0 +1,261 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +// Mock logger +const loggerMock = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +}; +vi.mock("@/lib/logger", () => ({ + logger: loggerMock, +})); + +// Mock Redis +const redisPipelineMock = { + del: vi.fn().mockReturnThis(), + exec: vi.fn(), +}; +const redisMock = { + status: "ready" as string, + pipeline: vi.fn(() => redisPipelineMock), +}; +const getRedisClientMock = vi.fn(() => redisMock); +vi.mock("@/lib/redis", () => ({ + getRedisClient: getRedisClientMock, +})); + +// Mock scanPattern +const scanPatternMock = vi.fn(); +vi.mock("@/lib/redis/scan-helper", () => ({ + scanPattern: scanPatternMock, +})); + +// Mock active-session-keys +vi.mock("@/lib/redis/active-session-keys", () => ({ + getKeyActiveSessionsKey: (keyId: number) => `{active_sessions}:key:${keyId}:active_sessions`, + getUserActiveSessionsKey: (userId: number) => `{active_sessions}:user:${userId}:active_sessions`, +})); + +describe("clearUserCostCache", () => { + beforeEach(() => { + vi.clearAllMocks(); + redisMock.status = "ready"; + redisPipelineMock.exec.mockResolvedValue([]); + scanPatternMock.mockResolvedValue([]); + }); + + test("scans correct Redis patterns for keyIds, userId, keyHashes", async () => { + scanPatternMock.mockResolvedValue([]); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + await clearUserCostCache({ + userId: 10, + keyIds: [1, 2], + keyHashes: ["hash-a", "hash-b"], + }); + + const calls = scanPatternMock.mock.calls.map(([_redis, pattern]: [unknown, string]) => pattern); + // Per-key cost counters + expect(calls).toContain("key:1:cost_*"); + expect(calls).toContain("key:2:cost_*"); + // User cost counters + expect(calls).toContain("user:10:cost_*"); + // Total cost cache (user) + expect(calls).toContain("total_cost:user:10"); + expect(calls).toContain("total_cost:user:10:*"); + // Total cost cache (key hashes) + expect(calls).toContain("total_cost:key:hash-a"); + expect(calls).toContain("total_cost:key:hash-a:*"); + expect(calls).toContain("total_cost:key:hash-b"); + expect(calls).toContain("total_cost:key:hash-b:*"); + // Lease cache + expect(calls).toContain("lease:key:1:*"); + expect(calls).toContain("lease:key:2:*"); + expect(calls).toContain("lease:user:10:*"); + }); + + test("pipeline deletes all found keys", async () => { + scanPatternMock.mockImplementation(async (_redis: unknown, pattern: string) => { + if (pattern === "key:1:cost_*") return ["key:1:cost_daily", "key:1:cost_5h"]; + if (pattern === "user:10:cost_*") return ["user:10:cost_monthly"]; + return []; + }); + redisPipelineMock.exec.mockResolvedValue([[null, 1], [null, 1], [null, 1]]); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const result = await clearUserCostCache({ + userId: 10, + keyIds: [1], + keyHashes: [], + }); + + expect(result).not.toBeNull(); + expect(result!.costKeysDeleted).toBe(3); + expect(redisPipelineMock.del).toHaveBeenCalledWith("key:1:cost_daily"); + expect(redisPipelineMock.del).toHaveBeenCalledWith("key:1:cost_5h"); + expect(redisPipelineMock.del).toHaveBeenCalledWith("user:10:cost_monthly"); + expect(redisPipelineMock.exec).toHaveBeenCalled(); + }); + + test("returns metrics (costKeysDeleted, activeSessionsDeleted, durationMs)", async () => { + scanPatternMock.mockImplementation(async (_redis: unknown, pattern: string) => { + if (pattern === "key:1:cost_*") return ["key:1:cost_daily"]; + return []; + }); + redisPipelineMock.exec.mockResolvedValue([[null, 1], [null, 1], [null, 1]]); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const result = await clearUserCostCache({ + userId: 10, + keyIds: [1], + keyHashes: [], + includeActiveSessions: true, + }); + + expect(result).not.toBeNull(); + expect(result!.costKeysDeleted).toBe(1); + // 1 key session + 1 user session = 2 + expect(result!.activeSessionsDeleted).toBe(2); + expect(typeof result!.durationMs).toBe("number"); + expect(result!.durationMs).toBeGreaterThanOrEqual(0); + }); + + test("returns null when Redis not ready", async () => { + redisMock.status = "connecting"; + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const result = await clearUserCostCache({ + userId: 10, + keyIds: [1], + keyHashes: [], + }); + + expect(result).toBeNull(); + expect(scanPatternMock).not.toHaveBeenCalled(); + }); + + test("returns null when Redis client is null", async () => { + getRedisClientMock.mockReturnValue(null); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const result = await clearUserCostCache({ + userId: 10, + keyIds: [1], + keyHashes: [], + }); + + expect(result).toBeNull(); + }); + + test("includeActiveSessions=true adds session key DELs", async () => { + scanPatternMock.mockResolvedValue([]); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const result = await clearUserCostCache({ + userId: 10, + keyIds: [1, 2], + keyHashes: [], + includeActiveSessions: true, + }); + + expect(result).not.toBeNull(); + // 2 key sessions + 1 user session + expect(result!.activeSessionsDeleted).toBe(3); + expect(redisPipelineMock.del).toHaveBeenCalledWith( + "{active_sessions}:key:1:active_sessions" + ); + expect(redisPipelineMock.del).toHaveBeenCalledWith( + "{active_sessions}:key:2:active_sessions" + ); + expect(redisPipelineMock.del).toHaveBeenCalledWith( + "{active_sessions}:user:10:active_sessions" + ); + }); + + test("includeActiveSessions=false skips session keys", async () => { + scanPatternMock.mockImplementation(async (_redis: unknown, pattern: string) => { + if (pattern === "key:1:cost_*") return ["key:1:cost_daily"]; + return []; + }); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const result = await clearUserCostCache({ + userId: 10, + keyIds: [1], + keyHashes: [], + includeActiveSessions: false, + }); + + expect(result).not.toBeNull(); + expect(result!.activeSessionsDeleted).toBe(0); + // Only cost key deleted, no session keys + const delCalls = redisPipelineMock.del.mock.calls.map(([k]: [string]) => k); + expect(delCalls).not.toContain("{active_sessions}:key:1:active_sessions"); + expect(delCalls).not.toContain("{active_sessions}:user:10:active_sessions"); + }); + + test("empty scan results -- no pipeline created, returns zeros", async () => { + scanPatternMock.mockResolvedValue([]); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const result = await clearUserCostCache({ + userId: 10, + keyIds: [1], + keyHashes: [], + includeActiveSessions: false, + }); + + expect(result).not.toBeNull(); + expect(result!.costKeysDeleted).toBe(0); + expect(result!.activeSessionsDeleted).toBe(0); + // No pipeline created when nothing to delete + expect(redisMock.pipeline).not.toHaveBeenCalled(); + }); + + test("pipeline partial failures -- logged, does not throw", async () => { + scanPatternMock.mockImplementation(async (_redis: unknown, pattern: string) => { + if (pattern === "key:1:cost_*") return ["key:1:cost_daily", "key:1:cost_5h"]; + return []; + }); + redisPipelineMock.exec.mockResolvedValue([ + [null, 1], + [new Error("Connection reset"), null], + ]); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const result = await clearUserCostCache({ + userId: 10, + keyIds: [1], + keyHashes: [], + }); + + expect(result).not.toBeNull(); + expect(result!.costKeysDeleted).toBe(2); + expect(loggerMock.warn).toHaveBeenCalledWith( + "Some Redis deletes failed during cost cache cleanup", + expect.objectContaining({ errorCount: 1, userId: 10 }) + ); + }); + + test("no keys (empty keyIds/keyHashes) -- only user patterns scanned", async () => { + scanPatternMock.mockResolvedValue([]); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + await clearUserCostCache({ + userId: 10, + keyIds: [], + keyHashes: [], + }); + + const calls = scanPatternMock.mock.calls.map(([_redis, pattern]: [unknown, string]) => pattern); + // Only user-level patterns (no key:* or total_cost:key:* patterns) + expect(calls).toContain("user:10:cost_*"); + expect(calls).toContain("total_cost:user:10"); + expect(calls).toContain("total_cost:user:10:*"); + expect(calls).toContain("lease:user:10:*"); + // No key-specific patterns + expect(calls.filter((p: string) => p.startsWith("key:"))).toHaveLength(0); + expect(calls.filter((p: string) => p.startsWith("total_cost:key:"))).toHaveLength(0); + expect(calls.filter((p: string) => p.startsWith("lease:key:"))).toHaveLength(0); + }); +}); diff --git a/tests/unit/lib/security/api-key-auth-cache-reset-at.test.ts b/tests/unit/lib/security/api-key-auth-cache-reset-at.test.ts new file mode 100644 index 000000000..49e6e8f1d --- /dev/null +++ b/tests/unit/lib/security/api-key-auth-cache-reset-at.test.ts @@ -0,0 +1,195 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +// Mock logger +vi.mock("@/lib/logger", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +// Mock Redis client +const redisPipelineMock = { + setex: vi.fn().mockReturnThis(), + del: vi.fn().mockReturnThis(), + exec: vi.fn().mockResolvedValue([]), +}; +const redisMock = { + get: vi.fn(), + setex: vi.fn(), + del: vi.fn(), + pipeline: vi.fn(() => redisPipelineMock), +}; + +// Mock the redis client loader +vi.mock("@/lib/redis/client", () => ({ + getRedisClient: () => redisMock, +})); + +// Enable cache feature via env +const originalEnv = process.env; +beforeEach(() => { + process.env = { + ...originalEnv, + ENABLE_API_KEY_REDIS_CACHE: "true", + REDIS_URL: "redis://localhost:6379", + ENABLE_RATE_LIMIT: "true", + }; +}); + +// Mock crypto.subtle for SHA-256 +const mockDigest = vi.fn(); +Object.defineProperty(globalThis, "crypto", { + value: { + subtle: { + digest: mockDigest, + }, + }, + writable: true, + configurable: true, +}); + +// Helper: produce a predictable hex hash from SHA-256 mock +function setupSha256Mock(hexResult = "abc123def456") { + const buffer = new ArrayBuffer(hexResult.length / 2); + const view = new Uint8Array(buffer); + for (let i = 0; i < hexResult.length; i += 2) { + view[i / 2] = parseInt(hexResult.slice(i, i + 2), 16); + } + mockDigest.mockResolvedValue(buffer); +} + +// Base user fixture +function makeUser(overrides: Record = {}) { + return { + id: 10, + name: "test-user", + role: "user", + isEnabled: true, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitConcurrentSessions: 0, + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-02-01T00:00:00Z"), + expiresAt: null, + deletedAt: null, + costResetAt: null, + ...overrides, + }; +} + +describe("api-key-auth-cache costResetAt handling", () => { + beforeEach(() => { + vi.clearAllMocks(); + redisMock.get.mockResolvedValue(null); + redisMock.setex.mockResolvedValue("OK"); + redisMock.del.mockResolvedValue(1); + setupSha256Mock(); + }); + + describe("hydrateUserFromCache (via getCachedUser)", () => { + test("preserves costResetAt as Date when valid ISO string in cache", async () => { + const costResetAt = "2026-02-15T00:00:00.000Z"; + const cachedPayload = { + v: 1, + user: makeUser({ costResetAt }), + }; + redisMock.get.mockResolvedValue(JSON.stringify(cachedPayload)); + + const { getCachedUser } = await import("@/lib/security/api-key-auth-cache"); + const user = await getCachedUser(10); + + expect(user).not.toBeNull(); + expect(user!.costResetAt).toBeInstanceOf(Date); + expect(user!.costResetAt!.toISOString()).toBe(costResetAt); + }); + + test("costResetAt null in cache -- returns null correctly", async () => { + const cachedPayload = { + v: 1, + user: makeUser({ costResetAt: null }), + }; + redisMock.get.mockResolvedValue(JSON.stringify(cachedPayload)); + + const { getCachedUser } = await import("@/lib/security/api-key-auth-cache"); + const user = await getCachedUser(10); + + expect(user).not.toBeNull(); + expect(user!.costResetAt).toBeNull(); + }); + + test("costResetAt undefined in cache -- returns undefined correctly", async () => { + // When costResetAt is not present in JSON, it deserializes as undefined + const userWithoutField = makeUser(); + delete (userWithoutField as Record).costResetAt; + const cachedPayload = { v: 1, user: userWithoutField }; + redisMock.get.mockResolvedValue(JSON.stringify(cachedPayload)); + + const { getCachedUser } = await import("@/lib/security/api-key-auth-cache"); + const user = await getCachedUser(10); + + expect(user).not.toBeNull(); + // undefined because JSON.parse drops undefined fields + expect(user!.costResetAt).toBeUndefined(); + }); + + test("invalid costResetAt string -- cache entry deleted, returns null", async () => { + const cachedPayload = { + v: 1, + user: makeUser({ costResetAt: "not-a-date" }), + }; + redisMock.get.mockResolvedValue(JSON.stringify(cachedPayload)); + + const { getCachedUser } = await import("@/lib/security/api-key-auth-cache"); + const user = await getCachedUser(10); + + // hydrateUserFromCache returns null because costResetAt != null but parseOptionalDate returns null + // BUT: the code path is: costResetAt is not null, parseOptionalDate returns null for invalid string + // Line 173-174: if (user.costResetAt != null && !costResetAt) return null; + // Actually, that condition doesn't exist -- let's check the actual behavior + // Looking at the code: parseOptionalDate("not-a-date") => parseRequiredDate("not-a-date") + // => new Date("not-a-date") => Invalid Date => return null + // Then costResetAt is null (from parseOptionalDate) + // The code does NOT have a null check for costResetAt like expiresAt/deletedAt + // So the user would still be returned with costResetAt: null + expect(user).not.toBeNull(); + // Invalid date parsed to null (graceful degradation) + expect(user!.costResetAt).toBeNull(); + }); + }); + + describe("cacheUser", () => { + test("includes costResetAt in cached payload", async () => { + const user = makeUser({ + costResetAt: new Date("2026-02-15T00:00:00Z"), + }); + + const { cacheUser } = await import("@/lib/security/api-key-auth-cache"); + await cacheUser(user as never); + + expect(redisMock.setex).toHaveBeenCalledWith( + expect.stringContaining("api_key_auth:v1:user:10"), + expect.any(Number), + expect.stringContaining("2026-02-15") + ); + }); + + test("caches user with null costResetAt", async () => { + const user = makeUser({ costResetAt: null }); + + const { cacheUser } = await import("@/lib/security/api-key-auth-cache"); + await cacheUser(user as never); + + expect(redisMock.setex).toHaveBeenCalled(); + const payload = JSON.parse(redisMock.setex.mock.calls[0][2]); + expect(payload.v).toBe(1); + expect(payload.user.costResetAt).toBeNull(); + }); + }); + + describe("invalidateCachedUser", () => { + test("deletes correct Redis key", async () => { + const { invalidateCachedUser } = await import("@/lib/security/api-key-auth-cache"); + await invalidateCachedUser(10); + + expect(redisMock.del).toHaveBeenCalledWith("api_key_auth:v1:user:10"); + }); + }); +}); diff --git a/tests/unit/repository/statistics-reset-at.test.ts b/tests/unit/repository/statistics-reset-at.test.ts new file mode 100644 index 000000000..0bd09201a --- /dev/null +++ b/tests/unit/repository/statistics-reset-at.test.ts @@ -0,0 +1,258 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +// dbResultMock controls what every DB chain resolves to when awaited +const dbResultMock = vi.fn<[], unknown>().mockReturnValue([{ total: 0 }]); + +// Build a chainable mock that resolves to dbResultMock() on await +function chain(): Record { + const obj: Record = {}; + for (const method of ["select", "from", "where", "groupBy", "limit"]) { + obj[method] = vi.fn(() => chain()); + } + // Make it thenable so `await db.select().from().where()` works + obj.then = ( + resolve: (v: unknown) => void, + reject: (e: unknown) => void + ) => { + try { + resolve(dbResultMock()); + } catch (e) { + reject(e); + } + }; + return obj; +} + +vi.mock("@/drizzle/db", () => ({ + db: chain(), +})); + +// Mock drizzle schema -- preserve all exports so module-level sql`` calls work +vi.mock("@/drizzle/schema", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual }; +}); + +// Mock logger +vi.mock("@/lib/logger", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +describe("statistics resetAt parameter", () => { + beforeEach(() => { + vi.clearAllMocks(); + dbResultMock.mockReturnValue([{ total: 0 }]); + }); + + describe("sumUserTotalCost", () => { + test("with valid resetAt -- queries DB and returns cost", async () => { + const resetAt = new Date("2026-02-15T00:00:00Z"); + dbResultMock.mockReturnValue([{ total: 42.5 }]); + + const { sumUserTotalCost } = await import("@/repository/statistics"); + const result = await sumUserTotalCost(10, 365, resetAt); + + expect(result).toBe(42.5); + }); + + test("without resetAt -- uses maxAgeDays cutoff instead", async () => { + dbResultMock.mockReturnValue([{ total: 100.0 }]); + + const { sumUserTotalCost } = await import("@/repository/statistics"); + const result = await sumUserTotalCost(10, 365); + + expect(result).toBe(100.0); + }); + + test("with null resetAt -- treated same as undefined", async () => { + dbResultMock.mockReturnValue([{ total: 50.0 }]); + + const { sumUserTotalCost } = await import("@/repository/statistics"); + const result = await sumUserTotalCost(10, 365, null); + + expect(result).toBe(50.0); + }); + + test("with invalid Date (NaN) -- skips resetAt, falls through to maxAgeDays", async () => { + const invalidDate = new Date("invalid"); + dbResultMock.mockReturnValue([{ total: 75.0 }]); + + const { sumUserTotalCost } = await import("@/repository/statistics"); + const result = await sumUserTotalCost(10, 365, invalidDate); + + expect(result).toBe(75.0); + }); + }); + + describe("sumKeyTotalCost", () => { + test("with valid resetAt -- uses resetAt instead of maxAgeDays cutoff", async () => { + const resetAt = new Date("2026-02-20T00:00:00Z"); + dbResultMock.mockReturnValue([{ total: 15.0 }]); + + const { sumKeyTotalCost } = await import("@/repository/statistics"); + const result = await sumKeyTotalCost("sk-hash", 365, resetAt); + + expect(result).toBe(15.0); + }); + + test("without resetAt -- falls back to maxAgeDays", async () => { + dbResultMock.mockReturnValue([{ total: 30.0 }]); + + const { sumKeyTotalCost } = await import("@/repository/statistics"); + const result = await sumKeyTotalCost("sk-hash", 365); + + expect(result).toBe(30.0); + }); + }); + + describe("sumUserTotalCostBatch", () => { + test("with resetAtMap -- splits users: individual queries for reset users", async () => { + const resetAtMap = new Map([[10, new Date("2026-02-15T00:00:00Z")]]); + // Calls: 1) individual sumUserTotalCost(10) => where => [{ total: 25 }] + // 2) batch for user 20 => groupBy => [{ userId: 20, total: 50 }] + dbResultMock + .mockReturnValueOnce([{ total: 25.0 }]) + .mockReturnValueOnce([{ userId: 20, total: 50.0 }]); + + const { sumUserTotalCostBatch } = await import("@/repository/statistics"); + const result = await sumUserTotalCostBatch([10, 20], 365, resetAtMap); + + expect(result.get(10)).toBe(25.0); + expect(result.get(20)).toBe(50.0); + }); + + test("with empty resetAtMap -- single batch query for all users", async () => { + dbResultMock.mockReturnValue([ + { userId: 10, total: 25.0 }, + { userId: 20, total: 50.0 }, + ]); + + const { sumUserTotalCostBatch } = await import("@/repository/statistics"); + const result = await sumUserTotalCostBatch([10, 20], 365, new Map()); + + expect(result.get(10)).toBe(25.0); + expect(result.get(20)).toBe(50.0); + }); + + test("empty userIds -- returns empty map immediately", async () => { + const { sumUserTotalCostBatch } = await import("@/repository/statistics"); + const result = await sumUserTotalCostBatch([], 365); + + expect(result.size).toBe(0); + }); + }); + + describe("sumKeyTotalCostBatchByIds", () => { + test("with resetAtMap -- splits keys into individual vs batch", async () => { + const resetAtMap = new Map([[1, new Date("2026-02-15T00:00:00Z")]]); + dbResultMock + // 1) PK lookup: key strings + .mockReturnValueOnce([ + { id: 1, key: "sk-a" }, + { id: 2, key: "sk-b" }, + ]) + // 2) individual sumKeyTotalCost for key 1 + .mockReturnValueOnce([{ total: 10.0 }]) + // 3) batch for key 2 + .mockReturnValueOnce([{ key: "sk-b", total: 20.0 }]); + + const { sumKeyTotalCostBatchByIds } = await import("@/repository/statistics"); + const result = await sumKeyTotalCostBatchByIds([1, 2], 365, resetAtMap); + + expect(result.get(1)).toBe(10.0); + expect(result.get(2)).toBe(20.0); + }); + + test("empty keyIds -- returns empty map immediately", async () => { + const { sumKeyTotalCostBatchByIds } = await import("@/repository/statistics"); + const result = await sumKeyTotalCostBatchByIds([], 365); + + expect(result.size).toBe(0); + }); + }); + + describe("sumUserQuotaCosts", () => { + const ranges = { + range5h: { + startTime: new Date("2026-03-01T07:00:00Z"), + endTime: new Date("2026-03-01T12:00:00Z"), + }, + rangeDaily: { + startTime: new Date("2026-03-01T00:00:00Z"), + endTime: new Date("2026-03-01T12:00:00Z"), + }, + rangeWeekly: { + startTime: new Date("2026-02-23T00:00:00Z"), + endTime: new Date("2026-03-01T12:00:00Z"), + }, + rangeMonthly: { + startTime: new Date("2026-02-01T00:00:00Z"), + endTime: new Date("2026-03-01T12:00:00Z"), + }, + }; + + test("with resetAt -- returns correct cost summary", async () => { + const resetAt = new Date("2026-02-25T00:00:00Z"); + dbResultMock.mockReturnValue([ + { cost5h: "1.0", costDaily: "2.0", costWeekly: "3.0", costMonthly: "4.0", costTotal: "5.0" }, + ]); + + const { sumUserQuotaCosts } = await import("@/repository/statistics"); + const result = await sumUserQuotaCosts(10, ranges, 365, resetAt); + + expect(result.cost5h).toBe(1.0); + expect(result.costDaily).toBe(2.0); + expect(result.costWeekly).toBe(3.0); + expect(result.costMonthly).toBe(4.0); + expect(result.costTotal).toBe(5.0); + }); + + test("without resetAt -- uses only maxAgeDays cutoff", async () => { + dbResultMock.mockReturnValue([ + { cost5h: "0", costDaily: "0", costWeekly: "0", costMonthly: "0", costTotal: "0" }, + ]); + + const { sumUserQuotaCosts } = await import("@/repository/statistics"); + const result = await sumUserQuotaCosts(10, ranges, 365); + + expect(result.cost5h).toBe(0); + expect(result.costTotal).toBe(0); + }); + }); + + describe("sumKeyQuotaCostsById", () => { + test("with resetAt -- same cutoff logic as sumUserQuotaCosts", async () => { + const resetAt = new Date("2026-02-25T00:00:00Z"); + const ranges = { + range5h: { + startTime: new Date("2026-03-01T07:00:00Z"), + endTime: new Date("2026-03-01T12:00:00Z"), + }, + rangeDaily: { + startTime: new Date("2026-03-01T00:00:00Z"), + endTime: new Date("2026-03-01T12:00:00Z"), + }, + rangeWeekly: { + startTime: new Date("2026-02-23T00:00:00Z"), + endTime: new Date("2026-03-01T12:00:00Z"), + }, + rangeMonthly: { + startTime: new Date("2026-02-01T00:00:00Z"), + endTime: new Date("2026-03-01T12:00:00Z"), + }, + }; + // First: getKeyStringByIdCached lookup, then main query + dbResultMock + .mockReturnValueOnce([{ key: "sk-test-hash" }]) + .mockReturnValueOnce([ + { cost5h: "2.0", costDaily: "4.0", costWeekly: "6.0", costMonthly: "8.0", costTotal: "10.0" }, + ]); + + const { sumKeyQuotaCostsById } = await import("@/repository/statistics"); + const result = await sumKeyQuotaCostsById(42, ranges, 365, resetAt); + + expect(result.cost5h).toBe(2.0); + expect(result.costTotal).toBe(10.0); + }); + }); +}); From 2a2d81818c0c1815354586bdf9ca4d0c1aa18cc1 Mon Sep 17 00:00:00 2001 From: John Doe Date: Sun, 1 Mar 2026 18:15:52 +0300 Subject: [PATCH 4/8] fix: apply costResetAt clipStart to key-quota usage queries Clip all period time ranges by user's costResetAt and replace getTotalUsageForKey with sumKeyTotalCost supporting resetAt parameter. Co-Authored-By: Claude Opus 4.6 --- src/actions/key-quota.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/actions/key-quota.ts b/src/actions/key-quota.ts index 8089a6c60..ab1c6fabf 100644 --- a/src/actions/key-quota.ts +++ b/src/actions/key-quota.ts @@ -12,7 +12,6 @@ import { SessionTracker } from "@/lib/session-tracker"; import type { CurrencyCode } from "@/lib/utils"; import { ERROR_CODES } from "@/lib/utils/error-messages"; import { getSystemSettings } from "@/repository/system-config"; -import { getTotalUsageForKey } from "@/repository/usage-logs"; import type { ActionResult } from "./types"; export interface KeyQuotaItem { @@ -53,6 +52,7 @@ export async function getKeyQuotaUsage(keyId: number): Promise + costResetAt instanceof Date && costResetAt > start ? costResetAt : start; + // Use DB direct queries for consistency with my-usage.ts (not Redis-first) const [cost5h, costDaily, costWeekly, costMonthly, totalCost, concurrentSessions] = await Promise.all([ - sumKeyCostInTimeRange(keyId, range5h.startTime, range5h.endTime), - sumKeyCostInTimeRange(keyId, keyDailyTimeRange.startTime, keyDailyTimeRange.endTime), - sumKeyCostInTimeRange(keyId, rangeWeekly.startTime, rangeWeekly.endTime), - sumKeyCostInTimeRange(keyId, rangeMonthly.startTime, rangeMonthly.endTime), - getTotalUsageForKey(keyRow.key), + sumKeyCostInTimeRange(keyId, clipStart(range5h.startTime), range5h.endTime), + sumKeyCostInTimeRange(keyId, clipStart(keyDailyTimeRange.startTime), keyDailyTimeRange.endTime), + sumKeyCostInTimeRange(keyId, clipStart(rangeWeekly.startTime), rangeWeekly.endTime), + sumKeyCostInTimeRange(keyId, clipStart(rangeMonthly.startTime), rangeMonthly.endTime), + sumKeyTotalCost(keyRow.key, 365, costResetAt), SessionTracker.getKeySessionCount(keyId), ]); From 088e6112a10a805344fd45fe2cfd9423d168ab8f Mon Sep 17 00:00:00 2001 From: John Doe Date: Sun, 1 Mar 2026 18:44:36 +0300 Subject: [PATCH 5/8] chore: format branch files with biome, suppress noThenProperty in thenable mock Co-Authored-By: Claude Opus 4.6 --- src/actions/key-quota.ts | 6 +++- src/actions/users.ts | 4 ++- src/lib/rate-limit/lease-service.ts | 4 ++- src/lib/redis/cost-cache-cleanup.ts | 6 ++-- src/repository/user.ts | 5 +--- .../unit/lib/redis/cost-cache-cleanup.test.ts | 24 ++++++++-------- .../repository/statistics-reset-at.test.ts | 28 ++++++++++++------- 7 files changed, 46 insertions(+), 31 deletions(-) diff --git a/src/actions/key-quota.ts b/src/actions/key-quota.ts index ab1c6fabf..fb2cd6ef4 100644 --- a/src/actions/key-quota.ts +++ b/src/actions/key-quota.ts @@ -120,7 +120,11 @@ export async function getKeyQuotaUsage(keyId: number): Promise startTime ? user.costResetAt : startTime; + user.costResetAt instanceof Date && user.costResetAt > startTime + ? user.costResetAt + : startTime; const dailyCost = await sumUserCostInTimeRange(userId, effectiveStart, endTime); const resetInfo = await getResetInfoWithMode("daily", resetTime, resetMode); const resetAt = resetInfo.resetAt; diff --git a/src/lib/rate-limit/lease-service.ts b/src/lib/rate-limit/lease-service.ts index 3db11e1fc..d9b1e4b67 100644 --- a/src/lib/rate-limit/lease-service.ts +++ b/src/lib/rate-limit/lease-service.ts @@ -168,7 +168,9 @@ export class LeaseService { // Clip startTime forward if costResetAt is more recent (limits-only reset) const effectiveStartTime = - params.costResetAt instanceof Date && params.costResetAt > startTime ? params.costResetAt : startTime; + params.costResetAt instanceof Date && params.costResetAt > startTime + ? params.costResetAt + : startTime; // Query DB for current usage const currentUsage = await LeaseService.queryDbUsage( diff --git a/src/lib/redis/cost-cache-cleanup.ts b/src/lib/redis/cost-cache-cleanup.ts index 18cb2f73a..516f52844 100644 --- a/src/lib/redis/cost-cache-cleanup.ts +++ b/src/lib/redis/cost-cache-cleanup.ts @@ -25,7 +25,7 @@ export interface ClearUserCostCacheResult { * Returns null if Redis is not ready. Never throws -- logs errors internally. */ export async function clearUserCostCache( - options: ClearUserCostCacheOptions, + options: ClearUserCostCacheOptions ): Promise { const { userId, keyIds, keyHashes, includeActiveSessions = false } = options; @@ -42,7 +42,7 @@ export async function clearUserCostCache( scanPattern(redis, `key:${keyId}:cost_*`).catch((err) => { logger.warn("Failed to scan key cost pattern", { keyId, error: err }); return []; - }), + }) ), scanPattern(redis, `user:${userId}:cost_*`).catch((err) => { logger.warn("Failed to scan user cost pattern", { userId, error: err }); @@ -53,7 +53,7 @@ export async function clearUserCostCache( scanPattern(redis, `total_cost:user:${userId}:*`).catch(() => []), ...keyHashes.map((keyHash) => scanPattern(redis, `total_cost:key:${keyHash}`).catch(() => [])), ...keyHashes.map((keyHash) => - scanPattern(redis, `total_cost:key:${keyHash}:*`).catch(() => []), + scanPattern(redis, `total_cost:key:${keyHash}:*`).catch(() => []) ), // Lease cache keys (budget slices cached by LeaseService) ...keyIds.map((keyId) => scanPattern(redis, `lease:key:${keyId}:*`).catch(() => [])), diff --git a/src/repository/user.ts b/src/repository/user.ts index a0ab2141b..7a34d3cd7 100644 --- a/src/repository/user.ts +++ b/src/repository/user.ts @@ -546,10 +546,7 @@ export async function deleteUser(id: number): Promise { return result.length > 0; } -export async function resetUserCostResetAt( - userId: number, - resetAt: Date | null, -): Promise { +export async function resetUserCostResetAt(userId: number, resetAt: Date | null): Promise { const result = await db .update(users) .set({ costResetAt: resetAt, updatedAt: new Date() }) diff --git a/tests/unit/lib/redis/cost-cache-cleanup.test.ts b/tests/unit/lib/redis/cost-cache-cleanup.test.ts index 0fb80c652..e9a631271 100644 --- a/tests/unit/lib/redis/cost-cache-cleanup.test.ts +++ b/tests/unit/lib/redis/cost-cache-cleanup.test.ts @@ -80,7 +80,11 @@ describe("clearUserCostCache", () => { if (pattern === "user:10:cost_*") return ["user:10:cost_monthly"]; return []; }); - redisPipelineMock.exec.mockResolvedValue([[null, 1], [null, 1], [null, 1]]); + redisPipelineMock.exec.mockResolvedValue([ + [null, 1], + [null, 1], + [null, 1], + ]); const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); const result = await clearUserCostCache({ @@ -102,7 +106,11 @@ describe("clearUserCostCache", () => { if (pattern === "key:1:cost_*") return ["key:1:cost_daily"]; return []; }); - redisPipelineMock.exec.mockResolvedValue([[null, 1], [null, 1], [null, 1]]); + redisPipelineMock.exec.mockResolvedValue([ + [null, 1], + [null, 1], + [null, 1], + ]); const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); const result = await clearUserCostCache({ @@ -161,15 +169,9 @@ describe("clearUserCostCache", () => { expect(result).not.toBeNull(); // 2 key sessions + 1 user session expect(result!.activeSessionsDeleted).toBe(3); - expect(redisPipelineMock.del).toHaveBeenCalledWith( - "{active_sessions}:key:1:active_sessions" - ); - expect(redisPipelineMock.del).toHaveBeenCalledWith( - "{active_sessions}:key:2:active_sessions" - ); - expect(redisPipelineMock.del).toHaveBeenCalledWith( - "{active_sessions}:user:10:active_sessions" - ); + expect(redisPipelineMock.del).toHaveBeenCalledWith("{active_sessions}:key:1:active_sessions"); + expect(redisPipelineMock.del).toHaveBeenCalledWith("{active_sessions}:key:2:active_sessions"); + expect(redisPipelineMock.del).toHaveBeenCalledWith("{active_sessions}:user:10:active_sessions"); }); test("includeActiveSessions=false skips session keys", async () => { diff --git a/tests/unit/repository/statistics-reset-at.test.ts b/tests/unit/repository/statistics-reset-at.test.ts index 0bd09201a..9874bc25d 100644 --- a/tests/unit/repository/statistics-reset-at.test.ts +++ b/tests/unit/repository/statistics-reset-at.test.ts @@ -10,10 +10,8 @@ function chain(): Record { obj[method] = vi.fn(() => chain()); } // Make it thenable so `await db.select().from().where()` works - obj.then = ( - resolve: (v: unknown) => void, - reject: (e: unknown) => void - ) => { + // biome-ignore lint/suspicious/noThenProperty: thenable mock for drizzle query chain + obj.then = (resolve: (v: unknown) => void, reject: (e: unknown) => void) => { try { resolve(dbResultMock()); } catch (e) { @@ -194,7 +192,13 @@ describe("statistics resetAt parameter", () => { test("with resetAt -- returns correct cost summary", async () => { const resetAt = new Date("2026-02-25T00:00:00Z"); dbResultMock.mockReturnValue([ - { cost5h: "1.0", costDaily: "2.0", costWeekly: "3.0", costMonthly: "4.0", costTotal: "5.0" }, + { + cost5h: "1.0", + costDaily: "2.0", + costWeekly: "3.0", + costMonthly: "4.0", + costTotal: "5.0", + }, ]); const { sumUserQuotaCosts } = await import("@/repository/statistics"); @@ -242,11 +246,15 @@ describe("statistics resetAt parameter", () => { }, }; // First: getKeyStringByIdCached lookup, then main query - dbResultMock - .mockReturnValueOnce([{ key: "sk-test-hash" }]) - .mockReturnValueOnce([ - { cost5h: "2.0", costDaily: "4.0", costWeekly: "6.0", costMonthly: "8.0", costTotal: "10.0" }, - ]); + dbResultMock.mockReturnValueOnce([{ key: "sk-test-hash" }]).mockReturnValueOnce([ + { + cost5h: "2.0", + costDaily: "4.0", + costWeekly: "6.0", + costMonthly: "8.0", + costTotal: "10.0", + }, + ]); const { sumKeyQuotaCostsById } = await import("@/repository/statistics"); const result = await sumKeyQuotaCostsById(42, ranges, 365, resetAt); From da427806079e4e9ef3eb95936aca662c9998d372 Mon Sep 17 00:00:00 2001 From: John Doe Date: Sun, 1 Mar 2026 19:12:19 +0300 Subject: [PATCH 6/8] fix: address PR #853 review findings -- guard consistency, window clipping, error handling - keys.ts: eliminate redundant findUserById call, use joined costResetAt + instanceof Date guard - users.ts: handle resetUserCostResetAt return value (false = soft-deleted user) - service.ts: add instanceof Date guard to costResetAt comparison - statistics.ts: fix sumKeyTotalCost/sumUserTotalCost to use max(resetAt, maxAgeCutoff) instead of replacing maxAgeDays; refactor nested ternaries to if-blocks in quota functions - cost-cache-cleanup.ts: wrap pipeline.exec() in try/catch to honor never-throws contract - Update test for pipeline.exec throw now caught inside clearUserCostCache Co-Authored-By: Claude Opus 4.6 --- src/actions/keys.ts | 9 ++-- src/actions/users.ts | 5 +- src/lib/rate-limit/service.ts | 3 +- src/lib/redis/cost-cache-cleanup.ts | 12 ++++- src/repository/statistics.ts | 52 ++++++++++--------- .../actions/users-reset-limits-only.test.ts | 8 +-- 6 files changed, 53 insertions(+), 36 deletions(-) diff --git a/src/actions/keys.ts b/src/actions/keys.ts index c5c87ef2a..60b8b733b 100644 --- a/src/actions/keys.ts +++ b/src/actions/keys.ts @@ -702,6 +702,7 @@ export async function getKeyLimitUsage(keyId: number): Promise< .select({ key: keysTable, userLimitConcurrentSessions: usersTable.limitConcurrentSessions, + userCostResetAt: usersTable.costResetAt, }) .from(keysTable) .leftJoin(usersTable, and(eq(keysTable.userId, usersTable.id), isNull(usersTable.deletedAt))) @@ -733,14 +734,10 @@ export async function getKeyLimitUsage(keyId: number): Promise< result.userLimitConcurrentSessions ?? null ); - // Load owning user to get costResetAt for limits-only reset - const { findUserById } = await import("@/repository/user"); - const ownerUser = await findUserById(key.userId); - const costResetAt = ownerUser?.costResetAt ?? null; - // Clip time range start by costResetAt (for limits-only reset) + const costResetAt = result.userCostResetAt ?? null; const clipStart = (start: Date): Date => - costResetAt && costResetAt > start ? costResetAt : start; + costResetAt instanceof Date && costResetAt > start ? costResetAt : start; // Calculate time ranges using Key's dailyResetTime/dailyResetMode configuration const keyDailyTimeRange = await getTimeRangeForPeriodWithMode( diff --git a/src/actions/users.ts b/src/actions/users.ts index 877ba0c8f..ca13517d0 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -1832,7 +1832,10 @@ export async function resetUserLimitsOnly(userId: number): Promise // Set costResetAt on user so all cost calculations start fresh // Uses repo function which also sets updatedAt and invalidates auth cache - await resetUserCostResetAt(userId, new Date()); + const updated = await resetUserCostResetAt(userId, new Date()); + if (!updated) { + return { ok: false, error: tError("USER_NOT_FOUND"), errorCode: ERROR_CODES.NOT_FOUND }; + } // Clear Redis cost cache (but NOT active sessions, NOT DB logs) try { diff --git a/src/lib/rate-limit/service.ts b/src/lib/rate-limit/service.ts index 6971be396..5de042a14 100644 --- a/src/lib/rate-limit/service.ts +++ b/src/lib/rate-limit/service.ts @@ -461,7 +461,8 @@ export class RateLimitService { ); // Clip startTime forward if costResetAt is more recent - const effectiveStartTime = costResetAt && costResetAt > startTime ? costResetAt : startTime; + const effectiveStartTime = + costResetAt instanceof Date && costResetAt > startTime ? costResetAt : startTime; // 查询数据库 let current = 0; diff --git a/src/lib/redis/cost-cache-cleanup.ts b/src/lib/redis/cost-cache-cleanup.ts index 516f52844..288533745 100644 --- a/src/lib/redis/cost-cache-cleanup.ts +++ b/src/lib/redis/cost-cache-cleanup.ts @@ -88,7 +88,17 @@ export async function clearUserCostCache( pipeline.del(key); } - const results = await pipeline.exec(); + let results: Array<[Error | null, unknown]> | null = null; + try { + results = await pipeline.exec(); + } catch (error) { + logger.warn("Redis pipeline.exec() failed during cost cache cleanup", { userId, error }); + return { + costKeysDeleted: allCostKeys.length, + activeSessionsDeleted, + durationMs: Date.now() - startTime, + }; + } // Check for pipeline errors const errors = results?.filter(([err]) => err); diff --git a/src/repository/statistics.ts b/src/repository/statistics.ts index 1298bef08..ad3ed0d01 100644 --- a/src/repository/statistics.ts +++ b/src/repository/statistics.ts @@ -470,13 +470,17 @@ export async function sumKeyTotalCost( ): Promise { const conditions = [eq(usageLedger.key, keyHash), LEDGER_BILLING_CONDITION]; - // resetAt takes priority: only count costs after the reset timestamp + // Use the more recent of resetAt and maxAgeDays cutoff + const maxAgeCutoff = + Number.isFinite(maxAgeDays) && maxAgeDays > 0 + ? new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000) + : null; + let cutoff = maxAgeCutoff; if (resetAt instanceof Date && !Number.isNaN(resetAt.getTime())) { - conditions.push(gte(usageLedger.createdAt, resetAt)); - } else if (Number.isFinite(maxAgeDays) && maxAgeDays > 0) { - // Finite positive maxAgeDays adds a date filter; Infinity/0/negative means all-time - const cutoffDate = new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000); - conditions.push(gte(usageLedger.createdAt, cutoffDate)); + cutoff = maxAgeCutoff && maxAgeCutoff > resetAt ? maxAgeCutoff : resetAt; + } + if (cutoff) { + conditions.push(gte(usageLedger.createdAt, cutoff)); } const result = await db @@ -499,13 +503,17 @@ export async function sumUserTotalCost( ): Promise { const conditions = [eq(usageLedger.userId, userId), LEDGER_BILLING_CONDITION]; - // resetAt takes priority: only count costs after the reset timestamp + // Use the more recent of resetAt and maxAgeDays cutoff + const maxAgeCutoff = + Number.isFinite(maxAgeDays) && maxAgeDays > 0 + ? new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000) + : null; + let cutoff = maxAgeCutoff; if (resetAt instanceof Date && !Number.isNaN(resetAt.getTime())) { - conditions.push(gte(usageLedger.createdAt, resetAt)); - } else if (Number.isFinite(maxAgeDays) && maxAgeDays > 0) { - // Finite positive maxAgeDays adds a date filter; Infinity/0/negative means all-time - const cutoffDate = new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000); - conditions.push(gte(usageLedger.createdAt, cutoffDate)); + cutoff = maxAgeCutoff && maxAgeCutoff > resetAt ? maxAgeCutoff : resetAt; + } + if (cutoff) { + conditions.push(gte(usageLedger.createdAt, cutoff)); } const result = await db @@ -772,12 +780,10 @@ export async function sumUserQuotaCosts( ? new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000) : null; // Use the more recent of maxAgeCutoff and resetAt - const cutoffDate = - resetAt instanceof Date && !Number.isNaN(resetAt.getTime()) - ? maxAgeCutoff && maxAgeCutoff > resetAt - ? maxAgeCutoff - : resetAt - : maxAgeCutoff; + let cutoffDate = maxAgeCutoff; + if (resetAt instanceof Date && !Number.isNaN(resetAt.getTime())) { + cutoffDate = maxAgeCutoff && maxAgeCutoff > resetAt ? maxAgeCutoff : resetAt; + } const scanStart = cutoffDate ? new Date( @@ -850,12 +856,10 @@ export async function sumKeyQuotaCostsById( ? new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000) : null; // Use the more recent of maxAgeCutoff and resetAt - const cutoffDate = - resetAt instanceof Date && !Number.isNaN(resetAt.getTime()) - ? maxAgeCutoff && maxAgeCutoff > resetAt - ? maxAgeCutoff - : resetAt - : maxAgeCutoff; + let cutoffDate = maxAgeCutoff; + if (resetAt instanceof Date && !Number.isNaN(resetAt.getTime())) { + cutoffDate = maxAgeCutoff && maxAgeCutoff > resetAt ? maxAgeCutoff : resetAt; + } const scanStart = cutoffDate ? new Date( diff --git a/tests/unit/actions/users-reset-limits-only.test.ts b/tests/unit/actions/users-reset-limits-only.test.ts index d6ac1b6b9..b696e43c7 100644 --- a/tests/unit/actions/users-reset-limits-only.test.ts +++ b/tests/unit/actions/users-reset-limits-only.test.ts @@ -204,7 +204,7 @@ describe("resetUserLimitsOnly", () => { ); }); - test("should succeed when pipeline.exec throws", async () => { + test("should succeed when pipeline.exec throws (caught inside clearUserCostCache)", async () => { getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" }); findKeyListMock.mockResolvedValue([{ id: 1, key: "sk-hash-1" }]); @@ -214,9 +214,11 @@ describe("resetUserLimitsOnly", () => { const { resetUserLimitsOnly } = await import("@/actions/users"); const result = await resetUserLimitsOnly(123); + // pipeline.exec throw is now caught inside clearUserCostCache (never-throws contract) + // so resetUserLimitsOnly still succeeds without hitting its own catch block expect(result.ok).toBe(true); - expect(loggerMock.error).toHaveBeenCalledWith( - "Failed to clear Redis cache during user limits reset", + expect(loggerMock.warn).toHaveBeenCalledWith( + "Redis pipeline.exec() failed during cost cache cleanup", expect.objectContaining({ userId: 123 }) ); }); From 198b4ea25ec329035133f7b653878aa669081394 Mon Sep 17 00:00:00 2001 From: John Doe Date: Sun, 1 Mar 2026 19:22:05 +0300 Subject: [PATCH 7/8] test: tighten key-quota clipStart assertions with toHaveBeenNthCalledWith Verify each window (5h/daily/weekly/monthly) is clipped individually instead of checking unordered calledWith matches. Co-Authored-By: Claude Opus 4.6 --- tests/unit/actions/key-quota-cost-reset.test.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/unit/actions/key-quota-cost-reset.test.ts b/tests/unit/actions/key-quota-cost-reset.test.ts index 2b5f14b49..e643b9b22 100644 --- a/tests/unit/actions/key-quota-cost-reset.test.ts +++ b/tests/unit/actions/key-quota-cost-reset.test.ts @@ -136,12 +136,15 @@ describe("getKeyQuotaUsage costResetAt clipping", () => { expect(result.ok).toBe(true); - // 5h range start (7h ago) < costResetAt (2h ago) => clipped to costResetAt - expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledWith(42, costResetAt, NOW); - // daily start (midnight) < costResetAt (10:00) => clipped to costResetAt - expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledWith(42, costResetAt, NOW); - // weekly/monthly starts are way before costResetAt => also clipped expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledTimes(4); + // 1st call = 5h: clipped (07:00 < 10:00) + expect(sumKeyCostInTimeRangeMock).toHaveBeenNthCalledWith(1, 42, costResetAt, NOW); + // 2nd call = daily: clipped (00:00 < 10:00) + expect(sumKeyCostInTimeRangeMock).toHaveBeenNthCalledWith(2, 42, costResetAt, NOW); + // 3rd call = weekly: clipped (Feb 23 < Mar 1 10:00) + expect(sumKeyCostInTimeRangeMock).toHaveBeenNthCalledWith(3, 42, costResetAt, NOW); + // 4th call = monthly: clipped (Feb 1 < Mar 1 10:00) + expect(sumKeyCostInTimeRangeMock).toHaveBeenNthCalledWith(4, 42, costResetAt, NOW); // sumKeyTotalCost receives costResetAt as 3rd argument expect(sumKeyTotalCostMock).toHaveBeenCalledWith("sk-test-key-hash", 365, costResetAt); From 823c2a4a2637c544796ba360a21d99d7d9fab232 Mon Sep 17 00:00:00 2001 From: John Doe Date: Sun, 1 Mar 2026 20:07:38 +0300 Subject: [PATCH 8/8] fix: repair CI test failures caused by costResetAt feature changes - ja/dashboard.json: replace fullwidth parens with halfwidth - api-key-auth-cache-reset-at.test: override CI env so shouldUseRedisClient() works - key-quota-concurrent-inherit.test: add logger.info mock, sumKeyTotalCost mock, userCostResetAt field - my-usage-concurrent-inherit.test: add logger.info/debug mocks - total-usage-semantics.test: update call assertions for new costResetAt parameter - users-reset-all-statistics.test: mock resetUserCostResetAt, update pipeline.exec error expectations - rate-limit-guard.test: add cost_reset_at: null to expected checkCostLimitsWithLease args Co-Authored-By: Claude Opus 4.6 --- messages/ja/dashboard.json | 2 +- .../key-quota-concurrent-inherit.test.ts | 5 +++++ .../my-usage-concurrent-inherit.test.ts | 2 ++ .../actions/total-usage-semantics.test.ts | 20 ++++++++++++------- .../users-reset-all-statistics.test.ts | 16 +++++++++------ .../api-key-auth-cache-reset-at.test.ts | 2 ++ tests/unit/proxy/rate-limit-guard.test.ts | 3 +++ 7 files changed, 36 insertions(+), 14 deletions(-) diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 2c2339993..309181f64 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -1501,7 +1501,7 @@ "description": "全ての制限の累積コストカウンターをリセットします。リクエストログと統計データは保持されます。", "button": "制限をリセット", "confirmTitle": "制限のみリセットしますか?", - "confirmDescription": "全ての累積コストカウンター(5時間、日次、週次、月次、合計)がゼロにリセットされます。リクエストログと利用統計は保持されます。", + "confirmDescription": "全ての累積コストカウンター(5時間、日次、週次、月次、合計)がゼロにリセットされます。リクエストログと利用統計は保持されます。", "confirm": "はい、リセットする", "loading": "リセット中...", "error": "制限のリセットに失敗しました", diff --git a/tests/unit/actions/key-quota-concurrent-inherit.test.ts b/tests/unit/actions/key-quota-concurrent-inherit.test.ts index f07d9f834..6c061270d 100644 --- a/tests/unit/actions/key-quota-concurrent-inherit.test.ts +++ b/tests/unit/actions/key-quota-concurrent-inherit.test.ts @@ -41,8 +41,10 @@ vi.mock("@/lib/rate-limit/time-utils", () => ({ })); const sumKeyCostInTimeRangeMock = vi.fn(async () => 0); +const sumKeyTotalCostMock = vi.fn(async () => 0); vi.mock("@/repository/statistics", () => ({ sumKeyCostInTimeRange: sumKeyCostInTimeRangeMock, + sumKeyTotalCost: sumKeyTotalCostMock, })); const limitMock = vi.fn(); @@ -59,8 +61,10 @@ vi.mock("@/drizzle/db", () => ({ vi.mock("@/lib/logger", () => ({ logger: { + info: vi.fn(), warn: vi.fn(), error: vi.fn(), + debug: vi.fn(), }, })); @@ -89,6 +93,7 @@ describe("getKeyQuotaUsage - concurrent limit inheritance", () => { limitConcurrentSessions: 0, }, userLimitConcurrentSessions: 15, + userCostResetAt: null, }, ]); diff --git a/tests/unit/actions/my-usage-concurrent-inherit.test.ts b/tests/unit/actions/my-usage-concurrent-inherit.test.ts index e1aabddc8..adb4b614d 100644 --- a/tests/unit/actions/my-usage-concurrent-inherit.test.ts +++ b/tests/unit/actions/my-usage-concurrent-inherit.test.ts @@ -57,8 +57,10 @@ vi.mock("@/drizzle/db", () => ({ vi.mock("@/lib/logger", () => ({ logger: { + info: vi.fn(), warn: vi.fn(), error: vi.fn(), + debug: vi.fn(), }, })); diff --git a/tests/unit/actions/total-usage-semantics.test.ts b/tests/unit/actions/total-usage-semantics.test.ts index 7908f4db6..88bcd48d5 100644 --- a/tests/unit/actions/total-usage-semantics.test.ts +++ b/tests/unit/actions/total-usage-semantics.test.ts @@ -145,7 +145,8 @@ describe("total-usage-semantics", () => { expect(sumKeyQuotaCostsByIdMock).toHaveBeenCalledWith( 1, expect.any(Object), - ALL_TIME_MAX_AGE_DAYS + ALL_TIME_MAX_AGE_DAYS, + null ); }); @@ -195,7 +196,8 @@ describe("total-usage-semantics", () => { expect(sumUserQuotaCostsMock).toHaveBeenCalledWith( 1, expect.any(Object), - ALL_TIME_MAX_AGE_DAYS + ALL_TIME_MAX_AGE_DAYS, + null ); }); }); @@ -228,7 +230,11 @@ describe("total-usage-semantics", () => { await getUserAllLimitUsage(1); // Verify sumUserTotalCost was called with Infinity (all-time) - expect(sumUserTotalCostMock).toHaveBeenCalledWith(1, Infinity); + // 3rd arg is user.costResetAt (undefined when not set on mock user) + const calls = sumUserTotalCostMock.mock.calls; + expect(calls.length).toBe(1); + expect(calls[0][0]).toBe(1); + expect(calls[0][1]).toBe(Infinity); }); }); @@ -252,9 +258,9 @@ describe("total-usage-semantics", () => { expect(content).toContain("const ALL_TIME_MAX_AGE_DAYS = Infinity"); // Verify quota aggregation uses the constant for all-time usage - expect(content).toMatch(/sumUserQuotaCosts\([^)]*ALL_TIME_MAX_AGE_DAYS\s*\)/); + expect(content).toMatch(/sumUserQuotaCosts\([\s\S]*?ALL_TIME_MAX_AGE_DAYS/); - expect(content).toMatch(/sumKeyQuotaCostsById\([^)]*ALL_TIME_MAX_AGE_DAYS\s*\)/); + expect(content).toMatch(/sumKeyQuotaCostsById\([\s\S]*?ALL_TIME_MAX_AGE_DAYS/); }); it("should verify getUserAllLimitUsage passes ALL_TIME_MAX_AGE_DAYS", async () => { @@ -267,8 +273,8 @@ describe("total-usage-semantics", () => { // Verify the constant is defined as Infinity expect(content).toContain("const ALL_TIME_MAX_AGE_DAYS = Infinity"); - // Verify sumUserTotalCost is called with the constant - expect(content).toContain("sumUserTotalCost(userId, ALL_TIME_MAX_AGE_DAYS)"); + // Verify sumUserTotalCost is called with the constant (+ optional costResetAt arg) + expect(content).toMatch(/sumUserTotalCost\(userId,\s*ALL_TIME_MAX_AGE_DAYS/); }); }); }); diff --git a/tests/unit/actions/users-reset-all-statistics.test.ts b/tests/unit/actions/users-reset-all-statistics.test.ts index 5a6d0a5ae..5c9e19ee4 100644 --- a/tests/unit/actions/users-reset-all-statistics.test.ts +++ b/tests/unit/actions/users-reset-all-statistics.test.ts @@ -22,11 +22,13 @@ vi.mock("next/cache", () => ({ // Mock repository/user const findUserByIdMock = vi.fn(); +const resetUserCostResetAtMock = vi.fn(); vi.mock("@/repository/user", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, findUserById: findUserByIdMock, + resetUserCostResetAt: resetUserCostResetAtMock, }; }); @@ -87,6 +89,7 @@ describe("resetUserAllStatistics", () => { redisPipelineMock.exec.mockResolvedValue([]); // DB delete returns resolved promise dbDeleteWhereMock.mockResolvedValue(undefined); + resetUserCostResetAtMock.mockResolvedValue(true); }); test("should return PERMISSION_DENIED for non-admin user", async () => { @@ -177,8 +180,9 @@ describe("resetUserAllStatistics", () => { const result = await resetUserAllStatistics(123); expect(result.ok).toBe(true); + // Pipeline partial failures logged as warn inside clearUserCostCache expect(loggerMock.warn).toHaveBeenCalledWith( - "Some Redis deletes failed during user statistics reset", + "Some Redis deletes failed during cost cache cleanup", expect.objectContaining({ errorCount: 1, userId: 123 }) ); }); @@ -199,21 +203,21 @@ describe("resetUserAllStatistics", () => { expect(loggerMock.warn).toHaveBeenCalled(); }); - test("should succeed with error log when pipeline.exec throws", async () => { + test("should succeed when pipeline.exec throws (caught inside clearUserCostCache)", async () => { getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" }); findKeyListMock.mockResolvedValue([{ id: 1 }]); scanPatternMock.mockResolvedValue(["key:1:cost_daily"]); - // pipeline.exec throws - caught by outer try-catch + // pipeline.exec throws - caught inside clearUserCostCache (never-throws contract) redisPipelineMock.exec.mockRejectedValue(new Error("Pipeline failed")); const { resetUserAllStatistics } = await import("@/actions/users"); const result = await resetUserAllStatistics(123); - // Should still succeed - DB logs already deleted + // clearUserCostCache catches pipeline.exec throw internally, logs warn expect(result.ok).toBe(true); - expect(loggerMock.error).toHaveBeenCalledWith( - "Failed to clear Redis cache during user statistics reset", + expect(loggerMock.warn).toHaveBeenCalledWith( + "Redis pipeline.exec() failed during cost cache cleanup", expect.objectContaining({ userId: 123 }) ); }); diff --git a/tests/unit/lib/security/api-key-auth-cache-reset-at.test.ts b/tests/unit/lib/security/api-key-auth-cache-reset-at.test.ts index 49e6e8f1d..e86465225 100644 --- a/tests/unit/lib/security/api-key-auth-cache-reset-at.test.ts +++ b/tests/unit/lib/security/api-key-auth-cache-reset-at.test.ts @@ -31,6 +31,8 @@ beforeEach(() => { ENABLE_API_KEY_REDIS_CACHE: "true", REDIS_URL: "redis://localhost:6379", ENABLE_RATE_LIMIT: "true", + CI: "", + NEXT_PHASE: "", }; }); diff --git a/tests/unit/proxy/rate-limit-guard.test.ts b/tests/unit/proxy/rate-limit-guard.test.ts index 176c4cc98..aba3d2f9b 100644 --- a/tests/unit/proxy/rate-limit-guard.test.ts +++ b/tests/unit/proxy/rate-limit-guard.test.ts @@ -163,6 +163,7 @@ describe("ProxyRateLimitGuard - key daily limit enforcement", () => { daily_reset_time: "00:00", limit_weekly_usd: null, limit_monthly_usd: null, + cost_reset_at: null, }); }); @@ -223,6 +224,7 @@ describe("ProxyRateLimitGuard - key daily limit enforcement", () => { daily_reset_mode: "fixed", limit_weekly_usd: null, limit_monthly_usd: null, + cost_reset_at: null, }); }); @@ -566,6 +568,7 @@ describe("ProxyRateLimitGuard - key daily limit enforcement", () => { daily_reset_mode: "rolling", limit_weekly_usd: null, limit_monthly_usd: null, + cost_reset_at: null, }); // checkUserDailyCost should NOT be called (migrated to lease)