diff --git a/.gitignore b/.gitignore index 09d22e7d9..e927f4fd6 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ /coverage-my-usage /coverage-proxy-guard-pipeline /coverage-thinking-signature-rectifier +/coverage-logs-sessionid-time-filter +/coverage-usage-logs-sessionid-search # next.js /.next/ diff --git a/docs/dashboard-logs-callchain.md b/docs/dashboard-logs-callchain.md new file mode 100644 index 000000000..da37381b7 --- /dev/null +++ b/docs/dashboard-logs-callchain.md @@ -0,0 +1,119 @@ +# Dashboard Logs(Usage Logs)入口与调用链盘点 + +本文用于锁定 `/dashboard/logs` 的真实入口与关键调用链边界,避免后续需求实现与验收口径跑偏。 + +## 1) 路由入口(Server) + +- 路由:`/dashboard/logs` +- 入口页面:`src/app/[locale]/dashboard/logs/page.tsx` + - 登录态校验:`getSession()`(未登录重定向到 `/login`) + - 数据区块入口:`UsageLogsDataSection`(`src/app/[locale]/dashboard/logs/_components/usage-logs-sections.tsx`) + +## 2) 真实渲染链路(Client) + +当前页面实际使用“虚拟列表”链路: + +- 虚拟列表入口:`UsageLogsViewVirtualized`(`src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx`) + - URL -> filters 解析:`parseLogsUrlFilters()`(`src/app/[locale]/dashboard/logs/_utils/logs-query.ts`) + - filters -> URL 回填:`buildLogsUrlQuery()`(`src/app/[locale]/dashboard/logs/_utils/logs-query.ts`) + - Filters 面板:`UsageLogsFilters` + - 列表:`VirtualizedLogsTable` + - 统计面板:`UsageLogsStatsPanel` + +仓库内仍存在“非虚拟表格”实现(目前不被路由引用,属于历史/备用路径): + +- `UsageLogsView`(`src/app/[locale]/dashboard/logs/_components/usage-logs-view.tsx`) +- `UsageLogsTable`(`src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx`) + +## 3) 过滤器 / URL / 时间语义 + +- URL 参数解析/构建(统一入口):`src/app/[locale]/dashboard/logs/_utils/logs-query.ts` + - `sessionId`:字符串(trim 后空值不落盘) + - `startTime/endTime`:毫秒时间戳 +- 秒级时间工具:`src/app/[locale]/dashboard/logs/_utils/time-range.ts` + - UI endTime 为“包含式”秒;对后端转换为“排他上界”(`endExclusive = endInclusive + 1s`) + - 后端查询语义保持:`created_at >= startTime` 且 `created_at < endTime` + +## 4) 数据获取链路(Actions -> Repository) + +### 列表(无限滚动) + +- Action:`src/actions/usage-logs.ts#getUsageLogsBatch` +- Repo:`src/repository/usage-logs.ts#findUsageLogsBatch` + +### 统计(折叠面板按需加载) + +- Action:`src/actions/usage-logs.ts#getUsageLogsStats` +- Repo:`src/repository/usage-logs.ts#findUsageLogsStats` + +### 导出 CSV + +- Action:`src/actions/usage-logs.ts#exportUsageLogs` +- Repo:`src/repository/usage-logs.ts#findUsageLogsWithDetails` +- CSV 生成:`src/actions/usage-logs.ts#generateCsv` + +### Session ID 联想(候选查询) + +- Action:`src/actions/usage-logs.ts#getUsageLogSessionIdSuggestions` +- Repo:`src/repository/usage-logs.ts#findUsageLogSessionIdSuggestions` + +#### 匹配语义与边界(2026-01-15 更新) + +- **前端约束**: + - 最小长度:`SESSION_ID_SUGGESTION_MIN_LEN`(`src/lib/constants/usage-logs.constants.ts`) + - 最大长度截断:`SESSION_ID_SUGGESTION_MAX_LEN`(`src/actions/usage-logs.ts` 内对输入 trim 后截断) + - 每次返回数量:`SESSION_ID_SUGGESTION_LIMIT` +- **后端匹配**: + - 语义:仅支持「字面量前缀匹配」(`term%`),不再支持包含匹配(`%term%`) + - 安全:输入中的 `%` / `_` / `\\` 会被统一转义,避免被当作 LIKE 通配符 + - SQL(核心条件):`session_id LIKE '%' ESCAPE '\\'` + - 转义实现:`src/repository/_shared/like.ts#escapeLike` +- **行为变更示例**: + - 之前:输入 `abc` 可能命中 `xxxabcxxx`(包含匹配) + - 之后:仅命中 `abc...`(前缀匹配) + - 之前:输入 `%` / `_` 可主动触发通配 + - 之后:`%` / `_` 按字面量处理(例如输入 `%a` 只匹配以 `%a` 开头的 session_id) + +#### 索引与迁移(前缀匹配性能) + +- 已有索引:`idx_message_request_session_id`(`message_request.session_id`,partial: `deleted_at IS NULL`) +- 新增索引(前缀匹配):`idx_message_request_session_id_prefix` + - opclass:`varchar_pattern_ops` + - partial:`deleted_at IS NULL AND (blocked_by IS NULL OR blocked_by <> 'warmup')` + - 迁移文件:`drizzle/0055_neat_stepford_cuckoos.sql` + +## 5) 本需求相关影响面(文件/符号清单) + +**前端(logs 页面内聚)**: + +- URL/过滤器:`src/app/[locale]/dashboard/logs/_utils/logs-query.ts` +- 秒级时间:`src/app/[locale]/dashboard/logs/_utils/time-range.ts` +- 过滤器 UI:`src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx` +- 虚拟列表:`src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx` +- 非虚拟表格:`src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx` +- 统计面板:`src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.tsx` + +**后端(Actions/Repo)**: + +- Actions:`src/actions/usage-logs.ts` + - `getUsageLogsBatch/getUsageLogsStats/exportUsageLogs/getUsageLogSessionIdSuggestions` +- Repo:`src/repository/usage-logs.ts` + - `findUsageLogsBatch/findUsageLogsWithDetails/findUsageLogsStats/findUsageLogSessionIdSuggestions` + +**i18n(用户可见文案)**: + +- `messages/*/dashboard.json`(`dashboard.logs.filters.*` / `dashboard.logs.columns.*`) + +## 6) 边界说明(在范围内 / 不在范围内) + +在范围内(本次需求直接相关): + +- `sessionId` 精确筛选 + URL 回填 + UI 展示(列/复制/tooltip) +- 秒级时间输入与 `endExclusive` 语义对齐(`< endTime`) +- Session ID 联想(最小成本:minLen + debounce + limit) + +不在范围内(需另开 issue/评审确认后再做): + +- 针对联想查询的索引/物化/离线表(优化类工程) +- 大规模改动数据库 schema 或重建索引策略(例如 CONCURRENTLY/离线重建) +- Logs 页面其它过滤项语义调整(非本需求验收口径) diff --git a/docs/error-session-id-guide.md b/docs/error-session-id-guide.md new file mode 100644 index 000000000..97f1c9ce8 --- /dev/null +++ b/docs/error-session-id-guide.md @@ -0,0 +1,26 @@ +# Error Session ID Guide + +When reporting an API error, include the CCH session id so maintainers can locate the exact request. + +## Where to find it + +1. **Preferred**: response header `x-cch-session-id` +2. **Fallback**: `error.message` suffix `cch_session_id: ` + +If the response does not include a session id, the server could not determine it for that request. + +## Example (curl) + +```bash +curl -i -sS \\ + -H "Authorization: Bearer " \\ + -H "Content-Type: application/json" \\ + -d '{"model":"gpt-4.1-mini","messages":[{"role":"user","content":"hi"}]}' \\ + http://localhost:13500/v1/chat/completions +``` + +In the response: + +- Check header: `x-cch-session-id: ...` +- If missing, check JSON: `{"error":{"message":"... (cch_session_id: ...)"} }` + diff --git a/drizzle/0055_neat_stepford_cuckoos.sql b/drizzle/0055_neat_stepford_cuckoos.sql new file mode 100644 index 000000000..c91fed32a --- /dev/null +++ b/drizzle/0055_neat_stepford_cuckoos.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS "idx_message_request_session_id_prefix" ON "message_request" USING btree ("session_id" varchar_pattern_ops) WHERE "message_request"."deleted_at" IS NULL AND ("message_request"."blocked_by" IS NULL OR "message_request"."blocked_by" <> 'warmup'); diff --git a/drizzle/meta/0055_snapshot.json b/drizzle/meta/0055_snapshot.json new file mode 100644 index 000000000..939e558ff --- /dev/null +++ b/drizzle/meta/0055_snapshot.json @@ -0,0 +1,2404 @@ +{ + "id": "b40c930a-4001-4403-90b9-652a5878893c", + "prevId": "36887729-08df-4af3-98fe-d4fa87c7c5c7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_cause": { + "name": "error_cause", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_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_session_id_prefix": { + "name": "idx_message_request_session_id_prefix", + "columns": [ + { + "expression": "\"session_id\" varchar_pattern_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_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 + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'litellm'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_source": { + "name": "idx_model_prices_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'Asia/Shanghai'" + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "join_claude_pool": { + "name": "join_claude_pool", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_effort_preference": { + "name": "codex_reasoning_effort_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_summary_preference": { + "name": "codex_reasoning_summary_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_text_verbosity_preference": { + "name": "codex_text_verbosity_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_parallel_tool_calls_preference": { + "name": "codex_parallel_tool_calls_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "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.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_codex_session_id_completion": { + "name": "enable_codex_session_id_completion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_fixer": { + "name": "enable_response_fixer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_fixer_config": { + "name": "response_fixer_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb" + }, + "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 + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_active_role_sort": { + "name": "idx_users_active_role_sort", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_enabled_expires_at": { + "name": "idx_users_enabled_expires_at", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_created_at": { + "name": "idx_users_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_targets": { + "name": "webhook_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "webhook_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "webhook_url": { + "name": "webhook_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "telegram_bot_token": { + "name": "telegram_bot_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "dingtalk_secret": { + "name": "dingtalk_secret", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "custom_template": { + "name": "custom_template", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_result": { + "name": "last_test_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.daily_reset_mode": { + "name": "daily_reset_mode", + "schema": "public", + "values": [ + "fixed", + "rolling" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "circuit_breaker", + "daily_leaderboard", + "cost_alert" + ] + }, + "public.webhook_provider_type": { + "name": "webhook_provider_type", + "schema": "public", + "values": [ + "wechat", + "feishu", + "dingtalk", + "telegram", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 106e43116..a4148b04e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -386,6 +386,13 @@ "when": 1768240715707, "tag": "0054_tidy_winter_soldier", "breakpoints": true + }, + { + "idx": 55, + "version": "7", + "when": 1768443427816, + "tag": "0055_neat_stepford_cuckoos", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 499e57682..fed1a3327 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -60,10 +60,13 @@ "filters": { "user": "User", "provider": "Provider", + "sessionId": "Session ID", "searchUser": "Search users...", "searchProvider": "Search providers...", + "searchSessionId": "Search session IDs...", "noUserFound": "No matching users found", "noProviderFound": "No matching providers found", + "noSessionFound": "No matching session IDs found", "model": "Model", "endpoint": "Endpoint", "status": "Status", @@ -96,6 +99,7 @@ "time": "Time", "user": "User", "key": "Key", + "sessionId": "Session ID", "provider": "Provider", "model": "Billing Model", "endpoint": "Endpoint", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 071fe6dcd..57af4b2e4 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -60,10 +60,13 @@ "filters": { "user": "ユーザー", "provider": "プロバイダー", + "sessionId": "セッションID", "searchUser": "ユーザーを検索...", "searchProvider": "プロバイダーを検索...", + "searchSessionId": "セッションIDを検索...", "noUserFound": "一致するユーザーが見つかりません", "noProviderFound": "一致するプロバイダーが見つかりません", + "noSessionFound": "一致するセッションIDが見つかりません", "model": "モデル", "endpoint": "エンドポイント", "status": "ステータス", @@ -96,6 +99,7 @@ "time": "時間", "user": "ユーザー", "key": "キー", + "sessionId": "セッションID", "provider": "プロバイダー", "model": "課金モデル", "endpoint": "エンドポイント", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 3abb884e3..a7ad1418d 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -60,10 +60,13 @@ "filters": { "user": "Пользователь", "provider": "Поставщик", + "sessionId": "ID сессии", "searchUser": "Поиск пользователей...", "searchProvider": "Поиск провайдеров...", + "searchSessionId": "Поиск ID сессии...", "noUserFound": "Пользователи не найдены", "noProviderFound": "Провайдеры не найдены", + "noSessionFound": "ID сессии не найдены", "model": "Модель", "endpoint": "Эндпоинт", "status": "Статус", @@ -96,6 +99,7 @@ "time": "Время", "user": "Пользователь", "key": "Ключ", + "sessionId": "ID сессии", "provider": "Поставщик", "model": "Модель тарификации", "endpoint": "Эндпоинт", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 282ff6db9..9627bf0c6 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -60,10 +60,13 @@ "filters": { "user": "用户", "provider": "供应商", + "sessionId": "Session ID", "searchUser": "搜索用户...", "searchProvider": "搜索供应商...", + "searchSessionId": "搜索 Session ID...", "noUserFound": "未找到匹配的用户", "noProviderFound": "未找到匹配的供应商", + "noSessionFound": "未找到匹配的 Session ID", "model": "模型", "endpoint": "端点", "status": "状态", @@ -96,6 +99,7 @@ "time": "时间", "user": "用户", "key": "密钥", + "sessionId": "Session ID", "provider": "供应商", "model": "计费模型", "endpoint": "端点", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index 6bc25609b..887447252 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -60,10 +60,13 @@ "filters": { "user": "使用者", "provider": "供應商", + "sessionId": "Session ID", "searchUser": "搜尋使用者...", "searchProvider": "搜尋供應商...", + "searchSessionId": "搜尋 Session ID...", "noUserFound": "未找到匹配的使用者", "noProviderFound": "未找到匹配的供應商", + "noSessionFound": "未找到匹配的 Session ID", "model": "Model", "endpoint": "端點", "status": "狀態", @@ -96,6 +99,7 @@ "time": "時間", "user": "使用者", "key": "金鑰", + "sessionId": "Session ID", "provider": "供應商", "model": "計費模型", "endpoint": "端點", diff --git a/package.json b/package.json index 8e6f5aacb..c6eae1017 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,13 @@ "test:e2e": "vitest run --config vitest.e2e.config.ts --reporter=verbose", "test:integration": "vitest run --config vitest.integration.config.ts --reporter=verbose", "test:coverage": "vitest run --coverage", + "test:coverage:logs-sessionid-time-filter": "vitest run --config vitest.logs-sessionid-time-filter.config.ts --coverage", "test:coverage:codex-session-id-completer": "vitest run --config vitest.codex-session-id-completer.config.ts --coverage", "test:coverage:thinking-signature-rectifier": "vitest run --config vitest.thinking-signature-rectifier.config.ts --coverage", "test:coverage:quota": "vitest run --config vitest.quota.config.ts --coverage", "test:coverage:my-usage": "vitest run --config vitest.my-usage.config.ts --coverage", "test:coverage:proxy-guard-pipeline": "vitest run --config vitest.proxy-guard-pipeline.config.ts --coverage", + "test:coverage:include-session-id-in-errors": "vitest run --config vitest.include-session-id-in-errors.config.ts --coverage", "test:ci": "vitest run --reporter=default --reporter=junit --outputFile.junit=reports/vitest-junit.xml", "cui": "npx cui-server --host 0.0.0.0 --port 30000 --token a7564bc8882aa9a2d25d8b4ea6ea1e2e", "db:generate": "drizzle-kit generate && node scripts/validate-migrations.js", diff --git a/src/actions/usage-logs.ts b/src/actions/usage-logs.ts index 0c8a9e079..b0d612b0a 100644 --- a/src/actions/usage-logs.ts +++ b/src/actions/usage-logs.ts @@ -1,8 +1,14 @@ "use server"; import { getSession } from "@/lib/auth"; +import { + SESSION_ID_SUGGESTION_LIMIT, + SESSION_ID_SUGGESTION_MAX_LEN, + SESSION_ID_SUGGESTION_MIN_LEN, +} from "@/lib/constants/usage-logs.constants"; import { logger } from "@/lib/logger"; import { + findUsageLogSessionIdSuggestions, findUsageLogsBatch, findUsageLogsStats, findUsageLogsWithDetails, @@ -279,6 +285,53 @@ export async function getFilterOptions(): Promise> { } } +export interface UsageLogSessionIdSuggestionInput { + term: string; + userId?: number; + keyId?: number; + providerId?: number; +} + +export async function getUsageLogSessionIdSuggestions( + input: UsageLogSessionIdSuggestionInput +): Promise> { + try { + const session = await getSession(); + if (!session) { + return { ok: false, error: "未登录" }; + } + + const trimmedTerm = input.term.trim().slice(0, SESSION_ID_SUGGESTION_MAX_LEN); + if (trimmedTerm.length < SESSION_ID_SUGGESTION_MIN_LEN) { + return { ok: true, data: [] }; + } + + const finalFilters = + session.user.role === "admin" + ? { + term: trimmedTerm, + userId: input.userId, + keyId: input.keyId, + providerId: input.providerId, + limit: SESSION_ID_SUGGESTION_LIMIT, + } + : { + term: trimmedTerm, + userId: session.user.id, + keyId: input.keyId, + providerId: input.providerId, + limit: SESSION_ID_SUGGESTION_LIMIT, + }; + + const sessionIds = await findUsageLogSessionIdSuggestions(finalFilters); + return { ok: true, data: sessionIds }; + } catch (error) { + logger.error("获取 sessionId 联想失败:", error); + const message = error instanceof Error ? error.message : "获取 sessionId 联想失败"; + return { ok: false, error: message }; + } +} + /** * 获取使用日志聚合统计(独立接口,用于可折叠面板按需加载) * diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx index 3d35c1e0d..540c86eac 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx @@ -1,13 +1,13 @@ "use client"; -import { addDays, format, parse } from "date-fns"; +import { format } from "date-fns"; import { Check, ChevronsUpDown, Download } from "lucide-react"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { getKeys } from "@/actions/keys"; -import { exportUsageLogs } from "@/actions/usage-logs"; +import { exportUsageLogs, getUsageLogSessionIdSuggestions } from "@/actions/usage-logs"; import { searchUsersForFilter } from "@/actions/users"; import { Button } from "@/components/ui/button"; import { @@ -20,7 +20,7 @@ import { } from "@/components/ui/command"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, @@ -28,6 +28,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { SESSION_ID_SUGGESTION_MIN_LEN } from "@/lib/constants/usage-logs.constants"; import { useDebounce } from "@/lib/hooks/use-debounce"; import type { Key } from "@/types/key"; import type { ProviderDisplay } from "@/types/provider"; @@ -36,6 +37,11 @@ import { useLazyModels, useLazyStatusCodes, } from "../_hooks/use-lazy-filter-options"; +import { + dateStringWithClockToTimestamp, + formatClockFromTimestamp, + inclusiveEndTimestampFromExclusive, +} from "../_utils/time-range"; import { LogsDateRangePicker } from "./logs-date-range-picker"; // 硬编码常用状态码(首次渲染时显示,无需等待加载) @@ -51,6 +57,7 @@ interface UsageLogsFiltersProps { userId?: number; keyId?: number; providerId?: number; + sessionId?: string; /** 开始时间戳(毫秒,浏览器本地时区的 00:00:00) */ startTime?: number; /** 结束时间戳(毫秒,浏览器本地时区的次日 00:00:00,用于 < 比较) */ @@ -125,6 +132,12 @@ export function UsageLogsFilters({ const [isExporting, setIsExporting] = useState(false); const [userPopoverOpen, setUserPopoverOpen] = useState(false); const [providerPopoverOpen, setProviderPopoverOpen] = useState(false); + const [sessionIdPopoverOpen, setSessionIdPopoverOpen] = useState(false); + const [isSessionIdsLoading, setIsSessionIdsLoading] = useState(false); + const [availableSessionIds, setAvailableSessionIds] = useState([]); + const debouncedSessionIdSearchTerm = useDebounce(localFilters.sessionId ?? "", 300); + const sessionIdSearchRequestIdRef = useRef(0); + const lastLoadedSessionIdSuggestionsKeyRef = useRef(undefined); useEffect(() => { isMountedRef.current = true; @@ -181,6 +194,84 @@ export function UsageLogsFilters({ } }, [isAdmin, userPopoverOpen]); + const loadSessionIdsForFilter = useCallback( + async (term: string) => { + const requestId = ++sessionIdSearchRequestIdRef.current; + setIsSessionIdsLoading(true); + const requestKey = [ + term, + isAdmin ? (localFilters.userId ?? "").toString() : "", + (localFilters.keyId ?? "").toString(), + (localFilters.providerId ?? "").toString(), + isAdmin ? "1" : "0", + ].join("|"); + lastLoadedSessionIdSuggestionsKeyRef.current = requestKey; + + try { + const result = await getUsageLogSessionIdSuggestions({ + term, + userId: isAdmin ? localFilters.userId : undefined, + keyId: localFilters.keyId, + providerId: localFilters.providerId, + }); + + if (!isMountedRef.current || requestId !== sessionIdSearchRequestIdRef.current) return; + + if (result.ok) { + setAvailableSessionIds(result.data); + } else { + console.error("Failed to load sessionId suggestions:", result.error); + setAvailableSessionIds([]); + } + } catch (error) { + if (!isMountedRef.current || requestId !== sessionIdSearchRequestIdRef.current) return; + console.error("Failed to load sessionId suggestions:", error); + setAvailableSessionIds([]); + } finally { + if (isMountedRef.current && requestId === sessionIdSearchRequestIdRef.current) { + setIsSessionIdsLoading(false); + } + } + }, + [isAdmin, localFilters.keyId, localFilters.providerId, localFilters.userId] + ); + + useEffect(() => { + if (!sessionIdPopoverOpen) return; + + const term = debouncedSessionIdSearchTerm.trim(); + if (term.length < SESSION_ID_SUGGESTION_MIN_LEN) { + setAvailableSessionIds([]); + lastLoadedSessionIdSuggestionsKeyRef.current = undefined; + return; + } + + const requestKey = [ + term, + isAdmin ? (localFilters.userId ?? "").toString() : "", + (localFilters.keyId ?? "").toString(), + (localFilters.providerId ?? "").toString(), + isAdmin ? "1" : "0", + ].join("|"); + if (requestKey === lastLoadedSessionIdSuggestionsKeyRef.current) return; + void loadSessionIdsForFilter(term); + }, [ + sessionIdPopoverOpen, + debouncedSessionIdSearchTerm, + isAdmin, + localFilters.userId, + localFilters.keyId, + localFilters.providerId, + loadSessionIdsForFilter, + ]); + + useEffect(() => { + if (!sessionIdPopoverOpen) { + setAvailableSessionIds([]); + lastLoadedSessionIdSuggestionsKeyRef.current = undefined; + } + }, [sessionIdPopoverOpen]); + useEffect(() => { if (initialKeys.length > 0) { setKeys(initialKeys); @@ -228,7 +319,33 @@ export function UsageLogsFilters({ }; const handleApply = () => { - onChange(localFilters); + const { + userId, + keyId, + providerId, + sessionId, + startTime, + endTime, + statusCode, + excludeStatusCode200, + model, + endpoint, + minRetryCount, + } = localFilters; + + onChange({ + userId, + keyId, + providerId, + sessionId, + startTime, + endTime, + statusCode, + excludeStatusCode200, + model, + endpoint, + minRetryCount, + }); }; const handleReset = () => { @@ -272,24 +389,28 @@ export function UsageLogsFilters({ return format(date, "yyyy-MM-dd"); }, []); - // Helper: parse date string to timestamp (start of day in browser timezone) - const dateStringToTimestamp = useCallback((dateStr: string): number => { - const [year, month, day] = dateStr.split("-").map(Number); - return new Date(year, month - 1, day, 0, 0, 0, 0).getTime(); - }, []); - // Memoized startDate for display (from timestamp) const displayStartDate = useMemo(() => { if (!localFilters.startTime) return undefined; return timestampToDateString(localFilters.startTime); }, [localFilters.startTime, timestampToDateString]); - // Memoized endDate calculation: endTime is next day 00:00, subtract 1 day to show correct end date + const displayStartClock = useMemo(() => { + if (!localFilters.startTime) return undefined; + return formatClockFromTimestamp(localFilters.startTime); + }, [localFilters.startTime]); + + // Memoized endDate calculation: endTime is exclusive, use endTime-1s to infer inclusive display end date const displayEndDate = useMemo(() => { if (!localFilters.endTime) return undefined; - // endTime is next day 00:00, so subtract 1 day to get actual end date - const actualEndDate = new Date(localFilters.endTime - 24 * 60 * 60 * 1000); - return format(actualEndDate, "yyyy-MM-dd"); + const inclusiveEndTime = inclusiveEndTimestampFromExclusive(localFilters.endTime); + return format(new Date(inclusiveEndTime), "yyyy-MM-dd"); + }, [localFilters.endTime]); + + const displayEndClock = useMemo(() => { + if (!localFilters.endTime) return undefined; + const inclusiveEndTime = inclusiveEndTimestampFromExclusive(localFilters.endTime); + return formatClockFromTimestamp(inclusiveEndTime); }, [localFilters.endTime]); // Memoized callback for date range changes @@ -297,20 +418,21 @@ export function UsageLogsFilters({ (range: { startDate?: string; endDate?: string }) => { if (range.startDate && range.endDate) { // Convert to millisecond timestamps: - // startTime: start of selected start date (00:00:00.000 in browser timezone) - // endTime: start of day AFTER selected end date (for < comparison) - const startTimestamp = dateStringToTimestamp(range.startDate); - const endDate = parse(range.endDate, "yyyy-MM-dd", new Date()); - const nextDay = addDays(endDate, 1); - const endTimestamp = new Date( - nextDay.getFullYear(), - nextDay.getMonth(), - nextDay.getDate(), - 0, - 0, - 0, - 0 - ).getTime(); + // startTime: startDate + startClock (default 00:00:00) + // endTime: endDate + endClock as exclusive upper bound (endClock default 23:59:59) + const startClock = displayStartClock ?? "00:00:00"; + const endClock = displayEndClock ?? "23:59:59"; + const startTimestamp = dateStringWithClockToTimestamp(range.startDate, startClock); + const endInclusiveTimestamp = dateStringWithClockToTimestamp(range.endDate, endClock); + if (startTimestamp === undefined || endInclusiveTimestamp === undefined) { + setLocalFilters((prev) => ({ + ...prev, + startTime: undefined, + endTime: undefined, + })); + return; + } + const endTimestamp = endInclusiveTimestamp + 1000; setLocalFilters((prev) => ({ ...prev, startTime: startTimestamp, @@ -324,7 +446,7 @@ export function UsageLogsFilters({ })); } }, - [dateStringToTimestamp] + [displayEndClock, displayStartClock] ); return ( @@ -338,6 +460,56 @@ export function UsageLogsFilters({ endDate={displayEndDate} onDateRangeChange={handleDateRangeChange} /> +
+
+ + { + const nextClock = e.target.value || "00:00:00"; + setLocalFilters((prev) => { + if (!prev.startTime) return prev; + const dateStr = timestampToDateString(prev.startTime); + const startTime = dateStringWithClockToTimestamp(dateStr, nextClock); + if (startTime === undefined) return prev; + return { + ...prev, + startTime, + }; + }); + }} + /> +
+
+ + { + const nextClock = e.target.value || "23:59:59"; + setLocalFilters((prev) => { + if (!prev.endTime) return prev; + const inclusiveEndTime = inclusiveEndTimestampFromExclusive(prev.endTime); + const endDateStr = timestampToDateString(inclusiveEndTime); + const endInclusiveTimestamp = dateStringWithClockToTimestamp( + endDateStr, + nextClock + ); + if (endInclusiveTimestamp === undefined) return prev; + return { + ...prev, + endTime: endInclusiveTimestamp + 1000, + }; + }); + }} + /> +
+
{/* 用户选择(仅 Admin) */} @@ -532,6 +704,63 @@ export function UsageLogsFilters({ )} + {/* Session ID 联想 */} +
+ + + + { + const term = (localFilters.sessionId ?? "").trim(); + setSessionIdPopoverOpen(term.length >= SESSION_ID_SUGGESTION_MIN_LEN); + }} + onChange={(e) => { + const next = e.target.value.trim(); + setLocalFilters((prev) => ({ ...prev, sessionId: next || undefined })); + setSessionIdPopoverOpen(next.length >= SESSION_ID_SUGGESTION_MIN_LEN); + }} + /> + + e.preventDefault()} + onWheel={(e) => e.stopPropagation()} + onTouchMove={(e) => e.stopPropagation()} + > + + + + {isSessionIdsLoading + ? t("logs.stats.loading") + : t("logs.filters.noSessionFound")} + + + {availableSessionIds.map((sessionId) => ( + { + setLocalFilters((prev) => ({ ...prev, sessionId })); + setSessionIdPopoverOpen(false); + }} + className="cursor-pointer" + > + {sessionId} + {localFilters.sessionId === sessionId && ( + + )} + + ))} + + + + + +
+ {/* 模型选择 */}
diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.tsx index 113e42827..44c826698 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.tsx @@ -17,6 +17,7 @@ interface UsageLogsStatsPanelProps { userId?: number; keyId?: number; providerId?: number; + sessionId?: string; startTime?: number; endTime?: number; statusCode?: number; diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx index b0b8e6c30..952055075 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx @@ -10,6 +10,15 @@ vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key, })); +const toastMocks = vi.hoisted(() => ({ + success: vi.fn(), + error: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: toastMocks, +})); + vi.mock("@/components/ui/tooltip", () => ({ TooltipProvider: ({ children }: { children?: ReactNode }) =>
{children}
, Tooltip: ({ children }: { children?: ReactNode }) =>
{children}
, @@ -181,4 +190,51 @@ describe("usage-logs-table multiplier badge", () => { }); container.remove(); }); + + test("copies sessionId on click and shows toast", async () => { + const writeText = vi.fn(async () => {}); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + Object.defineProperty(window, "isSecureContext", { + value: true, + configurable: true, + }); + + const container = document.createElement("div"); + document.body.appendChild(container); + + const root = createRoot(container); + await act(async () => { + root.render( + {}} + isPending={false} + /> + ); + }); + + const sessionBtn = Array.from(container.querySelectorAll("button")).find((b) => + (b.textContent ?? "").includes("session_test") + ); + expect(sessionBtn).not.toBeUndefined(); + + await act(async () => { + sessionBtn?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + expect(writeText).toHaveBeenCalledWith("session_test"); + expect(toastMocks.success).toHaveBeenCalledWith("actions.copied"); + + await act(async () => { + root.unmount(); + }); + container.remove(); + }); }); diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx index c02b907dc..eba9c8f4b 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx @@ -1,7 +1,8 @@ "use client"; import { useTranslations } from "next-intl"; -import { useState } from "react"; +import { type MouseEvent, useCallback, useState } from "react"; +import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { RelativeTime } from "@/components/ui/relative-time"; @@ -15,6 +16,7 @@ import { } from "@/components/ui/table"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { cn, formatTokenAmount } from "@/lib/utils"; +import { copyTextToClipboard } from "@/lib/utils/clipboard"; import type { CurrencyCode } from "@/lib/utils/currency"; import { formatCurrency } from "@/lib/utils/currency"; import { @@ -62,6 +64,18 @@ export function UsageLogsTable({ scrollToRedirect: boolean; }>({ logId: null, scrollToRedirect: false }); + const handleCopySessionIdClick = useCallback( + (event: MouseEvent) => { + const sessionId = event.currentTarget.dataset.sessionId; + if (!sessionId) return; + + void copyTextToClipboard(sessionId).then((ok) => { + if (ok) toast.success(t("actions.copied")); + }); + }, + [t] + ); + return (
@@ -71,6 +85,7 @@ export function UsageLogsTable({ {t("logs.columns.time")} {t("logs.columns.user")} {t("logs.columns.key")} + {t("logs.columns.sessionId")} {t("logs.columns.provider")} {t("logs.columns.model")} {t("logs.columns.tokens")} @@ -83,7 +98,7 @@ export function UsageLogsTable({ {logs.length === 0 ? ( - + {t("logs.table.noData")} @@ -129,6 +144,31 @@ export function UsageLogsTable({ {log.userName} {log.keyName} + + {log.sessionId ? ( + + + + + + +

+ {log.sessionId} +

+
+
+
+ ) : ( + - + )} +
{isWarmupSkipped ? ( // Warmup 被跳过的请求显示“抢答/跳过”标记 diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx index 396920af2..2e8d19cab 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx @@ -13,6 +13,7 @@ import type { CurrencyCode } from "@/lib/utils/currency"; import type { Key } from "@/types/key"; import type { ProviderDisplay } from "@/types/provider"; import type { BillingModelSource, SystemSettings } from "@/types/system-config"; +import { buildLogsUrlQuery, parseLogsUrlFilters } from "../_utils/logs-query"; import { UsageLogsFilters } from "./usage-logs-filters"; import { UsageLogsStatsPanel } from "./usage-logs-stats-panel"; import { VirtualizedLogsTable, type VirtualizedLogsTableFilters } from "./virtualized-logs-table"; @@ -91,40 +92,33 @@ function UsageLogsViewContent({ const resolvedKeys = initialKeys ?? (keysResult?.ok && keysResult.data ? keysResult.data : []); // Parse filters from URL with stable reference - const filters = useMemo( - () => ({ - userId: searchParams.userId ? parseInt(searchParams.userId as string, 10) : undefined, - keyId: searchParams.keyId ? parseInt(searchParams.keyId as string, 10) : undefined, - providerId: searchParams.providerId - ? parseInt(searchParams.providerId as string, 10) - : undefined, - startTime: searchParams.startTime - ? parseInt(searchParams.startTime as string, 10) - : undefined, - endTime: searchParams.endTime ? parseInt(searchParams.endTime as string, 10) : undefined, - statusCode: - searchParams.statusCode && searchParams.statusCode !== "!200" - ? parseInt(searchParams.statusCode as string, 10) - : undefined, - excludeStatusCode200: searchParams.statusCode === "!200", - model: searchParams.model as string | undefined, - endpoint: searchParams.endpoint as string | undefined, - minRetryCount: searchParams.minRetry - ? parseInt(searchParams.minRetry as string, 10) - : undefined, - }), - [ - searchParams.userId, - searchParams.keyId, - searchParams.providerId, - searchParams.startTime, - searchParams.endTime, - searchParams.statusCode, - searchParams.model, - searchParams.endpoint, - searchParams.minRetry, - ] - ); + const filters = useMemo(() => { + return parseLogsUrlFilters({ + userId: searchParams.userId, + keyId: searchParams.keyId, + providerId: searchParams.providerId, + sessionId: searchParams.sessionId, + startTime: searchParams.startTime, + endTime: searchParams.endTime, + statusCode: searchParams.statusCode, + model: searchParams.model, + endpoint: searchParams.endpoint, + minRetry: searchParams.minRetry, + page: searchParams.page, + }) as VirtualizedLogsTableFilters & { page?: number }; + }, [ + searchParams.userId, + searchParams.keyId, + searchParams.providerId, + searchParams.sessionId, + searchParams.startTime, + searchParams.endTime, + searchParams.statusCode, + searchParams.model, + searchParams.endpoint, + searchParams.minRetry, + searchParams.page, + ]); // Manual refresh handler const handleManualRefresh = useCallback(async () => { @@ -138,24 +132,7 @@ function UsageLogsViewContent({ // Handle filter changes const handleFilterChange = (newFilters: Omit) => { - const query = new URLSearchParams(); - - if (newFilters.userId) query.set("userId", newFilters.userId.toString()); - if (newFilters.keyId) query.set("keyId", newFilters.keyId.toString()); - if (newFilters.providerId) query.set("providerId", newFilters.providerId.toString()); - if (newFilters.startTime) query.set("startTime", newFilters.startTime.toString()); - if (newFilters.endTime) query.set("endTime", newFilters.endTime.toString()); - if (newFilters.excludeStatusCode200) { - query.set("statusCode", "!200"); - } else if (newFilters.statusCode !== undefined) { - query.set("statusCode", newFilters.statusCode.toString()); - } - if (newFilters.model) query.set("model", newFilters.model); - if (newFilters.endpoint) query.set("endpoint", newFilters.endpoint); - if (newFilters.minRetryCount !== undefined) { - query.set("minRetry", newFilters.minRetryCount.toString()); - } - + const query = buildLogsUrlQuery(newFilters); router.push(`/dashboard/logs?${query.toString()}`); }; @@ -181,6 +158,7 @@ function UsageLogsViewContent({ userId: filters.userId, keyId: filters.keyId, providerId: filters.providerId, + sessionId: filters.sessionId, startTime: filters.startTime, endTime: filters.endTime, statusCode: filters.statusCode, diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-view.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-view.tsx index 379f8212c..677f4e573 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-view.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-view.tsx @@ -13,6 +13,7 @@ import type { UsageLogsResult } from "@/repository/usage-logs"; import type { Key } from "@/types/key"; import type { ProviderDisplay } from "@/types/provider"; import type { BillingModelSource } from "@/types/system-config"; +import { buildLogsUrlQuery, parseLogsUrlFilters } from "../_utils/logs-query"; import { UsageLogsFilters } from "./usage-logs-filters"; import { UsageLogsStatsPanel } from "./usage-logs-stats-panel"; import { UsageLogsTable } from "./usage-logs-table"; @@ -50,39 +51,8 @@ export function UsageLogsView({ // 从 URL 参数解析筛选条件 // 使用毫秒时间戳传递时间,避免时区问题 - const filters: { - userId?: number; - keyId?: number; - providerId?: number; - startTime?: number; - endTime?: number; - statusCode?: number; - excludeStatusCode200?: boolean; - model?: string; - endpoint?: string; - minRetryCount?: number; - page: number; - } = { - userId: searchParams.userId ? parseInt(searchParams.userId as string, 10) : undefined, - keyId: searchParams.keyId ? parseInt(searchParams.keyId as string, 10) : undefined, - providerId: searchParams.providerId - ? parseInt(searchParams.providerId as string, 10) - : undefined, - // 使用毫秒时间戳,无时区歧义 - startTime: searchParams.startTime ? parseInt(searchParams.startTime as string, 10) : undefined, - endTime: searchParams.endTime ? parseInt(searchParams.endTime as string, 10) : undefined, - statusCode: - searchParams.statusCode && searchParams.statusCode !== "!200" - ? parseInt(searchParams.statusCode as string, 10) - : undefined, - excludeStatusCode200: searchParams.statusCode === "!200", - model: searchParams.model as string | undefined, - endpoint: searchParams.endpoint as string | undefined, - minRetryCount: searchParams.minRetry - ? parseInt(searchParams.minRetry as string, 10) - : undefined, - page: searchParams.page ? parseInt(searchParams.page as string, 10) : 1, - }; + const parsedFilters = parseLogsUrlFilters(searchParams); + const filters = { ...parsedFilters, page: parsedFilters.page ?? 1 } as const; // 使用 ref 来存储最新的值,避免闭包陷阱 const isPendingRef = useRef(isPending); @@ -176,25 +146,7 @@ export function UsageLogsView({ // 处理筛选条件变更 const handleFilterChange = (newFilters: Omit) => { - const query = new URLSearchParams(); - - if (newFilters.userId) query.set("userId", newFilters.userId.toString()); - if (newFilters.keyId) query.set("keyId", newFilters.keyId.toString()); - if (newFilters.providerId) query.set("providerId", newFilters.providerId.toString()); - // 使用毫秒时间戳传递时间,无时区歧义 - if (newFilters.startTime) query.set("startTime", newFilters.startTime.toString()); - if (newFilters.endTime) query.set("endTime", newFilters.endTime.toString()); - if (newFilters.excludeStatusCode200) { - query.set("statusCode", "!200"); - } else if (newFilters.statusCode !== undefined) { - query.set("statusCode", newFilters.statusCode.toString()); - } - if (newFilters.model) query.set("model", newFilters.model); - if (newFilters.endpoint) query.set("endpoint", newFilters.endpoint); - if (newFilters.minRetryCount !== undefined) { - query.set("minRetry", newFilters.minRetryCount.toString()); - } - + const query = buildLogsUrlQuery(newFilters); router.push(`/dashboard/logs?${query.toString()}`); }; @@ -213,6 +165,7 @@ export function UsageLogsView({ userId: filters.userId, keyId: filters.keyId, providerId: filters.providerId, + sessionId: filters.sessionId, startTime: filters.startTime, endTime: filters.endTime, statusCode: filters.statusCode, diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx index e38646a3d..6989abb0f 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx @@ -3,7 +3,8 @@ import { useInfiniteQuery } from "@tanstack/react-query"; import { ArrowUp, Loader2 } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { type MouseEvent, useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; import { getUsageLogsBatch } from "@/actions/usage-logs"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -11,6 +12,7 @@ import { RelativeTime } from "@/components/ui/relative-time"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { useVirtualizer } from "@/hooks/use-virtualizer"; import { cn, formatTokenAmount } from "@/lib/utils"; +import { copyTextToClipboard } from "@/lib/utils/clipboard"; import type { CurrencyCode } from "@/lib/utils/currency"; import { formatCurrency } from "@/lib/utils/currency"; import { @@ -31,6 +33,7 @@ export interface VirtualizedLogsTableFilters { userId?: number; keyId?: number; providerId?: number; + sessionId?: string; startTime?: number; endTime?: number; statusCode?: number; @@ -66,6 +69,18 @@ export function VirtualizedLogsTable({ scrollToRedirect: boolean; }>({ logId: null, scrollToRedirect: false }); + const handleCopySessionIdClick = useCallback( + (event: MouseEvent) => { + const sessionId = event.currentTarget.dataset.sessionId; + if (!sessionId) return; + + void copyTextToClipboard(sessionId).then((ok) => { + if (ok) toast.success(t("actions.copied")); + }); + }, + [t] + ); + // Infinite query with cursor-based pagination const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError, error } = useInfiniteQuery({ @@ -179,6 +194,12 @@ export function VirtualizedLogsTable({
{t("logs.columns.key")}
+
+ {t("logs.columns.sessionId")} +
+ {/* Session ID */} +
+ {log.sessionId ? ( + + + + + + +

+ {log.sessionId} +

+
+
+
+ ) : ( + - + )} +
+ {/* Provider */}
{log.blockedBy ? ( diff --git a/src/app/[locale]/dashboard/logs/_utils/logs-query.ts b/src/app/[locale]/dashboard/logs/_utils/logs-query.ts new file mode 100644 index 000000000..4430f3854 --- /dev/null +++ b/src/app/[locale]/dashboard/logs/_utils/logs-query.ts @@ -0,0 +1,93 @@ +export interface LogsUrlFilters { + userId?: number; + keyId?: number; + providerId?: number; + sessionId?: string; + startTime?: number; + endTime?: number; + statusCode?: number; + excludeStatusCode200?: boolean; + model?: string; + endpoint?: string; + minRetryCount?: number; + page?: number; +} + +function firstString(value: string | string[] | undefined): string | undefined { + if (Array.isArray(value)) return value[0]; + return value; +} + +function parseIntParam(value: string | string[] | undefined): number | undefined { + const raw = firstString(value); + if (!raw) return undefined; + const num = Number.parseInt(raw, 10); + return Number.isFinite(num) ? num : undefined; +} + +function parseStringParam(value: string | string[] | undefined): string | undefined { + const raw = firstString(value); + const trimmed = raw?.trim(); + return trimmed ? trimmed : undefined; +} + +export function parseLogsUrlFilters(searchParams: { + [key: string]: string | string[] | undefined; +}): LogsUrlFilters { + const statusCodeParam = parseStringParam(searchParams.statusCode); + const pageRaw = parseIntParam(searchParams.page); + const page = pageRaw && pageRaw >= 1 ? pageRaw : undefined; + + const statusCode = + statusCodeParam && statusCodeParam !== "!200" + ? Number.parseInt(statusCodeParam, 10) + : undefined; + + return { + userId: parseIntParam(searchParams.userId), + keyId: parseIntParam(searchParams.keyId), + providerId: parseIntParam(searchParams.providerId), + sessionId: parseStringParam(searchParams.sessionId), + startTime: parseIntParam(searchParams.startTime), + endTime: parseIntParam(searchParams.endTime), + statusCode: Number.isFinite(statusCode) ? statusCode : undefined, + excludeStatusCode200: statusCodeParam === "!200", + model: parseStringParam(searchParams.model), + endpoint: parseStringParam(searchParams.endpoint), + minRetryCount: parseIntParam(searchParams.minRetry), + page, + }; +} + +export function buildLogsUrlQuery(filters: LogsUrlFilters): URLSearchParams { + const query = new URLSearchParams(); + + if (filters.userId !== undefined) query.set("userId", filters.userId.toString()); + if (filters.keyId !== undefined) query.set("keyId", filters.keyId.toString()); + if (filters.providerId !== undefined) query.set("providerId", filters.providerId.toString()); + + const sessionId = filters.sessionId?.trim(); + if (sessionId) query.set("sessionId", sessionId); + + if (filters.startTime !== undefined) query.set("startTime", filters.startTime.toString()); + if (filters.endTime !== undefined) query.set("endTime", filters.endTime.toString()); + + if (filters.excludeStatusCode200) { + query.set("statusCode", "!200"); + } else if (filters.statusCode !== undefined) { + query.set("statusCode", filters.statusCode.toString()); + } + + if (filters.model) query.set("model", filters.model); + if (filters.endpoint) query.set("endpoint", filters.endpoint); + + if (filters.minRetryCount !== undefined) { + query.set("minRetry", filters.minRetryCount.toString()); + } + + if (filters.page !== undefined && filters.page > 1) { + query.set("page", filters.page.toString()); + } + + return query; +} diff --git a/src/app/[locale]/dashboard/logs/_utils/time-range.ts b/src/app/[locale]/dashboard/logs/_utils/time-range.ts new file mode 100644 index 000000000..2ccf628e8 --- /dev/null +++ b/src/app/[locale]/dashboard/logs/_utils/time-range.ts @@ -0,0 +1,49 @@ +export interface ClockParts { + hours: number; + minutes: number; + seconds: number; +} + +export function parseClockString(clockStr: string): ClockParts { + const [hoursRaw, minutesRaw, secondsRaw] = clockStr.split(":"); + + const hours = Number(hoursRaw); + const minutes = Number(minutesRaw); + const seconds = Number(secondsRaw ?? "0"); + + return { + hours: Number.isFinite(hours) ? hours : 0, + minutes: Number.isFinite(minutes) ? minutes : 0, + seconds: Number.isFinite(seconds) ? seconds : 0, + }; +} + +export function formatClockFromTimestamp(timestamp: number): string { + const date = new Date(timestamp); + const hh = `${date.getHours()}`.padStart(2, "0"); + const mm = `${date.getMinutes()}`.padStart(2, "0"); + const ss = `${date.getSeconds()}`.padStart(2, "0"); + return `${hh}:${mm}:${ss}`; +} + +export function dateStringWithClockToTimestamp( + dateStr: string, + clockStr: string +): number | undefined { + const [year, month, day] = dateStr.split("-").map(Number); + const { hours, minutes, seconds } = parseClockString(clockStr); + + const date = new Date(year, month - 1, day, hours, minutes, seconds, 0); + const timestamp = date.getTime(); + if (!Number.isFinite(timestamp)) return undefined; + + if (date.getFullYear() !== year) return undefined; + if (date.getMonth() !== month - 1) return undefined; + if (date.getDate() !== day) return undefined; + + return timestamp; +} + +export function inclusiveEndTimestampFromExclusive(endExclusiveTimestamp: number): number { + return Math.max(0, endExclusiveTimestamp - 1000); +} diff --git a/src/app/v1/_lib/codex/chat-completions-handler.ts b/src/app/v1/_lib/codex/chat-completions-handler.ts index 1b93880e1..184fc9dd8 100644 --- a/src/app/v1/_lib/codex/chat-completions-handler.ts +++ b/src/app/v1/_lib/codex/chat-completions-handler.ts @@ -10,13 +10,14 @@ import type { Context } from "hono"; import { logger } from "@/lib/logger"; import { ProxyStatusTracker } from "@/lib/proxy-status-tracker"; import { SessionTracker } from "@/lib/session-tracker"; -import { ProxyErrorHandler } from "../proxy/error-handler"; -import { ProxyError } from "../proxy/errors"; -import { ProxyForwarder } from "../proxy/forwarder"; -import { GuardPipelineBuilder, RequestType } from "../proxy/guard-pipeline"; -import { ProxyResponseHandler } from "../proxy/response-handler"; -import { ProxyResponses } from "../proxy/responses"; -import { ProxySession } from "../proxy/session"; +import { ProxyErrorHandler } from "@/app/v1/_lib/proxy/error-handler"; +import { ProxyError } from "@/app/v1/_lib/proxy/errors"; +import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder"; +import { GuardPipelineBuilder, RequestType } from "@/app/v1/_lib/proxy/guard-pipeline"; +import { attachSessionIdToErrorResponse } from "@/app/v1/_lib/proxy/error-session-id"; +import { ProxyResponseHandler } from "@/app/v1/_lib/proxy/response-handler"; +import { ProxyResponses } from "@/app/v1/_lib/proxy/responses"; +import { ProxySession } from "@/app/v1/_lib/proxy/session"; import type { ChatCompletionRequest } from "./types/compatible"; /** @@ -45,7 +46,7 @@ export async function handleChatCompletions(c: Context): Promise { const isResponseAPIFormat = "input" in request && Array.isArray(request.input); if (!isOpenAIFormat && !isResponseAPIFormat) { - return new Response( + const response = new Response( JSON.stringify({ error: { message: @@ -56,6 +57,7 @@ export async function handleChatCompletions(c: Context): Promise { }), { status: 400, headers: { "Content-Type": "application/json" } } ); + return await attachSessionIdToErrorResponse(session.sessionId, response); } if (isOpenAIFormat) { @@ -144,7 +146,7 @@ export async function handleChatCompletions(c: Context): Promise { // 验证必需字段 if (!request.model) { - return new Response( + const response = new Response( JSON.stringify({ error: { message: "Invalid request: model is required", @@ -154,6 +156,7 @@ export async function handleChatCompletions(c: Context): Promise { }), { status: 400, headers: { "Content-Type": "application/json" } } ); + return await attachSessionIdToErrorResponse(session.sessionId, response); } } @@ -161,7 +164,9 @@ export async function handleChatCompletions(c: Context): Promise { const pipeline = GuardPipelineBuilder.fromRequestType(type); const early = await pipeline.run(session); - if (early) return early; + if (early) { + return await attachSessionIdToErrorResponse(session.sessionId, early); + } // 增加并发计数(在所有检查通过后,请求开始前)- 跳过 count_tokens if (session.sessionId && !session.isCountTokensRequest()) { @@ -187,7 +192,8 @@ export async function handleChatCompletions(c: Context): Promise { const response = await ProxyForwarder.send(session); // 5. 响应处理(自动转换回 OpenAI 格式) - return await ProxyResponseHandler.dispatch(session, response); + const handled = await ProxyResponseHandler.dispatch(session, response); + return await attachSessionIdToErrorResponse(session.sessionId, handled); } catch (error) { logger.error("[ChatCompletions] Handler error:", error); if (session) { diff --git a/src/app/v1/_lib/proxy-handler.ts b/src/app/v1/_lib/proxy-handler.ts index 32f3242a9..ee38cbf41 100644 --- a/src/app/v1/_lib/proxy-handler.ts +++ b/src/app/v1/_lib/proxy-handler.ts @@ -9,6 +9,7 @@ import { ProxyForwarder } from "./proxy/forwarder"; import { GuardPipelineBuilder, RequestType } from "./proxy/guard-pipeline"; import { ProxyResponseHandler } from "./proxy/response-handler"; import { ProxyResponses } from "./proxy/responses"; +import { attachSessionIdToErrorResponse } from "./proxy/error-session-id"; import { ProxySession } from "./proxy/session"; export async function handleProxyRequest(c: Context): Promise { @@ -54,7 +55,9 @@ export async function handleProxyRequest(c: Context): Promise { // Run guard chain; may return early Response const early = await pipeline.run(session); - if (early) return early; + if (early) { + return await attachSessionIdToErrorResponse(session.sessionId, early); + } // 9. 增加并发计数(在所有检查通过后,请求开始前)- 跳过 count_tokens if (session.sessionId && !session.isCountTokensRequest()) { @@ -76,7 +79,8 @@ export async function handleProxyRequest(c: Context): Promise { } const response = await ProxyForwarder.send(session); - return await ProxyResponseHandler.dispatch(session, response); + const handled = await ProxyResponseHandler.dispatch(session, response); + return await attachSessionIdToErrorResponse(session.sessionId, handled); } catch (error) { logger.error("Proxy handler error:", error); if (session) { diff --git a/src/app/v1/_lib/proxy/error-handler.ts b/src/app/v1/_lib/proxy/error-handler.ts index c14e53c89..000ae7efc 100644 --- a/src/app/v1/_lib/proxy/error-handler.ts +++ b/src/app/v1/_lib/proxy/error-handler.ts @@ -14,6 +14,7 @@ import { ProxyError, type RateLimitError, } from "./errors"; +import { attachSessionIdToErrorResponse } from "./error-session-id"; import { ProxyResponses } from "./responses"; import type { ProxySession } from "./session"; @@ -60,7 +61,7 @@ export class ProxyErrorHandler { rateLimitMetadata ); - return response; + return await attachSessionIdToErrorResponse(session.sessionId, response); } // 识别 ProxyError,提取详细信息(包含上游响应) @@ -132,21 +133,27 @@ export class ProxyErrorHandler { }); // 跳过响应体覆写,但仍可应用状态码覆写 if (override.statusCode !== null) { - return ProxyResponses.buildError( - responseStatusCode, + return await attachSessionIdToErrorResponse( + session.sessionId, + ProxyResponses.buildError( + responseStatusCode, + clientErrorMessage, + undefined, + undefined, + safeRequestId + ) + ); + } + // 两者都无效,返回原始错误(但仍透传 request_id,因为有覆写意图) + return await attachSessionIdToErrorResponse( + session.sessionId, + ProxyResponses.buildError( + statusCode, clientErrorMessage, undefined, undefined, safeRequestId - ); - } - // 两者都无效,返回原始错误(但仍透传 request_id,因为有覆写意图) - return ProxyResponses.buildError( - statusCode, - clientErrorMessage, - undefined, - undefined, - safeRequestId + ) ); } @@ -187,10 +194,13 @@ export class ProxyErrorHandler { overridden: true, }); - return new Response(JSON.stringify(responseBody), { - status: responseStatusCode, - headers: { "Content-Type": "application/json" }, - }); + return await attachSessionIdToErrorResponse( + session.sessionId, + new Response(JSON.stringify(responseBody), { + status: responseStatusCode, + headers: { "Content-Type": "application/json" }, + }) + ); } // 情况 2: 仅状态码覆写 - 返回客户端安全消息,但使用覆写的状态码 @@ -207,12 +217,15 @@ export class ProxyErrorHandler { overridden: true, }); - return ProxyResponses.buildError( - responseStatusCode, - clientErrorMessage, - undefined, - undefined, - safeRequestId + return await attachSessionIdToErrorResponse( + session.sessionId, + ProxyResponses.buildError( + responseStatusCode, + clientErrorMessage, + undefined, + undefined, + safeRequestId + ) ); } } @@ -223,7 +236,10 @@ export class ProxyErrorHandler { overridden: false, }); - return ProxyResponses.buildError(statusCode, clientErrorMessage); + return await attachSessionIdToErrorResponse( + session.sessionId, + ProxyResponses.buildError(statusCode, clientErrorMessage) + ); } /** diff --git a/src/app/v1/_lib/proxy/error-session-id.ts b/src/app/v1/_lib/proxy/error-session-id.ts new file mode 100644 index 000000000..eb4827b34 --- /dev/null +++ b/src/app/v1/_lib/proxy/error-session-id.ts @@ -0,0 +1,56 @@ +export function attachSessionIdToErrorMessage( + sessionId: string | null | undefined, + message: string +): string { + if (!sessionId) return message; + if (message.includes("cch_session_id:")) return message; + return `${message} (cch_session_id: ${sessionId})`; +} + +export async function attachSessionIdToErrorResponse( + sessionId: string | null | undefined, + response: Response +): Promise { + if (!sessionId) return response; + if (response.status < 400) return response; + + const headers = new Headers(response.headers); + headers.set("x-cch-session-id", sessionId); + + const contentType = headers.get("content-type") || ""; + if (contentType.includes("text/event-stream")) { + return new Response(response.body, { status: response.status, headers }); + } + + if (!contentType.includes("application/json")) { + return new Response(response.body, { status: response.status, headers }); + } + + let text: string; + try { + text = await response.clone().text(); + } catch { + return new Response(response.body, { status: response.status, headers }); + } + + try { + const parsed = JSON.parse(text) as unknown; + if ( + parsed && + typeof parsed === "object" && + "error" in parsed && + parsed.error && + typeof parsed.error === "object" && + "message" in parsed.error && + typeof (parsed.error as { message?: unknown }).message === "string" + ) { + const p = parsed as { error: { message: string } } & Record; + p.error.message = attachSessionIdToErrorMessage(sessionId, p.error.message); + return new Response(JSON.stringify(p), { status: response.status, headers }); + } + } catch { + // best-effort: keep original response body + } + + return new Response(text, { status: response.status, headers }); +} diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index aecad1909..04bd542e8 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -347,6 +347,8 @@ export const messageRequest = pgTable('message_request', { 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`), + // Session ID 前缀查询索引(LIKE 'prefix%',可稳定命中 B-tree) + messageRequestSessionIdPrefixIdx: index('idx_message_request_session_id_prefix').on(sql`${table.sessionId} varchar_pattern_ops`).where(sql`${table.deletedAt} IS NULL AND (${table.blockedBy} IS NULL OR ${table.blockedBy} <> 'warmup')`), // Session + Sequence 复合索引(用于 Session 内请求列表查询) messageRequestSessionSeqIdx: index('idx_message_request_session_seq').on(table.sessionId, table.requestSequence).where(sql`${table.deletedAt} IS NULL`), // Endpoint 过滤查询索引(仅针对未删除数据) diff --git a/src/lib/constants/usage-logs.constants.ts b/src/lib/constants/usage-logs.constants.ts new file mode 100644 index 000000000..da3b0baf6 --- /dev/null +++ b/src/lib/constants/usage-logs.constants.ts @@ -0,0 +1,3 @@ +export const SESSION_ID_SUGGESTION_MIN_LEN = 2; +export const SESSION_ID_SUGGESTION_MAX_LEN = 128; +export const SESSION_ID_SUGGESTION_LIMIT = 20; diff --git a/src/lib/utils/clipboard.ts b/src/lib/utils/clipboard.ts index 5a980bc12..cb65f905f 100644 --- a/src/lib/utils/clipboard.ts +++ b/src/lib/utils/clipboard.ts @@ -11,20 +11,48 @@ export function isClipboardSupported(): boolean { return window.isSecureContext && !!navigator.clipboard?.writeText; } +function tryCopyViaExecCommand(text: string): boolean { + if (typeof document === "undefined" || !document.body) return false; + + try { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "absolute"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + + textarea.select(); + + const ok = document.execCommand?.("copy") ?? false; + document.body.removeChild(textarea); + return ok; + } catch { + return false; + } +} + /** - * 尝试复制文本到剪贴板 + * 尝试复制文本到剪贴板(Clipboard API 优先,失败则走 execCommand fallback) * @returns 是否成功复制 */ -export async function copyToClipboard(text: string): Promise { - if (!isClipboardSupported()) { - return false; - } +export async function copyTextToClipboard(text: string): Promise { + if (typeof window === "undefined") return false; - try { - await navigator.clipboard.writeText(text); - return true; - } catch (err) { - console.error("复制失败:", err); - return false; + if (isClipboardSupported()) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch {} } + + return tryCopyViaExecCommand(text); +} + +/** + * 尝试复制文本到剪贴板 + * @returns 是否成功复制 + */ +export async function copyToClipboard(text: string): Promise { + return copyTextToClipboard(text); } diff --git a/src/repository/_shared/like.ts b/src/repository/_shared/like.ts new file mode 100644 index 000000000..ab40b2727 --- /dev/null +++ b/src/repository/_shared/like.ts @@ -0,0 +1,3 @@ +export function escapeLike(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_"); +} diff --git a/src/repository/usage-logs.ts b/src/repository/usage-logs.ts index 1098107ce..e64c1651e 100644 --- a/src/repository/usage-logs.ts +++ b/src/repository/usage-logs.ts @@ -6,12 +6,15 @@ import { keys as keysTable, messageRequest, providers, users } from "@/drizzle/s import { buildUnifiedSpecialSettings } from "@/lib/utils/special-settings"; import type { ProviderChainItem } from "@/types/message"; import type { SpecialSetting } from "@/types/special-settings"; +import { escapeLike } from "./_shared/like"; import { EXCLUDE_WARMUP_CONDITION } from "./_shared/message-request-conditions"; export interface UsageLogFilters { userId?: number; keyId?: number; providerId?: number; + /** Session ID(精确匹配;空字符串/空白视为不筛选) */ + sessionId?: string; /** 开始时间戳(毫秒),用于 >= 比较 */ startTime?: number; /** 结束时间戳(毫秒),用于 < 比较 */ @@ -115,6 +118,7 @@ export async function findUsageLogsBatch( userId, keyId, providerId, + sessionId, startTime, endTime, statusCode, @@ -141,6 +145,11 @@ export async function findUsageLogsBatch( conditions.push(eq(messageRequest.providerId, providerId)); } + const trimmedSessionId = sessionId?.trim(); + if (trimmedSessionId) { + conditions.push(eq(messageRequest.sessionId, trimmedSessionId)); + } + if (startTime !== undefined) { const startDate = new Date(startTime); conditions.push(sql`${messageRequest.createdAt} >= ${startDate.toISOString()}::timestamptz`); @@ -320,6 +329,7 @@ export async function findUsageLogsWithDetails(filters: UsageLogFilters): Promis userId, keyId, providerId, + sessionId, startTime, endTime, statusCode, @@ -346,6 +356,11 @@ export async function findUsageLogsWithDetails(filters: UsageLogFilters): Promis conditions.push(eq(messageRequest.providerId, providerId)); } + const trimmedSessionId = sessionId?.trim(); + if (trimmedSessionId) { + conditions.push(eq(messageRequest.sessionId, trimmedSessionId)); + } + // 使用毫秒时间戳进行时间比较 // 前端传递的是浏览器本地时区的毫秒时间戳,直接与数据库的 timestamptz 比较 // PostgreSQL 会自动处理时区转换 @@ -545,6 +560,64 @@ export async function getUsedEndpoints(): Promise { return results.map((r) => r.endpoint).filter((e): e is string => e !== null); } +export interface UsageLogSessionIdSuggestionFilters { + term: string; + userId?: number; + keyId?: number; + providerId?: number; + limit?: number; +} + +export async function findUsageLogSessionIdSuggestions( + filters: UsageLogSessionIdSuggestionFilters +): Promise { + const { term, userId, keyId, providerId } = filters; + const limit = Math.min(50, Math.max(1, filters.limit ?? 20)); + const trimmedTerm = term.trim(); + if (!trimmedTerm) return []; + + const pattern = `${escapeLike(trimmedTerm)}%`; + const conditions = [ + isNull(messageRequest.deletedAt), + EXCLUDE_WARMUP_CONDITION, + sql`${messageRequest.sessionId} IS NOT NULL`, + sql`length(${messageRequest.sessionId}) > 0`, + sql`${messageRequest.sessionId} LIKE ${pattern} ESCAPE '\\'`, + ]; + + if (userId !== undefined) { + conditions.push(eq(messageRequest.userId, userId)); + } + + if (keyId !== undefined) { + conditions.push(eq(keysTable.id, keyId)); + } + + if (providerId !== undefined) { + conditions.push(eq(messageRequest.providerId, providerId)); + } + + const baseQuery = db + .select({ + sessionId: messageRequest.sessionId, + firstSeen: sql`min(${messageRequest.createdAt})`, + }) + .from(messageRequest); + + const query = + keyId !== undefined + ? baseQuery.innerJoin(keysTable, eq(messageRequest.key, keysTable.key)) + : baseQuery; + + const results = await query + .where(and(...conditions)) + .groupBy(messageRequest.sessionId) + .orderBy(desc(sql`min(${messageRequest.createdAt})`)) + .limit(limit); + + return results.map((r) => r.sessionId).filter((id): id is string => Boolean(id)); +} + /** * 独立获取使用日志聚合统计(用于可折叠面板按需加载) * @@ -560,6 +633,7 @@ export async function findUsageLogsStats( userId, keyId, providerId, + sessionId, startTime, endTime, statusCode, @@ -584,6 +658,11 @@ export async function findUsageLogsStats( conditions.push(eq(messageRequest.providerId, providerId)); } + const trimmedSessionId = sessionId?.trim(); + if (trimmedSessionId) { + conditions.push(eq(messageRequest.sessionId, trimmedSessionId)); + } + if (startTime !== undefined) { const startDate = new Date(startTime); conditions.push(sql`${messageRequest.createdAt} >= ${startDate.toISOString()}::timestamptz`); diff --git a/tests/unit/dashboard-logs-filters-time-range.test.tsx b/tests/unit/dashboard-logs-filters-time-range.test.tsx new file mode 100644 index 000000000..079ad3747 --- /dev/null +++ b/tests/unit/dashboard-logs-filters-time-range.test.tsx @@ -0,0 +1,128 @@ +/** + * @vitest-environment happy-dom + */ + +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { NextIntlClientProvider } from "next-intl"; +import { describe, expect, test, vi } from "vitest"; +import { UsageLogsFilters } from "@/app/[locale]/dashboard/logs/_components/usage-logs-filters"; +import dashboardMessages from "../../messages/en/dashboard.json"; + +vi.mock("@/app/[locale]/dashboard/logs/_components/logs-date-range-picker", () => ({ + LogsDateRangePicker: ({ + onDateRangeChange, + }: { + onDateRangeChange: (range: { startDate?: string; endDate?: string }) => void; + }) => ( + + ), +})); + +function renderWithIntl(node: ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + + {node} + + ); + }); + + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +async function actClick(el: Element | null) { + if (!el) throw new Error("element not found"); + await act(async () => { + el.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +describe("UsageLogsFilters - seconds-level time range", () => { + test("defaults to full-day semantics (end is exclusive next-day 00:00:00)", async () => { + const onChange = vi.fn(); + + const { container, unmount } = renderWithIntl( + {}} + /> + ); + + await actClick(container.querySelector("[data-testid='mock-date-range']")); + + const timeInputs = Array.from(container.querySelectorAll("input[type='time']")); + expect(timeInputs).toHaveLength(2); + + const applyBtn = Array.from(container.querySelectorAll("button")).find( + (b) => (b.textContent || "").trim() === "Apply Filter" + ); + await actClick(applyBtn ?? null); + + const expectedStart = new Date(2026, 0, 1, 0, 0, 0, 0).getTime(); + const expectedEnd = new Date(2026, 0, 3, 0, 0, 0, 0).getTime(); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ startTime: expectedStart, endTime: expectedEnd }) + ); + + unmount(); + }); + + test("Apply drops leaked page field from runtime filters object", async () => { + const onChange = vi.fn(); + + const leakedFilters = { sessionId: "abc", page: 3 } as unknown as Parameters< + typeof UsageLogsFilters + >[0]["filters"]; + + const { container, unmount } = renderWithIntl( + {}} + /> + ); + + const applyBtn = Array.from(container.querySelectorAll("button")).find( + (b) => (b.textContent || "").trim() === "Apply Filter" + ); + await actClick(applyBtn ?? null); + + expect(onChange).toHaveBeenCalledTimes(1); + const calledFilters = onChange.mock.calls[0]?.[0] as Record | undefined; + expect(calledFilters).toEqual(expect.objectContaining({ sessionId: "abc" })); + expect(calledFilters && "page" in calledFilters).toBe(false); + + unmount(); + }); +}); diff --git a/tests/unit/dashboard-logs-query-utils.test.ts b/tests/unit/dashboard-logs-query-utils.test.ts new file mode 100644 index 000000000..415dfe6b2 --- /dev/null +++ b/tests/unit/dashboard-logs-query-utils.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from "vitest"; +import { + buildLogsUrlQuery, + parseLogsUrlFilters, +} from "@/app/[locale]/dashboard/logs/_utils/logs-query"; + +describe("dashboard logs url query utils", () => { + test("parses and trims sessionId", () => { + const parsed = parseLogsUrlFilters({ sessionId: " abc " }); + expect(parsed.sessionId).toBe("abc"); + }); + + test("array params use the first value", () => { + const parsed = parseLogsUrlFilters({ + sessionId: [" abc ", "ignored"], + userId: ["1", "2"], + statusCode: ["!200", "200"], + }); + expect(parsed.sessionId).toBe("abc"); + expect(parsed.userId).toBe(1); + expect(parsed.excludeStatusCode200).toBe(true); + }); + + test("statusCode '!200' maps to excludeStatusCode200", () => { + const parsed = parseLogsUrlFilters({ statusCode: "!200" }); + expect(parsed.excludeStatusCode200).toBe(true); + expect(parsed.statusCode).toBeUndefined(); + }); + + test("parseIntParam returns undefined for invalid numbers", () => { + const parsed = parseLogsUrlFilters({ userId: "NaN", startTime: "bad" }); + expect(parsed.userId).toBeUndefined(); + expect(parsed.startTime).toBeUndefined(); + }); + + test("buildLogsUrlQuery omits empty sessionId", () => { + const query = buildLogsUrlQuery({ sessionId: " " }); + expect(query.get("sessionId")).toBeNull(); + }); + + test("buildLogsUrlQuery includes sessionId and time range", () => { + const query = buildLogsUrlQuery({ sessionId: "abc", startTime: 1, endTime: 2 }); + expect(query.get("sessionId")).toBe("abc"); + expect(query.get("startTime")).toBe("1"); + expect(query.get("endTime")).toBe("2"); + }); + + test("buildLogsUrlQuery includes startTime/endTime even when 0", () => { + const query = buildLogsUrlQuery({ startTime: 0, endTime: 0 }); + expect(query.get("startTime")).toBe("0"); + expect(query.get("endTime")).toBe("0"); + }); + + test("parseLogsUrlFilters sanitizes invalid page (<1) to undefined", () => { + expect(parseLogsUrlFilters({ page: "0" }).page).toBeUndefined(); + expect(parseLogsUrlFilters({ page: "-1" }).page).toBeUndefined(); + expect(parseLogsUrlFilters({ page: "1" }).page).toBe(1); + }); + + test("buildLogsUrlQuery only includes page when > 1", () => { + expect(buildLogsUrlQuery({ page: 0 }).get("page")).toBeNull(); + expect(buildLogsUrlQuery({ page: 1 }).get("page")).toBeNull(); + expect(buildLogsUrlQuery({ page: 2 }).get("page")).toBe("2"); + }); + + test("build + parse roundtrip preserves filters", () => { + const original = { + userId: 1, + keyId: 2, + providerId: 3, + sessionId: "abc", + startTime: 10, + endTime: 20, + statusCode: 500, + excludeStatusCode200: false, + model: "m", + endpoint: "/v1/messages", + minRetryCount: 2, + }; + const query = buildLogsUrlQuery(original); + + const parsed = parseLogsUrlFilters(Object.fromEntries(query.entries())); + expect(parsed).toEqual(expect.objectContaining(original)); + }); + + test("buildLogsUrlQuery includes minRetryCount even when 0", () => { + const query = buildLogsUrlQuery({ minRetryCount: 0 }); + expect(query.get("minRetry")).toBe("0"); + }); +}); diff --git a/tests/unit/dashboard-logs-sessionid-suggestions-ui.test.tsx b/tests/unit/dashboard-logs-sessionid-suggestions-ui.test.tsx new file mode 100644 index 000000000..0bc1d5dce --- /dev/null +++ b/tests/unit/dashboard-logs-sessionid-suggestions-ui.test.tsx @@ -0,0 +1,322 @@ +/** + * @vitest-environment happy-dom + */ + +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, test, vi } from "vitest"; +import { UsageLogsFilters } from "@/app/[locale]/dashboard/logs/_components/usage-logs-filters"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +const toastMocks = vi.hoisted(() => ({ + success: vi.fn(), + error: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: toastMocks, +})); + +const usageLogsActionMocks = vi.hoisted(() => ({ + exportUsageLogs: vi.fn(async () => ({ ok: true, data: "" })), + getUsageLogSessionIdSuggestions: vi.fn(async () => ({ ok: true, data: ["session_1"] })), + getModelList: vi.fn(async () => ({ ok: true, data: [] })), + getStatusCodeList: vi.fn(async () => ({ ok: true, data: [] })), + getEndpointList: vi.fn(async () => ({ ok: true, data: [] })), +})); + +const usersActionMocks = vi.hoisted(() => ({ + searchUsersForFilter: vi.fn(async () => ({ + ok: true, + data: [] as Array<{ id: number; name: string }>, + })), +})); + +vi.mock("@/actions/usage-logs", () => ({ + exportUsageLogs: usageLogsActionMocks.exportUsageLogs, + getUsageLogSessionIdSuggestions: usageLogsActionMocks.getUsageLogSessionIdSuggestions, + getModelList: usageLogsActionMocks.getModelList, + getStatusCodeList: usageLogsActionMocks.getStatusCodeList, + getEndpointList: usageLogsActionMocks.getEndpointList, +})); + +vi.mock("@/actions/users", () => ({ + searchUsersForFilter: usersActionMocks.searchUsersForFilter, +})); + +vi.mock("@/components/ui/popover", async () => { + const React = await import("react"); + + type PopoverCtx = { open: boolean; onOpenChange?: (open: boolean) => void }; + const PopoverContext = React.createContext({ open: false }); + + function Popover({ + open, + onOpenChange, + children, + }: { + open?: boolean; + onOpenChange?: (open: boolean) => void; + children?: ReactNode; + }) { + return ( + + {children} + + ); + } + + function PopoverTrigger({ asChild, children }: { asChild?: boolean; children?: ReactNode }) { + const { open, onOpenChange } = React.useContext(PopoverContext); + const child = React.Children.only(children) as unknown as { + props: { onClick?: (e: unknown) => void }; + }; + + const handleClick = (e: unknown) => { + child.props.onClick?.(e); + onOpenChange?.(!open); + }; + + if (asChild) { + return React.cloneElement(child as never, { onClick: handleClick }); + } + + return ( + + ); + } + + function PopoverContent({ children }: { children?: ReactNode }) { + const { open } = React.useContext(PopoverContext); + if (!open) return null; + return
{children}
; + } + + function PopoverAnchor({ children }: { children?: ReactNode }) { + return <>{children}; + } + + return { + Popover, + PopoverTrigger, + PopoverContent, + PopoverAnchor, + }; +}); + +vi.mock("@/components/ui/tooltip", () => ({ + TooltipProvider: ({ children }: { children?: ReactNode }) =>
{children}
, + Tooltip: ({ children }: { children?: ReactNode }) =>
{children}
, + TooltipTrigger: ({ children }: { children?: ReactNode }) =>
{children}
, + TooltipContent: ({ children }: { children?: ReactNode }) =>
{children}
, +})); + +async function flushMicrotasks() { + await act(async () => { + await Promise.resolve(); + }); +} + +async function actClick(el: Element | null) { + if (!el) throw new Error("element not found"); + await act(async () => { + el.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +function setReactInputValue(input: HTMLInputElement, value: string) { + const prototype = Object.getPrototypeOf(input) as HTMLInputElement; + const descriptor = Object.getOwnPropertyDescriptor(prototype, "value"); + descriptor?.set?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); +} + +describe("UsageLogsFilters sessionId suggestions", () => { + test("should debounce and require min length (>=2)", async () => { + vi.useFakeTimers(); + vi.clearAllMocks(); + document.body.innerHTML = ""; + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + {}} + onReset={() => {}} + /> + ); + }); + + const input = container.querySelector( + 'input[placeholder="logs.filters.searchSessionId"]' + ) as HTMLInputElement | null; + expect(input).toBeTruthy(); + + await act(async () => { + setReactInputValue(input!, "a"); + }); + + await act(async () => { + vi.advanceTimersByTime(350); + }); + await flushMicrotasks(); + + expect(usageLogsActionMocks.getUsageLogSessionIdSuggestions).not.toHaveBeenCalled(); + + await act(async () => { + setReactInputValue(input!, "ab"); + }); + + await act(async () => { + vi.advanceTimersByTime(299); + }); + await flushMicrotasks(); + expect(usageLogsActionMocks.getUsageLogSessionIdSuggestions).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(1); + }); + await flushMicrotasks(); + + expect(usageLogsActionMocks.getUsageLogSessionIdSuggestions).toHaveBeenCalledTimes(1); + expect(usageLogsActionMocks.getUsageLogSessionIdSuggestions).toHaveBeenCalledWith( + expect.objectContaining({ term: "ab" }) + ); + + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.useRealTimers(); + }); + + test("should keep input focused when opening suggestions popover", async () => { + vi.useFakeTimers(); + vi.clearAllMocks(); + document.body.innerHTML = ""; + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + {}} + onReset={() => {}} + /> + ); + }); + + const input = container.querySelector( + 'input[placeholder="logs.filters.searchSessionId"]' + ) as HTMLInputElement | null; + expect(input).toBeTruthy(); + + await act(async () => { + input?.focus(); + }); + await flushMicrotasks(); + + expect(document.activeElement).toBe(input); + + await act(async () => { + vi.advanceTimersByTime(350); + }); + await flushMicrotasks(); + + expect(usageLogsActionMocks.getUsageLogSessionIdSuggestions).toHaveBeenCalledTimes(1); + expect(document.activeElement).toBe(input); + + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.useRealTimers(); + }); + + test("should reload suggestions when provider scope changes (term unchanged)", async () => { + vi.useFakeTimers(); + vi.clearAllMocks(); + document.body.innerHTML = ""; + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + {}} + onReset={() => {}} + /> + ); + }); + await flushMicrotasks(); + + const input = container.querySelector( + 'input[placeholder="logs.filters.searchSessionId"]' + ) as HTMLInputElement | null; + expect(input).toBeTruthy(); + + await act(async () => { + input?.focus(); + }); + await flushMicrotasks(); + + await act(async () => { + vi.advanceTimersByTime(350); + }); + await flushMicrotasks(); + + expect(usageLogsActionMocks.getUsageLogSessionIdSuggestions).toHaveBeenCalledTimes(1); + + const providerBtn = Array.from(container.querySelectorAll("button")).find((b) => + (b.textContent || "").includes("logs.filters.allProviders") + ); + await actClick(providerBtn ?? null); + await flushMicrotasks(); + + const providerItem = Array.from(document.querySelectorAll("[cmdk-item]")).find((el) => + (el.textContent || "").includes("p1") + ); + await actClick(providerItem ?? null); + await flushMicrotasks(); + + expect(usageLogsActionMocks.getUsageLogSessionIdSuggestions).toHaveBeenCalledTimes(2); + expect(usageLogsActionMocks.getUsageLogSessionIdSuggestions).toHaveBeenLastCalledWith( + expect.objectContaining({ term: "ab", providerId: 1 }) + ); + + await act(async () => { + root.unmount(); + }); + container.remove(); + vi.useRealTimers(); + }); +}); diff --git a/tests/unit/dashboard-logs-time-range-utils.test.ts b/tests/unit/dashboard-logs-time-range-utils.test.ts new file mode 100644 index 000000000..db14488ce --- /dev/null +++ b/tests/unit/dashboard-logs-time-range-utils.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "vitest"; +import { + dateStringWithClockToTimestamp, + formatClockFromTimestamp, + inclusiveEndTimestampFromExclusive, + parseClockString, +} from "@/app/[locale]/dashboard/logs/_utils/time-range"; + +describe("dashboard logs time range utils", () => { + test("parseClockString supports HH:MM and defaults seconds to 0", () => { + expect(parseClockString("01:02")).toEqual({ hours: 1, minutes: 2, seconds: 0 }); + }); + + test("parseClockString falls back to 0 for invalid numbers", () => { + expect(parseClockString("xx:yy:zz")).toEqual({ hours: 0, minutes: 0, seconds: 0 }); + expect(parseClockString("01:02:xx")).toEqual({ hours: 1, minutes: 2, seconds: 0 }); + }); + + test("dateStringWithClockToTimestamp combines local date + clock", () => { + const ts = dateStringWithClockToTimestamp("2026-01-01", "01:02:03"); + const expected = new Date(2026, 0, 1, 1, 2, 3, 0).getTime(); + expect(ts).toBe(expected); + }); + + test("dateStringWithClockToTimestamp returns undefined for invalid date", () => { + expect(dateStringWithClockToTimestamp("not-a-date", "01:02:03")).toBeUndefined(); + expect(dateStringWithClockToTimestamp("2026-13-40", "01:02:03")).toBeUndefined(); + }); + + test("exclusive end time round-trips to inclusive end time (+/-1s)", () => { + const inclusive = dateStringWithClockToTimestamp("2026-01-02", "04:05:06"); + expect(inclusive).toBeDefined(); + const exclusive = inclusive! + 1000; + expect(inclusiveEndTimestampFromExclusive(exclusive)).toBe(inclusive); + }); + + test("inclusiveEndTimestampFromExclusive clamps at 0", () => { + expect(inclusiveEndTimestampFromExclusive(0)).toBe(0); + expect(inclusiveEndTimestampFromExclusive(500)).toBe(0); + }); + + test("formatClockFromTimestamp uses HH:MM:SS", () => { + const ts = new Date(2026, 0, 1, 1, 2, 3, 0).getTime(); + expect(formatClockFromTimestamp(ts)).toBe("01:02:03"); + }); +}); diff --git a/tests/unit/lib/constants/usage-logs.constants.test.ts b/tests/unit/lib/constants/usage-logs.constants.test.ts new file mode 100644 index 000000000..3876d61aa --- /dev/null +++ b/tests/unit/lib/constants/usage-logs.constants.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "vitest"; + +import { + SESSION_ID_SUGGESTION_LIMIT, + SESSION_ID_SUGGESTION_MAX_LEN, + SESSION_ID_SUGGESTION_MIN_LEN, +} from "@/lib/constants/usage-logs.constants"; + +describe("Usage logs constants", () => { + test("SESSION_ID_SUGGESTION_* 常量保持稳定(避免前后端阈值漂移)", () => { + expect(SESSION_ID_SUGGESTION_MIN_LEN).toBe(2); + expect(SESSION_ID_SUGGESTION_MAX_LEN).toBe(128); + expect(SESSION_ID_SUGGESTION_LIMIT).toBe(20); + }); +}); diff --git a/tests/unit/lib/utils/clipboard.test.ts b/tests/unit/lib/utils/clipboard.test.ts new file mode 100644 index 000000000..443710f91 --- /dev/null +++ b/tests/unit/lib/utils/clipboard.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { copyTextToClipboard, copyToClipboard, isClipboardSupported } from "@/lib/utils/clipboard"; + +function stubSecureContext(value: boolean) { + Object.defineProperty(window, "isSecureContext", { + value, + configurable: true, + }); +} + +function stubClipboard(writeText: (text: string) => Promise | void) { + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); +} + +function stubExecCommand(impl: (command: string) => boolean) { + Object.defineProperty(document, "execCommand", { + value: impl, + configurable: true, + }); +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("clipboard utils", () => { + test("SSR 环境:isClipboardSupported/copyTextToClipboard 应返回 false", async () => { + vi.stubGlobal("window", undefined as unknown as Window); + + expect(isClipboardSupported()).toBe(false); + await expect(copyTextToClipboard("abc")).resolves.toBe(false); + }); + + test("isClipboardSupported: 仅在安全上下文且 Clipboard API 可用时为 true", () => { + stubSecureContext(false); + stubClipboard(vi.fn()); + expect(isClipboardSupported()).toBe(false); + + stubSecureContext(true); + stubClipboard(vi.fn()); + expect(isClipboardSupported()).toBe(true); + }); + + test("copyTextToClipboard: Clipboard API 成功时返回 true", async () => { + stubSecureContext(true); + const writeText = vi.fn().mockResolvedValue(undefined); + stubClipboard(writeText); + + const execCommand = vi.fn(); + stubExecCommand(execCommand); + + const before = document.querySelectorAll("textarea").length; + await expect(copyTextToClipboard("abc")).resolves.toBe(true); + const after = document.querySelectorAll("textarea").length; + + expect(writeText).toHaveBeenCalledWith("abc"); + expect(execCommand).not.toHaveBeenCalled(); + expect(after).toBe(before); + }); + + test("copyTextToClipboard: Clipboard API 失败时应 fallback 到 execCommand", async () => { + stubSecureContext(true); + const writeText = vi.fn().mockRejectedValue(new Error("fail")); + stubClipboard(writeText); + + const execCommand = vi.fn(() => true); + stubExecCommand(execCommand); + + const before = document.querySelectorAll("textarea").length; + await expect(copyTextToClipboard("abc")).resolves.toBe(true); + const after = document.querySelectorAll("textarea").length; + + expect(writeText).toHaveBeenCalledWith("abc"); + expect(execCommand).toHaveBeenCalledWith("copy"); + expect(after).toBe(before); + }); + + test("copyTextToClipboard: 无 Clipboard API 时走 fallback(execCommand 失败则返回 false)", async () => { + stubSecureContext(false); + Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true }); + + stubExecCommand(() => false); + + await expect(copyTextToClipboard("abc")).resolves.toBe(false); + }); + + test("copyTextToClipboard: fallback 抛错时返回 false", async () => { + stubSecureContext(false); + Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true }); + + stubExecCommand(() => { + throw new Error("boom"); + }); + + await expect(copyTextToClipboard("abc")).resolves.toBe(false); + }); + + test("copyToClipboard: 兼容旧 API(内部调用 copyTextToClipboard)", async () => { + stubSecureContext(true); + const writeText = vi.fn().mockResolvedValue(undefined); + stubClipboard(writeText); + + await expect(copyToClipboard("abc")).resolves.toBe(true); + expect(writeText).toHaveBeenCalledWith("abc"); + }); + + test("copyTextToClipboard: 无 document 时 fallback 直接返回 false", async () => { + stubSecureContext(false); + Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true }); + vi.stubGlobal("document", undefined as unknown as Document); + + await expect(copyTextToClipboard("abc")).resolves.toBe(false); + }); +}); diff --git a/tests/unit/proxy/chat-completions-handler-guard-pipeline.test.ts b/tests/unit/proxy/chat-completions-handler-guard-pipeline.test.ts index acc1cd310..fb69e9c46 100644 --- a/tests/unit/proxy/chat-completions-handler-guard-pipeline.test.ts +++ b/tests/unit/proxy/chat-completions-handler-guard-pipeline.test.ts @@ -232,6 +232,28 @@ beforeEach(() => { }); describe("handleChatCompletions:必须走 GuardPipeline", () => { + test("pipeline 早退错误时,应附带 x-cch-session-id 且 message 追加 cch_session_id", async () => { + h.session = createSession({ + model: "gpt-4.1-mini", + messages: [{ role: "user", content: "hi" }], + }); + h.session.sessionId = "s_123"; + h.clientGuardResult = new Response( + JSON.stringify({ + error: { message: "client blocked", type: "invalid_request_error", code: "client_blocked" }, + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + + const { handleChatCompletions } = await import("@/app/v1/_lib/codex/chat-completions-handler"); + const res = await handleChatCompletions({} as any); + + expect(res.status).toBe(400); + expect(res.headers.get("x-cch-session-id")).toBe("s_123"); + const body = await res.json(); + expect(body.error.message).toBe("client blocked (cch_session_id: s_123)"); + }); + test("请求体既不是 messages 也不是 input 时,应返回 400(不进入 pipeline)", async () => { h.session = createSession({}); diff --git a/tests/unit/proxy/error-handler-session-id-error.test.ts b/tests/unit/proxy/error-handler-session-id-error.test.ts new file mode 100644 index 000000000..15942e10d --- /dev/null +++ b/tests/unit/proxy/error-handler-session-id-error.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "vitest"; +import { ProxyErrorHandler } from "@/app/v1/_lib/proxy/error-handler"; + +describe("ProxyErrorHandler.handle - session id on errors", () => { + test("decorates error response with x-cch-session-id and message suffix", async () => { + const session = { + sessionId: "s_123", + messageContext: null, + startTime: Date.now(), + getProviderChain: () => [], + getCurrentModel: () => null, + getContext1mApplied: () => false, + provider: null, + } as any; + + const res = await ProxyErrorHandler.handle(session, new Error("boom")); + + expect(res.status).toBe(500); + expect(res.headers.get("x-cch-session-id")).toBe("s_123"); + + const body = await res.json(); + expect(body.error.message).toBe("boom (cch_session_id: s_123)"); + }); +}); diff --git a/tests/unit/proxy/proxy-handler-session-id-error.test.ts b/tests/unit/proxy/proxy-handler-session-id-error.test.ts new file mode 100644 index 000000000..d336e0dc4 --- /dev/null +++ b/tests/unit/proxy/proxy-handler-session-id-error.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, test, vi } from "vitest"; +import { ProxyResponses } from "@/app/v1/_lib/proxy/responses"; +import { ProxyError } from "@/app/v1/_lib/proxy/errors"; + +const h = vi.hoisted(() => ({ + session: { + originalFormat: "openai", + sessionId: "s_123", + requestUrl: new URL("http://localhost/v1/messages"), + request: { + model: "gpt", + message: {}, + }, + isCountTokensRequest: () => false, + setOriginalFormat: () => {}, + messageContext: null, + provider: null, + } as any, + + fromContextError: null as unknown, + pipelineError: null as unknown, + earlyResponse: null as Response | null, + forwardResponse: new Response("ok", { status: 200 }), + dispatchedResponse: null as Response | null, + + endpointFormat: null as string | null, + trackerCalls: [] as string[], +})); + +vi.mock("@/app/v1/_lib/proxy/session", () => ({ + ProxySession: { + fromContext: async () => { + if (h.fromContextError) throw h.fromContextError; + return h.session; + }, + }, +})); + +vi.mock("@/app/v1/_lib/proxy/guard-pipeline", () => ({ + RequestType: { CHAT: "CHAT", COUNT_TOKENS: "COUNT_TOKENS" }, + GuardPipelineBuilder: { + fromRequestType: () => ({ + run: async () => { + if (h.pipelineError) throw h.pipelineError; + return h.earlyResponse; + }, + }), + }, +})); + +vi.mock("@/app/v1/_lib/proxy/format-mapper", () => ({ + detectClientFormat: () => "openai", + detectFormatByEndpoint: () => h.endpointFormat, +})); + +vi.mock("@/app/v1/_lib/proxy/forwarder", () => ({ + ProxyForwarder: { + send: async () => h.forwardResponse, + }, +})); + +vi.mock("@/app/v1/_lib/proxy/response-handler", () => ({ + ProxyResponseHandler: { + dispatch: async () => h.dispatchedResponse ?? h.forwardResponse, + }, +})); + +vi.mock("@/app/v1/_lib/proxy/error-handler", () => ({ + ProxyErrorHandler: { + handle: async () => new Response("handled", { status: 502 }), + }, +})); + +vi.mock("@/lib/session-tracker", () => ({ + SessionTracker: { + incrementConcurrentCount: async () => { + h.trackerCalls.push("inc"); + }, + decrementConcurrentCount: async () => { + h.trackerCalls.push("dec"); + }, + }, +})); + +vi.mock("@/lib/proxy-status-tracker", () => ({ + ProxyStatusTracker: { + getInstance: () => ({ + startRequest: () => { + h.trackerCalls.push("startRequest"); + }, + endRequest: () => {}, + }), + }, +})); + +describe("handleProxyRequest - session id on errors", async () => { + const { handleProxyRequest } = await import("@/app/v1/_lib/proxy-handler"); + + test("decorates early error response with x-cch-session-id and message suffix", async () => { + h.fromContextError = null; + h.session.originalFormat = "openai"; + h.endpointFormat = null; + h.trackerCalls.length = 0; + h.pipelineError = null; + h.earlyResponse = ProxyResponses.buildError(400, "bad request"); + const res = await handleProxyRequest({} as any); + + expect(res.status).toBe(400); + expect(res.headers.get("x-cch-session-id")).toBe("s_123"); + + const body = await res.json(); + expect(body.error.message).toBe("bad request (cch_session_id: s_123)"); + }); + + test("decorates dispatch error response with x-cch-session-id and message suffix", async () => { + h.fromContextError = null; + h.session.originalFormat = "openai"; + h.endpointFormat = null; + h.trackerCalls.length = 0; + h.pipelineError = null; + h.earlyResponse = null; + h.forwardResponse = new Response("upstream", { status: 502 }); + h.dispatchedResponse = ProxyResponses.buildError(502, "bad gateway"); + + const res = await handleProxyRequest({} as any); + + expect(res.status).toBe(502); + expect(res.headers.get("x-cch-session-id")).toBe("s_123"); + + const body = await res.json(); + expect(body.error.message).toBe("bad gateway (cch_session_id: s_123)"); + }); + + test("covers claude format detection branch without breaking behavior", async () => { + h.fromContextError = null; + h.session.originalFormat = "claude"; + h.endpointFormat = null; + h.trackerCalls.length = 0; + h.pipelineError = null; + h.earlyResponse = ProxyResponses.buildError(400, "bad request"); + h.session.requestUrl = new URL("http://localhost/v1/unknown"); + h.session.request = { model: "gpt", message: { contents: [] } }; + + const res = await handleProxyRequest({} as any); + expect(res.status).toBe(400); + expect(res.headers.get("x-cch-session-id")).toBe("s_123"); + }); + + test("covers endpoint format detection + tracking + finally decrement", async () => { + h.fromContextError = null; + h.session.originalFormat = "claude"; + h.endpointFormat = "openai"; + h.trackerCalls.length = 0; + h.pipelineError = null; + h.earlyResponse = null; + h.forwardResponse = new Response("ok", { status: 200 }); + h.dispatchedResponse = null; + + h.session.sessionId = "s_123"; + h.session.messageContext = { id: 1, user: { id: 1, name: "u" }, key: { name: "k" } }; + h.session.provider = { id: 1, name: "p" }; + h.session.isCountTokensRequest = () => false; + + const res = await handleProxyRequest({} as any); + expect(res.status).toBe(200); + expect(h.trackerCalls).toEqual(["inc", "startRequest", "dec"]); + }); + + test("session not created and ProxyError thrown: returns buildError without session header", async () => { + h.fromContextError = new ProxyError("upstream", 401); + h.endpointFormat = null; + h.trackerCalls.length = 0; + h.pipelineError = null; + h.earlyResponse = null; + + const res = await handleProxyRequest({} as any); + expect(res.status).toBe(401); + expect(res.headers.get("x-cch-session-id")).toBeNull(); + const body = await res.json(); + expect(body.error.message).toBe("upstream"); + }); + + test("session created but pipeline throws: routes to ProxyErrorHandler.handle", async () => { + h.fromContextError = null; + h.endpointFormat = null; + h.trackerCalls.length = 0; + h.pipelineError = new Error("pipeline boom"); + h.earlyResponse = null; + + const res = await handleProxyRequest({} as any); + expect(res.status).toBe(502); + expect(await res.text()).toBe("handled"); + }); + + test("session not created and non-ProxyError thrown: returns 500 buildError", async () => { + h.fromContextError = new Error("boom"); + h.endpointFormat = null; + h.trackerCalls.length = 0; + h.pipelineError = null; + h.earlyResponse = null; + + const res = await handleProxyRequest({} as any); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error.message).toBe("代理请求发生未知错误"); + }); +}); diff --git a/tests/unit/proxy/responses-session-id.test.ts b/tests/unit/proxy/responses-session-id.test.ts new file mode 100644 index 000000000..6b2ffbdb3 --- /dev/null +++ b/tests/unit/proxy/responses-session-id.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "vitest"; +import { ProxyResponses } from "@/app/v1/_lib/proxy/responses"; +import { attachSessionIdToErrorResponse } from "@/app/v1/_lib/proxy/error-session-id"; + +describe("ProxyResponses.attachSessionIdToErrorResponse", () => { + test("adds x-cch-session-id and appends to error.message for JSON error responses", async () => { + const response = ProxyResponses.buildError(400, "bad request"); + const decorated = await attachSessionIdToErrorResponse("s_123", response); + + expect(decorated.status).toBe(400); + expect(decorated.headers.get("x-cch-session-id")).toBe("s_123"); + + const body = await decorated.json(); + expect(body.error.message).toBe("bad request (cch_session_id: s_123)"); + }); + + test("does nothing when sessionId is missing", async () => { + const response = ProxyResponses.buildError(400, "bad request"); + const decorated = await attachSessionIdToErrorResponse(undefined, response); + + expect(decorated).toBe(response); + }); + + test("does nothing for non-error responses", async () => { + const response = new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + const decorated = await attachSessionIdToErrorResponse("s_123", response); + + expect(decorated).toBe(response); + }); + + test("does not double-append when message already contains cch_session_id", async () => { + const response = ProxyResponses.buildError(400, "bad request (cch_session_id: s_123)"); + const decorated = await attachSessionIdToErrorResponse("s_123", response); + + const body = await decorated.json(); + expect(body.error.message).toBe("bad request (cch_session_id: s_123)"); + }); + + test("adds header for non-json error responses (body unchanged)", async () => { + const response = new Response("oops", { + status: 500, + headers: { "Content-Type": "text/plain" }, + }); + const decorated = await attachSessionIdToErrorResponse("s_123", response); + + expect(decorated.status).toBe(500); + expect(decorated.headers.get("x-cch-session-id")).toBe("s_123"); + expect(await decorated.text()).toBe("oops"); + }); + + test("adds header for json without error.message (body unchanged)", async () => { + const response = new Response(JSON.stringify({ foo: "bar" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + const decorated = await attachSessionIdToErrorResponse("s_123", response); + + expect(decorated.headers.get("x-cch-session-id")).toBe("s_123"); + expect(await decorated.json()).toEqual({ foo: "bar" }); + }); + + test("adds header for SSE error responses (no body rewrite)", async () => { + const response = new Response("data: hi\n\n", { + status: 500, + headers: { "Content-Type": "text/event-stream" }, + }); + const decorated = await attachSessionIdToErrorResponse("s_123", response); + + expect(decorated.headers.get("x-cch-session-id")).toBe("s_123"); + expect(await decorated.text()).toBe("data: hi\n\n"); + }); +}); diff --git a/tests/unit/repository/escape-like.test.ts b/tests/unit/repository/escape-like.test.ts new file mode 100644 index 000000000..c243fa188 --- /dev/null +++ b/tests/unit/repository/escape-like.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "vitest"; + +import { escapeLike } from "@/repository/_shared/like"; + +describe("escapeLike", () => { + test("普通字符串保持不变", () => { + expect(escapeLike("abc-123")).toBe("abc-123"); + }); + + test("%/_/\\\\ 应被转义(用于 LIKE ... ESCAPE '\\\\')", () => { + expect(escapeLike("%")).toBe("\\%"); + expect(escapeLike("_")).toBe("\\_"); + expect(escapeLike("\\")).toBe("\\\\"); + }); + + test("组合输入应按字面量匹配语义转义", () => { + expect(escapeLike("a%b_c\\d")).toBe("a\\%b\\_c\\\\d"); + }); + + test("空字符串应返回空字符串", () => { + expect(escapeLike("")).toBe(""); + }); +}); diff --git a/tests/unit/repository/usage-logs-sessionid-filter.test.ts b/tests/unit/repository/usage-logs-sessionid-filter.test.ts new file mode 100644 index 000000000..d87d1991f --- /dev/null +++ b/tests/unit/repository/usage-logs-sessionid-filter.test.ts @@ -0,0 +1,309 @@ +import { describe, expect, test, vi } from "vitest"; + +function sqlToString(sqlObj: unknown): string { + const visited = new Set(); + + const walk = (node: unknown): string => { + if (!node || visited.has(node)) return ""; + visited.add(node); + + if (typeof node === "string") return node; + + if (typeof node === "object") { + const anyNode = node as any; + if (Array.isArray(anyNode)) { + return anyNode.map(walk).join(""); + } + + if (anyNode.value) { + if (Array.isArray(anyNode.value)) { + return anyNode.value.map(String).join(""); + } + return String(anyNode.value); + } + + if (anyNode.queryChunks) { + return walk(anyNode.queryChunks); + } + } + + return ""; + }; + + return walk(sqlObj); +} + +function createThenableQuery(result: T, whereArgs?: unknown[]) { + const query: any = Promise.resolve(result); + + query.from = vi.fn(() => query); + query.innerJoin = vi.fn(() => query); + query.leftJoin = vi.fn(() => query); + query.orderBy = vi.fn(() => query); + query.limit = vi.fn(() => query); + query.offset = vi.fn(() => query); + query.groupBy = vi.fn(() => query); + query.where = vi.fn((arg: unknown) => { + whereArgs?.push(arg); + return query; + }); + + return query; +} + +describe("Usage logs sessionId filter", () => { + test("findUsageLogsBatch: sessionId 为空/空白不应追加条件", async () => { + vi.resetModules(); + + const whereArgs: unknown[] = []; + const selectMock = vi.fn(() => createThenableQuery([], whereArgs)); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { findUsageLogsBatch } = await import("@/repository/usage-logs"); + await findUsageLogsBatch({}); + await findUsageLogsBatch({ sessionId: " " }); + + expect(whereArgs).toHaveLength(2); + const baseWhereSql = sqlToString(whereArgs[0]).toLowerCase(); + const blankWhereSql = sqlToString(whereArgs[1]).toLowerCase(); + expect(blankWhereSql).toBe(baseWhereSql); + }); + + test("findUsageLogsBatch: sessionId 应 trim 后精确匹配", async () => { + vi.resetModules(); + + const whereArgs: unknown[] = []; + const selectMock = vi.fn(() => createThenableQuery([], whereArgs)); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { findUsageLogsBatch } = await import("@/repository/usage-logs"); + await findUsageLogsBatch({ sessionId: " abc " }); + + expect(whereArgs.length).toBeGreaterThan(0); + const whereSql = sqlToString(whereArgs[0]).toLowerCase(); + expect(whereSql).toContain("abc"); + expect(whereSql).not.toContain(" abc "); + }); + + test("findUsageLogsWithDetails: sessionId 为空/空白不应追加条件", async () => { + vi.resetModules(); + + const whereArgs: unknown[] = []; + const selectQueue: any[] = []; + selectQueue.push( + createThenableQuery( + [ + { + totalRows: 0, + totalRequests: 0, + totalCost: "0", + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreationTokens: 0, + totalCacheReadTokens: 0, + totalCacheCreation5mTokens: 0, + totalCacheCreation1hTokens: 0, + }, + ], + whereArgs + ) + ); + selectQueue.push(createThenableQuery([])); + selectQueue.push( + createThenableQuery( + [ + { + totalRows: 0, + totalRequests: 0, + totalCost: "0", + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreationTokens: 0, + totalCacheReadTokens: 0, + totalCacheCreation5mTokens: 0, + totalCacheCreation1hTokens: 0, + }, + ], + whereArgs + ) + ); + selectQueue.push(createThenableQuery([])); + + const fallbackSelect = createThenableQuery([]); + const selectMock = vi.fn(() => selectQueue.shift() ?? fallbackSelect); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { findUsageLogsWithDetails } = await import("@/repository/usage-logs"); + await findUsageLogsWithDetails({ page: 1, pageSize: 1 }); + await findUsageLogsWithDetails({ page: 1, pageSize: 1, sessionId: " " }); + + expect(whereArgs).toHaveLength(2); + const baseWhereSql = sqlToString(whereArgs[0]).toLowerCase(); + const blankWhereSql = sqlToString(whereArgs[1]).toLowerCase(); + expect(blankWhereSql).toBe(baseWhereSql); + }); + + test("findUsageLogsWithDetails: sessionId 应 trim 后精确匹配", async () => { + vi.resetModules(); + + const whereArgs: unknown[] = []; + const selectQueue: any[] = []; + selectQueue.push( + createThenableQuery( + [ + { + totalRows: 0, + totalRequests: 0, + totalCost: "0", + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreationTokens: 0, + totalCacheReadTokens: 0, + totalCacheCreation5mTokens: 0, + totalCacheCreation1hTokens: 0, + }, + ], + whereArgs + ) + ); + selectQueue.push(createThenableQuery([])); + + const fallbackSelect = createThenableQuery([]); + const selectMock = vi.fn(() => selectQueue.shift() ?? fallbackSelect); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { findUsageLogsWithDetails } = await import("@/repository/usage-logs"); + await findUsageLogsWithDetails({ page: 1, pageSize: 1, sessionId: " abc " }); + + expect(whereArgs.length).toBeGreaterThan(0); + const whereSql = sqlToString(whereArgs[0]).toLowerCase(); + expect(whereSql).toContain("abc"); + expect(whereSql).not.toContain(" abc "); + }); + + test("findUsageLogsStats: sessionId 为空/空白不应追加条件", async () => { + vi.resetModules(); + + const whereArgs: unknown[] = []; + const selectQueue: any[] = []; + selectQueue.push( + createThenableQuery( + [ + { + totalRequests: 0, + totalCost: "0", + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreationTokens: 0, + totalCacheReadTokens: 0, + totalCacheCreation5mTokens: 0, + totalCacheCreation1hTokens: 0, + }, + ], + whereArgs + ) + ); + selectQueue.push( + createThenableQuery( + [ + { + totalRequests: 0, + totalCost: "0", + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreationTokens: 0, + totalCacheReadTokens: 0, + totalCacheCreation5mTokens: 0, + totalCacheCreation1hTokens: 0, + }, + ], + whereArgs + ) + ); + + const fallbackSelect = createThenableQuery([]); + const selectMock = vi.fn(() => selectQueue.shift() ?? fallbackSelect); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { findUsageLogsStats } = await import("@/repository/usage-logs"); + await findUsageLogsStats({}); + await findUsageLogsStats({ sessionId: " " }); + + expect(whereArgs).toHaveLength(2); + const baseWhereSql = sqlToString(whereArgs[0]).toLowerCase(); + const blankWhereSql = sqlToString(whereArgs[1]).toLowerCase(); + expect(blankWhereSql).toBe(baseWhereSql); + }); + + test("findUsageLogsStats: sessionId 应 trim 后精确匹配", async () => { + vi.resetModules(); + + const whereArgs: unknown[] = []; + const selectQueue: any[] = []; + selectQueue.push( + createThenableQuery( + [ + { + totalRequests: 0, + totalCost: "0", + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreationTokens: 0, + totalCacheReadTokens: 0, + totalCacheCreation5mTokens: 0, + totalCacheCreation1hTokens: 0, + }, + ], + whereArgs + ) + ); + + const fallbackSelect = createThenableQuery([]); + const selectMock = vi.fn(() => selectQueue.shift() ?? fallbackSelect); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { findUsageLogsStats } = await import("@/repository/usage-logs"); + await findUsageLogsStats({ sessionId: " abc " }); + + expect(whereArgs.length).toBeGreaterThan(0); + const whereSql = sqlToString(whereArgs[0]).toLowerCase(); + expect(whereSql).toContain("abc"); + expect(whereSql).not.toContain(" abc "); + }); +}); diff --git a/tests/unit/repository/usage-logs-sessionid-suggestions.test.ts b/tests/unit/repository/usage-logs-sessionid-suggestions.test.ts new file mode 100644 index 000000000..0114be6cc --- /dev/null +++ b/tests/unit/repository/usage-logs-sessionid-suggestions.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, test, vi } from "vitest"; + +function sqlToString(sqlObj: unknown): string { + const visited = new Set(); + + const walk = (node: unknown): string => { + if (!node || visited.has(node)) return ""; + visited.add(node); + + if (typeof node === "string") return node; + + if (typeof node === "object") { + const anyNode = node as any; + if (Array.isArray(anyNode)) { + return anyNode.map(walk).join(""); + } + + if (anyNode.value) { + if (Array.isArray(anyNode.value)) { + return anyNode.value.map(String).join(""); + } + return String(anyNode.value); + } + + if (anyNode.queryChunks) { + return walk(anyNode.queryChunks); + } + } + + return ""; + }; + + return walk(sqlObj); +} + +function createThenableQuery( + result: T, + opts?: { + whereArgs?: unknown[]; + groupByArgs?: unknown[]; + orderByArgs?: unknown[]; + limitArgs?: unknown[]; + } +) { + const query: any = Promise.resolve(result); + + query.from = vi.fn(() => query); + query.innerJoin = vi.fn(() => query); + query.leftJoin = vi.fn(() => query); + query.where = vi.fn((arg: unknown) => { + opts?.whereArgs?.push(arg); + return query; + }); + query.groupBy = vi.fn((...args: unknown[]) => { + opts?.groupByArgs?.push(args); + return query; + }); + query.orderBy = vi.fn((...args: unknown[]) => { + opts?.orderByArgs?.push(args); + return query; + }); + query.limit = vi.fn((arg: unknown) => { + opts?.limitArgs?.push(arg); + return query; + }); + + return query; +} + +describe("Usage logs sessionId suggestions", () => { + test("term 为空/空白:应直接返回空数组且不查询 DB", async () => { + vi.resetModules(); + + const selectMock = vi.fn(() => createThenableQuery([])); + vi.doMock("@/drizzle/db", () => ({ + db: { select: selectMock }, + })); + + const { findUsageLogSessionIdSuggestions } = await import("@/repository/usage-logs"); + const result = await findUsageLogSessionIdSuggestions({ term: " " }); + + expect(result).toEqual([]); + expect(selectMock).not.toHaveBeenCalled(); + }); + + test("term 应 trim 并按 MIN(created_at) 倒序,limit 生效", async () => { + vi.resetModules(); + + const whereArgs: unknown[] = []; + const groupByArgs: unknown[] = []; + const orderByArgs: unknown[] = []; + const limitArgs: unknown[] = []; + const selectMock = vi.fn(() => + createThenableQuery( + [ + { sessionId: "session_1", firstSeen: new Date("2026-01-01T00:00:00Z") }, + { sessionId: null, firstSeen: new Date("2026-01-01T00:00:00Z") }, + ], + { whereArgs, groupByArgs, orderByArgs, limitArgs } + ) + ); + + vi.doMock("@/drizzle/db", () => ({ + db: { select: selectMock }, + })); + + const { findUsageLogSessionIdSuggestions } = await import("@/repository/usage-logs"); + const result = await findUsageLogSessionIdSuggestions({ + term: " abc ", + userId: 1, + keyId: 2, + providerId: 3, + limit: 20, + }); + + expect(result).toEqual(["session_1"]); + + expect(whereArgs.length).toBeGreaterThan(0); + const whereSql = sqlToString(whereArgs[0]).toLowerCase(); + expect(whereSql).toContain("like"); + expect(whereSql).toContain("escape"); + expect(whereSql).toContain("abc%"); + expect(whereSql).not.toContain("%abc%"); + expect(whereSql).not.toContain("ilike"); + expect(whereSql).not.toContain(" abc "); + + expect(groupByArgs.length).toBeGreaterThan(0); + + expect(orderByArgs.length).toBeGreaterThan(0); + const orderSql = sqlToString(orderByArgs[0]).toLowerCase(); + expect(orderSql).toContain("min"); + + expect(limitArgs).toEqual([20]); + }); + + test("term 含 %/_/\\\\:应按字面量前缀匹配(需转义)", async () => { + vi.resetModules(); + + const whereArgs: unknown[] = []; + const selectMock = vi.fn(() => createThenableQuery([], { whereArgs })); + + vi.doMock("@/drizzle/db", () => ({ + db: { select: selectMock }, + })); + + const { findUsageLogSessionIdSuggestions } = await import("@/repository/usage-logs"); + await findUsageLogSessionIdSuggestions({ + term: "a%_\\b", + limit: 20, + }); + + expect(whereArgs.length).toBeGreaterThan(0); + const whereSql = sqlToString(whereArgs[0]).toLowerCase(); + expect(whereSql).toContain("like"); + expect(whereSql).toContain("escape"); + expect(whereSql).toContain("a\\%\\_\\\\b%"); + expect(whereSql).not.toContain("ilike"); + }); + + test("limit 应被 clamp 到 [1, 50]", async () => { + vi.resetModules(); + + const limitArgs: unknown[] = []; + const selectMock = vi.fn(() => createThenableQuery([], { limitArgs })); + vi.doMock("@/drizzle/db", () => ({ + db: { select: selectMock }, + })); + + const { findUsageLogSessionIdSuggestions } = await import("@/repository/usage-logs"); + await findUsageLogSessionIdSuggestions({ term: "abc", limit: 500 }); + + expect(limitArgs).toEqual([50]); + }); + + test("keyId 未提供时不应 innerJoin(keysTable)", async () => { + vi.resetModules(); + + const query = createThenableQuery([]); + const selectMock = vi.fn(() => query); + vi.doMock("@/drizzle/db", () => ({ + db: { select: selectMock }, + })); + + const { findUsageLogSessionIdSuggestions } = await import("@/repository/usage-logs"); + await findUsageLogSessionIdSuggestions({ term: "abc", limit: 20 }); + + expect(query.innerJoin).not.toHaveBeenCalled(); + }); + + test("keyId 提供时才 innerJoin(keysTable)", async () => { + vi.resetModules(); + + const query = createThenableQuery([]); + const selectMock = vi.fn(() => query); + vi.doMock("@/drizzle/db", () => ({ + db: { select: selectMock }, + })); + + const { findUsageLogSessionIdSuggestions } = await import("@/repository/usage-logs"); + await findUsageLogSessionIdSuggestions({ term: "abc", keyId: 2, limit: 20 }); + + expect(query.innerJoin).toHaveBeenCalledTimes(1); + }); +}); diff --git a/vitest.include-session-id-in-errors.config.ts b/vitest.include-session-id-in-errors.config.ts new file mode 100644 index 000000000..e07479f8b --- /dev/null +++ b/vitest.include-session-id-in-errors.config.ts @@ -0,0 +1,59 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +/** + * Include CCH session id in client errors - scoped coverage config + * + * 目的: + * - 验证错误响应中附带 sessionId 的行为(message + header) + * - 覆盖率只统计本次改动相关模块,避免引入 Next/DB/Redis 重模块 + * - 覆盖率阈值:>= 90% + */ +export default defineConfig({ + test: { + globals: true, + environment: "happy-dom", + setupFiles: ["./tests/setup.ts"], + + include: [ + "tests/unit/proxy/responses-session-id.test.ts", + "tests/unit/proxy/proxy-handler-session-id-error.test.ts", + "tests/unit/proxy/error-handler-session-id-error.test.ts", + "tests/unit/proxy/chat-completions-handler-guard-pipeline.test.ts", + ], + exclude: ["node_modules", ".next", "dist", "build", "coverage", "tests/integration/**"], + + coverage: { + provider: "v8", + reporter: ["text", "html", "json"], + reportsDirectory: "./coverage-include-session-id-in-errors", + + include: [ + "src/app/v1/_lib/proxy/error-session-id.ts", + "src/app/v1/_lib/proxy-handler.ts", + "src/app/v1/_lib/codex/chat-completions-handler.ts", + ], + exclude: ["node_modules/", "tests/", "**/*.d.ts", ".next/"], + + thresholds: { + lines: 90, + functions: 90, + branches: 90, + statements: 90, + }, + }, + + reporters: ["verbose"], + isolate: true, + mockReset: true, + restoreMocks: true, + clearMocks: true, + }, + + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + "server-only": path.resolve(__dirname, "./tests/server-only.mock.ts"), + }, + }, +}); diff --git a/vitest.logs-sessionid-time-filter.config.ts b/vitest.logs-sessionid-time-filter.config.ts new file mode 100644 index 000000000..7e2cef58c --- /dev/null +++ b/vitest.logs-sessionid-time-filter.config.ts @@ -0,0 +1,61 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +/** + * Dashboard Logs(Session ID + 秒级时间筛选)专项覆盖率配置 + * + * 目的: + * - 仅统计本需求可纯函数化/可隔离模块的覆盖率(>= 90%) + * - 仍然执行关键回归相关的单测集合,避免只跑“指标好看”的子集 + */ +export default defineConfig({ + test: { + globals: true, + environment: "happy-dom", + setupFiles: ["./tests/setup.ts"], + + include: [ + "tests/unit/repository/usage-logs-sessionid-filter.test.ts", + "tests/unit/repository/usage-logs-sessionid-suggestions.test.ts", + "tests/unit/dashboard-logs-query-utils.test.ts", + "tests/unit/dashboard-logs-time-range-utils.test.ts", + "tests/unit/dashboard-logs-filters-time-range.test.tsx", + "tests/unit/dashboard-logs-sessionid-suggestions-ui.test.tsx", + "tests/unit/dashboard-logs-virtualized-special-settings-ui.test.tsx", + "src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx", + ], + exclude: ["node_modules", ".next", "dist", "build", "coverage", "tests/integration/**"], + + coverage: { + provider: "v8", + reporter: ["text", "html", "json", "lcov"], + reportsDirectory: "./coverage-logs-sessionid-time-filter", + + include: [ + "src/app/[locale]/dashboard/logs/_utils/logs-query.ts", + "src/app/[locale]/dashboard/logs/_utils/time-range.ts", + ], + exclude: ["node_modules/", "tests/", "**/*.d.ts", ".next/"], + + thresholds: { + lines: 90, + functions: 90, + branches: 90, + statements: 90, + }, + }, + + reporters: ["verbose"], + isolate: true, + mockReset: true, + restoreMocks: true, + clearMocks: true, + }, + + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + "server-only": path.resolve(__dirname, "./tests/server-only.mock.ts"), + }, + }, +}); diff --git a/vitest.usage-logs-sessionid-search.config.ts b/vitest.usage-logs-sessionid-search.config.ts new file mode 100644 index 000000000..b83fa0368 --- /dev/null +++ b/vitest.usage-logs-sessionid-search.config.ts @@ -0,0 +1,60 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +/** + * Dashboard Logs(Session ID 搜索:前缀匹配 + LIKE 转义)专项覆盖率配置 + * + * 目的: + * - 仅统计本需求可隔离模块的覆盖率(>= 90%) + * - 同时执行关联单测集合,避免只跑“指标好看”的子集 + */ +export default defineConfig({ + test: { + globals: true, + environment: "happy-dom", + setupFiles: ["./tests/setup.ts"], + + include: [ + "tests/unit/repository/usage-logs-sessionid-suggestions.test.ts", + "tests/unit/repository/usage-logs-sessionid-filter.test.ts", + "tests/unit/repository/warmup-stats-exclusion.test.ts", + "tests/unit/repository/escape-like.test.ts", + "tests/unit/lib/constants/usage-logs.constants.test.ts", + "tests/unit/lib/utils/clipboard.test.ts", + ], + exclude: ["node_modules", ".next", "dist", "build", "coverage", "tests/integration/**"], + + coverage: { + provider: "v8", + reporter: ["text", "html", "json", "lcov"], + reportsDirectory: "./coverage-usage-logs-sessionid-search", + + include: [ + "src/repository/_shared/like.ts", + "src/lib/constants/usage-logs.constants.ts", + "src/lib/utils/clipboard.ts", + ], + exclude: ["node_modules/", "tests/", "**/*.d.ts", ".next/"], + + thresholds: { + lines: 90, + functions: 90, + branches: 90, + statements: 90, + }, + }, + + reporters: ["verbose"], + isolate: true, + mockReset: true, + restoreMocks: true, + clearMocks: true, + }, + + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + "server-only": path.resolve(__dirname, "./tests/server-only.mock.ts"), + }, + }, +});