feat: 实现基于费用的速率限制功能

- 新增 rateLimitCost 字段,支持按费用进行速率限制
- 新增 weeklyOpusCostLimit 字段,支持 Opus 模型周费用限制
- 优化速率限制逻辑,支持费用、请求数、token多维度控制
- 更新前端界面,添加费用限制配置选项
- 增强账户管理功能,支持费用统计和限制
- 改进 Redis 数据模型,支持费用计数器
- 优化价格计算服务,支持更精确的成本核算

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shaw
2025-08-31 17:27:37 +08:00
parent a54622e3d7
commit e84c6a5555
21 changed files with 1662 additions and 161 deletions

View File

@@ -252,17 +252,17 @@
<div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
>Token 限制</label
>费用限制 (美元)</label
>
<input
v-model="form.tokenLimit"
v-model="form.rateLimitCost"
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="无限制"
step="0.01"
type="number"
/>
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
窗口内最大Token
</p>
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">窗口内最大费用</p>
</div>
</div>
@@ -275,12 +275,9 @@
<div>
<strong>示例1:</strong> 时间窗口=60请求次数=1000 每60分钟最多1000次请求
</div>
<div><strong>示例2:</strong> 时间窗口=1费用=0.1 每分钟最多$0.1费用</div>
<div>
<strong>示例2:</strong> 时间窗口=1Token=10000 每分钟最多10,000个Token
</div>
<div>
<strong>示例3:</strong> 窗口=30请求=50Token=100000
每30分钟50次请求且不超10万Token
<strong>示例3:</strong> 窗口=30请求=50费用=5 每30分钟50次请求且不超$5费用
</div>
</div>
</div>
@@ -336,6 +333,55 @@
</div>
</div>
<div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>Opus 模型周费用限制 (美元)</label
>
<div class="space-y-2">
<div class="flex gap-2">
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '100'"
>
$100
</button>
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '500'"
>
$500
</button>
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '1000'"
>
$1000
</button>
<button
class="rounded bg-gray-100 px-2 py-1 text-xs font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = ''"
>
自定义
</button>
</div>
<input
v-model="form.weeklyOpusCostLimit"
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="0 表示无限制"
step="0.01"
type="number"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
设置 Opus 模型的周费用限制周一到周日仅限 Claude 官方账户0 或留空表示无限制
</p>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>并发限制 (可选)</label
@@ -739,11 +785,12 @@ const form = reactive({
batchCount: 10,
name: '',
description: '',
tokenLimit: '',
rateLimitWindow: '',
rateLimitRequests: '',
rateLimitCost: '', // 新增:费用限制
concurrencyLimit: '',
dailyCostLimit: '',
weeklyOpusCostLimit: '',
expireDuration: '',
customExpireDate: '',
expiresAt: null,
@@ -985,14 +1032,32 @@ const createApiKey = async () => {
}
}
// 检查是否设置了时间窗口但费用限制为0
if (form.rateLimitWindow && (!form.rateLimitCost || parseFloat(form.rateLimitCost) === 0)) {
let confirmed = false
if (window.showConfirm) {
confirmed = await window.showConfirm(
'费用限制提醒',
'您设置了时间窗口但费用限制为0这意味着不会有费用限制。\n\n是否继续',
'继续创建',
'返回修改'
)
} else {
// 降级方案
confirmed = confirm('您设置了时间窗口但费用限制为0这意味着不会有费用限制。\n是否继续')
}
if (!confirmed) {
return
}
}
loading.value = true
try {
// 准备提交的数据
const baseData = {
description: form.description || undefined,
tokenLimit:
form.tokenLimit !== '' && form.tokenLimit !== null ? parseInt(form.tokenLimit) : null,
tokenLimit: 0, // 设置为0清除历史token限制
rateLimitWindow:
form.rateLimitWindow !== '' && form.rateLimitWindow !== null
? parseInt(form.rateLimitWindow)
@@ -1001,6 +1066,10 @@ const createApiKey = async () => {
form.rateLimitRequests !== '' && form.rateLimitRequests !== null
? parseInt(form.rateLimitRequests)
: null,
rateLimitCost:
form.rateLimitCost !== '' && form.rateLimitCost !== null
? parseFloat(form.rateLimitCost)
: null,
concurrencyLimit:
form.concurrencyLimit !== '' && form.concurrencyLimit !== null
? parseInt(form.concurrencyLimit)
@@ -1009,6 +1078,10 @@ const createApiKey = async () => {
form.dailyCostLimit !== '' && form.dailyCostLimit !== null
? parseFloat(form.dailyCostLimit)
: 0,
weeklyOpusCostLimit:
form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null
? parseFloat(form.weeklyOpusCostLimit)
: 0,
expiresAt: form.expiresAt || undefined,
permissions: form.permissions,
tags: form.tags.length > 0 ? form.tags : undefined,

View File

@@ -166,17 +166,17 @@
<div>
<label class="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-300"
>Token 限制</label
>费用限制 (美元)</label
>
<input
v-model="form.tokenLimit"
v-model="form.rateLimitCost"
class="form-input w-full text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="无限制"
step="0.01"
type="number"
/>
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
窗口内最大Token
</p>
<p class="ml-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">窗口内最大费用</p>
</div>
</div>
@@ -189,12 +189,9 @@
<div>
<strong>示例1:</strong> 时间窗口=60请求次数=1000 每60分钟最多1000次请求
</div>
<div><strong>示例2:</strong> 时间窗口=1费用=0.1 每分钟最多$0.1费用</div>
<div>
<strong>示例2:</strong> 时间窗口=1Token=10000 每分钟最多10,000个Token
</div>
<div>
<strong>示例3:</strong> 窗口=30请求=50Token=100000
每30分钟50次请求且不超10万Token
<strong>示例3:</strong> 窗口=30请求=50费用=5 每30分钟50次请求且不超$5费用
</div>
</div>
</div>
@@ -250,6 +247,55 @@
</div>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>Opus 模型周费用限制 (美元)</label
>
<div class="space-y-3">
<div class="flex gap-2">
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '100'"
>
$100
</button>
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '500'"
>
$500
</button>
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = '1000'"
>
$1000
</button>
<button
class="rounded-lg bg-gray-100 px-3 py-1 text-sm font-medium hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
type="button"
@click="form.weeklyOpusCostLimit = ''"
>
自定义
</button>
</div>
<input
v-model="form.weeklyOpusCostLimit"
class="form-input w-full dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
min="0"
placeholder="0 表示无限制"
step="0.01"
type="number"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
设置 Opus 模型的周费用限制周一到周日仅限 Claude 官方账户0 或留空表示无限制
</p>
</div>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>并发限制</label
@@ -632,11 +678,13 @@ const unselectedTags = computed(() => {
// 表单数据
const form = reactive({
name: '',
tokenLimit: '',
tokenLimit: '', // 保留用于检测历史数据
rateLimitWindow: '',
rateLimitRequests: '',
rateLimitCost: '', // 新增:费用限制
concurrencyLimit: '',
dailyCostLimit: '',
weeklyOpusCostLimit: '',
permissions: 'all',
claudeAccountId: '',
geminiAccountId: '',
@@ -702,13 +750,31 @@ const removeTag = (index) => {
// 更新 API Key
const updateApiKey = async () => {
// 检查是否设置了时间窗口但费用限制为0
if (form.rateLimitWindow && (!form.rateLimitCost || parseFloat(form.rateLimitCost) === 0)) {
let confirmed = false
if (window.showConfirm) {
confirmed = await window.showConfirm(
'费用限制提醒',
'您设置了时间窗口但费用限制为0这意味着不会有费用限制。\n\n是否继续',
'继续保存',
'返回修改'
)
} else {
// 降级方案
confirmed = confirm('您设置了时间窗口但费用限制为0这意味着不会有费用限制。\n是否继续')
}
if (!confirmed) {
return
}
}
loading.value = true
try {
// 准备提交的数据
const data = {
tokenLimit:
form.tokenLimit !== '' && form.tokenLimit !== null ? parseInt(form.tokenLimit) : 0,
tokenLimit: 0, // 清除历史token限制
rateLimitWindow:
form.rateLimitWindow !== '' && form.rateLimitWindow !== null
? parseInt(form.rateLimitWindow)
@@ -717,6 +783,10 @@ const updateApiKey = async () => {
form.rateLimitRequests !== '' && form.rateLimitRequests !== null
? parseInt(form.rateLimitRequests)
: 0,
rateLimitCost:
form.rateLimitCost !== '' && form.rateLimitCost !== null
? parseFloat(form.rateLimitCost)
: 0,
concurrencyLimit:
form.concurrencyLimit !== '' && form.concurrencyLimit !== null
? parseInt(form.concurrencyLimit)
@@ -725,6 +795,10 @@ const updateApiKey = async () => {
form.dailyCostLimit !== '' && form.dailyCostLimit !== null
? parseFloat(form.dailyCostLimit)
: 0,
weeklyOpusCostLimit:
form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null
? parseFloat(form.weeklyOpusCostLimit)
: 0,
permissions: form.permissions,
tags: form.tags
}
@@ -893,11 +967,22 @@ onMounted(async () => {
}
form.name = props.apiKey.name
// 处理速率限制迁移如果有tokenLimit且没有rateLimitCost提示用户
form.tokenLimit = props.apiKey.tokenLimit || ''
form.rateLimitCost = props.apiKey.rateLimitCost || ''
// 如果有历史tokenLimit但没有rateLimitCost提示用户需要重新设置
if (props.apiKey.tokenLimit > 0 && !props.apiKey.rateLimitCost) {
// 可以根据需要添加提示,或者自动迁移(这里选择让用户手动设置)
console.log('检测到历史Token限制请考虑设置费用限制')
}
form.rateLimitWindow = props.apiKey.rateLimitWindow || ''
form.rateLimitRequests = props.apiKey.rateLimitRequests || ''
form.concurrencyLimit = props.apiKey.concurrencyLimit || ''
form.dailyCostLimit = props.apiKey.dailyCostLimit || ''
form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || ''
form.permissions = props.apiKey.permissions || 'all'
// 处理 Claude 账号(区分 OAuth 和 Console
if (props.apiKey.claudeConsoleAccountId) {

View File

@@ -196,6 +196,8 @@
时间窗口限制
</h5>
<WindowCountdown
:cost-limit="apiKey.rateLimitCost"
:current-cost="apiKey.currentWindowCost"
:current-requests="apiKey.currentWindowRequests"
:current-tokens="apiKey.currentWindowTokens"
label="窗口状态"

View File

@@ -33,6 +33,7 @@
</div>
</div>
<!-- Token限制向后兼容 -->
<div v-if="hasTokenLimit" class="space-y-0.5">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-400">Token</span>
@@ -48,6 +49,23 @@
/>
</div>
</div>
<!-- 费用限制新功能 -->
<div v-if="hasCostLimit" class="space-y-0.5">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-400">费用</span>
<span class="text-gray-600">
${{ (currentCost || 0).toFixed(2) }}/${{ costLimit.toFixed(2) }}
</span>
</div>
<div class="h-1 w-full rounded-full bg-gray-200">
<div
class="h-1 rounded-full transition-all duration-300"
:class="getCostProgressColor()"
:style="{ width: getCostProgress() + '%' }"
/>
</div>
</div>
</div>
<!-- 额外提示信息 -->
@@ -102,6 +120,14 @@ const props = defineProps({
type: Number,
default: 0
},
currentCost: {
type: Number,
default: 0
},
costLimit: {
type: Number,
default: 0
},
showProgress: {
type: Boolean,
default: true
@@ -132,6 +158,7 @@ const windowState = computed(() => {
const hasRequestLimit = computed(() => props.requestLimit > 0)
const hasTokenLimit = computed(() => props.tokenLimit > 0)
const hasCostLimit = computed(() => props.costLimit > 0)
// 方法
const formatTime = (seconds) => {
@@ -196,6 +223,19 @@ const getTokenProgressColor = () => {
return 'bg-purple-500'
}
const getCostProgress = () => {
if (!props.costLimit || props.costLimit === 0) return 0
const percentage = ((props.currentCost || 0) / props.costLimit) * 100
return Math.min(percentage, 100)
}
const getCostProgressColor = () => {
const progress = getCostProgress()
if (progress >= 100) return 'bg-red-500'
if (progress >= 80) return 'bg-yellow-500'
return 'bg-green-500'
}
// 更新倒计时
const updateCountdown = () => {
if (props.windowEndTime && remainingSeconds.value > 0) {