Skip to content

Commit 2ba57e0

Browse files
authored
Merge pull request #37 from solar2ain/feat/backend-and-webui-enhancements
feat: 后端增强与WebUI请求模型功能
2 parents 73b7388 + 38f9fd3 commit 2ba57e0

11 files changed

Lines changed: 2477 additions & 78 deletions

File tree

‎src/backend/utils/download.js‎

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,37 +3,61 @@
33
* @description 图片下载与 Base64 转换
44
*/
55

6+
import { logger } from '../../utils/logger.js';
7+
8+
/**
9+
* 判断错误是否可重试
10+
* @param {string} message - 错误消息
11+
* @returns {boolean}
12+
*/
13+
function isRetryableError(message) {
14+
return /timeout|network|econnreset|econnrefused|etimedout|disconnected|tls|socket/i.test(message);
15+
}
16+
617
/**
718
* 使用页面上下文下载图片并转换为 Base64
819
* 自动继承页面的 Cookie 和 Session,解决鉴权问题
920
* @param {string} url - 图片 URL
1021
* @param {import('playwright-core').Page} page - Playwright 页面对象
1122
* @param {object} [options] - 可选配置
1223
* @param {number} [options.timeout=60000] - 超时时间(毫秒)
13-
* @param {number} [options.retries=0] - 下载失败时的重试次数
14-
* @returns {Promise<{ image?: string, error?: string }>} 下载结果
24+
* @param {number} [options.retries=3] - 最大重试次数
25+
* @param {number} [options.retryDelay=1000] - 重试延迟基数(毫秒)
26+
* @returns {Promise<{ image?: string, imageUrl?: string, error?: string }>} 下载结果(包含原始 URL)
1527
*/
1628
export async function useContextDownload(url, page, options = {}) {
17-
const { timeout = 60000, retries = 0 } = options;
29+
const { timeout = 120000, retries = 3, retryDelay = 1000 } = options;
1830

19-
for (let attempt = 0; attempt <= retries; attempt++) {
31+
for (let attempt = 1; attempt <= retries; attempt++) {
2032
try {
2133
const response = await page.request.get(url, { timeout });
2234

2335
if (!response.ok()) {
24-
if (attempt < retries) continue;
25-
return { error: `下载失败: HTTP ${response.status()}` };
36+
const status = response.status();
37+
// 5xx 错误可重试
38+
if (status >= 500 && attempt < retries) {
39+
logger.warn('下载', `HTTP ${status},重试 ${attempt}/${retries}...`);
40+
await new Promise(r => setTimeout(r, retryDelay * attempt));
41+
continue;
42+
}
43+
return { error: `下载失败: HTTP ${status}`, imageUrl: url };
2644
}
2745

2846
const buffer = await response.body();
2947
const base64 = buffer.toString('base64');
3048
const contentType = response.headers()['content-type'] || 'image/png';
3149
const mimeType = contentType.split(';')[0].trim();
3250

33-
return { image: `data:${mimeType};base64,${base64}` };
51+
return { image: `data:${mimeType};base64,${base64}`, imageUrl: url };
3452
} catch (e) {
35-
if (attempt < retries) continue;
36-
return { error: `已获取结果,但图片下载时遇到错误: ${e.message}` };
53+
if (isRetryableError(e.message) && attempt < retries) {
54+
logger.warn('下载', `${e.message},重试 ${attempt}/${retries}...`);
55+
await new Promise(r => setTimeout(r, retryDelay * attempt));
56+
continue;
57+
}
58+
return { error: `已获取结果,但图片下载时遇到错误: ${e.message}`, imageUrl: url };
3759
}
3860
}
61+
62+
return { error: '下载失败: 已达最大重试次数', imageUrl: url };
3963
}

‎src/backend/utils/error.js‎

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,36 @@ export function normalizePageError(err, meta = {}) {
9696
export function normalizeHttpError(response, content = null) {
9797
const status = response.status();
9898

99+
// 尝试从响应体中提取具体错误信息
100+
let detailError = null;
101+
if (content) {
102+
try {
103+
const json = JSON.parse(content);
104+
// 格式: {"error": "Request rejected: ..."}
105+
if (json.error && typeof json.error === 'string') {
106+
detailError = json.error;
107+
}
108+
// 格式: {"error": {"message": "..."}}
109+
else if (json.error?.message) {
110+
detailError = json.error.message;
111+
}
112+
} catch {
113+
// 非 JSON 格式,尝试直接使用内容
114+
if (content.length < 200) {
115+
detailError = content;
116+
}
117+
}
118+
}
119+
120+
// 检查是否是内容审核拒绝 (通常返回 422 或 429 但含有拒绝信息)
121+
const isContentRejection = detailError && (
122+
/reject|violat|terms|blocked|forbidden|unsafe|moderat/i.test(detailError) ||
123+
detailError === 'prompt failed'
124+
);
125+
if (isContentRejection) {
126+
return { error: `内容被拒绝: ${detailError}`, code: ADAPTER_ERRORS.CONTENT_BLOCKED, retryable: false };
127+
}
128+
99129
// 429 限流检查
100130
if (status === 429 || content?.includes('Too Many Requests')) {
101131
return { error: '触发限流/上游繁忙', code: ADAPTER_ERRORS.RATE_LIMITED, retryable: true };
@@ -108,12 +138,18 @@ export function normalizeHttpError(response, content = null) {
108138

109139
// 5xx 服务端错误(可重试)
110140
if (status >= 500) {
111-
return { error: `上游服务器错误,HTTP错误码: ${status}`, code: ADAPTER_ERRORS.HTTP_ERROR, retryable: true };
141+
const msg = detailError
142+
? `上游服务器错误 (${status}): ${detailError}`
143+
: `上游服务器错误,HTTP错误码: ${status}`;
144+
return { error: msg, code: ADAPTER_ERRORS.HTTP_ERROR, retryable: true };
112145
}
113146

114147
// 4xx 客户端错误(不可重试)
115148
if (status >= 400) {
116-
return { error: `请求错误,HTTP错误码: ${status}`, code: ADAPTER_ERRORS.HTTP_ERROR, retryable: false };
149+
const msg = detailError
150+
? `请求被拒绝 (${status}): ${detailError}`
151+
: `请求错误,HTTP错误���: ${status}`;
152+
return { error: msg, code: ADAPTER_ERRORS.HTTP_ERROR, retryable: false };
117153
}
118154

119155
return null;

‎src/server/api/admin/routes.js‎

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,19 @@ import {
3636
import { registry } from '../../../backend/registry.js';
3737
import { sendRestartSignal, sendStopSignal, isUnderSupervisor, getVncInfo } from '../../../utils/ipc.js';
3838
import { getTodayStats, getStatsRange, clearStatsRange } from '../../../utils/stats.js';
39+
import {
40+
getList as getHistoryList,
41+
getDetail as getHistoryDetail,
42+
deleteRecords as deleteHistoryRecords,
43+
deleteByDateRange as deleteHistoryByDateRange,
44+
retryMediaDownload,
45+
getStats as getHistoryStats,
46+
getModelList as getHistoryModelList,
47+
getMediaDir
48+
} from '../../../utils/history.js';
49+
import path from 'path';
50+
import fs from 'fs/promises';
51+
import { useContextDownload } from '../../../backend/utils/download.js';
3952

4053
/**
4154
* 读取请求体
@@ -458,6 +471,148 @@ export function createAdminRouter(context) {
458471
return;
459472
}
460473

474+
// ==================== 请求历史 ====================
475+
476+
// GET /admin/history - 历史记录列表
477+
if (method === 'GET' && pathname === '/history') {
478+
const url = new URL(req.url, `http://${req.headers.host}`);
479+
const page = parseInt(url.searchParams.get('page') || '1', 10);
480+
const pageSize = parseInt(url.searchParams.get('pageSize') || '20', 10);
481+
const filters = {
482+
status: url.searchParams.get('status') || null,
483+
modelId: url.searchParams.get('model') || null,
484+
search: url.searchParams.get('search') || null,
485+
startDate: url.searchParams.get('startDate') || null,
486+
endDate: url.searchParams.get('endDate') || null
487+
};
488+
489+
const result = getHistoryList(filters, page, pageSize);
490+
sendJson(res, 200, result);
491+
return;
492+
}
493+
494+
// GET /admin/history/stats - 历史统计摘要
495+
if (method === 'GET' && pathname === '/history/stats') {
496+
const url = new URL(req.url, `http://${req.headers.host}`);
497+
const filters = {
498+
startDate: url.searchParams.get('startDate') || null,
499+
endDate: url.searchParams.get('endDate') || null
500+
};
501+
502+
const stats = getHistoryStats(filters);
503+
sendJson(res, 200, stats);
504+
return;
505+
}
506+
507+
// GET /admin/history/models - 获取历史中使用过的模型列表
508+
if (method === 'GET' && pathname === '/history/models') {
509+
const models = getHistoryModelList();
510+
sendJson(res, 200, models);
511+
return;
512+
}
513+
514+
// GET /admin/history/media/:filename - 静态媒体文件服务
515+
if (method === 'GET' && pathname.startsWith('/history/media/')) {
516+
const filename = pathname.replace('/history/media/', '');
517+
if (!filename || filename.includes('..') || filename.includes('/')) {
518+
sendApiError(res, { code: ERROR_CODES.INVALID_REQUEST_BODY, message: '无效的文件名' });
519+
return;
520+
}
521+
522+
const mediaDir = getMediaDir();
523+
const filePath = path.join(mediaDir, filename);
524+
525+
try {
526+
const data = await fs.readFile(filePath);
527+
const ext = path.extname(filename).toLowerCase();
528+
const mimeTypes = {
529+
'.png': 'image/png',
530+
'.jpg': 'image/jpeg',
531+
'.jpeg': 'image/jpeg',
532+
'.gif': 'image/gif',
533+
'.webp': 'image/webp',
534+
'.mp4': 'video/mp4',
535+
'.webm': 'video/webm'
536+
};
537+
res.writeHead(200, {
538+
'Content-Type': mimeTypes[ext] || 'application/octet-stream',
539+
'Content-Length': data.length,
540+
'Cache-Control': 'public, max-age=31536000'
541+
});
542+
res.end(data);
543+
} catch (e) {
544+
sendApiError(res, { code: ERROR_CODES.NOT_FOUND, message: '文件不存在', status: 404 });
545+
}
546+
return;
547+
}
548+
549+
// GET /admin/history/:id - 单条记录详情
550+
const historyDetailMatch = pathname.match(/^\/history\/([^/]+)$/);
551+
if (method === 'GET' && historyDetailMatch && !pathname.includes('/retry-media')) {
552+
const id = historyDetailMatch[1];
553+
const record = getHistoryDetail(id);
554+
if (record) {
555+
sendJson(res, 200, record);
556+
} else {
557+
sendApiError(res, { code: ERROR_CODES.NOT_FOUND, message: '记录不存在', status: 404 });
558+
}
559+
return;
560+
}
561+
562+
// POST /admin/history/:id/retry-media - 重试下载媒体
563+
const retryMediaMatch = pathname.match(/^\/history\/([^/]+)\/retry-media$/);
564+
if (method === 'POST' && retryMediaMatch) {
565+
const id = retryMediaMatch[1];
566+
const body = await readBody(req);
567+
const mediaIndex = body.mediaIndex ?? 0;
568+
569+
// 使用 Pool 的浏览器下载(如果可用)
570+
let downloadFn = null;
571+
try {
572+
const poolContext = queueManager?.getPoolContext?.();
573+
const page = poolContext?.getFirstPage?.();
574+
if (page) {
575+
const imgDlCfg = config?.backend?.pool?.failover || {};
576+
downloadFn = (url) => useContextDownload(url, page, {
577+
retries: imgDlCfg.imgDlRetry ? (imgDlCfg.imgDlRetryMaxRetries || 3) : 1
578+
});
579+
}
580+
} catch { /* Pool 未初始化,使用后备方案 */ }
581+
582+
const result = await retryMediaDownload(id, mediaIndex, downloadFn);
583+
if (result.success) {
584+
sendJson(res, 200, result);
585+
} else {
586+
sendApiError(res, { code: ERROR_CODES.INTERNAL_ERROR, message: result.message });
587+
}
588+
return;
589+
}
590+
591+
// DELETE /admin/history - 批量删除记录
592+
if (method === 'DELETE' && pathname === '/history') {
593+
const url = new URL(req.url, `http://${req.headers.host}`);
594+
const startDate = url.searchParams.get('startDate');
595+
const endDate = url.searchParams.get('endDate');
596+
597+
// 支持按��期范围删除
598+
if (startDate && endDate) {
599+
const deleted = await deleteHistoryByDateRange(startDate, endDate);
600+
sendJson(res, 200, { success: true, deleted });
601+
return;
602+
}
603+
604+
// 支持按 ID 列表删除
605+
const body = await readBody(req);
606+
if (body.ids && Array.isArray(body.ids)) {
607+
const deleted = await deleteHistoryRecords(body.ids);
608+
sendJson(res, 200, { success: true, deleted });
609+
return;
610+
}
611+
612+
sendApiError(res, { code: ERROR_CODES.INVALID_REQUEST_BODY, message: '缺少 ids 数组或日期范围参数' });
613+
return;
614+
}
615+
461616
// 404
462617
res.writeHead(404);
463618
res.end(JSON.stringify({ error: 'Not Found' }));

0 commit comments

Comments
 (0)