diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index d9c42041f..d881abf57 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -218,6 +218,7 @@ import { normalizeDulupayConfig, type DulupayConfig, } from "@/lib/payment/dulupay-config"; +import { ExternalImageApiSettingsCard } from "@/components/admin/external-image-api-settings-card"; import { DEFAULT_CANVAS_SKILL_TAGS, DEFAULT_CANVAS_SKILL_TAGS_TITLE, @@ -17726,6 +17727,8 @@ export function AdminSettingsClient() { + +
diff --git a/app/api/admin/external-image-api/logs/route.ts b/app/api/admin/external-image-api/logs/route.ts new file mode 100644 index 000000000..e0edf46a8 --- /dev/null +++ b/app/api/admin/external-image-api/logs/route.ts @@ -0,0 +1,74 @@ +import { desc, eq } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/middleware/admin"; +import { db } from "@/lib/db"; +import { externalImageApiRequestLog, user } from "@/lib/db/schema"; +import { ChatSDKError } from "@/lib/errors"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +export async function GET(request: Request) { + try { + await requireAdmin(); + + const { searchParams } = new URL(request.url); + const limit = Math.min( + 100, + Math.max(1, Number.parseInt(searchParams.get("limit") || "50", 10)), + ); + + const rows = await db + .select({ + id: externalImageApiRequestLog.id, + apiKeyId: externalImageApiRequestLog.apiKeyId, + apiKeyName: externalImageApiRequestLog.apiKeyName, + ownerUserId: externalImageApiRequestLog.ownerUserId, + ownerUserEmail: user.email, + ownerUserName: user.name, + modelDbId: externalImageApiRequestLog.modelDbId, + modelRuntimeId: externalImageApiRequestLog.modelRuntimeId, + modelDisplayName: externalImageApiRequestLog.modelDisplayName, + prompt: externalImageApiRequestLog.prompt, + negativePrompt: externalImageApiRequestLog.negativePrompt, + imageCount: externalImageApiRequestLog.imageCount, + aspectRatio: externalImageApiRequestLog.aspectRatio, + imageSize: externalImageApiRequestLog.imageSize, + status: externalImageApiRequestLog.status, + requesterIp: externalImageApiRequestLog.requesterIp, + requesterUserAgent: externalImageApiRequestLog.requesterUserAgent, + requestPayload: externalImageApiRequestLog.requestPayload, + responsePayload: externalImageApiRequestLog.responsePayload, + generatedImageIds: externalImageApiRequestLog.generatedImageIds, + generatedAssetIds: externalImageApiRequestLog.generatedAssetIds, + generatedImageUrls: externalImageApiRequestLog.generatedImageUrls, + errorMessage: externalImageApiRequestLog.errorMessage, + durationMs: externalImageApiRequestLog.durationMs, + createdAt: externalImageApiRequestLog.createdAt, + completedAt: externalImageApiRequestLog.completedAt, + }) + .from(externalImageApiRequestLog) + .leftJoin(user, eq(externalImageApiRequestLog.ownerUserId, user.id)) + .orderBy(desc(externalImageApiRequestLog.createdAt)) + .limit(limit); + + return NextResponse.json({ + success: true, + data: { + logs: rows, + }, + }); + } catch (error) { + if (error instanceof ChatSDKError) { + return error.toResponse(); + } + console.error("[Admin External Image API Logs] GET failed:", error); + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "获取日志失败", + }, + { status: 500 }, + ); + } +} diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts index a95841dcb..37aabde9e 100644 --- a/app/api/admin/settings/route.ts +++ b/app/api/admin/settings/route.ts @@ -63,6 +63,10 @@ import { STYLE_PRO_CONFIG_KEY, normalizeStyleProConfig, } from "@/lib/style-pro/config"; +import { + EXTERNAL_IMAGE_API_CONFIG_KEY, + normalizeExternalImageApiConfig, +} from "@/lib/external-image-api/config"; const settingSchema = z.object({ key: z.string(), @@ -128,6 +132,12 @@ export async function GET(request: Request) { setting: setting ? { ...setting, value } : { key, value }, }); } + if (key === EXTERNAL_IMAGE_API_CONFIG_KEY) { + const value = normalizeExternalImageApiConfig(setting?.value); + return Response.json({ + setting: setting ? { ...setting, value } : { key, value }, + }); + } return Response.json({ setting }); } @@ -254,6 +264,18 @@ export async function POST(request: Request) { nextValue = normalizedValue; } + if (key === EXTERNAL_IMAGE_API_CONFIG_KEY) { + const saved = await upsertSystemSetting({ + key: EXTERNAL_IMAGE_API_CONFIG_KEY, + value: normalizeExternalImageApiConfig(value), + updatedBy: session.user.id, + }); + + return Response.json({ + setting: saved[0], + }); + } + if (key === VIDEO_CANVAS_FUNCTION_CONFIG_KEY) { nextValue = normalizeVideoCanvasFunctionConfig(value); } diff --git a/app/api/external/image/generate/route.ts b/app/api/external/image/generate/route.ts new file mode 100644 index 000000000..267b95371 --- /dev/null +++ b/app/api/external/image/generate/route.ts @@ -0,0 +1,184 @@ +import { eq } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { db } from "@/lib/db"; +import { externalImageApiRequestLog } from "@/lib/db/schema"; +import { saveGeneratedImages } from "@/lib/ai/runtime/generated-image-persistence"; +import { + authenticateExternalImageApiRequest, + buildExternalImageApiKeyFingerprint, + ensureHostedImageMarkdown, + extractGeneratedImagesFromMarkdown, + generateImagesWithModel, + getRequesterIp, + resolveExternalImageApiModel, + resolveExternalImageApiOwnerUserId, + serializeExternalImageApiModel, +} from "@/lib/external-image-api/service"; + +export const runtime = "nodejs"; +export const maxDuration = 120; + +const requestSchema = z.object({ + model: z.string().trim().min(1).optional(), + prompt: z.string().trim().min(1, "prompt 不能为空"), + negativePrompt: z.string().trim().optional(), + imageCount: z.coerce.number().int().min(1).max(8).optional(), + aspectRatio: z.string().trim().max(32).optional(), + imageSize: z.string().trim().max(32).optional(), + metadata: z.record(z.string(), z.any()).optional(), +}); + +export async function POST(request: Request) { + const authResult = await authenticateExternalImageApiRequest(request); + if (!authResult.accessKey) { + return NextResponse.json( + { + success: false, + error: authResult.error, + }, + { status: authResult.status }, + ); + } + + const startedAt = Date.now(); + let logId: string | null = null; + + try { + const json = await request.json(); + const parsed = requestSchema.safeParse(json); + if (!parsed.success) { + return NextResponse.json( + { + success: false, + error: parsed.error.errors[0]?.message || "参数错误", + }, + { status: 400 }, + ); + } + + const payload = parsed.data; + const selectedModel = await resolveExternalImageApiModel(payload.model); + const ownerUserId = await resolveExternalImageApiOwnerUserId( + authResult.config.defaultOwnerEmail, + ); + const requesterIp = getRequesterIp(request); + const requesterUserAgent = request.headers.get("user-agent") || null; + + const [createdLog] = await db + .insert(externalImageApiRequestLog) + .values({ + apiKeyId: authResult.accessKey.id, + apiKeyName: authResult.accessKey.name, + ownerUserId, + modelDbId: selectedModel.model.id, + modelRuntimeId: selectedModel.model.modelId, + modelDisplayName: selectedModel.model.displayName, + prompt: payload.prompt, + negativePrompt: payload.negativePrompt || null, + imageCount: payload.imageCount || 1, + aspectRatio: payload.aspectRatio || null, + imageSize: payload.imageSize || null, + status: "pending", + requesterIp, + requesterUserAgent, + requestPayload: { + ...payload, + apiKeyFingerprint: buildExternalImageApiKeyFingerprint( + authResult.accessKey, + ), + }, + }) + .returning({ id: externalImageApiRequestLog.id }); + + logId = createdLog?.id || null; + + const rawMarkdown = await generateImagesWithModel({ + modelDbId: selectedModel.model.id, + prompt: payload.prompt, + negativePrompt: payload.negativePrompt || null, + imageCount: payload.imageCount || 1, + aspectRatio: payload.aspectRatio || null, + imageSize: payload.imageSize || null, + }); + + const hostedMarkdown = await ensureHostedImageMarkdown( + rawMarkdown, + payload.imageSize || "2K", + ); + const images = extractGeneratedImagesFromMarkdown(hostedMarkdown); + if (!images.length) { + throw new Error("模型返回结果里未解析到图片"); + } + + const persistResult = await saveGeneratedImages( + hostedMarkdown, + ownerUserId, + null, + null, + payload.prompt, + selectedModel.model.id, + { + sourceType: "external_api_generated", + userPrompt: payload.prompt, + optimizedPrompt: payload.prompt, + requestedImageCount: payload.imageCount || images.length, + aspectRatio: payload.aspectRatio, + imageSize: payload.imageSize, + skipBilling: true, + }, + ); + + if (logId) { + await db + .update(externalImageApiRequestLog) + .set({ + status: "succeeded", + responsePayload: { + model: serializeExternalImageApiModel(selectedModel), + markdown: hostedMarkdown, + images, + }, + generatedImageIds: persistResult.savedImageIds, + generatedAssetIds: persistResult.savedAssetIds, + generatedImageUrls: persistResult.savedOriginalUrls, + durationMs: Date.now() - startedAt, + completedAt: new Date(), + }) + .where(eq(externalImageApiRequestLog.id, logId)); + } + + return NextResponse.json({ + success: true, + data: { + requestId: logId, + model: serializeExternalImageApiModel(selectedModel), + images, + generatedImageIds: persistResult.savedImageIds, + generatedAssetIds: persistResult.savedAssetIds, + }, + }); + } catch (error) { + console.error("[External Image API] Generate failed:", error); + + if (logId) { + await db + .update(externalImageApiRequestLog) + .set({ + status: "failed", + errorMessage: error instanceof Error ? error.message : "图片生成失败", + durationMs: Date.now() - startedAt, + completedAt: new Date(), + }) + .where(eq(externalImageApiRequestLog.id, logId)); + } + + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "图片生成失败", + }, + { status: 500 }, + ); + } +} diff --git a/app/api/external/image/models/route.ts b/app/api/external/image/models/route.ts new file mode 100644 index 000000000..518702793 --- /dev/null +++ b/app/api/external/image/models/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { + authenticateExternalImageApiRequest, + getExternalImageApiModels, + serializeExternalImageApiModel, +} from "@/lib/external-image-api/service"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +export async function GET(request: Request) { + try { + const authResult = await authenticateExternalImageApiRequest(request); + if (!authResult.accessKey) { + return NextResponse.json( + { + success: false, + error: authResult.error, + }, + { status: authResult.status }, + ); + } + + const models = await getExternalImageApiModels(); + + return NextResponse.json({ + success: true, + data: { + models: models.map(serializeExternalImageApiModel), + }, + }); + } catch (error) { + console.error("[External Image API] Failed to load models:", error); + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "获取模型列表失败", + }, + { status: 500 }, + ); + } +} diff --git a/components/admin/external-image-api-settings-card.tsx b/components/admin/external-image-api-settings-card.tsx new file mode 100644 index 000000000..bd4a13cc6 --- /dev/null +++ b/components/admin/external-image-api-settings-card.tsx @@ -0,0 +1,458 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import useSWR from "swr"; +import { toast } from "sonner"; +import { KeyRound, Plus, Save, Trash2 } from "lucide-react"; +import { formatBeijingTime } from "@/lib/utils"; +import { + defaultExternalImageApiConfig, + EXTERNAL_IMAGE_API_CONFIG_KEY, + maskExternalImageApiKey, + normalizeExternalImageApiConfig, + type ExternalImageApiConfig, +} from "@/lib/external-image-api/config"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; + +const fetcher = async (url: string) => { + const response = await fetch(url); + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + throw new Error(payload?.error || payload?.message || "请求失败"); + } + return response.json(); +}; + +type AdminExternalImageApiLog = { + id: string; + apiKeyId: string | null; + apiKeyName: string | null; + ownerUserEmail: string | null; + modelDisplayName: string | null; + modelRuntimeId: string | null; + prompt: string | null; + negativePrompt: string | null; + imageCount: number; + aspectRatio: string | null; + imageSize: string | null; + status: string; + generatedImageUrls: string[] | null; + errorMessage: string | null; + durationMs: number | null; + requesterIp: string | null; + createdAt: string; + completedAt: string | null; +}; + +function buildKeyDraft() { + return { + id: `external-key-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + name: "", + apiKey: "", + enabled: true, + notes: "", + }; +} + +export function ExternalImageApiSettingsCard() { + const { data: configData, mutate: mutateConfig } = useSWR<{ + setting?: { value?: unknown }; + }>(`/api/admin/settings?key=${EXTERNAL_IMAGE_API_CONFIG_KEY}`, fetcher); + const { data: logsData, mutate: mutateLogs } = useSWR<{ + success: boolean; + data: { logs: AdminExternalImageApiLog[] }; + }>("/api/admin/external-image-api/logs?limit=50", fetcher); + + const [config, setConfig] = useState( + defaultExternalImageApiConfig, + ); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + setConfig( + normalizeExternalImageApiConfig(configData?.setting?.value), + ); + }, [configData]); + + const enabledKeyCount = useMemo( + () => config.apiKeys.filter((item) => item.enabled).length, + [config.apiKeys], + ); + + const handleAddKey = () => { + setConfig((current) => ({ + ...current, + apiKeys: [...current.apiKeys, buildKeyDraft()], + })); + }; + + const handleRemoveKey = (id: string) => { + setConfig((current) => ({ + ...current, + apiKeys: current.apiKeys.filter((item) => item.id !== id), + })); + }; + + const handleSave = async () => { + setIsSaving(true); + try { + const response = await fetch("/api/admin/settings", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + key: EXTERNAL_IMAGE_API_CONFIG_KEY, + value: normalizeExternalImageApiConfig(config), + }), + }); + + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error( + String(payload?.message || payload?.error || "保存失败"), + ); + } + + toast.success("第三方生图 API 配置已保存"); + await Promise.all([mutateConfig(), mutateLogs()]); + } catch (error) { + console.error("[External Image API Settings] Save failed:", error); + toast.error(error instanceof Error ? error.message : "保存失败"); + } finally { + setIsSaving(false); + } + }; + + return ( + + +
+
+ +
+
+ 第三方生图 API + + 对外开放当前系统里已启用的生图模型。第三方先调 + + /api/external/image/models + + 获取模型名称,再调用 + + /api/external/image/generate + + 生图。结果会继续走我们的储存桶并落到数据库。 + +
+
+
+ +
+
+
+
+ +
+ 关闭后第三方 API Key 会全部失效。 +
+
+
+ + setConfig((current) => ({ ...current, enabled: checked })) + } + /> + + {config.enabled ? "已开启" : "已关闭"} + +
+
+ +
+ + + setConfig((current) => ({ + ...current, + defaultOwnerEmail: event.target.value, + })) + } + placeholder="external-image-api@system.local" + /> +
+ 外部 API 生成图片会统一挂到这个用户下,方便我们保留生成记录、媒体资产和后续分析数据。 +
+
+ +
+ 当前共 {config.apiKeys.length} 个 Key,启用中 {enabledKeyCount} 个。 + 外部调用支持 + + Authorization: Bearer <apiKey> + + 和 + + x-api-key + + 两种方式。 +
+ +
+ {config.apiKeys.map((item, index) => ( +
+
+
+ Key #{index + 1} +
+ +
+
+
+ + + setConfig((current) => ({ + ...current, + apiKeys: current.apiKeys.map((record) => + record.id === item.id + ? { ...record, name: event.target.value } + : record, + ), + })) + } + placeholder="渠道 A / 合作方 B" + /> +
+
+ + + setConfig((current) => ({ + ...current, + apiKeys: current.apiKeys.map((record) => + record.id === item.id + ? { ...record, id: event.target.value } + : record, + ), + })) + } + placeholder="partner-a" + /> +
+
+
+ + + setConfig((current) => ({ + ...current, + apiKeys: current.apiKeys.map((record) => + record.id === item.id + ? { ...record, apiKey: event.target.value } + : record, + ), + })) + } + placeholder="请输入调用第三方生图 API 的密钥" + /> +
+ 当前显示:{maskExternalImageApiKey(item.apiKey)} +
+
+
+ +