Compare commits
2 Commits
45fd9fee7b
...
70229119be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70229119be | ||
|
|
dd854479eb |
169
admin-v2/src/pages/ApiKeys.tsx
Normal file
169
admin-v2/src/pages/ApiKeys.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })) },
|
||||
|
||||
@@ -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,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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 {}: {} ({}/{})",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
224
docs/INTEGRATION_TEST_REPORT_20260414.md
Normal file
224
docs/INTEGRATION_TEST_REPORT_20260414.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# ZCLAW 三端联调测试报告 2026-04-14
|
||||
|
||||
> 测试类型: 系统性联调测试(SaaS + Admin V2 + Tauri 桌面端)
|
||||
> 测试方法: 真实 API curl + Chrome DevTools UI 操作 + Tauri MCP 桌面端操作 + 数据一致性交叉验证
|
||||
> 测试时间: 2026-04-14 09:40 ~ 11:00
|
||||
> 测试环境: Windows 11 Pro / SaaS :8080 / Admin V2 :5173 / Tauri Dev :1420 / PostgreSQL
|
||||
|
||||
---
|
||||
|
||||
## 总览
|
||||
|
||||
| 维度 | 结果 |
|
||||
|------|------|
|
||||
| SaaS 后端 API | 30+ 端点测试,27 正常,3 异常 |
|
||||
| Admin V2 页面 | 16 页面测试,14 正常,2 有问题 |
|
||||
| Tauri 桌面端 | 核心功能 8 项测试,6 正常,2 异常 |
|
||||
| 数据一致性 | 5 项交叉验证,3 一致,2 不一致 |
|
||||
| **P0 问题** | **1 个** |
|
||||
| **P1 问题** | **3 个** |
|
||||
| **P2 问题** | **3 个** |
|
||||
| **P3 问题** | **3 个** |
|
||||
|
||||
### 与上次测试 (2026-04-13) 对比
|
||||
|
||||
| 变化 | 详情 |
|
||||
|------|------|
|
||||
| P0-1 模型不存在 | **已修复** — deepseek-chat 正常工作 |
|
||||
| P0-2 中文编码损坏 | **已修复** — Plans/Roles/Config 中文正确 |
|
||||
| P1-3 UI 模型选择器 | **已修复** — Tauri 端模型选择生效 |
|
||||
| P1-4 Industries 404 | **根因定位** — SaaS 二进制未重编译 |
|
||||
| P1-7 用量限额不一致 | **仍存在** — plan=100, usage.max=2000 |
|
||||
| P1-8 用量未执行 | **仍存在** — input_tokens 6.77x 超限 |
|
||||
| P2-9 Token 计数为零 | **仍存在** — 17 个任务中 5 个 tokens=0 |
|
||||
| P2-10 Tauri Token 统计 | **仍存在** — 桌面端显示"总 Token: 0" |
|
||||
|
||||
---
|
||||
|
||||
## P0 CRITICAL
|
||||
|
||||
### P0-NEW-01: SaaS 运行二进制严重过期
|
||||
|
||||
- **现象**: 运行中的 zclaw-saas.exe 构建于 2026-04-11 22:38,但代码自 2026-04-12 起有多项关键变更未反映到运行服务中
|
||||
- **影响范围**:
|
||||
- Industries API 全部 404(路由在 04-12 添加到 main.rs)
|
||||
- Knowledge Phase A 功能不可用(04-12 变更)
|
||||
- 任何 04-12 之后提交的修复均未生效
|
||||
- **证据**:
|
||||
- 二进制修改时间: `2026-04-11 22:38:20`
|
||||
- 代码最新影响 main.rs 的提交: `c3593d3 2026-04-12 18:36:05`
|
||||
- `git log -- crates/zclaw-saas/src/main.rs` 确认有 2 个提交未编译
|
||||
- **修复**: `cargo build -p zclaw-saas && 重启 SaaS 服务`
|
||||
|
||||
---
|
||||
|
||||
## P1 HIGH
|
||||
|
||||
### P1-04: Industries API 路由未注册(二进制过期导致)
|
||||
|
||||
- **现象**: `GET /api/v1/industries` 返回 HTTP 404
|
||||
- **根因**: P0-NEW-01 — Industry 路由在 `5d1050b (2026-04-12)` 添加到 main.rs,但二进制未重编译
|
||||
- **影响**:
|
||||
- Admin V2 行业配置页面空
|
||||
- Tauri 账号编辑弹窗"授权行业"下拉框永久 loading
|
||||
- 行业知识域/管家技能优先级不可用
|
||||
- **代码验证**: `industry/mod.rs` 路由定义正确,`main.rs:363` `.merge()` 注册正确
|
||||
- **修复**: 重编译 SaaS 二进制
|
||||
|
||||
### P1-07: 用量限额数据不一致
|
||||
|
||||
- **现象**: Plan 定义 `max_relay_requests_monthly=100`,但 usage 对象返回 `max_relay_requests=2000`
|
||||
- **API 证据**:
|
||||
```
|
||||
GET /billing/plans → plan-free.limits.max_relay_requests_monthly = 100
|
||||
GET /billing/subscription → usage.max_relay_requests = 2000
|
||||
```
|
||||
- **UI 影响**: Admin V2 计费管理页面 — 免费版卡片显示 "100 次/月",当前用量显示 "2,000"
|
||||
- **影响**: 用户和 Admin 看到矛盾数据,计费系统不可信
|
||||
|
||||
### P1-08: 用量限制未执行
|
||||
|
||||
- **现象**: admin 账户 `input_tokens=3,386,978`,是 free plan 限额 500,000 的 **6.77 倍**,无任何拦截
|
||||
- **影响**: 用户可无限使用超出计划限制的资源
|
||||
- **修复方向**: 在 relay 请求前检查 usage 是否超限
|
||||
|
||||
---
|
||||
|
||||
## P2 MEDIUM
|
||||
|
||||
### P2-09: 29% Relay 任务 token 计数为零
|
||||
|
||||
- **现象**: 17 个已完成 relay 任务中 **5 个** input_tokens=0, output_tokens=0
|
||||
- **证据**:
|
||||
```
|
||||
5b85b045... completed tokens=0/0
|
||||
644134f4... completed tokens=0/0
|
||||
25820499... completed tokens=0/0
|
||||
a37669b0... completed tokens=0/0
|
||||
539b26a8... completed tokens=0/0
|
||||
```
|
||||
- **修复方向**: 检查 relay 完成后 token 统计逻辑,某些路径可能跳过了 token 累加
|
||||
|
||||
### P2-10: Tauri 端 Token 统计为 0
|
||||
|
||||
- **现象**: 详情面板"用量统计"显示"总 Token: 0",但 SaaS relay 有真实 token 使用记录 (3,386,978 input + 197,420 output)
|
||||
- **影响**: 桌面端用户无法看到自己的 token 消耗
|
||||
- **修复方向**: Tauri 端应从 SaaS API 获取 usage 数据而非本地累计
|
||||
|
||||
### P2-14: Subscription 为 null
|
||||
|
||||
- **现象**: admin 账号的 `billing.subscription` 为 null,使用默认 free plan
|
||||
- **影响**: 无法区分"主动订阅"和"默认计划"
|
||||
|
||||
---
|
||||
|
||||
## P3 LOW
|
||||
|
||||
### P3-19: Admin API 密钥页面路由指向 ModelServices 组件
|
||||
|
||||
- **现象**: 侧边栏 "API 密钥" 按钮 (/api-keys) 加载了 ModelServices 组件,而非独立的 API 密钥管理页面
|
||||
- **代码**: `admin-v2/src/router/index.tsx:29` 懒加载 `ModelServices` 用于 `api-keys` 路径
|
||||
- **可能是设计如此**(Provider 管理 = Key 池管理),但页面标题与内容不匹配
|
||||
|
||||
### P3-15: antd Modal destroyOnClose 废弃
|
||||
|
||||
- Admin V2 多个页面使用 `destroyOnClose`,antd 新版应使用 `destroyOnHidden`
|
||||
|
||||
### P3-16: onSearch React DOM 属性警告
|
||||
|
||||
- 知识库页面 `Unknown event handler property onSearch`
|
||||
|
||||
---
|
||||
|
||||
## 已验证正常的功能
|
||||
|
||||
### SaaS 后端 API (27/30 正常)
|
||||
|
||||
| 模块 | 端点 | 状态 | 数据验证 |
|
||||
|------|------|------|----------|
|
||||
| Auth | login/refresh/me/password/totp-setup | ✅ | JWT+rotation 正常,TOTP secret 生成正确 |
|
||||
| Accounts | CRUD/搜索/状态切换 | ✅ | 30 个账号,分页正常 |
|
||||
| Providers | CRUD | ✅ | 3 Provider (DeepSeek/Kimi/zhipu),Key 池正常 |
|
||||
| Models | CRUD/列表 | ✅ | 3 模型 (deepseek-chat/GLM-4.7/kimi-for-coding) |
|
||||
| **Relay Chat** | **流式+非流式** | **✅** | **核心链路正常,真实 LLM 响应** |
|
||||
| Relay Tasks | 列表 | ✅ | 17 个真实任务 |
|
||||
| Billing Plans | 列表 | ✅ | 3 计划,中文正确 |
|
||||
| Billing Usage | 查询 | ✅ | 详细用量统计 |
|
||||
| Roles | 列表 | ✅ | 3 角色,权限列表正确 |
|
||||
| Agent Templates | 列表 | ✅ | 10 模板(含 4 行业) |
|
||||
| Knowledge | CRUD/搜索 | ✅ | 6 分类/6 条目,搜索返回带分值结果 |
|
||||
| Knowledge Analytics | overview | ✅ | 统计数据完整 |
|
||||
| Config | items/analysis | ✅ | 62 配置项,8 分类 |
|
||||
| Dashboard Stats | 聚合 | ✅ | 30 账号/3 Provider/3 模型 |
|
||||
| Operation Logs | 列表 | ✅ | 2,047 条日志 |
|
||||
| Provider Keys | Key 池 | ✅ | RPM/TPM/cooldown 追踪正常 |
|
||||
| Prompts | 列表 | ✅ | 3 内置提示词 |
|
||||
| Scheduler | 任务列表 | ✅ | 路径正确 (/api/v1/scheduler/tasks) |
|
||||
|
||||
### Admin V2 管理后台 (14/16 正常)
|
||||
|
||||
| 页面 | 数据来源 | 交互验证 |
|
||||
|------|----------|----------|
|
||||
| 仪表盘 | ✅ API 实时数据 | 30 账号/3 Provider/3 模型/14 tokens 全部与 API 一致 |
|
||||
| 计费管理 | ✅ Plans + Usage | 3 计划卡片正确,用量进度条准确 |
|
||||
| 账号管理 | ✅ 30 账号 | 编辑弹窗/搜索/分页/状态切换全部正常 |
|
||||
| 角色权限 | ✅ 3 角色 | 权限列表正确,模板 tab 为空(符合预期) |
|
||||
| 模型服务 | ✅ 3 Provider | 展开 Provider 显示 Key 池和模型 |
|
||||
| Agent 模板 | ✅ 10 模板 | 列表/筛选正常 |
|
||||
| 知识库 | ✅ 6 分类/6 条目 | 5 个 tab 全部有数据 |
|
||||
| 用量统计 | ✅ 30 用户统计 | 图表渲染正常 |
|
||||
| 中转任务 | ✅ 9 任务 | 全部显示 completed |
|
||||
| 操作日志 | ✅ 2,039 条 | 分页/筛选正常 |
|
||||
| 系统配置 | ✅ 62 配置项 | 6 个 tab 分类清晰 |
|
||||
| 提示词管理 | ✅ 3 提示词 | 列表正常 |
|
||||
| 同步日志 | ✅ 空(符合预期) | 页面正常渲染 |
|
||||
| 定时任务 | ✅ 空(符合预期) | 页面正常渲染 |
|
||||
|
||||
### Tauri 桌面端 (6/8 正常)
|
||||
|
||||
| 功能 | 状态 | 验证结果 |
|
||||
|------|------|----------|
|
||||
| Gateway 连接 | ✅ | saas-relay 模式,http://127.0.0.1:8080 |
|
||||
| 模型选择 | ✅ | deepseek-chat 正确匹配 SaaS 白名单 |
|
||||
| 聊天发送/接收 | ✅ | 发送"你好"→ 收到"你好!很高兴为你服务" |
|
||||
| 对话历史 | ✅ | 7 个对话,114 条消息,时间戳正确 |
|
||||
| 设置页面 | ✅ | 19 个设置页全部可访问,Gateway 状态正确 |
|
||||
| 简洁/专业模式 | ✅ | 切换按钮正常,管家快捷操作可见 |
|
||||
| 用量统计 | ❌ | 总 Token 显示 0(P2-10) |
|
||||
| 行业下拉框 | ❌ | 编辑账号时"授权行业"永久 loading(P1-04) |
|
||||
|
||||
---
|
||||
|
||||
## 数据一致性交叉验证
|
||||
|
||||
| 验证项 | SaaS API | Admin V2 | Tauri | 一致? |
|
||||
|--------|----------|----------|-------|-------|
|
||||
| 账号总数 | 30 | 30 | - | ✅ |
|
||||
| Provider 数 | 3 | 3 | 3 | ✅ |
|
||||
| 模型数 | 3 | 3 | 3 | ✅ |
|
||||
| Relay 请求数 | 561 | 553 | - | ✅ (差 8 = 测试期间新增) |
|
||||
| Operation Logs | 2,047 | 2,039 | - | ✅ (差 8 = 并发写入) |
|
||||
| 当前模型 | deepseek-chat | - | deepseek-chat | ✅ |
|
||||
| Plan max_relay | 100 | 100 | - | ✅ |
|
||||
| Usage max_relay | **2,000** | **2,000** | - | ❌ 与 Plan 不一致 |
|
||||
|
||||
---
|
||||
|
||||
## 测试环境信息
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| SaaS 后端 | http://localhost:8080 (zclaw-saas.exe PID=10976, build 2026-04-11) |
|
||||
| Admin V2 | http://localhost:5173 (Vite dev server) |
|
||||
| Tauri Dev | http://localhost:1420 (saas-relay 模式) |
|
||||
| PostgreSQL | localhost:5432/zclaw |
|
||||
| Admin 账号 | admin / admin123 / super_admin |
|
||||
| 截图位置 | `tests/screenshots/admin-*.png` |
|
||||
|
||||
---
|
||||
|
||||
## 修复优先级建议
|
||||
|
||||
1. **立即**: 重编译 SaaS 二进制 → 解决 P0-NEW-01 + P1-04
|
||||
2. **发布前**: 修复 P1-07 (用量限额不一致) + P1-08 (用量未执行)
|
||||
3. **发布后**: P2-09 (token 计数) + P2-10 (Tauri 统计) + P2-14 (subscription null)
|
||||
Reference in New Issue
Block a user