Просмотр исходного кода

feat: 实现基于内容哈希的 Session 会话粘性机制

核心功能:
- 基于 messages 内容哈希(前 N 条,N = min(length, 3))识别 session
- 支持客户端主动传递 metadata.session_id(优先级最高)
- 同一 session 自动复用上次供应商(提升缓存命中率)
- 支持多个并发 session(通过 limitConcurrentSessions 限制)
- TTL 滑动窗口机制(每次请求刷新 5 分钟过期时间)

技术实现:
- 新增 SessionManager 模块(src/lib/session-manager.ts)
  * extractClientSessionId() - 从客户端提取 session_id
  * calculateMessagesHash() - 计算 messages 内容哈希(SHA-256)
  * getOrCreateSessionId() - 智能分配 session_id
  * bindSessionToProvider() - 绑定 session 到供应商
  * refreshSessionTTL() - 刷新 session 过期时间

- 新增 ProxySessionGuard(src/app/v1/_lib/proxy/session-guard.ts)
  * 在认证后、限流前分配 session_id

- 重构供应商复用逻辑(src/app/v1/_lib/proxy/provider-selector.ts)
  * 从基于 API Key 查询最近请求改为基于 session 从 Redis 读取
  * 选定供应商后自动绑定到 session

- 数据库变更(src/drizzle/schema.ts)
  * 添加 message_request.session_id 字段
  * 添加索引 idx_message_request_session_id
  * 生成迁移文件 drizzle/0001_ambiguous_bromley.sql

- 更新类型定义(src/types/message.ts)
  * MessageRequest 添加 sessionId 字段
  * CreateMessageRequestData 添加 session_id 字段

- 前端日志页面(src/app/dashboard/logs/_components/usage-logs-table.tsx)
  * 添加"会话"列,显示 session_id(前 8 字符)
  * 鼠标悬停显示完整 ID

- 修复 Redis 连接错误(src/lib/redis/session-stats.ts)
  * 添加连接状态检查(redis.status !== 'ready')

Redis 存储结构:
- hash:{contentHash}:session → session_id
- session:{sessionId}:provider → providerId
- session:{sessionId}:last_seen → timestamp
- session:{sessionId}:key → keyId
- key:{keyId}:active_sessions → Set<sessionId>

优势:
✅ 精准识别会话(基于内容哈希,避免误匹配)
✅ 智能供应商复用(提升上下文缓存命中率)
✅ 支持多客户端并行对话(一个 key 可有多个活跃 session)
✅ 完整日志追踪(可按 session_id 聚合查看对话链路)
✅ 滑动窗口 TTL(活跃对话自动续期)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
ding113 3 месяцев назад
Родитель
Сommit
989a12fe14

+ 2 - 0
drizzle/0001_ambiguous_bromley.sql

@@ -0,0 +1,2 @@
+ALTER TABLE "message_request" ADD COLUMN "session_id" varchar(64);--> statement-breakpoint
+CREATE INDEX "idx_message_request_session_id" ON "message_request" USING btree ("session_id") WHERE "message_request"."deleted_at" IS NULL;

+ 951 - 0
drizzle/meta/0001_snapshot.json

@@ -0,0 +1,951 @@
+{
+  "id": "3dbb5906-275d-4782-95b5-17f8feb4bced",
+  "prevId": "13008e39-9fe5-4b0a-ba5f-dfa116406751",
+  "version": "7",
+  "dialect": "postgresql",
+  "tables": {
+    "public.keys": {
+      "name": "keys",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": true
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_5h_usd": {
+          "name": "limit_5h_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_weekly_usd": {
+          "name": "limit_weekly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_monthly_usd": {
+          "name": "limit_monthly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_concurrent_sessions": {
+          "name": "limit_concurrent_sessions",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_keys_user_id": {
+          "name": "idx_keys_user_id",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_keys_created_at": {
+          "name": "idx_keys_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_keys_deleted_at": {
+          "name": "idx_keys_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.message_request": {
+      "name": "message_request",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "provider_id": {
+          "name": "provider_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "model": {
+          "name": "model",
+          "type": "varchar(128)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "duration_ms": {
+          "name": "duration_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "numeric(21, 15)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0'"
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "varchar(64)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "provider_chain": {
+          "name": "provider_chain",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "status_code": {
+          "name": "status_code",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "api_type": {
+          "name": "api_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_creation_input_tokens": {
+          "name": "cache_creation_input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_read_input_tokens": {
+          "name": "cache_read_input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "error_message": {
+          "name": "error_message",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_message_request_user_date_cost": {
+          "name": "idx_message_request_user_date_cost",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "cost_usd",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_user_query": {
+          "name": "idx_message_request_user_query",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_session_id": {
+          "name": "idx_message_request_session_id",
+          "columns": [
+            {
+              "expression": "session_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_provider_id": {
+          "name": "idx_message_request_provider_id",
+          "columns": [
+            {
+              "expression": "provider_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_user_id": {
+          "name": "idx_message_request_user_id",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_key": {
+          "name": "idx_message_request_key",
+          "columns": [
+            {
+              "expression": "key",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_created_at": {
+          "name": "idx_message_request_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_deleted_at": {
+          "name": "idx_message_request_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.model_prices": {
+      "name": "model_prices",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "model_name": {
+          "name": "model_name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "price_data": {
+          "name": "price_data",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "idx_model_prices_latest": {
+          "name": "idx_model_prices_latest",
+          "columns": [
+            {
+              "expression": "model_name",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": false,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_model_prices_model_name": {
+          "name": "idx_model_prices_model_name",
+          "columns": [
+            {
+              "expression": "model_name",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_model_prices_created_at": {
+          "name": "idx_model_prices_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": false,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.providers": {
+      "name": "providers",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "url": {
+          "name": "url",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "weight": {
+          "name": "weight",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 1
+        },
+        "priority": {
+          "name": "priority",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "cost_multiplier": {
+          "name": "cost_multiplier",
+          "type": "numeric(10, 4)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'1.0'"
+        },
+        "group_tag": {
+          "name": "group_tag",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "provider_type": {
+          "name": "provider_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'claude'"
+        },
+        "model_redirects": {
+          "name": "model_redirects",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_5h_usd": {
+          "name": "limit_5h_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_weekly_usd": {
+          "name": "limit_weekly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_monthly_usd": {
+          "name": "limit_monthly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_concurrent_sessions": {
+          "name": "limit_concurrent_sessions",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "tpm": {
+          "name": "tpm",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "rpm": {
+          "name": "rpm",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "rpd": {
+          "name": "rpd",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "cc": {
+          "name": "cc",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_providers_enabled_priority": {
+          "name": "idx_providers_enabled_priority",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "priority",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "weight",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"providers\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_providers_group": {
+          "name": "idx_providers_group",
+          "columns": [
+            {
+              "expression": "group_tag",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"providers\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_providers_created_at": {
+          "name": "idx_providers_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_providers_deleted_at": {
+          "name": "idx_providers_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.system_settings": {
+      "name": "system_settings",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "site_title": {
+          "name": "site_title",
+          "type": "varchar(128)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'Claude Code Hub'"
+        },
+        "allow_global_usage_view": {
+          "name": "allow_global_usage_view",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.users": {
+      "name": "users",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "role": {
+          "name": "role",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'user'"
+        },
+        "rpm_limit": {
+          "name": "rpm_limit",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 60
+        },
+        "daily_limit_usd": {
+          "name": "daily_limit_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'100.00'"
+        },
+        "provider_group": {
+          "name": "provider_group",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_users_active_role_sort": {
+          "name": "idx_users_active_role_sort",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "role",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"users\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_users_created_at": {
+          "name": "idx_users_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_users_deleted_at": {
+          "name": "idx_users_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    }
+  },
+  "enums": {},
+  "schemas": {},
+  "sequences": {},
+  "roles": {},
+  "policies": {},
+  "views": {},
+  "_meta": {
+    "columns": {},
+    "schemas": {},
+    "tables": {}
+  }
+}

+ 7 - 0
drizzle/meta/_journal.json

@@ -8,6 +8,13 @@
       "when": 1761057311271,
       "tag": "0000_legal_brother_voodoo",
       "breakpoints": true
+    },
+    {
+      "idx": 1,
+      "version": "7",
+      "when": 1761113092032,
+      "tag": "0001_ambiguous_bromley",
+      "breakpoints": true
     }
   ]
 }

+ 9 - 1
src/app/dashboard/logs/_components/usage-logs-table.tsx

@@ -43,6 +43,7 @@ export function UsageLogsTable({
               <TableHead>时间</TableHead>
               <TableHead>用户</TableHead>
               <TableHead>密钥</TableHead>
+              <TableHead>会话</TableHead>
               <TableHead>供应商</TableHead>
               <TableHead>模型</TableHead>
               <TableHead className="text-right">输入</TableHead>
@@ -57,7 +58,7 @@ export function UsageLogsTable({
           <TableBody>
             {logs.length === 0 ? (
               <TableRow>
-                <TableCell colSpan={12} className="text-center text-muted-foreground">
+                <TableCell colSpan={13} className="text-center text-muted-foreground">
                   暂无数据
                 </TableCell>
               </TableRow>
@@ -72,6 +73,13 @@ export function UsageLogsTable({
                   </TableCell>
                   <TableCell>{log.userName}</TableCell>
                   <TableCell className="font-mono text-xs">{log.keyName}</TableCell>
+                  <TableCell className="font-mono text-xs">
+                    {log.sessionId ? (
+                      <span title={log.sessionId} className="cursor-help">
+                        {log.sessionId.slice(0, 8)}...
+                      </span>
+                    ) : '-'}
+                  </TableCell>
                   <TableCell>
                     {log.providerChain && log.providerChain.length > 0 ? (
                       <ProviderChainPopover

+ 6 - 2
src/app/v1/_lib/proxy-handler.ts

@@ -1,6 +1,7 @@
 import type { Context } from "hono";
 import { ProxySession } from "./proxy/session";
 import { ProxyAuthenticator } from "./proxy/auth-guard";
+import { ProxySessionGuard } from "./proxy/session-guard";
 import { ProxyRateLimitGuard } from "./proxy/rate-limit-guard";
 import { ProxyProviderResolver } from "./proxy/provider-selector";
 import { ProxyMessageService } from "./proxy/message-service";
@@ -19,13 +20,16 @@ export async function handleProxyRequest(c: Context): Promise<Response> {
       return unauthorized;
     }
 
-    // 2. 限流检查(新增)
+    // 2. Session 分配(新增)
+    await ProxySessionGuard.ensure(session);
+
+    // 3. 限流检查
     const rateLimited = await ProxyRateLimitGuard.ensure(session);
     if (rateLimited) {
       return rateLimited;
     }
 
-    // 3. 供应商选择
+    // 4. 供应商选择
     const providerUnavailable = await ProxyProviderResolver.ensure(session);
     if (providerUnavailable) {
       return providerUnavailable;

+ 2 - 1
src/app/v1/_lib/proxy/message-service.ts

@@ -15,7 +15,8 @@ export class ProxyMessageService {
       provider_id: provider.id,
       user_id: authState.user.id,
       key: authState.apiKey,
-      model: session.request.model ?? undefined
+      model: session.request.model ?? undefined,
+      session_id: session.sessionId ?? undefined,  // 新增:传入 session_id
     });
 
     session.setMessageContext({

+ 22 - 12
src/app/v1/_lib/proxy/provider-selector.ts

@@ -1,7 +1,7 @@
 import type { Provider } from "@/types/provider";
 import { findProviderList, findProviderById } from "@/repository/provider";
-import { findLatestMessageRequestByKey } from "@/repository/message";
 import { RateLimitService } from "@/lib/rate-limit";
+import { SessionManager } from "@/lib/session-manager";
 import { isCircuitOpen, getCircuitState } from "@/lib/circuit-breaker";
 import { ProxyLogger } from "./logger";
 import { ProxyResponses } from "./responses";
@@ -12,7 +12,7 @@ export class ProxyProviderResolver {
     // 标记选择方法
     let selectionMethod: 'reuse' | 'random' | 'group_filter' | 'fallback' = 'random';
 
-    // 尝试复用之前的供应商
+    // 尝试复用之前的供应商(基于 session)
     const reusedProvider = await ProxyProviderResolver.findReusable(session, targetProviderType);
     if (reusedProvider) {
       session.setProvider(reusedProvider);
@@ -24,13 +24,19 @@ export class ProxyProviderResolver {
       session.setProvider(await ProxyProviderResolver.pickRandomProvider(session, [], targetProviderType));
     }
 
-    // 关键修复:选定供应商后立即记录到决策链
+    // 关键修复:选定供应商后立即记录到决策链并绑定到 session
     if (session.provider) {
       session.addProviderToChain(session.provider, {
         reason: 'initial_selection',
         selectionMethod,
         circuitState: getCircuitState(session.provider.id),
       });
+
+      // 绑定 session 到 provider(异步,不阻塞)
+      if (session.sessionId) {
+        void SessionManager.bindSessionToProvider(session.sessionId, session.provider.id);
+      }
+
       return null;
     }
 
@@ -55,23 +61,25 @@ export class ProxyProviderResolver {
     return this.pickRandomProvider(session, excludeIds, targetProviderType);
   }
 
+  /**
+   * 查找可复用的供应商(基于 session)
+   */
   private static async findReusable(session: ProxySession, targetProviderType: 'claude' | 'codex'): Promise<Provider | null> {
-    if (!session.shouldReuseProvider()) {
+    if (!session.shouldReuseProvider() || !session.sessionId) {
       return null;
     }
 
-    const apiKey = session.authState?.apiKey;
-    if (!apiKey) {
+    // 从 Redis 读取该 session 绑定的 provider
+    const providerId = await SessionManager.getSessionProvider(session.sessionId);
+    if (!providerId) {
+      console.debug(`[ProviderSelector] Session ${session.sessionId} has no bound provider`);
       return null;
     }
 
-    const latestRequest = await findLatestMessageRequestByKey(apiKey);
-    if (!latestRequest?.providerId) {
-      return null;
-    }
-
-    const provider = await findProviderById(latestRequest.providerId);
+    // 验证 provider 可用性
+    const provider = await findProviderById(providerId);
     if (!provider || !provider.isEnabled) {
+      console.debug(`[ProviderSelector] Session ${session.sessionId} provider ${providerId} unavailable`);
       return null;
     }
 
@@ -81,6 +89,7 @@ export class ProxyProviderResolver {
       return null;
     }
 
+    console.info(`[ProviderSelector] Reusing provider ${provider.name} (id=${provider.id}) for session ${session.sessionId}`);
     return provider;
   }
 
@@ -280,3 +289,4 @@ export class ProxyProviderResolver {
     return providers[providers.length - 1];
   }
 }
+

+ 3 - 9
src/app/v1/_lib/proxy/response-handler.ts

@@ -333,7 +333,7 @@ async function trackCostToRedis(
   session: ProxySession,
   usage: UsageMetrics | null
 ): Promise<void> {
-  if (!usage) return;
+  if (!usage || !session.sessionId) return;
 
   const messageContext = session.messageContext;
   const provider = session.provider;
@@ -351,17 +351,11 @@ async function trackCostToRedis(
   const cost = calculateRequestCost(usage, priceData.priceData, provider.costMultiplier);
   if (cost.lte(0)) return;
 
-  // 获取 sessionId(优先使用 conversation_id)
-  const conversationId = typeof session.request.message === 'object' && session.request.message !== null
-    ? (session.request.message as Record<string, unknown>).conversation_id
-    : null;
-  const sessionId = typeof conversationId === 'string' ? conversationId : `msg_${messageContext.id}`;
-
-  // 追踪到 Redis
+  // 追踪到 Redis(使用 session.sessionId)
   await RateLimitService.trackCost(
     key.id,
     provider.id,
-    sessionId,
+    session.sessionId,  // 直接使用 session.sessionId
     parseFloat(cost.toString())
   );
 }

+ 47 - 0
src/app/v1/_lib/proxy/session-guard.ts

@@ -0,0 +1,47 @@
+import type { ProxySession } from './session';
+import { SessionManager } from '@/lib/session-manager';
+
+/**
+ * Session 守卫:负责为请求分配 Session ID
+ *
+ * 调用时机:在认证成功后、限流检查前
+ */
+export class ProxySessionGuard {
+  /**
+   * 为请求分配 Session ID
+   */
+  static async ensure(session: ProxySession): Promise<void> {
+    const keyId = session.authState?.key?.id;
+    if (!keyId) {
+      console.warn('[ProxySessionGuard] No key ID, skipping session assignment');
+      return;
+    }
+
+    try {
+      // 1. 尝试从客户端提取 session_id(metadata.session_id)
+      const clientSessionId = SessionManager.extractClientSessionId(session.request.message);
+
+      // 2. 获取 messages 数组
+      const messages = session.getMessages();
+
+      // 3. 获取或创建 session_id
+      const sessionId = await SessionManager.getOrCreateSessionId(
+        keyId,
+        messages,
+        clientSessionId
+      );
+
+      // 4. 设置到 session 对象
+      session.setSessionId(sessionId);
+
+      console.debug(
+        `[ProxySessionGuard] Session assigned: ${sessionId} (key=${keyId}, messagesLength=${session.getMessagesLength()}, clientProvided=${!!clientSessionId})`
+      );
+    } catch (error) {
+      console.error('[ProxySessionGuard] Failed to assign session:', error);
+      // 降级:生成新 session(不阻塞请求)
+      const fallbackSessionId = SessionManager.generateSessionId();
+      session.setSessionId(fallbackSessionId);
+    }
+  }
+}

+ 33 - 5
src/app/v1/_lib/proxy/session.ts

@@ -45,6 +45,9 @@ export class ProxySession {
   provider: Provider | null;
   messageContext: MessageContext | null;
 
+  // Session ID(用于会话粘性和并发限流)
+  sessionId: string | null;
+
   // Codex 支持:记录原始请求格式和供应商类型
   originalFormat: 'response' | 'openai' | 'claude' = 'claude';
   providerType: 'claude' | 'codex' | null = null;
@@ -70,6 +73,7 @@ export class ProxySession {
     this.authState = null;
     this.provider = null;
     this.messageContext = null;
+    this.sessionId = null;
     this.providerChain = [];
   }
 
@@ -120,6 +124,35 @@ export class ProxySession {
     }
   }
 
+  /**
+   * 设置 session ID
+   */
+  setSessionId(sessionId: string): void {
+    this.sessionId = sessionId;
+  }
+
+  /**
+   * 获取 messages 数组长度
+   */
+  getMessagesLength(): number {
+    const messages = (this.request.message as Record<string, unknown>).messages;
+    return Array.isArray(messages) ? messages.length : 0;
+  }
+
+  /**
+   * 获取 messages 数组
+   */
+  getMessages(): unknown {
+    return (this.request.message as Record<string, unknown>).messages;
+  }
+
+  /**
+   * 是否应该复用 provider(基于 messages 长度)
+   */
+  shouldReuseProvider(): boolean {
+    return this.getMessagesLength() > 1;
+  }
+
   /**
    * 添加供应商到决策链(带详细元数据)
    */
@@ -166,11 +199,6 @@ export class ProxySession {
   getProviderChain(): ProviderChainItem[] {
     return this.providerChain;
   }
-
-  shouldReuseProvider(): boolean {
-    const messages = (this.request.message as Record<string, unknown>).messages;
-    return Array.isArray(messages) && messages.length > 1;
-  }
 }
 
 function formatHeadersForLog(headers: Headers): string {

+ 5 - 0
src/drizzle/schema.ts

@@ -111,6 +111,9 @@ export const messageRequest = pgTable('message_request', {
   durationMs: integer('duration_ms'),
   costUsd: numeric('cost_usd', { precision: 21, scale: 15 }).default('0'),
 
+  // Session ID(用于会话粘性和日志追踪)
+  sessionId: varchar('session_id', { length: 64 }),
+
   // 上游决策链(记录尝试的供应商列表)
   providerChain: jsonb('provider_chain').$type<Array<{ id: number; name: string }>>(),
 
@@ -137,6 +140,8 @@ export const messageRequest = pgTable('message_request', {
   messageRequestUserDateCostIdx: index('idx_message_request_user_date_cost').on(table.userId, table.createdAt, table.costUsd).where(sql`${table.deletedAt} IS NULL`),
   // 优化用户查询的复合索引(按创建时间倒序)
   messageRequestUserQueryIdx: index('idx_message_request_user_query').on(table.userId, table.createdAt).where(sql`${table.deletedAt} IS NULL`),
+  // Session 查询索引(按 session 聚合查看对话)
+  messageRequestSessionIdIdx: index('idx_message_request_session_id').on(table.sessionId).where(sql`${table.deletedAt} IS NULL`),
   // 基础索引
   messageRequestProviderIdIdx: index('idx_message_request_provider_id').on(table.providerId),
   messageRequestUserIdIdx: index('idx_message_request_user_id').on(table.userId),

+ 6 - 0
src/lib/redis/session-stats.ts

@@ -17,6 +17,12 @@ export async function getActiveConcurrentSessions(): Promise<number> {
   }
 
   try {
+    // 检查 Redis 连接状态
+    if (redis.status !== 'ready') {
+      console.warn('[SessionStats] Redis not ready, status:', redis.status);
+      return 0; // Fail Open
+    }
+
     let cursor = '0';
     let count = 0;
     const pattern = 'session:*:last_seen';

+ 239 - 0
src/lib/session-manager.ts

@@ -0,0 +1,239 @@
+import crypto from 'crypto';
+import { getRedisClient } from './redis';
+
+/**
+ * Session 管理器
+ *
+ * 核心功能:
+ * 1. 基于 messages 内容哈希识别 session
+ * 2. 管理 session 与 provider 的绑定关系
+ * 3. 支持客户端主动传递 session_id
+ */
+export class SessionManager {
+  private static readonly SESSION_TTL = parseInt(process.env.SESSION_TTL || '300'); // 5 分钟
+
+  /**
+   * 从客户端请求中提取 session_id(支持 metadata 或 header)
+   */
+  static extractClientSessionId(requestMessage: Record<string, unknown>): string | null {
+    // 尝试从 metadata.session_id 提取
+    const metadata = requestMessage.metadata;
+    if (metadata && typeof metadata === 'object') {
+      const sessionId = (metadata as Record<string, unknown>).session_id;
+      if (typeof sessionId === 'string' && sessionId.length > 0) {
+        return sessionId;
+      }
+    }
+
+    // 未来可扩展:从 header 提取(需要在 ProxySession 中传递 headers)
+    return null;
+  }
+
+  /**
+   * 生成新的 session_id
+   * 格式:sess_{timestamp}_{random}
+   */
+  static generateSessionId(): string {
+    const timestamp = Date.now().toString(36);
+    const random = crypto.randomBytes(6).toString('hex');
+    return `sess_${timestamp}_${random}`;
+  }
+
+  /**
+   * 计算 messages 内容哈希(用于 session 匹配)
+   *
+   * @param messages - 消息数组
+   * @returns 哈希值(16 字符)或 null
+   */
+  static calculateMessagesHash(messages: unknown): string | null {
+    if (!Array.isArray(messages) || messages.length === 0) {
+      return null;
+    }
+
+    // 计算范围:前 N 条(N = min(length, 3))
+    const count = Math.min(messages.length, 3);
+    const contents: string[] = [];
+
+    for (let i = 0; i < count; i++) {
+      const message = messages[i];
+      if (message && typeof message === 'object') {
+        const content = (message as Record<string, unknown>).content;
+        if (typeof content === 'string') {
+          contents.push(content);
+        } else if (Array.isArray(content)) {
+          // 支持多模态 content(数组格式)
+          const textParts = content
+            .filter((item) => item && typeof item === 'object' && (item as Record<string, unknown>).type === 'text')
+            .map((item) => (item as Record<string, unknown>).text);
+          contents.push(textParts.join(''));
+        }
+      }
+    }
+
+    if (contents.length === 0) {
+      return null;
+    }
+
+    // 拼接并计算 SHA-256 哈希
+    const combined = contents.join('|');
+    const hash = crypto.createHash('sha256').update(combined, 'utf8').digest('hex');
+
+    // 截取前 16 字符(足够区分,节省存储)
+    return hash.substring(0, 16);
+  }
+
+  /**
+   * 获取或创建 session_id(核心方法)
+   *
+   * @param keyId - API Key ID
+   * @param messages - 消息数组
+   * @param clientSessionId - 客户端传递的 session_id(可选)
+   * @returns session_id
+   */
+  static async getOrCreateSessionId(
+    keyId: number,
+    messages: unknown,
+    clientSessionId?: string | null
+  ): Promise<string> {
+    const redis = getRedisClient();
+
+    // 1. 优先使用客户端传递的 session_id
+    if (clientSessionId) {
+      // 刷新 TTL(滑动窗口)
+      if (redis && redis.status === 'ready') {
+        await this.refreshSessionTTL(clientSessionId, keyId).catch((err) => {
+          console.error('[SessionManager] Failed to refresh TTL:', err);
+        });
+      }
+      return clientSessionId;
+    }
+
+    // 2. 计算 messages 哈希
+    const contentHash = this.calculateMessagesHash(messages);
+    if (!contentHash) {
+      // 降级:无法计算哈希,生成新 session
+      console.warn('[SessionManager] Cannot calculate hash, generating new session');
+      return this.generateSessionId();
+    }
+
+    // 3. 尝试从 Redis 查找已有 session
+    if (redis && redis.status === 'ready') {
+      try {
+        const hashKey = `hash:${contentHash}:session`;
+        const existingSessionId = await redis.get(hashKey);
+
+        if (existingSessionId) {
+          // 找到已有 session,刷新 TTL
+          await this.refreshSessionTTL(existingSessionId, keyId);
+          console.debug(`[SessionManager] Reusing session ${existingSessionId} via hash ${contentHash}`);
+          return existingSessionId;
+        }
+
+        // 未找到:创建新 session
+        const newSessionId = this.generateSessionId();
+
+        // 存储映射关系(异步,不阻塞)
+        void this.storeSessionMapping(contentHash, newSessionId, keyId);
+
+        console.debug(`[SessionManager] Created new session ${newSessionId} with hash ${contentHash}`);
+        return newSessionId;
+      } catch (error) {
+        console.error('[SessionManager] Redis error:', error);
+        // 降级:Redis 错误,生成新 session
+        return this.generateSessionId();
+      }
+    }
+
+    // 4. Redis 不可用,降级生成新 session
+    return this.generateSessionId();
+  }
+
+  /**
+   * 存储 hash → session 映射关系
+   */
+  private static async storeSessionMapping(
+    contentHash: string,
+    sessionId: string,
+    keyId: number
+  ): Promise<void> {
+    const redis = getRedisClient();
+    if (!redis || redis.status !== 'ready') return;
+
+    try {
+      const pipeline = redis.pipeline();
+      const hashKey = `hash:${contentHash}:session`;
+
+      // 存储映射关系
+      pipeline.setex(hashKey, this.SESSION_TTL, sessionId);
+
+      // 初始化 session 元数据
+      pipeline.setex(`session:${sessionId}:key`, this.SESSION_TTL, keyId.toString());
+      pipeline.setex(`session:${sessionId}:last_seen`, this.SESSION_TTL, Date.now().toString());
+
+      await pipeline.exec();
+    } catch (error) {
+      console.error('[SessionManager] Failed to store session mapping:', error);
+    }
+  }
+
+  /**
+   * 刷新 session TTL(滑动窗口)
+   */
+  private static async refreshSessionTTL(sessionId: string, keyId: number): Promise<void> {
+    const redis = getRedisClient();
+    if (!redis || redis.status !== 'ready') return;
+
+    try {
+      const pipeline = redis.pipeline();
+
+      // 刷新所有 session 相关 key 的 TTL
+      pipeline.expire(`session:${sessionId}:key`, this.SESSION_TTL);
+      pipeline.expire(`session:${sessionId}:provider`, this.SESSION_TTL);
+      pipeline.setex(`session:${sessionId}:last_seen`, this.SESSION_TTL, Date.now().toString());
+
+      // 刷新 active_sessions 集合的 TTL
+      pipeline.expire(`key:${keyId}:active_sessions`, this.SESSION_TTL);
+
+      await pipeline.exec();
+    } catch (error) {
+      console.error('[SessionManager] Failed to refresh TTL:', error);
+    }
+  }
+
+  /**
+   * 绑定 session 到 provider
+   */
+  static async bindSessionToProvider(sessionId: string, providerId: number): Promise<void> {
+    const redis = getRedisClient();
+    if (!redis || redis.status !== 'ready') return;
+
+    try {
+      await redis.setex(`session:${sessionId}:provider`, this.SESSION_TTL, providerId.toString());
+      console.debug(`[SessionManager] Bound session ${sessionId} to provider ${providerId}`);
+    } catch (error) {
+      console.error('[SessionManager] Failed to bind provider:', error);
+    }
+  }
+
+  /**
+   * 获取 session 绑定的 provider
+   */
+  static async getSessionProvider(sessionId: string): Promise<number | null> {
+    const redis = getRedisClient();
+    if (!redis || redis.status !== 'ready') return null;
+
+    try {
+      const value = await redis.get(`session:${sessionId}:provider`);
+      if (value) {
+        const providerId = parseInt(value, 10);
+        if (!isNaN(providerId)) {
+          return providerId;
+        }
+      }
+    } catch (error) {
+      console.error('[SessionManager] Failed to get session provider:', error);
+    }
+
+    return null;
+  }
+}

+ 2 - 0
src/repository/message.ts

@@ -22,6 +22,7 @@ export async function createMessageRequest(data: CreateMessageRequestData): Prom
     model: data.model,
     durationMs: data.duration_ms,
     costUsd: formattedCost ?? undefined,
+    sessionId: data.session_id,  // 新增:Session ID
   };
 
   const [result] = await db.insert(messageRequest).values(dbData).returning({
@@ -32,6 +33,7 @@ export async function createMessageRequest(data: CreateMessageRequestData): Prom
     model: messageRequest.model,
     durationMs: messageRequest.durationMs,
     costUsd: messageRequest.costUsd,
+    sessionId: messageRequest.sessionId,  // 新增
     createdAt: messageRequest.createdAt,
     updatedAt: messageRequest.updatedAt,
     deletedAt: messageRequest.deletedAt,

+ 2 - 0
src/repository/usage-logs.ts

@@ -20,6 +20,7 @@ export interface UsageLogFilters {
 export interface UsageLogRow {
   id: number;
   createdAt: Date | null;
+  sessionId: string | null;  // 新增:Session ID
   userName: string;
   keyName: string;
   providerName: string;
@@ -151,6 +152,7 @@ export async function findUsageLogsWithDetails(
     .select({
       id: messageRequest.id,
       createdAt: messageRequest.createdAt,
+      sessionId: messageRequest.sessionId,  // 新增:Session ID
       userName: users.name,
       keyName: keysTable.name,
       providerName: providers.name,

+ 6 - 0
src/types/message.ts

@@ -41,6 +41,9 @@ export interface MessageRequest {
   durationMs?: number;
   costUsd?: string; // 单次请求费用(美元),保持高精度字符串表示
 
+  // Session ID(用于会话粘性和日志追踪)
+  sessionId?: string;
+
   // 上游决策链(记录尝试的供应商列表)
   providerChain?: ProviderChainItem[];
 
@@ -72,6 +75,9 @@ export interface CreateMessageRequestData {
   duration_ms?: number;
   cost_usd?: Numeric; // 单次请求费用(美元),支持高精度
 
+  // Session ID(用于会话粘性和日志追踪)
+  session_id?: string;
+
   // 上游决策链
   provider_chain?: ProviderChainItem[];