| import { Router } from "express"; |
| import { db, imagesTable, usersTable, configTable, creditTransactionsTable } from "@workspace/db"; |
| import { |
| GenerateImageBody, |
| GetImageHistoryQueryParams, |
| DeleteImageParams, |
| } from "@workspace/api-zod"; |
| import { desc, eq, count, and, or, isNull, sql } from "drizzle-orm"; |
| import { |
| getValidBearerToken, refreshAccessToken, |
| getPoolToken, markAccountUsed, tryRefreshPoolAccount, disablePoolAccount, |
| } from "./config"; |
| import { optionalJwtAuth } from "./auth"; |
| import { generateGuardId } from "../guardId"; |
|
|
| const router = Router(); |
|
|
| const GEMINIGEN_BASE = "https://api.geminigen.ai/api"; |
|
|
| const STYLE_PROMPTS: Record<string, string> = { |
| realistic: "photorealistic, high quality, detailed, 8k resolution", |
| anime: "anime style, manga art style, japanese animation", |
| artistic: "artistic, fine art, expressive brushwork", |
| cartoon: "cartoon style, colorful, fun illustration", |
| sketch: "pencil sketch, hand drawn, black and white drawing", |
| oil_painting: "oil painting, classical art style, textured canvas", |
| watercolor: "watercolor painting, soft colors, fluid brushstrokes", |
| digital_art: "digital art, concept art, highly detailed digital illustration", |
| }; |
|
|
| const ORIENTATION_MAP: Record<string, string> = { |
| "1:1": "square", |
| "16:9": "landscape", |
| "9:16": "portrait", |
| "4:3": "landscape", |
| "3:4": "portrait", |
| "2:3": "portrait", |
| "3:2": "landscape", |
| }; |
|
|
| const USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.6367.111 Mobile/15E148 Safari/604.1"; |
|
|
| function base64ToBlob(base64: string, mime: string): Blob { |
| const binary = Buffer.from(base64, "base64"); |
| return new Blob([binary], { type: mime }); |
| } |
|
|
| async function pollForImage(uuid: string, token: string, maxWaitMs = 120000): Promise<string | null> { |
| const interval = 3000; |
| const start = Date.now(); |
|
|
| while (Date.now() - start < maxWaitMs) { |
| await new Promise((r) => setTimeout(r, interval)); |
|
|
| const resp = await fetch(`${GEMINIGEN_BASE}/history/${uuid}`, { |
| headers: { |
| Authorization: `Bearer ${token}`, |
| "x-guard-id": generateGuardId("/api/history/" + uuid, "get"), |
| "User-Agent": USER_AGENT, |
| Accept: "application/json", |
| }, |
| }); |
|
|
| if (!resp.ok) break; |
|
|
| const data = await resp.json() as { |
| status?: number; |
| generated_image?: Array<{ image_url?: string }>; |
| }; |
|
|
| if (data.status === 2 || data.status === 3) { |
| return data.generated_image?.[0]?.image_url || null; |
| } |
|
|
| if (typeof data.status === "number" && data.status > 3) break; |
| } |
|
|
| return null; |
| } |
|
|
| async function callGrokEndpoint(prompt: string, orientation: string, token: string, refImageBase64?: string, refImageMime?: string) { |
| const form = new FormData(); |
| form.append("prompt", prompt); |
| form.append("orientation", orientation); |
| form.append("num_result", "1"); |
|
|
| if (refImageBase64 && refImageMime) { |
| const blob = base64ToBlob(refImageBase64, refImageMime); |
| form.append("files", blob, "reference.jpg"); |
| } |
|
|
| const resp = await fetch(`${GEMINIGEN_BASE}/imagen/grok`, { |
| method: "POST", |
| headers: { Authorization: `Bearer ${token}`, "x-guard-id": generateGuardId("/api/imagen/grok", "post"), "User-Agent": USER_AGENT, Accept: "application/json" }, |
| body: form, |
| }); |
|
|
| const body = await resp.json().catch(async () => ({ raw: await resp.text().catch(() => "") })); |
| return { status: resp.status, body }; |
| } |
|
|
| async function callMetaEndpoint(prompt: string, orientation: string, token: string, refImageBase64?: string, refImageMime?: string) { |
| const form = new FormData(); |
| form.append("prompt", prompt); |
| form.append("orientation", orientation); |
| form.append("num_result", "1"); |
|
|
| if (refImageBase64 && refImageMime) { |
| const blob = base64ToBlob(refImageBase64, refImageMime); |
| form.append("files", blob, "reference.jpg"); |
| } |
|
|
| const resp = await fetch(`${GEMINIGEN_BASE}/meta_ai/generate`, { |
| method: "POST", |
| headers: { Authorization: `Bearer ${token}`, "x-guard-id": generateGuardId("/api/meta_ai/generate", "post"), "User-Agent": USER_AGENT, Accept: "application/json" }, |
| body: form, |
| }); |
|
|
| const body = await resp.json().catch(async () => ({ raw: await resp.text().catch(() => "") })); |
| return { status: resp.status, body }; |
| } |
|
|
| async function callImagenEndpoint(model: string, prompt: string, aspectRatio: string, style: string, token: string, refImageBase64?: string, refImageMime?: string, resolution?: string) { |
| const form = new FormData(); |
| form.append("prompt", prompt); |
| form.append("model", model); |
| form.append("aspect_ratio", aspectRatio); |
| form.append("output_format", "jpg"); |
| if (resolution) form.append("resolution", resolution); |
|
|
| if (refImageBase64 && refImageMime) { |
| const blob = base64ToBlob(refImageBase64, refImageMime); |
| form.append("files", blob, "reference.jpg"); |
| } |
|
|
| const resp = await fetch(`${GEMINIGEN_BASE}/generate_image`, { |
| method: "POST", |
| headers: { Authorization: `Bearer ${token}`, "x-guard-id": generateGuardId("/api/generate_image", "post"), "User-Agent": USER_AGENT, Accept: "application/json" }, |
| body: form, |
| }); |
|
|
| const body = await resp.json().catch(async () => ({ raw: await resp.text().catch(() => "") })); |
| return { status: resp.status, body }; |
| } |
|
|
| |
| async function getConfigVal(key: string): Promise<string | null> { |
| const rows = await db.select({ value: configTable.value }).from(configTable).where(eq(configTable.key, key)).limit(1); |
| return rows[0]?.value ?? null; |
| } |
|
|
| async function checkAndDeductCredits(userId: number, cost: number, description: string): Promise<{ ok: boolean; balance?: number }> { |
| const enabled = await getConfigVal("enable_credits"); |
| if (enabled !== "true") return { ok: true }; |
|
|
| const [user] = await db.select({ credits: usersTable.credits }).from(usersTable).where(eq(usersTable.id, userId)).limit(1); |
| if (!user) return { ok: false }; |
| if (user.credits < cost) return { ok: false, balance: user.credits }; |
|
|
| const [updated] = await db |
| .update(usersTable) |
| .set({ credits: sql`${usersTable.credits} - ${cost}` }) |
| .where(eq(usersTable.id, userId)) |
| .returning({ credits: usersTable.credits }); |
|
|
| await db.insert(creditTransactionsTable).values({ |
| userId, |
| amount: -cost, |
| type: "spend", |
| description, |
| }); |
|
|
| return { ok: true, balance: updated.credits }; |
| } |
|
|
| router.post("/generate", optionalJwtAuth, async (req, res) => { |
| const bodyResult = GenerateImageBody.safeParse(req.body); |
| if (!bodyResult.success) { |
| return res.status(400).json({ error: "VALIDATION_ERROR", message: "Invalid request body" }); |
| } |
|
|
| const { |
| prompt, |
| style = "realistic", |
| aspectRatio = "1:1", |
| model = "grok", |
| resolution, |
| referenceImageBase64, |
| referenceImageMime, |
| isPrivate = false, |
| } = bodyResult.data as any; |
|
|
| const userId: number | null = (req as any).jwtUserId ?? null; |
|
|
| |
| if (userId !== null) { |
| const costStr = await getConfigVal("image_gen_cost"); |
| const cost = Number(costStr) || 0; |
| if (cost > 0) { |
| const creditResult = await checkAndDeductCredits(userId, cost, `圖片生成(${model})`); |
| if (!creditResult.ok) { |
| return res.status(402).json({ |
| error: "INSUFFICIENT_CREDITS", |
| message: `點數不足,此操作需要 ${cost} 點`, |
| balance: creditResult.balance ?? 0, |
| }); |
| } |
| } |
| } |
|
|
| const stylePrompt = style === "none" ? "" : (STYLE_PROMPTS[style] || ""); |
| const fullPrompt = stylePrompt ? `${prompt}, ${stylePrompt}` : prompt; |
| const orientation = ORIENTATION_MAP[aspectRatio] || "square"; |
|
|
| const isImagenModel = model === "imagen-pro" || model === "imagen-4" || model === "imagen-flash" || model === "nano-banana-pro" || model === "nano-banana-2"; |
| const apiModelId = isImagenModel ? model : model; |
|
|
| let imageUrl = ""; |
| let usedFallback = false; |
| let fallbackReason = ""; |
| let responseStatus = 0; |
| let responseBody: unknown = {}; |
| let pollResult: Record<string, unknown> = {}; |
| const startTime = Date.now(); |
|
|
| |
| const failedPoolIds: number[] = []; |
| let currentAccountId: number | null = null; |
|
|
| async function pickToken(): Promise<string | null> { |
| const poolEntry = await getPoolToken(failedPoolIds); |
| if (poolEntry) { |
| currentAccountId = poolEntry.accountId; |
| return poolEntry.token; |
| } |
| currentAccountId = null; |
| return getValidBearerToken(); |
| } |
|
|
| async function handleTokenExpiry(): Promise<string | null> { |
| if (currentAccountId !== null) { |
| const refreshed = await tryRefreshPoolAccount(currentAccountId); |
| if (refreshed) return refreshed; |
| failedPoolIds.push(currentAccountId); |
| const next = await getPoolToken(failedPoolIds); |
| if (next) { |
| currentAccountId = next.accountId; |
| return next.token; |
| } |
| } |
| return refreshAccessToken(); |
| } |
|
|
| let token = await pickToken(); |
|
|
| const requestInfo = { |
| url: isImagenModel |
| ? `${GEMINIGEN_BASE}/generate_image` |
| : model === "meta" |
| ? `${GEMINIGEN_BASE}/meta_ai/generate` |
| : `${GEMINIGEN_BASE}/imagen/grok`, |
| model: isImagenModel ? apiModelId : model, |
| fields: isImagenModel |
| ? { prompt: fullPrompt, model: apiModelId, aspect_ratio: aspectRatio, output_format: "jpg", ...(resolution ? { resolution } : {}), hasReferenceImage: !!referenceImageBase64 } |
| : { prompt: fullPrompt, orientation, num_result: "1", hasReferenceImage: !!referenceImageBase64 }, |
| }; |
|
|
| try { |
| if (!token) throw new Error("未設定 API Token,請到設定頁面輸入 token"); |
|
|
| let result: { status: number; body: unknown }; |
|
|
| if (model === "grok") { |
| result = await callGrokEndpoint(fullPrompt, orientation, token, referenceImageBase64, referenceImageMime); |
| } else if (model === "meta") { |
| result = await callMetaEndpoint(fullPrompt, orientation, token, referenceImageBase64, referenceImageMime); |
| } else { |
| result = await callImagenEndpoint(apiModelId, fullPrompt, aspectRatio, style, token, referenceImageBase64, referenceImageMime, resolution); |
| } |
|
|
| if (result.status === 401) { |
| const newToken = await handleTokenExpiry(); |
| if (!newToken) throw new Error("Token 已過期且無法自動刷新"); |
| token = newToken; |
|
|
| if (model === "grok") result = await callGrokEndpoint(fullPrompt, orientation, token, referenceImageBase64, referenceImageMime); |
| else if (model === "meta") result = await callMetaEndpoint(fullPrompt, orientation, token, referenceImageBase64, referenceImageMime); |
| else result = await callImagenEndpoint(apiModelId, fullPrompt, aspectRatio, style, token, referenceImageBase64, referenceImageMime, resolution); |
| } |
|
|
| responseStatus = result.status; |
| responseBody = result.body; |
|
|
| const data = result.body as { uuid?: string; base64_images?: string; generated_image?: Array<{ image_url?: string }>; detail?: { error_code?: string; error_message?: string } }; |
|
|
| const errMsg = (data?.detail?.error_message || "").toLowerCase(); |
| const isTokenExpired = result.status === 401 || result.status === 403 |
| || data?.detail?.error_code === "TOKEN_EXPIRED" |
| || errMsg.includes("expired") || errMsg.includes("token"); |
|
|
| if (isTokenExpired) { |
| const newToken = await handleTokenExpiry(); |
| if (!newToken) throw new Error("Token 已過期且無法自動刷新,請重新取得"); |
| token = newToken; |
|
|
| let retryResult: { status: number; body: unknown }; |
| if (model === "grok") retryResult = await callGrokEndpoint(fullPrompt, orientation, token, referenceImageBase64, referenceImageMime); |
| else if (model === "meta") retryResult = await callMetaEndpoint(fullPrompt, orientation, token, referenceImageBase64, referenceImageMime); |
| else retryResult = await callImagenEndpoint(apiModelId, fullPrompt, aspectRatio, style, token, referenceImageBase64, referenceImageMime, resolution); |
|
|
| responseStatus = retryResult.status; |
| responseBody = retryResult.body; |
| Object.assign(data, retryResult.body); |
| } |
|
|
| if (!result.status.toString().startsWith("2") && !isTokenExpired) { |
| const msg = (data as any)?.detail?.error_message || (data as any)?.detail?.error_code || `HTTP ${result.status}`; |
| throw new Error(`API 錯誤:${msg}`); |
| } |
|
|
| const finalData = responseBody as typeof data; |
|
|
| if ((finalData as any)?.detail?.error_code && (finalData as any)?.detail?.error_code !== "TOKEN_EXPIRED") { |
| const msg = (finalData as any)?.detail?.error_message || (finalData as any)?.detail?.error_code; |
| throw new Error(`API 錯誤:${msg}`); |
| } |
|
|
| if (data.base64_images) { |
| imageUrl = `data:image/png;base64,${data.base64_images}`; |
| pollResult = { type: "immediate_base64" }; |
| } else if (data.generated_image?.[0]?.image_url) { |
| imageUrl = data.generated_image[0].image_url; |
| pollResult = { type: "immediate_url" }; |
| } else if (data.uuid) { |
| pollResult.uuid = data.uuid; |
| const polledUrl = await pollForImage(data.uuid, token); |
| if (!polledUrl) throw new Error("圖片生成逾時或未返回結果"); |
| imageUrl = polledUrl; |
| pollResult.imageUrl = imageUrl; |
| pollResult.status = "completed"; |
| } else { |
| throw new Error("API 未返回圖片或任務 UUID"); |
| } |
| } catch (err: unknown) { |
| const errMsg = err instanceof Error ? err.message : String(err); |
| req.log.warn({ err }, "Image generation failed, using fallback"); |
| usedFallback = true; |
| fallbackReason = errMsg; |
|
|
| const fallbackSizes: Record<string, string> = { |
| "1:1": "1024/1024", "16:9": "1344/768", "9:16": "768/1344", |
| "4:3": "1152/896", "3:4": "896/1152", "2:3": "768/1152", "3:2": "1152/768", |
| }; |
| const seed = Math.floor(Math.random() * 1000000); |
| imageUrl = `https://picsum.photos/seed/${seed}/${fallbackSizes[aspectRatio] || "1024/1024"}`; |
| } |
|
|
| const durationMs = Date.now() - startTime; |
| const isTokenError = usedFallback && ( |
| fallbackReason?.includes("Token 已過期") || |
| fallbackReason?.includes("無法自動刷新") || |
| fallbackReason?.includes("未設定 API Token") || |
| fallbackReason?.includes("REFRESH_TOKEN_EXPIRED") |
| ); |
|
|
| |
| if (currentAccountId !== null) { |
| markAccountUsed(currentAccountId).catch(() => {}); |
| } |
|
|
| const [inserted] = await db |
| .insert(imagesTable) |
| .values({ imageUrl, prompt, style, aspectRatio, model, isPrivate: !!isPrivate, userId }) |
| .returning(); |
|
|
| res.json({ |
| id: inserted.id, |
| imageUrl: inserted.imageUrl, |
| prompt: inserted.prompt, |
| style: inserted.style, |
| aspectRatio: inserted.aspectRatio, |
| model: inserted.model, |
| createdAt: inserted.createdAt.toISOString(), |
| ...(usedFallback ? { error: fallbackReason, tokenExpired: isTokenError } : {}), |
| apiDebug: { |
| requestUrl: requestInfo.url, |
| requestMethod: "POST", |
| requestContentType: "multipart/form-data", |
| requestHeaders: { |
| Authorization: token ? "Bearer ****" : "(無 Token)", |
| "User-Agent": USER_AGENT, |
| Accept: "application/json", |
| }, |
| requestBody: requestInfo.fields, |
| responseStatus, |
| responseBody, |
| pollResult, |
| durationMs, |
| usedFallback, |
| ...(fallbackReason ? { fallbackReason } : {}), |
| }, |
| }); |
| }); |
|
|
| router.get("/history", optionalJwtAuth, async (req, res) => { |
| const paramsResult = GetImageHistoryQueryParams.safeParse({ |
| limit: req.query.limit ? Number(req.query.limit) : 20, |
| offset: req.query.offset ? Number(req.query.offset) : 0, |
| }); |
|
|
| const { limit = 20, offset = 0 } = paramsResult.success ? paramsResult.data : {}; |
| const currentUserId: number | null = (req as any).jwtUserId ?? null; |
|
|
| |
| |
| |
| const visibilityFilter = currentUserId |
| ? or(eq(imagesTable.isPrivate, false), and(eq(imagesTable.isPrivate, true), eq(imagesTable.userId, currentUserId))) |
| : eq(imagesTable.isPrivate, false); |
|
|
| const [images, [{ value: total }]] = await Promise.all([ |
| db.select().from(imagesTable).where(visibilityFilter).orderBy(desc(imagesTable.createdAt)).limit(limit).offset(offset), |
| db.select({ value: count() }).from(imagesTable).where(visibilityFilter), |
| ]); |
|
|
| res.json({ |
| images: images.map((img) => ({ |
| id: img.id, |
| imageUrl: img.imageUrl, |
| prompt: img.prompt, |
| style: img.style, |
| aspectRatio: img.aspectRatio, |
| model: img.model, |
| isPrivate: img.isPrivate, |
| userId: img.userId, |
| createdAt: img.createdAt.toISOString(), |
| })), |
| total: Number(total), |
| }); |
| }); |
|
|
| router.delete("/:id", async (req, res) => { |
| const paramsResult = DeleteImageParams.safeParse({ id: Number(req.params.id) }); |
| if (!paramsResult.success) { |
| return res.status(400).json({ error: "INVALID_ID", message: "Invalid image ID" }); |
| } |
|
|
| const deleted = await db.delete(imagesTable).where(eq(imagesTable.id, paramsResult.data.id)).returning(); |
|
|
| if (deleted.length === 0) { |
| return res.status(404).json({ error: "NOT_FOUND", message: "Image not found" }); |
| } |
|
|
| res.json({ success: true, message: "Image deleted successfully" }); |
| }); |
|
|
| export default router; |
|
|