fix: harden enhance pro task charging
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-04-27 18:38:42 +08:00
parent 9f17020582
commit b8a59fe2a4
8 changed files with 679 additions and 195 deletions

View File

@ -1,4 +1,5 @@
import { createHash } from "node:crypto";
import { and, eq, or } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
@ -11,7 +12,11 @@ import {
getUserById,
upsertMediaAsset,
} from "@/lib/db/queries";
import { generatedImage } from "@/lib/db/schema";
import {
enhanceProTask,
generatedImage,
pointsLedger,
} from "@/lib/db/schema";
import {
ENHANCE_PRO_KREA_SETTING_KEY,
normalizeEnhanceProKreaConfig,
@ -24,7 +29,6 @@ import { calculateEnhanceProPointsEstimate } from "@/lib/points/config";
import { uploadFile } from "@/lib/storage/service";
const ENHANCE_PRO_CREATE_TIMEOUT_MS = 120_000;
const ENHANCE_PRO_SYNC_WAIT_TIMEOUT_MS = 20_000;
const ENHANCE_PRO_TASK_POLL_INTERVAL_MS = 1_500;
const ENHANCE_PRO_MODEL_ID = "krea-topaz-generative";
const ENHANCE_PRO_MAX_RESULT_IMAGES = 4;
@ -37,6 +41,7 @@ type EnhanceProRequestBody = {
prompt?: string;
chatId?: string | null;
messageId?: string | null;
clientRequestId?: string | null;
width?: number;
height?: number;
batchSize?: number;
@ -64,6 +69,27 @@ type FinalizedImageResult = {
height?: number;
};
type EnhanceProStoredRequest = {
prompt: string;
sourceImageUrl: string;
width: number;
height: number;
batchSize: number;
model: EnhanceProKreaModel;
upscalingActivated: boolean;
imageScalingFactor: number;
chatId: string | null;
messageId: string | null;
};
type EnhanceProSuccessPayload = Awaited<
ReturnType<typeof finalizeEnhanceProTaskResult>
> & {
chargedPoints: number;
};
type EnhanceProTaskRecord = typeof enhanceProTask.$inferSelect;
function clampNumber(value: unknown, min: number, max: number, fallback: number) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
@ -85,6 +111,10 @@ function normalizeJobStatus(value: unknown) {
return String(value || "").trim().toLowerCase();
}
function normalizeClientRequestId(value: unknown) {
return String(value || "").trim().slice(0, 64);
}
function getEnhanceProKeyFingerprint(apiKey: string) {
return createHash("sha256")
.update(String(apiKey || "").trim())
@ -126,14 +156,13 @@ function resolveEnhanceProApiKey(
};
}
function buildEnhanceProTaskPendingResponse(taskId: string, taskKeyId?: string | null) {
function buildEnhanceProTaskPendingResponse(taskId: string) {
return NextResponse.json(
{
success: true,
processing: true,
status: "running",
taskId,
taskKeyId,
pollAfterMs: ENHANCE_PRO_TASK_POLL_INTERVAL_MS,
},
{ status: 202 }
@ -252,7 +281,8 @@ async function resolveSourceInput(body: EnhanceProRequestBody) {
}
if (imageUrl) {
const hasRequestedDimensions = Number.isFinite(Number(body.width)) && Number.isFinite(Number(body.height));
const hasRequestedDimensions =
Number.isFinite(Number(body.width)) && Number.isFinite(Number(body.height));
if (hasRequestedDimensions) {
return {
sourceImageUrl: imageUrl,
@ -351,12 +381,7 @@ async function createEnhanceProJob(params: {
}
}
async function pollEnhanceProJob(
config: EnhanceProKreaConfig,
jobId: string,
delay = ENHANCE_PRO_TASK_POLL_INTERVAL_MS,
timeout = ENHANCE_PRO_SYNC_WAIT_TIMEOUT_MS,
): Promise<any> {
async function fetchEnhanceProJobStatus(config: EnhanceProKreaConfig, jobId: string) {
const apiUrl = normalizeBaseUrl(config.baseUrl);
const response = await fetch(`${apiUrl}/jobs/${jobId}`, {
method: "GET",
@ -371,27 +396,7 @@ async function pollEnhanceProJob(
throw new Error(`查询任务状态失败: ${response.status} ${detail || response.statusText}`);
}
const payload = await response.json();
const status = normalizeJobStatus(payload?.status);
if (status === "completed") {
return payload;
}
if (status === "failed" || status === "cancelled") {
throw new Error(`Krea 任务${status === "failed" ? "失败" : "已取消"}`);
}
if (timeout <= 0) {
throw new Error("轮询超时");
}
await new Promise((resolve) => setTimeout(resolve, delay));
return pollEnhanceProJob(config, jobId, delay, timeout - delay);
}
function isEnhanceProTaskStillProcessing(error: unknown) {
return error instanceof Error && /轮询超时/i.test(error.message || "");
return await response.json();
}
async function saveGeneratedImageRecord(params: {
@ -534,6 +539,235 @@ async function finalizeEnhanceProTaskResult(params: {
};
}
function parseEnhanceProStoredRequest(task: EnhanceProTaskRecord): EnhanceProStoredRequest {
const request =
task.request && typeof task.request === "object"
? (task.request as Partial<EnhanceProStoredRequest>)
: {};
const width = sanitizeDimension(request.width, 1024);
const height = sanitizeDimension(request.height, 1024);
return {
prompt: String(request.prompt || task.prompt || "").trim() || "图像增强 Pro",
sourceImageUrl:
String(request.sourceImageUrl || task.sourceImageUrl || "").trim(),
width,
height,
batchSize: clampInteger(request.batchSize, 1, 8, 1),
model:
request.model === "Recovery" ||
request.model === "Recovery V2" ||
request.model === "Reimagine" ||
request.model === "Redefine"
? request.model
: "Recovery V2",
upscalingActivated: normalizeBoolean(request.upscalingActivated, false),
imageScalingFactor: clampNumber(request.imageScalingFactor, 1, 8, 1),
chatId: request.chatId ? String(request.chatId) : task.chatId || null,
messageId: request.messageId ? String(request.messageId) : task.messageId || null,
};
}
async function getEnhanceProTaskForUser(params: { taskId: string; userId: string }) {
const rows = await db
.select()
.from(enhanceProTask)
.where(and(eq(enhanceProTask.id, params.taskId), eq(enhanceProTask.userId, params.userId)))
.limit(1);
return rows[0] ?? null;
}
async function getEnhanceProTaskByClientRequestId(params: {
userId: string;
clientRequestId: string;
}) {
const rows = await db
.select()
.from(enhanceProTask)
.where(
and(
eq(enhanceProTask.userId, params.userId),
eq(enhanceProTask.clientRequestId, params.clientRequestId),
),
)
.limit(1);
return rows[0] ?? null;
}
async function markEnhanceProTaskRunning(taskId: string) {
await db
.update(enhanceProTask)
.set({
status: "running",
updatedAt: new Date(),
error: null,
})
.where(eq(enhanceProTask.id, taskId));
}
async function claimEnhanceProTaskFinalization(taskId: string) {
const rows = await db
.update(enhanceProTask)
.set({
status: "finalizing",
updatedAt: new Date(),
error: null,
})
.where(
and(
eq(enhanceProTask.id, taskId),
or(eq(enhanceProTask.status, "pending"), eq(enhanceProTask.status, "running")),
),
)
.returning();
return rows[0] ?? null;
}
async function markEnhanceProTaskFailed(taskId: string, errorMessage: string) {
await db
.update(enhanceProTask)
.set({
status: "failed",
error: errorMessage,
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(enhanceProTask.id, taskId));
}
async function persistEnhanceProTaskSuccess(params: {
taskId: string;
payload: EnhanceProSuccessPayload;
pointsCharged: boolean;
}) {
await db
.update(enhanceProTask)
.set({
status: "succeeded",
result: params.payload as Record<string, unknown>,
error: null,
pointsCharged: params.pointsCharged,
chargedAt: params.pointsCharged ? new Date() : null,
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(enhanceProTask.id, params.taskId));
}
async function consumeEnhanceProPointsOnce(
task: EnhanceProTaskRecord,
request: EnhanceProStoredRequest,
) {
if (task.pointsCost <= 0) {
return { ok: true, skipped: true };
}
const note = `图像增强 Pro 消耗积分(任务 ${task.id} / ${task.providerTaskId})`;
const existingLedger = await db
.select({ id: pointsLedger.id })
.from(pointsLedger)
.where(
and(
eq(pointsLedger.userId, task.userId),
eq(pointsLedger.reason, "enhance_pro_generation"),
eq(pointsLedger.note, note),
),
)
.limit(1);
if (existingLedger[0]) {
return { ok: true, skipped: true };
}
return await consumeUserPoints({
userId: task.userId,
amount: task.pointsCost,
reason: "enhance_pro_generation",
modelId: `${ENHANCE_PRO_MODEL_ID}:${request.model}`,
chatId: request.chatId,
messageId: request.messageId,
note,
});
}
function getStoredEnhanceProSuccessPayload(task: EnhanceProTaskRecord) {
if (!task.result || typeof task.result !== "object") {
return null;
}
return task.result as EnhanceProSuccessPayload;
}
async function finalizeEnhanceProTask(
task: EnhanceProTaskRecord,
taskResult: any,
startedAt?: number,
) {
const existingPayload = getStoredEnhanceProSuccessPayload(task);
if (task.status === "succeeded" && existingPayload) {
return existingPayload;
}
const claimedTask =
task.status === "finalizing" ? task : await claimEnhanceProTaskFinalization(task.id);
if (!claimedTask) {
return null;
}
const request = parseEnhanceProStoredRequest(claimedTask);
try {
const responsePayload = await finalizeEnhanceProTaskResult({
userId: claimedTask.userId,
taskResult,
prompt: request.prompt,
chatId: request.chatId,
messageId: request.messageId,
startedAt,
sourceImageUrls: request.sourceImageUrl ? [request.sourceImageUrl] : undefined,
width: request.width,
height: request.height,
});
let pointsCharged = claimedTask.pointsCharged;
if (!pointsCharged && claimedTask.pointsCost > 0) {
try {
const chargeResult = await consumeEnhanceProPointsOnce(claimedTask, request);
pointsCharged = chargeResult.ok || chargeResult.skipped;
} catch (pointsError) {
console.error("[Points] Failed to consume Enhance Pro points:", pointsError);
}
}
const payload: EnhanceProSuccessPayload = {
...responsePayload,
chargedPoints: pointsCharged ? claimedTask.pointsCost : 0,
};
await persistEnhanceProTaskSuccess({
taskId: claimedTask.id,
payload,
pointsCharged,
});
return payload;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "未知错误";
await markEnhanceProTaskFailed(claimedTask.id, errorMessage);
throw error;
}
}
function buildEnhanceProTaskFailedResponse(task: EnhanceProTaskRecord) {
return NextResponse.json(
{
error: "图像增强 Pro 处理失败",
detail: String(task.error || "任务失败"),
},
{ status: 500 },
);
}
async function loadEnhanceProConfig() {
const setting = await getSystemSetting({ key: ENHANCE_PRO_KREA_SETTING_KEY });
const existingValue =
@ -558,50 +792,76 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "缺少 taskId 参数" }, { status: 400 });
}
const task = await getEnhanceProTaskForUser({
taskId,
userId: session.user.id,
});
if (!task) {
return NextResponse.json({ error: "任务不存在" }, { status: 404 });
}
const storedPayload = getStoredEnhanceProSuccessPayload(task);
if (task.status === "succeeded" && storedPayload) {
return NextResponse.json(storedPayload);
}
if (task.status === "failed") {
return buildEnhanceProTaskFailedResponse(task);
}
if (task.status === "finalizing") {
return buildEnhanceProTaskPendingResponse(task.id);
}
const config = await loadEnhanceProConfig();
if (!config.enabled) {
return NextResponse.json({ error: "图像增强 Pro 功能未开启" }, { status: 403 });
}
const keySelection = resolveEnhanceProApiKey(config, searchParams.get("taskKeyId"));
const keySelection = resolveEnhanceProApiKey(config, task.taskKeyId);
if (!keySelection || !config.baseUrl) {
return NextResponse.json({ error: "图像增强 Pro API 尚未配置" }, { status: 400 });
}
let taskResult: any;
try {
taskResult = await pollEnhanceProJob(
{ ...config, apiKey: keySelection.apiKey },
taskId
);
} catch (pollError) {
if (isEnhanceProTaskStillProcessing(pollError)) {
return buildEnhanceProTaskPendingResponse(taskId, keySelection.keyId);
const taskResult = await fetchEnhanceProJobStatus(
{ ...config, apiKey: keySelection.apiKey },
task.providerTaskId,
);
const providerStatus = normalizeJobStatus(taskResult?.status);
if (providerStatus === "completed") {
const completedPayload = await finalizeEnhanceProTask(task, taskResult);
if (completedPayload) {
return NextResponse.json(completedPayload);
}
const refreshedTask = await getEnhanceProTaskForUser({
taskId,
userId: session.user.id,
});
const refreshedPayload = refreshedTask
? getStoredEnhanceProSuccessPayload(refreshedTask)
: null;
if (refreshedTask?.status === "succeeded" && refreshedPayload) {
return NextResponse.json(refreshedPayload);
}
if (refreshedTask?.status === "failed") {
return buildEnhanceProTaskFailedResponse(refreshedTask);
}
return buildEnhanceProTaskPendingResponse(task.id);
}
if (providerStatus === "failed" || providerStatus === "cancelled") {
const detail = `Krea 任务${providerStatus === "failed" ? "失败" : "已取消"}`;
await markEnhanceProTaskFailed(task.id, detail);
return NextResponse.json(
{
error: "图像增强 Pro 处理失败",
detail: pollError instanceof Error ? pollError.message : "轮询失败",
detail,
},
{ status: 500 },
);
}
const width = sanitizeDimension(searchParams.get("width"), 1024);
const height = sanitizeDimension(searchParams.get("height"), 1024);
const responsePayload = await finalizeEnhanceProTaskResult({
userId: session.user.id,
taskResult,
prompt: searchParams.get("prompt") || "图像增强 Pro",
chatId: searchParams.get("chatId"),
messageId: searchParams.get("messageId"),
sourceImageUrls: searchParams.get("sourceImageUrl")
? [String(searchParams.get("sourceImageUrl"))]
: undefined,
width,
height,
});
return NextResponse.json(responsePayload);
await markEnhanceProTaskRunning(task.id);
return buildEnhanceProTaskPendingResponse(task.id);
} catch (error) {
console.error("[Enhance Pro API][TaskStatus] 错误:", error);
return NextResponse.json(
@ -629,6 +889,24 @@ export async function POST(request: NextRequest) {
const requestConfig = { ...config, apiKey: keySelection.apiKey };
const body = (await request.json()) as EnhanceProRequestBody;
const clientRequestId = normalizeClientRequestId(body.clientRequestId);
if (clientRequestId) {
const existingTask = await getEnhanceProTaskByClientRequestId({
userId: session.user.id,
clientRequestId,
});
if (existingTask) {
const existingPayload = getStoredEnhanceProSuccessPayload(existingTask);
if (existingTask.status === "succeeded" && existingPayload) {
return NextResponse.json(existingPayload);
}
if (existingTask.status === "failed") {
return buildEnhanceProTaskFailedResponse(existingTask);
}
return buildEnhanceProTaskPendingResponse(existingTask.id);
}
}
const prompt = String(body.prompt || "").trim();
const chatId = body.chatId || null;
const messageId = body.messageId || null;
@ -706,78 +984,100 @@ export async function POST(request: NextRequest) {
});
const createdStatus = normalizeJobStatus(createdJob?.status);
const taskId = String(createdJob?.job_id || "").trim();
if (!taskId) {
const providerTaskId = String(createdJob?.job_id || "").trim();
if (!providerTaskId) {
return NextResponse.json(
{ error: "创建图像增强 Pro 任务失败", detail: "Krea 未返回 job_id" },
{ status: 500 },
);
}
if (pointsEstimate.chargeable) {
try {
await consumeUserPoints({
const storedRequest: EnhanceProStoredRequest = {
prompt: prompt || "图像增强 Pro",
sourceImageUrl: sourceInput.sourceImageUrl,
width: clampedDimensions.width,
height: clampedDimensions.height,
batchSize,
model: selectedModel,
upscalingActivated,
imageScalingFactor,
chatId,
messageId,
};
let createdTask: EnhanceProTaskRecord | null = null;
try {
const inserted = await db
.insert(enhanceProTask)
.values({
userId: session.user.id,
amount: pointsEstimate.cost,
reason: "enhance_pro_generation",
modelId: `${ENHANCE_PRO_MODEL_ID}:${selectedModel}`,
chatId,
messageId,
note: "图像增强 Pro 消耗积分",
clientRequestId: clientRequestId || null,
providerTaskId,
taskKeyId: keySelection.keyId,
status:
createdStatus === "completed"
? "running"
: createdStatus === "failed" || createdStatus === "cancelled"
? "failed"
: "pending",
prompt: storedRequest.prompt,
sourceImageUrl: storedRequest.sourceImageUrl,
request: storedRequest as unknown as Record<string, unknown>,
error:
createdStatus === "failed" || createdStatus === "cancelled"
? `Krea 任务${createdStatus === "failed" ? "失败" : "已取消"}`
: null,
pointsCost: pointsEstimate.chargeable ? pointsEstimate.cost : 0,
pointsCharged: false,
completedAt:
createdStatus === "failed" || createdStatus === "cancelled" ? new Date() : null,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
createdTask = inserted[0] ?? null;
} catch (insertError) {
if (clientRequestId) {
const existingTask = await getEnhanceProTaskByClientRequestId({
userId: session.user.id,
clientRequestId,
});
} catch (pointsError) {
console.error("[Points] Failed to consume Enhance Pro points:", pointsError);
if (existingTask) {
const existingPayload = getStoredEnhanceProSuccessPayload(existingTask);
if (existingTask.status === "succeeded" && existingPayload) {
return NextResponse.json(existingPayload);
}
if (existingTask.status === "failed") {
return buildEnhanceProTaskFailedResponse(existingTask);
}
return buildEnhanceProTaskPendingResponse(existingTask.id);
}
}
throw insertError;
}
if (createdStatus === "completed") {
const responsePayload = await finalizeEnhanceProTaskResult({
userId: session.user.id,
taskResult: createdJob,
prompt,
chatId,
messageId,
startedAt: startTime,
sourceImageUrls: [sourceInput.sourceImageUrl],
width: clampedDimensions.width,
height: clampedDimensions.height,
});
return NextResponse.json({
...responsePayload,
chargedPoints: pointsEstimate.chargeable ? pointsEstimate.cost : 0,
});
}
try {
const taskResult = await pollEnhanceProJob(requestConfig, taskId);
const responsePayload = await finalizeEnhanceProTaskResult({
userId: session.user.id,
taskResult,
prompt,
chatId,
messageId,
startedAt: startTime,
sourceImageUrls: [sourceInput.sourceImageUrl],
width: clampedDimensions.width,
height: clampedDimensions.height,
});
return NextResponse.json({
...responsePayload,
chargedPoints: pointsEstimate.chargeable ? pointsEstimate.cost : 0,
});
} catch (pollError) {
if (isEnhanceProTaskStillProcessing(pollError)) {
return buildEnhanceProTaskPendingResponse(taskId, keySelection.keyId);
}
if (!createdTask) {
return NextResponse.json(
{
error: "图像增强 Pro 处理失败",
detail: pollError instanceof Error ? pollError.message : "轮询失败",
},
{ error: "创建图像增强 Pro 任务失败", detail: "任务入库失败" },
{ status: 500 },
);
}
if (createdStatus === "completed") {
const responsePayload = await finalizeEnhanceProTask(createdTask, createdJob, startTime);
if (responsePayload) {
return NextResponse.json(responsePayload);
}
return buildEnhanceProTaskPendingResponse(createdTask.id);
}
if (createdStatus === "failed" || createdStatus === "cancelled") {
return buildEnhanceProTaskFailedResponse(createdTask);
}
return buildEnhanceProTaskPendingResponse(createdTask.id);
} catch (error) {
console.error("[Enhance Pro API] 错误:", error);
return NextResponse.json(

View File

@ -60,6 +60,46 @@ import {
const fetcher = (url: string) => fetch(url).then((res) => res.json());
function createEnhanceProClientRequestId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `enhance-pro-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function shouldPreserveEnhanceProRequestId(message: string) {
return /任务.*已提交|避免重复提交|稍后重试查看结果|failed to fetch|network/i.test(
String(message || "")
);
}
function buildEnhanceProRequestSignature(params: EnhanceProRequestParams) {
return JSON.stringify({
imageUrl: params.imageUrl || null,
image_base64: params.image_base64 || null,
mimeType: params.mimeType || null,
prompt: params.prompt || "",
width: params.width,
height: params.height,
batchSize: params.batchSize || 1,
seed: params.seed || 0,
output_format: params.output_format || null,
subject_detection: params.subject_detection || null,
face_enhancement: params.face_enhancement === true,
face_enhancement_creativity: params.face_enhancement_creativity ?? null,
face_enhancement_strength: params.face_enhancement_strength ?? null,
crop_to_fill: params.crop_to_fill === true,
model: params.model || null,
upscaling_activated: params.upscaling_activated === true,
image_scaling_factor: params.image_scaling_factor ?? 1,
creativity: params.creativity ?? null,
texture: params.texture ?? null,
sharpen: params.sharpen ?? null,
denoise: params.denoise ?? null,
detail: params.detail ?? null,
});
}
type EnhanceProClientConfig = {
enabled: boolean;
providerName: string;
@ -398,6 +438,8 @@ export function EnhanceProWorkspace({
const [faceOpen, setFaceOpen] = useState(true);
const [comparePosition, setComparePosition] = useState(50);
const compareDragPointerIdRef = useRef<number | null>(null);
const requestIdRef = useRef<string | null>(null);
const requestSignatureRef = useRef<string | null>(null);
const { data: configPayload, isLoading } = useSWR<{
success?: boolean;
@ -552,50 +594,61 @@ export function EnhanceProWorkspace({
setIsSubmitting(true);
setLastErrorMessage("");
const requestParams: EnhanceProRequestParams = {
...sourcePayload,
prompt: formState.prompt.trim() || "图像增强 Pro",
width: clampNumber(
Math.round(formState.width),
1,
config.maxOutputEdge
),
height: clampNumber(
Math.round(formState.height),
1,
config.maxOutputEdge
),
batchSize: config.allowBatchSize
? clampNumber(Math.round(formState.batchSize), 1, config.maxBatchSize)
: 1,
seed: Math.round(formState.seed),
output_format: formState.output_format,
subject_detection: formState.subject_detection,
face_enhancement: formState.face_enhancement,
face_enhancement_creativity: clampNumber(
formState.face_enhancement_creativity,
0,
1
),
face_enhancement_strength: clampNumber(
formState.face_enhancement_strength,
0,
1
),
crop_to_fill: formState.crop_to_fill,
model: formState.model,
upscaling_activated: formState.upscaling_activated,
image_scaling_factor: clampNumber(
formState.image_scaling_factor,
1,
config.maxUpscaleFactor
),
creativity: clampNumber(Math.round(formState.creativity), 1, 6),
texture: clampNumber(Math.round(formState.texture), 1, 5),
sharpen: clampNumber(formState.sharpen, 0, 1),
denoise: clampNumber(formState.denoise, 0, 1),
detail: clampNumber(formState.detail, 0, 1),
};
const requestSignature = buildEnhanceProRequestSignature(requestParams);
const clientRequestId =
requestIdRef.current && requestSignatureRef.current === requestSignature
? requestIdRef.current
: createEnhanceProClientRequestId();
requestIdRef.current = clientRequestId;
requestSignatureRef.current = requestSignature;
try {
const result = await imageGenerationService.enhanceImagePro({
...sourcePayload,
prompt: formState.prompt.trim() || "图像增强 Pro",
width: clampNumber(
Math.round(formState.width),
1,
config.maxOutputEdge
),
height: clampNumber(
Math.round(formState.height),
1,
config.maxOutputEdge
),
batchSize: config.allowBatchSize
? clampNumber(Math.round(formState.batchSize), 1, config.maxBatchSize)
: 1,
seed: Math.round(formState.seed),
output_format: formState.output_format,
subject_detection: formState.subject_detection,
face_enhancement: formState.face_enhancement,
face_enhancement_creativity: clampNumber(
formState.face_enhancement_creativity,
0,
1
),
face_enhancement_strength: clampNumber(
formState.face_enhancement_strength,
0,
1
),
crop_to_fill: formState.crop_to_fill,
model: formState.model,
upscaling_activated: formState.upscaling_activated,
image_scaling_factor: clampNumber(
formState.image_scaling_factor,
1,
config.maxUpscaleFactor
),
creativity: clampNumber(Math.round(formState.creativity), 1, 6),
texture: clampNumber(Math.round(formState.texture), 1, 5),
sharpen: clampNumber(formState.sharpen, 0, 1),
denoise: clampNumber(formState.denoise, 0, 1),
detail: clampNumber(formState.detail, 0, 1),
...requestParams,
clientRequestId,
});
if (!result.success || !result.data?.images?.length) {
@ -604,12 +657,18 @@ export function EnhanceProWorkspace({
setGeneratedImages(result.data.images);
setSelectedResultIndex(0);
requestIdRef.current = null;
requestSignatureRef.current = null;
toast.success(
`图像增强 Pro 完成,已生成 ${result.data.images.length} 张结果`
);
} catch (error) {
const message =
error instanceof Error ? error.message : "图像增强 Pro 失败";
if (!shouldPreserveEnhanceProRequestId(message)) {
requestIdRef.current = null;
requestSignatureRef.current = null;
}
setLastErrorMessage(message);
toast.error(message);
} finally {

View File

@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import useSWR from "swr";
import { Loader2, Sparkles } from "lucide-react";
import { toast } from "sonner";
@ -30,6 +30,44 @@ import { imageGenerationService, type EnhanceProImageResult } from "@/services/c
const fetcher = (url: string) => fetch(url).then((res) => res.json());
function createEnhanceProClientRequestId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `enhance-pro-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function shouldPreserveEnhanceProRequestId(message: string) {
return /任务.*已提交|避免重复提交|稍后重试查看结果|failed to fetch|network/i.test(
String(message || "")
);
}
function buildEnhanceProRequestSignature(params: {
imageUrl: string;
prompt: string;
width: number;
height: number;
batchSize: number;
seed: number;
output_format: EnhanceProOutputFormat;
subject_detection: EnhanceProSubjectDetection;
face_enhancement: boolean;
face_enhancement_creativity: number;
face_enhancement_strength: number;
crop_to_fill: boolean;
model: EnhanceProKreaModel;
upscaling_activated: boolean;
image_scaling_factor: number;
creativity: number;
texture: number;
sharpen: number;
denoise: number;
detail: number;
}) {
return JSON.stringify(params);
}
type EnhanceProClientConfig = {
enabled: boolean;
providerName: string;
@ -196,6 +234,8 @@ export function EnhanceProDialog({
const [isSubmitting, setIsSubmitting] = useState(false);
const [naturalSize, setNaturalSize] = useState({ width: 1024, height: 1024 });
const [selectedPresetId, setSelectedPresetId] = useState("");
const requestIdRef = useRef<string | null>(null);
const requestSignatureRef = useRef<string | null>(null);
const [formState, setFormState] = useState<FormState>(() =>
createDefaultFormState(FALLBACK_CONFIG, 1024, 1024),
);
@ -262,34 +302,45 @@ export function EnhanceProDialog({
}
setIsSubmitting(true);
const requestParams = {
imageUrl,
prompt: formState.prompt.trim() || "图像增强 Pro",
width: clampNumber(Math.round(formState.width), 1, config.maxOutputEdge),
height: clampNumber(Math.round(formState.height), 1, config.maxOutputEdge),
batchSize: config.allowBatchSize
? clampNumber(Math.round(formState.batchSize), 1, config.maxBatchSize)
: 1,
seed: Math.round(formState.seed),
output_format: formState.output_format,
subject_detection: formState.subject_detection,
face_enhancement: formState.face_enhancement,
face_enhancement_creativity: clampNumber(formState.face_enhancement_creativity, 0, 1),
face_enhancement_strength: clampNumber(formState.face_enhancement_strength, 0, 1),
crop_to_fill: formState.crop_to_fill,
model: formState.model,
upscaling_activated: formState.upscaling_activated,
image_scaling_factor: clampNumber(
formState.image_scaling_factor,
1,
config.maxUpscaleFactor
),
creativity: clampNumber(Math.round(formState.creativity), 1, 6),
texture: clampNumber(Math.round(formState.texture), 1, 5),
sharpen: clampNumber(formState.sharpen, 0, 1),
denoise: clampNumber(formState.denoise, 0, 1),
detail: clampNumber(formState.detail, 0, 1),
};
const requestSignature = buildEnhanceProRequestSignature(requestParams);
const clientRequestId =
requestIdRef.current && requestSignatureRef.current === requestSignature
? requestIdRef.current
: createEnhanceProClientRequestId();
requestIdRef.current = clientRequestId;
requestSignatureRef.current = requestSignature;
try {
const result = await imageGenerationService.enhanceImagePro({
imageUrl,
prompt: formState.prompt.trim() || "图像增强 Pro",
width: clampNumber(Math.round(formState.width), 1, config.maxOutputEdge),
height: clampNumber(Math.round(formState.height), 1, config.maxOutputEdge),
batchSize: config.allowBatchSize
? clampNumber(Math.round(formState.batchSize), 1, config.maxBatchSize)
: 1,
seed: Math.round(formState.seed),
output_format: formState.output_format,
subject_detection: formState.subject_detection,
face_enhancement: formState.face_enhancement,
face_enhancement_creativity: clampNumber(formState.face_enhancement_creativity, 0, 1),
face_enhancement_strength: clampNumber(formState.face_enhancement_strength, 0, 1),
crop_to_fill: formState.crop_to_fill,
model: formState.model,
upscaling_activated: formState.upscaling_activated,
image_scaling_factor: clampNumber(
formState.image_scaling_factor,
1,
config.maxUpscaleFactor
),
creativity: clampNumber(Math.round(formState.creativity), 1, 6),
texture: clampNumber(Math.round(formState.texture), 1, 5),
sharpen: clampNumber(formState.sharpen, 0, 1),
denoise: clampNumber(formState.denoise, 0, 1),
detail: clampNumber(formState.detail, 0, 1),
...requestParams,
clientRequestId,
});
if (!result.success || !result.data?.images?.length) {
@ -297,10 +348,17 @@ export function EnhanceProDialog({
}
onComplete?.(result.data.images);
requestIdRef.current = null;
requestSignatureRef.current = null;
toast.success(`图像增强 Pro 完成,已生成 ${result.data.images.length} 张结果`);
onOpenChange(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : "图像增强 Pro 失败");
const message = error instanceof Error ? error.message : "图像增强 Pro 失败";
if (!shouldPreserveEnhanceProRequestId(message)) {
requestIdRef.current = null;
requestSignatureRef.current = null;
}
toast.error(message);
} finally {
setIsSubmitting(false);
}

View File

@ -30,6 +30,14 @@ function buildTaskError(payload: LongTaskResponse | null | undefined, fallback:
return new Error(String(message || fallback));
}
function buildTaskInterruptedError(taskId: string) {
const error = new Error(
"任务已提交,网络连接暂时中断,请稍后重试查看结果,避免重复提交。"
) as Error & { taskId?: string };
error.taskId = taskId;
return error;
}
async function safeParseJson(response: Response) {
try {
return (await response.json()) as LongTaskResponse;
@ -132,9 +140,7 @@ export async function requestLongTaskResult(params: {
} catch (error) {
consecutivePollFailures += 1;
if (consecutivePollFailures >= maxPollFailures) {
throw error instanceof Error
? error
: new Error("查询任务状态失败");
throw buildTaskInterruptedError(taskId);
}
pollAfterMs = resolveRetryAfterMs(null, null, pollAfterMs, consecutivePollFailures);
continue;

View File

@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS "EnhanceProTask" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"userId" uuid NOT NULL REFERENCES "User"("id") ON DELETE cascade,
"chatId" uuid REFERENCES "Chat"("id") ON DELETE cascade,
"messageId" uuid,
"clientRequestId" varchar(64),
"providerTaskId" varchar(128) NOT NULL,
"taskKeyId" varchar(64),
"status" varchar(16) NOT NULL DEFAULT 'pending',
"prompt" text,
"sourceImageUrl" text NOT NULL,
"request" jsonb NOT NULL,
"result" jsonb,
"error" text,
"pointsCost" integer NOT NULL DEFAULT 0,
"pointsCharged" boolean NOT NULL DEFAULT false,
"chargedAt" timestamp,
"completedAt" timestamp,
"createdAt" timestamp NOT NULL DEFAULT now(),
"updatedAt" timestamp NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS "EnhanceProTask_providerTaskId_idx"
ON "EnhanceProTask" ("providerTaskId");
CREATE UNIQUE INDEX IF NOT EXISTS "EnhanceProTask_userId_clientRequestId_idx"
ON "EnhanceProTask" ("userId", "clientRequestId");

View File

@ -421,6 +421,13 @@
"when": 1776336730695,
"tag": "0073_add_system_setting_history",
"breakpoints": true
},
{
"idx": 60,
"version": "7",
"when": 1777252800000,
"tag": "0074_add_enhance_pro_tasks",
"breakpoints": true
}
]
}

View File

@ -751,6 +751,34 @@ export const canvasImageTask = pgTable("CanvasImageTask", {
export type CanvasImageTask = InferSelectModel<typeof canvasImageTask>;
export const enhanceProTask = pgTable("EnhanceProTask", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
userId: uuid("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
chatId: uuid("chatId").references(() => chat.id, { onDelete: "cascade" }),
messageId: uuid("messageId"),
clientRequestId: varchar("clientRequestId", { length: 64 }),
providerTaskId: varchar("providerTaskId", { length: 128 }).notNull(),
taskKeyId: varchar("taskKeyId", { length: 64 }),
status: varchar("status", { length: 16 })
.notNull()
.default("pending"), // pending | running | finalizing | succeeded | failed
prompt: text("prompt"),
sourceImageUrl: text("sourceImageUrl").notNull(),
request: jsonb("request").notNull(),
result: jsonb("result"),
error: text("error"),
pointsCost: integer("pointsCost").notNull().default(0),
pointsCharged: boolean("pointsCharged").notNull().default(false),
chargedAt: timestamp("chargedAt"),
completedAt: timestamp("completedAt"),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
});
export type EnhanceProTask = InferSelectModel<typeof enhanceProTask>;
export const agentPsdRenderTask = pgTable("AgentPsdRenderTask", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
userId: uuid("userId")

View File

@ -65,6 +65,7 @@ export interface EnhanceProRequestParams {
prompt?: string;
chatId?: string | null;
messageId?: string | null;
clientRequestId?: string | null;
width: number;
height: number;
batchSize?: number;
@ -388,14 +389,6 @@ export const imageGenerationService = {
const payload = await requestLongTaskResult({
endpoint: '/api/enhance/pro',
payload: params,
pollingParams: {
prompt: params.prompt || '图像增强 Pro',
chatId: params.chatId,
messageId: params.messageId,
sourceImageUrl: params.imageUrl,
width: params.width,
height: params.height,
},
});
if (!payload?.success) {
@ -440,6 +433,12 @@ export const imageGenerationService = {
};
} catch (error) {
console.error('[Canvas] 图像增强 Pro 失败:', error);
if (error instanceof Error && /failed to fetch|network/i.test(error.message)) {
return {
success: false,
error: '任务可能已提交,网络连接暂时中断,请稍后重试查看结果,避免重复提交。',
};
}
return {
success: false,
error: error instanceof Error ? error.message : '图像增强 Pro 失败',