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, apiKey: event.target.value }
+ : record,
+ ),
+ }))
+ }
+ placeholder="请输入调用第三方生图 API 的密钥"
+ />
+
+ 当前显示:{maskExternalImageApiKey(item.apiKey)}
+
+
+
+
+
+
+
+ 单独停用后,这个 Key 会立即失效,但不会影响其他 Key。
+
+
+
+ setConfig((current) => ({
+ ...current,
+ apiKeys: current.apiKeys.map((record) =>
+ record.id === item.id
+ ? { ...record, enabled: checked }
+ : record,
+ ),
+ }))
+ }
+ />
+
+ {item.enabled ? "已启用" : "已停用"}
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
调用说明
+
+ 这条第三方接口直接复用我们系统里启用的生图模型、模型名称和存储链路。
+
+
+
+
+
接口 1:获取模型
+
+ GET /api/external/image/models
+
+
+
+
+
接口 2:发起生图
+
+ {`POST /api/external/image/generate
+{
+ "model": "模型 UUID / 模型ID / 显示名称",
+ "prompt": "请生成一张暖色家居客厅图",
+ "negativePrompt": "不要出现人物",
+ "imageCount": 2,
+ "aspectRatio": "16:9",
+ "imageSize": "2K"
+}`}
+
+
+
+
+ 返回图片地址会优先上传到我们的储存桶,然后再写入
+
+ GeneratedImage
+
+ 和媒体资产表。
+
+
+
+
+
+
+
最近 50 条请求日志
+
+ 这里会记录 API Key、模型、提示词、生成张数、耗时和结果地址,方便排查对接问题。
+
+
+
+
+ {logsData?.data?.logs?.length ? (
+ logsData.data.logs.map((log) => (
+
+
+
+ {log.modelDisplayName || log.modelRuntimeId || "未记录模型"}
+
+
+ {log.status}
+
+
+ {log.apiKeyName || log.apiKeyId || "未命名 Key"}
+
+
+
+ {log.prompt || "无提示词"}
+
+
+
归属用户:{log.ownerUserEmail || "-"}
+
生成张数:{log.imageCount}
+
+ 规格:{log.aspectRatio || "-"} / {log.imageSize || "-"}
+
+
+ 耗时:{typeof log.durationMs === "number" ? `${log.durationMs}ms` : "-"}
+
+
请求 IP:{log.requesterIp || "-"}
+
生成图片:{log.generatedImageUrls?.length || 0} 张
+
开始时间:{formatBeijingTime(log.createdAt)}
+
+ 完成时间:
+ {log.completedAt ? formatBeijingTime(log.completedAt) : "-"}
+
+
+ {log.errorMessage ? (
+
+ {log.errorMessage}
+
+ ) : null}
+
+ ))
+ ) : (
+
+ 还没有第三方生图 API 请求记录。
+
+ )}
+
+
+
+
+ );
+}
diff --git a/docs/第三方生图API调用说明.md b/docs/第三方生图API调用说明.md
new file mode 100644
index 000000000..c7c5f3a75
--- /dev/null
+++ b/docs/第三方生图API调用说明.md
@@ -0,0 +1,207 @@
+# 第三方生图 API 调用说明
+
+本文档用于提供给第三方调用方,说明如何对接我们系统开放的生图能力。
+
+## 1. 接口概览
+
+当前开放两条接口:
+
+1. 获取可用模型列表
+ `GET /api/external/image/models`
+
+2. 发起生图
+ `POST /api/external/image/generate`
+
+这套接口直接复用我们系统后台已启用的生图模型与模型名称。
+生成结果图片会先上传到我们的储存桶,再写入我们的数据库与媒体资产表。
+
+## 2. 鉴权方式
+
+支持以下两种方式,任选其一:
+
+### 方式 A:Authorization Bearer
+
+```http
+Authorization: Bearer YOUR_API_KEY
+```
+
+### 方式 B:x-api-key
+
+```http
+x-api-key: YOUR_API_KEY
+```
+
+如果 API Key 无效、已停用,或后台未启用第三方生图 API,接口会返回 `401` 或 `403`。
+
+## 3. 获取模型列表
+
+### 请求
+
+```bash
+curl -X GET "https://你的域名/api/external/image/models" \
+ -H "Authorization: Bearer YOUR_API_KEY"
+```
+
+### 成功响应示例
+
+```json
+{
+ "success": true,
+ "data": {
+ "models": [
+ {
+ "id": "9b8f7c10-1111-4d22-9abc-1234567890ab",
+ "modelId": "seedream-5.0-lite",
+ "displayName": "Seedream 5.0 Lite",
+ "description": "轻量版商品生图模型",
+ "providerType": "volcano-vision",
+ "providerName": "火山视觉",
+ "avatarUrl": "https://你的域名/uploads/xxx.png",
+ "imageGenConfig": {
+ "creditCost": 20
+ }
+ }
+ ]
+ }
+}
+```
+
+### 字段说明
+
+- `id`:系统内部模型 UUID,推荐第三方优先使用这个值调用生成接口
+- `modelId`:上游模型 ID
+- `displayName`:系统中展示给用户的模型名称
+- `providerType`:模型服务商类型
+- `providerName`:模型服务商显示名称
+- `imageGenConfig`:当前模型的生图配置,包含积分等后台配置
+
+## 4. 发起生图
+
+### 请求地址
+
+```http
+POST /api/external/image/generate
+Content-Type: application/json
+Authorization: Bearer YOUR_API_KEY
+```
+
+### 请求参数
+
+```json
+{
+ "model": "9b8f7c10-1111-4d22-9abc-1234567890ab",
+ "prompt": "请生成一张高端现代客厅空间图,暖光,真实摄影感,适合商业首页展示",
+ "negativePrompt": "不要人物,不要文字水印,不要畸变",
+ "imageCount": 2,
+ "aspectRatio": "16:9",
+ "imageSize": "2K",
+ "metadata": {
+ "bizId": "partner-order-20260518-001"
+ }
+}
+```
+
+### 参数说明
+
+- `model`:可传以下任意一种
+ - 模型 UUID `id`
+ - 模型运行 ID `modelId`
+ - 模型显示名 `displayName`
+- `prompt`:正向提示词,必填
+- `negativePrompt`:反向提示词,可选
+- `imageCount`:生成张数,可选,默认 `1`,最大 `8`
+- `aspectRatio`:宽高比,可选,例如 `1:1`、`16:9`
+- `imageSize`:图片规格,可选,例如 `1K`、`2K`
+- `metadata`:业务扩展字段,可选,会原样记录到请求日志里
+
+## 5. 生图成功响应
+
+```json
+{
+ "success": true,
+ "data": {
+ "requestId": "5e8a63f6-1111-4d6c-8bb4-1234567890ab",
+ "model": {
+ "id": "9b8f7c10-1111-4d22-9abc-1234567890ab",
+ "modelId": "seedream-5.0-lite",
+ "displayName": "Seedream 5.0 Lite",
+ "description": "轻量版商品生图模型",
+ "providerType": "volcano-vision",
+ "providerName": "火山视觉",
+ "avatarUrl": null,
+ "imageGenConfig": {
+ "creditCost": 20
+ }
+ },
+ "images": [
+ {
+ "id": 1,
+ "originalUrl": "https://你的域名/uploads/2026xxxx-orig.jpg",
+ "thumbnailUrl": "https://你的域名/uploads/2026xxxx-orig.jpg",
+ "imageSize": "2K"
+ },
+ {
+ "id": 2,
+ "originalUrl": "https://你的域名/uploads/2026yyyy-orig.jpg",
+ "thumbnailUrl": "https://你的域名/uploads/2026yyyy-orig.jpg",
+ "imageSize": "2K"
+ }
+ ],
+ "generatedImageIds": [
+ "3d3a0f24-1111-4c0c-8d9d-1234567890ab"
+ ],
+ "generatedAssetIds": [
+ "b7b19e10-1111-4db8-aeb2-1234567890ab"
+ ]
+ }
+}
+```
+
+### 返回字段说明
+
+- `requestId`:本次请求日志 ID,可用于后台排查
+- `images`:最终生成结果
+ - `originalUrl`:原图地址
+ - `thumbnailUrl`:缩略图地址
+ - `imageSize`:记录的图片规格
+- `generatedImageIds`:写入 `GeneratedImage` 表后的记录 ID
+- `generatedAssetIds`:写入媒体资产表后的记录 ID
+
+## 6. 失败响应示例
+
+```json
+{
+ "success": false,
+ "error": "未找到匹配的生图模型"
+}
+```
+
+常见失败原因:
+
+- API Key 缺失或无效
+- 第三方生图 API 后台未开启
+- 请求的模型不存在或未启用
+- 上游模型生成失败
+- 上游返回内容中未解析出图片
+
+## 7. 调用建议
+
+1. 先调用模型列表接口,缓存可用模型
+2. 生成时优先传模型 UUID `id`,最稳定
+3. 将返回的 `requestId` 保存到业务日志,便于双方排查问题
+4. 返回的图片地址已经是我们系统储存桶地址,可以直接展示或下载
+
+## 8. 后台能力
+
+后台已支持以下管理能力:
+
+- 启用/关闭第三方生图 API
+- 配置默认归属用户邮箱
+- 配置多个 API Key
+- 单独启用/停用某个 Key
+- 查看最近请求日志、生图明细、耗时、失败原因、结果地址
+
+## 9. 对接备注
+
+- 这套接口使用的是我们系统当前启用的生图模型,模型列表会随后台配置变化而变化
+- 如果需要增加调用频控、签名校验、白名单 IP、异步回调等能力,可以继续在这套 API 上扩展
diff --git a/lib/ai/runtime/direct-agent-image-edit-executor.ts b/lib/ai/runtime/direct-agent-image-edit-executor.ts
index 18c4e0e54..b83688a8a 100644
--- a/lib/ai/runtime/direct-agent-image-edit-executor.ts
+++ b/lib/ai/runtime/direct-agent-image-edit-executor.ts
@@ -40,8 +40,8 @@ interface DirectAgentImageParamsSnapshot {
type SaveGeneratedImages = (
imageMarkdown: string,
userId: string,
- chatId: string,
- messageId: string,
+ chatId: string | null,
+ messageId: string | null,
prompt: string,
modelId: string,
options?: {
@@ -55,8 +55,14 @@ type SaveGeneratedImages = (
requestedImageCount?: number;
aspectRatio?: string;
imageSize?: string;
+ skipBilling?: boolean;
},
-) => Promise;
+) => Promise<{
+ savedImageCount: number;
+ savedImageIds: string[];
+ savedAssetIds: string[];
+ savedOriginalUrls: string[];
+}>;
export interface DirectAgentImageEditExecutionResult {
originalUrl: string;
diff --git a/lib/ai/runtime/generated-image-persistence.ts b/lib/ai/runtime/generated-image-persistence.ts
index 7649c5a91..fa4e5b42c 100644
--- a/lib/ai/runtime/generated-image-persistence.ts
+++ b/lib/ai/runtime/generated-image-persistence.ts
@@ -88,8 +88,8 @@ async function resolveBillingModelByReference(modelRef: string): Promise {
+): Promise<{
+ savedImageCount: number;
+ savedImageIds: string[];
+ savedAssetIds: string[];
+ savedOriginalUrls: string[];
+}> {
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
const matches = [...imageMarkdown.matchAll(imageRegex)];
let savedImageCount = 0;
+ const savedImageIds: string[] = [];
const savedAssetIds: string[] = [];
const savedOriginalUrls: string[] = [];
let relationSourceAssetIds = Array.isArray(options?.sourceAssetIds)
@@ -141,8 +148,8 @@ export async function saveGeneratedImages(
.insert(generatedImage)
.values({
userId,
- chatId,
- messageId,
+ chatId: chatId ?? null,
+ messageId: messageId ?? null,
thumbnailUrl,
originalUrl,
imageSize,
@@ -152,6 +159,9 @@ export async function saveGeneratedImages(
.returning();
savedImageCount += 1;
+ if (savedImage?.id) {
+ savedImageIds.push(savedImage.id);
+ }
if (!savedOriginalUrls.includes(originalUrl)) {
savedOriginalUrls.push(originalUrl);
}
@@ -208,7 +218,7 @@ export async function saveGeneratedImages(
}
}
- if (savedOriginalUrls.length > 0) {
+ if (savedOriginalUrls.length > 0 && chatId && messageId) {
try {
const resultAssetIds = savedAssetIds.length > 0 ? savedAssetIds : null;
await persistImageWorkflowTurn({
@@ -241,21 +251,56 @@ export async function saveGeneratedImages(
}
}
- if (savedImageCount <= 0) return;
+ if (savedImageCount <= 0) {
+ return {
+ savedImageCount,
+ savedImageIds,
+ savedAssetIds,
+ savedOriginalUrls,
+ };
+ }
try {
+ if (options?.skipBilling) {
+ return {
+ savedImageCount,
+ savedImageIds,
+ savedAssetIds,
+ savedOriginalUrls,
+ };
+ }
+
const pointsConfig = await getPointsSystemConfig();
- if (!pointsConfig.enabled) return;
+ if (!pointsConfig.enabled) {
+ return {
+ savedImageCount,
+ savedImageIds,
+ savedAssetIds,
+ savedOriginalUrls,
+ };
+ }
const userData = await getUserById({ id: userId }).catch(() => undefined);
if (userData?.isHighVip) {
console.log(`[Points] Skip image points for VIP user=${userId} model=${modelId}`);
- return;
+ return {
+ savedImageCount,
+ savedImageIds,
+ savedAssetIds,
+ savedOriginalUrls,
+ };
}
const modelConfig = await resolveBillingModelByReference(modelId);
const unitCost = getModelCreditCost(modelConfig, "image");
- if (unitCost <= 0) return;
+ if (unitCost <= 0) {
+ return {
+ savedImageCount,
+ savedImageIds,
+ savedAssetIds,
+ savedOriginalUrls,
+ };
+ }
const totalCost = unitCost * savedImageCount;
const consumeResult = await consumeUserPoints({
@@ -263,8 +308,8 @@ export async function saveGeneratedImages(
amount: totalCost,
reason: "image_generation",
modelId: modelConfig?.id || modelId,
- chatId,
- messageId,
+ chatId: chatId ?? undefined,
+ messageId: messageId ?? undefined,
note: `模型(${modelConfig?.displayName || modelId}) 生图 ${savedImageCount} 张`,
});
@@ -284,4 +329,11 @@ export async function saveGeneratedImages(
} catch (error) {
console.error("[Points] Failed to consume points after image generation:", error);
}
+
+ return {
+ savedImageCount,
+ savedImageIds,
+ savedAssetIds,
+ savedOriginalUrls,
+ };
}
diff --git a/lib/db/migrations/0081_add_external_image_api_logs.sql b/lib/db/migrations/0081_add_external_image_api_logs.sql
new file mode 100644
index 000000000..8dcb17dd3
--- /dev/null
+++ b/lib/db/migrations/0081_add_external_image_api_logs.sql
@@ -0,0 +1,52 @@
+CREATE TABLE IF NOT EXISTS "ExternalImageApiRequestLog" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "apiKeyId" varchar(64),
+ "apiKeyName" varchar(128),
+ "ownerUserId" uuid,
+ "modelDbId" uuid,
+ "modelRuntimeId" varchar(128),
+ "modelDisplayName" varchar(128),
+ "prompt" text,
+ "negativePrompt" text,
+ "imageCount" integer NOT NULL DEFAULT 1,
+ "aspectRatio" varchar(32),
+ "imageSize" varchar(32),
+ "status" varchar(32) NOT NULL DEFAULT 'pending',
+ "requesterIp" varchar(64),
+ "requesterUserAgent" text,
+ "requestPayload" jsonb,
+ "responsePayload" jsonb,
+ "generatedImageIds" jsonb,
+ "generatedAssetIds" jsonb,
+ "generatedImageUrls" jsonb,
+ "errorMessage" text,
+ "durationMs" integer,
+ "createdAt" timestamp DEFAULT now() NOT NULL,
+ "completedAt" timestamp
+);
+
+DO $$
+BEGIN
+ ALTER TABLE "ExternalImageApiRequestLog"
+ ADD CONSTRAINT "ExternalImageApiRequestLog_ownerUserId_User_id_fk"
+ FOREIGN KEY ("ownerUserId")
+ REFERENCES "public"."User"("id")
+ ON DELETE NO ACTION
+ ON UPDATE NO ACTION;
+EXCEPTION
+ WHEN duplicate_object THEN NULL;
+END
+$$;
+
+DO $$
+BEGIN
+ ALTER TABLE "ExternalImageApiRequestLog"
+ ADD CONSTRAINT "ExternalImageApiRequestLog_modelDbId_AiModel_id_fk"
+ FOREIGN KEY ("modelDbId")
+ REFERENCES "public"."AiModel"("id")
+ ON DELETE NO ACTION
+ ON UPDATE NO ACTION;
+EXCEPTION
+ WHEN duplicate_object THEN NULL;
+END
+$$;
diff --git a/lib/db/migrations/meta/_journal.json b/lib/db/migrations/meta/_journal.json
index 17d137ff0..d468c50f0 100644
--- a/lib/db/migrations/meta/_journal.json
+++ b/lib/db/migrations/meta/_journal.json
@@ -470,6 +470,13 @@
"when": 1778461200000,
"tag": "0080_add_ai_model_health_recovery",
"breakpoints": true
+ },
+ {
+ "idx": 67,
+ "version": "7",
+ "when": 1779069600000,
+ "tag": "0081_add_external_image_api_logs",
+ "breakpoints": true
}
]
}
diff --git a/lib/db/schema.ts b/lib/db/schema.ts
index 8d42a99d9..ac1b42322 100644
--- a/lib/db/schema.ts
+++ b/lib/db/schema.ts
@@ -582,6 +582,37 @@ export const generatedImage = pgTable("GeneratedImage", {
export type GeneratedImage = InferSelectModel;
+export const externalImageApiRequestLog = pgTable("ExternalImageApiRequestLog", {
+ id: uuid("id").primaryKey().notNull().defaultRandom(),
+ apiKeyId: varchar("apiKeyId", { length: 64 }),
+ apiKeyName: varchar("apiKeyName", { length: 128 }),
+ ownerUserId: uuid("ownerUserId").references(() => user.id),
+ modelDbId: uuid("modelDbId").references(() => aiModel.id),
+ modelRuntimeId: varchar("modelRuntimeId", { length: 128 }),
+ modelDisplayName: varchar("modelDisplayName", { length: 128 }),
+ prompt: text("prompt"),
+ negativePrompt: text("negativePrompt"),
+ imageCount: integer("imageCount").notNull().default(1),
+ aspectRatio: varchar("aspectRatio", { length: 32 }),
+ imageSize: varchar("imageSize", { length: 32 }),
+ status: varchar("status", { length: 32 }).notNull().default("pending"),
+ requesterIp: varchar("requesterIp", { length: 64 }),
+ requesterUserAgent: text("requesterUserAgent"),
+ requestPayload: jsonb("requestPayload"),
+ responsePayload: jsonb("responsePayload"),
+ generatedImageIds: jsonb("generatedImageIds"),
+ generatedAssetIds: jsonb("generatedAssetIds"),
+ generatedImageUrls: jsonb("generatedImageUrls"),
+ errorMessage: text("errorMessage"),
+ durationMs: integer("durationMs"),
+ createdAt: timestamp("createdAt").notNull().defaultNow(),
+ completedAt: timestamp("completedAt"),
+});
+
+export type ExternalImageApiRequestLog = InferSelectModel<
+ typeof externalImageApiRequestLog
+>;
+
// 风格化分类表
export const agentPresetCategory = pgTable("AgentPresetCategory", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
diff --git a/lib/external-image-api/config.ts b/lib/external-image-api/config.ts
new file mode 100644
index 000000000..f58ff3a04
--- /dev/null
+++ b/lib/external-image-api/config.ts
@@ -0,0 +1,104 @@
+export const EXTERNAL_IMAGE_API_CONFIG_KEY = "external_image_api_config";
+
+export type ExternalImageApiAccessKey = {
+ id: string;
+ name: string;
+ apiKey: string;
+ enabled: boolean;
+ notes: string;
+};
+
+export type ExternalImageApiConfig = {
+ enabled: boolean;
+ defaultOwnerEmail: string;
+ apiKeys: ExternalImageApiAccessKey[];
+};
+
+export const DEFAULT_EXTERNAL_IMAGE_API_OWNER_EMAIL =
+ "external-image-api@system.local";
+
+export const defaultExternalImageApiConfig: ExternalImageApiConfig = {
+ enabled: false,
+ defaultOwnerEmail: DEFAULT_EXTERNAL_IMAGE_API_OWNER_EMAIL,
+ apiKeys: [],
+};
+
+function normalizeText(value: unknown) {
+ return String(value || "").replace(/[\u0000-\u001F\u007F]/g, "").trim();
+}
+
+function normalizeEmail(value: unknown) {
+ const normalized = normalizeText(value).toLowerCase();
+ if (!normalized) return DEFAULT_EXTERNAL_IMAGE_API_OWNER_EMAIL;
+ return normalized;
+}
+
+function normalizeKeyId(value: unknown, fallbackIndex: number) {
+ const normalized = normalizeText(value)
+ .toLowerCase()
+ .replace(/[^a-z0-9_-]+/g, "-")
+ .replace(/-+/g, "-")
+ .replace(/^-|-$/g, "");
+ return normalized || `external-key-${fallbackIndex + 1}`;
+}
+
+function normalizeAccessKey(
+ value: unknown,
+ index: number,
+): ExternalImageApiAccessKey | null {
+ if (!value || typeof value !== "object") return null;
+
+ const raw = value as Partial;
+ const apiKey = normalizeText(raw.apiKey);
+ if (!apiKey) return null;
+
+ return {
+ id: normalizeKeyId(raw.id, index),
+ name: normalizeText(raw.name) || `第三方调用 Key ${index + 1}`,
+ apiKey,
+ enabled: raw.enabled !== false,
+ notes: normalizeText(raw.notes),
+ };
+}
+
+export function normalizeExternalImageApiConfig(
+ value: unknown,
+): ExternalImageApiConfig {
+ if (!value || typeof value !== "object") {
+ return defaultExternalImageApiConfig;
+ }
+
+ const raw = value as Partial & {
+ apiKeys?: unknown[];
+ };
+
+ const seenIds = new Set();
+ const apiKeys = Array.isArray(raw.apiKeys)
+ ? raw.apiKeys
+ .map((item, index) => normalizeAccessKey(item, index))
+ .filter((item): item is ExternalImageApiAccessKey => Boolean(item))
+ .map((item, index) => {
+ let nextId = item.id || `external-key-${index + 1}`;
+ while (seenIds.has(nextId)) {
+ nextId = `${nextId}-${seenIds.size + 1}`;
+ }
+ seenIds.add(nextId);
+ return {
+ ...item,
+ id: nextId,
+ };
+ })
+ : [];
+
+ return {
+ enabled: raw.enabled === true,
+ defaultOwnerEmail: normalizeEmail(raw.defaultOwnerEmail),
+ apiKeys,
+ };
+}
+
+export function maskExternalImageApiKey(apiKey: string) {
+ const trimmed = normalizeText(apiKey);
+ if (trimmed.length <= 8) return trimmed ? "********" : "";
+ return `${trimmed.slice(0, 4)}********${trimmed.slice(-4)}`;
+}
diff --git a/lib/external-image-api/service.ts b/lib/external-image-api/service.ts
new file mode 100644
index 000000000..9b9abbc9c
--- /dev/null
+++ b/lib/external-image-api/service.ts
@@ -0,0 +1,405 @@
+import { createHash, timingSafeEqual } from "node:crypto";
+import { sql } from "drizzle-orm";
+import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
+import { isImageGenerationModelDescriptor } from "@/lib/ai/model-classification";
+import { db } from "@/lib/db";
+import { getEnabledAiModelsWithProvider, getSystemSetting } from "@/lib/db/queries";
+import { user } from "@/lib/db/schema";
+import { generateDummyPassword } from "@/lib/db/utils";
+import {
+ DEFAULT_EXTERNAL_IMAGE_API_OWNER_EMAIL,
+ EXTERNAL_IMAGE_API_CONFIG_KEY,
+ normalizeExternalImageApiConfig,
+ type ExternalImageApiAccessKey,
+} from "@/lib/external-image-api/config";
+import { extractImageUrlsFromText } from "@/lib/image-url";
+import { uploadFile } from "@/lib/storage/service";
+
+type ImageModelEntry = Awaited>[number];
+
+function normalizeText(value: unknown) {
+ return String(value || "").trim();
+}
+
+function mimeTypeToExtension(mimeType: string) {
+ const normalized = mimeType.toLowerCase();
+ if (normalized.includes("png")) return "png";
+ if (normalized.includes("webp")) return "webp";
+ if (normalized.includes("gif")) return "gif";
+ if (normalized.includes("bmp")) return "bmp";
+ return "jpg";
+}
+
+function getExtensionFromUrl(url: string): string {
+ try {
+ const pathname = new URL(url).pathname;
+ const ext = pathname.split(".").pop()?.toLowerCase();
+ if (!ext) return "jpg";
+ if (["jpg", "jpeg", "png", "webp", "gif", "bmp"].includes(ext)) {
+ return ext;
+ }
+ } catch {
+ // ignore
+ }
+ return "jpg";
+}
+
+function safeTokenCompare(left: string, right: string) {
+ const a = Buffer.from(left);
+ const b = Buffer.from(right);
+ if (a.length !== b.length) return false;
+ return timingSafeEqual(a, b);
+}
+
+function extractBearerToken(request: Request) {
+ const authorization = normalizeText(request.headers.get("authorization"));
+ if (/^bearer\s+/i.test(authorization)) {
+ return authorization.replace(/^bearer\s+/i, "").trim();
+ }
+ return normalizeText(request.headers.get("x-api-key"));
+}
+
+async function uploadRemoteImageToStorage(imageUrl: string): Promise {
+ const response = await fetch(imageUrl, {
+ method: "GET",
+ signal: AbortSignal.timeout(30_000),
+ });
+
+ if (!response.ok) {
+ throw new Error(
+ `Remote image fetch failed: ${response.status} ${response.statusText}`,
+ );
+ }
+
+ const contentType = response.headers.get("content-type")?.toLowerCase() || "";
+ if (contentType && !contentType.startsWith("image/")) {
+ throw new Error(`Remote url is not an image: ${contentType}`);
+ }
+
+ const arrayBuffer = await response.arrayBuffer();
+ if (!arrayBuffer.byteLength) {
+ throw new Error("Remote image content is empty");
+ }
+
+ const ext = contentType
+ ? mimeTypeToExtension(contentType)
+ : getExtensionFromUrl(imageUrl);
+ const fileName = `external-image-api-${Date.now()}-${Math.random()
+ .toString(36)
+ .slice(2, 8)}.${ext}`;
+ const uploaded = await uploadFile(fileName, arrayBuffer);
+ return uploaded.url;
+}
+
+async function uploadDataUrlImage(dataUrl: string): Promise {
+ const match = dataUrl.match(/^data:([^;]+);base64,([\s\S]+)$/i);
+ if (!match) {
+ throw new Error("Invalid data URL image format");
+ }
+
+ const mimeType = match[1] || "image/jpeg";
+ const base64Data = normalizeText(match[2]).replace(/\s+/g, "");
+ if (!base64Data) {
+ throw new Error("Empty base64 image content");
+ }
+
+ const buffer = Buffer.from(base64Data, "base64");
+ const fileName = `external-inline-${Date.now()}-${Math.random()
+ .toString(36)
+ .slice(2, 8)}.${mimeTypeToExtension(mimeType)}`;
+ const arrayBuffer = buffer.buffer.slice(
+ buffer.byteOffset,
+ buffer.byteOffset + buffer.byteLength,
+ ) as ArrayBuffer;
+ const uploaded = await uploadFile(fileName, arrayBuffer);
+ return uploaded.url;
+}
+
+async function ensureHostedImageUrl(imageUrl: string): Promise {
+ const normalizedUrl = normalizeText(imageUrl);
+ if (!normalizedUrl) {
+ throw new Error("Image URL is empty");
+ }
+
+ if (normalizedUrl.startsWith("data:image")) {
+ return uploadDataUrlImage(normalizedUrl);
+ }
+
+ if (!/^https?:\/\//i.test(normalizedUrl)) {
+ throw new Error(`Unsupported image URL: ${normalizedUrl}`);
+ }
+
+ if (/\/uploads\//i.test(normalizedUrl)) {
+ return normalizedUrl;
+ }
+
+ return uploadRemoteImageToStorage(normalizedUrl);
+}
+
+export async function ensureHostedImageMarkdown(
+ markdown: string,
+ imageSize = "2K",
+) {
+ const pattern = /!\[([^\]]*)\]\(([^)]+)\)/gim;
+ const matches = [...markdown.matchAll(pattern)];
+
+ if (!matches.length) {
+ const plainUrls = extractImageUrlsFromText(markdown);
+ if (!plainUrls.length) {
+ return markdown;
+ }
+
+ const hostedUrls = await Promise.all(
+ plainUrls.map((url) => ensureHostedImageUrl(url)),
+ );
+ return hostedUrls
+ .map((url) => ``)
+ .join("\n");
+ }
+
+ let rebuilt = "";
+ let cursor = 0;
+
+ for (const match of matches) {
+ const raw = match[0];
+ const sourceUrl = match[2] || "";
+ const start = match.index ?? -1;
+ if (start < 0) continue;
+
+ rebuilt += markdown.slice(cursor, start);
+ const hostedUrl = await ensureHostedImageUrl(sourceUrl);
+ rebuilt += ``;
+ cursor = start + raw.length;
+ }
+
+ rebuilt += markdown.slice(cursor);
+ return rebuilt;
+}
+
+export function extractGeneratedImagesFromMarkdown(markdown: string) {
+ const pattern = /!\[([^\]]*)\]\(([^)]+)\)/gim;
+ const matches = [...markdown.matchAll(pattern)];
+
+ return matches.map((match, index) => {
+ const alt = match[1] || "";
+ const thumbnailUrl = match[2] || "";
+ let originalUrl = thumbnailUrl;
+ let imageSize = "2K";
+ const metaMatch = alt.match(/thumb\|\|\|([^|]+)\|\|\|(.+)$/i);
+ if (metaMatch) {
+ originalUrl = normalizeText(metaMatch[1]) || thumbnailUrl;
+ imageSize = normalizeText(metaMatch[2]) || imageSize;
+ }
+ return {
+ id: index + 1,
+ originalUrl,
+ thumbnailUrl,
+ imageSize,
+ };
+ });
+}
+
+export async function getExternalImageApiConfig() {
+ const setting = await getSystemSetting({
+ key: EXTERNAL_IMAGE_API_CONFIG_KEY,
+ }).catch(() => null);
+ return normalizeExternalImageApiConfig(setting?.value);
+}
+
+export async function authenticateExternalImageApiRequest(request: Request) {
+ const config = await getExternalImageApiConfig();
+ const token = extractBearerToken(request);
+
+ if (!config.enabled) {
+ return {
+ config,
+ accessKey: null,
+ token,
+ error: "第三方生图 API 未启用",
+ status: 403 as const,
+ };
+ }
+
+ if (!token) {
+ return {
+ config,
+ accessKey: null,
+ token,
+ error: "缺少 API Key",
+ status: 401 as const,
+ };
+ }
+
+ const accessKey =
+ config.apiKeys.find(
+ (item) => item.enabled && safeTokenCompare(item.apiKey, token),
+ ) || null;
+
+ if (!accessKey) {
+ return {
+ config,
+ accessKey,
+ token,
+ error: "API Key 无效或已禁用",
+ status: 401 as const,
+ };
+ }
+
+ return {
+ config,
+ accessKey,
+ token,
+ error: null,
+ status: 200 as const,
+ };
+}
+
+export async function getExternalImageApiModels() {
+ const models = await getEnabledAiModelsWithProvider(false, false);
+
+ return models.filter((entry) =>
+ isImageGenerationModelDescriptor({
+ providerType: entry.provider.type,
+ modelId: entry.model.modelId,
+ capabilities: entry.model.capabilities,
+ supportsImageGen: entry.model.supportsImageGen,
+ }),
+ );
+}
+
+export function serializeExternalImageApiModel(entry: ImageModelEntry) {
+ return {
+ id: entry.model.id,
+ modelId: entry.model.modelId,
+ displayName: entry.model.displayName,
+ description: entry.model.description,
+ providerType: entry.provider.type,
+ providerName: entry.provider.name,
+ avatarUrl: entry.model.avatarUrl,
+ imageGenConfig: entry.model.imageGenConfig,
+ };
+}
+
+export async function resolveExternalImageApiModel(modelRef?: string | null) {
+ const models = await getExternalImageApiModels();
+ if (!models.length) {
+ throw new Error("当前没有启用可用于生图的模型");
+ }
+
+ const normalizedRef = normalizeText(modelRef).toLowerCase();
+ if (!normalizedRef) {
+ return models[0];
+ }
+
+ const matched = models.find((entry) => {
+ const candidates = [
+ entry.model.id,
+ entry.model.modelId,
+ entry.model.displayName,
+ ]
+ .map((item) => normalizeText(item).toLowerCase())
+ .filter(Boolean);
+ return candidates.includes(normalizedRef);
+ });
+
+ if (!matched) {
+ throw new Error("未找到匹配的生图模型");
+ }
+
+ return matched;
+}
+
+export async function resolveExternalImageApiOwnerUserId(ownerEmail?: string | null) {
+ const normalizedEmail = normalizeText(ownerEmail).toLowerCase() ||
+ DEFAULT_EXTERNAL_IMAGE_API_OWNER_EMAIL;
+
+ const existing = await db
+ .select({ id: user.id })
+ .from(user)
+ .where(sql`lower(${user.email}) = ${normalizedEmail}`)
+ .limit(1);
+
+ if (existing[0]?.id) {
+ return existing[0].id;
+ }
+
+ const [created] = await db
+ .insert(user)
+ .values({
+ email: normalizedEmail,
+ password: generateDummyPassword(),
+ role: "guest",
+ name: "第三方生图 API",
+ isActive: true,
+ })
+ .returning({ id: user.id });
+
+ if (!created?.id) {
+ throw new Error("创建第三方 API 挂载用户失败");
+ }
+
+ return created.id;
+}
+
+export async function generateImagesWithModel(params: {
+ modelDbId: string;
+ prompt: string;
+ negativePrompt?: string | null;
+ imageCount: number;
+ aspectRatio?: string | null;
+ imageSize?: string | null;
+}) {
+ const languageModel = await getLanguageModelById(params.modelDbId);
+ if (!languageModel || typeof (languageModel as any).doGenerate !== "function") {
+ throw new Error("当前模型暂不支持第三方生图调用");
+ }
+
+ const composedPrompt = [
+ normalizeText(params.prompt),
+ normalizeText(params.negativePrompt)
+ ? `Negative prompt:\n${normalizeText(params.negativePrompt)}`
+ : "",
+ ]
+ .filter(Boolean)
+ .join("\n\n");
+
+ const result = await (languageModel as any).doGenerate({
+ messages: [
+ {
+ role: "user",
+ content: [{ type: "text", text: composedPrompt }],
+ },
+ ],
+ imageParams: {
+ imageCount: params.imageCount,
+ aspectRatio: normalizeText(params.aspectRatio) || undefined,
+ imageSize: normalizeText(params.imageSize) || undefined,
+ },
+ });
+
+ if (result?.finishReason === "error") {
+ throw new Error(result.text || "图片生成失败");
+ }
+
+ const text = normalizeText(result?.text);
+ if (!text) {
+ throw new Error("模型未返回生成图片");
+ }
+
+ return text;
+}
+
+export function buildExternalImageApiKeyFingerprint(
+ accessKey: ExternalImageApiAccessKey | null,
+) {
+ if (!accessKey?.apiKey) return null;
+ return createHash("sha256").update(accessKey.apiKey).digest("hex").slice(0, 16);
+}
+
+export function getRequesterIp(request: Request) {
+ const forwarded = normalizeText(request.headers.get("x-forwarded-for"));
+ if (forwarded) {
+ return forwarded.split(",")[0]?.trim() || null;
+ }
+ return normalizeText(request.headers.get("x-real-ip")) || null;
+}