From 3e41208f741476e5e955e4e5e60cba7e6ac5c2ac Mon Sep 17 00:00:00 2001 From: mci77777 Date: Sun, 26 Apr 2026 12:49:36 +0800 Subject: [PATCH 1/2] feat: expose self-service quota windows --- .../api-key-quota-extractor-compatible.js | 94 +++++++ .../examples/api-key-quota-extractor-daily.js | 48 ++++ .../api-key-quota-extractor-direct.js | 64 +++++ .../examples/api-key-quota-extractor-total.js | 48 ++++ .../api-key-quota-extractor-weekly.js | 52 ++++ docs/examples/api-key-quota-extractor.js | 67 +++++ .../api-key-quota-extractor-compatible.js | 94 +++++++ .../api-key-quota-extractor-direct.js | 64 +++++ public/examples/api-key-quota-extractor.js | 67 +++++ src/actions/my-usage.ts | 234 +++++++++++++++++- src/app/api/actions/[...route]/route.ts | 62 +++++ src/app/v1/_lib/proxy/auth-guard.ts | 14 +- src/app/v1/_lib/proxy/session.ts | 1 + src/lib/api/action-adapter-openapi.ts | 5 +- src/lib/auth.ts | 16 +- src/lib/auth/readonly-access.ts | 12 + src/lib/my-usage/readonly-redaction.ts | 33 +++ src/proxy.ts | 5 +- tests/api/my-usage-readonly.test.ts | 187 +++++++++++++- tests/configs/my-usage.config.ts | 6 + .../my-usage-concurrent-inherit.test.ts | 77 +++++- .../auth/auth-scoped-session-branches.test.ts | 128 ++++++++++ 22 files changed, 1361 insertions(+), 17 deletions(-) create mode 100644 docs/examples/api-key-quota-extractor-compatible.js create mode 100644 docs/examples/api-key-quota-extractor-daily.js create mode 100644 docs/examples/api-key-quota-extractor-direct.js create mode 100644 docs/examples/api-key-quota-extractor-total.js create mode 100644 docs/examples/api-key-quota-extractor-weekly.js create mode 100644 docs/examples/api-key-quota-extractor.js create mode 100644 public/examples/api-key-quota-extractor-compatible.js create mode 100644 public/examples/api-key-quota-extractor-direct.js create mode 100644 public/examples/api-key-quota-extractor.js create mode 100644 src/lib/auth/readonly-access.ts create mode 100644 src/lib/my-usage/readonly-redaction.ts create mode 100644 tests/unit/auth/auth-scoped-session-branches.test.ts diff --git a/docs/examples/api-key-quota-extractor-compatible.js b/docs/examples/api-key-quota-extractor-compatible.js new file mode 100644 index 000000000..d6b74976d --- /dev/null +++ b/docs/examples/api-key-quota-extractor-compatible.js @@ -0,0 +1,94 @@ +({ + request: { + url: "{{baseUrl}}/api/actions/my-usage/getMyQuota", + method: "POST", + headers: { + "Authorization": "Bearer {{apiKey}}", + "Content-Type": "application/json", + "User-Agent": "cc-switch/1.0" + }, + body: "{}" + }, + + extractor: function(response) { + const data = response && response.ok === true && response.data && typeof response.data === "object" + ? response.data + : {}; + + const toNumber = function(value, fallback) { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; + }; + + const formatPercent = function(value) { + return typeof value === "number" && Number.isFinite(value) ? value + "%" : "-"; + }; + + const toBoolean = function(value, fallback) { + return typeof value === "boolean" ? value : fallback; + }; + + const round2 = function(value) { + return Math.round(value * 100) / 100; + }; + + const percent = function(used, total) { + return total > 0 ? round2((used / total) * 100) : null; + }; + + const quotaWindows = data.quotaWindows && typeof data.quotaWindows === "object" + ? data.quotaWindows + : {}; + const fiveHour = quotaWindows.fiveHour || {}; + const daily = quotaWindows.daily || {}; + const weekly = quotaWindows.weekly || {}; + const monthly = quotaWindows.monthly || {}; + const total = quotaWindows.total || {}; + + const limitMonthlyUsd = toNumber(data.limitMonthlyUsd, null); + const limitTotalUsd = toNumber(data.limitTotalUsd, limitMonthlyUsd); + const usedTotalUsd = toNumber(data.usedTotalUsd, toNumber(data.usedMonthlyUsd, 0)); + const remainingTotalUsd = limitTotalUsd === null ? null : round2(Math.max(limitTotalUsd - usedTotalUsd, 0)); + + const limitDailyUsd = toNumber(data.limitDailyUsd, null); + const usedDailyUsd = toNumber(data.usedDailyUsd, 0); + const remainingDailyUsd = limitDailyUsd === null ? null : round2(Math.max(limitDailyUsd - usedDailyUsd, 0)); + + const limit5hUsd = toNumber(data.limit5hUsd, null); + const used5hUsd = toNumber(data.used5hUsd, 0); + const remaining5hUsd = limit5hUsd === null ? null : round2(Math.max(limit5hUsd - used5hUsd, 0)); + + const isValid = + response && + response.ok === true && + toBoolean(data.keyIsEnabled, true) && + toBoolean(data.userIsEnabled, true); + + return { + isValid: !!isValid, + invalidMessage: isValid ? undefined : "套餐不可用", + planName: "Total Quota", + unit: typeof data.unit === "string" ? data.unit : "USD", + remaining: toNumber(total.remainingUsd, remainingTotalUsd), + total: toNumber(total.limitUsd, limitTotalUsd), + used: toNumber(total.usedUsd, usedTotalUsd), + todayUsed: toNumber(data.todayUsedUsd, toNumber(daily.usedUsd, usedDailyUsd)), + todayRemaining: toNumber(data.todayRemainingUsd, toNumber(daily.remainingUsd, remainingDailyUsd)), + todayUsedPercent: toNumber(data.todayUsedPercent, toNumber(daily.usedPercent, percent(usedDailyUsd, limitDailyUsd))), + todayRemainingPercent: toNumber( + data.todayRemainingPercent, + toNumber(daily.remainingPercent, percent(remainingDailyUsd || 0, limitDailyUsd)) + ), + remaining5h: toNumber(fiveHour.remainingUsd, remaining5hUsd), + remainingDaily: toNumber(daily.remainingUsd, remainingDailyUsd), + remainingWeekly: toNumber(weekly.remainingUsd, toNumber(data.remainingWeeklyUsd, null)), + remainingMonthly: toNumber(monthly.remainingUsd, toNumber(data.remainingMonthlyUsd, null)), + remainingTotal: toNumber(total.remainingUsd, remainingTotalUsd), + remainingPercent: toNumber(total.remainingPercent, data.remainingPercent), + extra: "5H剩余:" + formatPercent(toNumber(fiveHour.remainingPercent, percent(remaining5hUsd || 0, limit5hUsd))) + + "/日剩余:" + formatPercent(toNumber(daily.remainingPercent, percent(remainingDailyUsd || 0, limitDailyUsd))) + + "/周剩余:" + formatPercent(weekly.remainingPercent) + + "/月剩余:" + formatPercent(monthly.remainingPercent) + + "/总剩余:" + formatPercent(toNumber(total.remainingPercent, data.remainingPercent)) + }; + } +}) diff --git a/docs/examples/api-key-quota-extractor-daily.js b/docs/examples/api-key-quota-extractor-daily.js new file mode 100644 index 000000000..b9ca4e335 --- /dev/null +++ b/docs/examples/api-key-quota-extractor-daily.js @@ -0,0 +1,48 @@ +({ + request: { + url: "{{baseUrl}}/api/actions/my-usage/getMyQuota", + method: "POST", + headers: { + "Authorization": "Bearer {{apiKey}}", + "Content-Type": "application/json", + "User-Agent": "cc-switch/1.0" + }, + body: "{}" + }, + + extractor: function(response) { + const data = response && response.ok === true && response.data && typeof response.data === "object" + ? response.data + : {}; + + const toNumber = function(value, fallback) { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; + }; + + const toBoolean = function(value, fallback) { + return typeof value === "boolean" ? value : fallback; + }; + + const quotaWindows = data.quotaWindows && typeof data.quotaWindows === "object" + ? data.quotaWindows + : {}; + const daily = quotaWindows.daily || {}; + + return { + ok: response && response.ok === true, + isValid: toBoolean(data.keyIsEnabled, true) && toBoolean(data.userIsEnabled, true), + planName: "Daily Quota", + remaining: toNumber(daily.remainingUsd, toNumber(data.remainingDailyUsd, null)), + total: toNumber(daily.limitUsd, toNumber(data.limitDailyUsd, null)), + used: toNumber(daily.usedUsd, toNumber(data.usedDailyUsd, 0)), + usedPercent: toNumber(daily.usedPercent, toNumber(data.todayUsedPercent, null)), + remainingPercent: toNumber(daily.remainingPercent, toNumber(data.todayRemainingPercent, null)), + unit: typeof data.unit === "string" ? data.unit : "USD", + keyName: typeof data.keyName === "string" ? data.keyName : null, + userName: typeof data.userName === "string" ? data.userName : null, + providerGroup: typeof data.providerGroup === "string" ? data.providerGroup : null, + resetMode: typeof data.resetMode === "string" ? data.resetMode : null, + resetTime: typeof data.resetTime === "string" ? data.resetTime : null + }; + } +}) diff --git a/docs/examples/api-key-quota-extractor-direct.js b/docs/examples/api-key-quota-extractor-direct.js new file mode 100644 index 000000000..e530e2d3e --- /dev/null +++ b/docs/examples/api-key-quota-extractor-direct.js @@ -0,0 +1,64 @@ +({ + request: { + url: "{{baseUrl}}/api/actions/my-usage/getMyQuota", + method: "POST", + headers: { + "Authorization": "Bearer {{apiKey}}", + "Content-Type": "application/json", + "User-Agent": "cc-switch/1.0" + }, + body: "{}" + }, + + extractor: function(response) { + const data = response && response.ok === true && response.data && typeof response.data === "object" + ? response.data + : {}; + const quotaWindows = data.quotaWindows && typeof data.quotaWindows === "object" + ? data.quotaWindows + : {}; + const fiveHour = quotaWindows.fiveHour || {}; + const daily = quotaWindows.daily || {}; + const weekly = quotaWindows.weekly || {}; + const monthly = quotaWindows.monthly || {}; + const total = quotaWindows.total || {}; + const formatPercent = function(value) { + return typeof value === "number" && Number.isFinite(value) ? value + "%" : "-"; + }; + + const toBoolean = function(value, fallback) { + return typeof value === "boolean" ? value : fallback; + }; + + const isValid = + response && + response.ok === true && + toBoolean(data.keyIsEnabled, true) && + toBoolean(data.userIsEnabled, true); + + return { + isValid: !!isValid, + invalidMessage: isValid ? undefined : "套餐不可用", + planName: "Total Quota", + unit: typeof data.unit === "string" ? data.unit : "USD", + remaining: total.remainingUsd, + total: total.limitUsd, + used: total.usedUsd, + todayUsed: data.todayUsedUsd, + todayRemaining: data.todayRemainingUsd, + todayUsedPercent: data.todayUsedPercent, + todayRemainingPercent: data.todayRemainingPercent, + remaining5h: fiveHour.remainingUsd, + remainingDaily: daily.remainingUsd, + remainingWeekly: weekly.remainingUsd, + remainingMonthly: monthly.remainingUsd, + remainingTotal: total.remainingUsd, + remainingPercent: data.remainingPercent, + extra: "5H剩余:" + formatPercent(fiveHour.remainingPercent) + + "/日剩余:" + formatPercent(daily.remainingPercent) + + "/周剩余:" + formatPercent(weekly.remainingPercent) + + "/月剩余:" + formatPercent(monthly.remainingPercent) + + "/总剩余:" + formatPercent(total.remainingPercent) + }; + } +}) diff --git a/docs/examples/api-key-quota-extractor-total.js b/docs/examples/api-key-quota-extractor-total.js new file mode 100644 index 000000000..703c42355 --- /dev/null +++ b/docs/examples/api-key-quota-extractor-total.js @@ -0,0 +1,48 @@ +({ + request: { + url: "{{baseUrl}}/api/actions/my-usage/getMyQuota", + method: "POST", + headers: { + "Authorization": "Bearer {{apiKey}}", + "Content-Type": "application/json", + "User-Agent": "cc-switch/1.0" + }, + body: "{}" + }, + + extractor: function(response) { + const data = response && response.ok === true && response.data && typeof response.data === "object" + ? response.data + : {}; + + const toNumber = function(value, fallback) { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; + }; + + const toBoolean = function(value, fallback) { + return typeof value === "boolean" ? value : fallback; + }; + + const quotaWindows = data.quotaWindows && typeof data.quotaWindows === "object" + ? data.quotaWindows + : {}; + const total = quotaWindows.total || {}; + + return { + ok: response && response.ok === true, + isValid: toBoolean(data.keyIsEnabled, true) && toBoolean(data.userIsEnabled, true), + planName: "Total Quota", + remaining: toNumber(total.remainingUsd, toNumber(data.remainingTotalUsd, null)), + total: toNumber(total.limitUsd, toNumber(data.limitTotalUsd, null)), + used: toNumber(total.usedUsd, toNumber(data.usedTotalUsd, 0)), + usedPercent: toNumber(total.usedPercent, null), + remainingPercent: toNumber(total.remainingPercent, toNumber(data.remainingPercent, null)), + unit: typeof data.unit === "string" ? data.unit : "USD", + keyName: typeof data.keyName === "string" ? data.keyName : null, + userName: typeof data.userName === "string" ? data.userName : null, + providerGroup: typeof data.providerGroup === "string" ? data.providerGroup : null, + resetMode: typeof data.resetMode === "string" ? data.resetMode : null, + resetTime: typeof data.resetTime === "string" ? data.resetTime : null + }; + } +}) diff --git a/docs/examples/api-key-quota-extractor-weekly.js b/docs/examples/api-key-quota-extractor-weekly.js new file mode 100644 index 000000000..7f1c64e35 --- /dev/null +++ b/docs/examples/api-key-quota-extractor-weekly.js @@ -0,0 +1,52 @@ +({ + request: { + url: "{{baseUrl}}/api/actions/my-usage/getMyQuota", + method: "POST", + headers: { + "Authorization": "Bearer {{apiKey}}", + "Content-Type": "application/json", + "User-Agent": "cc-switch/1.0" + }, + body: "{}" + }, + + extractor: function(response) { + const data = response && response.ok === true && response.data && typeof response.data === "object" + ? response.data + : {}; + + const toNumber = function(value, fallback) { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; + }; + + const toBoolean = function(value, fallback) { + return typeof value === "boolean" ? value : fallback; + }; + + const quotaWindows = data.quotaWindows && typeof data.quotaWindows === "object" + ? data.quotaWindows + : {}; + const weekly = quotaWindows.weekly || {}; + + return { + isValid: !!( + response && + response.ok === true && + toBoolean(data.keyIsEnabled, true) && + toBoolean(data.userIsEnabled, true) + ), + planName: "Weekly Quota", + remaining: toNumber(weekly.remainingUsd, toNumber(data.remainingWeeklyUsd, null)), + total: toNumber(weekly.limitUsd, toNumber(data.limitWeeklyUsd, null)), + used: toNumber(weekly.usedUsd, toNumber(data.usedWeeklyUsd, 0)), + usedPercent: toNumber(weekly.usedPercent, null), + remainingPercent: toNumber(weekly.remainingPercent, null), + unit: typeof data.unit === "string" ? data.unit : "USD", + keyName: typeof data.keyName === "string" ? data.keyName : null, + userName: typeof data.userName === "string" ? data.userName : null, + providerGroup: typeof data.providerGroup === "string" ? data.providerGroup : null, + resetMode: typeof data.resetMode === "string" ? data.resetMode : null, + resetTime: typeof data.resetTime === "string" ? data.resetTime : null + }; + } +}) diff --git a/docs/examples/api-key-quota-extractor.js b/docs/examples/api-key-quota-extractor.js new file mode 100644 index 000000000..1539f8bb7 --- /dev/null +++ b/docs/examples/api-key-quota-extractor.js @@ -0,0 +1,67 @@ +({ + request: { + url: "{{baseUrl}}/api/actions/my-usage/getMyQuota", + method: "POST", + headers: { + "Authorization": "Bearer {{apiKey}}", + "Content-Type": "application/json", + "User-Agent": "cc-switch/1.0" + }, + body: "{}" + }, + + extractor: function(response) { + const data = response && response.ok === true && response.data && typeof response.data === "object" + ? response.data + : {}; + + const toNumber = function(value, fallback) { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; + }; + + const formatPercent = function(value) { + return typeof value === "number" && Number.isFinite(value) ? value + "%" : "-"; + }; + + const toBoolean = function(value, fallback) { + return typeof value === "boolean" ? value : fallback; + }; + + const quotaWindows = data.quotaWindows && typeof data.quotaWindows === "object" + ? data.quotaWindows + : {}; + const fiveHour = quotaWindows.fiveHour || {}; + const daily = quotaWindows.daily || {}; + const weekly = quotaWindows.weekly || {}; + const monthly = quotaWindows.monthly || {}; + const total = quotaWindows.total || {}; + + const isValid = + response && + response.ok === true && + toBoolean(data.keyIsEnabled, true) && + toBoolean(data.userIsEnabled, true); + + return { + isValid: !!isValid, + invalidMessage: isValid ? undefined : "套餐不可用", + remaining: toNumber(total.remainingUsd, toNumber(data.remainingTotalUsd, null)), + unit: typeof data.unit === "string" ? data.unit : "USD", + planName: "Total Quota", + total: toNumber(total.limitUsd, toNumber(data.limitTotalUsd, null)), + used: toNumber(total.usedUsd, toNumber(data.usedTotalUsd, 0)), + todayUsed: toNumber(data.todayUsedUsd, toNumber(daily.usedUsd, 0)), + todayRemaining: toNumber(data.todayRemainingUsd, toNumber(daily.remainingUsd, null)), + remainingWeekly: toNumber(weekly.remainingUsd, toNumber(data.remainingWeeklyUsd, null)), + remainingMonthly: toNumber(monthly.remainingUsd, toNumber(data.remainingMonthlyUsd, null)), + remainingTotal: toNumber(total.remainingUsd, toNumber(data.remainingTotalUsd, null)), + remaining5h: toNumber(fiveHour.remainingUsd, toNumber(data.remaining5hUsd, null)), + remainingDaily: toNumber(daily.remainingUsd, toNumber(data.remainingDailyUsd, null)), + extra: "5H剩余:" + formatPercent(fiveHour.remainingPercent) + + "/日剩余:" + formatPercent(toNumber(daily.remainingPercent, data.todayRemainingPercent)) + + "/周剩余:" + formatPercent(weekly.remainingPercent) + + "/月剩余:" + formatPercent(monthly.remainingPercent) + + "/总剩余:" + formatPercent(toNumber(total.remainingPercent, data.remainingPercent)) + }; + } +}) diff --git a/public/examples/api-key-quota-extractor-compatible.js b/public/examples/api-key-quota-extractor-compatible.js new file mode 100644 index 000000000..d6b74976d --- /dev/null +++ b/public/examples/api-key-quota-extractor-compatible.js @@ -0,0 +1,94 @@ +({ + request: { + url: "{{baseUrl}}/api/actions/my-usage/getMyQuota", + method: "POST", + headers: { + "Authorization": "Bearer {{apiKey}}", + "Content-Type": "application/json", + "User-Agent": "cc-switch/1.0" + }, + body: "{}" + }, + + extractor: function(response) { + const data = response && response.ok === true && response.data && typeof response.data === "object" + ? response.data + : {}; + + const toNumber = function(value, fallback) { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; + }; + + const formatPercent = function(value) { + return typeof value === "number" && Number.isFinite(value) ? value + "%" : "-"; + }; + + const toBoolean = function(value, fallback) { + return typeof value === "boolean" ? value : fallback; + }; + + const round2 = function(value) { + return Math.round(value * 100) / 100; + }; + + const percent = function(used, total) { + return total > 0 ? round2((used / total) * 100) : null; + }; + + const quotaWindows = data.quotaWindows && typeof data.quotaWindows === "object" + ? data.quotaWindows + : {}; + const fiveHour = quotaWindows.fiveHour || {}; + const daily = quotaWindows.daily || {}; + const weekly = quotaWindows.weekly || {}; + const monthly = quotaWindows.monthly || {}; + const total = quotaWindows.total || {}; + + const limitMonthlyUsd = toNumber(data.limitMonthlyUsd, null); + const limitTotalUsd = toNumber(data.limitTotalUsd, limitMonthlyUsd); + const usedTotalUsd = toNumber(data.usedTotalUsd, toNumber(data.usedMonthlyUsd, 0)); + const remainingTotalUsd = limitTotalUsd === null ? null : round2(Math.max(limitTotalUsd - usedTotalUsd, 0)); + + const limitDailyUsd = toNumber(data.limitDailyUsd, null); + const usedDailyUsd = toNumber(data.usedDailyUsd, 0); + const remainingDailyUsd = limitDailyUsd === null ? null : round2(Math.max(limitDailyUsd - usedDailyUsd, 0)); + + const limit5hUsd = toNumber(data.limit5hUsd, null); + const used5hUsd = toNumber(data.used5hUsd, 0); + const remaining5hUsd = limit5hUsd === null ? null : round2(Math.max(limit5hUsd - used5hUsd, 0)); + + const isValid = + response && + response.ok === true && + toBoolean(data.keyIsEnabled, true) && + toBoolean(data.userIsEnabled, true); + + return { + isValid: !!isValid, + invalidMessage: isValid ? undefined : "套餐不可用", + planName: "Total Quota", + unit: typeof data.unit === "string" ? data.unit : "USD", + remaining: toNumber(total.remainingUsd, remainingTotalUsd), + total: toNumber(total.limitUsd, limitTotalUsd), + used: toNumber(total.usedUsd, usedTotalUsd), + todayUsed: toNumber(data.todayUsedUsd, toNumber(daily.usedUsd, usedDailyUsd)), + todayRemaining: toNumber(data.todayRemainingUsd, toNumber(daily.remainingUsd, remainingDailyUsd)), + todayUsedPercent: toNumber(data.todayUsedPercent, toNumber(daily.usedPercent, percent(usedDailyUsd, limitDailyUsd))), + todayRemainingPercent: toNumber( + data.todayRemainingPercent, + toNumber(daily.remainingPercent, percent(remainingDailyUsd || 0, limitDailyUsd)) + ), + remaining5h: toNumber(fiveHour.remainingUsd, remaining5hUsd), + remainingDaily: toNumber(daily.remainingUsd, remainingDailyUsd), + remainingWeekly: toNumber(weekly.remainingUsd, toNumber(data.remainingWeeklyUsd, null)), + remainingMonthly: toNumber(monthly.remainingUsd, toNumber(data.remainingMonthlyUsd, null)), + remainingTotal: toNumber(total.remainingUsd, remainingTotalUsd), + remainingPercent: toNumber(total.remainingPercent, data.remainingPercent), + extra: "5H剩余:" + formatPercent(toNumber(fiveHour.remainingPercent, percent(remaining5hUsd || 0, limit5hUsd))) + + "/日剩余:" + formatPercent(toNumber(daily.remainingPercent, percent(remainingDailyUsd || 0, limitDailyUsd))) + + "/周剩余:" + formatPercent(weekly.remainingPercent) + + "/月剩余:" + formatPercent(monthly.remainingPercent) + + "/总剩余:" + formatPercent(toNumber(total.remainingPercent, data.remainingPercent)) + }; + } +}) diff --git a/public/examples/api-key-quota-extractor-direct.js b/public/examples/api-key-quota-extractor-direct.js new file mode 100644 index 000000000..e530e2d3e --- /dev/null +++ b/public/examples/api-key-quota-extractor-direct.js @@ -0,0 +1,64 @@ +({ + request: { + url: "{{baseUrl}}/api/actions/my-usage/getMyQuota", + method: "POST", + headers: { + "Authorization": "Bearer {{apiKey}}", + "Content-Type": "application/json", + "User-Agent": "cc-switch/1.0" + }, + body: "{}" + }, + + extractor: function(response) { + const data = response && response.ok === true && response.data && typeof response.data === "object" + ? response.data + : {}; + const quotaWindows = data.quotaWindows && typeof data.quotaWindows === "object" + ? data.quotaWindows + : {}; + const fiveHour = quotaWindows.fiveHour || {}; + const daily = quotaWindows.daily || {}; + const weekly = quotaWindows.weekly || {}; + const monthly = quotaWindows.monthly || {}; + const total = quotaWindows.total || {}; + const formatPercent = function(value) { + return typeof value === "number" && Number.isFinite(value) ? value + "%" : "-"; + }; + + const toBoolean = function(value, fallback) { + return typeof value === "boolean" ? value : fallback; + }; + + const isValid = + response && + response.ok === true && + toBoolean(data.keyIsEnabled, true) && + toBoolean(data.userIsEnabled, true); + + return { + isValid: !!isValid, + invalidMessage: isValid ? undefined : "套餐不可用", + planName: "Total Quota", + unit: typeof data.unit === "string" ? data.unit : "USD", + remaining: total.remainingUsd, + total: total.limitUsd, + used: total.usedUsd, + todayUsed: data.todayUsedUsd, + todayRemaining: data.todayRemainingUsd, + todayUsedPercent: data.todayUsedPercent, + todayRemainingPercent: data.todayRemainingPercent, + remaining5h: fiveHour.remainingUsd, + remainingDaily: daily.remainingUsd, + remainingWeekly: weekly.remainingUsd, + remainingMonthly: monthly.remainingUsd, + remainingTotal: total.remainingUsd, + remainingPercent: data.remainingPercent, + extra: "5H剩余:" + formatPercent(fiveHour.remainingPercent) + + "/日剩余:" + formatPercent(daily.remainingPercent) + + "/周剩余:" + formatPercent(weekly.remainingPercent) + + "/月剩余:" + formatPercent(monthly.remainingPercent) + + "/总剩余:" + formatPercent(total.remainingPercent) + }; + } +}) diff --git a/public/examples/api-key-quota-extractor.js b/public/examples/api-key-quota-extractor.js new file mode 100644 index 000000000..1539f8bb7 --- /dev/null +++ b/public/examples/api-key-quota-extractor.js @@ -0,0 +1,67 @@ +({ + request: { + url: "{{baseUrl}}/api/actions/my-usage/getMyQuota", + method: "POST", + headers: { + "Authorization": "Bearer {{apiKey}}", + "Content-Type": "application/json", + "User-Agent": "cc-switch/1.0" + }, + body: "{}" + }, + + extractor: function(response) { + const data = response && response.ok === true && response.data && typeof response.data === "object" + ? response.data + : {}; + + const toNumber = function(value, fallback) { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; + }; + + const formatPercent = function(value) { + return typeof value === "number" && Number.isFinite(value) ? value + "%" : "-"; + }; + + const toBoolean = function(value, fallback) { + return typeof value === "boolean" ? value : fallback; + }; + + const quotaWindows = data.quotaWindows && typeof data.quotaWindows === "object" + ? data.quotaWindows + : {}; + const fiveHour = quotaWindows.fiveHour || {}; + const daily = quotaWindows.daily || {}; + const weekly = quotaWindows.weekly || {}; + const monthly = quotaWindows.monthly || {}; + const total = quotaWindows.total || {}; + + const isValid = + response && + response.ok === true && + toBoolean(data.keyIsEnabled, true) && + toBoolean(data.userIsEnabled, true); + + return { + isValid: !!isValid, + invalidMessage: isValid ? undefined : "套餐不可用", + remaining: toNumber(total.remainingUsd, toNumber(data.remainingTotalUsd, null)), + unit: typeof data.unit === "string" ? data.unit : "USD", + planName: "Total Quota", + total: toNumber(total.limitUsd, toNumber(data.limitTotalUsd, null)), + used: toNumber(total.usedUsd, toNumber(data.usedTotalUsd, 0)), + todayUsed: toNumber(data.todayUsedUsd, toNumber(daily.usedUsd, 0)), + todayRemaining: toNumber(data.todayRemainingUsd, toNumber(daily.remainingUsd, null)), + remainingWeekly: toNumber(weekly.remainingUsd, toNumber(data.remainingWeeklyUsd, null)), + remainingMonthly: toNumber(monthly.remainingUsd, toNumber(data.remainingMonthlyUsd, null)), + remainingTotal: toNumber(total.remainingUsd, toNumber(data.remainingTotalUsd, null)), + remaining5h: toNumber(fiveHour.remainingUsd, toNumber(data.remaining5hUsd, null)), + remainingDaily: toNumber(daily.remainingUsd, toNumber(data.remainingDailyUsd, null)), + extra: "5H剩余:" + formatPercent(fiveHour.remainingPercent) + + "/日剩余:" + formatPercent(toNumber(daily.remainingPercent, data.todayRemainingPercent)) + + "/周剩余:" + formatPercent(weekly.remainingPercent) + + "/月剩余:" + formatPercent(monthly.remainingPercent) + + "/总剩余:" + formatPercent(toNumber(total.remainingPercent, data.remainingPercent)) + }; + } +}) diff --git a/src/actions/my-usage.ts b/src/actions/my-usage.ts index a1536f2dc..e15bab10b 100644 --- a/src/actions/my-usage.ts +++ b/src/actions/my-usage.ts @@ -8,6 +8,7 @@ import { messageRequest, usageLedger } from "@/drizzle/schema"; import { getSession } from "@/lib/auth"; import { lookupIp } from "@/lib/ip-geo/client"; import { logger } from "@/lib/logger"; +import { redactReadonlyLogs, redactReadonlyQuota } from "@/lib/my-usage/readonly-redaction"; import { resolveKeyConcurrentSessionLimit } from "@/lib/rate-limit/concurrent-session-limit"; import { clipStartByResetAt, @@ -95,6 +96,7 @@ function scrubUsageLogsBatchForReadonly(result: UsageLogsBatchResult): UsageLogs keyName: "", providerName: null, errorMessage: null, + endpoint: null, blockedReason: null, userAgent: null, messagesCount: null, @@ -171,6 +173,17 @@ export interface MyUsageMetadata { billingModelSource: BillingModelSource; } +export interface MyUsageQuotaWindow { + period: "5h" | "daily" | "weekly" | "monthly" | "total"; + limitUsd: number | null; + usedUsd: number; + remainingUsd: number | null; + usedPercent: number | null; + remainingPercent: number | null; + isUnlimited: boolean; + isExhausted: boolean; +} + export interface MyUsageQuota { keyLimit5hUsd: number | null; keyLimitDailyUsd: number | null; @@ -208,12 +221,147 @@ export interface MyUsageQuota { keyName: string; keyIsEnabled: boolean; + providerGroup: string | null; + + limit5hUsd: number | null; + used5hUsd: number; + remaining5hUsd: number | null; + + limitDailyUsd: number | null; + usedDailyUsd: number; + remainingDailyUsd: number | null; + + limitWeeklyUsd: number | null; + usedWeeklyUsd: number; + remainingWeeklyUsd: number | null; + + limitMonthlyUsd: number | null; + usedMonthlyUsd: number; + remainingMonthlyUsd: number | null; + + limitTotalUsd: number | null; + usedTotalUsd: number; + remainingTotalUsd: number | null; + + quotaWindows: { + fiveHour: MyUsageQuotaWindow; + daily: MyUsageQuotaWindow; + weekly: MyUsageQuotaWindow; + monthly: MyUsageQuotaWindow; + total: MyUsageQuotaWindow; + }; + todayUsedUsd: number; + todayRemainingUsd: number | null; + todayUsedPercent: number | null; + todayRemainingPercent: number | null; + remainingPercent: number | null; + + rpmLimit: number | null; + concurrentSessions: number; + concurrentSessionsLimit: number | null; + userAllowedModels: string[]; userAllowedClients: string[]; + readonlyRedactedFields?: string[]; expiresAt: Date | null; dailyResetMode: "fixed" | "rolling"; dailyResetTime: string; + resetMode: "fixed" | "rolling"; + resetTime: string; + remaining: number | null; + unit: "USD"; +} + +type EffectiveQuotaWindow = { + limit: number | null; + used: number; + remaining: number | null; +}; + +function clampRemaining(limit: number, used: number): number { + return Math.max(limit - used, 0); +} + +function resolveEffectiveQuotaWindow( + candidates: Array<{ limit: number | null | undefined; used: number }> +): EffectiveQuotaWindow { + const boundedCandidates = candidates + .filter((candidate): candidate is { limit: number; used: number } => candidate.limit != null) + .map((candidate) => ({ + limit: candidate.limit, + used: candidate.used, + remaining: clampRemaining(candidate.limit, candidate.used), + })); + + if (boundedCandidates.length === 0) { + return { + limit: null, + used: Math.max(...candidates.map((candidate) => candidate.used), 0), + remaining: null, + }; + } + + const mostRestrictive = boundedCandidates.reduce((current, candidate) => { + if (candidate.remaining < current.remaining) { + return candidate; + } + + if (candidate.remaining === current.remaining && candidate.limit < current.limit) { + return candidate; + } + + return current; + }); + + return mostRestrictive; +} + +function resolveOverallRemaining(values: Array): number | null { + const boundedValues = values.filter((value): value is number => value != null); + if (boundedValues.length === 0) { + return null; + } + + return Math.max(Math.min(...boundedValues), 0); +} + +function round2(value: number): number { + return Math.round((value + Number.EPSILON) * 100) / 100; +} + +function buildQuotaWindow( + period: MyUsageQuotaWindow["period"], + window: EffectiveQuotaWindow +): MyUsageQuotaWindow { + const limitUsd = window.limit == null ? null : round2(window.limit); + const usedUsd = round2(window.used); + const remainingUsd = window.remaining == null ? null : round2(window.remaining); + const hasPositiveLimit = limitUsd != null && limitUsd > 0; + + return { + period, + limitUsd, + usedUsd, + remainingUsd, + usedPercent: hasPositiveLimit ? round2((window.used / limitUsd) * 100) : null, + remainingPercent: + hasPositiveLimit && remainingUsd != null ? round2((remainingUsd / limitUsd) * 100) : null, + isUnlimited: limitUsd == null, + isExhausted: remainingUsd != null && remainingUsd <= 0, + }; +} + +function resolveOverallRemainingPercent(windows: MyUsageQuotaWindow[]): number | null { + const values = windows + .map((window) => window.remainingPercent) + .filter((value): value is number => value != null); + + if (values.length === 0) { + return null; + } + + return Math.max(Math.min(...values), 0); } export interface MyTodayStats { @@ -475,13 +623,46 @@ export async function getMyQuota(): Promise> { } = userCosts; const resolvedKeyCurrent5hUsd = keyFixed5hUsd ?? keyCurrent5hUsd; const resolvedUserCurrent5hUsd = userFixed5hUsd ?? userCurrent5hUsd; + const keyLimitTotalUsd = key.limitTotalUsd ?? null; + const userLimitTotalUsd = user.limitTotalUsd ?? null; + + const effective5h = resolveEffectiveQuotaWindow([ + { limit: key.limit5hUsd, used: resolvedKeyCurrent5hUsd }, + { limit: user.limit5hUsd, used: resolvedUserCurrent5hUsd }, + ]); + const effectiveDaily = resolveEffectiveQuotaWindow([ + { limit: key.limitDailyUsd, used: keyCostDaily }, + { limit: user.dailyQuota, used: userCostDaily }, + ]); + const effectiveWeekly = resolveEffectiveQuotaWindow([ + { limit: key.limitWeeklyUsd, used: keyCostWeekly }, + { limit: user.limitWeeklyUsd, used: userCostWeekly }, + ]); + const effectiveMonthly = resolveEffectiveQuotaWindow([ + { limit: key.limitMonthlyUsd, used: keyCostMonthly }, + { limit: user.limitMonthlyUsd, used: userCostMonthly }, + ]); + const effectiveTotal = resolveEffectiveQuotaWindow([ + { limit: keyLimitTotalUsd, used: keyTotalCost }, + { limit: userLimitTotalUsd, used: userTotalCost }, + ]); + const quotaWindows = { + fiveHour: buildQuotaWindow("5h", effective5h), + daily: buildQuotaWindow("daily", effectiveDaily), + weekly: buildQuotaWindow("weekly", effectiveWeekly), + monthly: buildQuotaWindow("monthly", effectiveMonthly), + total: buildQuotaWindow("total", effectiveTotal), + }; + const concurrentSessions = keyConcurrent; + const concurrentSessionsLimit = + effectiveKeyConcurrentLimit > 0 ? effectiveKeyConcurrentLimit : null; const quota: MyUsageQuota = { keyLimit5hUsd: key.limit5hUsd ?? null, keyLimitDailyUsd: key.limitDailyUsd ?? null, keyLimitWeeklyUsd: key.limitWeeklyUsd ?? null, keyLimitMonthlyUsd: key.limitMonthlyUsd ?? null, - keyLimitTotalUsd: key.limitTotalUsd ?? null, + keyLimitTotalUsd, keyLimitConcurrentSessions: effectiveKeyConcurrentLimit, keyCurrent5hUsd: resolvedKeyCurrent5hUsd, keyCurrentDailyUsd: keyCostDaily, @@ -493,7 +674,7 @@ export async function getMyQuota(): Promise> { userLimit5hUsd: user.limit5hUsd ?? null, userLimitWeeklyUsd: user.limitWeeklyUsd ?? null, userLimitMonthlyUsd: user.limitMonthlyUsd ?? null, - userLimitTotalUsd: user.limitTotalUsd ?? null, + userLimitTotalUsd, userLimitConcurrentSessions: user.limitConcurrentSessions ?? null, userRpmLimit: user.rpm ?? null, userCurrent5hUsd: resolvedUserCurrent5hUsd, @@ -513,15 +694,52 @@ export async function getMyQuota(): Promise> { keyName: key.name, keyIsEnabled: key.isEnabled ?? true, + providerGroup: key.providerGroup ?? user.providerGroup ?? null, + + limit5hUsd: quotaWindows.fiveHour.limitUsd, + used5hUsd: quotaWindows.fiveHour.usedUsd, + remaining5hUsd: quotaWindows.fiveHour.remainingUsd, + + limitDailyUsd: quotaWindows.daily.limitUsd, + usedDailyUsd: quotaWindows.daily.usedUsd, + remainingDailyUsd: quotaWindows.daily.remainingUsd, + + limitWeeklyUsd: quotaWindows.weekly.limitUsd, + usedWeeklyUsd: quotaWindows.weekly.usedUsd, + remainingWeeklyUsd: quotaWindows.weekly.remainingUsd, + + limitMonthlyUsd: quotaWindows.monthly.limitUsd, + usedMonthlyUsd: quotaWindows.monthly.usedUsd, + remainingMonthlyUsd: quotaWindows.monthly.remainingUsd, + + limitTotalUsd: quotaWindows.total.limitUsd, + usedTotalUsd: quotaWindows.total.usedUsd, + remainingTotalUsd: quotaWindows.total.remainingUsd, + + quotaWindows, + todayUsedUsd: quotaWindows.daily.usedUsd, + todayRemainingUsd: quotaWindows.daily.remainingUsd, + todayUsedPercent: quotaWindows.daily.usedPercent, + todayRemainingPercent: quotaWindows.daily.remainingPercent, + remainingPercent: resolveOverallRemainingPercent(Object.values(quotaWindows)), + + rpmLimit: user.rpm ?? null, + concurrentSessions, + concurrentSessionsLimit, + userAllowedModels: user.allowedModels ?? [], userAllowedClients: user.allowedClients ?? [], expiresAt: key.expiresAt ?? null, dailyResetMode: key.dailyResetMode ?? "fixed", dailyResetTime: key.dailyResetTime ?? "00:00", + resetMode: key.dailyResetMode ?? "fixed", + resetTime: key.dailyResetTime ?? "00:00", + remaining: resolveOverallRemaining(Object.values(quotaWindows).map((window) => window.remainingUsd)), + unit: "USD", }; - return { ok: true, data: quota }; + return { ok: true, data: redactReadonlyQuota(quota, key) }; } catch (error) { logger.error("[my-usage] getMyQuota failed", error); return { ok: false, error: "Failed to get quota information" }; @@ -695,7 +913,10 @@ export async function getMyUsageLogs( return { ok: true, data: { - logs: mapMyUsageLogEntries(result, settings.billingModelSource), + logs: redactReadonlyLogs( + mapMyUsageLogEntries(result, settings.billingModelSource), + session.key + ), total: result.total, page, pageSize, @@ -740,7 +961,10 @@ export async function getMyUsageLogsBatch( return { ok: true, data: { - logs: mapMyUsageLogEntries(result, settings.billingModelSource), + logs: redactReadonlyLogs( + mapMyUsageLogEntries(result, settings.billingModelSource), + session.key + ), nextCursor: result.nextCursor, hasMore: result.hasMore, currencyCode: settings.currencyDisplay, diff --git a/src/app/api/actions/[...route]/route.ts b/src/app/api/actions/[...route]/route.ts index 3cc906a7c..652d5b8d0 100644 --- a/src/app/api/actions/[...route]/route.ts +++ b/src/app/api/actions/[...route]/route.ts @@ -1099,6 +1099,17 @@ const { route: getMyUsageMetadataRoute, handler: getMyUsageMetadataHandler } = c ); app.openapi(getMyUsageMetadataRoute, getMyUsageMetadataHandler); +const myUsageQuotaWindowSchema = z.object({ + period: z.enum(["5h", "daily", "weekly", "monthly", "total"]), + limitUsd: z.number().nullable().describe("该周期有效限额;null 表示不限额"), + usedUsd: z.number().describe("该周期已用金额"), + remainingUsd: z.number().nullable().describe("该周期剩余金额;null 表示不限额"), + usedPercent: z.number().nullable().describe("该周期已用百分比;null 表示不限额或限额为 0"), + remainingPercent: z.number().nullable().describe("该周期剩余百分比;null 表示不限额或限额为 0"), + isUnlimited: z.boolean().describe("该周期是否不限额"), + isExhausted: z.boolean().describe("该周期是否已无剩余额度"), +}); + const { route: getMyQuotaRoute, handler: getMyQuotaHandler } = createActionRoute( "my-usage", "getMyQuota", @@ -1124,6 +1135,7 @@ const { route: getMyQuotaRoute, handler: getMyQuotaHandler } = createActionRoute userLimitMonthlyUsd: z.number().nullable(), userLimitTotalUsd: z.number().nullable(), userLimitConcurrentSessions: z.number().nullable(), + userRpmLimit: z.number().nullable(), userCurrent5hUsd: z.number(), userCurrentDailyUsd: z.number(), userCurrentWeeklyUsd: z.number(), @@ -1141,9 +1153,59 @@ const { route: getMyQuotaRoute, handler: getMyQuotaHandler } = createActionRoute keyName: z.string(), keyIsEnabled: z.boolean(), + providerGroup: z.string().nullable(), + + limit5hUsd: z.number().nullable(), + used5hUsd: z.number(), + remaining5hUsd: z.number().nullable(), + + limitDailyUsd: z.number().nullable(), + usedDailyUsd: z.number(), + remainingDailyUsd: z.number().nullable(), + + limitWeeklyUsd: z.number().nullable(), + usedWeeklyUsd: z.number(), + remainingWeeklyUsd: z.number().nullable(), + + limitMonthlyUsd: z.number().nullable(), + usedMonthlyUsd: z.number(), + remainingMonthlyUsd: z.number().nullable(), + + limitTotalUsd: z.number().nullable(), + usedTotalUsd: z.number(), + remainingTotalUsd: z.number().nullable(), + + quotaWindows: z.object({ + fiveHour: myUsageQuotaWindowSchema, + daily: myUsageQuotaWindowSchema, + weekly: myUsageQuotaWindowSchema, + monthly: myUsageQuotaWindowSchema, + total: myUsageQuotaWindowSchema, + }), + todayUsedUsd: z.number().describe("按当前 Key 日限额窗口计算的已用金额"), + todayRemainingUsd: z.number().nullable().describe("按当前 Key 日限额窗口计算的剩余金额"), + todayUsedPercent: z.number().nullable().describe("按当前 Key 日限额窗口计算的已用百分比"), + todayRemainingPercent: z + .number() + .nullable() + .describe("按当前 Key 日限额窗口计算的剩余百分比"), + remainingPercent: z.number().nullable().describe("所有已配置金额限额中最少的剩余百分比"), + + rpmLimit: z.number().nullable(), + concurrentSessions: z.number(), + concurrentSessionsLimit: z.number().nullable(), + + userAllowedModels: z.array(z.string()), + userAllowedClients: z.array(z.string()), + readonlyRedactedFields: z.array(z.string()).optional(), + expiresAt: z.string().nullable(), dailyResetMode: z.enum(["fixed", "rolling"]), dailyResetTime: z.string(), + resetMode: z.enum(["fixed", "rolling"]), + resetTime: z.string(), + remaining: z.number().nullable(), + unit: z.literal("USD"), }), description: "获取当前会话的限额与当前使用量(仅返回自己的数据)", summary: "获取我的限额与用量", diff --git a/src/app/v1/_lib/proxy/auth-guard.ts b/src/app/v1/_lib/proxy/auth-guard.ts index e8886352b..b00a861d7 100644 --- a/src/app/v1/_lib/proxy/auth-guard.ts +++ b/src/app/v1/_lib/proxy/auth-guard.ts @@ -1,3 +1,4 @@ +import { isReadonlyKey } from "@/lib/auth/readonly-access"; import { getClientIpWithFreshSettings } from "@/lib/ip"; import { logger } from "@/lib/logger"; import { LoginAbusePolicy } from "@/lib/security/login-abuse-policy"; @@ -131,6 +132,7 @@ export class ProxyAuthenticator { user: null, key: null, apiKey: null, + readonlyAccess: false, success: false, errorResponse: ProxyResponses.buildError( 401, @@ -151,6 +153,7 @@ export class ProxyAuthenticator { user: null, key: null, apiKey: null, + readonlyAccess: false, success: false, errorResponse: ProxyResponses.buildError( 401, @@ -173,6 +176,7 @@ export class ProxyAuthenticator { user: null, key: null, apiKey, + readonlyAccess: false, success: false, errorResponse: ProxyResponses.buildError( 401, @@ -195,6 +199,7 @@ export class ProxyAuthenticator { user: null, key: null, apiKey, + readonlyAccess: false, success: false, errorResponse: ProxyResponses.buildError( 401, @@ -222,6 +227,7 @@ export class ProxyAuthenticator { user: null, key: null, apiKey, + readonlyAccess: false, success: false, errorResponse: ProxyResponses.buildError( 401, @@ -237,7 +243,13 @@ export class ProxyAuthenticator { keyName: authResult.key.name, }); - return { user: authResult.user, key: authResult.key, apiKey, success: true }; + return { + user: authResult.user, + key: authResult.key, + apiKey, + readonlyAccess: isReadonlyKey(authResult.key), + success: true, + }; } private static extractKeyFromAuthorization(authHeader?: string): string | null { diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index 692e695d2..09c9937d8 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -34,6 +34,7 @@ export interface AuthState { user: User | null; key: Key | null; apiKey: string | null; + readonlyAccess: boolean; success: boolean; errorResponse?: Response; // 认证失败时的详细错误响应 } diff --git a/src/lib/api/action-adapter-openapi.ts b/src/lib/api/action-adapter-openapi.ts index 82a93c44b..156617e2d 100644 --- a/src/lib/api/action-adapter-openapi.ts +++ b/src/lib/api/action-adapter-openapi.ts @@ -314,7 +314,10 @@ export function createActionRoute( return c.json({ ok: false, error: "未认证" }, 401); } - const session = await validateAuthToken(authToken, { allowReadOnlyAccess }); + const session = await validateAuthToken(authToken, { + allowReadOnlyAccess, + allowLegacyReadOnlyBearer: module === "my-usage", + }); if (!session) { logger.warn(`[ActionAPI] ${fullPath} 认证失败: 无效的 ${AUTH_COOKIE_NAME}`); return c.json({ ok: false, error: "认证无效或已过期" }, 401); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 0fe1ae039..90e29608a 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,5 +1,6 @@ import { cookies, headers } from "next/headers"; import type { NextResponse } from "next/server"; +import { canUseReadonlyAccess } from "@/lib/auth/readonly-access"; import { config } from "@/lib/config/config"; import { getEnvConfig } from "@/lib/config/env.schema"; import { logger } from "@/lib/logger"; @@ -227,7 +228,7 @@ export async function validateKey( } // 检查 Web UI 登录权限 - if (!allowReadOnlyAccess && !key.canLoginWebUi) { + if (!canUseReadonlyAccess(key, { allowReadOnlyAccess })) { return null; } @@ -264,9 +265,10 @@ export async function clearAuthCookie() { export async function validateAuthToken( token: string, - options?: { allowReadOnlyAccess?: boolean } + options?: { allowReadOnlyAccess?: boolean; allowLegacyReadOnlyBearer?: boolean } ): Promise { const mode = getSessionTokenMode(); + const tokenKind = detectSessionTokenKind(token); if (mode !== "legacy") { try { @@ -293,6 +295,10 @@ export async function validateAuthToken( return validateKey(token, options); } + if (options?.allowReadOnlyAccess && options.allowLegacyReadOnlyBearer && tokenKind === "legacy") { + return validateKey(token, options); + } + // Opaque mode: allow raw ADMIN_TOKEN for backward-compatible programmatic API access. // Safe because admin token is a server-side env secret, not a user-issued DB key. const adminToken = config.auth.adminToken; @@ -315,7 +321,11 @@ export async function getSession(options?: { // 关键:scoped 会话必须遵循其"创建时语义",仅允许内部显式降权(不允许提权) const effectiveAllowReadOnlyAccess = scoped.allowReadOnlyAccess && (options?.allowReadOnlyAccess ?? true); - if (!effectiveAllowReadOnlyAccess && !scoped.session.key.canLoginWebUi) { + if ( + !canUseReadonlyAccess(scoped.session.key, { + allowReadOnlyAccess: effectiveAllowReadOnlyAccess, + }) + ) { return null; } return scoped.session; diff --git a/src/lib/auth/readonly-access.ts b/src/lib/auth/readonly-access.ts new file mode 100644 index 000000000..559d686a0 --- /dev/null +++ b/src/lib/auth/readonly-access.ts @@ -0,0 +1,12 @@ +import type { Key } from "@/types/key"; + +export function isReadonlyKey(key: Pick): boolean { + return key.canLoginWebUi === false; +} + +export function canUseReadonlyAccess( + key: Pick, + options?: { allowReadOnlyAccess?: boolean } +): boolean { + return options?.allowReadOnlyAccess === true || !isReadonlyKey(key); +} diff --git a/src/lib/my-usage/readonly-redaction.ts b/src/lib/my-usage/readonly-redaction.ts new file mode 100644 index 000000000..fb65001f5 --- /dev/null +++ b/src/lib/my-usage/readonly-redaction.ts @@ -0,0 +1,33 @@ +import type { MyUsageLogEntry, MyUsageQuota } from "@/actions/my-usage"; +import { isReadonlyKey } from "@/lib/auth/readonly-access"; +import type { Key } from "@/types/key"; + +export function redactReadonlyQuota( + quota: T, + key: Pick +): T { + if (!isReadonlyKey(key)) { + return quota; + } + + return { + ...quota, + userAllowedModels: [], + userAllowedClients: [], + readonlyRedactedFields: ["userAllowedModels", "userAllowedClients"], + }; +} + +export function redactReadonlyLogs( + logs: T, + key: Pick +): T { + if (!isReadonlyKey(key)) { + return logs; + } + + return logs.map((log) => ({ + ...log, + endpoint: null, + })) as T; +} diff --git a/src/proxy.ts b/src/proxy.ts index 65b1b4628..a06c10ccf 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -13,6 +13,8 @@ const PUBLIC_PATH_PATTERNS = [ "/login", "/usage-doc", "/status", + "/system-status", + "/examples", "/api/auth/login", "/api/auth/logout", ]; @@ -129,7 +131,8 @@ export const config = { * - _next/static (static files) * - _next/image (image optimization files) * - favicon.ico (favicon file) + * - examples (public extractor examples) */ - "/((?!api|_next/static|_next/image|favicon.ico).*)", + "/((?!api|_next/static|_next/image|favicon.ico|examples).*)", ], }; diff --git a/tests/api/my-usage-readonly.test.ts b/tests/api/my-usage-readonly.test.ts index ab5a6bb7e..ba699b7f0 100644 --- a/tests/api/my-usage-readonly.test.ts +++ b/tests/api/my-usage-readonly.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeEach, describe, expect, test, vi } from "vitest"; -import { inArray } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { keys, messageRequest, usageLedger, users } from "@/drizzle/schema"; @@ -349,7 +349,30 @@ describe.skipIf(!process.env.DSN)("my-usage API:只读 Key 自助查询", () = }); createdKeyIds.push(readonlyKey.id); + await db + .update(users) + .set({ + rpmLimit: 60, + dailyLimitUsd: "15", + limit5hUsd: 12, + limitWeeklyUsd: 25, + limitMonthlyUsd: 35, + providerGroup: "default", + }) + .where(eq(users.id, user.id)); + + await db + .update(keys) + .set({ + limit5hUsd: 10, + limitDailyUsd: 20, + limitWeeklyUsd: 30, + limitMonthlyUsd: 40, + }) + .where(eq(keys.id, readonlyKey.id)); + const now = new Date(); + const usedAt = new Date(now.getTime() - 60 * 1000); const msgId = await createMessage({ userId: user.id, key: readonlyKey.key, @@ -358,7 +381,7 @@ describe.skipIf(!process.env.DSN)("my-usage API:只读 Key 自助查询", () = costUsd: "0.0100", inputTokens: 10, outputTokens: 20, - createdAt: new Date(now.getTime() - 60 * 1000), + createdAt: usedAt, }); createdMessageIds.push(msgId); @@ -374,6 +397,86 @@ describe.skipIf(!process.env.DSN)("my-usage API:只读 Key 自助查询", () = expect(stats.json).toMatchObject({ ok: true }); expect((stats.json as any).data.calls).toBe(1); + const quota = await callActionsRoute({ + method: "POST", + pathname: "/api/actions/my-usage/getMyQuota", + headers: { Authorization: currentAuthorization }, + body: {}, + }); + expect(quota.response.status).toBe(200); + expect(quota.json).toMatchObject({ ok: true }); + + const quotaData = (quota.json as { ok: boolean; data: Record }).data; + expect(quotaData.keyName).toBe(readonlyKey.name); + expect(quotaData.userName).toBe(user.name); + expect(quotaData.providerGroup).toBe("default"); + expect(quotaData.keyIsEnabled).toBe(true); + expect(quotaData.userIsEnabled).toBe(true); + expect(quotaData.rpmLimit).toBe(60); + expect(quotaData.unit).toBe("USD"); + expect(quotaData.remaining).toBeTypeOf("number"); + expect(quotaData.remaining5hUsd).toBeTypeOf("number"); + expect(quotaData.remainingDailyUsd).toBeTypeOf("number"); + expect(quotaData.remainingWeeklyUsd).toBeTypeOf("number"); + expect(quotaData.remainingMonthlyUsd).toBeTypeOf("number"); + expect(quotaData.used5hUsd).toBeTypeOf("number"); + expect(quotaData.usedDailyUsd).toBeTypeOf("number"); + expect(quotaData.usedWeeklyUsd).toBeTypeOf("number"); + expect(quotaData.usedMonthlyUsd).toBeTypeOf("number"); + expect(quotaData.limit5hUsd).toBe(10); + expect(quotaData.limitDailyUsd).toBe(15); + expect(quotaData.limitWeeklyUsd).toBe(25); + expect(quotaData.limitMonthlyUsd).toBe(35); + expect(quotaData.limitTotalUsd).toBe(35); + expect(quotaData.todayUsedUsd).toBeCloseTo(0.01, 6); + expect(quotaData.todayRemainingUsd).toBeCloseTo(14.99, 6); + expect(quotaData.todayUsedPercent).toBeCloseTo(0.07, 6); + expect(quotaData.todayRemainingPercent).toBeCloseTo(99.93, 6); + expect(quotaData.remainingPercent).toBeCloseTo(99.9, 6); + expect(quotaData.quotaWindows).toMatchObject({ + fiveHour: { + period: "5h", + limitUsd: 10, + usedUsd: 0.01, + remainingUsd: 9.99, + usedPercent: 0.1, + remainingPercent: 99.9, + isUnlimited: false, + isExhausted: false, + }, + daily: { + period: "daily", + limitUsd: 15, + usedUsd: 0.01, + remainingUsd: 14.99, + usedPercent: 0.07, + remainingPercent: 99.93, + isUnlimited: false, + isExhausted: false, + }, + weekly: { + period: "weekly", + limitUsd: 25, + usedUsd: 0.01, + remainingUsd: 24.99, + }, + monthly: { + period: "monthly", + limitUsd: 35, + usedUsd: 0.01, + remainingUsd: 34.99, + }, + total: { + period: "total", + limitUsd: null, + usedUsd: 0.01, + remainingUsd: null, + }, + }); + expect(quotaData.userAllowedModels).toEqual([]); + expect(quotaData.userAllowedClients).toEqual([]); + expect(quotaData.readonlyRedactedFields).toEqual(["userAllowedModels", "userAllowedClients"]); + // Issue #687 fix: getUsers 现在也支持 allowReadOnlyAccess const usersApi = await callActionsRoute({ method: "POST", @@ -460,6 +563,86 @@ describe.skipIf(!process.env.DSN)("my-usage API:只读 Key 自助查询", () = }); }); + test("总额度缺失时应回退 monthly,且只读日志隐藏 endpoint", async () => { + const unique = `my-usage-total-fallback-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const user = await createTestUser(`Test ${unique}`); + createdUserIds.push(user.id); + + const readonlyKey = await createTestKey({ + userId: user.id, + key: `test-readonly-key-${unique}`, + name: `readonly-${unique}`, + canLoginWebUi: false, + }); + createdKeyIds.push(readonlyKey.id); + + await db + .update(users) + .set({ + limitMonthlyUsd: 9, + limitTotalUsd: null, + allowedModels: ["gpt-4.1", "claude-3-7-sonnet"], + allowedClients: ["claude-code", "codex"], + }) + .where(eq(users.id, user.id)); + + await db + .update(keys) + .set({ + limitMonthlyUsd: 7, + limitTotalUsd: null, + }) + .where(eq(keys.id, readonlyKey.id)); + + const now = new Date(); + const msgId = await createMessage({ + userId: user.id, + key: readonlyKey.key, + model: "gpt-4.1-mini", + endpoint: "/v1/chat/completions", + costUsd: "1.5000", + inputTokens: 12, + outputTokens: 34, + createdAt: new Date(now.getTime() - 30 * 1000), + }); + createdMessageIds.push(msgId); + + currentAuthorization = `Bearer ${readonlyKey.key}`; + + const quota = await callActionsRoute({ + method: "POST", + pathname: "/api/actions/my-usage/getMyQuota", + headers: { Authorization: currentAuthorization }, + body: {}, + }); + expect(quota.response.status).toBe(200); + const quotaData = (quota.json as { ok: boolean; data: Record }).data; + expect(quotaData.keyLimitTotalUsd).toBe(7); + expect(quotaData.userLimitTotalUsd).toBe(9); + expect(quotaData.limitTotalUsd).toBe(7); + expect(quotaData.remainingTotalUsd).toBeTypeOf("number"); + expect(quotaData.userAllowedModels).toEqual([]); + expect(quotaData.userAllowedClients).toEqual([]); + expect(quotaData.readonlyRedactedFields).toEqual(["userAllowedModels", "userAllowedClients"]); + + const logs = await callActionsRoute({ + method: "POST", + pathname: "/api/actions/my-usage/getMyUsageLogs", + headers: { Authorization: currentAuthorization }, + body: {}, + }); + expect(logs.response.status).toBe(200); + const logData = ( + logs.json as { + ok: boolean; + data: { logs: Array<{ endpoint: string | null; model: string | null }> }; + } + ).data.logs; + expect(logData.length).toBeGreaterThan(0); + expect(logData[0].model).toBe("gpt-4.1-mini"); + expect(logData[0].endpoint).toBeNull(); + }); + test("今日统计:应与 message_request 数据一致,并排除 warmup 与其他 Key 数据", async () => { const unique = `my-usage-stats-${Date.now()}-${Math.random().toString(16).slice(2)}`; diff --git a/tests/configs/my-usage.config.ts b/tests/configs/my-usage.config.ts index b19d9e5d7..adacbd8e8 100644 --- a/tests/configs/my-usage.config.ts +++ b/tests/configs/my-usage.config.ts @@ -8,6 +8,12 @@ export default createCoverageConfig({ "tests/api/api-actions-integrity.test.ts", "tests/integration/auth.test.ts", "tests/api/action-adapter-openapi.unit.test.ts", + "tests/unit/auth/admin-token-opaque-fallback.test.ts", + "tests/unit/auth/opaque-admin-session.test.ts", + "tests/unit/auth/auth-scoped-session-branches.test.ts", + "tests/security/session-contract.test.ts", + "tests/security/auth-dual-read.test.ts", + "tests/unit/proxy/proxy-auth-cookie-passthrough.test.ts", ], sourceFiles: [ "src/actions/my-usage.ts", diff --git a/tests/unit/actions/my-usage-concurrent-inherit.test.ts b/tests/unit/actions/my-usage-concurrent-inherit.test.ts index adb4b614d..0f6b2fa5f 100644 --- a/tests/unit/actions/my-usage-concurrent-inherit.test.ts +++ b/tests/unit/actions/my-usage-concurrent-inherit.test.ts @@ -67,6 +67,10 @@ vi.mock("@/lib/logger", () => ({ function createSession(params: { keyLimitConcurrentSessions: number | null; userLimitConcurrentSessions: number | null; + keyLimitMonthlyUsd?: number | null; + keyLimitTotalUsd?: number | null; + userLimitMonthlyUsd?: number | null; + userLimitTotalUsd?: number | null; }) { return { key: { @@ -78,8 +82,8 @@ function createSession(params: { limit5hUsd: null, limitDailyUsd: null, limitWeeklyUsd: null, - limitMonthlyUsd: null, - limitTotalUsd: null, + limitMonthlyUsd: params.keyLimitMonthlyUsd ?? null, + limitTotalUsd: params.keyLimitTotalUsd ?? null, limitConcurrentSessions: params.keyLimitConcurrentSessions, providerGroup: null, isEnabled: true, @@ -93,8 +97,8 @@ function createSession(params: { limit5hUsd: null, dailyQuota: null, limitWeeklyUsd: null, - limitMonthlyUsd: null, - limitTotalUsd: null, + limitMonthlyUsd: params.userLimitMonthlyUsd ?? null, + limitTotalUsd: params.userLimitTotalUsd ?? null, limitConcurrentSessions: params.userLimitConcurrentSessions, rpm: null, providerGroup: null, @@ -152,4 +156,69 @@ describe("getMyQuota - concurrent limit inheritance", () => { expect(result.data.keyLimitConcurrentSessions).toBe(0); } }); + + it("总额度为空时 lifetime window 不应回退到月额度", async () => { + getSessionMock.mockResolvedValue( + createSession({ + keyLimitConcurrentSessions: 0, + userLimitConcurrentSessions: 0, + keyLimitMonthlyUsd: 30, + keyLimitTotalUsd: null, + userLimitMonthlyUsd: 100, + userLimitTotalUsd: null, + }) + ); + statisticsMock.sumKeyQuotaCostsById.mockResolvedValue({ + cost5h: 0, + costDaily: 0, + costWeekly: 0, + costMonthly: 5, + costTotal: 12, + }); + statisticsMock.sumUserQuotaCosts.mockResolvedValue({ + cost5h: 0, + costDaily: 0, + costWeekly: 0, + costMonthly: 10, + costTotal: 40, + }); + + const { getMyQuota } = await import("@/actions/my-usage"); + const result = await getMyQuota(); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.keyLimitTotalUsd).toBeNull(); + expect(result.data.userLimitTotalUsd).toBeNull(); + expect(result.data.limitTotalUsd).toBeNull(); + expect(result.data.usedTotalUsd).toBe(40); + expect(result.data.remainingTotalUsd).toBeNull(); + expect(result.data.quotaWindows.monthly.limitUsd).toBe(30); + expect(result.data.quotaWindows.monthly.remainingUsd).toBe(25); + expect(result.data.quotaWindows.total.isUnlimited).toBe(true); + } + }); + + it("存在总额度时 usage API 应优先返回总额度", async () => { + getSessionMock.mockResolvedValue( + createSession({ + keyLimitConcurrentSessions: 0, + userLimitConcurrentSessions: 0, + keyLimitMonthlyUsd: 30, + keyLimitTotalUsd: 80, + userLimitMonthlyUsd: 100, + userLimitTotalUsd: 200, + }) + ); + + const { getMyQuota } = await import("@/actions/my-usage"); + const result = await getMyQuota(); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.keyLimitTotalUsd).toBe(80); + expect(result.data.userLimitTotalUsd).toBe(200); + expect(result.data.limitTotalUsd).toBe(80); + } + }); }); diff --git a/tests/unit/auth/auth-scoped-session-branches.test.ts b/tests/unit/auth/auth-scoped-session-branches.test.ts new file mode 100644 index 000000000..0929cc803 --- /dev/null +++ b/tests/unit/auth/auth-scoped-session-branches.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockCookies = vi.hoisted(() => vi.fn()); +const mockHeaders = vi.hoisted(() => vi.fn()); +const mockGetEnvConfig = vi.hoisted(() => vi.fn()); +const mockValidateApiKeyAndGetUser = vi.hoisted(() => vi.fn()); +const mockFindKeyList = vi.hoisted(() => vi.fn()); +const mockReadSession = vi.hoisted(() => vi.fn()); +const mockCookieStore = vi.hoisted(() => ({ + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), +})); +const mockHeadersStore = vi.hoisted(() => ({ + get: vi.fn(), +})); +const mockConfig = vi.hoisted(() => ({ + auth: { adminToken: "test-admin-token-secret" }, +})); + +vi.mock("next/headers", () => ({ + cookies: mockCookies, + headers: mockHeaders, +})); + +vi.mock("@/lib/config/env.schema", () => ({ + getEnvConfig: mockGetEnvConfig, +})); + +vi.mock("@/repository/key", () => ({ + validateApiKeyAndGetUser: mockValidateApiKeyAndGetUser, + findKeyList: mockFindKeyList, +})); + +vi.mock("@/lib/auth-session-store/redis-session-store", () => ({ + RedisSessionStore: class { + read = mockReadSession; + create = vi.fn(); + revoke = vi.fn(); + rotate = vi.fn(); + }, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() }, +})); + +vi.mock("@/lib/config/config", () => ({ + config: mockConfig, +})); + +describe("auth scoped session branches", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + + mockCookies.mockResolvedValue(mockCookieStore); + mockHeaders.mockResolvedValue(mockHeadersStore); + mockCookieStore.get.mockReturnValue(undefined); + mockHeadersStore.get.mockReturnValue(null); + mockGetEnvConfig.mockReturnValue({ + SESSION_TOKEN_MODE: "opaque", + ENABLE_SECURE_COOKIES: false, + }); + mockReadSession.mockResolvedValue(null); + mockFindKeyList.mockResolvedValue([]); + mockValidateApiKeyAndGetUser.mockResolvedValue(null); + }); + + it("rejects scoped readonly session when caller tries to access it without readonly permission", async () => { + await import("@/lib/auth-session-storage.node"); + const { getSession, runWithAuthSession } = await import("@/lib/auth"); + + const session = { + user: { role: "user" }, + key: { canLoginWebUi: false }, + } as any; + + const result = await runWithAuthSession( + session, + () => getSession({ allowReadOnlyAccess: false }), + { allowReadOnlyAccess: true } + ); + + expect(result).toBeNull(); + }); + + it("allows legacy bearer token fallback in opaque mode for readonly self-service", async () => { + mockHeadersStore.get.mockReturnValue("Bearer sk-readonly-fallback"); + mockValidateApiKeyAndGetUser.mockResolvedValue({ + user: { + id: 1, + name: "user", + role: "user", + isEnabled: true, + expiresAt: null, + }, + key: { + id: 1, + userId: 1, + name: "readonly", + key: "sk-readonly-fallback", + isEnabled: true, + canLoginWebUi: false, + }, + }); + + const { validateAuthToken } = await import("@/lib/auth"); + const session = await validateAuthToken("sk-readonly-fallback", { + allowReadOnlyAccess: true, + allowLegacyReadOnlyBearer: true, + }); + + expect(session).not.toBeNull(); + expect(session?.key.key).toBe("sk-readonly-fallback"); + expect(mockValidateApiKeyAndGetUser).toHaveBeenCalledWith("sk-readonly-fallback"); + }); + + it("blocks legacy bearer fallback in opaque mode when caller is not self-service", async () => { + const { validateAuthToken } = await import("@/lib/auth"); + const session = await validateAuthToken("sk-readonly-fallback", { + allowReadOnlyAccess: true, + }); + + expect(session).toBeNull(); + expect(mockValidateApiKeyAndGetUser).not.toHaveBeenCalled(); + }); +}); From 50ec0145f56c1ef71b2d7fb204c5c5461d3fad4b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 26 Apr 2026 05:12:47 +0000 Subject: [PATCH 2/2] chore: format code (pr-dev-quota-windows-20260426-3e41208) --- src/actions/my-usage.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/actions/my-usage.ts b/src/actions/my-usage.ts index e15bab10b..ffab94cd9 100644 --- a/src/actions/my-usage.ts +++ b/src/actions/my-usage.ts @@ -735,7 +735,9 @@ export async function getMyQuota(): Promise> { dailyResetTime: key.dailyResetTime ?? "00:00", resetMode: key.dailyResetMode ?? "fixed", resetTime: key.dailyResetTime ?? "00:00", - remaining: resolveOverallRemaining(Object.values(quotaWindows).map((window) => window.remainingUsd)), + remaining: resolveOverallRemaining( + Object.values(quotaWindows).map((window) => window.remainingUsd) + ), unit: "USD", };