feat: add external image generation api
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-18 15:56:08 +08:00
parent 040a1f514f
commit b4698be089
14 changed files with 1662 additions and 15 deletions

View File

@ -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() {
</CardContent>
</Card>
<ExternalImageApiSettingsCard />
<Card>
<CardHeader className="flex flex-col gap-4 space-y-0 md:flex-row md:items-start md:justify-between">
<div className="space-y-1.5">

View File

@ -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 },
);
}
}

View File

@ -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);
}

184
app/api/external/image/generate/route.ts vendored Normal file
View File

@ -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 },
);
}
}

42
app/api/external/image/models/route.ts vendored Normal file
View File

@ -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 },
);
}
}

View File

@ -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<ExternalImageApiConfig>(
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 (
<Card>
<CardHeader className="space-y-2">
<div className="flex items-center gap-3">
<div className="rounded-full bg-primary/10 p-2 text-primary">
<KeyRound className="h-5 w-5" />
</div>
<div>
<CardTitle className="text-xl">第三方生图 API</CardTitle>
<CardDescription>
对外开放当前系统里已启用的生图模型。第三方先调
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-[11px]">
/api/external/image/models
</code>
获取模型名称,再调用
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-[11px]">
/api/external/image/generate
</code>
生图。结果会继续走我们的储存桶并落到数据库。
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 lg:grid-cols-[1.2fr_1fr]">
<div className="space-y-4 rounded-xl border p-4">
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label className="text-sm">启用第三方生图 API</Label>
<div className="text-xs text-muted-foreground">
关闭后第三方 API Key 会全部失效。
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={config.enabled}
onCheckedChange={(checked) =>
setConfig((current) => ({ ...current, enabled: checked }))
}
/>
<span className="min-w-[3rem] text-sm font-medium text-muted-foreground">
{config.enabled ? "已开启" : "已关闭"}
</span>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="externalImageApiOwnerEmail">
生成结果归属邮箱
</Label>
<Input
id="externalImageApiOwnerEmail"
value={config.defaultOwnerEmail}
onChange={(event) =>
setConfig((current) => ({
...current,
defaultOwnerEmail: event.target.value,
}))
}
placeholder="external-image-api@system.local"
/>
<div className="text-xs text-muted-foreground">
外部 API 生成图片会统一挂到这个用户下,方便我们保留生成记录、媒体资产和后续分析数据。
</div>
</div>
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
当前共 {config.apiKeys.length} 个 Key,启用中 {enabledKeyCount} 个。
外部调用支持
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-[11px]">
Authorization: Bearer &lt;apiKey&gt;
</code>
和
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-[11px]">
x-api-key
</code>
两种方式。
</div>
<div className="space-y-3">
{config.apiKeys.map((item, index) => (
<div
key={item.id}
className="space-y-3 rounded-xl border p-4"
>
<div className="flex items-center justify-between">
<div className="text-sm font-medium">
Key #{index + 1}
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemoveKey(item.id)}
>
<Trash2 className="mr-2 h-4 w-4" />
删除
</Button>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label>名称</Label>
<Input
value={item.name}
onChange={(event) =>
setConfig((current) => ({
...current,
apiKeys: current.apiKeys.map((record) =>
record.id === item.id
? { ...record, name: event.target.value }
: record,
),
}))
}
placeholder="渠道 A / 合作方 B"
/>
</div>
<div className="space-y-2">
<Label>Key ID</Label>
<Input
value={item.id}
onChange={(event) =>
setConfig((current) => ({
...current,
apiKeys: current.apiKeys.map((record) =>
record.id === item.id
? { ...record, id: event.target.value }
: record,
),
}))
}
placeholder="partner-a"
/>
</div>
</div>
<div className="space-y-2">
<Label>API Key</Label>
<Input
value={item.apiKey}
onChange={(event) =>
setConfig((current) => ({
...current,
apiKeys: current.apiKeys.map((record) =>
record.id === item.id
? { ...record, apiKey: event.target.value }
: record,
),
}))
}
placeholder="请输入调用第三方生图 API 的密钥"
/>
<div className="text-xs text-muted-foreground">
当前显示:{maskExternalImageApiKey(item.apiKey)}
</div>
</div>
<div className="space-y-2">
<Label>备注</Label>
<Textarea
rows={2}
value={item.notes}
onChange={(event) =>
setConfig((current) => ({
...current,
apiKeys: current.apiKeys.map((record) =>
record.id === item.id
? { ...record, notes: event.target.value }
: record,
),
}))
}
placeholder="可以记录对接方、用途、流量限制等"
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="text-xs text-muted-foreground">
单独停用后,这个 Key 会立即失效,但不会影响其他 Key。
</div>
<div className="flex items-center gap-2">
<Switch
checked={item.enabled}
onCheckedChange={(checked) =>
setConfig((current) => ({
...current,
apiKeys: current.apiKeys.map((record) =>
record.id === item.id
? { ...record, enabled: checked }
: record,
),
}))
}
/>
<span className="text-sm text-muted-foreground">
{item.enabled ? "已启用" : "已停用"}
</span>
</div>
</div>
</div>
))}
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={handleAddKey}>
<Plus className="mr-2 h-4 w-4" />
新增 API Key
</Button>
<Button type="button" onClick={handleSave} disabled={isSaving}>
<Save className="mr-2 h-4 w-4" />
{isSaving ? "保存中..." : "保存配置"}
</Button>
</div>
</div>
<div className="space-y-4 rounded-xl border p-4">
<div className="space-y-1">
<div className="text-base font-medium">调用说明</div>
<div className="text-sm text-muted-foreground">
这条第三方接口直接复用我们系统里启用的生图模型、模型名称和存储链路。
</div>
</div>
<div className="rounded-xl border border-dashed bg-muted/20 p-4 text-sm">
<div className="font-medium">接口 1:获取模型</div>
<code className="mt-2 block rounded bg-background px-3 py-2 text-xs">
GET /api/external/image/models
</code>
</div>
<div className="rounded-xl border border-dashed bg-muted/20 p-4 text-sm">
<div className="font-medium">接口 2:发起生图</div>
<code className="mt-2 block whitespace-pre-wrap rounded bg-background px-3 py-2 text-xs">
{`POST /api/external/image/generate
{
"model": "模型 UUID / 模型ID / 显示名称",
"prompt": "请生成一张暖色家居客厅图",
"negativePrompt": "不要出现人物",
"imageCount": 2,
"aspectRatio": "16:9",
"imageSize": "2K"
}`}
</code>
</div>
<div className="rounded-xl border border-dashed bg-muted/20 p-4 text-sm text-muted-foreground">
返回图片地址会优先上传到我们的储存桶,然后再写入
<code className="mx-1 rounded bg-background px-1 py-0.5 text-[11px]">
GeneratedImage
</code>
和媒体资产表。
</div>
</div>
</div>
<div className="space-y-3 rounded-xl border p-4">
<div className="space-y-1">
<div className="text-base font-medium">最近 50 条请求日志</div>
<div className="text-sm text-muted-foreground">
这里会记录 API Key、模型、提示词、生成张数、耗时和结果地址,方便排查对接问题。
</div>
</div>
<div className="space-y-3">
{logsData?.data?.logs?.length ? (
logsData.data.logs.map((log) => (
<div key={log.id} className="rounded-lg border p-4">
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium">
{log.modelDisplayName || log.modelRuntimeId || "未记录模型"}
</div>
<span
className={
log.status === "succeeded"
? "rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs text-emerald-600"
: log.status === "failed"
? "rounded-full bg-rose-500/10 px-2 py-0.5 text-xs text-rose-600"
: "rounded-full bg-slate-500/10 px-2 py-0.5 text-xs text-slate-600"
}
>
{log.status}
</span>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{log.apiKeyName || log.apiKeyId || "未命名 Key"}
</span>
</div>
<div className="mt-2 text-sm text-muted-foreground">
{log.prompt || "无提示词"}
</div>
<div className="mt-3 grid gap-2 text-xs text-muted-foreground md:grid-cols-2 xl:grid-cols-4">
<div>归属用户:{log.ownerUserEmail || "-"}</div>
<div>生成张数:{log.imageCount}</div>
<div>
规格:{log.aspectRatio || "-"} / {log.imageSize || "-"}
</div>
<div>
耗时:{typeof log.durationMs === "number" ? `${log.durationMs}ms` : "-"}
</div>
<div>请求 IP:{log.requesterIp || "-"}</div>
<div>生成图片:{log.generatedImageUrls?.length || 0} 张</div>
<div>开始时间:{formatBeijingTime(log.createdAt)}</div>
<div>
完成时间:
{log.completedAt ? formatBeijingTime(log.completedAt) : "-"}
</div>
</div>
{log.errorMessage ? (
<div className="mt-3 rounded-lg bg-rose-500/5 px-3 py-2 text-sm text-rose-600">
{log.errorMessage}
</div>
) : null}
</div>
))
) : (
<div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
还没有第三方生图 API 请求记录。
</div>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -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 上扩展

View File

@ -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<void>;
) => Promise<{
savedImageCount: number;
savedImageIds: string[];
savedAssetIds: string[];
savedOriginalUrls: string[];
}>;
export interface DirectAgentImageEditExecutionResult {
originalUrl: string;

View File

@ -88,8 +88,8 @@ async function resolveBillingModelByReference(modelRef: string): Promise<any | u
export async function saveGeneratedImages(
imageMarkdown: string,
userId: string,
chatId: string,
messageId: string,
chatId: string | null,
messageId: string | null,
prompt: string,
modelId: string,
options?: {
@ -103,12 +103,19 @@ export async function saveGeneratedImages(
requestedImageCount?: number;
aspectRatio?: string;
imageSize?: string;
skipBilling?: boolean;
},
): Promise<void> {
): 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,
};
}

View File

@ -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
$$;

View File

@ -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
}
]
}

View File

@ -582,6 +582,37 @@ export const generatedImage = pgTable("GeneratedImage", {
export type GeneratedImage = InferSelectModel<typeof generatedImage>;
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(),

View File

@ -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<ExternalImageApiAccessKey>;
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<ExternalImageApiConfig> & {
apiKeys?: unknown[];
};
const seenIds = new Set<string>();
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)}`;
}

View File

@ -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<ReturnType<typeof getEnabledAiModelsWithProvider>>[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<string> {
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<string> {
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<string> {
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) => `![thumb|||${url}|||${imageSize}](${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 += `![thumb|||${hostedUrl}|||${imageSize}](${hostedUrl})`;
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;
}