feat(ai): Phase 1C 管理看板 — 用量/成本/功能开关三合一
- UsageService 新增 get_daily_usage + aggregate_daily 日聚合能力 - 新增 3 个管理端点: /ai/admin/daily-usage, /ai/admin/flags (GET+POST) - AiUsageDashboard 扩展为三 Tab: 用量概览/成本分析/功能开关 - 功能开关支持 Switch 实时切换,权限码 ai.admin.flags - 日聚合用量 30 天趋势表,含 Token/成本汇总统计
This commit is contained in:
@@ -45,6 +45,22 @@ export interface CostEstimate {
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface DailyUsageRow {
|
||||
date: string;
|
||||
feature: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
total_calls: number;
|
||||
total_input_tokens: number;
|
||||
total_output_tokens: number;
|
||||
total_cost_cents: number;
|
||||
}
|
||||
|
||||
export interface FeatureFlag {
|
||||
feature: string;
|
||||
is_enabled: boolean;
|
||||
}
|
||||
|
||||
export const usageApi = {
|
||||
overview: async () => {
|
||||
const resp = await client.get('/ai/usage/overview');
|
||||
@@ -74,4 +90,18 @@ export const usageApi = {
|
||||
const resp = await client.get('/ai/cost/estimate', { params });
|
||||
return resp.data.data as CostEstimate;
|
||||
},
|
||||
getDailyUsage: async (startDate: string, endDate: string) => {
|
||||
const resp = await client.get('/ai/admin/daily-usage', {
|
||||
params: { start_date: startDate, end_date: endDate },
|
||||
});
|
||||
return resp.data.data as DailyUsageRow[];
|
||||
},
|
||||
getFeatureFlags: async () => {
|
||||
const resp = await client.get('/ai/admin/flags');
|
||||
return resp.data.data as FeatureFlag[];
|
||||
},
|
||||
updateFeatureFlag: async (feature: string, enabled: boolean) => {
|
||||
const resp = await client.post('/ai/admin/flags', { feature, enabled });
|
||||
return resp.data.data as { feature: string; enabled: boolean };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Spin, Statistic, message, Empty, Result, Row, Col } from 'antd';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Card, Spin, Statistic, message, Empty, Result, Row, Col, Tabs, Table, Switch, Tag,
|
||||
} from 'antd';
|
||||
import {
|
||||
ThunderboltOutlined,
|
||||
ExperimentOutlined,
|
||||
DollarOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { usageApi, type UsageOverview, type TypeDistribution } from '../../api/ai/usage';
|
||||
import {
|
||||
usageApi,
|
||||
type UsageOverview,
|
||||
type TypeDistribution,
|
||||
type DailyUsageRow,
|
||||
type FeatureFlag,
|
||||
} from '../../api/ai/usage';
|
||||
|
||||
const ANALYSIS_TYPE_MAP: Record<string, string> = {
|
||||
lab_report_interpretation: '化验单解读',
|
||||
health_trend_analysis: '趋势分析',
|
||||
personalized_checkup_plan: '体检方案',
|
||||
report_summary_generation: '报告摘要',
|
||||
chat: 'AI 聊天',
|
||||
};
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
@@ -20,35 +31,80 @@ const TYPE_COLORS: Record<string, string> = {
|
||||
health_trend_analysis: '#52c41a',
|
||||
personalized_checkup_plan: '#722ed1',
|
||||
report_summary_generation: '#fa8c16',
|
||||
chat: '#eb2f96',
|
||||
};
|
||||
|
||||
const FEATURE_LABELS: Record<string, string> = {
|
||||
'ai.analysis': 'AI 分析',
|
||||
'ai.chat': 'AI 聊天',
|
||||
'ai.trend': '趋势分析',
|
||||
'ai.report': '报告摘要',
|
||||
'ai.checkup': '体检方案',
|
||||
'ai.copilot': 'Copilot 辅助',
|
||||
'ai.alert.push': 'AI 预警推送',
|
||||
'ai.rag': 'RAG 知识检索',
|
||||
'ai.voice': '语音交互',
|
||||
'ai.suggestion': 'AI 建议',
|
||||
'ai.lab': '化验解读',
|
||||
'ai.summary': '综合摘要',
|
||||
};
|
||||
|
||||
export default function AiUsageDashboard() {
|
||||
const { hasPermission } = usePermission('ai.usage.list');
|
||||
if (!hasPermission) return <Result status="403" title="权限不足" subTitle="您没有查看 AI 用量的权限" />;
|
||||
const adminFlags = usePermission('ai.admin.flags');
|
||||
const [overview, setOverview] = useState<UsageOverview | null>(null);
|
||||
const [types, setTypes] = useState<TypeDistribution[]>([]);
|
||||
const [dailyUsage, setDailyUsage] = useState<DailyUsageRow[]>([]);
|
||||
const [flags, setFlags] = useState<FeatureFlag[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const isDark = useThemeMode();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [ov, tp] = await Promise.all([
|
||||
usageApi.overview(),
|
||||
usageApi.byType(),
|
||||
]);
|
||||
setOverview(ov);
|
||||
setTypes(tp);
|
||||
} catch {
|
||||
message.error('加载用量统计失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [ov, tp] = await Promise.all([
|
||||
usageApi.overview(),
|
||||
usageApi.byType(),
|
||||
]);
|
||||
setOverview(ov);
|
||||
setTypes(tp);
|
||||
} catch {
|
||||
message.error('加载用量统计失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchDailyUsage = useCallback(async () => {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setDate(start.getDate() - 30);
|
||||
const fmt = (d: Date) => d.toISOString().slice(0, 10);
|
||||
try {
|
||||
const rows = await usageApi.getDailyUsage(fmt(start), fmt(end));
|
||||
setDailyUsage(rows);
|
||||
} catch {
|
||||
message.error('加载日聚合用量失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchFlags = useCallback(async () => {
|
||||
try {
|
||||
const data = await usageApi.getFeatureFlags();
|
||||
setFlags(data);
|
||||
} catch {
|
||||
message.error('加载功能开关失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
if (!hasPermission) {
|
||||
return <Result status="403" title="权限不足" subTitle="您没有查看 AI 用量的权限" />;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 80 }}>
|
||||
@@ -65,81 +121,236 @@ export default function AiUsageDashboard() {
|
||||
|
||||
const totalCount = types.reduce((sum, t) => sum + t.count, 0);
|
||||
|
||||
// 日聚合汇总
|
||||
const totalCalls = dailyUsage.reduce((s, r) => s + r.total_calls, 0);
|
||||
const totalTokens = dailyUsage.reduce(
|
||||
(s, r) => s + r.total_input_tokens + r.total_output_tokens,
|
||||
0,
|
||||
);
|
||||
const totalCost = dailyUsage.reduce((s, r) => s + r.total_cost_cents, 0);
|
||||
|
||||
const handleToggleFlag = async (feature: string, enabled: boolean) => {
|
||||
try {
|
||||
await usageApi.updateFeatureFlag(feature, enabled);
|
||||
setFlags((prev) =>
|
||||
prev.map((f) => (f.feature === feature ? { ...f, is_enabled: enabled } : f)),
|
||||
);
|
||||
message.success(`${FEATURE_LABELS[feature] || feature} 已${enabled ? '启用' : '禁用'}`);
|
||||
} catch {
|
||||
message.error('更新功能开关失败');
|
||||
}
|
||||
};
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'overview',
|
||||
label: '用量概览',
|
||||
icon: <ThunderboltOutlined />,
|
||||
children: (
|
||||
<>
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={8}>
|
||||
<Card style={cardStyle}>
|
||||
<Statistic
|
||||
title="总分析次数"
|
||||
value={overview?.total_count ?? 0}
|
||||
prefix={<ThunderboltOutlined style={{ color: '#1890ff' }} />}
|
||||
valueStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card style={cardStyle}>
|
||||
<Statistic
|
||||
title="分析类型数"
|
||||
value={types.length}
|
||||
prefix={<ExperimentOutlined style={{ color: '#52c41a' }} />}
|
||||
valueStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card style={cardStyle}>
|
||||
<Statistic
|
||||
title="本月分析"
|
||||
value={totalCount}
|
||||
prefix={<ThunderboltOutlined style={{ color: '#fa8c16' }} />}
|
||||
valueStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card style={cardStyle} title="分析类型分布">
|
||||
{types.length === 0 ? (
|
||||
<Empty description="暂无分析数据" />
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{types.map((t) => {
|
||||
const pct = totalCount > 0 ? Math.round((t.count / totalCount) * 100) : 0;
|
||||
const label = ANALYSIS_TYPE_MAP[t.analysis_type] || t.analysis_type;
|
||||
const color = TYPE_COLORS[t.analysis_type] || '#1890ff';
|
||||
return (
|
||||
<Col span={6} key={t.analysis_type}>
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
background: isDark ? '#0f172a' : '#f8fafc',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, color }}>{t.count}</div>
|
||||
<div style={{ fontSize: 13, color: isDark ? '#94a3b8' : '#475569', marginTop: 4 }}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: isDark ? '#475569' : '#94a3b8', marginTop: 4 }}>
|
||||
{pct}%
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'cost',
|
||||
label: '成本分析',
|
||||
icon: <DollarOutlined />,
|
||||
children: (
|
||||
<>
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={8}>
|
||||
<Card style={cardStyle}>
|
||||
<Statistic
|
||||
title="30 天总调用"
|
||||
value={totalCalls}
|
||||
valueStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card style={cardStyle}>
|
||||
<Statistic
|
||||
title="30 天总 Token"
|
||||
value={totalTokens}
|
||||
valueStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card style={cardStyle}>
|
||||
<Statistic
|
||||
title="30 天成本"
|
||||
value={(totalCost / 100).toFixed(2)}
|
||||
prefix="¥"
|
||||
valueStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card style={cardStyle} title="日用量明细(近 30 天)">
|
||||
<Table
|
||||
dataSource={dailyUsage}
|
||||
rowKey={(r) => `${r.date}-${r.feature}`}
|
||||
size="small"
|
||||
pagination={{ pageSize: 15 }}
|
||||
columns={[
|
||||
{ title: '日期', dataIndex: 'date', width: 120 },
|
||||
{ title: '功能', dataIndex: 'feature', width: 150 },
|
||||
{ title: '调用次数', dataIndex: 'total_calls', width: 100 },
|
||||
{
|
||||
title: '输入 Token',
|
||||
dataIndex: 'total_input_tokens',
|
||||
width: 120,
|
||||
render: (v: number) => v.toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '输出 Token',
|
||||
dataIndex: 'total_output_tokens',
|
||||
width: 120,
|
||||
render: (v: number) => v.toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '成本',
|
||||
dataIndex: 'total_cost_cents',
|
||||
width: 100,
|
||||
render: (v: number) => `¥${(v / 100).toFixed(2)}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'flags',
|
||||
label: '功能开关',
|
||||
icon: <SettingOutlined />,
|
||||
children: (
|
||||
<Card style={cardStyle} title="AI 功能开关">
|
||||
{flags.length === 0 ? (
|
||||
<Empty description="暂无功能开关配置" />
|
||||
) : (
|
||||
<Table
|
||||
dataSource={flags}
|
||||
rowKey="feature"
|
||||
size="small"
|
||||
pagination={false}
|
||||
columns={[
|
||||
{
|
||||
title: '功能',
|
||||
dataIndex: 'feature',
|
||||
render: (f: string) => FEATURE_LABELS[f] || f,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'is_enabled',
|
||||
width: 200,
|
||||
render: (enabled: boolean, record: FeatureFlag) =>
|
||||
adminFlags.hasPermission ? (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={(checked) => handleToggleFlag(record.feature, checked)}
|
||||
checkedChildren="启用"
|
||||
unCheckedChildren="禁用"
|
||||
/>
|
||||
) : (
|
||||
<Tag color={enabled ? 'green' : 'default'}>
|
||||
{enabled ? '启用' : '禁用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="erp-page-header">
|
||||
<div>
|
||||
<h4>AI 用量统计</h4>
|
||||
<div className="erp-page-subtitle">查看 AI 分析服务的使用情况</div>
|
||||
<h4>AI 管理看板</h4>
|
||||
<div className="erp-page-subtitle">AI 功能用量统计、成本分析与功能管理</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={8}>
|
||||
<Card style={cardStyle}>
|
||||
<Statistic
|
||||
title="总分析次数"
|
||||
value={overview?.total_count ?? 0}
|
||||
prefix={<ThunderboltOutlined style={{ color: '#1890ff' }} />}
|
||||
valueStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card style={cardStyle}>
|
||||
<Statistic
|
||||
title="分析类型数"
|
||||
value={types.length}
|
||||
prefix={<ExperimentOutlined style={{ color: '#52c41a' }} />}
|
||||
valueStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card style={cardStyle}>
|
||||
<Statistic
|
||||
title="本月分析"
|
||||
value={totalCount}
|
||||
prefix={<ThunderboltOutlined style={{ color: '#fa8c16' }} />}
|
||||
valueStyle={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card style={cardStyle} title="分析类型分布">
|
||||
{types.length === 0 ? (
|
||||
<Empty description="暂无分析数据" />
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{types.map((t) => {
|
||||
const pct = totalCount > 0 ? Math.round((t.count / totalCount) * 100) : 0;
|
||||
const label = ANALYSIS_TYPE_MAP[t.analysis_type] || t.analysis_type;
|
||||
const color = TYPE_COLORS[t.analysis_type] || '#1890ff';
|
||||
return (
|
||||
<Col span={6} key={t.analysis_type}>
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
background: isDark ? '#0f172a' : '#f8fafc',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, color }}>{t.count}</div>
|
||||
<div style={{ fontSize: 13, color: isDark ? '#94a3b8' : '#475569', marginTop: 4 }}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: isDark ? '#475569' : '#94a3b8', marginTop: 4 }}>
|
||||
{pct}%
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
<Tabs
|
||||
defaultActiveKey="overview"
|
||||
items={tabItems}
|
||||
onChange={(key) => {
|
||||
if (key === 'cost') fetchDailyUsage();
|
||||
if (key === 'flags') fetchFlags();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -832,6 +832,107 @@ where
|
||||
Ok(Json(ApiResponse::ok(estimate)))
|
||||
}
|
||||
|
||||
// === AI 管理看板 ===
|
||||
|
||||
#[derive(Debug, Deserialize, utoipa::IntoParams)]
|
||||
pub struct DailyUsageQuery {
|
||||
pub start_date: String,
|
||||
pub end_date: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/ai/admin/daily-usage",
|
||||
params(DailyUsageQuery),
|
||||
responses((status = 200, description = "按日聚合用量")),
|
||||
tag = "AI 管理",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn admin_daily_usage<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(params): Query<DailyUsageQuery>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.admin.dashboard")?;
|
||||
|
||||
let start_date = chrono::NaiveDate::parse_from_str(¶ms.start_date, "%Y-%m-%d")
|
||||
.map_err(|_| erp_core::error::AppError::Validation("start_date 格式错误".into()))?;
|
||||
let end_date = chrono::NaiveDate::parse_from_str(¶ms.end_date, "%Y-%m-%d")
|
||||
.map_err(|_| erp_core::error::AppError::Validation("end_date 格式错误".into()))?;
|
||||
|
||||
let rows = state
|
||||
.usage
|
||||
.get_daily_usage(ctx.tenant_id, start_date, end_date)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"data": rows,
|
||||
"start_date": params.start_date,
|
||||
"end_date": params.end_date,
|
||||
}))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/ai/admin/flags",
|
||||
responses((status = 200, description = "功能开关列表")),
|
||||
tag = "AI 管理",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn admin_list_flags<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<
|
||||
Json<ApiResponse<Vec<crate::service::feature_flag_service::FeatureFlag>>>,
|
||||
erp_core::error::AppError,
|
||||
>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.admin.flags")?;
|
||||
let flags = state.feature_flags.get_all(ctx.tenant_id).await?;
|
||||
Ok(Json(ApiResponse::ok(flags)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpdateFlagBody {
|
||||
pub feature: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/ai/admin/flags",
|
||||
request_body = UpdateFlagBody,
|
||||
responses((status = 200, description = "更新功能开关")),
|
||||
tag = "AI 管理",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn admin_update_flag<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(body): Json<UpdateFlagBody>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.admin.flags")?;
|
||||
state
|
||||
.feature_flags
|
||||
.set_enabled(ctx.tenant_id, &body.feature, body.enabled, ctx.user_id)
|
||||
.await?;
|
||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||
"feature": body.feature,
|
||||
"enabled": body.enabled,
|
||||
}))))
|
||||
}
|
||||
|
||||
// === SSE 流构建辅助 ===
|
||||
|
||||
fn build_sse_stream(
|
||||
|
||||
@@ -494,6 +494,19 @@ impl AiModule {
|
||||
"/ai/health-summary",
|
||||
axum::routing::get(crate::handler::insight_handler::health_summary),
|
||||
)
|
||||
// AI 管理看板
|
||||
.route(
|
||||
"/ai/admin/daily-usage",
|
||||
axum::routing::get(crate::handler::admin_daily_usage),
|
||||
)
|
||||
.route(
|
||||
"/ai/admin/flags",
|
||||
axum::routing::get(crate::handler::admin_list_flags),
|
||||
)
|
||||
.route(
|
||||
"/ai/admin/flags",
|
||||
axum::routing::post(crate::handler::admin_update_flag),
|
||||
)
|
||||
.route(
|
||||
"/ai/budget/status",
|
||||
axum::routing::get(crate::handler::budget_status),
|
||||
|
||||
@@ -5,6 +5,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::entity::ai_analysis;
|
||||
use crate::entity::ai_usage;
|
||||
use crate::entity::ai_usage_daily;
|
||||
use crate::error::AiResult;
|
||||
|
||||
pub struct UsageService {
|
||||
@@ -76,6 +77,85 @@ impl UsageService {
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 按日期范围查询日聚合用量
|
||||
pub async fn get_daily_usage(
|
||||
&self,
|
||||
tenant_id: Uuid,
|
||||
start_date: chrono::NaiveDate,
|
||||
end_date: chrono::NaiveDate,
|
||||
) -> AiResult<Vec<DailyUsageRow>> {
|
||||
let rows = ai_usage_daily::Entity::find()
|
||||
.filter(ai_usage_daily::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_usage_daily::Column::Date.gte(start_date))
|
||||
.filter(ai_usage_daily::Column::Date.lte(end_date))
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| DailyUsageRow {
|
||||
date: r.date,
|
||||
feature: r.feature,
|
||||
provider: r.provider,
|
||||
model: r.model,
|
||||
total_calls: r.total_calls,
|
||||
total_input_tokens: r.total_input_tokens,
|
||||
total_output_tokens: r.total_output_tokens,
|
||||
total_cost_cents: r.total_cost_cents,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// 聚合指定日期的用量到日聚合表(由定时任务调用)
|
||||
pub async fn aggregate_daily(&self, tenant_id: Uuid, date: chrono::NaiveDate) -> AiResult<()> {
|
||||
let date_start = date.and_hms_opt(0, 0, 0).unwrap_or_default();
|
||||
let date_end = date_start + chrono::Duration::days(1);
|
||||
|
||||
// 从 ai_usage 按分析类型聚合
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct AggRow {
|
||||
analysis_type: String,
|
||||
total_calls: i64,
|
||||
total_input_tokens: i64,
|
||||
total_output_tokens: i64,
|
||||
total_cost_cents: i64,
|
||||
}
|
||||
|
||||
let rows: Vec<AggRow> = ai_usage::Entity::find()
|
||||
.filter(ai_usage::Column::TenantId.eq(tenant_id))
|
||||
.filter(ai_usage::Column::CreatedAt.gte(date_start))
|
||||
.filter(ai_usage::Column::CreatedAt.lt(date_end))
|
||||
.select_only()
|
||||
.column(ai_usage::Column::AnalysisType)
|
||||
.column_as(ai_usage::Column::Id.count(), "total_calls")
|
||||
.column_as(ai_usage::Column::InputTokens.sum(), "total_input_tokens")
|
||||
.column_as(ai_usage::Column::OutputTokens.sum(), "total_output_tokens")
|
||||
.column_as(ai_usage::Column::CostCents.sum(), "total_cost_cents")
|
||||
.group_by(ai_usage::Column::AnalysisType)
|
||||
.into_model::<AggRow>()
|
||||
.all(&self.db)
|
||||
.await?;
|
||||
|
||||
for row in &rows {
|
||||
let id = Uuid::now_v7();
|
||||
let active = ai_usage_daily::ActiveModel {
|
||||
id: Set(id),
|
||||
tenant_id: Set(tenant_id),
|
||||
date: Set(date),
|
||||
feature: Set(row.analysis_type.clone()),
|
||||
provider: Set("aggregated".into()),
|
||||
model: Set("mixed".into()),
|
||||
total_calls: Set(row.total_calls as i32),
|
||||
total_input_tokens: Set(row.total_input_tokens),
|
||||
total_output_tokens: Set(row.total_output_tokens),
|
||||
total_cost_cents: Set(row.total_cost_cents),
|
||||
created_at: Set(chrono::Utc::now()),
|
||||
};
|
||||
active.insert(&self.db).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
@@ -88,3 +168,15 @@ pub struct TypeCount {
|
||||
pub analysis_type: String,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct DailyUsageRow {
|
||||
pub date: chrono::NaiveDate,
|
||||
pub feature: String,
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub total_calls: i32,
|
||||
pub total_input_tokens: i64,
|
||||
pub total_output_tokens: i64,
|
||||
pub total_cost_cents: i64,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user