fix: 三端联调测试 2 P1 + 2 P2 + 4 P3 修复

P1-07: billing get_or_create_usage 同步 max_* 列到当前计划限额
P1-08: relay handler 增加直接配额检查 (relay_requests/input/output_tokens)
P2-09: relay failover 成功后记录 tokens 并标记 completed
P2-10: Tauri agentStore saas-relay 模式下从 SaaS API 获取真实用量
P2-14: super_admin 合成 subscription + check_quota 放行
P3-19: 新建 ApiKeys.tsx 页面替代 ModelServices 路由
P3-15: antd destroyOnClose → destroyOnHidden (3处)
P3-16: ProTable onSearch → onSubmit (2处)
This commit is contained in:
iven
2026-04-14 17:48:22 +08:00
parent 45fd9fee7b
commit dd854479eb
11 changed files with 259 additions and 12 deletions

View File

@@ -0,0 +1,169 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Button, message, Tag, Modal, Form, Input, InputNumber, Select, Space, Popconfirm, Typography } from 'antd'
import { PlusOutlined, CopyOutlined } from '@ant-design/icons'
import { ProTable } from '@ant-design/pro-components'
import type { ProColumns } from '@ant-design/pro-components'
import { apiKeyService } from '@/services/api-keys'
import type { TokenInfo } from '@/types'
const { Text, Paragraph } = Typography
const PERMISSION_OPTIONS = [
{ label: 'Relay Chat', value: 'relay:use' },
{ label: 'Knowledge Read', value: 'knowledge:read' },
{ label: 'Knowledge Write', value: 'knowledge:write' },
{ label: 'Agent Read', value: 'agent:read' },
{ label: 'Agent Write', value: 'agent:write' },
]
export default function ApiKeys() {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [createOpen, setCreateOpen] = useState(false)
const [newToken, setNewToken] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const { data, isLoading } = useQuery({
queryKey: ['api-keys', page, pageSize],
queryFn: ({ signal }) => apiKeyService.list({ page, page_size: pageSize }, signal),
})
const createMutation = useMutation({
mutationFn: (values: { name: string; expires_days?: number; permissions: string[] }) =>
apiKeyService.create(values),
onSuccess: (result: TokenInfo) => {
message.success('API 密钥创建成功')
if (result.token) {
setNewToken(result.token)
}
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
form.resetFields()
},
onError: (err: Error) => message.error(err.message || '创建失败'),
})
const revokeMutation = useMutation({
mutationFn: (id: string) => apiKeyService.revoke(id),
onSuccess: () => {
message.success('密钥已吊销')
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
},
onError: (err: Error) => message.error(err.message || '吊销失败'),
})
const handleCreate = async () => {
const values = await form.validateFields()
createMutation.mutate(values)
}
const columns: ProColumns<TokenInfo>[] = [
{ title: '名称', dataIndex: 'name', width: 180 },
{
title: '前缀',
dataIndex: 'token_prefix',
width: 120,
render: (val: string) => <Text code>{val}...</Text>,
},
{
title: '权限',
dataIndex: 'permissions',
width: 240,
render: (perms: string[]) =>
perms?.map((p) => <Tag key={p}>{p}</Tag>) || '-',
},
{
title: '最后使用',
dataIndex: 'last_used_at',
width: 180,
render: (val: string) => (val ? new Date(val).toLocaleString() : <Text type="secondary">使</Text>),
},
{
title: '过期时间',
dataIndex: 'expires_at',
width: 180,
render: (val: string) =>
val ? new Date(val).toLocaleString() : <Text type="secondary"></Text>,
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 180,
render: (val: string) => new Date(val).toLocaleString(),
},
{
title: '操作',
width: 100,
render: (_: unknown, record: TokenInfo) => (
<Popconfirm
title="确定吊销此密钥?"
description="吊销后使用该密钥的所有请求将被拒绝"
onConfirm={() => revokeMutation.mutate(record.id)}
>
<Button danger size="small"></Button>
</Popconfirm>
),
},
]
return (
<div style={{ padding: 24 }}>
<ProTable<TokenInfo>
columns={columns}
dataSource={data?.items || []}
loading={isLoading}
rowKey="id"
search={false}
pagination={{
current: page,
pageSize,
total: data?.total || 0,
onChange: (p, ps) => { setPage(p); setPageSize(ps) },
}}
toolBarRender={() => [
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
</Button>,
]}
/>
<Modal
title="创建 API 密钥"
open={createOpen}
onOk={handleCreate}
onCancel={() => { setCreateOpen(false); setNewToken(null); form.resetFields() }}
confirmLoading={createMutation.isPending}
destroyOnHidden
>
{newToken ? (
<div style={{ marginBottom: 16 }}>
<Paragraph type="warning">
</Paragraph>
<Space>
<Text code style={{ fontSize: 13 }}>{newToken}</Text>
<Button
icon={<CopyOutlined />}
size="small"
onClick={() => { navigator.clipboard.writeText(newToken); message.success('已复制') }}
/>
</Space>
</div>
) : (
<Form form={form} layout="vertical">
<Form.Item name="name" label="密钥名称" rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="例如: 生产环境 API Key" />
</Form.Item>
<Form.Item name="expires_days" label="有效期 (天)">
<InputNumber min={1} max={3650} placeholder="留空表示永不过期" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="permissions" label="权限" rules={[{ required: true, message: '请选择至少一项权限' }]}>
<Select mode="multiple" options={PERMISSION_OPTIONS} placeholder="选择权限" />
</Form.Item>
</Form>
)}
</Modal>
</div>
)
}

View File

@@ -144,7 +144,7 @@ function IndustryListPanel() {
rowKey="id"
search={{
onReset: () => { setFilters({}); setPage(1) },
onSearch: (values) => { setFilters(values); setPage(1) },
onSubmit: (values) => { setFilters(values); setPage(1) },
}}
toolBarRender={() => [
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
@@ -225,7 +225,7 @@ function IndustryEditModal({ open, industryId, onClose }: {
onOk={() => form.submit()}
confirmLoading={updateMutation.isPending}
width={720}
destroyOnClose
destroyOnHidden
>
{isLoading ? (
<div className="flex justify-center py-8"><Spin /></div>
@@ -300,7 +300,7 @@ function IndustryCreateModal({ open, onClose }: {
onOk={() => form.submit()}
confirmLoading={createMutation.isPending}
width={640}
destroyOnClose
destroyOnHidden
>
<Form
form={form}

View File

@@ -333,7 +333,7 @@ function ItemsPanel() {
rowKey="id"
search={{
onReset: () => { setFilters({}); setPage(1) },
onSearch: (values) => { setFilters(values); setPage(1) },
onSubmit: (values) => { setFilters(values); setPage(1) },
}}
toolBarRender={() => [
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>

View File

@@ -327,7 +327,7 @@ export default function ScheduledTasks() {
onCancel={closeModal}
confirmLoading={createMutation.isPending || updateMutation.isPending}
width={520}
destroyOnClose
destroyOnHidden
>
<Form form={form} layout="vertical" className="mt-4">
<Form.Item

View File

@@ -26,7 +26,7 @@ export const router = createBrowserRouter([
{ path: 'providers', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
{ path: 'models', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
{ path: 'agent-templates', lazy: () => import('@/pages/AgentTemplates').then((m) => ({ Component: m.default })) },
{ path: 'api-keys', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
{ path: 'api-keys', lazy: () => import('@/pages/ApiKeys').then((m) => ({ Component: m.default })) },
{ path: 'usage', lazy: () => import('@/pages/Usage').then((m) => ({ Component: m.default })) },
{ path: 'billing', lazy: () => import('@/pages/Billing').then((m) => ({ Component: m.default })) },
{ path: 'relay', lazy: () => import('@/pages/Relay').then((m) => ({ Component: m.default })) },

View File

@@ -39,9 +39,23 @@ pub async fn get_subscription(
let sub = service::get_active_subscription(&state.db, &ctx.account_id).await?;
let usage = service::get_or_create_usage(&state.db, &ctx.account_id).await?;
// P2-14 修复: super_admin 无订阅时合成一个 "active" subscription
let sub_value = if sub.is_none() && ctx.role == "super_admin" {
Some(serde_json::json!({
"id": format!("sub-admin-{}", &ctx.account_id.chars().take(8).collect::<String>()),
"account_id": ctx.account_id,
"plan_id": plan.id,
"status": "active",
"current_period_start": usage.period_start,
"current_period_end": usage.period_end,
}))
} else {
sub.map(|s| serde_json::to_value(s).unwrap_or_default())
};
Ok(Json(serde_json::json!({
"plan": plan,
"subscription": sub,
"subscription": sub_value,
"usage": usage,
})))
}

View File

@@ -114,7 +114,26 @@ pub async fn get_or_create_usage(pool: &PgPool, account_id: &str) -> SaasResult<
.await?;
if let Some(usage) = existing {
return Ok(usage);
// P1-07 修复: 同步当前计划限额到 max_* 列(防止计划变更后数据不一致)
let plan = get_account_plan(pool, account_id).await?;
let limits: PlanLimits = serde_json::from_value(plan.limits.clone())
.unwrap_or_else(|_| PlanLimits::free());
sqlx::query(
"UPDATE billing_usage_quotas SET max_input_tokens=$2, max_output_tokens=$3, \
max_relay_requests=$4, max_hand_executions=$5, max_pipeline_runs=$6, updated_at=NOW() \
WHERE id=$1"
)
.bind(&usage.id)
.bind(limits.max_input_tokens_monthly)
.bind(limits.max_output_tokens_monthly)
.bind(limits.max_relay_requests_monthly)
.bind(limits.max_hand_executions_monthly)
.bind(limits.max_pipeline_runs_monthly)
.execute(pool).await?;
let updated = sqlx::query_as::<_, UsageQuota>(
"SELECT * FROM billing_usage_quotas WHERE id = $1"
).bind(&usage.id).fetch_one(pool).await?;
return Ok(updated);
}
// 获取当前计划限额
@@ -288,8 +307,13 @@ pub async fn increment_dimension_by(
pub async fn check_quota(
pool: &PgPool,
account_id: &str,
role: &str,
quota_type: &str,
) -> SaasResult<QuotaCheck> {
// P2-14 修复: super_admin 不受配额限制
if role == "super_admin" {
return Ok(QuotaCheck { allowed: true, reason: None, current: 0, limit: None, remaining: None });
}
let usage = get_or_create_usage(pool, account_id).await?;
// 从当前 Plan 读取真实限额,而非 usage 表的 stale 冗余列
let plan = get_account_plan(pool, account_id).await?;

View File

@@ -119,13 +119,13 @@ pub async fn quota_check_middleware(
}
// 从扩展中获取认证上下文
let account_id = match req.extensions().get::<AuthContext>() {
Some(ctx) => ctx.account_id.clone(),
let (account_id, role) = match req.extensions().get::<AuthContext>() {
Some(ctx) => (ctx.account_id.clone(), ctx.role.clone()),
None => return next.run(req).await,
};
// 检查 relay_requests 配额
match crate::billing::service::check_quota(&state.db, &account_id, "relay_requests").await {
match crate::billing::service::check_quota(&state.db, &account_id, &role, "relay_requests").await {
Ok(check) if !check.allowed => {
tracing::warn!(
"Quota exceeded for account {}: {} ({}/{})",
@@ -146,7 +146,7 @@ pub async fn quota_check_middleware(
}
// P1-8 修复: 同时检查 input_tokens 配额
match crate::billing::service::check_quota(&state.db, &account_id, "input_tokens").await {
match crate::billing::service::check_quota(&state.db, &account_id, &role, "input_tokens").await {
Ok(check) if !check.allowed => {
tracing::warn!(
"Token quota exceeded for account {}: {} ({}/{})",

View File

@@ -23,6 +23,18 @@ pub async fn chat_completions(
) -> SaasResult<Response> {
check_permission(&ctx, "relay:use")?;
// P1-08 修复: 直接配额检查(不依赖中间件,防御性编程)
for quota_type in &["relay_requests", "input_tokens", "output_tokens"] {
let check = crate::billing::service::check_quota(
&state.db, &ctx.account_id, &ctx.role, quota_type,
).await?;
if !check.allowed {
return Err(SaasError::RateLimited(
check.reason.unwrap_or_else(|| format!("{} 配额已用尽", quota_type))
));
}
}
// 队列容量检查:使用内存 AtomicI64 计数器,消除 DB COUNT 查询
let max_queue_size = {
let config = state.config.read().await;

View File

@@ -591,6 +591,17 @@ pub async fn execute_relay_with_failover(
candidate.model_id
);
}
// P2-09 修复: 非 SSE 响应在 failover 成功后记录 tokens 并标记 completed
if let RelayResponse::Json(ref body) = response {
let (input_tokens, output_tokens) = extract_token_usage(body);
if input_tokens > 0 || output_tokens > 0 {
if let Err(e) = update_task_status(db, task_id, "completed",
Some(input_tokens), Some(output_tokens), None).await {
tracing::warn!("Failed to update task {} tokens after failover: {}", task_id, e);
}
}
}
// SSE 响应由 StreamBridge 后台任务处理,无需在此更新
return Ok((response, candidate.provider_id.clone(), candidate.model_id.clone()));
}
Err(SaasError::RateLimited(msg)) => {

View File

@@ -10,6 +10,7 @@ import type { AgentTemplateFull } from '../lib/saas-client';
import { saasClient } from '../lib/saas-client';
import { useChatStore } from './chatStore';
import { useConversationStore } from './chat/conversationStore';
import { getGatewayVersion } from './connectionStore';
import { useSaaSStore } from './saasStore';
import { createLogger } from '../lib/logger';
@@ -338,6 +339,22 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
byModel: {},
};
// P2-10 修复: saas-relay 模式下从服务端获取真实用量
const gwVersion = getGatewayVersion();
if (gwVersion === 'saas-relay') {
try {
const sub = await saasClient.getSubscription();
if (sub?.usage) {
const serverTokens = (sub.usage.input_tokens ?? 0) + (sub.usage.output_tokens ?? 0);
if (serverTokens > 0) {
stats.totalTokens = serverTokens;
}
}
} catch {
// 降级到本地计数器
}
}
set({ usageStats: stats });
} catch {
// Usage stats are non-critical, ignore errors silently