Spaces:
Sleeping
Sleeping
feat: add usage dashboard and metrics usage rate limiting
Browse files- .env.example +15 -0
- README.md +4 -0
- frontend/src/App.tsx +8 -1
- frontend/src/components/UsageDashboard.tsx +282 -0
- frontend/src/components/app/top-right-actions.tsx +11 -1
- frontend/src/lib/api.ts +22 -1
- frontend/src/types/api.ts +53 -7
- src/middlewares/rate-limit.ts +75 -0
- src/queues/processors/video.processor.ts +1 -1
- src/routes/generate.route.ts +7 -4
- src/routes/metrics.route.ts +47 -0
- src/routes/modify.route.ts +3 -0
- src/services/job-cancel.ts +28 -21
- src/services/job-store.ts +54 -23
- src/services/usage-metrics.ts +301 -0
- src/types/index.ts +10 -9
.env.example
CHANGED
|
@@ -83,4 +83,19 @@ NODE_ENV=development
|
|
| 83 |
|
| 84 |
# MEDIA_CLEANUP_INTERVAL_MINUTES: 清理任务执行间隔(分钟,默认 60)
|
| 85 |
# MEDIA_CLEANUP_INTERVAL_MINUTES=60
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
|
|
|
| 83 |
|
| 84 |
# MEDIA_CLEANUP_INTERVAL_MINUTES: 清理任务执行间隔(分钟,默认 60)
|
| 85 |
# MEDIA_CLEANUP_INTERVAL_MINUTES=60
|
| 86 |
+
|
| 87 |
+
# -----------------------------------------------------------------------------
|
| 88 |
+
# 任务明细与用量统计保留配置(可选)
|
| 89 |
+
# -----------------------------------------------------------------------------
|
| 90 |
+
# JOB_RESULT_RETENTION_HOURS: 任务结果与阶段信息在 Redis 中保留小时数(默认 24)
|
| 91 |
+
# JOB_RESULT_RETENTION_HOURS=24
|
| 92 |
+
|
| 93 |
+
# USAGE_RETENTION_DAYS: 按天聚合的用量统计保留天数(默认 90)
|
| 94 |
+
# USAGE_RETENTION_DAYS=90
|
| 95 |
+
|
| 96 |
+
# METRICS_USAGE_RATE_LIMIT_MAX: 用量接口每个 IP 在窗口期内允许的最大请求数(默认 30)
|
| 97 |
+
# METRICS_USAGE_RATE_LIMIT_MAX=30
|
| 98 |
+
|
| 99 |
+
# METRICS_USAGE_RATE_LIMIT_WINDOW_MS: 用量接口限流窗口时长(毫秒,默认 60000)
|
| 100 |
+
# METRICS_USAGE_RATE_LIMIT_WINDOW_MS=60000
|
| 101 |
|
README.md
CHANGED
|
@@ -202,6 +202,10 @@ pinned: false
|
|
| 202 |
| `CODE_RETRY_MAX_RETRIES` | `4` | 代码修复重试次数 |
|
| 203 |
| `MEDIA_RETENTION_HOURS` | `72` | 图片/视频文件保留小时数 |
|
| 204 |
| `MEDIA_CLEANUP_INTERVAL_MINUTES` | `60` | 媒体清理任务执行间隔(分钟) |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
|
| 206 |
**示例 `.env` 文件:**
|
| 207 |
|
|
|
|
| 202 |
| `CODE_RETRY_MAX_RETRIES` | `4` | 代码修复重试次数 |
|
| 203 |
| `MEDIA_RETENTION_HOURS` | `72` | 图片/视频文件保留小时数 |
|
| 204 |
| `MEDIA_CLEANUP_INTERVAL_MINUTES` | `60` | 媒体清理任务执行间隔(分钟) |
|
| 205 |
+
| `JOB_RESULT_RETENTION_HOURS` | `24` | 任务结果与阶段信息保留小时数 |
|
| 206 |
+
| `USAGE_RETENTION_DAYS` | `90` | 用量统计(按天聚合)保留天数 |
|
| 207 |
+
| `METRICS_USAGE_RATE_LIMIT_MAX` | `30` | 用量接口每个 IP 的窗口最大请求数 |
|
| 208 |
+
| `METRICS_USAGE_RATE_LIMIT_WINDOW_MS` | `60000` | 用量接口限流窗口时长(毫秒) |
|
| 209 |
|
| 210 |
**示例 `.env` 文件:**
|
| 211 |
|
frontend/src/App.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import { AiModifyModal } from './components/AiModifyModal';
|
|
| 8 |
import { SettingsModal } from './components/SettingsModal';
|
| 9 |
import { PromptsManager } from './components/PromptsManager';
|
| 10 |
import { DonationModal } from './components/DonationModal';
|
|
|
|
| 11 |
import ManimCatLogo from './components/ManimCatLogo';
|
| 12 |
import { TopLeftActions } from './components/app/top-left-actions';
|
| 13 |
import { TopRightActions } from './components/app/top-right-actions';
|
|
@@ -19,6 +20,7 @@ function App() {
|
|
| 19 |
const [settingsOpen, setSettingsOpen] = useState(false);
|
| 20 |
const [promptsOpen, setPromptsOpen] = useState(false);
|
| 21 |
const [donationOpen, setDonationOpen] = useState(false);
|
|
|
|
| 22 |
const [aiModifyOpen, setAiModifyOpen] = useState(false);
|
| 23 |
const [aiModifyInput, setAiModifyInput] = useState('');
|
| 24 |
const [currentCode, setCurrentCode] = useState('');
|
|
@@ -79,7 +81,11 @@ function App() {
|
|
| 79 |
return (
|
| 80 |
<div className="min-h-screen bg-bg-primary transition-colors duration-300 overflow-x-hidden">
|
| 81 |
<TopLeftActions onOpenDonation={() => setDonationOpen(true)} />
|
| 82 |
-
<TopRightActions
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
<div className="max-w-4xl mx-auto px-4 min-h-screen flex flex-col justify-center" style={{ paddingTop: '18vh', paddingBottom: '12vh' }}>
|
| 85 |
<div className="text-center mb-12">
|
|
@@ -122,6 +128,7 @@ function App() {
|
|
| 122 |
<SettingsModal isOpen={settingsOpen} onClose={() => setSettingsOpen(false)} onSave={(config) => console.log('保存配置:', config)} />
|
| 123 |
<PromptsManager isOpen={promptsOpen} onClose={() => setPromptsOpen(false)} />
|
| 124 |
<DonationModal isOpen={donationOpen} onClose={() => setDonationOpen(false)} />
|
|
|
|
| 125 |
<AiModifyModal
|
| 126 |
isOpen={aiModifyOpen}
|
| 127 |
value={aiModifyInput}
|
|
|
|
| 8 |
import { SettingsModal } from './components/SettingsModal';
|
| 9 |
import { PromptsManager } from './components/PromptsManager';
|
| 10 |
import { DonationModal } from './components/DonationModal';
|
| 11 |
+
import { UsageDashboard } from './components/UsageDashboard';
|
| 12 |
import ManimCatLogo from './components/ManimCatLogo';
|
| 13 |
import { TopLeftActions } from './components/app/top-left-actions';
|
| 14 |
import { TopRightActions } from './components/app/top-right-actions';
|
|
|
|
| 20 |
const [settingsOpen, setSettingsOpen] = useState(false);
|
| 21 |
const [promptsOpen, setPromptsOpen] = useState(false);
|
| 22 |
const [donationOpen, setDonationOpen] = useState(false);
|
| 23 |
+
const [usageOpen, setUsageOpen] = useState(false);
|
| 24 |
const [aiModifyOpen, setAiModifyOpen] = useState(false);
|
| 25 |
const [aiModifyInput, setAiModifyInput] = useState('');
|
| 26 |
const [currentCode, setCurrentCode] = useState('');
|
|
|
|
| 81 |
return (
|
| 82 |
<div className="min-h-screen bg-bg-primary transition-colors duration-300 overflow-x-hidden">
|
| 83 |
<TopLeftActions onOpenDonation={() => setDonationOpen(true)} />
|
| 84 |
+
<TopRightActions
|
| 85 |
+
onOpenUsage={() => setUsageOpen(true)}
|
| 86 |
+
onOpenPrompts={() => setPromptsOpen(true)}
|
| 87 |
+
onOpenSettings={() => setSettingsOpen(true)}
|
| 88 |
+
/>
|
| 89 |
|
| 90 |
<div className="max-w-4xl mx-auto px-4 min-h-screen flex flex-col justify-center" style={{ paddingTop: '18vh', paddingBottom: '12vh' }}>
|
| 91 |
<div className="text-center mb-12">
|
|
|
|
| 128 |
<SettingsModal isOpen={settingsOpen} onClose={() => setSettingsOpen(false)} onSave={(config) => console.log('保存配置:', config)} />
|
| 129 |
<PromptsManager isOpen={promptsOpen} onClose={() => setPromptsOpen(false)} />
|
| 130 |
<DonationModal isOpen={donationOpen} onClose={() => setDonationOpen(false)} />
|
| 131 |
+
<UsageDashboard isOpen={usageOpen} onClose={() => setUsageOpen(false)} />
|
| 132 |
<AiModifyModal
|
| 133 |
isOpen={aiModifyOpen}
|
| 134 |
value={aiModifyInput}
|
frontend/src/components/UsageDashboard.tsx
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useMemo, useState } from 'react';
|
| 2 |
+
import { getUsageMetrics } from '../lib/api';
|
| 3 |
+
import type { UsageDailyPoint, UsageMetricsResponse } from '../types/api';
|
| 4 |
+
|
| 5 |
+
interface UsageDashboardProps {
|
| 6 |
+
isOpen: boolean;
|
| 7 |
+
onClose: () => void;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
const RANGE_OPTIONS = [7, 14, 30] as const;
|
| 11 |
+
const REFRESH_INTERVAL_MS = 30_000;
|
| 12 |
+
|
| 13 |
+
function formatNumber(value: number): string {
|
| 14 |
+
return new Intl.NumberFormat('zh-CN').format(Math.round(value));
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
function formatPercent(value: number): string {
|
| 18 |
+
return `${(value * 100).toFixed(1)}%`;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
function formatDuration(ms: number): string {
|
| 22 |
+
if (!Number.isFinite(ms) || ms <= 0) {
|
| 23 |
+
return '-';
|
| 24 |
+
}
|
| 25 |
+
if (ms >= 1000) {
|
| 26 |
+
return `${(ms / 1000).toFixed(1)}s`;
|
| 27 |
+
}
|
| 28 |
+
return `${Math.round(ms)}ms`;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function formatDateLabel(date: string): string {
|
| 32 |
+
const [year, month, day] = date.split('-');
|
| 33 |
+
if (!year || !month || !day) {
|
| 34 |
+
return date;
|
| 35 |
+
}
|
| 36 |
+
return `${month}/${day}`;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
function getMaxDailyValue(daily: UsageDailyPoint[]): number {
|
| 40 |
+
const maxValue = daily.reduce((max, item) => Math.max(max, item.submittedTotal), 0);
|
| 41 |
+
return maxValue > 0 ? maxValue : 1;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export function UsageDashboard({ isOpen, onClose }: UsageDashboardProps) {
|
| 45 |
+
const [shouldRender, setShouldRender] = useState(isOpen);
|
| 46 |
+
const [isVisible, setIsVisible] = useState(isOpen);
|
| 47 |
+
const [rangeDays, setRangeDays] = useState<(typeof RANGE_OPTIONS)[number]>(7);
|
| 48 |
+
const [data, setData] = useState<UsageMetricsResponse | null>(null);
|
| 49 |
+
const [loading, setLoading] = useState(false);
|
| 50 |
+
const [error, setError] = useState<string | null>(null);
|
| 51 |
+
|
| 52 |
+
useEffect(() => {
|
| 53 |
+
if (isOpen) {
|
| 54 |
+
setShouldRender(true);
|
| 55 |
+
setTimeout(() => setIsVisible(true), 50);
|
| 56 |
+
return;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
setIsVisible(false);
|
| 60 |
+
const timeout = setTimeout(() => setShouldRender(false), 250);
|
| 61 |
+
return () => clearTimeout(timeout);
|
| 62 |
+
}, [isOpen]);
|
| 63 |
+
|
| 64 |
+
useEffect(() => {
|
| 65 |
+
if (!isOpen) {
|
| 66 |
+
return;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
let active = true;
|
| 70 |
+
const controller = new AbortController();
|
| 71 |
+
|
| 72 |
+
const loadData = async () => {
|
| 73 |
+
if (!active) {
|
| 74 |
+
return;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
setLoading(true);
|
| 78 |
+
setError(null);
|
| 79 |
+
|
| 80 |
+
try {
|
| 81 |
+
const response = await getUsageMetrics(rangeDays, controller.signal);
|
| 82 |
+
if (active) {
|
| 83 |
+
setData(response);
|
| 84 |
+
}
|
| 85 |
+
} catch (err) {
|
| 86 |
+
if (!active) {
|
| 87 |
+
return;
|
| 88 |
+
}
|
| 89 |
+
if (err instanceof Error && err.name === 'AbortError') {
|
| 90 |
+
return;
|
| 91 |
+
}
|
| 92 |
+
setError(err instanceof Error ? err.message : '加载统计数据失败');
|
| 93 |
+
} finally {
|
| 94 |
+
if (active) {
|
| 95 |
+
setLoading(false);
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
void loadData();
|
| 101 |
+
const timer = window.setInterval(() => {
|
| 102 |
+
void loadData();
|
| 103 |
+
}, REFRESH_INTERVAL_MS);
|
| 104 |
+
|
| 105 |
+
return () => {
|
| 106 |
+
active = false;
|
| 107 |
+
controller.abort();
|
| 108 |
+
clearInterval(timer);
|
| 109 |
+
};
|
| 110 |
+
}, [isOpen, rangeDays]);
|
| 111 |
+
|
| 112 |
+
const chartRows = useMemo(() => data?.daily ?? [], [data]);
|
| 113 |
+
const maxDailyValue = useMemo(() => getMaxDailyValue(chartRows), [chartRows]);
|
| 114 |
+
const latestTenRows = useMemo(() => [...chartRows].reverse().slice(0, 10), [chartRows]);
|
| 115 |
+
|
| 116 |
+
if (!shouldRender) {
|
| 117 |
+
return null;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
return (
|
| 121 |
+
<div
|
| 122 |
+
className={`fixed inset-0 z-50 flex flex-col bg-bg-primary transition-all duration-300 ${
|
| 123 |
+
isVisible ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
| 124 |
+
}`}
|
| 125 |
+
>
|
| 126 |
+
<div className="h-14 bg-bg-secondary/50 border-b border-bg-tertiary/30 flex items-center justify-between px-4">
|
| 127 |
+
<div className="flex items-center gap-3">
|
| 128 |
+
<button
|
| 129 |
+
onClick={onClose}
|
| 130 |
+
className="p-2 text-text-secondary/70 hover:text-text-primary hover:bg-bg-tertiary/50 rounded-lg transition-colors"
|
| 131 |
+
aria-label="关闭用量面板"
|
| 132 |
+
>
|
| 133 |
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 134 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
| 135 |
+
</svg>
|
| 136 |
+
</button>
|
| 137 |
+
<div>
|
| 138 |
+
<p className="text-sm font-medium text-text-primary">用量面板</p>
|
| 139 |
+
<p className="text-[11px] text-text-secondary/60">每 30 秒自动刷新</p>
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
|
| 143 |
+
<div className="flex items-center gap-2">
|
| 144 |
+
{RANGE_OPTIONS.map((days) => (
|
| 145 |
+
<button
|
| 146 |
+
key={days}
|
| 147 |
+
onClick={() => setRangeDays(days)}
|
| 148 |
+
className={`px-3 py-1.5 text-xs rounded-lg transition-colors ${
|
| 149 |
+
rangeDays === days
|
| 150 |
+
? 'bg-accent text-white'
|
| 151 |
+
: 'bg-bg-secondary/50 text-text-secondary/80 hover:text-text-primary'
|
| 152 |
+
}`}
|
| 153 |
+
>
|
| 154 |
+
{days} 天
|
| 155 |
+
</button>
|
| 156 |
+
))}
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
|
| 160 |
+
<div className="flex-1 overflow-y-auto p-4 sm:p-6">
|
| 161 |
+
{loading && !data ? (
|
| 162 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 animate-pulse">
|
| 163 |
+
{Array.from({ length: 8 }).map((_, index) => (
|
| 164 |
+
<div key={index} className="rounded-2xl bg-bg-secondary/40 h-24" />
|
| 165 |
+
))}
|
| 166 |
+
</div>
|
| 167 |
+
) : null}
|
| 168 |
+
|
| 169 |
+
{error ? (
|
| 170 |
+
<div className="rounded-2xl bg-red-50/80 dark:bg-red-900/20 border border-red-200/50 dark:border-red-700/40 p-4 text-sm text-red-600 dark:text-red-300">
|
| 171 |
+
{error}
|
| 172 |
+
</div>
|
| 173 |
+
) : null}
|
| 174 |
+
|
| 175 |
+
{data ? (
|
| 176 |
+
<div className="space-y-5 animate-fade-in">
|
| 177 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
|
| 178 |
+
<MetricCard title="提交任务" value={formatNumber(data.totals.submittedTotal)} hint={`${data.rangeDays} 天累计`} />
|
| 179 |
+
<MetricCard title="成功任务" value={formatNumber(data.totals.completedTotal)} hint="渲染完成" />
|
| 180 |
+
<MetricCard title="成功率" value={formatPercent(data.totals.successRate)} hint="completed / submitted" />
|
| 181 |
+
<MetricCard title="平均渲染耗时" value={formatDuration(data.totals.avgRenderMs)} hint="仅统计成功任务" />
|
| 182 |
+
<MetricCard title="失败任务" value={formatNumber(data.totals.failedTotal)} hint={`取消 ${formatNumber(data.totals.cancelledTotal)} 次`} />
|
| 183 |
+
<MetricCard title="视频完成" value={formatNumber(data.totals.completedVideo)} hint="outputMode=video" />
|
| 184 |
+
<MetricCard title="图片完成" value={formatNumber(data.totals.completedImage)} hint="outputMode=image" />
|
| 185 |
+
<MetricCard title="队列积压" value={formatNumber(data.queue.waiting + data.queue.delayed)} hint={`处理中 ${formatNumber(data.queue.active)}`} />
|
| 186 |
+
</div>
|
| 187 |
+
|
| 188 |
+
<div className="rounded-2xl bg-bg-secondary/25 border border-bg-tertiary/30 p-4 sm:p-5">
|
| 189 |
+
<div className="flex items-center justify-between mb-4">
|
| 190 |
+
<h3 className="text-sm font-medium text-text-primary">日趋势</h3>
|
| 191 |
+
<p className="text-xs text-text-secondary/70">提交/成功(最近 {data.rangeDays} 天)</p>
|
| 192 |
+
</div>
|
| 193 |
+
|
| 194 |
+
<div className="h-52 sm:h-60 flex items-end gap-2 sm:gap-3 overflow-x-auto pb-2">
|
| 195 |
+
{chartRows.map((item) => {
|
| 196 |
+
const submittedHeight = Math.max((item.submittedTotal / maxDailyValue) * 100, item.submittedTotal > 0 ? 8 : 0);
|
| 197 |
+
const completedHeight = Math.max((item.completedTotal / maxDailyValue) * 100, item.completedTotal > 0 ? 6 : 0);
|
| 198 |
+
return (
|
| 199 |
+
<div key={item.date} className="relative min-w-[40px] sm:min-w-[48px] flex-1 flex flex-col items-center gap-1.5 group">
|
| 200 |
+
<div className="w-full h-40 sm:h-48 rounded-xl bg-bg-tertiary/20 relative overflow-hidden border border-bg-tertiary/30">
|
| 201 |
+
<div
|
| 202 |
+
className="absolute left-[22%] bottom-0 w-[22%] rounded-t-md bg-text-tertiary/45 transition-all"
|
| 203 |
+
style={{ height: `${submittedHeight}%` }}
|
| 204 |
+
/>
|
| 205 |
+
<div
|
| 206 |
+
className="absolute right-[22%] bottom-0 w-[22%] rounded-t-md bg-accent/85 transition-all"
|
| 207 |
+
style={{ height: `${completedHeight}%` }}
|
| 208 |
+
/>
|
| 209 |
+
</div>
|
| 210 |
+
<span className="text-[10px] text-text-secondary/70">{formatDateLabel(item.date)}</span>
|
| 211 |
+
<div className="absolute opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity -translate-y-20 bg-bg-secondary border border-bg-tertiary/40 text-[11px] text-text-secondary px-2 py-1 rounded-md shadow-md">
|
| 212 |
+
提交 {item.submittedTotal} / 成功 {item.completedTotal}
|
| 213 |
+
</div>
|
| 214 |
+
</div>
|
| 215 |
+
);
|
| 216 |
+
})}
|
| 217 |
+
</div>
|
| 218 |
+
</div>
|
| 219 |
+
|
| 220 |
+
<div className="rounded-2xl bg-bg-secondary/25 border border-bg-tertiary/30 overflow-hidden">
|
| 221 |
+
<div className="px-4 py-3 border-b border-bg-tertiary/30 flex items-center justify-between">
|
| 222 |
+
<h3 className="text-sm font-medium text-text-primary">最近 10 天</h3>
|
| 223 |
+
<p className="text-xs text-text-secondary/60">{new Date(data.timestamp).toLocaleString()}</p>
|
| 224 |
+
</div>
|
| 225 |
+
|
| 226 |
+
<div className="overflow-x-auto">
|
| 227 |
+
<table className="w-full text-sm">
|
| 228 |
+
<thead className="text-text-secondary/70 text-xs bg-bg-secondary/30">
|
| 229 |
+
<tr>
|
| 230 |
+
<th className="text-left px-4 py-2.5 font-medium">日期</th>
|
| 231 |
+
<th className="text-right px-4 py-2.5 font-medium">提交</th>
|
| 232 |
+
<th className="text-right px-4 py-2.5 font-medium">成功</th>
|
| 233 |
+
<th className="text-right px-4 py-2.5 font-medium">失败</th>
|
| 234 |
+
<th className="text-right px-4 py-2.5 font-medium">成功率</th>
|
| 235 |
+
<th className="text-right px-4 py-2.5 font-medium">平均耗时</th>
|
| 236 |
+
</tr>
|
| 237 |
+
</thead>
|
| 238 |
+
<tbody>
|
| 239 |
+
{latestTenRows.length === 0 ? (
|
| 240 |
+
<tr>
|
| 241 |
+
<td colSpan={6} className="px-4 py-6 text-center text-text-secondary/60 text-xs">
|
| 242 |
+
暂无数据
|
| 243 |
+
</td>
|
| 244 |
+
</tr>
|
| 245 |
+
) : (
|
| 246 |
+
latestTenRows.map((row) => (
|
| 247 |
+
<tr key={row.date} className="border-t border-bg-tertiary/20 hover:bg-bg-secondary/20 transition-colors">
|
| 248 |
+
<td className="px-4 py-2.5 text-text-primary">{row.date}</td>
|
| 249 |
+
<td className="px-4 py-2.5 text-right text-text-secondary">{formatNumber(row.submittedTotal)}</td>
|
| 250 |
+
<td className="px-4 py-2.5 text-right text-text-secondary">{formatNumber(row.completedTotal)}</td>
|
| 251 |
+
<td className="px-4 py-2.5 text-right text-text-secondary">{formatNumber(row.failedTotal)}</td>
|
| 252 |
+
<td className="px-4 py-2.5 text-right text-text-secondary">{formatPercent(row.successRate)}</td>
|
| 253 |
+
<td className="px-4 py-2.5 text-right text-text-secondary">{formatDuration(row.avgRenderMs)}</td>
|
| 254 |
+
</tr>
|
| 255 |
+
))
|
| 256 |
+
)}
|
| 257 |
+
</tbody>
|
| 258 |
+
</table>
|
| 259 |
+
</div>
|
| 260 |
+
</div>
|
| 261 |
+
</div>
|
| 262 |
+
) : null}
|
| 263 |
+
</div>
|
| 264 |
+
</div>
|
| 265 |
+
);
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
interface MetricCardProps {
|
| 269 |
+
title: string;
|
| 270 |
+
value: string;
|
| 271 |
+
hint: string;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
function MetricCard({ title, value, hint }: MetricCardProps) {
|
| 275 |
+
return (
|
| 276 |
+
<div className="rounded-2xl bg-bg-secondary/25 border border-bg-tertiary/30 px-4 py-3.5">
|
| 277 |
+
<p className="text-xs text-text-secondary/70">{title}</p>
|
| 278 |
+
<p className="mt-2 text-2xl font-medium text-text-primary tracking-tight">{value}</p>
|
| 279 |
+
<p className="mt-1 text-[11px] text-text-secondary/55">{hint}</p>
|
| 280 |
+
</div>
|
| 281 |
+
);
|
| 282 |
+
}
|
frontend/src/components/app/top-right-actions.tsx
CHANGED
|
@@ -3,11 +3,21 @@ import { ThemeToggle } from '../ThemeToggle';
|
|
| 3 |
interface TopRightActionsProps {
|
| 4 |
onOpenPrompts: () => void;
|
| 5 |
onOpenSettings: () => void;
|
|
|
|
| 6 |
}
|
| 7 |
|
| 8 |
-
export function TopRightActions({ onOpenPrompts, onOpenSettings }: TopRightActionsProps) {
|
| 9 |
return (
|
| 10 |
<div className="fixed top-4 right-4 z-50 flex items-center gap-2">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
<button
|
| 12 |
onClick={onOpenPrompts}
|
| 13 |
className="p-2.5 text-text-secondary/70 hover:text-text-secondary hover:bg-bg-secondary/50 rounded-full transition-all active:scale-90 active:duration-75"
|
|
|
|
| 3 |
interface TopRightActionsProps {
|
| 4 |
onOpenPrompts: () => void;
|
| 5 |
onOpenSettings: () => void;
|
| 6 |
+
onOpenUsage: () => void;
|
| 7 |
}
|
| 8 |
|
| 9 |
+
export function TopRightActions({ onOpenPrompts, onOpenSettings, onOpenUsage }: TopRightActionsProps) {
|
| 10 |
return (
|
| 11 |
<div className="fixed top-4 right-4 z-50 flex items-center gap-2">
|
| 12 |
+
<button
|
| 13 |
+
onClick={onOpenUsage}
|
| 14 |
+
className="p-2.5 text-text-secondary/70 hover:text-text-secondary hover:bg-bg-secondary/50 rounded-full transition-all active:scale-90 active:duration-75"
|
| 15 |
+
title="用量面板"
|
| 16 |
+
>
|
| 17 |
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 18 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 19h16M6 16V9m6 7V5m6 11v-4" />
|
| 19 |
+
</svg>
|
| 20 |
+
</button>
|
| 21 |
<button
|
| 22 |
onClick={onOpenPrompts}
|
| 23 |
className="p-2.5 text-text-secondary/70 hover:text-text-secondary hover:bg-bg-secondary/50 rounded-full transition-all active:scale-90 active:duration-75"
|
frontend/src/lib/api.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
| 1 |
-
import type {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import { loadSettings } from './settings';
|
| 3 |
|
| 4 |
const API_BASE = '/api';
|
|
@@ -94,3 +102,16 @@ export async function cancelJob(jobId: string): Promise<void> {
|
|
| 94 |
}
|
| 95 |
}
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type {
|
| 2 |
+
GenerateRequest,
|
| 3 |
+
GenerateResponse,
|
| 4 |
+
JobResult,
|
| 5 |
+
ApiError,
|
| 6 |
+
PromptDefaults,
|
| 7 |
+
ModifyRequest,
|
| 8 |
+
UsageMetricsResponse
|
| 9 |
+
} from '../types/api';
|
| 10 |
import { loadSettings } from './settings';
|
| 11 |
|
| 12 |
const API_BASE = '/api';
|
|
|
|
| 102 |
}
|
| 103 |
}
|
| 104 |
|
| 105 |
+
export async function getUsageMetrics(days = 7, signal?: AbortSignal): Promise<UsageMetricsResponse> {
|
| 106 |
+
const response = await fetch(`${API_BASE}/metrics/usage?days=${days}`, {
|
| 107 |
+
headers: getAuthHeaders(),
|
| 108 |
+
signal,
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
if (!response.ok) {
|
| 112 |
+
const error: ApiError = await response.json();
|
| 113 |
+
throw new Error(error.error || '获取用量统计失败');
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
return response.json();
|
| 117 |
+
}
|
frontend/src/types/api.ts
CHANGED
|
@@ -132,10 +132,56 @@ export interface JobResult {
|
|
| 132 |
|
| 133 |
error?: string;
|
| 134 |
details?: string;
|
| 135 |
-
cancel_reason?: string;
|
| 136 |
-
}
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
|
| 133 |
error?: string;
|
| 134 |
details?: string;
|
| 135 |
+
cancel_reason?: string;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
export interface QueueStats {
|
| 139 |
+
waiting: number;
|
| 140 |
+
active: number;
|
| 141 |
+
completed: number;
|
| 142 |
+
failed: number;
|
| 143 |
+
delayed: number;
|
| 144 |
+
total: number;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
export interface UsageDailyPoint {
|
| 148 |
+
date: string;
|
| 149 |
+
submittedTotal: number;
|
| 150 |
+
submittedGenerate: number;
|
| 151 |
+
submittedModify: number;
|
| 152 |
+
completedTotal: number;
|
| 153 |
+
failedTotal: number;
|
| 154 |
+
cancelledTotal: number;
|
| 155 |
+
completedVideo: number;
|
| 156 |
+
completedImage: number;
|
| 157 |
+
renderMsSum: number;
|
| 158 |
+
successRate: number;
|
| 159 |
+
avgRenderMs: number;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
export interface UsageTotals {
|
| 163 |
+
submittedTotal: number;
|
| 164 |
+
submittedGenerate: number;
|
| 165 |
+
submittedModify: number;
|
| 166 |
+
completedTotal: number;
|
| 167 |
+
failedTotal: number;
|
| 168 |
+
cancelledTotal: number;
|
| 169 |
+
completedVideo: number;
|
| 170 |
+
completedImage: number;
|
| 171 |
+
renderMsSum: number;
|
| 172 |
+
successRate: number;
|
| 173 |
+
avgRenderMs: number;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
export interface UsageMetricsResponse {
|
| 177 |
+
timestamp: string;
|
| 178 |
+
rangeDays: number;
|
| 179 |
+
daily: UsageDailyPoint[];
|
| 180 |
+
totals: UsageTotals;
|
| 181 |
+
queue: QueueStats;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
/** API 错误 */
|
| 185 |
+
export interface ApiError {
|
| 186 |
+
error: string;
|
| 187 |
+
}
|
src/middlewares/rate-limit.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextFunction, Request, Response } from 'express'
|
| 2 |
+
|
| 3 |
+
interface RateLimitOptions {
|
| 4 |
+
windowMs: number
|
| 5 |
+
maxRequests: number
|
| 6 |
+
message?: string
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
interface RateLimitEntry {
|
| 10 |
+
count: number
|
| 11 |
+
resetAt: number
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
function parseClientIp(req: Request): string {
|
| 15 |
+
const forwardedFor = req.headers['x-forwarded-for']
|
| 16 |
+
if (typeof forwardedFor === 'string' && forwardedFor.trim().length > 0) {
|
| 17 |
+
const firstIp = forwardedFor.split(',')[0]?.trim()
|
| 18 |
+
if (firstIp) {
|
| 19 |
+
return firstIp
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
if (Array.isArray(forwardedFor) && forwardedFor.length > 0) {
|
| 24 |
+
const firstIp = forwardedFor[0]?.trim()
|
| 25 |
+
if (firstIp) {
|
| 26 |
+
return firstIp
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
return req.ip || req.socket.remoteAddress || 'unknown'
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export function createIpRateLimiter(options: RateLimitOptions) {
|
| 34 |
+
const { windowMs, maxRequests, message = 'Too many requests, please try again later.' } = options
|
| 35 |
+
const counters = new Map<string, RateLimitEntry>()
|
| 36 |
+
const cleanupIntervalMs = Math.max(1000, Math.min(windowMs, 60_000))
|
| 37 |
+
let lastCleanupAt = 0
|
| 38 |
+
|
| 39 |
+
return (req: Request, res: Response, next: NextFunction): void => {
|
| 40 |
+
const now = Date.now()
|
| 41 |
+
|
| 42 |
+
if (now - lastCleanupAt >= cleanupIntervalMs) {
|
| 43 |
+
for (const [key, entry] of counters.entries()) {
|
| 44 |
+
if (entry.resetAt <= now) {
|
| 45 |
+
counters.delete(key)
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
lastCleanupAt = now
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
const clientIp = parseClientIp(req)
|
| 52 |
+
const current = counters.get(clientIp)
|
| 53 |
+
|
| 54 |
+
if (!current || current.resetAt <= now) {
|
| 55 |
+
counters.set(clientIp, { count: 1, resetAt: now + windowMs })
|
| 56 |
+
next()
|
| 57 |
+
return
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
if (current.count >= maxRequests) {
|
| 61 |
+
const retryAfterSeconds = Math.max(1, Math.ceil((current.resetAt - now) / 1000))
|
| 62 |
+
res.setHeader('Retry-After', String(retryAfterSeconds))
|
| 63 |
+
res.status(429).json({
|
| 64 |
+
error: 'Rate limit exceeded',
|
| 65 |
+
message,
|
| 66 |
+
retryAfterSeconds
|
| 67 |
+
})
|
| 68 |
+
return
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
current.count += 1
|
| 72 |
+
counters.set(clientIp, current)
|
| 73 |
+
next()
|
| 74 |
+
}
|
| 75 |
+
}
|
src/queues/processors/video.processor.ts
CHANGED
|
@@ -97,7 +97,7 @@ videoQueue.process(async (job) => {
|
|
| 97 |
|
| 98 |
await storeJobResult(jobId, {
|
| 99 |
status: 'failed',
|
| 100 |
-
data: { error: errorMessage, cancelReason }
|
| 101 |
})
|
| 102 |
await clearJobCancelled(jobId)
|
| 103 |
|
|
|
|
| 97 |
|
| 98 |
await storeJobResult(jobId, {
|
| 99 |
status: 'failed',
|
| 100 |
+
data: { error: errorMessage, cancelReason, outputMode }
|
| 101 |
})
|
| 102 |
await clearJobCancelled(jobId)
|
| 103 |
|
src/routes/generate.route.ts
CHANGED
|
@@ -14,6 +14,7 @@ import express from 'express'
|
|
| 14 |
import { v4 as uuidv4 } from 'uuid'
|
| 15 |
import { videoQueue } from '../config/bull'
|
| 16 |
import { storeJobStage } from '../services/job-store'
|
|
|
|
| 17 |
import { createLogger } from '../utils/logger'
|
| 18 |
import { ValidationError } from '../utils/errors'
|
| 19 |
import { asyncHandler } from '../middlewares/error-handler'
|
|
@@ -64,7 +65,7 @@ async function handleGenerateRequest(req: express.Request, res: express.Response
|
|
| 64 |
await storeJobStage(jobId, code ? 'rendering' : 'analyzing')
|
| 65 |
|
| 66 |
// 添加任务到 Bull 队列
|
| 67 |
-
await videoQueue.add(
|
| 68 |
{
|
| 69 |
jobId,
|
| 70 |
concept: sanitizedConcept,
|
|
@@ -80,9 +81,11 @@ async function handleGenerateRequest(req: express.Request, res: express.Response
|
|
| 80 |
{
|
| 81 |
jobId
|
| 82 |
}
|
| 83 |
-
)
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
| 86 |
|
| 87 |
const response: GenerateResponse = {
|
| 88 |
success: true,
|
|
|
|
| 14 |
import { v4 as uuidv4 } from 'uuid'
|
| 15 |
import { videoQueue } from '../config/bull'
|
| 16 |
import { storeJobStage } from '../services/job-store'
|
| 17 |
+
import { recordUsageSubmission } from '../services/usage-metrics'
|
| 18 |
import { createLogger } from '../utils/logger'
|
| 19 |
import { ValidationError } from '../utils/errors'
|
| 20 |
import { asyncHandler } from '../middlewares/error-handler'
|
|
|
|
| 65 |
await storeJobStage(jobId, code ? 'rendering' : 'analyzing')
|
| 66 |
|
| 67 |
// 添加任务到 Bull 队列
|
| 68 |
+
await videoQueue.add(
|
| 69 |
{
|
| 70 |
jobId,
|
| 71 |
concept: sanitizedConcept,
|
|
|
|
| 81 |
{
|
| 82 |
jobId
|
| 83 |
}
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
await recordUsageSubmission('generate', outputMode)
|
| 87 |
+
|
| 88 |
+
logger.info('动画请求已加入队列', { jobId })
|
| 89 |
|
| 90 |
const response: GenerateResponse = {
|
| 91 |
success: true,
|
src/routes/metrics.route.ts
CHANGED
|
@@ -14,9 +14,27 @@ import {
|
|
| 14 |
} from './metrics/memory-peak'
|
| 15 |
import { getCPUInfo, getRuntimeInfo, getSystemMemory } from './metrics/system-metrics'
|
| 16 |
import { getDiskUsage, getRedisMemory } from './metrics/storage-metrics'
|
|
|
|
|
|
|
| 17 |
|
| 18 |
const router = Router()
|
| 19 |
const logger = createLogger('Metrics')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
const stopMemorySampler = startMemoryPeakSampler()
|
| 22 |
void stopMemorySampler
|
|
@@ -71,4 +89,33 @@ router.post('/reset', (req: Request, res: Response) => {
|
|
| 71 |
})
|
| 72 |
})
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
export default router
|
|
|
|
| 14 |
} from './metrics/memory-peak'
|
| 15 |
import { getCPUInfo, getRuntimeInfo, getSystemMemory } from './metrics/system-metrics'
|
| 16 |
import { getDiskUsage, getRedisMemory } from './metrics/storage-metrics'
|
| 17 |
+
import { getUsageSummary, getUsageRetentionDays } from '../services/usage-metrics'
|
| 18 |
+
import { createIpRateLimiter } from '../middlewares/rate-limit'
|
| 19 |
|
| 20 |
const router = Router()
|
| 21 |
const logger = createLogger('Metrics')
|
| 22 |
+
const DEFAULT_USAGE_RATE_LIMIT_MAX = 30
|
| 23 |
+
const DEFAULT_USAGE_RATE_LIMIT_WINDOW_MS = 60_000
|
| 24 |
+
|
| 25 |
+
function parsePositiveInteger(input: string | undefined, fallback: number): number {
|
| 26 |
+
const parsed = Number(input)
|
| 27 |
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
| 28 |
+
return fallback
|
| 29 |
+
}
|
| 30 |
+
return Math.floor(parsed)
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const usageRateLimiter = createIpRateLimiter({
|
| 34 |
+
maxRequests: parsePositiveInteger(process.env.METRICS_USAGE_RATE_LIMIT_MAX, DEFAULT_USAGE_RATE_LIMIT_MAX),
|
| 35 |
+
windowMs: parsePositiveInteger(process.env.METRICS_USAGE_RATE_LIMIT_WINDOW_MS, DEFAULT_USAGE_RATE_LIMIT_WINDOW_MS),
|
| 36 |
+
message: '用量接口访问过于频繁,请稍后再试。'
|
| 37 |
+
})
|
| 38 |
|
| 39 |
const stopMemorySampler = startMemoryPeakSampler()
|
| 40 |
void stopMemorySampler
|
|
|
|
| 89 |
})
|
| 90 |
})
|
| 91 |
|
| 92 |
+
/**
|
| 93 |
+
* GET /api/metrics/usage?days=7
|
| 94 |
+
* 获取用量统计(按天聚合)
|
| 95 |
+
*/
|
| 96 |
+
router.get('/usage', usageRateLimiter, async (req: Request, res: Response) => {
|
| 97 |
+
try {
|
| 98 |
+
const queryDays = Array.isArray(req.query.days) ? req.query.days[0] : req.query.days
|
| 99 |
+
const parsedDays = Number.parseInt(String(queryDays || '7'), 10)
|
| 100 |
+
const retentionDays = getUsageRetentionDays()
|
| 101 |
+
const days = Number.isFinite(parsedDays)
|
| 102 |
+
? Math.min(Math.max(parsedDays, 1), retentionDays)
|
| 103 |
+
: Math.min(7, retentionDays)
|
| 104 |
+
|
| 105 |
+
const [usage, queue] = await Promise.all([getUsageSummary(days), getQueueStats()])
|
| 106 |
+
|
| 107 |
+
res.json({
|
| 108 |
+
timestamp: new Date().toISOString(),
|
| 109 |
+
...usage,
|
| 110 |
+
queue
|
| 111 |
+
})
|
| 112 |
+
} catch (error) {
|
| 113 |
+
logger.error('Failed to get usage metrics', { error })
|
| 114 |
+
res.status(500).json({
|
| 115 |
+
error: 'Failed to collect usage metrics',
|
| 116 |
+
message: error instanceof Error ? error.message : 'Unknown error'
|
| 117 |
+
})
|
| 118 |
+
}
|
| 119 |
+
})
|
| 120 |
+
|
| 121 |
export default router
|
src/routes/modify.route.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { z } from 'zod'
|
|
| 8 |
import { v4 as uuidv4 } from 'uuid'
|
| 9 |
import { videoQueue } from '../config/bull'
|
| 10 |
import { storeJobStage } from '../services/job-store'
|
|
|
|
| 11 |
import { createLogger } from '../utils/logger'
|
| 12 |
import { ValidationError } from '../utils/errors'
|
| 13 |
import { asyncHandler } from '../middlewares/error-handler'
|
|
@@ -83,6 +84,8 @@ async function handleModifyRequest(req: express.Request, res: express.Response)
|
|
| 83 |
{ jobId }
|
| 84 |
)
|
| 85 |
|
|
|
|
|
|
|
| 86 |
const response: GenerateResponse = {
|
| 87 |
success: true,
|
| 88 |
jobId,
|
|
|
|
| 8 |
import { v4 as uuidv4 } from 'uuid'
|
| 9 |
import { videoQueue } from '../config/bull'
|
| 10 |
import { storeJobStage } from '../services/job-store'
|
| 11 |
+
import { recordUsageSubmission } from '../services/usage-metrics'
|
| 12 |
import { createLogger } from '../utils/logger'
|
| 13 |
import { ValidationError } from '../utils/errors'
|
| 14 |
import { asyncHandler } from '../middlewares/error-handler'
|
|
|
|
| 84 |
{ jobId }
|
| 85 |
)
|
| 86 |
|
| 87 |
+
await recordUsageSubmission('modify', outputMode)
|
| 88 |
+
|
| 89 |
const response: GenerateResponse = {
|
| 90 |
success: true,
|
| 91 |
jobId,
|
src/services/job-cancel.ts
CHANGED
|
@@ -4,11 +4,12 @@
|
|
| 4 |
*/
|
| 5 |
|
| 6 |
import { videoQueue } from '../config/bull'
|
| 7 |
-
import { createLogger } from '../utils/logger'
|
| 8 |
-
import { JobCancelledError } from '../utils/errors'
|
| 9 |
-
import { clearJobCancelled, getCancelReason, isJobCancelled, markJobCancelled } from './job-cancel-store'
|
| 10 |
-
import { cancelManimProcess } from '../utils/manim-process-registry'
|
| 11 |
-
import { deleteJobStage, getJobResult, storeJobResult } from './job-store'
|
|
|
|
| 12 |
|
| 13 |
const logger = createLogger('JobCancel')
|
| 14 |
export async function ensureJobNotCancelled(jobId: string, job?: { discard: () => void }): Promise<void> {
|
|
@@ -26,20 +27,26 @@ export async function ensureJobNotCancelled(jobId: string, job?: { discard: () =
|
|
| 26 |
throw new JobCancelledError('Job cancelled', reason || undefined)
|
| 27 |
}
|
| 28 |
|
| 29 |
-
export async function cancelJob(jobId: string): Promise<{ jobState: string | null }> {
|
| 30 |
-
const existing = await getJobResult(jobId)
|
| 31 |
-
if (existing?.status === 'completed') {
|
| 32 |
-
return { jobState: 'completed' }
|
| 33 |
-
}
|
| 34 |
|
| 35 |
const cancelReason = 'Cancelled by client'
|
| 36 |
await markJobCancelled(jobId, cancelReason)
|
| 37 |
|
| 38 |
-
let jobState: string | null = null
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
if (jobState === 'waiting' || jobState === 'delayed') {
|
| 45 |
await job.remove()
|
|
@@ -55,12 +62,12 @@ export async function cancelJob(jobId: string): Promise<{ jobState: string | nul
|
|
| 55 |
await clearJobCancelled(jobId)
|
| 56 |
}
|
| 57 |
|
| 58 |
-
if (!existing || existing.status != 'failed') {
|
| 59 |
-
await storeJobResult(jobId, {
|
| 60 |
-
status: 'failed',
|
| 61 |
-
data: { error: 'Job cancelled', cancelReason }
|
| 62 |
-
})
|
| 63 |
-
}
|
| 64 |
|
| 65 |
await deleteJobStage(jobId)
|
| 66 |
|
|
|
|
| 4 |
*/
|
| 5 |
|
| 6 |
import { videoQueue } from '../config/bull'
|
| 7 |
+
import { createLogger } from '../utils/logger'
|
| 8 |
+
import { JobCancelledError } from '../utils/errors'
|
| 9 |
+
import { clearJobCancelled, getCancelReason, isJobCancelled, markJobCancelled } from './job-cancel-store'
|
| 10 |
+
import { cancelManimProcess } from '../utils/manim-process-registry'
|
| 11 |
+
import { deleteJobStage, getJobResult, storeJobResult } from './job-store'
|
| 12 |
+
import type { OutputMode } from '../types'
|
| 13 |
|
| 14 |
const logger = createLogger('JobCancel')
|
| 15 |
export async function ensureJobNotCancelled(jobId: string, job?: { discard: () => void }): Promise<void> {
|
|
|
|
| 27 |
throw new JobCancelledError('Job cancelled', reason || undefined)
|
| 28 |
}
|
| 29 |
|
| 30 |
+
export async function cancelJob(jobId: string): Promise<{ jobState: string | null }> {
|
| 31 |
+
const existing = await getJobResult(jobId)
|
| 32 |
+
if (existing?.status === 'completed') {
|
| 33 |
+
return { jobState: 'completed' }
|
| 34 |
+
}
|
| 35 |
|
| 36 |
const cancelReason = 'Cancelled by client'
|
| 37 |
await markJobCancelled(jobId, cancelReason)
|
| 38 |
|
| 39 |
+
let jobState: string | null = null
|
| 40 |
+
let outputMode: OutputMode | undefined =
|
| 41 |
+
existing?.status === 'failed' ? existing.data.outputMode : undefined
|
| 42 |
+
const job = await videoQueue.getJob(jobId)
|
| 43 |
+
|
| 44 |
+
if (job) {
|
| 45 |
+
jobState = await job.getState()
|
| 46 |
+
const queueOutputMode = (job.data as { outputMode?: OutputMode } | undefined)?.outputMode
|
| 47 |
+
if (queueOutputMode) {
|
| 48 |
+
outputMode = queueOutputMode
|
| 49 |
+
}
|
| 50 |
|
| 51 |
if (jobState === 'waiting' || jobState === 'delayed') {
|
| 52 |
await job.remove()
|
|
|
|
| 62 |
await clearJobCancelled(jobId)
|
| 63 |
}
|
| 64 |
|
| 65 |
+
if (!existing || existing.status != 'failed') {
|
| 66 |
+
await storeJobResult(jobId, {
|
| 67 |
+
status: 'failed',
|
| 68 |
+
data: { error: 'Job cancelled', cancelReason, outputMode }
|
| 69 |
+
})
|
| 70 |
+
}
|
| 71 |
|
| 72 |
await deleteJobStage(jobId)
|
| 73 |
|
src/services/job-store.ts
CHANGED
|
@@ -10,22 +10,41 @@
|
|
| 10 |
import { redisClient, REDIS_KEYS, generateRedisKey } from '../config/redis'
|
| 11 |
import { videoQueue } from '../config/bull'
|
| 12 |
import { getCancelReason, isJobCancelled } from './job-cancel-store'
|
|
|
|
| 13 |
import { JobCancelledError } from '../utils/errors'
|
| 14 |
import { createLogger } from '../utils/logger'
|
| 15 |
-
import type { JobResult, ProcessingStage } from '../types'
|
| 16 |
|
| 17 |
const logger = createLogger('JobStore')
|
| 18 |
|
| 19 |
-
const JOB_RESULTS_GROUP = 'job-results'
|
| 20 |
-
const JOB_RESULT_KEY_PREFIX = `${REDIS_KEYS.JOB_RESULT}`
|
| 21 |
-
const JOB_STAGE_KEY_PREFIX = `${REDIS_KEYS.JOB_RESULT}:stage`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
/**
|
| 24 |
* 使用 Redis 存储任务结果
|
| 25 |
*/
|
| 26 |
export async function storeJobResult(
|
| 27 |
jobId: string,
|
| 28 |
-
result:
|
| 29 |
): Promise<void> {
|
| 30 |
if (result.status === 'completed' && (await isJobCancelled(jobId))) {
|
| 31 |
const reason = await getCancelReason(jobId)
|
|
@@ -39,14 +58,26 @@ export async function storeJobResult(
|
|
| 39 |
timestamp: Date.now()
|
| 40 |
}
|
| 41 |
|
| 42 |
-
try {
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
await redisClient.expire(key,
|
| 46 |
-
logger.info('任务结果已存储', { jobId, status: result.status })
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
}
|
| 51 |
}
|
| 52 |
|
|
@@ -141,18 +172,18 @@ export async function getAllJobResults(): Promise<Array<{ jobId: string; result:
|
|
| 141 |
/**
|
| 142 |
* 存储任务处理阶段
|
| 143 |
*/
|
| 144 |
-
export async function storeJobStage(
|
| 145 |
-
jobId: string,
|
| 146 |
-
stage: ProcessingStage
|
| 147 |
-
): Promise<void> {
|
| 148 |
const key = generateRedisKey(JOB_STAGE_KEY_PREFIX, jobId)
|
| 149 |
|
| 150 |
-
try {
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
await redisClient.expire(key,
|
| 154 |
-
logger.debug('任务阶段已存储', { jobId, stage })
|
| 155 |
-
} catch (error) {
|
| 156 |
logger.error('存储任务阶段失败', { jobId, error })
|
| 157 |
throw error
|
| 158 |
}
|
|
|
|
| 10 |
import { redisClient, REDIS_KEYS, generateRedisKey } from '../config/redis'
|
| 11 |
import { videoQueue } from '../config/bull'
|
| 12 |
import { getCancelReason, isJobCancelled } from './job-cancel-store'
|
| 13 |
+
import { recordUsageFinalization } from './usage-metrics'
|
| 14 |
import { JobCancelledError } from '../utils/errors'
|
| 15 |
import { createLogger } from '../utils/logger'
|
| 16 |
+
import type { CompletedJobResult, FailedJobResult, JobResult, ProcessingStage } from '../types'
|
| 17 |
|
| 18 |
const logger = createLogger('JobStore')
|
| 19 |
|
| 20 |
+
const JOB_RESULTS_GROUP = 'job-results'
|
| 21 |
+
const JOB_RESULT_KEY_PREFIX = `${REDIS_KEYS.JOB_RESULT}`
|
| 22 |
+
const JOB_STAGE_KEY_PREFIX = `${REDIS_KEYS.JOB_RESULT}:stage`
|
| 23 |
+
const DEFAULT_JOB_RESULT_RETENTION_HOURS = 24
|
| 24 |
+
type StorableJobResult = Pick<CompletedJobResult, 'status' | 'data'> | Pick<FailedJobResult, 'status' | 'data'>
|
| 25 |
+
|
| 26 |
+
function parsePositiveInteger(input: string | undefined, fallback: number): number {
|
| 27 |
+
const value = Number(input)
|
| 28 |
+
if (!Number.isFinite(value) || value <= 0) {
|
| 29 |
+
return fallback
|
| 30 |
+
}
|
| 31 |
+
return Math.floor(value)
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function getJobResultRetentionSeconds(): number {
|
| 35 |
+
const retentionHours = parsePositiveInteger(
|
| 36 |
+
process.env.JOB_RESULT_RETENTION_HOURS,
|
| 37 |
+
DEFAULT_JOB_RESULT_RETENTION_HOURS
|
| 38 |
+
)
|
| 39 |
+
return retentionHours * 60 * 60
|
| 40 |
+
}
|
| 41 |
|
| 42 |
/**
|
| 43 |
* 使用 Redis 存储任务结果
|
| 44 |
*/
|
| 45 |
export async function storeJobResult(
|
| 46 |
jobId: string,
|
| 47 |
+
result: StorableJobResult
|
| 48 |
): Promise<void> {
|
| 49 |
if (result.status === 'completed' && (await isJobCancelled(jobId))) {
|
| 50 |
const reason = await getCancelReason(jobId)
|
|
|
|
| 58 |
timestamp: Date.now()
|
| 59 |
}
|
| 60 |
|
| 61 |
+
try {
|
| 62 |
+
const retentionSeconds = getJobResultRetentionSeconds()
|
| 63 |
+
await redisClient.set(key, JSON.stringify(data))
|
| 64 |
+
await redisClient.expire(key, retentionSeconds)
|
| 65 |
+
logger.info('任务结果已存储', { jobId, status: result.status })
|
| 66 |
+
|
| 67 |
+
const isCancelled = result.status === 'failed' ? Boolean(result.data.cancelReason) : false
|
| 68 |
+
const renderMs = result.status === 'completed' ? result.data.timings?.total : undefined
|
| 69 |
+
const outputMode = result.data.outputMode
|
| 70 |
+
|
| 71 |
+
await recordUsageFinalization({
|
| 72 |
+
jobId,
|
| 73 |
+
status: result.status,
|
| 74 |
+
outputMode,
|
| 75 |
+
isCancelled,
|
| 76 |
+
renderMs
|
| 77 |
+
})
|
| 78 |
+
} catch (error) {
|
| 79 |
+
logger.error('存储任务结果失败', { jobId, error })
|
| 80 |
+
throw error
|
| 81 |
}
|
| 82 |
}
|
| 83 |
|
|
|
|
| 172 |
/**
|
| 173 |
* 存储任务处理阶段
|
| 174 |
*/
|
| 175 |
+
export async function storeJobStage(
|
| 176 |
+
jobId: string,
|
| 177 |
+
stage: ProcessingStage
|
| 178 |
+
): Promise<void> {
|
| 179 |
const key = generateRedisKey(JOB_STAGE_KEY_PREFIX, jobId)
|
| 180 |
|
| 181 |
+
try {
|
| 182 |
+
const retentionSeconds = getJobResultRetentionSeconds()
|
| 183 |
+
await redisClient.set(key, stage)
|
| 184 |
+
await redisClient.expire(key, retentionSeconds)
|
| 185 |
+
logger.debug('任务阶段已存储', { jobId, stage })
|
| 186 |
+
} catch (error) {
|
| 187 |
logger.error('存储任务阶段失败', { jobId, error })
|
| 188 |
throw error
|
| 189 |
}
|
src/services/usage-metrics.ts
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { redisClient } from '../config/redis'
|
| 2 |
+
import type { OutputMode } from '../types'
|
| 3 |
+
import { createLogger } from '../utils/logger'
|
| 4 |
+
|
| 5 |
+
const logger = createLogger('UsageMetrics')
|
| 6 |
+
|
| 7 |
+
const USAGE_DAILY_KEY_PREFIX = 'usage:daily:'
|
| 8 |
+
const USAGE_FINALIZED_MARK_KEY_PREFIX = 'usage:finalized:'
|
| 9 |
+
|
| 10 |
+
const DEFAULT_USAGE_RETENTION_DAYS = 90
|
| 11 |
+
|
| 12 |
+
const DAILY_FIELDS = [
|
| 13 |
+
'submitted_total',
|
| 14 |
+
'submitted_generate',
|
| 15 |
+
'submitted_modify',
|
| 16 |
+
'completed_total',
|
| 17 |
+
'failed_total',
|
| 18 |
+
'cancelled_total',
|
| 19 |
+
'completed_video',
|
| 20 |
+
'completed_image',
|
| 21 |
+
'render_ms_sum'
|
| 22 |
+
] as const
|
| 23 |
+
|
| 24 |
+
type DailyField = (typeof DAILY_FIELDS)[number]
|
| 25 |
+
|
| 26 |
+
type DailyCounters = Record<DailyField, number>
|
| 27 |
+
|
| 28 |
+
export interface UsageDailyPoint {
|
| 29 |
+
date: string
|
| 30 |
+
submittedTotal: number
|
| 31 |
+
submittedGenerate: number
|
| 32 |
+
submittedModify: number
|
| 33 |
+
completedTotal: number
|
| 34 |
+
failedTotal: number
|
| 35 |
+
cancelledTotal: number
|
| 36 |
+
completedVideo: number
|
| 37 |
+
completedImage: number
|
| 38 |
+
renderMsSum: number
|
| 39 |
+
successRate: number
|
| 40 |
+
avgRenderMs: number
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export interface UsageSummary {
|
| 44 |
+
rangeDays: number
|
| 45 |
+
daily: UsageDailyPoint[]
|
| 46 |
+
totals: {
|
| 47 |
+
submittedTotal: number
|
| 48 |
+
submittedGenerate: number
|
| 49 |
+
submittedModify: number
|
| 50 |
+
completedTotal: number
|
| 51 |
+
failedTotal: number
|
| 52 |
+
cancelledTotal: number
|
| 53 |
+
completedVideo: number
|
| 54 |
+
completedImage: number
|
| 55 |
+
renderMsSum: number
|
| 56 |
+
successRate: number
|
| 57 |
+
avgRenderMs: number
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
function parsePositiveInteger(input: string | undefined, fallback: number): number {
|
| 62 |
+
const parsed = Number(input)
|
| 63 |
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
| 64 |
+
return fallback
|
| 65 |
+
}
|
| 66 |
+
return Math.floor(parsed)
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
export function getUsageRetentionDays(): number {
|
| 70 |
+
return parsePositiveInteger(process.env.USAGE_RETENTION_DAYS, DEFAULT_USAGE_RETENTION_DAYS)
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
function getUsageRetentionSeconds(): number {
|
| 74 |
+
return getUsageRetentionDays() * 24 * 60 * 60
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
function getUtcDateString(date: Date): string {
|
| 78 |
+
return date.toISOString().slice(0, 10)
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
function getDailyKey(dateString: string): string {
|
| 82 |
+
return `${USAGE_DAILY_KEY_PREFIX}${dateString}`
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function parseCounter(value: string | null): number {
|
| 86 |
+
if (!value) {
|
| 87 |
+
return 0
|
| 88 |
+
}
|
| 89 |
+
const parsed = Number.parseInt(value, 10)
|
| 90 |
+
if (!Number.isFinite(parsed)) {
|
| 91 |
+
return 0
|
| 92 |
+
}
|
| 93 |
+
return parsed
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
function buildDailyPoint(date: string, counters: DailyCounters): UsageDailyPoint {
|
| 97 |
+
const submittedTotal = counters.submitted_total
|
| 98 |
+
const completedTotal = counters.completed_total
|
| 99 |
+
const successRate = submittedTotal > 0 ? completedTotal / submittedTotal : 0
|
| 100 |
+
const avgRenderMs = completedTotal > 0 ? counters.render_ms_sum / completedTotal : 0
|
| 101 |
+
|
| 102 |
+
return {
|
| 103 |
+
date,
|
| 104 |
+
submittedTotal,
|
| 105 |
+
submittedGenerate: counters.submitted_generate,
|
| 106 |
+
submittedModify: counters.submitted_modify,
|
| 107 |
+
completedTotal,
|
| 108 |
+
failedTotal: counters.failed_total,
|
| 109 |
+
cancelledTotal: counters.cancelled_total,
|
| 110 |
+
completedVideo: counters.completed_video,
|
| 111 |
+
completedImage: counters.completed_image,
|
| 112 |
+
renderMsSum: counters.render_ms_sum,
|
| 113 |
+
successRate,
|
| 114 |
+
avgRenderMs
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
async function incrementDailyCounters(dateString: string, counters: Partial<DailyCounters>): Promise<void> {
|
| 119 |
+
const key = getDailyKey(dateString)
|
| 120 |
+
const retentionSeconds = getUsageRetentionSeconds()
|
| 121 |
+
const tx = redisClient.multi()
|
| 122 |
+
|
| 123 |
+
for (const field of DAILY_FIELDS) {
|
| 124 |
+
const increment = counters[field]
|
| 125 |
+
if (!increment) {
|
| 126 |
+
continue
|
| 127 |
+
}
|
| 128 |
+
tx.hincrby(key, field, Math.floor(increment))
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
tx.expire(key, retentionSeconds)
|
| 132 |
+
await tx.exec()
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
export async function recordUsageSubmission(
|
| 136 |
+
source: 'generate' | 'modify',
|
| 137 |
+
_outputMode: OutputMode
|
| 138 |
+
): Promise<void> {
|
| 139 |
+
const dateString = getUtcDateString(new Date())
|
| 140 |
+
const counters: Partial<DailyCounters> = {
|
| 141 |
+
submitted_total: 1
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
if (source === 'generate') {
|
| 145 |
+
counters.submitted_generate = 1
|
| 146 |
+
} else {
|
| 147 |
+
counters.submitted_modify = 1
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
try {
|
| 151 |
+
await incrementDailyCounters(dateString, counters)
|
| 152 |
+
} catch (error) {
|
| 153 |
+
logger.warn('记录任务提交用量失败', { source, error: String(error) })
|
| 154 |
+
}
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
export async function recordUsageFinalization(args: {
|
| 158 |
+
jobId: string
|
| 159 |
+
status: 'completed' | 'failed'
|
| 160 |
+
outputMode?: OutputMode
|
| 161 |
+
isCancelled?: boolean
|
| 162 |
+
renderMs?: number
|
| 163 |
+
}): Promise<void> {
|
| 164 |
+
const { jobId, status, outputMode, isCancelled = false, renderMs } = args
|
| 165 |
+
const retentionSeconds = getUsageRetentionSeconds()
|
| 166 |
+
const markKey = `${USAGE_FINALIZED_MARK_KEY_PREFIX}${jobId}`
|
| 167 |
+
|
| 168 |
+
try {
|
| 169 |
+
const markResult = await redisClient.set(markKey, '1', 'EX', retentionSeconds, 'NX')
|
| 170 |
+
if (markResult !== 'OK') {
|
| 171 |
+
return
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
const dateString = getUtcDateString(new Date())
|
| 175 |
+
const counters: Partial<DailyCounters> = {}
|
| 176 |
+
|
| 177 |
+
if (status === 'completed') {
|
| 178 |
+
counters.completed_total = 1
|
| 179 |
+
if (outputMode === 'video') {
|
| 180 |
+
counters.completed_video = 1
|
| 181 |
+
} else if (outputMode === 'image') {
|
| 182 |
+
counters.completed_image = 1
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
if (typeof renderMs === 'number' && Number.isFinite(renderMs) && renderMs > 0) {
|
| 186 |
+
counters.render_ms_sum = Math.round(renderMs)
|
| 187 |
+
}
|
| 188 |
+
} else {
|
| 189 |
+
counters.failed_total = 1
|
| 190 |
+
if (isCancelled) {
|
| 191 |
+
counters.cancelled_total = 1
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
await incrementDailyCounters(dateString, counters)
|
| 196 |
+
} catch (error) {
|
| 197 |
+
logger.warn('记录任务完成用量失败', {
|
| 198 |
+
jobId,
|
| 199 |
+
status,
|
| 200 |
+
outputMode,
|
| 201 |
+
isCancelled,
|
| 202 |
+
error: String(error)
|
| 203 |
+
})
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
function clampRangeDays(input: number): number {
|
| 208 |
+
const retentionDays = getUsageRetentionDays()
|
| 209 |
+
if (!Number.isFinite(input) || input <= 0) {
|
| 210 |
+
return Math.min(7, retentionDays)
|
| 211 |
+
}
|
| 212 |
+
return Math.min(Math.floor(input), retentionDays)
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
function createDateWindow(rangeDays: number): string[] {
|
| 216 |
+
const now = new Date()
|
| 217 |
+
const todayUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
|
| 218 |
+
const dates: string[] = []
|
| 219 |
+
|
| 220 |
+
for (let offset = rangeDays - 1; offset >= 0; offset -= 1) {
|
| 221 |
+
const target = new Date(todayUtc)
|
| 222 |
+
target.setUTCDate(todayUtc.getUTCDate() - offset)
|
| 223 |
+
dates.push(getUtcDateString(target))
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
return dates
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
function getEmptyCounters(): DailyCounters {
|
| 230 |
+
return {
|
| 231 |
+
submitted_total: 0,
|
| 232 |
+
submitted_generate: 0,
|
| 233 |
+
submitted_modify: 0,
|
| 234 |
+
completed_total: 0,
|
| 235 |
+
failed_total: 0,
|
| 236 |
+
cancelled_total: 0,
|
| 237 |
+
completed_video: 0,
|
| 238 |
+
completed_image: 0,
|
| 239 |
+
render_ms_sum: 0
|
| 240 |
+
}
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
export async function getUsageSummary(days: number): Promise<UsageSummary> {
|
| 244 |
+
const rangeDays = clampRangeDays(days)
|
| 245 |
+
const dates = createDateWindow(rangeDays)
|
| 246 |
+
|
| 247 |
+
const pipeline = redisClient.pipeline()
|
| 248 |
+
for (const date of dates) {
|
| 249 |
+
pipeline.hmget(getDailyKey(date), ...DAILY_FIELDS)
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
const responses = await pipeline.exec()
|
| 253 |
+
const daily = dates.map((date, index) => {
|
| 254 |
+
const entry = responses?.[index]
|
| 255 |
+
const counters = getEmptyCounters()
|
| 256 |
+
const values = Array.isArray(entry?.[1]) ? (entry[1] as Array<string | null>) : []
|
| 257 |
+
|
| 258 |
+
DAILY_FIELDS.forEach((field, fieldIndex) => {
|
| 259 |
+
counters[field] = parseCounter(values[fieldIndex] ?? null)
|
| 260 |
+
})
|
| 261 |
+
|
| 262 |
+
return buildDailyPoint(date, counters)
|
| 263 |
+
})
|
| 264 |
+
|
| 265 |
+
const totals = daily.reduce(
|
| 266 |
+
(acc, current) => {
|
| 267 |
+
acc.submittedTotal += current.submittedTotal
|
| 268 |
+
acc.submittedGenerate += current.submittedGenerate
|
| 269 |
+
acc.submittedModify += current.submittedModify
|
| 270 |
+
acc.completedTotal += current.completedTotal
|
| 271 |
+
acc.failedTotal += current.failedTotal
|
| 272 |
+
acc.cancelledTotal += current.cancelledTotal
|
| 273 |
+
acc.completedVideo += current.completedVideo
|
| 274 |
+
acc.completedImage += current.completedImage
|
| 275 |
+
acc.renderMsSum += current.renderMsSum
|
| 276 |
+
return acc
|
| 277 |
+
},
|
| 278 |
+
{
|
| 279 |
+
submittedTotal: 0,
|
| 280 |
+
submittedGenerate: 0,
|
| 281 |
+
submittedModify: 0,
|
| 282 |
+
completedTotal: 0,
|
| 283 |
+
failedTotal: 0,
|
| 284 |
+
cancelledTotal: 0,
|
| 285 |
+
completedVideo: 0,
|
| 286 |
+
completedImage: 0,
|
| 287 |
+
renderMsSum: 0,
|
| 288 |
+
successRate: 0,
|
| 289 |
+
avgRenderMs: 0
|
| 290 |
+
}
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
totals.successRate = totals.submittedTotal > 0 ? totals.completedTotal / totals.submittedTotal : 0
|
| 294 |
+
totals.avgRenderMs = totals.completedTotal > 0 ? totals.renderMsSum / totals.completedTotal : 0
|
| 295 |
+
|
| 296 |
+
return {
|
| 297 |
+
rangeDays,
|
| 298 |
+
daily,
|
| 299 |
+
totals
|
| 300 |
+
}
|
| 301 |
+
}
|
src/types/index.ts
CHANGED
|
@@ -119,15 +119,16 @@ export interface CompletedJobResult {
|
|
| 119 |
/**
|
| 120 |
* 浠诲姟缁撴灉 - 澶辫触鐘舵€?
|
| 121 |
*/
|
| 122 |
-
export interface FailedJobResult {
|
| 123 |
-
status: 'failed'
|
| 124 |
-
data: {
|
| 125 |
-
error: string
|
| 126 |
-
details?: string
|
| 127 |
-
cancelReason?: string
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
|
|
|
| 131 |
|
| 132 |
/**
|
| 133 |
* 浠诲姟缁撴灉鑱斿悎绫诲瀷
|
|
|
|
| 119 |
/**
|
| 120 |
* 浠诲姟缁撴灉 - 澶辫触鐘舵€?
|
| 121 |
*/
|
| 122 |
+
export interface FailedJobResult {
|
| 123 |
+
status: 'failed'
|
| 124 |
+
data: {
|
| 125 |
+
error: string
|
| 126 |
+
details?: string
|
| 127 |
+
cancelReason?: string
|
| 128 |
+
outputMode?: OutputMode
|
| 129 |
+
}
|
| 130 |
+
timestamp: number
|
| 131 |
+
}
|
| 132 |
|
| 133 |
/**
|
| 134 |
* 浠诲姟缁撴灉鑱斿悎绫诲瀷
|