Bin29 commited on
Commit
f6cb378
·
1 Parent(s): 4cb64fb

feat: add usage dashboard and metrics usage rate limiting

Browse files
.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 onOpenPrompts={() => setPromptsOpen(true)} onOpenSettings={() => setSettingsOpen(true)} />
 
 
 
 
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 { GenerateRequest, GenerateResponse, JobResult, ApiError, PromptDefaults, ModifyRequest } from '../types/api';
 
 
 
 
 
 
 
 
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
- /** API 错误 */
139
- export interface ApiError {
140
- error: string;
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
- logger.info('动画请求已加入队列', { jobId })
 
 
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
- const job = await videoQueue.getJob(jobId)
40
-
41
- if (job) {
42
- jobState = await job.getState()
 
 
 
 
 
 
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: Omit<JobResult, 'timestamp'>
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
- await redisClient.set(key, JSON.stringify(data))
44
- // 设置过期时间:7 天后自动清理
45
- await redisClient.expire(key, 7 * 24 * 60 * 60)
46
- logger.info('任务结果已存储', { jobId, status: result.status })
47
- } catch (error) {
48
- logger.error('存储任务结果失败', { jobId, error })
49
- throw error
 
 
 
 
 
 
 
 
 
 
 
 
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
- await redisClient.set(key, stage)
152
- // 设置过期时间:与 job result 相同,7 天后自动清理
153
- await redisClient.expire(key, 7 * 24 * 60 * 60)
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
- timestamp: number
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
  * 浠诲姟缁撴灉鑱斿悎绫诲瀷