在线 AI 绘图平台:Flux 模型集成、Serverless 弹性部署与 ComfyUI 工作流的 React + Spring AI 全栈实战

在线 AI 绘图平台:Flux 模型集成、Serverless 弹性部署与 ComfyUI 工作流的 React + Spring AI 全栈实战

本文将构建一个功能完整的在线 AI 绘图平台,支持文生图、图生图、图片编辑等功能。前端使用 React + Ant Design,后端使用 Spring Boot + Spring AI 集成 DashScope 通义万相文生图 API,同时深入讲解 Flux 模型的企业级 Java 集成方案、阿里云函数计算(FC)Serverless 弹性部署架构以及 ComfyUI 工作流的异步任务编排。


博客:

https://blog.csdn.net/badao_liumang_qizhi

一、项目架构设计

1.1 整体架构

复制代码
┌─────────────────────────────────────────────────────────────┐
│                   前端 (React + Vite)                        │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐    │
│  │ 绘图面板  │  │ 画廊展示  │  │ 图片编辑  │  │ 用户中心  │    │
│  └──────────┘  └──────────┘  └──────────┘  └──────────┘    │
└─────────────────────────────────────────────────────────────┘
                              │ HTTP / WebSocket
┌─────────────────────────────────────────────────────────────┐
│                   后端 (Spring Boot 3.x)                     │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐    │
│  │ 绘图服务  │  │ 图片存储  │  │ 任务队列  │  │ 用户服务  │    │
│  └──────────┘  └──────────┘  └──────────┘  └──────────┘    │
│  ┌─────────────────────────────────────────────────────┐    │
│  │    Spring AI Alibaba (ImageModel / DashScope)        │    │
│  │    通义万相 wanx2.1-t2i-turbo | Flux 推理服务       │    │
│  └─────────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────────┘
                              │
┌─────────────────────────────────────────────────────────────┐
│                    数据层                                    │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐    │
│  │  MySQL   │  │  Redis   │  │  MinIO   │  │ RabbitMQ │    │
│  └──────────┘  └──────────┘  └──────────┘  └──────────┘    │
└─────────────────────────────────────────────────────────────┘

1.2 技术栈选型

层级 技术选型 版本 选型理由
前端框架 React + TypeScript 18.2+ 组件化开发,类型安全
UI 组件库 Ant Design 5.12+ 企业级中后台标准
构建工具 Vite 5.0+ 极速 HMR,开箱即用
状态管理 Zustand 4.4+ 轻量、无样板代码
后端框架 Spring Boot 3.2+ 成熟的企业级生态
AI 框架 Spring AI Alibaba 1.1.2+ 统一 ImageModel 抽象,自动配置
文生图模型 DashScope 通义万相 wanx2.1-t2i-turbo 中文理解好、速度快
备用模型 Flux.1-dev-fp8-dit - 写实画质高,fp8 量化后 A10 可跑
对象存储 MinIO - 兼容 S3,私有化部署
消息队列 RabbitMQ 3.12+ 异步任务解耦

架构选型说明 :后端选择 Spring AI Alibaba 而非直接调用 DashScope SDK,核心原因是其提供了统一的 ImageModel 接口,底层无论对接通义万相还是 Flux 推理服务,业务代码都保持一致。若未来需要切换到其他图像生成模型,只需更换 ImageModel 的 Bean 实现,Controller 和 Service 层无需改动。

1.3 核心功能模块与数据流

功能模块 核心能力 关键技术
文生图 输入文字描述生成图片 ImageModel + DashScopeImageOptions
图生图 上传参考图生成变体 图生图 API + Denoising Strength
ComfyUI 工作流 复杂多步骤图像编辑 ComfyUI HTTP API + 工作流 JSON
任务队列 异步处理绘图请求 RabbitMQ + 信号量并发控制
实时进度推送 WebSocket 推送任务状态 STOMP + SimpMessagingTemplate
图片存储 生成结果持久化 MinIO 对象存储 + 预签名 URL
安全过滤 NSFW 内容拦截 内容安全 API

核心数据流:用户输入提示词 → 提交绘图任务 → RabbitMQ 异步队列 → ImageModel 调用模型 → 轮询或 WebSocket 获取结果 → 图片存储到 MinIO → 返回访问 URL → 前端展示。

二、后端实现

2.1 项目依赖配置

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
         https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.0</version>
    </parent>
    
    <groupId>com.example</groupId>
    <artifactId>ai-draw-backend</artifactId>
    <version>1.0.0</version>
    <name>AI Draw Backend</name>
    
    <properties>
        <java.version>17</java.version>
        <spring-ai-alibaba.version>1.1.2.0</spring-ai-alibaba.version>
    </properties>
    
    <dependencies>
        <!-- Spring Boot Starters -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>
        
        <!-- Spring AI Alibaba --- 统一 ImageModel 抽象 -->
        <dependency>
            <groupId>com.alibaba.cloud.ai</groupId>
            <artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
            <version>${spring-ai-alibaba.version}</version>
        </dependency>
        
        <!-- MinIO -->
        <dependency>
            <groupId>io.minio</groupId>
            <artifactId>minio</artifactId>
            <version>8.5.7</version>
        </dependency>
        
        <!-- MySQL -->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <version>8.0.33</version>
        </dependency>
        
        <!-- Lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        
        <!-- OkHttp for Flux inference service calls -->
        <dependency>
            <groupId>com.squareup.okhttp3</groupId>
            <artifactId>okhttp</artifactId>
            <version>4.12.0</version>
        </dependency>
    </dependencies>
</project>

关键依赖说明 :核心依赖只有 spring-ai-alibaba-starter-dashscope,它已经替你管理好了和阿里云通信的全部逻辑,引入后 Spring Boot 会自动配置一个 DashScopeImageModel 实例。

2.2 配置文件

yaml 复制代码
# application.yml
server:
  port: 8080

spring:
  application:
    name: ai-draw-backend
  
  datasource:
    url: jdbc:mysql://localhost:3306/ai_draw?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver
  
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: false
  
  data:
    redis:
      host: localhost
      port: 6379
  
  rabbitmq:
    host: localhost
    port: 5672
    username: guest
    password: guest

  # Spring AI Alibaba DashScope 配置
  ai:
    dashscope:
      api-key: ${DASHSCOPE_API_KEY}
      image:
        options:
          model: wanx2.1-t2i-turbo
          size: 1024*1024

# MinIO 配置
minio:
  endpoint: http://localhost:9000
  access-key: minioadmin
  secret-key: minioadmin
  bucket-name: ai-draw-images

# Flux 推理服务配置(可选)
flux:
  enabled: ${FLUX_ENABLED:false}
  endpoint: ${FLUX_ENDPOINT:http://flux-inference-service:8000}
  timeout-seconds: 45
  max-retries: 2

# ComfyUI 服务配置(可选)
comfyui:
  enabled: ${COMFYUI_ENABLED:false}
  endpoint: ${COMFYUI_ENDPOINT:http://comfy-server:8188}

# 任务配置
draw:
  task:
    timeout: 120000
    max-concurrent: 5
    retry-count: 2

配置说明 :spring.ai.dashscope 前缀下的配置是 Spring AI Alibaba 自动配置的入口。只需填写 API Key 和模型名称,框架就会自动创建 ImageModel Bean。Flux 和 ComfyUI 的配置默认关闭,需要时通过环境变量启用。

2.3 实体类定义

java 复制代码
package com.example.aidraw.entity;

import jakarta.persistence.*;
import lombok.Data;
import org.hibernate.annotations.CreationTimestamp;

import java.time.LocalDateTime;

@Data
@Entity
@Table(name = "draw_task")
public class DrawTask {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false, unique = true, length = 32)
    private String taskId;
    
    @Column(nullable = false)
    private Long userId;
    
    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private DrawType type; // TEXT_TO_IMAGE, IMAGE_TO_IMAGE, COMFY_WORKFLOW
    
    @Column(columnDefinition = "TEXT")
    private String prompt;
    
    @Column(columnDefinition = "TEXT")
    private String negativePrompt;
    
    private String imageUrl;      // 输入图片URL(图生图)
    private String resultImageUrl; // 生成结果URL
    
    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private TaskStatus status = TaskStatus.PENDING;
    
    private Integer width = 1024;
    private Integer height = 1024;
    private Integer steps = 20;
    private Double cfgScale = 7.0;
    private String style;
    private Long seed;
    
    @Column(length = 50)
    private String model;  // 使用的模型名称
    
    private String errorMessage;
    private Integer retryCount = 0;
    
    @CreationTimestamp
    private LocalDateTime createdAt;
    
    private LocalDateTime completedAt;
    
    public enum DrawType {
        TEXT_TO_IMAGE,
        IMAGE_TO_IMAGE,
        COMFY_WORKFLOW
    }
    
    public enum TaskStatus {
        PENDING,      // 等待中
        PROCESSING,   // 处理中
        COMPLETED,    // 完成
        FAILED        // 失败
    }
}
java 复制代码
package com.example.aidraw.entity;

import jakarta.persistence.*;
import lombok.Data;
import org.hibernate.annotations.CreationTimestamp;

import java.time.LocalDateTime;

@Data
@Entity
@Table(name = "gallery_image")
public class GalleryImage {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false)
    private Long userId;
    
    @Column(nullable = false)
    private String imageUrl;
    
    @Column(length = 500)
    private String thumbnailUrl;
    
    @Column(nullable = false, length = 200)
    private String title;
    
    @Column(columnDefinition = "TEXT")
    private String prompt;
    
    @Column(columnDefinition = "TEXT")
    private String negativePrompt;
    
    @Column(length = 50)
    private String model;
    
    private Integer width;
    private Integer height;
    
    @Column(nullable = false)
    private Boolean isPublic = false;
    
    @Column(nullable = false)
    private Integer likeCount = 0;
    
    @Column(nullable = false)
    private Integer viewCount = 0;
    
    @CreationTimestamp
    private LocalDateTime createdAt;
}

2.4 基于 ImageModel 的文生图服务

Spring AI 设计的精髓在于"换个模型只是换个实现"。对于图像生成,它定义了 ImageModel 接口作为核心入口,ImagePrompt 包含提示词和选项,ImageResponse 返回生成结果。你只需要注入一个 ImageModel,传入 ImagePrompt,就能得到结果。底层到底是通义万相、OpenAI DALL·E 还是 Flux,对你来说都变成同一套代码。

java 复制代码
package com.example.aidraw.service;

import com.alibaba.cloud.ai.dashscope.image.DashScopeImageOptions;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.image.*;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class AiDrawService {

    private final ImageModel imageModel;

    /**
     * 文生图 --- 基于 Spring AI ImageModel 统一抽象
     */
    public String textToImage(String prompt, String negativePrompt,
                               int width, int height, String model) {
        // 1. 构建生成选项
        DashScopeImageOptions options = DashScopeImageOptions.builder()
                .withModel(model != null ? model : "wanx2.1-t2i-turbo")
                .withWidth(width)
                .withHeight(height)
                .withN(1)
                .build();

        // 2. 创建 ImagePrompt
        ImagePrompt imagePrompt = new ImagePrompt(prompt, options);

        // 3. 调用模型生成图片
        ImageResponse response = imageModel.call(imagePrompt);

        // 4. 提取图片 URL(阿里云百炼的临时访问链接)
        String imageUrl = response.getResult().getOutput().getUrl();
        log.info("文生图完成: prompt={}, url={}", prompt, imageUrl);

        return imageUrl;
    }

    /**
     * 批量生成多张图片
     */
    public List<String> batchGenerate(String prompt, int count,
                                       int width, int height) {
        DashScopeImageOptions options = DashScopeImageOptions.builder()
                .withModel("wanx2.1-t2i-turbo")
                .withWidth(width)
                .withHeight(height)
                .withN(count)
                .build();

        ImageResponse response = imageModel.call(
                new ImagePrompt(prompt, options));

        return response.getResults().stream()
                .map(r -> r.getOutput().getUrl())
                .collect(Collectors.toList());
    }
}

代码解析 :textToImage 方法通过 DashScopeImageOptions 构建生成选项,包括模型名称、尺寸和生成数量。然后创建 ImagePrompt 并调用 imageModel.call() 获取结果。getResult().getOutput().getUrl() 是标准的 URL 提取路径,返回的是阿里云百炼的临时图片 URL。

生活类比:ImageModel 就像"万能遥控器"。 无论你家里是格力空调还是美的空调(底层模型不同),遥控器上的"开机"按钮(call 方法)都能让空调启动。你不需要因为换了空调品牌而重新学习使用遥控器。

2.5 Flux 模型企业级集成

对于需要更高写实画质的企业场景,Flux.1-dev-fp8-dit 是理想选择。它用 fp8 精度和 dit 架构做了轻量化设计,在 A10 或 A100 级别显卡上就能跑出印刷级画质,对中文提示词的理解很扎实,不需要反复调试英文关键词。

集成思路:把 Flux 部署成独立的推理服务(比如用星图 GPU 平台的一键镜像),然后在 SpringBoot 里用标准 HTTP 方式调用。这样做的好处很明显------模型更新不用动 Java 代码,显卡故障不影响业务主流程,甚至可以把生成服务部署在单独的 GPU 集群上,和业务系统物理隔离。

java 复制代码
package com.example.aidraw.service;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import okhttp3.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;

@Slf4j
@Service
public class FluxImageService {

    @Value("${flux.endpoint}")
    private String fluxEndpoint;

    @Value("${flux.timeout-seconds:45}")
    private int timeoutSeconds;

    private final ObjectMapper objectMapper = new ObjectMapper();
    private final OkHttpClient httpClient;

    public FluxImageService(@Value("${flux.timeout-seconds:45}") int timeout) {
        this.httpClient = new OkHttpClient.Builder()
                .connectTimeout(10, TimeUnit.SECONDS)
                .readTimeout(timeout, TimeUnit.SECONDS)
                .build();
    }

    /**
     * 调用 Flux 推理服务生成图片
     * 超时设为45秒,因为实测95%的请求都在30秒内完成,
     * 留15秒缓冲刚好覆盖复杂提示词的生成波动
     */
    public String generateImage(String prompt, String negativePrompt,
                                 int width, int height) throws IOException {
        Map<String, Object> params = new HashMap<>();
        params.put("prompt", prompt);
        if (negativePrompt != null) {
            params.put("negative_prompt", negativePrompt);
        }
        params.put("width", width);
        params.put("height", height);
        params.put("num_inference_steps", 20);
        params.put("guidance_scale", 3.5);

        String jsonBody = objectMapper.writeValueAsString(params);

        Request request = new Request.Builder()
                .url(fluxEndpoint + "/generate")
                .post(RequestBody.create(jsonBody,
                        MediaType.parse("application/json")))
                .build();

        try (Response response = httpClient.newCall(request).execute()) {
            if (!response.isSuccessful()) {
                // 只对网络超时重试,对模型返回"生成失败"的响应绝不重试
                throw new IOException("Flux 推理失败: " + response.code());
            }
            String body = response.body().string();
            JsonNode node = objectMapper.readTree(body);
            return node.path("image_url").asText();
        }
    }
}

重试策略:只对网络超时重试两次,但对模型返回"生成失败"的响应绝不重试------这说明是提示词或内容问题,重试只会浪费资源。

2.6 ComfyUI 工作流集成

对于复杂多步骤图像编辑需求(如换脸、风格迁移、Inpainting),ComfyUI 的节点化工作流是首选。ComfyUI 提供两种 API:同步的 /prompt 接口直接触发工作流执行;异步方式结合 /prompt 和 /history 接口获取结果。

java 复制代码
package com.example.aidraw.service;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.extern.slf4j.Slf4j;
import okhttp3.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

@Slf4j
@Service
public class ComfyWorkflowService {

    @Value("${comfyui.endpoint}")
    private String comfyEndpoint;

    private final ObjectMapper objectMapper = new ObjectMapper();
    private final OkHttpClient httpClient = new OkHttpClient.Builder()
            .connectTimeout(10, TimeUnit.SECONDS)
            .readTimeout(120, TimeUnit.SECONDS)
            .build();

    /**
     * 提交 ComfyUI 工作流任务
     * 需要先在 ComfyUI 界面手动配置好工作流,
     * 通过"保存工作流为JSON"功能获取模板
     */
    public String submitWorkflow(String workflowJson,
                                  String imageUrl, String stylePrompt)
            throws IOException {
        // 1. 动态构建工作流JSON --- 替换节点中的参数
        JsonNode workflow = objectMapper.readTree(workflowJson);

        // 替换输入图片URL(节点ID需要根据实际工作流配置)
        ((ObjectNode) workflow.path("nodes").path("10").path("inputs"))
                .put("image", imageUrl);

        // 替换风格提示词
        ((ObjectNode) workflow.path("nodes").path("15").path("inputs"))
                .put("text", stylePrompt);

        // 2. 包装为 ComfyUI API 请求格式
        ObjectNode request = objectMapper.createObjectNode();
        request.set("prompt", workflow);
        request.put("client_id", UUID.randomUUID().toString());

        Request httpRequest = new Request.Builder()
                .url(comfyEndpoint + "/prompt")
                .post(RequestBody.create(
                        objectMapper.writeValueAsString(request),
                        MediaType.parse("application/json")))
                .build();

        try (Response response = httpClient.newCall(httpRequest).execute()) {
            String body = response.body().string();
            JsonNode result = objectMapper.readTree(body);
            return result.path("prompt_id").asText();
        }
    }

    /**
     * 查询 ComfyUI 任务结果
     */
    public String getWorkflowResult(String promptId) throws IOException {
        Request request = new Request.Builder()
                .url(comfyEndpoint + "/history/" + promptId)
                .get()
                .build();

        try (Response response = httpClient.newCall(request).execute()) {
            String body = response.body().string();
            JsonNode history = objectMapper.readTree(body);

            // 从历史记录中提取生成的图片
            JsonNode outputs = history.path(promptId).path("outputs");
            var fields = outputs.fields();
            while (fields.hasNext()) {
                var entry = fields.next();
                JsonNode images = entry.getValue().path("images");
                if (images.isArray() && images.size() > 0) {
                    return images.get(0).path("filename").asText();
                }
            }
            return null; // 尚未完成
        }
    }
}

关键设计:一是用 Spring 的异步任务处理来解耦请求和耗时操作,提升接口响应速度;二是精心设计 HTTP 客户端与 ComfyUI 服务的通信协议,特别是动态构建工作流 JSON 那部分,需要耐心调试。

2.7 任务队列服务

java 复制代码
package com.example.aidraw.service;

import com.example.aidraw.entity.DrawTask;
import com.example.aidraw.repository.DrawTaskRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

@Slf4j
@Service
@RequiredArgsConstructor
public class DrawTaskService {

    private final DrawTaskRepository taskRepository;
    private final AiDrawService aiDrawService;
    private final FluxImageService fluxImageService;
    private final RabbitTemplate rabbitTemplate;
    private final SimpMessagingTemplate messagingTemplate;
    private final RedisTemplate<String, Object> redisTemplate;
    private final ImageStorageService imageStorageService;

    @Value("${draw.task.max-concurrent}")
    private int maxConcurrent;

    private final Semaphore semaphore = new Semaphore(5);

    private static final String TASK_QUEUE = "draw.task.queue";
    private static final String TASK_STATUS_CHANNEL = "/topic/task/";

    /**
     * 提交绘图任务
     */
    public DrawTask submitTask(DrawTask task) {
        task.setTaskId(generateTaskId());
        task.setStatus(DrawTask.TaskStatus.PENDING);
        task = taskRepository.save(task);

        // 发送到消息队列
        rabbitTemplate.convertAndSend(TASK_QUEUE, task.getTaskId());

        return task;
    }

    /**
     * 消费并处理任务
     */
    @RabbitListener(queues = TASK_QUEUE, concurrency = "3-5")
    public void processTask(String taskId) {
        DrawTask task = taskRepository.findByTaskId(taskId)
                .orElseThrow(() -> new RuntimeException("任务不存在: " + taskId));

        try {
            semaphore.acquire();
            task.setStatus(DrawTask.TaskStatus.PROCESSING);
            taskRepository.save(task);

            // 通知前端状态更新
            notifyStatusChange(task);

            // 根据任务类型选择模型
            String resultUrl;
            if ("flux".equals(task.getModel()) && fluxEnabled) {
                resultUrl = fluxImageService.generateImage(
                        task.getPrompt(), task.getNegativePrompt(),
                        task.getWidth(), task.getHeight());
            } else {
                resultUrl = aiDrawService.textToImage(
                        task.getPrompt(), task.getNegativePrompt(),
                        task.getWidth(), task.getHeight(), task.getModel());
            }

            // 下载图片并存储到 MinIO
            String storedUrl = imageStorageService
                    .downloadAndStore(resultUrl, taskId);

            task.setStatus(DrawTask.TaskStatus.COMPLETED);
            task.setResultImageUrl(storedUrl);
            task.setCompletedAt(LocalDateTime.now());
            taskRepository.save(task);
            notifyStatusChange(task);

        } catch (Exception e) {
            log.error("任务处理失败: {}", taskId, e);
            task.setStatus(DrawTask.TaskStatus.FAILED);
            task.setErrorMessage(e.getMessage());
            taskRepository.save(task);
            notifyStatusChange(task);
        } finally {
            semaphore.release();
        }
    }

    /**
     * 通过 WebSocket 通知前端
     */
    private void notifyStatusChange(DrawTask task) {
        messagingTemplate.convertAndSend(
                TASK_STATUS_CHANNEL + task.getTaskId(), task);
    }

    /**
     * 获取用户任务列表
     */
    public List<DrawTask> getUserTasks(Long userId, int page, int size) {
        return taskRepository.findByUserIdOrderByCreatedAtDesc(
                userId, PageRequest.of(page, size)).getContent();
    }

    /**
     * 获取任务详情
     */
    public DrawTask getTask(String taskId) {
        return taskRepository.findByTaskId(taskId)
                .orElseThrow(() -> new RuntimeException("任务不存在"));
    }

    private String generateTaskId() {
        return java.util.UUID.randomUUID().toString()
                .replace("-", "").substring(0, 16);
    }
}

代码解析 :processTask 是异步任务处理的核心。通过 semaphore.acquire() 控制并发数(最大 5 个同时执行),避免 GPU 显存溢出。notifyStatusChange 通过 WebSocket 的 STOMP 协议将任务状态推送给前端,实现实时进度展示。根据 task.getModel() 决定使用通义万相还是 Flux 推理服务,实现了多模型路由。

2.8 图片存储服务

java 复制代码
package com.example.aidraw.service;

import io.minio.*;
import io.minio.http.Method;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import jakarta.annotation.PostConstruct;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.net.URL;
import java.util.concurrent.TimeUnit;

@Slf4j
@Service
public class ImageStorageService {

    @Value("${minio.endpoint}")
    private String endpoint;

    @Value("${minio.access-key}")
    private String accessKey;

    @Value("${minio.secret-key}")
    private String secretKey;

    @Value("${minio.bucket-name}")
    private String bucketName;

    private MinioClient minioClient;

    @PostConstruct
    public void init() {
        minioClient = MinioClient.builder()
                .endpoint(endpoint)
                .credentials(accessKey, secretKey)
                .build();
        createBucketIfNotExists();
    }

    private void createBucketIfNotExists() {
        try {
            boolean exists = minioClient.bucketExists(
                    BucketExistsArgs.builder().bucket(bucketName).build());
            if (!exists) {
                minioClient.makeBucket(
                        MakeBucketArgs.builder().bucket(bucketName).build());
                log.info("创建 Bucket: {}", bucketName);
            }
        } catch (Exception e) {
            log.error("初始化 MinIO 失败", e);
        }
    }

    /**
     * 下载临时 URL 的图片并转存到 MinIO
     * 阿里云百炼返回的是临时访问链接,如果业务需要长期保存,
     * 应该由后端下载后再转存到对象存储
     */
    public String downloadAndStore(String imageUrl, String taskId)
            throws Exception {
        URL url = new URL(imageUrl);
        byte[] imageBytes;
        try (InputStream inputStream = url.openStream()) {
            imageBytes = inputStream.readAllBytes();
        }

        String fileName = "images/" + taskId + ".png";

        minioClient.putObject(PutObjectArgs.builder()
                .bucket(bucketName)
                .object(fileName)
                .stream(new ByteArrayInputStream(imageBytes),
                        imageBytes.length, -1)
                .contentType("image/png")
                .build());

        return minioClient.getPresignedObjectUrl(
                GetPresignedObjectUrlArgs.builder()
                        .method(Method.GET)
                        .bucket(bucketName)
                        .object(fileName)
                        .expiry(7, TimeUnit.DAYS)
                        .build());
    }

    /**
     * 上传用户图片(图生图场景)
     */
    public String uploadImage(byte[] imageData, String fileName,
                               String contentType) throws Exception {
        String objectName = "uploads/" + System.currentTimeMillis()
                + "_" + fileName;

        minioClient.putObject(PutObjectArgs.builder()
                .bucket(bucketName)
                .object(objectName)
                .stream(new ByteArrayInputStream(imageData),
                        imageData.length, -1)
                .contentType(contentType)
                .build());

        return endpoint + "/" + bucketName + "/" + objectName;
    }
}

2.9 REST 控制器

java 复制代码
package com.example.aidraw.controller;

import com.example.aidraw.entity.DrawTask;
import com.example.aidraw.entity.GalleryImage;
import com.example.aidraw.service.*;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/draw")
@RequiredArgsConstructor
@CrossOrigin(origins = "*")
public class DrawController {

    private final DrawTaskService taskService;
    private final AiDrawService aiDrawService;
    private final GalleryService galleryService;
    private final ImageStorageService storageService;
    private final ComfyWorkflowService comfyWorkflowService;

    /**
     * 提交文生图任务
     */
    @PostMapping("/text-to-image")
    public ResponseEntity<DrawTask> textToImage(
            @RequestBody TextToImageRequest request) {
        DrawTask task = new DrawTask();
        task.setUserId(request.getUserId());
        task.setType(DrawTask.DrawType.TEXT_TO_IMAGE);
        task.setPrompt(request.getPrompt());
        task.setNegativePrompt(request.getNegativePrompt());
        task.setWidth(request.getWidth());
        task.setHeight(request.getHeight());
        task.setStyle(request.getStyle());
        task.setSeed(request.getSeed());
        task.setModel(request.getModel());

        return ResponseEntity.ok(taskService.submitTask(task));
    }

    /**
     * 提交图生图任务
     */
    @PostMapping("/image-to-image")
    public ResponseEntity<DrawTask> imageToImage(
            @RequestBody ImageToImageRequest request) {
        DrawTask task = new DrawTask();
        task.setUserId(request.getUserId());
        task.setType(DrawTask.DrawType.IMAGE_TO_IMAGE);
        task.setPrompt(request.getPrompt());
        task.setNegativePrompt(request.getNegativePrompt());
        task.setImageUrl(request.getImageUrl());
        task.setWidth(request.getWidth());
        task.setHeight(request.getHeight());

        return ResponseEntity.ok(taskService.submitTask(task));
    }

    /**
     * 提交 ComfyUI 工作流任务
     */
    @PostMapping("/comfy-workflow")
    public ResponseEntity<Map<String, String>> comfyWorkflow(
            @RequestBody ComfyWorkflowRequest request) throws Exception {
        String promptId = comfyWorkflowService.submitWorkflow(
                request.getWorkflowJson(),
                request.getImageUrl(),
                request.getStylePrompt());
        return ResponseEntity.ok(Map.of("promptId", promptId));
    }

    /**
     * 查询任务状态
     */
    @GetMapping("/task/{taskId}")
    public ResponseEntity<DrawTask> getTaskStatus(
            @PathVariable String taskId) {
        return ResponseEntity.ok(taskService.getTask(taskId));
    }

    /**
     * 上传图片
     */
    @PostMapping("/upload")
    public ResponseEntity<Map<String, String>> uploadImage(
            @RequestParam("file") MultipartFile file) throws Exception {
        String url = storageService.uploadImage(
                file.getBytes(), file.getOriginalFilename(),
                file.getContentType());
        return ResponseEntity.ok(Map.of("url", url));
    }
}

// DTO 类
@Data
class TextToImageRequest {
    private Long userId;
    private String prompt;
    private String negativePrompt;
    private Integer width = 1024;
    private Integer height = 1024;
    private String style;
    private Long seed;
    private String model; // "wanx2.1-t2i-turbo" | "flux"
}

@Data
class ImageToImageRequest {
    private Long userId;
    private String prompt;
    private String negativePrompt;
    private String imageUrl;
    private Integer width = 1024;
    private Integer height = 1024;
}

@Data
class ComfyWorkflowRequest {
    private String workflowJson;
    private String imageUrl;
    private String stylePrompt;
}

2.10 WebSocket 配置

java 复制代码
package com.example.aidraw.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic");
        config.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")
                .setAllowedOriginPatterns("*")
                .withSockJS();
    }
}

三、前端实现

3.1 项目初始化

bash 复制代码
npm create vite@latest ai-draw-frontend -- --template react-ts
cd ai-draw-frontend
npm install
npm install antd @ant-design/icons axios zustand
npm install @stomp/stompjs sockjs-client

3.2 状态管理

typescript 复制代码
// src/store/drawStore.ts
import { create } from 'zustand'
import { DrawTask, GalleryImage } from '../types'

interface DrawState {
  currentTask: DrawTask | null
  tasks: DrawTask[]
  galleryImages: GalleryImage[]
  isLoading: boolean
  prompt: string
  negativePrompt: string
  width: number
  height: number
  style: string
  seed: number | null
  model: string
  
  setCurrentTask: (task: DrawTask | null) => void
  setTasks: (tasks: DrawTask[]) => void
  updateTask: (taskId: string, updates: Partial<DrawTask>) => void
  setGalleryImages: (images: GalleryImage[]) => void
  setLoading: (loading: boolean) => void
  setPrompt: (prompt: string) => void
  setNegativePrompt: (prompt: string) => void
  setWidth: (width: number) => void
  setHeight: (height: number) => void
  setStyle: (style: string) => void
  setSeed: (seed: number | null) => void
  setModel: (model: string) => void
}

export const useDrawStore = create<DrawState>((set) => ({
  currentTask: null,
  tasks: [],
  galleryImages: [],
  isLoading: false,
  prompt: '',
  negativePrompt: '',
  width: 1024,
  height: 1024,
  style: 'default',
  seed: null,
  model: 'wanx2.1-t2i-turbo',
  
  setCurrentTask: (task) => set({ currentTask: task }),
  setTasks: (tasks) => set({ tasks }),
  updateTask: (taskId, updates) => set((state) => ({
    tasks: state.tasks.map(t => 
      t.taskId === taskId ? { ...t, ...updates } : t
    ),
    currentTask: state.currentTask?.taskId === taskId 
      ? { ...state.currentTask, ...updates } 
      : state.currentTask
  })),
  setGalleryImages: (images) => set({ galleryImages: images }),
  setLoading: (loading) => set({ isLoading: loading }),
  setPrompt: (prompt) => set({ prompt }),
  setNegativePrompt: (prompt) => set({ negativePrompt: prompt }),
  setWidth: (width) => set({ width }),
  setHeight: (height) => set({ height }),
  setStyle: (style) => set({ style }),
  setSeed: (seed) => set({ seed }),
  setModel: (model) => set({ model })
}))

3.3 API 封装

typescript 复制代码
// src/api/draw.ts
import axios from 'axios'
import { DrawTask, GalleryImage, PageResponse } from '../types'

const api = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api',
  timeout: 30000
})

export const drawApi = {
  textToImage: (data: {
    userId: number
    prompt: string
    negativePrompt?: string
    width?: number
    height?: number
    style?: string
    seed?: number
    model?: string
  }) => api.post<DrawTask>('/draw/text-to-image', data),

  imageToImage: (data: {
    userId: number
    prompt: string
    negativePrompt?: string
    imageUrl: string
    width?: number
    height?: number
  }) => api.post<DrawTask>('/draw/image-to-image', data),

  comfyWorkflow: (data: {
    workflowJson: string
    imageUrl: string
    stylePrompt: string
  }) => api.post<{ promptId: string }>('/draw/comfy-workflow', data),

  getTaskStatus: (taskId: string) => 
    api.get<DrawTask>(`/draw/task/${taskId}`),

  getUserTasks: (userId: number, page = 0, size = 20) =>
    api.get<PageResponse<DrawTask>>('/draw/tasks', {
      params: { userId, page, size }
    }),

  enhancePrompt: (prompt: string) =>
    api.post<{ prompt: string }>('/draw/enhance-prompt', { prompt }),

  uploadImage: (file: File) => {
    const formData = new FormData()
    formData.append('file', file)
    return api.post<{ url: string }>('/draw/upload', formData)
  }
}

3.4 类型定义

typescript 复制代码
// src/types/index.ts
export interface DrawTask {
  id: number
  taskId: string
  userId: number
  type: 'TEXT_TO_IMAGE' | 'IMAGE_TO_IMAGE' | 'COMFY_WORKFLOW'
  prompt: string
  negativePrompt?: string
  imageUrl?: string
  resultImageUrl?: string
  status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED'
  width: number
  height: number
  steps?: number
  cfgScale?: number
  style?: string
  seed?: number
  model?: string
  errorMessage?: string
  createdAt: string
  completedAt?: string
}

export interface GalleryImage {
  id: number
  userId: number
  imageUrl: string
  thumbnailUrl?: string
  title: string
  prompt: string
  negativePrompt?: string
  model?: string
  width?: number
  height?: number
  isPublic: boolean
  likeCount: number
  viewCount: number
  createdAt: string
}

export interface PageResponse<T> {
  content: T[]
  totalElements: number
  totalPages: number
  size: number
  number: number
}

3.5 绘图面板组件

tsx 复制代码
// src/components/DrawPanel.tsx
import React, { useCallback, useRef } from 'react'
import {
  Card, Input, Button, Select, Space, Spin, message,
  Row, Col, Tooltip, Tag
} from 'antd'
import {
  PictureOutlined, SettingOutlined, ThunderboltOutlined,
  ReloadOutlined, CopyOutlined, DownloadOutlined
} from '@ant-design/icons'
import { useDrawStore } from '../store/drawStore'
import { drawApi } from '../api/draw'
import { useWebSocket } from '../hooks/useWebSocket'

const { TextArea } = Input
const { Option } = Select

const STYLES = [
  { value: 'default', label: '默认' },
  { value: 'anime', label: '动漫' },
  { value: 'oil', label: '油画' },
  { value: 'watercolor', label: '水彩' },
  { value: 'sketch', label: '素描' },
  { value: '3d', label: '3D渲染' },
  { value: 'cyberpunk', label: '赛博朋克' }
]

const MODELS = [
  { value: 'wanx2.1-t2i-turbo', label: '通义万相 Turbo(快速)' },
  { value: 'flux', label: 'Flux.1-dev(高质量)' }
]

const SIZES = [
  { value: '512x512', label: '512×512', width: 512, height: 512 },
  { value: '768x768', label: '768×768', width: 768, height: 768 },
  { value: '1024x1024', label: '1024×1024', width: 1024, height: 1024 },
  { value: '1024x768', label: '1024×768 (横版)', width: 1024, height: 768 },
  { value: '768x1024', label: '768×1024 (竖版)', width: 768, height: 1024 }
]

export const DrawPanel: React.FC = () => {
  const {
    currentTask, prompt, negativePrompt, width, height,
    style, seed, model, setPrompt, setNegativePrompt,
    setWidth, setHeight, setStyle, setSeed, setModel,
    setCurrentTask, updateTask, isLoading, setLoading
  } = useDrawStore()

  // WebSocket 监听任务状态
  useWebSocket(currentTask?.taskId || null, (task) => {
    updateTask(task.taskId, task)
    if (task.status === 'COMPLETED' || task.status === 'FAILED') {
      setLoading(false)
    }
  })

  const handleGenerate = useCallback(async () => {
    if (!prompt.trim()) {
      message.warning('请输入提示词')
      return
    }

    setLoading(true)
    try {
      const { data } = await drawApi.textToImage({
        userId: 1,
        prompt,
        negativePrompt,
        width,
        height,
        style,
        seed: seed || undefined,
        model
      })
      setCurrentTask(data)
      message.success('任务已提交')
    } catch (error) {
      message.error('提交失败')
      setLoading(false)
    }
  }, [prompt, negativePrompt, width, height, style, seed, model])

  const handleEnhancePrompt = async () => {
    if (!prompt.trim()) return
    try {
      const { data } = await drawApi.enhancePrompt(prompt)
      setPrompt(data.prompt)
      message.success('提示词已优化')
    } catch (error) {
      message.error('优化失败')
    }
  }

  const handleRandomSeed = () => {
    setSeed(Math.floor(Math.random() * 2147483647))
  }

  const handleDownload = () => {
    if (currentTask?.resultImageUrl) {
      const link = document.createElement('a')
      link.href = currentTask.resultImageUrl
      link.download = `ai-draw-${currentTask.taskId}.png`
      link.click()
    }
  }

  return (
    <div className="draw-panel">
      <Row gutter={24}>
        <Col xs={24} lg={12}>
          <Card title="AI 绘图" extra={
            <Tooltip title="高级设置">
              <Button icon={<SettingOutlined />} />
            </Tooltip>
          }>
            <div className="form-item">
              <label>模型</label>
              <Select value={model} onChange={setModel} style={{ width: '100%' }}>
                {MODELS.map(m => (
                  <Option key={m.value} value={m.value}>{m.label}</Option>
                ))}
              </Select>
            </div>

            <div className="form-item">
              <label>提示词 (Prompt)</label>
              <TextArea
                value={prompt}
                onChange={(e) => setPrompt(e.target.value)}
                placeholder="描述你想生成的图片..."
                rows={4}
                maxLength={1000}
                showCount
              />
              <Button
                type="link"
                icon={<ThunderboltOutlined />}
                onClick={handleEnhancePrompt}
              >
                优化提示词
              </Button>
            </div>

            <div className="form-item">
              <label>负面提示词 (可选)</label>
              <TextArea
                value={negativePrompt}
                onChange={(e) => setNegativePrompt(e.target.value)}
                placeholder="不想出现的元素..."
                rows={2}
              />
            </div>

            <div className="form-item">
              <label>风格</label>
              <Select value={style} onChange={setStyle} style={{ width: '100%' }}>
                {STYLES.map(s => (
                  <Option key={s.value} value={s.value}>{s.label}</Option>
                ))}
              </Select>
            </div>

            <div className="form-item">
              <label>尺寸</label>
              <Select
                value={`${width}x${height}`}
                onChange={(val) => {
                  const size = SIZES.find(s => s.value === val)
                  if (size) { setWidth(size.width); setHeight(size.height) }
                }}
                style={{ width: '100%' }}
              >
                {SIZES.map(s => (
                  <Option key={s.value} value={s.value}>{s.label}</Option>
                ))}
              </Select>
            </div>

            <div className="form-item">
              <label>种子 (Seed)</label>
              <Space>
                <Input
                  value={seed || ''}
                  onChange={(e) => setSeed(
                    e.target.value ? Number(e.target.value) : null)}
                  placeholder="随机"
                  style={{ width: 150 }}
                />
                <Tooltip title="随机种子">
                  <Button icon={<ReloadOutlined />} onClick={handleRandomSeed} />
                </Tooltip>
              </Space>
            </div>

            <Button
              type="primary"
              size="large"
              block
              icon={<PictureOutlined />}
              onClick={handleGenerate}
              loading={isLoading}
              style={{ marginTop: 24 }}
            >
              {isLoading ? '生成中...' : '开始生成'}
            </Button>
          </Card>
        </Col>

        <Col xs={24} lg={12}>
          <Card
            title="预览"
            extra={
              currentTask?.resultImageUrl && (
                <Space>
                  <Button
                    icon={<CopyOutlined />}
                    onClick={() => {
                      navigator.clipboard.writeText(currentTask.resultImageUrl!)
                      message.success('已复制链接')
                    }}
                  >复制链接</Button>
                  <Button
                    type="primary"
                    icon={<DownloadOutlined />}
                    onClick={handleDownload}
                  >下载</Button>
                </Space>
              )
            }
          >
            <div className="preview-area">
              {isLoading ? (
                <div className="loading-preview">
                  <Spin size="large" tip="AI 正在创作中..." />
                  <div className="task-status">
                    {currentTask?.status === 'PENDING' && 
                      <Tag color="default">等待中</Tag>}
                    {currentTask?.status === 'PROCESSING' && 
                      <Tag color="processing">生成中</Tag>}
                  </div>
                </div>
              ) : currentTask?.resultImageUrl ? (
                <img
                  src={currentTask.resultImageUrl}
                  alt="生成结果"
                  className="result-image"
                />
              ) : (
                <div className="empty-preview">
                  <PictureOutlined style={{ fontSize: 64, color: '#ccc' }} />
                  <p>输入提示词并点击生成</p>
                </div>
              )}
            </div>
          </Card>
        </Col>
      </Row>
    </div>
  )
}

3.6 WebSocket Hook

typescript 复制代码
// src/hooks/useWebSocket.ts
import { useEffect, useRef } from 'react'
import { Client, IMessage } from '@stomp/stompjs'
import SockJS from 'sockjs-client'
import { DrawTask } from '../types'

const WS_URL = import.meta.env.VITE_WS_URL || 'http://localhost:8080/ws'

export const useWebSocket = (
  taskId: string | null,
  onMessage: (task: DrawTask) => void
) => {
  const clientRef = useRef<Client | null>(null)

  useEffect(() => {
    if (!taskId) return

    const client = new Client({
      webSocketFactory: () => new SockJS(WS_URL),
      reconnectDelay: 5000,
      heartbeatIncoming: 4000,
      heartbeatOutgoing: 4000,
      onConnect: () => {
        client.subscribe(`/topic/task/${taskId}`, (message: IMessage) => {
          const task = JSON.parse(message.body) as DrawTask
          onMessage(task)
        })
      },
      onStompError: (frame) => {
        console.error('WebSocket error:', frame)
      }
    })

    client.activate()
    clientRef.current = client

    return () => { client.deactivate() }
  }, [taskId, onMessage])
}

3.7 画廊组件

tsx 复制代码
// src/components/Gallery.tsx
import React, { useEffect, useState } from 'react'
import { Card, Row, Col, Modal, Tag, Space, Pagination, Spin } from 'antd'
import { HeartOutlined, EyeOutlined, DownloadOutlined } from '@ant-design/icons'
import { drawApi } from '../api/draw'
import { GalleryImage } from '../types'

const { Meta } = Card

export const Gallery: React.FC = () => {
  const [images, setImages] = useState<GalleryImage[]>([])
  const [loading, setLoading] = useState(false)
  const [page, setPage] = useState(0)
  const [totalPages, setTotalPages] = useState(0)
  const [selectedImage, setSelectedImage] = useState<GalleryImage | null>(null)

  const loadImages = async (pageNum = 0) => {
    setLoading(true)
    try {
      const { data } = await drawApi.getGallery(pageNum, 12)
      setImages(data.content)
      setTotalPages(data.totalPages)
    } catch (error) {
      console.error('加载画廊失败', error)
    } finally {
      setLoading(false)
    }
  }

  useEffect(() => { loadImages() }, [])

  return (
    <div className="gallery-container">
      <Spin spinning={loading}>
        <Row gutter={[16, 16]}>
          {images.map((img) => (
            <Col xs={24} sm={12} md={8} lg={6} key={img.id}>
              <Card
                hoverable
                cover={
                  <div className="gallery-image-wrapper">
                    <img
                      alt={img.title}
                      src={img.thumbnailUrl || img.imageUrl}
                      onClick={() => setSelectedImage(img)}
                    />
                  </div>
                }
                actions={[
                  <Space><HeartOutlined /> {img.likeCount}</Space>,
                  <Space><EyeOutlined /> {img.viewCount}</Space>,
                  <DownloadOutlined onClick={() => {
                    const link = document.createElement('a')
                    link.href = img.imageUrl
                    link.download = `${img.title}.png`
                    link.click()
                  }} />
                ]}
              >
                <Meta
                  title={img.title}
                  description={
                    <Space direction="vertical" size={0}>
                      <Tag color="blue">{img.model || 'AI'}</Tag>
                      <span className="prompt-text">
                        {img.prompt.slice(0, 50)}...
                      </span>
                    </Space>
                  }
                />
              </Card>
            </Col>
          ))}
        </Row>
      </Spin>

      <Pagination
        current={page + 1}
        total={totalPages * 12}
        pageSize={12}
        onChange={(p) => { setPage(p - 1); loadImages(p - 1) }}
        style={{ marginTop: 24, textAlign: 'center' }}
      />

      <Modal
        open={!!selectedImage}
        footer={null}
        onCancel={() => setSelectedImage(null)}
        width="80%"
        style={{ top: 20 }}
      >
        {selectedImage && (
          <div className="image-detail">
            <img
              src={selectedImage.imageUrl}
              alt={selectedImage.title}
              style={{ width: '100%' }}
            />
            <div className="image-info" style={{ marginTop: 16 }}>
              <h3>{selectedImage.title}</h3>
              <p><strong>提示词:</strong>{selectedImage.prompt}</p>
              {selectedImage.negativePrompt && (
                <p><strong>负面提示词:</strong>
                  {selectedImage.negativePrompt}</p>
              )}
              <p><strong>模型:</strong>{selectedImage.model}</p>
            </div>
          </div>
        )}
      </Modal>
    </div>
  )
}

3.8 主应用组件

tsx 复制代码
// src/App.tsx
import React from 'react'
import { Layout, Menu, ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import {
  PictureOutlined, AppstoreOutlined, HistoryOutlined, UserOutlined
} from '@ant-design/icons'
import { DrawPanel } from './components/DrawPanel'
import { Gallery } from './components/Gallery'
import { TaskHistory } from './components/TaskHistory'

const { Header, Content } = Layout

type TabKey = 'draw' | 'gallery' | 'history' | 'profile'

const App: React.FC = () => {
  const [activeTab, setActiveTab] = React.useState<TabKey>('draw')

  const menuItems = [
    { key: 'draw', icon: <PictureOutlined />, label: 'AI 绘图' },
    { key: 'gallery', icon: <AppstoreOutlined />, label: '画廊' },
    { key: 'history', icon: <HistoryOutlined />, label: '历史记录' },
    { key: 'profile', icon: <UserOutlined />, label: '个人中心' }
  ]

  const renderContent = () => {
    switch (activeTab) {
      case 'draw': return <DrawPanel />
      case 'gallery': return <Gallery />
      case 'history': return <TaskHistory />
      case 'profile': return <div>个人中心</div>
      default: return null
    }
  }

  return (
    <ConfigProvider locale={zhCN}>
      <Layout style={{ minHeight: '100vh' }}>
        <Header style={{ display: 'flex', alignItems: 'center',
                          background: '#001529' }}>
          <div className="logo" style={{ color: '#fff', fontSize: 20,
                                          marginRight: 48 }}>
            AI 绘图平台
          </div>
          <Menu
            theme="dark"
            mode="horizontal"
            items={menuItems}
            onClick={({ key }) => setActiveTab(key as TabKey)}
            selectedKeys={[activeTab]}
            style={{ flex: 1 }}
          />
        </Header>
        <Content style={{ padding: 24 }}>
          {renderContent()}
        </Content>
      </Layout>
    </ConfigProvider>
  )
}

export default App

四、Serverless 弹性部署方案

4.1 为什么选择 Serverless 部署

当前企业落地 AI 大模型时,通常面临 GPU 服务器采购成本高昂、环境配置复杂、资源利用率低、弹性能力不足等挑战。Serverless 架构通过将 GPU 计算能力函数化,可以实现 AI 推理能力的按需调用与按量计费。

类比:Serverless 部署就像"共享充电宝"模式。 传统 GPU 服务器是你自己买一个充电宝------不管用不用,充电宝都是你的资产。Serverless 是共享充电宝------用的时候扫码付费,不用的时候不需要为闲置付费。对于 AI 绘图这种"事件驱动型"负载------有用户请求才需要 GPU,没有请求时 GPU 闲置浪费------Serverless 方案的经济性优势非常明显。

4.2 基于阿里云函数计算(FC)的架构

yaml 复制代码
# 函数计算 FC 部署配置(s.yaml)
# 基于阿里云函数计算部署 ComfyUI + Flux 文生图服务
service:
  name: ai-draw-serverless
  description: AI 绘图 Serverless 服务
  internetAccess: true

provider:
  name: alibaba
  region: cn-hangzhou
  runtime: custom-container
  memorySize: 32768       # 32GB 内存
  timeout: 300            # 5分钟超时
  instanceConcurrency: 1  # 单实例并发

functions:
  comfyui-flux:
    handler: not-used
    caPort: 8188
    customContainerConfig:
      image: registry.cn-hangzhou.aliyuncs.com/your-ns/comfyui-flux:latest
      command: ["python", "main.py", "--listen", "0.0.0.0"]
    instanceType: gpu     # GPU 实例
    gpuMemorySize: 16384  # 16GB GPU 显存
    nasConfig:
      mountPoints:
        - serverAddr: "your-nas.cn-hangzhou.nas.aliyuncs.com:/models"
          mountDir: /root/models
    triggers:
      - type: http
        config:
          authType: function
          methods: [POST]

关键配置说明:

  • 实例规格:32GB 内存 + 16GB GPU + 8 核 vCPU,这是运行 ComfyUI + Flux 的基础配置
  • NAS 挂载:用于持久化大模型文件(Checkpoints、LoRA 等),避免实例销毁后重复下载
  • 冷启动初始化:约 60 秒(含模型加载),热调用推理 512×512 图像约 5 秒
  • 预估成本:0.02 ~ 0.2 元/张

4.3 成本对比与优化策略

部署方案 月度成本(日均1000张) 适用场景 启动延迟
自建GPU服务器 5000-15000元 高并发稳定负载 无
函数计算FC 600-6000元 波动性负载 冷启动60秒
容器实例(ECI) 2000-8000元 中等负载 冷启动15-30秒

成本优化建议:函数计算按调用时长与资源规格计费,闲置不产生费用。对于日调用量低于 5000 张的场景,Serverless 方案的月成本通常仅为自建 GPU 服务器的 10%-40%。新用户可享受免费额度,进一步降低试错成本。

相关推荐
昇腾CANN1 小时前
10月16日16点直播丨基于950超节点的万亿MoE模型预训练参考实践
人工智能·昇腾·cann·cann开源
过河卒_zh15667661 小时前
再更新!人工智能安全治理指引性文件第三版发布!
人工智能·算法·aigc·生成式人工智能·算法备案
树獭哥1 小时前
Deep Agents 学习笔记(一):从 Agent Harness 到第一个 Deep Agent
人工智能·python
武科大许志伟1 小时前
GPX:TSP中的两条好路线,怎样拼成一条更好的路线?
人工智能·演化计算·组合优化
CallFay云起未来1 小时前
2026年AI客服能不能减少人工回复?从知识检索到人机协同的技术实现
java·大数据·前端·人工智能·架构·文心一言
xieyan08111 小时前
HyperFrames:给一段文字让 AI 自动做视频,实测效果怎么样?
人工智能·音视频
IT_陈寒1 小时前
一文搞懂SpringBoot的核心概念
前端·人工智能·后端
LX567771 小时前
用AI减少网点重复劳动:从识别问题到设计辅助工作流
人工智能
十铭忘1 小时前
四大坐标系的转换10——协向量
人工智能