diff --git a/build/desktop-icon.png b/build/desktop-icon.png new file mode 100644 index 0000000..00a365e Binary files /dev/null and b/build/desktop-icon.png differ diff --git a/drizzle/0012_account_token_value_status.sql b/drizzle/0012_account_token_value_status.sql new file mode 100644 index 0000000..0e999eb --- /dev/null +++ b/drizzle/0012_account_token_value_status.sql @@ -0,0 +1 @@ +ALTER TABLE `account_tokens` ADD `value_status` text DEFAULT 'ready' NOT NULL; diff --git a/drizzle/meta/0012_snapshot.json b/drizzle/meta/0012_snapshot.json new file mode 100644 index 0000000..bfcee45 --- /dev/null +++ b/drizzle/meta/0012_snapshot.json @@ -0,0 +1,1892 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b1aee21b-b789-4e47-ab03-7ba93cc26951", + "prevId": "4bdb796c-cedc-4b81-8bfb-78c5e0bde8c0", + "tables": { + "account_tokens": { + "name": "account_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_group": { + "name": "token_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "value_status": { + "name": "value_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ready'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'manual'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "account_tokens_account_id_idx": { + "name": "account_tokens_account_id_idx", + "columns": [ + "account_id" + ], + "isUnique": false + }, + "account_tokens_account_enabled_idx": { + "name": "account_tokens_account_enabled_idx", + "columns": [ + "account_id", + "enabled" + ], + "isUnique": false + }, + "account_tokens_enabled_idx": { + "name": "account_tokens_enabled_idx", + "columns": [ + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_tokens_account_id_accounts_id_fk": { + "name": "account_tokens_account_id_accounts_id_fk", + "tableFrom": "account_tokens", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "site_id": { + "name": "site_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_token": { + "name": "api_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "balance": { + "name": "balance", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "balance_used": { + "name": "balance_used", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "quota": { + "name": "quota", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "unit_cost": { + "name": "unit_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "value_score": { + "name": "value_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'active'" + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "checkin_enabled": { + "name": "checkin_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "last_checkin_at": { + "name": "last_checkin_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_balance_refresh": { + "name": "last_balance_refresh", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra_config": { + "name": "extra_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "accounts_site_id_idx": { + "name": "accounts_site_id_idx", + "columns": [ + "site_id" + ], + "isUnique": false + }, + "accounts_status_idx": { + "name": "accounts_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "accounts_site_status_idx": { + "name": "accounts_site_status_idx", + "columns": [ + "site_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "accounts_site_id_sites_id_fk": { + "name": "accounts_site_id_sites_id_fk", + "tableFrom": "accounts", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "checkin_logs": { + "name": "checkin_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reward": { + "name": "reward", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "checkin_logs_account_created_at_idx": { + "name": "checkin_logs_account_created_at_idx", + "columns": [ + "account_id", + "created_at" + ], + "isUnique": false + }, + "checkin_logs_created_at_idx": { + "name": "checkin_logs_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "checkin_logs_status_idx": { + "name": "checkin_logs_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "checkin_logs_account_id_accounts_id_fk": { + "name": "checkin_logs_account_id_accounts_id_fk", + "tableFrom": "checkin_logs", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "downstream_api_keys": { + "name": "downstream_api_keys", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_cost": { + "name": "max_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_cost": { + "name": "used_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "max_requests": { + "name": "max_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_requests": { + "name": "used_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "supported_models": { + "name": "supported_models", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_route_ids": { + "name": "allowed_route_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "site_weight_multipliers": { + "name": "site_weight_multipliers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "downstream_api_keys_key_unique": { + "name": "downstream_api_keys_key_unique", + "columns": [ + "key" + ], + "isUnique": true + }, + "downstream_api_keys_name_idx": { + "name": "downstream_api_keys_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "downstream_api_keys_enabled_idx": { + "name": "downstream_api_keys_enabled_idx", + "columns": [ + "enabled" + ], + "isUnique": false + }, + "downstream_api_keys_expires_at_idx": { + "name": "downstream_api_keys_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'info'" + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "related_id": { + "name": "related_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "related_type": { + "name": "related_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "events_read_created_at_idx": { + "name": "events_read_created_at_idx", + "columns": [ + "read", + "created_at" + ], + "isUnique": false + }, + "events_type_created_at_idx": { + "name": "events_type_created_at_idx", + "columns": [ + "type", + "created_at" + ], + "isUnique": false + }, + "events_created_at_idx": { + "name": "events_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "model_availability": { + "name": "model_availability", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_name": { + "name": "model_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "available": { + "name": "available", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_manual": { + "name": "is_manual", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "model_availability_account_model_unique": { + "name": "model_availability_account_model_unique", + "columns": [ + "account_id", + "model_name" + ], + "isUnique": true + }, + "model_availability_account_available_idx": { + "name": "model_availability_account_available_idx", + "columns": [ + "account_id", + "available" + ], + "isUnique": false + }, + "model_availability_model_name_idx": { + "name": "model_availability_model_name_idx", + "columns": [ + "model_name" + ], + "isUnique": false + } + }, + "foreignKeys": { + "model_availability_account_id_accounts_id_fk": { + "name": "model_availability_account_id_accounts_id_fk", + "tableFrom": "model_availability", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "proxy_files": { + "name": "proxy_files", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_base64": { + "name": "content_base64", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "proxy_files_public_id_unique": { + "name": "proxy_files_public_id_unique", + "columns": [ + "public_id" + ], + "isUnique": true + }, + "proxy_files_owner_lookup_idx": { + "name": "proxy_files_owner_lookup_idx", + "columns": [ + "owner_type", + "owner_id", + "deleted_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "proxy_logs": { + "name": "proxy_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "route_id": { + "name": "route_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "downstream_api_key_id": { + "name": "downstream_api_key_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_requested": { + "name": "model_requested", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_actual": { + "name": "model_actual", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "http_status": { + "name": "http_status", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimated_cost": { + "name": "estimated_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "billing_details": { + "name": "billing_details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "proxy_logs_created_at_idx": { + "name": "proxy_logs_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "proxy_logs_account_created_at_idx": { + "name": "proxy_logs_account_created_at_idx", + "columns": [ + "account_id", + "created_at" + ], + "isUnique": false + }, + "proxy_logs_status_created_at_idx": { + "name": "proxy_logs_status_created_at_idx", + "columns": [ + "status", + "created_at" + ], + "isUnique": false + }, + "proxy_logs_model_actual_created_at_idx": { + "name": "proxy_logs_model_actual_created_at_idx", + "columns": [ + "model_actual", + "created_at" + ], + "isUnique": false + }, + "proxy_logs_downstream_api_key_created_at_idx": { + "name": "proxy_logs_downstream_api_key_created_at_idx", + "columns": [ + "downstream_api_key_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "proxy_video_tasks": { + "name": "proxy_video_tasks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "upstream_video_id": { + "name": "upstream_video_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "site_url": { + "name": "site_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_value": { + "name": "token_value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_model": { + "name": "requested_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actual_model": { + "name": "actual_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_snapshot": { + "name": "status_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_response_meta": { + "name": "upstream_response_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_upstream_status": { + "name": "last_upstream_status", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "proxy_video_tasks_public_id_unique": { + "name": "proxy_video_tasks_public_id_unique", + "columns": [ + "public_id" + ], + "isUnique": true + }, + "proxy_video_tasks_upstream_video_id_idx": { + "name": "proxy_video_tasks_upstream_video_id_idx", + "columns": [ + "upstream_video_id" + ], + "isUnique": false + }, + "proxy_video_tasks_created_at_idx": { + "name": "proxy_video_tasks_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "route_channels": { + "name": "route_channels", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "route_id": { + "name": "route_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_id": { + "name": "token_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_model": { + "name": "source_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "manual_override": { + "name": "manual_override", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "success_count": { + "name": "success_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "fail_count": { + "name": "fail_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "total_latency_ms": { + "name": "total_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_selected_at": { + "name": "last_selected_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_fail_at": { + "name": "last_fail_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "consecutive_fail_count": { + "name": "consecutive_fail_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cooldown_level": { + "name": "cooldown_level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cooldown_until": { + "name": "cooldown_until", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "route_channels_route_id_idx": { + "name": "route_channels_route_id_idx", + "columns": [ + "route_id" + ], + "isUnique": false + }, + "route_channels_account_id_idx": { + "name": "route_channels_account_id_idx", + "columns": [ + "account_id" + ], + "isUnique": false + }, + "route_channels_token_id_idx": { + "name": "route_channels_token_id_idx", + "columns": [ + "token_id" + ], + "isUnique": false + }, + "route_channels_route_enabled_idx": { + "name": "route_channels_route_enabled_idx", + "columns": [ + "route_id", + "enabled" + ], + "isUnique": false + }, + "route_channels_route_token_idx": { + "name": "route_channels_route_token_idx", + "columns": [ + "route_id", + "token_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "route_channels_route_id_token_routes_id_fk": { + "name": "route_channels_route_id_token_routes_id_fk", + "tableFrom": "route_channels", + "tableTo": "token_routes", + "columnsFrom": [ + "route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "route_channels_account_id_accounts_id_fk": { + "name": "route_channels_account_id_accounts_id_fk", + "tableFrom": "route_channels", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "route_channels_token_id_account_tokens_id_fk": { + "name": "route_channels_token_id_account_tokens_id_fk", + "tableFrom": "route_channels", + "tableTo": "account_tokens", + "columnsFrom": [ + "token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "site_disabled_models": { + "name": "site_disabled_models", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "site_id": { + "name": "site_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_name": { + "name": "model_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "site_disabled_models_site_model_unique": { + "name": "site_disabled_models_site_model_unique", + "columns": [ + "site_id", + "model_name" + ], + "isUnique": true + }, + "site_disabled_models_site_id_idx": { + "name": "site_disabled_models_site_id_idx", + "columns": [ + "site_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "site_disabled_models_site_id_sites_id_fk": { + "name": "site_disabled_models_site_id_sites_id_fk", + "tableFrom": "site_disabled_models", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sites": { + "name": "sites", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_checkin_url": { + "name": "external_checkin_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_system_proxy": { + "name": "use_system_proxy", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "global_weight": { + "name": "global_weight", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "sites_status_idx": { + "name": "sites_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "token_model_availability": { + "name": "token_model_availability", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "token_id": { + "name": "token_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_name": { + "name": "model_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "available": { + "name": "available", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "token_model_availability_token_model_unique": { + "name": "token_model_availability_token_model_unique", + "columns": [ + "token_id", + "model_name" + ], + "isUnique": true + }, + "token_model_availability_token_available_idx": { + "name": "token_model_availability_token_available_idx", + "columns": [ + "token_id", + "available" + ], + "isUnique": false + }, + "token_model_availability_model_name_idx": { + "name": "token_model_availability_model_name_idx", + "columns": [ + "model_name" + ], + "isUnique": false + }, + "token_model_availability_available_idx": { + "name": "token_model_availability_available_idx", + "columns": [ + "available" + ], + "isUnique": false + } + }, + "foreignKeys": { + "token_model_availability_token_id_account_tokens_id_fk": { + "name": "token_model_availability_token_id_account_tokens_id_fk", + "tableFrom": "token_model_availability", + "tableTo": "account_tokens", + "columnsFrom": [ + "token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "token_routes": { + "name": "token_routes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "model_pattern": { + "name": "model_pattern", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_icon": { + "name": "display_icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_mapping": { + "name": "model_mapping", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decision_snapshot": { + "name": "decision_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decision_refreshed_at": { + "name": "decision_refreshed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "routing_strategy": { + "name": "routing_strategy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'weighted'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "token_routes_model_pattern_idx": { + "name": "token_routes_model_pattern_idx", + "columns": [ + "model_pattern" + ], + "isUnique": false + }, + "token_routes_enabled_idx": { + "name": "token_routes_enabled_idx", + "columns": [ + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d5c3ccf..b31d3e3 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1773605400000, "tag": "0011_downstream_api_key_metadata", "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1773665311013, + "tag": "0012_account_token_value_status", + "breakpoints": true } ] } diff --git a/electron-builder.yml b/electron-builder.yml index e99c646..ae093b1 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -4,7 +4,7 @@ artifactName: ${name}-${version}-${os}-${arch}.${ext} directories: output: release electronDist: node_modules/electron/dist -icon: build/icon.png +icon: build/desktop-icon.png asar: false files: - dist/**/* diff --git a/package.json b/package.json index 57ea6d1..d9cf755 100644 --- a/package.json +++ b/package.json @@ -29,14 +29,15 @@ "dev:server": "tsx scripts/dev/run-server.ts --watch", "dev": "concurrently \"npm run dev:server\" \"vite\"", "dev:desktop": "concurrently \"npm run dev:server\" \"vite\" \"tsc -p tsconfig.desktop.json --watch --preserveWatchOutput\" \"wait-on tcp:127.0.0.1:4000 http://127.0.0.1:5173 file:dist/desktop/main.js && cross-env METAPI_DESKTOP_DEV_SERVER_URL=http://127.0.0.1:5173 METAPI_DESKTOP_EXTERNAL_SERVER_URL=http://127.0.0.1:4000 electron dist/desktop/main.js\"", - "build:web": "vite build", + "desktop:icons": "node scripts/desktop/generate-icons.mjs", + "build:web": "npm run desktop:icons && vite build", "build:server": "tsc -p tsconfig.server.json && tsx scripts/dev/copy-runtime-db-generated.ts", "build:desktop": "tsc -p tsconfig.desktop.json", "build": "npm run build:web && npm run build:server && npm run build:desktop", "dist:desktop": "npm run build && electron-builder --config electron-builder.yml --publish never", "dist:desktop:mac:intel": "npm run build && electron-builder --config electron-builder.yml --publish never --mac --x64", - "package:desktop": "electron-builder --config electron-builder.yml --publish never", - "package:desktop:mac:intel": "electron-builder --config electron-builder.yml --publish never --mac --x64", + "package:desktop": "npm run desktop:icons && electron-builder --config electron-builder.yml --publish never", + "package:desktop:mac:intel": "npm run desktop:icons && electron-builder --config electron-builder.yml --publish never --mac --x64", "docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173", "docs:build": "vitepress build docs", "docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173", diff --git a/scripts/desktop/generate-icons.mjs b/scripts/desktop/generate-icons.mjs new file mode 100644 index 0000000..ac587fe --- /dev/null +++ b/scripts/desktop/generate-icons.mjs @@ -0,0 +1,85 @@ +import { mkdir } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import sharp from 'sharp'; + +export const DESKTOP_ICON_SIZE = 512; +export const DESKTOP_ICON_PADDING = 40; +export const DESKTOP_ICON_RADIUS = 96; + +function createRoundedMask(size, cornerRadius) { + return Buffer.from( + ``, + ); +} + +async function renderDesktopIconBuffer({ + sourcePath, + size = DESKTOP_ICON_SIZE, + padding = DESKTOP_ICON_PADDING, + cornerRadius = DESKTOP_ICON_RADIUS, +}) { + const innerSize = size - padding * 2; + const roundedMask = createRoundedMask(innerSize, Math.min(cornerRadius, Math.floor(innerSize / 2))); + + const roundedInner = await sharp(sourcePath) + .resize(innerSize, innerSize, { + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + .composite([{ input: roundedMask, blend: 'dest-in' }]) + .png() + .toBuffer(); + + return sharp({ + create: { + width: size, + height: size, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }, + }) + .composite([{ input: roundedInner, left: padding, top: padding }]) + .png() + .toBuffer(); +} + +export async function generateDesktopIconAssets({ + sourcePath = join(process.cwd(), 'src', 'web', 'public', 'logo.png'), + buildOutputPath = join(process.cwd(), 'build', 'desktop-icon.png'), + webOutputPath = join(process.cwd(), 'src', 'web', 'public', 'desktop-icon.png'), + size = DESKTOP_ICON_SIZE, + padding = DESKTOP_ICON_PADDING, + cornerRadius = DESKTOP_ICON_RADIUS, +} = {}) { + const outputBuffer = await renderDesktopIconBuffer({ + sourcePath, + size, + padding, + cornerRadius, + }); + + await Promise.all([ + mkdir(dirname(buildOutputPath), { recursive: true }), + mkdir(dirname(webOutputPath), { recursive: true }), + ]); + + await Promise.all([ + sharp(outputBuffer).toFile(buildOutputPath), + sharp(outputBuffer).toFile(webOutputPath), + ]); + + return { + buildOutputPath, + webOutputPath, + }; +} + +const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isDirectRun) { + const outputs = await generateDesktopIconAssets(); + console.log(`[metapi-desktop] Generated desktop icons: +- ${outputs.buildOutputPath} +- ${outputs.webOutputPath}`); +} diff --git a/scripts/desktop/generate-icons.test.ts b/scripts/desktop/generate-icons.test.ts new file mode 100644 index 0000000..b65247b --- /dev/null +++ b/scripts/desktop/generate-icons.test.ts @@ -0,0 +1,68 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import sharp from 'sharp'; +import { afterEach, describe, expect, it } from 'vitest'; + +const tempDirs: string[] = []; + +async function makeTempDir() { + const dir = await mkdtemp(join(tmpdir(), 'metapi-desktop-icons-')); + tempDirs.push(dir); + return dir; +} + +function alphaAt(buffer: Buffer, width: number, x: number, y: number) { + return buffer[(y * width + x) * 4 + 3]; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe('generateDesktopIconAssets', () => { + it('writes rounded desktop icon outputs for build and runtime usage', async () => { + const { generateDesktopIconAssets } = await import('./generate-icons.mjs'); + + const dir = await makeTempDir(); + const sourcePath = join(dir, 'logo.png'); + const buildOutputPath = join(dir, 'build.png'); + const webOutputPath = join(dir, 'desktop-icon.png'); + + await sharp({ + create: { + width: 512, + height: 512, + channels: 4, + background: { r: 255, g: 112, b: 48, alpha: 1 }, + }, + }).png().toFile(sourcePath); + + await generateDesktopIconAssets({ + sourcePath, + buildOutputPath, + webOutputPath, + }); + + const buildMeta = await sharp(buildOutputPath).metadata(); + const webMeta = await sharp(webOutputPath).metadata(); + + expect(buildMeta.width).toBe(512); + expect(buildMeta.height).toBe(512); + expect(webMeta.width).toBe(512); + expect(webMeta.height).toBe(512); + + const { data, info } = await sharp(buildOutputPath) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + + expect(alphaAt(data, info.width, 0, 0)).toBe(0); + expect(alphaAt(data, info.width, 12, 12)).toBe(0); + expect(alphaAt(data, info.width, Math.floor(info.width / 2), 6)).toBe(0); + expect(alphaAt(data, info.width, Math.floor(info.width / 2), Math.floor(info.height / 2))).toBe(255); + + expect(await sharp(buildOutputPath).png().toBuffer()) + .toEqual(await sharp(webOutputPath).png().toBuffer()); + }); +}); diff --git a/src/desktop/iconAssets.test.ts b/src/desktop/iconAssets.test.ts new file mode 100644 index 0000000..34b3227 --- /dev/null +++ b/src/desktop/iconAssets.test.ts @@ -0,0 +1,24 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('desktop icon assets', () => { + it('keeps runtime icon paths aligned to the generated desktop icon', async () => { + const { + DESKTOP_BUILD_ICON_RELATIVE_PATH, + DESKTOP_RUNTIME_ICON_RELATIVE_PATH, + getDesktopRuntimeIconPath, + } = await import('./iconAssets.js'); + + expect(DESKTOP_RUNTIME_ICON_RELATIVE_PATH).toBe(join('dist', 'web', 'desktop-icon.png')); + expect(DESKTOP_BUILD_ICON_RELATIVE_PATH).toBe(join('build', 'desktop-icon.png')); + expect(getDesktopRuntimeIconPath('/app')).toBe(join('/app', 'dist', 'web', 'desktop-icon.png')); + }); + + it('points electron-builder at the generated desktop package icon', async () => { + const configPath = join(process.cwd(), 'electron-builder.yml'); + const config = await readFile(configPath, 'utf8'); + + expect(config).toContain('icon: build/desktop-icon.png'); + }); +}); diff --git a/src/desktop/iconAssets.ts b/src/desktop/iconAssets.ts new file mode 100644 index 0000000..ee73dd0 --- /dev/null +++ b/src/desktop/iconAssets.ts @@ -0,0 +1,9 @@ +import { join } from 'node:path'; + +export const DESKTOP_RUNTIME_ICON_RELATIVE_PATH = join('dist', 'web', 'desktop-icon.png'); +export const DESKTOP_BUILD_ICON_RELATIVE_PATH = join('build', 'desktop-icon.png'); +export const DESKTOP_ICON_PUBLIC_PATH = '/desktop-icon.png'; + +export function getDesktopRuntimeIconPath(appPath: string) { + return join(appPath, DESKTOP_RUNTIME_ICON_RELATIVE_PATH); +} diff --git a/src/desktop/main.ts b/src/desktop/main.ts index df21f4a..dd81eff 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -21,6 +21,7 @@ import { resolveDesktopServerWorkingDir, waitForServerReady, } from './runtime.js'; +import { getDesktopRuntimeIconPath } from './iconAssets.js'; const { autoUpdater } = electronUpdater; @@ -51,7 +52,7 @@ function getServerEntryPath() { } function getTrayIconPath() { - return join(app.getAppPath(), 'dist', 'web', 'logo.png'); + return getDesktopRuntimeIconPath(app.getAppPath()); } function getWindowIconPath() { diff --git a/src/server/db/accountTokenSchemaCompatibility.ts b/src/server/db/accountTokenSchemaCompatibility.ts index bd2ec40..c97b4a8 100644 --- a/src/server/db/accountTokenSchemaCompatibility.ts +++ b/src/server/db/accountTokenSchemaCompatibility.ts @@ -23,6 +23,15 @@ const ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS: AccountTokenColumnCompatibilityS postgres: 'ALTER TABLE "account_tokens" ADD COLUMN "token_group" TEXT', }, }, + { + table: 'account_tokens', + column: 'value_status', + addSql: { + sqlite: "ALTER TABLE account_tokens ADD COLUMN value_status text NOT NULL DEFAULT 'ready';", + mysql: "ALTER TABLE `account_tokens` ADD COLUMN `value_status` VARCHAR(191) NOT NULL DEFAULT 'ready'", + postgres: "ALTER TABLE \"account_tokens\" ADD COLUMN \"value_status\" TEXT NOT NULL DEFAULT 'ready'", + }, + }, ]; function normalizeSchemaErrorMessage(error: unknown): string { diff --git a/src/server/db/generated/mysql.bootstrap.sql b/src/server/db/generated/mysql.bootstrap.sql index 0e7fa75..6d5bbd4 100644 --- a/src/server/db/generated/mysql.bootstrap.sql +++ b/src/server/db/generated/mysql.bootstrap.sql @@ -1,6 +1,6 @@ CREATE TABLE IF NOT EXISTS `sites` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `platform` TEXT NOT NULL, `status` VARCHAR(191) NOT NULL DEFAULT 'active', `api_key` TEXT, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, `proxy_url` TEXT, `use_system_proxy` BOOLEAN DEFAULT false, `custom_headers` JSON, `external_checkin_url` TEXT, `global_weight` DOUBLE DEFAULT 1); CREATE TABLE IF NOT EXISTS `accounts` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `username` TEXT, `access_token` TEXT NOT NULL, `api_token` TEXT, `balance` DOUBLE DEFAULT 0, `balance_used` DOUBLE DEFAULT 0, `quota` DOUBLE DEFAULT 0, `unit_cost` DOUBLE, `value_score` DOUBLE DEFAULT 0, `status` VARCHAR(191) DEFAULT 'active', `checkin_enabled` BOOLEAN DEFAULT true, `last_checkin_at` DATETIME, `last_balance_refresh` DATETIME, `extra_config` JSON, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE); -CREATE TABLE IF NOT EXISTS `account_tokens` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `name` TEXT NOT NULL, `token` TEXT NOT NULL, `source` VARCHAR(191) DEFAULT 'manual', `enabled` BOOLEAN DEFAULT true, `is_default` BOOLEAN DEFAULT false, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `token_group` TEXT, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE); +CREATE TABLE IF NOT EXISTS `account_tokens` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `name` TEXT NOT NULL, `token` TEXT NOT NULL, `source` VARCHAR(191) DEFAULT 'manual', `enabled` BOOLEAN DEFAULT true, `is_default` BOOLEAN DEFAULT false, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `token_group` TEXT, `value_status` VARCHAR(191) NOT NULL DEFAULT 'ready', FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS `checkin_logs` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `status` TEXT NOT NULL, `message` TEXT, `reward` TEXT, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS `downstream_api_keys` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `key` TEXT NOT NULL, `description` TEXT, `enabled` BOOLEAN DEFAULT true, `expires_at` DATETIME, `max_cost` DOUBLE, `used_cost` DOUBLE DEFAULT 0, `max_requests` INT, `used_requests` INT DEFAULT 0, `supported_models` JSON, `allowed_route_ids` JSON, `site_weight_multipliers` JSON, `last_used_at` DATETIME, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `group_name` TEXT, `tags` TEXT); CREATE TABLE IF NOT EXISTS `events` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `type` TEXT NOT NULL, `title` TEXT NOT NULL, `message` TEXT, `level` VARCHAR(191) NOT NULL DEFAULT 'info', `read` BOOLEAN DEFAULT false, `related_id` INT, `related_type` TEXT, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP); diff --git a/src/server/db/generated/mysql.upgrade.sql b/src/server/db/generated/mysql.upgrade.sql index 498dfc7..06b4ac6 100644 --- a/src/server/db/generated/mysql.upgrade.sql +++ b/src/server/db/generated/mysql.upgrade.sql @@ -1,4 +1 @@ -ALTER TABLE `downstream_api_keys` ADD COLUMN `group_name` TEXT; -ALTER TABLE `downstream_api_keys` ADD COLUMN `tags` TEXT; -ALTER TABLE `proxy_logs` ADD COLUMN `downstream_api_key_id` INT; -CREATE INDEX `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`); +ALTER TABLE `account_tokens` ADD COLUMN `value_status` VARCHAR(191) NOT NULL DEFAULT 'ready'; diff --git a/src/server/db/generated/postgres.bootstrap.sql b/src/server/db/generated/postgres.bootstrap.sql index d8762b6..9d1e27a 100644 --- a/src/server/db/generated/postgres.bootstrap.sql +++ b/src/server/db/generated/postgres.bootstrap.sql @@ -1,6 +1,6 @@ CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "platform" TEXT NOT NULL, "status" TEXT NOT NULL DEFAULT 'active', "api_key" TEXT, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, "proxy_url" TEXT, "use_system_proxy" BOOLEAN DEFAULT false, "custom_headers" JSONB, "external_checkin_url" TEXT, "global_weight" DOUBLE PRECISION DEFAULT 1); CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" DOUBLE PRECISION DEFAULT 0, "balance_used" DOUBLE PRECISION DEFAULT 0, "quota" DOUBLE PRECISION DEFAULT 0, "unit_cost" DOUBLE PRECISION, "value_score" DOUBLE PRECISION DEFAULT 0, "status" TEXT DEFAULT 'active', "checkin_enabled" BOOLEAN DEFAULT true, "last_checkin_at" TIMESTAMP, "last_balance_refresh" TIMESTAMP, "extra_config" JSONB, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE); -CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT true, "is_default" BOOLEAN DEFAULT false, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "token_group" TEXT, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE); +CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT true, "is_default" BOOLEAN DEFAULT false, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "token_group" TEXT, "value_status" TEXT NOT NULL DEFAULT 'ready', FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL, "description" TEXT, "enabled" BOOLEAN DEFAULT true, "expires_at" TIMESTAMP, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" JSONB, "allowed_route_ids" JSONB, "site_weight_multipliers" JSONB, "last_used_at" TIMESTAMP, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "group_name" TEXT, "tags" TEXT); CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT NOT NULL DEFAULT 'info', "read" BOOLEAN DEFAULT false, "related_id" INTEGER, "related_type" TEXT, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP); diff --git a/src/server/db/generated/postgres.upgrade.sql b/src/server/db/generated/postgres.upgrade.sql index 2e50f3f..95e5611 100644 --- a/src/server/db/generated/postgres.upgrade.sql +++ b/src/server/db/generated/postgres.upgrade.sql @@ -1,4 +1 @@ -ALTER TABLE "downstream_api_keys" ADD COLUMN "group_name" TEXT; -ALTER TABLE "downstream_api_keys" ADD COLUMN "tags" TEXT; -ALTER TABLE "proxy_logs" ADD COLUMN "downstream_api_key_id" INTEGER; -CREATE INDEX "proxy_logs_downstream_api_key_created_at_idx" ON "proxy_logs" ("downstream_api_key_id", "created_at"); +ALTER TABLE "account_tokens" ADD COLUMN "value_status" TEXT NOT NULL DEFAULT 'ready'; diff --git a/src/server/db/generated/schemaContract.json b/src/server/db/generated/schemaContract.json index e7cc01a..65aa0e1 100644 --- a/src/server/db/generated/schemaContract.json +++ b/src/server/db/generated/schemaContract.json @@ -61,6 +61,12 @@ "notNull": false, "defaultValue": null, "primaryKey": false + }, + "value_status": { + "logicalType": "text", + "notNull": true, + "defaultValue": "'ready'", + "primaryKey": false } } }, diff --git a/src/server/db/index.ts b/src/server/db/index.ts index 542d56e..a78fdb3 100644 --- a/src/server/db/index.ts +++ b/src/server/db/index.ts @@ -137,6 +137,7 @@ function ensureTokenManagementSchema() { name text NOT NULL, token text NOT NULL, token_group text, + value_status text NOT NULL DEFAULT 'ready', source text DEFAULT 'manual', enabled integer DEFAULT true, is_default integer DEFAULT false, @@ -152,6 +153,9 @@ function ensureTokenManagementSchema() { if (!tableColumnExists('account_tokens', 'token_group')) { execSqliteLegacyCompat('ALTER TABLE account_tokens ADD COLUMN token_group text;'); } + if (!tableColumnExists('account_tokens', 'value_status')) { + execSqliteLegacyCompat("ALTER TABLE account_tokens ADD COLUMN value_status text NOT NULL DEFAULT 'ready';"); + } execSqliteStatement(` INSERT INTO account_tokens (account_id, name, token, source, enabled, is_default, created_at, updated_at) diff --git a/src/server/db/legacySchemaCompat.ts b/src/server/db/legacySchemaCompat.ts index 5a2ecfb..a480ac2 100644 --- a/src/server/db/legacySchemaCompat.ts +++ b/src/server/db/legacySchemaCompat.ts @@ -46,6 +46,7 @@ const LEGACY_COMPAT_COLUMNS = new Set([ 'sites.external_checkin_url', 'sites.global_weight', 'account_tokens.token_group', + 'account_tokens.value_status', 'token_routes.display_name', 'token_routes.display_icon', 'token_routes.decision_snapshot', diff --git a/src/server/db/migrate.ts b/src/server/db/migrate.ts index 0740ca9..1a89115 100644 --- a/src/server/db/migrate.ts +++ b/src/server/db/migrate.ts @@ -62,6 +62,8 @@ const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [ // 0011: downstream key metadata columns { table: 'downstream_api_keys', column: 'group_name' }, { table: 'downstream_api_keys', column: 'tags' }, + // 0012: value_status column on account_tokens + { table: 'account_tokens', column: 'value_status' }, ]; diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 8c9c356..4b779b9 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -63,6 +63,7 @@ export const accountTokens = sqliteTable('account_tokens', { name: text('name').notNull(), token: text('token').notNull(), tokenGroup: text('token_group'), + valueStatus: text('value_status').notNull().default('ready'), source: text('source').default('manual'), // 'manual' | 'sync' | 'legacy' enabled: integer('enabled', { mode: 'boolean' }).default(true), isDefault: integer('is_default', { mode: 'boolean' }).default(false), diff --git a/src/server/db/schemaContract.test.ts b/src/server/db/schemaContract.test.ts index 136d21f..b3961fc 100644 --- a/src/server/db/schemaContract.test.ts +++ b/src/server/db/schemaContract.test.ts @@ -11,6 +11,11 @@ describe('schema contract generation', () => { primaryKey: false, }); expect(contract.tables.account_tokens.columns.token_group).toBeDefined(); + expect(contract.tables.account_tokens.columns.value_status).toMatchObject({ + logicalType: 'text', + notNull: true, + defaultValue: "'ready'", + }); expect(contract.tables.site_disabled_models).toBeDefined(); expect(contract.tables.downstream_api_keys).toBeDefined(); expect(contract.tables.proxy_files).toBeDefined(); diff --git a/src/server/routes/api/accountTokens.batch.test.ts b/src/server/routes/api/accountTokens.batch.test.ts index 619bcc8..e38fe2e 100644 --- a/src/server/routes/api/accountTokens.batch.test.ts +++ b/src/server/routes/api/accountTokens.batch.test.ts @@ -3,6 +3,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites import { mkdtempSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { eq } from 'drizzle-orm'; const { deleteApiTokenMock } = vi.hoisted(() => ({ deleteApiTokenMock: vi.fn(), @@ -105,6 +106,43 @@ describe('account token batch routes', () => { expect(rows.every((row) => row.enabled === true)).toBe(true); }); + it('rejects enabling masked_pending placeholders until they are completed', async () => { + await db.update(schema.accountTokens) + .set({ + enabled: false, + valueStatus: 'masked_pending' as any, + token: 'sk-mask***tail', + }) + .where(eq(schema.accountTokens.id, 1)) + .run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/account-tokens/batch', + payload: { + ids: [1, 2], + action: 'enable', + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + successIds?: number[]; + failedItems?: Array<{ id: number; message: string }>; + }; + expect(body.successIds).toEqual([2]); + expect(body.failedItems).toEqual([ + expect.objectContaining({ + id: 1, + message: expect.stringContaining('待补全令牌'), + }), + ]); + + const rows = await db.select().from(schema.accountTokens).all(); + expect(rows.find((row) => row.id === 1)?.enabled).toBe(false); + expect(rows.find((row) => row.id === 2)?.enabled).toBe(true); + }); + it('deletes selected account tokens through the upstream adapter', async () => { const response = await app.inject({ method: 'POST', diff --git a/src/server/routes/api/accountTokens.sync.test.ts b/src/server/routes/api/accountTokens.sync.test.ts index 8b594d4..d993a8c 100644 --- a/src/server/routes/api/accountTokens.sync.test.ts +++ b/src/server/routes/api/accountTokens.sync.test.ts @@ -138,7 +138,7 @@ describe('account tokens sync routes with site status', () => { expect(tokenRows.length).toBe(0); }); - it('skips masked upstream token values and does not store them', async () => { + it('stores masked upstream token values as masked_pending placeholders', async () => { const { account } = await seedAccount({ siteStatus: 'active' }); getApiTokensMock.mockResolvedValue([ { name: 'masked-only', key: 'sk-abc***xyz', enabled: true }, @@ -153,12 +153,13 @@ describe('account tokens sync routes with site status', () => { expect(response.statusCode).toBe(200); expect(response.json()).toMatchObject({ success: true, - synced: false, - status: 'skipped', + synced: true, + status: 'synced', reason: 'upstream_masked_tokens', - maskedSkipped: 1, - total: 0, - created: 0, + maskedPending: 1, + pendingTokenIds: [expect.any(Number)], + total: 1, + created: 1, updated: 0, }); @@ -166,7 +167,33 @@ describe('account tokens sync routes with site status', () => { .from(schema.accountTokens) .where(eq(schema.accountTokens.accountId, account.id)) .all(); - expect(tokenRows.length).toBe(0); + expect(tokenRows).toHaveLength(1); + expect(tokenRows[0]).toMatchObject({ + name: 'masked-only', + token: 'sk-abc***xyz', + source: 'sync', + enabled: false, + isDefault: false, + }); + expect((tokenRows[0] as any).valueStatus).toBe('masked_pending'); + + const owner = await db.select() + .from(schema.accounts) + .where(eq(schema.accounts.id, account.id)) + .get(); + expect(owner?.apiToken ?? null).toBeNull(); + + const listResponse = await app.inject({ + method: 'GET', + url: '/api/account-tokens', + }); + expect(listResponse.statusCode).toBe(200); + expect(listResponse.json()).toMatchObject([ + expect.objectContaining({ + id: tokenRows[0].id, + valueStatus: 'masked_pending', + }), + ]); }); it('rejects sync and token management for apikey connections', async () => { @@ -491,6 +518,7 @@ describe('account tokens sync routes with site status', () => { source: 'sync', enabled: true, isDefault: false, + valueStatus: 'masked_pending' as any, }).returning().get(); const response = await app.inject({ @@ -503,4 +531,139 @@ describe('account tokens sync routes with site status', () => { success: false, }); }); + + it('upgrades an existing masked_pending placeholder when upstream later returns the full token', async () => { + const { account } = await seedAccount({ siteStatus: 'active' }); + await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'masked-only', + token: 'sk-abc***xyz', + source: 'sync', + enabled: false, + isDefault: false, + tokenGroup: 'default', + valueStatus: 'masked_pending' as any, + }).run(); + + getApiTokensMock.mockResolvedValue([ + { name: 'masked-only', key: 'sk-real-token-1234', enabled: true, tokenGroup: 'default' }, + ]); + getApiTokenMock.mockResolvedValue(null); + + const response = await app.inject({ + method: 'POST', + url: `/api/account-tokens/sync/${account.id}`, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + success: true, + synced: true, + status: 'synced', + total: 1, + created: 0, + updated: 1, + }); + + const tokenRows = await db.select() + .from(schema.accountTokens) + .where(eq(schema.accountTokens.accountId, account.id)) + .all(); + expect(tokenRows).toHaveLength(1); + expect(tokenRows[0]).toMatchObject({ + name: 'masked-only', + token: 'sk-real-token-1234', + enabled: true, + }); + expect((tokenRows[0] as any).valueStatus).toBe('ready'); + }); + + it('does not allow setting a masked_pending placeholder as default', async () => { + const { account } = await seedAccount({ siteStatus: 'active' }); + const token = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'masked-token', + token: 'sk-mask***tail', + source: 'sync', + enabled: false, + isDefault: false, + valueStatus: 'masked_pending' as any, + }).returning().get(); + + const response = await app.inject({ + method: 'POST', + url: `/api/account-tokens/${token.id}/default`, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + success: false, + message: expect.stringContaining('待补全令牌'), + }); + }); + + it('promotes a masked_pending placeholder to ready when a full token is saved', async () => { + const { account } = await seedAccount({ siteStatus: 'active' }); + const token = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'masked-token', + token: 'sk-mask***tail', + source: 'sync', + enabled: false, + isDefault: false, + valueStatus: 'masked_pending' as any, + }).returning().get(); + + const response = await app.inject({ + method: 'PUT', + url: `/api/account-tokens/${token.id}`, + payload: { + token: 'sk-real-token-updated', + enabled: true, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + success: true, + token: expect.objectContaining({ + id: token.id, + enabled: true, + valueStatus: 'ready', + }), + }); + + const latest = await db.select() + .from(schema.accountTokens) + .where(eq(schema.accountTokens.id, token.id)) + .get(); + expect(latest).toMatchObject({ + token: 'sk-real-token-updated', + enabled: true, + }); + expect((latest as any)?.valueStatus).toBe('ready'); + }); + + it('deletes masked_pending placeholders locally without calling upstream delete', async () => { + const { account } = await seedAccount({ siteStatus: 'active' }); + const token = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'masked-token', + token: 'sk-mask***tail', + source: 'sync', + enabled: false, + isDefault: false, + valueStatus: 'masked_pending' as any, + }).returning().get(); + + const response = await app.inject({ + method: 'DELETE', + url: `/api/account-tokens/${token.id}`, + }); + + expect(response.statusCode).toBe(200); + expect(deleteApiTokenMock).not.toHaveBeenCalled(); + const removed = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, token.id)).get(); + expect(removed).toBeUndefined(); + }); }); diff --git a/src/server/routes/api/accountTokens.ts b/src/server/routes/api/accountTokens.ts index 6a0407e..770ef41 100644 --- a/src/server/routes/api/accountTokens.ts +++ b/src/server/routes/api/accountTokens.ts @@ -2,12 +2,17 @@ import { and, eq } from 'drizzle-orm'; import { db, schema } from '../../db/index.js'; import { + ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING, + ACCOUNT_TOKEN_VALUE_STATUS_READY, ensureDefaultTokenForAccount, + isMaskedPendingAccountToken, isMaskedTokenValue, + isUsableAccountToken, listTokensWithRelations, normalizeTokenForDisplay, maskToken, repairDefaultToken, + resolveAccountTokenValueStatus, setDefaultToken, syncTokensFromUpstream, } from '../../services/accountTokenService.js'; @@ -38,7 +43,8 @@ type SyncExecutionResult = { synced: boolean; created: number; updated: number; - maskedSkipped?: number; + maskedPending?: number; + pendingTokenIds?: number[]; total: number; defaultTokenId?: number | null; }; @@ -287,13 +293,13 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise 0) { + if ((synced.maskedPending || 0) > 0) { return { ...base, - status: 'skipped', + status: 'synced', reason: 'upstream_masked_tokens', - message: `上游仅返回脱敏令牌(含*),跳过 ${synced.maskedSkipped} 条,无法同步明文令牌`, - synced: false, + message: `上游返回 ${synced.maskedPending} 条脱敏令牌,已保存为待补全记录,请手动补全明文 token。`, + synced: true, ...synced, }; } @@ -326,7 +332,7 @@ async function appendTokenSyncEvent(result: SyncExecutionResult) { ? 'info' : (result.status === 'skipped' ? 'warning' : 'error'); const detail = result.status === 'synced' - ? `新增 ${result.created},更新 ${result.updated},总数 ${result.total}` + ? `新增 ${result.created},更新 ${result.updated},待补全 ${result.maskedPending || 0},总数 ${result.total}` : (result.message || result.reason || 'sync skipped'); try { @@ -485,15 +491,25 @@ export async function accountTokensRoutes(app: FastifyInstance) { const existing = await db.select().from(schema.accountTokens) .where(eq(schema.accountTokens.accountId, body.accountId)) .all(); + const valueStatus = isMaskedTokenValue(tokenValue) + ? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING + : ACCOUNT_TOKEN_VALUE_STATUS_READY; + const enabled = valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY + ? (body.enabled ?? true) + : false; + const isDefault = valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY + ? (body.isDefault ?? false) + : false; const inserted = await db.insert(schema.accountTokens).values({ accountId: body.accountId, name: (body.name || '').trim() || (existing.length === 0 ? 'default' : `token-${existing.length + 1}`), token: tokenValue, tokenGroup: (body.group || '').trim() || null, + valueStatus, source: body.source || 'manual', - enabled: body.enabled ?? true, - isDefault: body.isDefault ?? false, + enabled, + isDefault, createdAt: now, updatedAt: now, }).run(); @@ -506,9 +522,9 @@ export async function accountTokensRoutes(app: FastifyInstance) { return reply.code(500).send({ success: false, message: '创建令牌失败' }); } - if (body.isDefault || (existing.length === 0 && (body.enabled ?? true))) { + if (valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY && (body.isDefault || (existing.length === 0 && enabled))) { await setDefaultToken(created.id); - } else if (existing.every((token) => !token.isDefault) && (body.enabled ?? true)) { + } else if (valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY && existing.every((token) => !token.isDefault) && enabled) { await setDefaultToken(created.id); } const coverageRefresh = await refreshCoverageForAccounts([body.accountId]); @@ -629,7 +645,10 @@ export async function accountTokensRoutes(app: FastifyInstance) { const account = row.accounts; const site = row.sites; const adapter = getAdapter(site.platform); - const shouldDeleteUpstream = !isSiteDisabled(site.status) && !!account.accessToken?.trim() && !!adapter; + const shouldDeleteUpstream = !isMaskedPendingAccountToken(existing) + && !isSiteDisabled(site.status) + && !!account.accessToken?.trim() + && !!adapter; if (shouldDeleteUpstream) { const platformUserId = resolvePlatformUserId(account.extraConfig, account.username); @@ -690,6 +709,10 @@ export async function accountTokensRoutes(app: FastifyInstance) { continue; } } else { + if (isMaskedPendingAccountToken(existing)) { + failedItems.push({ id, message: '待补全令牌不能修改启用状态,请先补全明文 token' }); + continue; + } await db.update(schema.accountTokens) .set({ enabled: action === 'enable', @@ -736,6 +759,7 @@ export async function accountTokensRoutes(app: FastifyInstance) { const body = request.body; const updates: Record = { updatedAt: new Date().toISOString() }; + let nextValueStatus = resolveAccountTokenValueStatus(existing); if (body.name !== undefined) { updates.name = (body.name || '').trim() || existing.name; @@ -747,15 +771,24 @@ export async function accountTokensRoutes(app: FastifyInstance) { return reply.code(400).send({ success: false, message: '令牌不能为空' }); } updates.token = tokenValue; + nextValueStatus = isMaskedTokenValue(tokenValue) + ? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING + : ACCOUNT_TOKEN_VALUE_STATUS_READY; + updates.valueStatus = nextValueStatus; } if (body.group !== undefined) { updates.tokenGroup = (body.group || '').trim() || null; } - if (body.enabled !== undefined) updates.enabled = body.enabled; + if (nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING) { + updates.enabled = false; + updates.isDefault = false; + } else { + if (body.enabled !== undefined) updates.enabled = body.enabled; + if (body.isDefault !== undefined) updates.isDefault = body.isDefault; + } if (body.source !== undefined) updates.source = body.source; - if (body.isDefault !== undefined) updates.isDefault = body.isDefault; await db.update(schema.accountTokens).set(updates).where(eq(schema.accountTokens.id, tokenId)).run(); @@ -764,11 +797,11 @@ export async function accountTokensRoutes(app: FastifyInstance) { return reply.code(500).send({ success: false, message: '更新失败' }); } - if (body.isDefault === true) { + if (body.isDefault === true && isUsableAccountToken(latest)) { setDefaultToken(tokenId); - } else if (latest.isDefault && latest.enabled) { + } else if (latest.isDefault && isUsableAccountToken(latest)) { setDefaultToken(tokenId); - } else if (existing.isDefault && !latest.enabled) { + } else if (existing.isDefault && !isUsableAccountToken(latest)) { repairDefaultToken(existing.accountId); } else if (body.isDefault === false && existing.isDefault) { repairDefaultToken(existing.accountId); @@ -793,6 +826,9 @@ export async function accountTokensRoutes(app: FastifyInstance) { if (isApiKeyConnection(owner)) { return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' }); } + if (isMaskedPendingAccountToken(tokenRow)) { + return reply.code(400).send({ success: false, message: '待补全令牌不能设为默认,请先补全明文 token' }); + } const success = await setDefaultToken(tokenId); if (!success) { return reply.code(404).send({ success: false, message: '令牌不存在' }); @@ -820,7 +856,7 @@ export async function accountTokensRoutes(app: FastifyInstance) { return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' }); } - if (isMaskedTokenValue(row.account_tokens.token)) { + if (isMaskedPendingAccountToken(row.account_tokens) || isMaskedTokenValue(row.account_tokens.token)) { return reply.code(409).send({ success: false, message: '当前仅保存了脱敏令牌,无法展开/复制。请在站点重新生成并同步,或手动更新为完整令牌。', diff --git a/src/server/routes/api/search.ts b/src/server/routes/api/search.ts index 6725ae4..de4f2a1 100644 --- a/src/server/routes/api/search.ts +++ b/src/server/routes/api/search.ts @@ -3,6 +3,7 @@ import { db, schema } from '../../db/index.js'; import { and, like, desc, eq, or } from 'drizzle-orm'; import { getProxyLogBaseSelectFields } from '../../services/proxyLogStore.js'; import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js'; +import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from '../../services/accountTokenService.js'; function hasSessionTokenValue(value: string | null | undefined): boolean { return typeof value === 'string' && value.trim().length > 0; @@ -134,6 +135,7 @@ export async function searchRoutes(app: FastifyInstance) { like(schema.tokenModelAvailability.modelName, q), eq(schema.tokenModelAvailability.available, true), eq(schema.accountTokens.enabled, true), + eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY), eq(schema.accounts.status, 'active'), ), ) diff --git a/src/server/routes/api/stats.ts b/src/server/routes/api/stats.ts index f09b084..0401c62 100644 --- a/src/server/routes/api/stats.ts +++ b/src/server/routes/api/stats.ts @@ -18,6 +18,7 @@ import { withProxyLogSelectFields, } from '../../services/proxyLogStore.js'; import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js'; +import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from '../../services/accountTokenService.js'; import { formatLocalDateTime, formatUtcSqlDateTime, @@ -724,6 +725,15 @@ export async function statsRoutes(app: FastifyInstance) { .innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id)) .innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id)) .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .where( + and( + eq(schema.tokenModelAvailability.available, true), + eq(schema.accountTokens.enabled, true), + eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY), + eq(schema.accounts.status, 'active'), + eq(schema.sites.status, 'active'), + ), + ) .all(); const accountAvailability = await db.select().from(schema.modelAvailability) .innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id)) @@ -989,6 +999,7 @@ export async function statsRoutes(app: FastifyInstance) { and( eq(schema.tokenModelAvailability.available, true), eq(schema.accountTokens.enabled, true), + eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY), eq(schema.accounts.status, 'active'), eq(schema.sites.status, 'active'), ), diff --git a/src/server/routes/api/tokens.ts b/src/server/routes/api/tokens.ts index 7b0f6a5..0b84a81 100644 --- a/src/server/routes/api/tokens.ts +++ b/src/server/routes/api/tokens.ts @@ -1,10 +1,14 @@ import { FastifyInstance } from 'fastify'; -import { and, eq, inArray } from 'drizzle-orm'; -import { db, schema } from '../../db/index.js'; +import { and, eq, inArray } from 'drizzle-orm'; +import { db, schema } from '../../db/index.js'; import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import { + ACCOUNT_TOKEN_VALUE_STATUS_READY, + isUsableAccountToken, +} from '../../services/accountTokenService.js'; import { normalizeRouteRoutingStrategy } from '../../services/routeRoutingStrategy.js'; import { invalidateTokenRouterCache, matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js'; -import { startBackgroundTask } from '../../services/backgroundTaskService.js'; +import { startBackgroundTask } from '../../services/backgroundTaskService.js'; import { clearRouteDecisionSnapshot, clearRouteDecisionSnapshots, @@ -19,12 +23,17 @@ function isExactModelPattern(modelPattern: string): boolean { return !/[\*\?\[]/.test(normalized); } -async function getDefaultTokenId(accountId: number): Promise { - const token = await db.select().from(schema.accountTokens) - .where(and(eq(schema.accountTokens.accountId, accountId), eq(schema.accountTokens.enabled, true), eq(schema.accountTokens.isDefault, true))) - .get(); - return token?.id ?? null; -} +async function getDefaultTokenId(accountId: number): Promise { + const token = await db.select().from(schema.accountTokens) + .where(and( + eq(schema.accountTokens.accountId, accountId), + eq(schema.accountTokens.enabled, true), + eq(schema.accountTokens.isDefault, true), + eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY), + )) + .get(); + return isUsableAccountToken(token ?? null) ? token!.id : null; +} function canonicalModelAlias(modelName: string): string { const normalized = modelName.trim().toLowerCase(); @@ -58,12 +67,12 @@ async function tokenSupportsModel(tokenId: number, modelName: string): Promise { - const row = await db.select().from(schema.accountTokens) - .where(and(eq(schema.accountTokens.id, tokenId), eq(schema.accountTokens.accountId, accountId))) - .get(); - return !!row; -} +async function checkTokenBelongsToAccount(tokenId: number, accountId: number): Promise { + const row = await db.select().from(schema.accountTokens) + .where(and(eq(schema.accountTokens.id, tokenId), eq(schema.accountTokens.accountId, accountId))) + .get(); + return isUsableAccountToken(row ?? null); +} async function getPatternTokenCandidates(modelPattern: string): Promise> { const rows = await db.select().from(schema.tokenModelAvailability) @@ -72,18 +81,20 @@ async function getPatternTokenCandidates(modelPattern: string): Promise = []; - for (const row of rows) { - const modelName = row.token_model_availability.modelName?.trim(); - if (!modelName) continue; + eq(schema.tokenModelAvailability.available, true), + eq(schema.accountTokens.enabled, true), + eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY), + eq(schema.accounts.status, 'active'), + eq(schema.sites.status, 'active'), + ), + ) + .all(); + + const result: Array<{ tokenId: number; accountId: number; sourceModel: string }> = []; + for (const row of rows) { + if (!isUsableAccountToken(row.account_tokens)) continue; + const modelName = row.token_model_availability.modelName?.trim(); + if (!modelName) continue; if (!matchesModelPattern(modelName, modelPattern)) continue; result.push({ tokenId: row.account_tokens.id, diff --git a/src/server/services/accountTokenService.ts b/src/server/services/accountTokenService.ts index 56954fa..da03492 100644 --- a/src/server/services/accountTokenService.ts +++ b/src/server/services/accountTokenService.ts @@ -9,6 +9,14 @@ type UpstreamApiToken = { tokenGroup?: string | null; }; +type AccountTokenRow = typeof schema.accountTokens.$inferSelect; + +export const ACCOUNT_TOKEN_VALUE_STATUS_READY = 'ready' as const; +export const ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING = 'masked_pending' as const; +export type AccountTokenValueStatus = + | typeof ACCOUNT_TOKEN_VALUE_STATUS_READY + | typeof ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING; + export function normalizeTokenForDisplay(token?: string | null, platform?: string | null): string { if (!token) return ''; const value = token.trim(); @@ -52,6 +60,45 @@ export function isMaskedTokenValue(token: string | null | undefined): boolean { return value.includes('*') || value.includes('•'); } +function normalizeTokenValueStatus(value: string | null | undefined): AccountTokenValueStatus { + const normalized = (value || '').trim().toLowerCase(); + return normalized === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING + ? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING + : ACCOUNT_TOKEN_VALUE_STATUS_READY; +} + +export function resolveAccountTokenValueStatus( + value: Pick | string | null | undefined, +): AccountTokenValueStatus { + if (typeof value === 'string' || value == null) { + return normalizeTokenValueStatus(value); + } + + const explicit = normalizeTokenValueStatus(value.valueStatus); + if (explicit === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING) { + return ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING; + } + return isMaskedTokenValue(value.token) + ? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING + : ACCOUNT_TOKEN_VALUE_STATUS_READY; +} + +export function isReadyAccountToken(token: Pick | null | undefined): boolean { + if (!token) return false; + return resolveAccountTokenValueStatus(token) === ACCOUNT_TOKEN_VALUE_STATUS_READY + && !isMaskedTokenValue(token.token); +} + +export function isMaskedPendingAccountToken(token: Pick | null | undefined): boolean { + if (!token) return false; + return resolveAccountTokenValueStatus(token) === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING; +} + +export function isUsableAccountToken(token: AccountTokenRow | null | undefined): boolean { + if (!token) return false; + return token.enabled === true && isReadyAccountToken(token); +} + function normalizeTokenGroup(value: string | null | undefined, tokenName?: string | null): string | null { const explicit = (value || '').trim(); if (explicit.length > 0) return explicit; @@ -66,6 +113,15 @@ function normalizeTokenGroup(value: string | null | undefined, tokenName?: strin return name; } +function sameTokenGroup( + leftGroup: string | null | undefined, + leftName: string | null | undefined, + rightGroup: string | null | undefined, + rightName: string | null | undefined, +): boolean { + return normalizeTokenGroup(leftGroup, leftName) === normalizeTokenGroup(rightGroup, rightName); +} + async function updateAccountApiToken(accountId: number, tokenValue: string | null) { await db.update(schema.accounts) .set({ apiToken: tokenValue || null, updatedAt: new Date().toISOString() }) @@ -85,9 +141,10 @@ export async function getPreferredAccountToken(accountId: number) { .where(and(eq(schema.accountTokens.accountId, accountId), eq(schema.accountTokens.enabled, true))) .all(); - if (tokens.length === 0) return null; + const usableTokens = tokens.filter(isUsableAccountToken); + if (usableTokens.length === 0) return null; - const preferred = tokens.find((t) => t.isDefault) || tokens[0]; + const preferred = usableTokens.find((t) => t.isDefault) || usableTokens[0]; return preferred; } @@ -98,6 +155,7 @@ export async function ensureDefaultTokenForAccount( ): Promise { const normalizedToken = normalizeTokenValue(tokenValue); if (!normalizedToken) return null; + if (isMaskedTokenValue(normalizedToken)) return null; const tokenGroup = normalizeTokenGroup(options?.tokenGroup, options?.name) || 'default'; const now = new Date().toISOString(); @@ -114,6 +172,7 @@ export async function ensureDefaultTokenForAccount( name: normalizeTokenName(options?.name, tokens.length + 1), token: normalizedToken, tokenGroup, + valueStatus: ACCOUNT_TOKEN_VALUE_STATUS_READY, source: options?.source || 'manual', enabled: options?.enabled ?? true, isDefault: true, @@ -131,6 +190,7 @@ export async function ensureDefaultTokenForAccount( .set({ name: options?.name ? normalizeTokenName(options.name) : target.name, tokenGroup, + valueStatus: ACCOUNT_TOKEN_VALUE_STATUS_READY, source: options?.source || target.source || 'manual', enabled: options?.enabled ?? target.enabled, isDefault: true, @@ -151,7 +211,7 @@ export async function ensureDefaultTokenForAccount( export async function setDefaultToken(tokenId: number): Promise { const target = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).get(); - if (!target) return false; + if (!target || !isUsableAccountToken(target)) return false; const now = new Date().toISOString(); await db.update(schema.accountTokens) @@ -174,7 +234,7 @@ export async function repairDefaultToken(accountId: number) { .where(eq(schema.accountTokens.accountId, accountId)) .all(); - const enabled = tokens.filter((t) => t.enabled); + const enabled = tokens.filter(isUsableAccountToken); if (enabled.length === 0) { await updateAccountApiToken(accountId, null); return null; @@ -206,27 +266,30 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens: let created = 0; let updated = 0; - let maskedSkipped = 0; + let maskedPending = 0; + const pendingTokenIds: number[] = []; let index = existing.length + 1; for (const upstream of upstreamTokens) { const tokenValue = normalizeTokenValue(upstream.key); if (!tokenValue) continue; - if (isMaskedTokenValue(tokenValue)) { - maskedSkipped++; - continue; - } - const tokenName = normalizeTokenName(upstream.name, index); const enabled = upstream.enabled ?? true; const tokenGroup = normalizeTokenGroup(upstream.tokenGroup, tokenName); + const nextValueStatus = isMaskedTokenValue(tokenValue) + ? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING + : ACCOUNT_TOKEN_VALUE_STATUS_READY; - const byToken = existing.find((row) => row.token === tokenValue); + const byToken = existing.find((row) => ( + row.token === tokenValue + && resolveAccountTokenValueStatus(row) === ACCOUNT_TOKEN_VALUE_STATUS_READY + )); if (byToken) { await db.update(schema.accountTokens) .set({ name: tokenName, tokenGroup, + valueStatus: ACCOUNT_TOKEN_VALUE_STATUS_READY, source: 'sync', enabled, updatedAt: now, @@ -235,6 +298,7 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens: .run(); byToken.name = tokenName; byToken.tokenGroup = tokenGroup; + byToken.valueStatus = ACCOUNT_TOKEN_VALUE_STATUS_READY; byToken.enabled = enabled; byToken.source = 'sync'; byToken.updatedAt = now; @@ -242,14 +306,52 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens: continue; } + const matchingPlaceholder = existing.find((row) => ( + isMaskedPendingAccountToken(row) + && row.name === tokenName + && sameTokenGroup(row.tokenGroup, row.name, tokenGroup, tokenName) + )); + + if (matchingPlaceholder) { + const nextEnabled = nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY ? enabled : false; + await db.update(schema.accountTokens) + .set({ + name: tokenName, + token: tokenValue, + tokenGroup, + valueStatus: nextValueStatus, + source: 'sync', + enabled: nextEnabled, + isDefault: false, + updatedAt: now, + }) + .where(eq(schema.accountTokens.id, matchingPlaceholder.id)) + .run(); + matchingPlaceholder.name = tokenName; + matchingPlaceholder.token = tokenValue; + matchingPlaceholder.tokenGroup = tokenGroup; + matchingPlaceholder.valueStatus = nextValueStatus; + matchingPlaceholder.source = 'sync'; + matchingPlaceholder.enabled = nextEnabled; + matchingPlaceholder.isDefault = false; + matchingPlaceholder.updatedAt = now; + updated++; + if (nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING) { + maskedPending++; + pendingTokenIds.push(matchingPlaceholder.id); + } + continue; + } + const inserted = await db.insert(schema.accountTokens) .values({ accountId, name: tokenName, token: tokenValue, tokenGroup, + valueStatus: nextValueStatus, source: 'sync', - enabled, + enabled: nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY ? enabled : false, isDefault: false, createdAt: now, updatedAt: now, @@ -263,6 +365,10 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens: existing.push(createdRow); created++; index++; + if (nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING) { + maskedPending++; + pendingTokenIds.push(createdRow.id); + } } const repaired = await repairDefaultToken(accountId); @@ -270,7 +376,8 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens: return { created, updated, - maskedSkipped, + maskedPending, + pendingTokenIds, total: existing.length, defaultTokenId: repaired?.id || null, }; @@ -292,6 +399,7 @@ export async function listTokensWithRelations(accountId?: number) { const { token, ...tokenMeta } = row.account_tokens; return { ...tokenMeta, + valueStatus: resolveAccountTokenValueStatus(row.account_tokens), tokenMasked: maskToken(token, row.sites.platform), account: { id: row.accounts.id, diff --git a/src/server/services/backupService.ts b/src/server/services/backupService.ts index af7e538..56e3442 100644 --- a/src/server/services/backupService.ts +++ b/src/server/services/backupService.ts @@ -238,6 +238,7 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS name: 'default', token: apiToken, tokenGroup: 'default', + valueStatus: 'ready', source: 'legacy', enabled: true, isDefault: true, @@ -520,6 +521,7 @@ async function importAccountsSection(section: AccountsBackupSection): Promise { reason: 'adapter_or_status', }); }); + + it('does not scan masked_pending placeholders as token credentials', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockImplementation(async (_baseUrl: string, token: string) => ( + token === 'sk-mask***tail' ? ['gpt-5.2-codex'] : [] + )); + + const site = await db.insert(schema.sites).values({ + name: 'site-placeholder', + url: 'https://site-placeholder.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'placeholder-user', + accessToken: '', + apiToken: null, + status: 'active', + extraConfig: JSON.stringify({ credentialMode: 'session' }), + }).returning().get(); + + const placeholder = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'masked-token', + token: 'sk-mask***tail', + source: 'sync', + enabled: true, + isDefault: false, + valueStatus: 'masked_pending' as any, + }).returning().get(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'failed', + tokenScanned: 0, + }); + + const placeholderModels = await db.select().from(schema.tokenModelAvailability) + .where(eq(schema.tokenModelAvailability.tokenId, placeholder.id)) + .all(); + expect(placeholderModels).toEqual([]); + expect(getModelsMock).not.toHaveBeenCalledWith(site.url, 'sk-mask***tail', account.username); + }); }); diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index 5d81716..f3751fe 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -1,7 +1,13 @@ import { and, eq } from 'drizzle-orm'; import { db, schema } from '../db/index.js'; import { getAdapter } from './platforms/index.js'; -import { ensureDefaultTokenForAccount, getPreferredAccountToken } from './accountTokenService.js'; +import { + ACCOUNT_TOKEN_VALUE_STATUS_READY, + ensureDefaultTokenForAccount, + getPreferredAccountToken, + isMaskedTokenValue, + isUsableAccountToken, +} from './accountTokenService.js'; import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from './accountExtraConfig.js'; import { invalidateTokenRouterCache } from './tokenRouter.js'; import { setAccountRuntimeHealth } from './accountHealthService.js'; @@ -256,20 +262,27 @@ export async function refreshModelsForAccount(accountId: number): Promise { const credential = (credentialRaw || '').trim(); if (!credential) return; + if (isMaskedTokenValue(credential)) return; if (attemptedCredentials.has(credential)) return; attemptedCredentials.add(credential); @@ -446,11 +465,13 @@ export async function rebuildTokenRoutesFromAvailability() { and( eq(schema.tokenModelAvailability.available, true), eq(schema.accountTokens.enabled, true), + eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY), eq(schema.accounts.status, 'active'), eq(schema.sites.status, 'active'), ), ) .all(); + const usableTokenRows = tokenRows.filter((row) => isUsableAccountToken(row.account_tokens)); const accountRows = await db.select().from(schema.modelAvailability) .innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id)) @@ -487,7 +508,7 @@ export async function rebuildTokenRoutesFromAvailability() { modelCandidates.get(modelName)!.set(candidateKey, { accountId, tokenId }); }; - for (const row of tokenRows) { + for (const row of usableTokenRows) { addModelCandidate(row.token_model_availability.modelName, row.accounts.id, row.account_tokens.id, row.accounts.siteId); } diff --git a/src/server/services/tokenRouter.ts b/src/server/services/tokenRouter.ts index 5f05b8f..441da94 100644 --- a/src/server/services/tokenRouter.ts +++ b/src/server/services/tokenRouter.ts @@ -9,6 +9,7 @@ import { type RouteRoutingStrategy, } from './routeRoutingStrategy.js'; import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js'; +import { isUsableAccountToken } from './accountTokenService.js'; interface RouteMatch { route: typeof schema.tokenRoutes.$inferSelect; @@ -1032,10 +1033,10 @@ export class TokenRouter { account: typeof schema.accounts.$inferSelect; site?: typeof schema.sites.$inferSelect | null; token: typeof schema.accountTokens.$inferSelect | null; - }): string | null { + }): string | null { if (candidate.channel.tokenId) { if (!candidate.token) return null; - if (!candidate.token.enabled) return null; + if (!isUsableAccountToken(candidate.token)) return null; const token = candidate.token.token?.trim(); return token ? token : null; } diff --git a/src/web/pages/Tokens.tsx b/src/web/pages/Tokens.tsx index 7667f79..0c3c13c 100644 --- a/src/web/pages/Tokens.tsx +++ b/src/web/pages/Tokens.tsx @@ -23,8 +23,11 @@ type AccountTokenSyncResult = { success?: boolean; synced?: boolean; message?: string; + reason?: string; created?: number; updated?: number; + maskedPending?: number; + pendingTokenIds?: number[]; accountId?: number; accountName?: string; account?: { @@ -65,6 +68,12 @@ const resolveSyncMessage = (result: AccountTokenSyncResult | null | undefined, f return message || fallback; }; +const isMaskedPendingToken = (token: any): boolean => token?.valueStatus === 'masked_pending'; + +const isMaskedPendingSyncResult = (result: AccountTokenSyncResult | null | undefined) => + String(result?.reason || '').trim().toLowerCase() === 'upstream_masked_tokens' + && Number(result?.maskedPending || 0) > 0; + const resolveAccountLabel = (result: AccountTokenSyncResult | null | undefined) => { const name = typeof result?.accountName === 'string' ? result.accountName.trim() : ''; if (name) return name; @@ -130,8 +139,10 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token const [savingEdit, setSavingEdit] = useState(false); const [editingToken, setEditingToken] = useState(null); const [editingTokenValueLoading, setEditingTokenValueLoading] = useState(false); + const [editingTokenPendingMessage, setEditingTokenPendingMessage] = useState(''); const [createHintModelName, setCreateHintModelName] = useState(''); const [highlightTokenId, setHighlightTokenId] = useState(null); + const [pendingAutoOpenTokenId, setPendingAutoOpenTokenId] = useState(null); const [rowLoading, setRowLoading] = useState>({}); const [selectedTokenIds, setSelectedTokenIds] = useState([]); const [batchActionLoading, setBatchActionLoading] = useState(false); @@ -173,8 +184,16 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token if (!hasCurrentSelected) { setSyncingAccountId(syncableAccounts[0]?.id || 0); } + return { + tokens: nextTokens, + accounts: latestAccounts, + }; } catch (e: any) { toast.error(e.message || '加载令牌失败'); + return { + tokens: [] as any[], + accounts: [] as any[], + }; } finally { setLoading(false); } @@ -346,6 +365,18 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token navigate({ pathname: location.pathname, search: cleanedSearch }, { replace: true }); }, [loading, location.pathname, location.search, navigate, tokens]); + const focusTokenRow = useCallback((tokenId: number) => { + const row = rowRefs.current.get(tokenId); + if (row) { + row.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + setHighlightTokenId(tokenId); + if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current); + highlightTimerRef.current = setTimeout(() => { + setHighlightTokenId((current) => (current === tokenId ? null : current)); + }, 2200); + }, []); + const withRowLoading = async (key: string, fn: () => Promise) => { setRowLoading((prev) => ({ ...prev, [key]: true })); try { @@ -422,13 +453,24 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token setCreateHintModelName(''); setEditingToken(token); editingTokenIdRef.current = token.id; + setEditingTokenPendingMessage( + isMaskedPendingToken(token) + ? '请粘贴完整明文 token;当前本地仅保存了上游返回的脱敏占位值。' + : '', + ); setEditForm({ name: token?.name || '', token: '', group: (token?.tokenGroup || '').trim() || 'default', - enabled: token?.enabled !== false, + enabled: isMaskedPendingToken(token) ? true : token?.enabled !== false, isDefault: !!token?.isDefault, }); + + if (isMaskedPendingToken(token)) { + setEditingTokenValueLoading(false); + return; + } + setEditingTokenValueLoading(true); void api.getAccountTokenValue(token.id) @@ -454,6 +496,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token setEditingToken(null); setSavingEdit(false); setEditingTokenValueLoading(false); + setEditingTokenPendingMessage(''); setEditForm({ name: '', token: '', @@ -465,6 +508,10 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token const saveEditPanel = async () => { if (!editingToken) return; + if (isMaskedPendingToken(editingToken) && !editForm.token.trim()) { + toast.error('请粘贴完整明文 token 后再保存'); + return; + } setSavingEdit(true); try { await api.updateAccountToken(editingToken.id, { @@ -484,6 +531,15 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token } }; + useEffect(() => { + if (!pendingAutoOpenTokenId || loading) return; + const token = tokens.find((item: any) => item.id === pendingAutoOpenTokenId); + if (!token) return; + focusTokenRow(token.id); + openEditPanel(token); + setPendingAutoOpenTokenId(null); + }, [focusTokenRow, loading, openEditPanel, pendingAutoOpenTokenId, tokens]); + const handleTokenRowClick = (tokenId: number, event: React.MouseEvent) => { if (shouldIgnoreRowSelectionClick(event.target)) return; const isSelected = selectedTokenIds.includes(tokenId); @@ -551,6 +607,25 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token const status = resolveSyncStatus(res); if (status === 'failed') { toast.error(`同步失败:${resolveSyncMessage(res, '请检查账号令牌或站点状态')}`); + } else if (isMaskedPendingSyncResult(res)) { + toast.info(resolveSyncMessage(res, '上游返回了脱敏令牌,请补全明文 token')); + const loaded = await load(); + const pendingIds = Array.isArray(res.pendingTokenIds) + ? res.pendingTokenIds.map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0) + : []; + const nextTokens = Array.isArray(loaded?.tokens) ? loaded.tokens : []; + if (pendingIds.length === 1) { + const pendingToken = nextTokens.find((token: any) => token.id === pendingIds[0]); + if (pendingToken) { + focusTokenRow(pendingToken.id); + openEditPanel(pendingToken); + } else { + setPendingAutoOpenTokenId(pendingIds[0] || null); + } + } else if (pendingIds.length > 1) { + focusTokenRow(pendingIds[0]!); + } + return; } else if (status === 'skipped') { toast.info(`同步已跳过:${resolveSyncMessage(res, '账号缺少可用 Session Cookie')}`); } else { @@ -562,7 +637,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token } finally { setSyncing(false); } - }, [load, syncingAccountId, toast]); + }, [focusTokenRow, load, openEditPanel, syncingAccountId, toast]); const handleSyncAll = useCallback(async () => { setSyncingAll(true); @@ -594,12 +669,16 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token const failedRows = syncResults.filter((item) => resolveSyncStatus(item) === 'failed'); const skippedRows = syncResults.filter((item) => resolveSyncStatus(item) === 'skipped'); const successRows = syncResults.filter((item) => resolveSyncStatus(item) === 'success'); + const maskedRows = syncResults.filter((item) => isMaskedPendingSyncResult(item)); toast.success(`全部同步完成:成功 ${successRows.length},跳过 ${skippedRows.length},失败 ${failedRows.length}`); failedRows.slice(0, 3).forEach((item) => { toast.error(`${resolveAccountLabel(item)} 同步失败:${resolveSyncMessage(item, '请检查账号配置')}`); }); + maskedRows.slice(0, 3).forEach((item) => { + toast.info(`${resolveAccountLabel(item)} 需要补全明文 token:${resolveSyncMessage(item, '上游返回脱敏令牌')}`); + }); skippedRows.slice(0, 3).forEach((item) => { toast.info(`${resolveAccountLabel(item)} 已跳过:${resolveSyncMessage(item, '不满足同步条件')}`); }); @@ -771,6 +850,20 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token > 账号: {editingToken.account?.username || `account-${editingToken.accountId}`} @ {editingToken.site?.name || '-'} + {editingTokenPendingMessage ? ( +
+ {editingTokenPendingMessage} +
+ ) : null}
基本信息
@@ -1008,6 +1101,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
{accountClusteredTokens.map((token: any) => { const loadingPrefix = `token-${token.id}`; + const isPending = isMaskedPendingToken(token); return ( - {token.enabled ? '启用' : '禁用'} + + {isPending ? '待补全' : (token.enabled ? '启用' : '禁用')} )} /> @@ -1060,7 +1154,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token />
- {!token.isDefault && ( + {!isPending && !token.isDefault && ( )} - + {!isPending ? ( + + ) : null} - + {!isPending ? ( + + ) : null} )} - + {!isPending ? ( + + ) : null} - + {!isPending ? ( + + ) : null}