route.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. import { NextRequest, NextResponse } from 'next/server';
  2. import { auth } from '@/lib/auth';
  3. import { db } from '@/lib/db';
  4. import { users, userActivities } from '@/lib/schema';
  5. import { eq } from 'drizzle-orm';
  6. import * as fal from '@fal-ai/serverless-client';
  7. import { deductCredits } from '@/lib/credit-service';
  8. import { CREDIT_CONFIG, USER_CONFIG, SUPPORTED_IMAGE_FORMATS } from '@/lib/constants';
  9. // 配置fal.ai客户端
  10. fal.config({
  11. credentials: process.env.FAL_KEY!
  12. });
  13. // 翻译消息
  14. const messages = {
  15. zh: {
  16. loginRequired: '请先登录后再使用多图编辑功能',
  17. userNotFound: '用户不存在',
  18. insufficientCredits: `积分不足,多图编辑需要${CREDIT_CONFIG.COSTS.MULTI_IMAGE_EDIT}积分`,
  19. noFiles: '请上传至少一张图片',
  20. tooManyFiles: '一次最多可上传20张图片',
  21. unsupportedImageFormat: '不支持的图片格式,请上传 JPG、PNG 或 WebP 格式的图片',
  22. imageTooLarge: '图片文件过大,请上传小于 5MB 的图片',
  23. promptRequired: '多图编辑需要提供编辑指令',
  24. processingError: '多图编辑失败,请重试',
  25. partialSuccess: '图片编辑部分成功',
  26. allSuccess: '图片编辑成功',
  27. // API错误消息
  28. pleaseLogin: '请先登录',
  29. enterPrompt: '请输入编辑指令',
  30. maxImagesLimit: '最多只能上传10张图片',
  31. creditInsufficient: '积分不足,需要 {needed} 积分,当前有 {current} 积分',
  32. imageSizeExceeded: '图片 {name} 超过5MB限制',
  33. imageFormatNotSupported: '图片 {name} 格式不支持,请使用 JPEG、PNG 或 WebP 格式',
  34. imageUploadFailed: '图片 {name} 上传失败: {error}',
  35. imageUrlVerificationFailed: '图片URL验证失败,请重试',
  36. promptEmpty: '提示词不能为空',
  37. needAtLeastOneImage: '至少需要一张图片',
  38. invalidImageUrl: '无效的图片URL格式: {url}',
  39. apiReturnEmpty: 'API返回数据为空',
  40. apiNoValidImages: 'API未返回有效的图片数据',
  41. imageDataMissingUrl: '返回的图片数据缺少URL',
  42. validationFailed: '参数验证失败: {details}',
  43. // 通用错误处理
  44. processingFailed: '处理失败,请稍后重试',
  45. creditsInsufficient: '积分不足或配额已用完',
  46. rateLimited: '请求过于频繁,请稍后重试',
  47. invalidImageFormat: '图片格式不正确或已损坏,请检查图片格式',
  48. contentPolicyViolation: '内容不符合使用政策,请修改提示词或图片',
  49. requestTimeout: '处理超时,请稍后重试',
  50. modelUnavailable: '模型服务暂时不可用,请稍后重试',
  51. aiProcessingFailed: 'AI处理失败,请重新尝试或联系客服'
  52. },
  53. en: {
  54. loginRequired: 'Please log in first to use multi-image editing features',
  55. userNotFound: 'User not found',
  56. insufficientCredits: `Insufficient credits, ${CREDIT_CONFIG.COSTS.MULTI_IMAGE_EDIT} credits required for multi-image editing`,
  57. noFiles: 'Please upload at least one image',
  58. tooManyFiles: 'Maximum 20 images can be uploaded at once',
  59. unsupportedImageFormat: 'Unsupported image format, please upload JPG, PNG or WebP format images',
  60. imageTooLarge: 'Image file too large, please upload images smaller than 5MB',
  61. promptRequired: 'Multi-image editing requires a prompt',
  62. processingError: 'Multi-image editing failed, please try again',
  63. partialSuccess: 'Image editing partially successful',
  64. allSuccess: 'Image editing successful',
  65. // API错误消息
  66. pleaseLogin: 'Please log in first',
  67. enterPrompt: 'Please enter editing instructions',
  68. maxImagesLimit: 'Maximum 10 images can be uploaded',
  69. creditInsufficient: 'Insufficient credits, need {needed} credits, currently have {current} credits',
  70. imageSizeExceeded: 'Image {name} exceeds 5MB limit',
  71. imageFormatNotSupported: 'Image {name} format not supported, please use JPEG, PNG or WebP format',
  72. imageUploadFailed: 'Image {name} upload failed: {error}',
  73. imageUrlVerificationFailed: 'Image URL verification failed, please try again',
  74. promptEmpty: 'Prompt cannot be empty',
  75. needAtLeastOneImage: 'At least one image is required',
  76. invalidImageUrl: 'Invalid image URL format: {url}',
  77. apiReturnEmpty: 'API returned empty data',
  78. apiNoValidImages: 'API did not return valid image data',
  79. imageDataMissingUrl: 'Returned image data is missing URL',
  80. validationFailed: 'Parameter validation failed: {details}',
  81. // 通用错误处理
  82. processingFailed: 'Processing failed, please try again later',
  83. creditsInsufficient: 'Insufficient credits or quota exhausted',
  84. rateLimited: 'Too many requests, please try again later',
  85. invalidImageFormat: 'Image format is incorrect or corrupted, please check image format',
  86. contentPolicyViolation: 'Content does not comply with usage policy, please modify prompt or images',
  87. requestTimeout: 'Processing timeout, please try again later',
  88. modelUnavailable: 'Model service temporarily unavailable, please try again later',
  89. aiProcessingFailed: 'AI processing failed, please try again or contact customer service'
  90. }
  91. };
  92. // 获取翻译消息
  93. function getMessage(locale: string, key: keyof typeof messages.zh): string {
  94. const lang = (locale === 'zh' || locale === 'zh-CN') ? 'zh' : 'en';
  95. return messages[lang][key];
  96. }
  97. // 获取带参数的翻译消息
  98. function getMessageWithParams(locale: string, key: keyof typeof messages.zh, params: Record<string, string | number>): string {
  99. const lang = (locale === 'zh' || locale === 'zh-CN') ? 'zh' : 'en';
  100. let message = messages[lang][key];
  101. // 替换占位符
  102. Object.keys(params).forEach(paramKey => {
  103. message = message.replace(`{${paramKey}}`, String(params[paramKey]));
  104. });
  105. return message;
  106. }
  107. // 文件转换为 Data URL
  108. async function fileToDataUrl(file: File): Promise<string> {
  109. const arrayBuffer = await file.arrayBuffer();
  110. const base64 = Buffer.from(arrayBuffer).toString('base64');
  111. return `data:${file.type};base64,${base64}`;
  112. }
  113. // 最大文件数量限制
  114. const MAX_FILES = 20;
  115. export async function POST(request: NextRequest) {
  116. try {
  117. // 解析表单数据以获取locale
  118. const formData = await request.formData();
  119. const locale = formData.get('locale') as string || 'zh';
  120. // 检查用户认证
  121. const session = await auth();
  122. if (!session?.user?.email) {
  123. return NextResponse.json(
  124. { success: false, error: getMessage(locale, 'pleaseLogin') },
  125. { status: 401 }
  126. );
  127. }
  128. // 获取用户信息
  129. const user = await db.query.users.findFirst({
  130. where: eq(users.email, session.user.email)
  131. });
  132. if (!user) {
  133. return NextResponse.json(
  134. { success: false, error: getMessage(locale, 'userNotFound') },
  135. { status: 404 }
  136. );
  137. }
  138. // 获取其他表单数据
  139. const images = formData.getAll('images') as File[];
  140. const prompt = formData.get('prompt') as string;
  141. const aspectRatio = formData.get('aspect_ratio') as string;
  142. // 验证输入
  143. if (!images || images.length === 0) {
  144. return NextResponse.json(
  145. { success: false, error: getMessage(locale, 'noFiles') },
  146. { status: 400 }
  147. );
  148. }
  149. if (!prompt?.trim()) {
  150. return NextResponse.json(
  151. { success: false, error: getMessage(locale, 'enterPrompt') },
  152. { status: 400 }
  153. );
  154. }
  155. // 检查文件数量限制
  156. if (images.length > 10) {
  157. return NextResponse.json(
  158. { success: false, error: getMessage(locale, 'maxImagesLimit') },
  159. { status: 400 }
  160. );
  161. }
  162. // 计算所需积分 - 使用配置文件中的积分值
  163. const creditsNeeded = CREDIT_CONFIG.COSTS.MULTI_IMAGE_EDIT;
  164. const totalCredits = (user.credits || 0) + (user.subscriptionCredits || 0);
  165. if (totalCredits < creditsNeeded) {
  166. return NextResponse.json(
  167. { success: false, error: getMessageWithParams(locale, 'creditInsufficient', { needed: creditsNeeded, current: totalCredits }) },
  168. { status: 400 }
  169. );
  170. }
  171. // 上传图片到fal.ai存储
  172. const imageUrls: string[] = [];
  173. for (let i = 0; i < images.length; i++) {
  174. const image = images[i];
  175. console.log(`处理图片 ${i + 1}/${images.length}: ${image.name}, 大小: ${image.size} bytes, 类型: ${image.type}`);
  176. // 检查文件大小
  177. if (image.size > USER_CONFIG.MAX_FILE_SIZE) {
  178. return NextResponse.json(
  179. { success: false, error: getMessageWithParams(locale, 'imageSizeExceeded', { name: image.name }) },
  180. { status: 400 }
  181. );
  182. }
  183. // 检查文件类型
  184. if (!SUPPORTED_IMAGE_FORMATS.includes(image.type as any)) {
  185. return NextResponse.json(
  186. { success: false, error: getMessageWithParams(locale, 'imageFormatNotSupported', { name: image.name }) },
  187. { status: 400 }
  188. );
  189. }
  190. try {
  191. // 上传到fal.ai存储
  192. console.log(`开始上传图片: ${image.name}`);
  193. const uploadedUrl = await fal.storage.upload(image);
  194. console.log(`图片上传成功: ${image.name} -> ${uploadedUrl}`);
  195. imageUrls.push(uploadedUrl);
  196. } catch (uploadError) {
  197. console.error(`图片上传失败: ${image.name}`, uploadError);
  198. return NextResponse.json(
  199. { success: false, error: getMessageWithParams(locale, 'imageUploadFailed', { name: image.name, error: uploadError instanceof Error ? uploadError.message : String(uploadError) }) },
  200. { status: 500 }
  201. );
  202. }
  203. }
  204. console.log(`所有图片上传完成,共 ${imageUrls.length} 张图片`);
  205. // 等待图片URL变为可访问状态
  206. console.log('等待图片URL变为可访问状态...');
  207. await new Promise(resolve => setTimeout(resolve, 2000)); // 等待2秒
  208. // 验证图片URL是否可访问
  209. for (let i = 0; i < imageUrls.length; i++) {
  210. const url = imageUrls[i];
  211. try {
  212. console.log(`验证图片URL ${i + 1}: ${url}`);
  213. const response = await fetch(url, { method: 'HEAD' });
  214. if (!response.ok) {
  215. throw new Error(`图片URL不可访问: ${response.status} ${response.statusText}`);
  216. }
  217. console.log(`图片URL ${i + 1} 验证成功`);
  218. } catch (fetchError) {
  219. console.error(`图片URL验证失败: ${url}`, fetchError);
  220. return NextResponse.json(
  221. { success: false, error: getMessage(locale, 'imageUrlVerificationFailed') },
  222. { status: 500 }
  223. );
  224. }
  225. }
  226. // 准备API请求参数 - 使用最简配置
  227. const apiParams: any = {
  228. prompt: prompt.trim(),
  229. image_urls: imageUrls
  230. };
  231. console.log('调用fal.ai多图编辑API,参数:', {
  232. prompt: apiParams.prompt,
  233. image_urls_count: imageUrls.length,
  234. image_urls_sample: imageUrls.slice(0, 1) // 显示第一个URL作为示例
  235. });
  236. // 确保prompt不为空且格式正确
  237. if (!apiParams.prompt || apiParams.prompt.trim().length === 0) {
  238. throw new Error(getMessage(locale, 'promptEmpty'));
  239. }
  240. // 确保至少有一张图片
  241. if (!imageUrls || imageUrls.length === 0) {
  242. throw new Error(getMessage(locale, 'needAtLeastOneImage'));
  243. }
  244. // 验证图片URL格式
  245. for (const url of imageUrls) {
  246. if (!url || typeof url !== 'string' || !url.startsWith('https://')) {
  247. throw new Error(getMessageWithParams(locale, 'invalidImageUrl', { url }));
  248. }
  249. }
  250. try {
  251. // 调用fal.ai多图编辑API - 使用效果更好的max模型
  252. const result = await fal.subscribe("fal-ai/flux-pro/kontext/max/multi", {
  253. input: apiParams,
  254. logs: true,
  255. onQueueUpdate: (update) => {
  256. if (update.status === "IN_PROGRESS") {
  257. console.log('处理进度:', update.logs?.map(log => log.message).join(', '));
  258. }
  259. },
  260. }) as any;
  261. console.log('fal.ai API响应:', result);
  262. // 验证API响应 - fal.ai直接返回包含images的对象
  263. if (!result) {
  264. throw new Error(getMessage(locale, 'apiReturnEmpty'));
  265. }
  266. // 检查返回的图片数据 - 直接检查images数组
  267. if (!result.images || !Array.isArray(result.images) || result.images.length === 0) {
  268. throw new Error(getMessage(locale, 'apiNoValidImages'));
  269. }
  270. // 验证每个图片对象的结构
  271. for (const image of result.images) {
  272. if (!image.url) {
  273. throw new Error(getMessage(locale, 'imageDataMissingUrl'));
  274. }
  275. }
  276. // 扣除积分并记录活动
  277. const creditDeductResult = await deductCredits(
  278. user.id,
  279. creditsNeeded,
  280. `credit_description.multi_image_edit:${prompt.trim().substring(0, 100)}`,
  281. {
  282. prompt: prompt.trim(),
  283. imageCount: images.length,
  284. creditsUsed: creditsNeeded,
  285. aspectRatio: aspectRatio || 'original',
  286. locale,
  287. type: 'multi_image_edit'
  288. }
  289. );
  290. if (!creditDeductResult.success) {
  291. return NextResponse.json(
  292. { success: false, error: creditDeductResult.message },
  293. { status: 400 }
  294. );
  295. }
  296. // 返回成功响应
  297. return NextResponse.json({
  298. success: true,
  299. data: {
  300. images: result.images,
  301. model_used: 'flux-pro-kontext-max-multi',
  302. input_count: images.length,
  303. output_count: result.images.length,
  304. message: `成功编辑了 ${images.length} 张图片,生成了 ${result.images.length} 张结果图片`
  305. },
  306. credits: creditDeductResult.credits
  307. });
  308. } catch (falError) {
  309. console.error('fal.ai API调用失败:', {
  310. error: falError,
  311. message: falError instanceof Error ? falError.message : String(falError),
  312. stack: falError instanceof Error ? falError.stack : undefined,
  313. // 显示详细的验证错误
  314. body: (falError as any)?.body,
  315. detail: (falError as any)?.body?.detail,
  316. status: (falError as any)?.status,
  317. apiParams: {
  318. ...apiParams,
  319. image_urls: `${imageUrls.length} URLs`
  320. }
  321. });
  322. // 如果是验证错误,尝试提取详细信息
  323. if ((falError as any)?.status === 422 && (falError as any)?.body?.detail) {
  324. const details = (falError as any).body.detail;
  325. console.error('验证错误详情:', details);
  326. // 构建更友好的错误消息
  327. let detailMessage = getMessageWithParams(locale, 'validationFailed', {
  328. details: Array.isArray(details) ? details.map((d: any) => {
  329. if (typeof d === 'string') return d;
  330. if (d.msg) return `${d.loc ? d.loc.join('.') + ': ' : ''}${d.msg}`;
  331. return JSON.stringify(d);
  332. }).join(', ') : JSON.stringify(details)
  333. });
  334. throw new Error(detailMessage);
  335. }
  336. throw falError;
  337. }
  338. } catch (error) {
  339. console.error('多图编辑API错误:', error);
  340. // 尝试从request中获取locale,如果失败则使用默认值
  341. let locale = 'zh';
  342. try {
  343. const formData = await request.formData();
  344. locale = (formData.get('locale') as string) || 'zh';
  345. } catch {
  346. // 如果无法读取formData,使用默认语言
  347. }
  348. let errorMessage = getMessage(locale, 'processingFailed');
  349. let statusCode = 500;
  350. if (error instanceof Error) {
  351. // fal.ai API特定错误处理
  352. if (error.message.includes('insufficient credits') || error.message.includes('quota')) {
  353. errorMessage = getMessage(locale, 'creditsInsufficient');
  354. statusCode = 402;
  355. } else if (error.message.includes('rate limit') || error.message.includes('too many requests')) {
  356. errorMessage = getMessage(locale, 'rateLimited');
  357. statusCode = 429;
  358. } else if (error.message.includes('invalid image') || error.message.includes('unsupported format')) {
  359. errorMessage = getMessage(locale, 'invalidImageFormat');
  360. statusCode = 400;
  361. } else if (error.message.includes('content policy') || error.message.includes('safety')) {
  362. errorMessage = getMessage(locale, 'contentPolicyViolation');
  363. statusCode = 400;
  364. } else if (error.message.includes('timeout') || error.message.includes('request timeout')) {
  365. errorMessage = getMessage(locale, 'requestTimeout');
  366. statusCode = 408;
  367. } else if (error.message.includes('model not found') || error.message.includes('endpoint not found')) {
  368. errorMessage = getMessage(locale, 'modelUnavailable');
  369. statusCode = 503;
  370. } else if (error.message.includes(getMessage(locale, 'apiReturnEmpty')) || error.message.includes(getMessage(locale, 'apiNoValidImages'))) {
  371. errorMessage = getMessage(locale, 'aiProcessingFailed');
  372. statusCode = 502;
  373. } else {
  374. // 记录详细错误信息用于调试
  375. console.error('未知错误详情:', {
  376. message: error.message,
  377. stack: error.stack,
  378. name: error.name
  379. });
  380. errorMessage = `${getMessage(locale, 'processingFailed')}: ${error.message}`;
  381. }
  382. }
  383. return NextResponse.json(
  384. {
  385. success: false,
  386. error: errorMessage,
  387. debug: process.env.NODE_ENV === 'development' ? (error instanceof Error ? error.message : String(error)) : undefined
  388. },
  389. { status: statusCode }
  390. );
  391. }
  392. }
  393. export async function GET() {
  394. return NextResponse.json({
  395. message: '多图像编辑 API',
  396. version: '1.0.0',
  397. supported_actions: ['multi_image_edit', 'image_edit'],
  398. supported_formats: ['JPG', 'JPEG', 'PNG', 'WebP'],
  399. max_file_size: '5MB',
  400. max_files: MAX_FILES,
  401. processing_modes: ['edit'],
  402. credit_cost: `${CREDIT_CONFIG.COSTS.MULTI_IMAGE_EDIT} credits per edit`,
  403. models: ['flux-pro-kontext-max-multi']
  404. });
  405. }