Skip to content

项目介绍

最近在学习AI零代码应用生成平台,特此记录下来,博主本身就是做前端开发的,主要记录后端代码,前端只记录核心代码

技术栈选型

业务流程

主业务流程

应用管理流程

普通用户管理应用的流程

管理员管理流程

功能模块

架构设计

项目初始化

后端项目初始化

新建项目

添加核心依赖

修改配置文件 src/main/resources/application.yaml

yaml
# 应用服务 WEB 访问端口
spring:
  application:
    name: ai-code-platform-backend
server:
  port: 8123
  servlet:
    context-path: /api

整合依赖

Hutool工具库

Hutool 是主流的 Java 工具类库,集合了丰富的工具类,涵盖字符串处理、日期操作、文件处理、加解密、反射、正则匹配等常见功能。它的轻量化和无侵入性让开发者能够专注于业务逻辑而不必编写重复的工具代码。

xml
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.38</version>
</dependency>
Knife4j文件工具

Knife4j 是基于 Swagger 接口文档的增强工具,提供了更加友好的⁢ API 文档界面和功能扩展,例如动态参数调试、分组文⁠档等。它适合用于 Spring Boot 项目​中,能够通过简单的配置自动生成接口文档,让开发者和‍前端快速了解和调试接口,提高协作效率。

1、添加依赖

xml
<dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
    <version>4.4.0</version>
</dependency>

2、添加配置 在 application.yaml 中添加配置,重点是 controller的包路径

application.yaml

xml
springdoc:
  group-configs:
    - group: 'default'
      packages-to-scan: com.zhaochao.aicodeplatform.controller
knife4j:
  enable: true
  setting:
    language: zh_cn

3、在 controller 中添加一个测试接口

com.zhaochao.aicodeplatform.controller.HealthController

java
package com.zhaochao.aicodeplatform.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/health")
public class HealthController {

    @GetMapping("/")
    public String healthCheck(){
        return "ok";
    }
}

重启项目后浏览器访问:http://localhost:8123/api/doc.html#/home

其他依赖

AOP 切面编程依赖

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

启动类添加 @EnableAspectJAutoProxy(exposeProxy = true) 注解

java
@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true)
public class AiCodePlatformApplication {

    public static void main(String[] args) {
        SpringApplication.run(AiCodePlatformApplication.class, args);
    }

}
最终的pom文件

当前 pom.xml 文件配置如下,后续新的依赖还会添加 pom.xml

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>
    <groupId>com.zhaochao</groupId>
    <artifactId>ai-code-platform</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>ai-code-platform</name>
    <description>ai-code-platform</description>
    <properties>
        <java.version>17</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <spring-boot.version>3.5.3</spring-boot.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.8.38</version>
        </dependency>
        <dependency>
            <groupId>com.github.xiaoymin</groupId>
            <artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
            <version>4.4.0</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>
    </dependencies>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <source>17</source>
                    <target>17</target>
                    <encoding>UTF-8</encoding>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>${spring-boot.version}</version>
                <configuration>
                    <mainClass>com.zhaochao.aicodeplatform.AiCodePlatformApplication</mainClass>
                    <skip>true</skip>
                </configuration>
                <executions>
                    <execution>
                        <id>repackage</id>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

</project>

通用基础代码

通用的基础代码,使用场景很高,例如请求响应体、异常拦截、构建请求通用体等

自定义异常

exception 包下新建错误码枚举类 com.zhaochao.aicodeplatform.exception.ErrorCode

java
package com.zhaochao.aicodeplatform.exception;

import lombok.Getter;

@Getter
public enum ErrorCode {

    SUCCESS(0, "ok"),
    PARAMS_ERROR(40000, "请求参数错误"),
    NOT_LOGIN_ERROR(40100, "未登录"),
    NO_AUTH_ERROR(40101, "无权限"),
    NOT_FOUND_ERROR(40400, "请求数据不存在"),
    FORBIDDEN_ERROR(40300, "禁止访问"),
    SYSTEM_ERROR(50000, "系统内部异常"),
    OPERATION_ERROR(50001, "操作失败");

    /**
     * 状态码
     */
    private final int code;

    /**
     * 信息
     */
    private final String message;

    ErrorCode(int code, String message) {
        this.code = code;
        this.message = message;
    }

}

一般不建议直接抛出 Java 内置的 R⁢untimeExcepti⁠on,而是自定义一个业务异​常,和内置的异常类区分开,‍便于定制化输出错误信息:

com.zhaochao.aicodeplatform.exception.BusinessException

java
package com.zhaochao.aicodeplatform.exception;

import lombok.Getter;

@Getter
public class BusinessException extends RuntimeException {

    /**
     * 错误码
     */
    private final int code;

    public BusinessException(int code, String message) {
        super(message);
        this.code = code;
    }

    public BusinessException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.code = errorCode.getCode();
    }

    public BusinessException(ErrorCode errorCode, String message) {
        super(message);
        this.code = errorCode.getCode();
    }
}

为了更方便地根据情况抛出异常⁢,可以封装一个 T⁠hrowUtils​,类似断言类,简化‍抛异常的代码:

java
package com.zhaochao.aicodeplatform.exception;

public class ThrowUtils {

    /**
     * 条件成立则抛异常
     *
     * @param condition        条件
     * @param runtimeException 异常
     */
    public static void throwIf(boolean condition, RuntimeException runtimeException) {
        if (condition) {
            throw runtimeException;
        }
    }

    /**
     * 条件成立则抛异常
     *
     * @param condition 条件
     * @param errorCode 错误码
     */
    public static void throwIf(boolean condition, ErrorCode errorCode) {
        throwIf(condition, new BusinessException(errorCode));
    }

    /**
     * 条件成立则抛异常
     *
     * @param condition 条件
     * @param errorCode 错误码
     * @param message   错误信息
     */
    public static void throwIf(boolean condition, ErrorCode errorCode, String message) {
        throwIf(condition, new BusinessException(errorCode, message));
    }
}
响应包装类

com.zhaochao.aicodeplatform.common.BaseResponse

java
package com.zhaochao.aicodeplatform.common;

import com.zhaochao.aicodeplatform.exception.ErrorCode;
import lombok.Data;

import java.io.Serializable;

@Data
public class BaseResponse<T> implements Serializable {

    private int code;

    private T data;

    private String message;

    public BaseResponse(int code, T data, String message) {
        this.code = code;
        this.data = data;
        this.message = message;
    }

    public BaseResponse(int code, T data) {
        this(code, data, "");
    }

    public BaseResponse(ErrorCode errorCode) {
        this(errorCode.getCode(), null, errorCode.getMessage());
    }
}

每次请求返回数据的时候都需要new BaseResponse类,这样太麻烦了,我们可以封装一个工具类,以简化调用

com.zhaochao.aicodeplatform.common.ResultUtils

java
package com.zhaochao.aicodeplatform.common;

import com.zhaochao.aicodeplatform.exception.ErrorCode;

public class ResultUtils {

    /**
     * 成功
     *
     * @param data 数据
     * @param <T>  数据类型
     * @return 响应
     */
    public static <T> BaseResponse<T> success(T data) {
        return new BaseResponse<>(0, data, "ok");
    }

    /**
     * 失败
     *
     * @param errorCode 错误码
     * @return 响应
     */
    public static BaseResponse<?> error(ErrorCode errorCode) {
        return new BaseResponse<>(errorCode);
    }

    /**
     * 失败
     *
     * @param code    错误码
     * @param message 错误信息
     * @return 响应
     */
    public static BaseResponse<?> error(int code, String message) {
        return new BaseResponse<>(code, null, message);
    }

    /**
     * 失败
     *
     * @param errorCode 错误码
     * @return 响应
     */
    public static BaseResponse<?> error(ErrorCode errorCode, String message) {
        return new BaseResponse<>(errorCode.getCode(), null, message);
    }
}
全局异常处理器

为了防止意料之外的异常,利用⁢ AOP 切面全局⁠对业务异常和 Ru​ntimeExce‍ption 进行捕获:

java
package com.zhaochao.aicodeplatform.exception;

import com.zhaochao.aicodeplatform.common.BaseResponse;
import com.zhaochao.aicodeplatform.common.ResultUtils;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@Hidden
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public BaseResponse<?> businessExceptionHandler(BusinessException e) {
        log.error("BusinessException", e);
        return ResultUtils.error(e.getCode(), e.getMessage());
    }

    @ExceptionHandler(RuntimeException.class)
    public BaseResponse<?> runtimeExceptionHandler(RuntimeException e) {
        log.error("RuntimeException", e);
        return ResultUtils.error(ErrorCode.SYSTEM_ERROR, "系统错误");
    }
}

注意!由于本项目使用的 Spring Boot 版本 >= 3.4、并且是 OpenAPI 3 版本的 Knife4j,这会导致@RestControllerAdvice 注解不兼容,所以必须给这个类加上 @Hidden 注解,不被 Swagger加载。虽然网上也有其他的解决方案,但这种方法是最直接有效的。

请求包装类

对于 “分页”、“删除某条数据” 这类通⁢用的请求,可以封装统一的请⁠求包装类,用于接受前端传来​的参数,之后相同参数的请求‍就不用专门再新建一个类了。

分页请求包装类,包括当前页号⁢、页面大小、排序字⁠段、排序顺序参数:

com.zhaochao.aicodeplatform.common.PageRequest

java
@Data
public class PageRequest {

    /**
     * 当前页号
     */
    private int pageNum = 1;

    /**
     * 页面大小
     */
    private int pageSize = 10;

    /**
     * 排序字段
     */
    private String sortField;

    /**
     * 排序顺序(默认降序)
     */
    private String sortOrder = "descend";
}

删除请求包装类,接受要删除数据的 id 作为参数: com.zhaochao.aicodeplatform.common.DeleteRequest

java
@Data
public class DeleteRequest implements Serializable {

    /**
     * id
     */
    private Long id;

    private static final long serialVersionUID = 1L;
}
全局跨域配置

设置允许跨域,方便开发阶段的调试

com.zhaochao.aicodeplatform.config.CorsConfig

java
package com.zhaochao.aicodeplatform.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * 配置是否允许跨域
 */
@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        // 覆盖所有请求
        registry.addMapping("/**")
                // 允许发送 Cookie
                .allowCredentials(true)
                // 放行哪些域名(必须用 patterns,否则 * 会和 allowCredentials 冲突)
                .allowedOriginPatterns("*")
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                .allowedHeaders("*")
                .exposedHeaders("*");
    }
}

测试验证

接下来改造 HealthController 测试接口是否正常 com.zhaochao.aicodeplatform.controller.HealthController

java
package com.zhaochao.aicodeplatform.controller;

import com.zhaochao.aicodeplatform.common.BaseResponse;
import com.zhaochao.aicodeplatform.common.ResultUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/health")
public class HealthController {

    @GetMapping("/")
    public BaseResponse<String> healthCheck(){
        return ResultUtils.success("OK");
    }
}

到这里后端项目就初始化完成了,现在的目录结构如下:

业务实现

用户模块

需求分析

  • 用户注册

  • 用户登录

  • 获取当前登录用户

  • 用户注销

  • 用户权限控制

  • 【管理员】管理用户

库表设计

user 用户表结构

sql
-- 用户表
create table if not exists user
(
    id           bigint auto_increment comment 'id' primary key,
    userAccount  varchar(256)                           not null comment '账号',
    userPassword varchar(512)                           not null comment '密码',
    userName     varchar(256)                           null comment '用户昵称',
    userAvatar   varchar(1024)                          null comment '用户头像',
    userProfile  varchar(512)                           null comment '用户简介',
    userRole     varchar(256) default 'user'            not null comment '用户角色:user/admin',
    editTime     datetime     default CURRENT_TIMESTAMP not null comment '编辑时间',
    createTime   datetime     default CURRENT_TIMESTAMP not null comment '创建时间',
    updateTime   datetime     default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
    isDelete     tinyint      default 0                 not null comment '是否删除',
    UNIQUE KEY uk_userAccount (userAccount),
    INDEX idx_userName (userName)
) comment '用户' collate = utf8mb4_unicode_ci;

注意事项:

  • editTimeupdateTime 的区别:editTime 表示用户编辑个人信息的时间(需要业务代码来更新),而 updateTime 表示这条用户记录任何字段发生修改的时间(由数据库自动更新)。

  • 给唯一值添加唯一键(唯一索引),比如账号 userAccount,利用数据库天然防重复,同时可以增加查询效率。

  • 给经常用于查询的字段添加索引,比如用户昵称 userName,可以增加查询效率。

将数据库建表语句存在 src/main/resources/sql/create_table.sql 项目中

create_table.sql

sql
-- 创建库
create database if not exists ai_code_platform;

-- 切换库
use ai_code_platform;

-- 用户表

扩展设计

1、如果要实现会员功能,可以对表进行如下扩展:

(1)、给 userRole 字段新增枚举值 vip,表示会员用户,可根据该值判断用户权限

(2)、新增会员过期时间字段,可用于记录会员有效期

(3)、新增会员兑换码字段,可用于记录会员的开通方式

(4)、新增会员编号字段,可便于定位用户并提供额外服务,并增加会员归属感

对应的 SQL 如下:

sql
vipExpireTime datetime     null comment '会员过期时间',
vipCode       varchar(128) null comment '会员兑换码',
vipNumber     bigint       null comment '会员编号'

2、如果要实现用户邀请功能,可以对表进行如下扩展:

(1)、新增 shareCode 分享码字段,用于记录每个用户的唯一邀请标识,可拼接到邀请网址后面,比如 https://xxxxx.com/?shareCode=xxx

(2)、新增 inviteUser 字段,用于记录该用户被哪个用户邀请了,可通过这个字段查询某用户邀请的用户列表。

对应的 SQL 如下:

sql
shareCode     varchar(20)  DEFAULT NULL COMMENT '分享码',
inviteUser    bigint       DEFAULT NULL COMMENT '邀请用户 id'

用户登录流程

1、建立初始会话:前端与服务器建立连接后,服务器⁢会为该客户端创建一个初始的匿名 ⁠Session,并将其状态保​存下来。这个 SessionI‍D 会作为唯一标识,返回给前端。

2、登录成功,更新会话信息:当用户在前端输入正确的账号密码并提交到后端验证成功后,后端会⁢更新该用户的 Session,将用户的登录信息(如用户 I⁠D、用户名等)保存到与该 Session 关联的存储中。同​时,服务器会生成一个 Set-Cookie 的响应头,指示‍前端保存该用户的 Session ID

3、前端保存 Cookie:前端接收到后端的响应⁢后,浏览器会自动根据 Set-C⁠ookie 指令,将 Sessi​on ID 存储到浏览器的 Co‍okie中,与该域名绑定。

4、带 Cookie 的后续请求:当前端再⁢次向相同域名的服务器发送请求⁠时,浏览器会自动在请求头中附​带之前保存的 Cookie,‍其中包含 Session ID

5、后端验证会话:服务器接收到⁢请求后,从请求头中提⁠取 Session ​ID,找到对应的 S‍ession 数据。

6、获取会话中存储的信息:后端通过该⁢ Session 获取之⁠前存储的用户信息(如登录​名、权限等),从而识别用‍户身份并执行相应的业务逻辑。

用户登录流程图

如何对权限进行控制

可以将接口分为 4 种权限:

  • 未登录也可以使用
  • 登录用户才能使用
  • 未登录也可以使用,但是登录用户能进行更多操作(比如登录后查看全文)
  • 仅管理员才能使用

传统的权限控制方法是,在每个⁢接口内单独编写逻辑⁠:先获取到当前登录​用户信息,然后判断‍用户的权限是否符合要求。这种方法最灵活,但是会写很多⁢重复的代码,而且其⁠他开发者无法一眼得​知接口所需要的权限‍。

权限校验其实是一个比较通用的业务需求,一般会通过 Spring AOP 切面 + 自定义权限校验注解实现统一的接口拦截和权限校验;如果有特殊的权限校验逻辑,再单独在接口中编码。

如果需要更复杂更灵活的权限控制,可以引入 Shiro / Spring Security / Sa-Token 等专门的权限管理框架。

MyBatis Flex 代码生成器

MyBatis-Flex相比MyBatis-Plus做了一些升级

  • 更轻量:MyBatis-Flex 除了 MyBatis 本身,再无任何第三方依赖,因此会带来更高的自主性、把控性和稳定性。在任何一个系统中,依赖越多,稳定性越差。

  • 更灵活:MyBatis-Flex 提供了非常灵活的 QueryWrapper,支持关联查询、多表查询、多主键、逻辑删除、乐观锁更新、数据填充、数据脱敏等等。

  • 更高的性能:MyBatis-Flex 通过独特的架构,没有任何 MyBatis 拦截器、在 SQL 执行的过程中,没有任何的 SQL Parse,因此会带来指数级的性能增长。

功能对比:

Mybatis Flex 中,有了一个名称为 mybatis-flex-codegen 的模块,提供了可以通过数据库表,生成代码的功能。当我们把数据库表设计完成后, 就可以使用其快速生成 Entity、Mapper、Service、Controller 代码,能大幅提高我们的开发效率。

使用代码生成器

引入依赖

引入Mybatis Flex依赖,适用SpringBoot3版本

xml
<dependency>
    <groupId>com.mybatis-flex</groupId>
    <artifactId>mybatis-flex-spring-boot3-starter</artifactId>
    <version>1.11.0</version>
</dependency>

还需要引入生成代码和数据库连接池依赖

xml
<!-- 代码生成模块 -->
<dependency>
    <groupId>com.mybatis-flex</groupId>
    <artifactId>mybatis-flex-codegen</artifactId>
    <version>1.11.0</version>
</dependency>
<!-- 数据库连接池 -->
<dependency>
    <groupId>com.zaxxer</groupId>
    <artifactId>HikariCP</artifactId>
</dependency>
添加数据库连接配置

添加数据库连接配置

yaml
spring:
  # mysql
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/yu_ai_code_mother
    username: root
    password: 123456
使用生成器

在根包下新建 generator 包,新⁢建 MyBatisCode⁠Generator 类,编​写代码生成器。

Mybatis Flex的代码生成器中,支持如下8种类型的产物

  • Entity 实体类 ✅
  • Mapper 映射类 ✅
  • Service 服务类 ✅
  • ServiceImpl 服务实现类 ✅
  • Controller 控制类 ✅
  • MapperXml 文件 ✅
  • TableDef 表定义辅助类
  • package-info.java 文件

com.zhaochao.aicodeplatform.generator.MyBatisCodeGenerator

java
package com.zhaochao.aicodeplatform.generator;

import cn.hutool.core.lang.Dict;
import cn.hutool.setting.yaml.YamlUtil;
import com.mybatisflex.codegen.Generator;
import com.mybatisflex.codegen.config.GlobalConfig;
import com.zaxxer.hikari.HikariDataSource;

import java.util.Map;

class MyBatisCodeGenerator {

    // 需要生成的表名
    private static final String[] TABLE_NAMES = {"user"};

    public static void main(String[] args) {
        // 获取数据源信息
        Dict dict = YamlUtil.loadByPath("application.yaml");
        Map<String, Object> dataSourceConfig = dict.getByPath("spring.datasource");
        String url = String.valueOf(dataSourceConfig.get("url"));
        String username = String.valueOf(dataSourceConfig.get("username"));
        String password = String.valueOf(dataSourceConfig.get("password"));
        // 配置数据源
        HikariDataSource dataSource = new HikariDataSource();
        dataSource.setJdbcUrl(url);
        dataSource.setUsername(username);
        dataSource.setPassword(password);

        // 创建配置内容
        GlobalConfig globalConfig = createGlobalConfig();

        // 通过 datasource 和 globalConfig 创建代码生成器
        Generator generator = new Generator(dataSource, globalConfig);

        // 生成代码
        generator.generate();
    }

    // 详细配置见:https://mybatis-flex.com/zh/others/codegen.html
    public static GlobalConfig createGlobalConfig() {
        // 创建配置内容
        GlobalConfig globalConfig = new GlobalConfig();

        // 设置根包,建议先生成到一个临时目录下,生成代码后,再移动到项目目录下
        globalConfig.getPackageConfig()
                .setBasePackage("com.zhaochao.aicodeplatform.genresult");

        // 设置表前缀和只生成哪些表,setGenerateTable 未配置时,生成所有表
        globalConfig.getStrategyConfig()
                .setGenerateTable(TABLE_NAMES)
                // 设置逻辑删除的默认字段名称
                .setLogicDeleteColumn("isDelete");

        // 设置生成 entity 并启用 Lombok
        globalConfig.enableEntity()
                .setWithLombok(true)
                .setJdkVersion(21);

        // 设置生成 mapper
        globalConfig.enableMapper();
        globalConfig.enableMapperXml();

        // 设置生成 service
        globalConfig.enableService();
        globalConfig.enableServiceImpl();

        // 设置生成 controller
        globalConfig.enableController();

        // 设置生成时间和字符串为空,避免多余的代码改动
        globalConfig.getJavadocConfig()
                .setAuthor("<a href=\"https://zzcc.website/\">zhaochao</a>")
                .setSince("");
        return globalConfig;
    }
}

生成产物

启动类需要添加MapperScan 注解

java
@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true)
@MapperScan("com.zhaochao.aicodeplatform.mapper")
public class AiCodePlatformApplication {

    public static void main(String[] args) {
        SpringApplication.run(AiCodePlatformApplication.class, args);
    }

}

然后可以启动项目验证接口了

改造生成的代码

生成的代码也许不能完全满足我⁢们的要求,比如数据⁠库实体类,我们可以​手动更改其字段配置‍,指定主键生成策略。

id 默认是连续生成的,容易被爬虫抓取,所以更换策略为 ASSIGN_ID 雪花算法生成。

com.zhaochao.aicodeplatform.model.entity.User

java
@Table("user")
public class User implements Serializable {

    @Id(keyType = KeyType.Generator, value = KeyGenerators.snowFlakeId)
    private Long id;
}

对于用户角色这样值的数量有限的⁢、可枚举的字段,最好⁠定义一个枚举类,便于​在项目中获取值、减少‍枚举值输入错误的情况。

com.zhaochao.aicodeplatform.model.enums.UserRoleEnum

java
package com.zhaochao.aicodeplatform.model.enums;

import cn.hutool.core.util.ObjUtil;
import lombok.Getter;

@Getter
public enum UserRoleEnum {

    USER("用户", "user"),
    ADMIN("管理员", "admin");

    private final String text;

    private final String value;

    UserRoleEnum(String text, String value) {
        this.text = text;
        this.value = value;
    }

    /**
     * 根据 value 获取枚举
     *
     * @param value 枚举值的value
     * @return 枚举值
     */
    public static UserRoleEnum getEnumByValue(String value) {
        if (ObjUtil.isEmpty(value)) {
            return null;
        }
        for (UserRoleEnum anEnum : UserRoleEnum.values()) {
            if (anEnum.value.equals(value)) {
                return anEnum;
            }
        }
        return null;
    }
}

用户注册

添加数据请求体类 com.zhaochao.aicodeplatform.model.dto.user.UserRegisterRequest

java
package com.zhaochao.aicodeplatform.model.dto.user;

import lombok.Data;

import java.io.Serial;

@Data
public class UserRegisterRequest {
    @Serial
    private static final long serialVersionUID = 1L;

    /**
     * 账号
     */
    private String userAccount;

    /**
     * 密码
     */
    private String userPassword;

    /**
     * 确认密码
     */
    private String checkPassword;
}

com.zhaochao.aicodeplatform.service.UserService

java
/**
 * 用户注册
 *
 * @param userAccount   用户账户
 * @param userPassword  用户密码
 * @param checkPassword 校验密码
 * @return 新用户 id
 */
long userRegister(String userAccount, String userPassword, String checkPassword);

com.zhaochao.aicodeplatform.service.impl.UserServiceImpl

java
package com.zhaochao.aicodeplatform.service.impl;

import cn.hutool.core.util.StrUtil;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.spring.service.impl.ServiceImpl;
import com.zhaochao.aicodeplatform.exception.BusinessException;
import com.zhaochao.aicodeplatform.exception.ErrorCode;
import com.zhaochao.aicodeplatform.model.entity.User;
import com.zhaochao.aicodeplatform.mapper.UserMapper;
import com.zhaochao.aicodeplatform.model.enums.UserRoleEnum;
import com.zhaochao.aicodeplatform.service.UserService;
import org.springframework.stereotype.Service;
import org.springframework.util.DigestUtils;

/**
 * 用户 服务层实现。
 *
 * @author <a href="https://zzcc.website">zhaochao</a>
 */
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User>  implements UserService {

    public String getEncryptPassword(String userPassword) {
        // 盐值,混淆密码
        final String SALT = "zhaochao";
        return DigestUtils.md5DigestAsHex((SALT + userPassword).getBytes());
    }

    @Override
    public long userRegister(String userAccount, String userPassword, String checkPassword) {
        // 1. 校验
        if (StrUtil.hasBlank(userAccount, userPassword, checkPassword)) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数为空");
        }
        if (userAccount.length() < 4) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户账号过短");
        }
        if (userPassword.length() < 8 || checkPassword.length() < 8) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户密码过短");
        }
        if (!userPassword.equals(checkPassword)) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "两次输入的密码不一致");
        }
        // 2. 检查是否重复
        QueryWrapper queryWrapper = new QueryWrapper();
        queryWrapper.eq("userAccount", userAccount);
        long count = this.mapper.selectCountByQuery(queryWrapper);
        if (count > 0) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "账号重复");
        }
        // 3. 加密
        String encryptPassword = getEncryptPassword(userPassword);
        // 4. 插入数据
        User user = new User();
        user.setUserAccount(userAccount);
        user.setUserPassword(encryptPassword);
        user.setUserRole(UserRoleEnum.USER.getValue());
        boolean saveResult = this.save(user);
        if (!saveResult) {
            throw new BusinessException(ErrorCode.SYSTEM_ERROR, "注册失败,数据库错误");
        }
        return user.getId();
    }
}

com.zhaochao.aicodeplatform.controller.UserController

java
@RestController
@RequestMapping("/user")
public class UserController {

    @Resource
    private UserService userService;

    /**
     * 用户注册
     *
     * @param userRegisterRequest 用户注册请求
     * @return 注册结果
     */
    @PostMapping("register")
    public BaseResponse<Long> userRegister(@RequestBody UserRegisterRequest userRegisterRequest) {
        ThrowUtils.throwIf(userRegisterRequest == null, ErrorCode.PARAMS_ERROR);
        String userAccount = userRegisterRequest.getUserAccount();
        String userPassword = userRegisterRequest.getUserPassword();
        String checkPassword = userRegisterRequest.getCheckPassword();
        long result = userService.userRegister(userAccount, userPassword, checkPassword);
        return ResultUtils.success(result);
    }
}

测试一下注册接口是否正常

用户登录

新增登陆请求实体类 com.zhaochao.aicodeplatform.model.dto.user.UserLoginRequest

java
package com.zhaochao.aicodeplatform.model.dto.user;

import lombok.Data;

import java.io.Serial;
import java.io.Serializable;

@Data
public class UserLoginRequest implements Serializable {
    @Serial
    private static final long serialVersionUID = 1L;

    /**
     * 账号
     */
    private String userAccount;

    /**
     * 密码
     */
    private String userPassword;
}

数据脱敏,无论是用户注册还是用户登录接口,都应该返回⁢已登录的用户信息,而且一定要⁠对返回结果进行脱敏处理,不能​直接将数据库查到的所有信息都‍返回给了前端(包括密码)。

model.vo 包下新建 LoginUserVO 类,表示脱敏后的登录用户信息:

com.zhaochao.aicodeplatform.model.vo.LoginUserVO

java
package com.zhaochao.aicodeplatform.model.vo;

import lombok.Data;

import java.io.Serializable;
import java.time.LocalDateTime;

@Data
public class LoginUserVO implements Serializable {

    /**
     * 用户 id
     */
    private Long id;

    /**
     * 账号
     */
    private String userAccount;

    /**
     * 用户昵称
     */
    private String userName;

    /**
     * 用户头像
     */
    private String userAvatar;

    /**
     * 用户简介
     */
    private String userProfile;

    /**
     * 用户角色:user/admin
     */
    private String userRole;

    /**
     * 创建时间
     */
    private LocalDateTime createTime;

    /**
     * 更新时间
     */
    private LocalDateTime updateTime;

    private static final long serialVersionUID = 1L;
}

com.zhaochao.aicodeplatform.service.UserService

java
    /**
     * 获取脱敏后的用户信息
     * @param user
     * @return 过滤敏感字段后的user
     */
    LoginUserVO getLoginUserVO(User user);

编写对应的实现类,其实就是将User类的属性复制到 LoginUserVO

com.zhaochao.aicodeplatform.service.impl.UserServiceImpl

java
@Override
public LoginUserVO getLoginUserVO(User user) {
    if (user == null) {
        return null;
    }
    LoginUserVO loginUserVO = new LoginUserVO();
    BeanUtil.copyProperties(user, loginUserVO);
    return loginUserVO;
}

添加登录方法 com.zhaochao.aicodeplatform.service.UserService

java
/**
 * 用户登录
 *
 * @param userAccount  用户账户
 * @param userPassword 用户密码
 * @param request
 * @return 脱敏后的用户信息
 */
LoginUserVO userLogin(String userAccount, String userPassword, HttpServletRequest request);

添加常量类 com.zhaochao.aicodeplatform.constant.UserConstant

java
package com.zhaochao.aicodeplatform.constant;

public interface UserConstant {

    /**
     * 用户登录态键
     */
    String USER_LOGIN_STATE = "user_login";

    //  region 权限

    /**
     * 默认角色
     */
    String DEFAULT_ROLE = "user";

    /**
     * 管理员角色
     */
    String ADMIN_ROLE = "admin";

    // endregion
}

添加登录方法的实现类 com.zhaochao.aicodeplatform.service.impl.UserServiceImpl

java
@Override
public LoginUserVO userLogin(String userAccount, String userPassword, HttpServletRequest request) {
    // 1. 校验
    if (StrUtil.hasBlank(userAccount, userPassword)) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数为空");
    }
    if (userAccount.length() < 4) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR, "账号错误");
    }
    if (userPassword.length() < 8) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR, "密码错误");
    }
    // 2. 加密
    String encryptPassword = getEncryptPassword(userPassword);
    // 查询用户是否存在
    QueryWrapper queryWrapper = new QueryWrapper();
    queryWrapper.eq("userAccount", userAccount);
    queryWrapper.eq("userPassword", encryptPassword);
    User user = this.mapper.selectOneByQuery(queryWrapper);
    // 用户不存在
    if (user == null) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户不存在或密码错误");
    }
    // 3. 记录用户的登录态
    request.getSession().setAttribute(USER_LOGIN_STATE, user);
    // 4. 获得脱敏后的用户信息
    return this.getLoginUserVO(user);
}

可以把上述的 Session 理解为一个 Map,可以给 Map 设置 keyvalue,每个不同的 SessionID 对应的 Session 存储都是不同的,不用担心会污染。所以上述代码中,给 Session 设置了固定的 key(USER_LOGIN_STATE),可以将这个 key 值提取为常量,便于后续获取。

com.zhaochao.aicodeplatform.controller.UserController

java
    @PostMapping("/login")
    public BaseResponse<LoginUserVO> userLogin(@RequestBody UserLoginRequest userLoginRequest, HttpServletRequest request) {
        ThrowUtils.throwIf(userLoginRequest == null, ErrorCode.PARAMS_ERROR);
        String userAccount = userLoginRequest.getUserAccount();
        String userPassword = userLoginRequest.getUserPassword();
        LoginUserVO loginUserVO = userService.userLogin(userAccount,userPassword,request);
        return ResultUtils.success(loginUserVO);
    }

测试一下登录接口

获取当前登录用户信息

com.zhaochao.aicodeplatform.service.UserService

java
/**
 * 获取当前登录用户
 *
 * @param request
 * @return
 */
User getLoginUser(HttpServletRequest request);

com.zhaochao.aicodeplatform.service.impl.UserServiceImpl

java
    @Override
    public User getLoginUser(HttpServletRequest request) {
        // 先判断用户是否已经登陆
        Object obj = request.getSession().getAttribute(USER_LOGIN_STATE);
        User currentUser = (User) obj;
        if (currentUser == null || currentUser.getId() == null) {
            throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR);
        }
        // 从数据库查询
        long userId = currentUser.getId();
        currentUser = this.getById(userId);
        if (currentUser == null) {
            throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR);
        }
        return currentUser;
    }

com.zhaochao.aicodeplatform.controller.UserController

java
@GetMapping("/get/login")
public BaseResponse<LoginUserVO> getLoginUser(HttpServletRequest request) {
    User loginUser = userService.getLoginUser(request);
    return ResultUtils.success(userService.getLoginUserVO(loginUser));
}

用户注销

com.zhaochao.aicodeplatform.service.UserService

java
/**
 * 用户注销
 *
 * @param request
 * @return
 */
boolean userLogout(HttpServletRequest request);

com.zhaochao.aicodeplatform.service.impl.UserServiceImpl

java
@Override
public boolean userLogout(HttpServletRequest request) {
    // 先判断是否已登录
    Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE);
    if (userObj == null) {
        throw new BusinessException(ErrorCode.OPERATION_ERROR, "未登录");
    }
    // 移除登录态
    request.getSession().removeAttribute(USER_LOGIN_STATE);
    return true;
}

com.zhaochao.aicodeplatform.controller.UserController

java
@PostMapping("/logout")
public BaseResponse<Boolean> userLogout(HttpServletRequest request) {
    ThrowUtils.throwIf(request == null, ErrorCode.PARAMS_ERROR);
    boolean result = userService.userLogout(request);
    return ResultUtils.success(result);
}

用户权限控制

一般是通过 Spring AOP 切面和自定义权限校验注解实现统一的接口拦截和权限校验

首先编写权限校验注解,放到 annotation 包下: com.zhaochao.aicodeplatform.annotation.AuthCheck

java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthCheck {

    /**
     * 必须有某个角色
     */
    String mustRole() default "";
}

编写权限校验 AOP,采用环绕通知,在 打上该注解的方法 执行前后进行一些额外的操作,比如校验权限。

代码如下,放到 aop 包下: com.zhaochao.aicodeplatform.aop.AuthInterceptor

java
@Aspect
@Component
public class AuthInterceptor {

    @Resource
    private UserService userService;

    /**
     * 执行拦截
     *
     * @param joinPoint 切入点
     * @param authCheck 权限校验注解
     */
    @Around("@annotation(authCheck)")
    public Object doInterceptor(ProceedingJoinPoint joinPoint, AuthCheck authCheck) throws Throwable {
        String mustRole = authCheck.mustRole();
        RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();
        HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
        // 当前登录用户
        User loginUser = userService.getLoginUser(request);
        UserRoleEnum mustRoleEnum = UserRoleEnum.getEnumByValue(mustRole);
        // 不需要权限,放行
        if (mustRoleEnum == null) {
            return joinPoint.proceed();
        }
        // 以下为:必须有该权限才通过
        // 获取当前用户具有的权限
        UserRoleEnum userRoleEnum = UserRoleEnum.getEnumByValue(loginUser.getUserRole());
        // 没有权限,拒绝
        if (userRoleEnum == null) {
            throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
        }
        // 要求必须有管理员权限,但用户没有管理员权限,拒绝
        if (UserRoleEnum.ADMIN.equals(mustRoleEnum) && !UserRoleEnum.ADMIN.equals(userRoleEnum)) {
            throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
        }
        // 通过权限校验,放行
        return joinPoint.proceed();
    }
}

使用注解:

只要给方法添加了 @AuthCheck 注解,就必须要登录,否则会抛出异常。

可以设置 mustRole ⁢为管理员,这样仅管⁠理员才能使用该接口​:

java
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)

用户管理

用户管理需求具体可以拆分为:

  • 【管理员】创建用户

  • 【管理员】根据 id 删除用户

  • 【管理员】更新用户

  • 【管理员】分页获取用户列表(需要脱敏)

  • 【管理员】根据 id 获取用户(未脱敏)

根据 id 获取用户(脱敏)

每个请求都需要对应的请求体

com.zhaochao.aicodeplatform.model.dto.user.UserAddRequest

java
@Data
public class UserAddRequest implements Serializable {

    /**
     * 用户昵称
     */
    private String userName;

    /**
     * 账号
     */
    private String userAccount;

    /**
     * 用户头像
     */
    private String userAvatar;

    /**
     * 用户简介
     */
    private String userProfile;

    /**
     * 用户角色: user, admin
     */
    private String userRole;

    private static final long serialVersionUID = 1L;
}

com.zhaochao.aicodeplatform.model.dto.user.UserUpdateRequest

java
@Data
public class UserUpdateRequest implements Serializable {

    /**
     * id
     */
    private Long id;

    /**
     * 用户昵称
     */
    private String userName;

    /**
     * 用户头像
     */
    private String userAvatar;

    /**
     * 简介
     */
    private String userProfile;

    /**
     * 用户角色:user/admin
     */
    private String userRole;

    private static final long serialVersionUID = 1L;
}

用户查询请求,需要继承公共包中的 PageRequest 来支持分页查询: com.zhaochao.aicodeplatform.model.dto.user.UserQueryRequest

java
@EqualsAndHashCode(callSuper = true)
@Data
public class UserQueryRequest extends PageRequest implements Serializable {

    /**
     * id
     */
    private Long id;

    /**
     * 用户昵称
     */
    private String userName;

    /**
     * 账号
     */
    private String userAccount;

    /**
     * 简介
     */
    private String userProfile;

    /**
     * 用户角色:user/admin/ban
     */
    private String userRole;

    private static final long serialVersionUID = 1L;
}

由于要提供获取用户信息的⁢接口,需要和获取当⁠前登录用户接口一样​对用户信息进行脱敏‍。在 model.vo 包下新建 UserVO,表示脱敏后的用户: com.zhaochao.aicodeplatform.model.vo.UserVO

java
@Data
public class UserVO implements Serializable {

    /**
     * id
     */
    private Long id;

    /**
     * 账号
     */
    private String userAccount;

    /**
     * 用户昵称
     */
    private String userName;

    /**
     * 用户头像
     */
    private String userAvatar;

    /**
     * 用户简介
     */
    private String userProfile;

    /**
     * 用户角色:user/admin
     */
    private String userRole;

    /**
     * 创建时间
     */
    private LocalDateTime createTime;

    private static final long serialVersionUID = 1L;
}

服务端开发

UserServic⁢e 中编写获取脱敏⁠后的单个用户信息、​获取脱敏后的用户列‍表方法: com.zhaochao.aicodeplatform.service.impl.UserServiceImpl

java
    /**
     * 获取脱敏后的用户信息
     * @param user
     * @return
     */
    public UserVO getUserVO(User user) {
        if(user == null){
            return null;
        }
        UserVO userVO = new UserVO();
        BeanUtils.copyProperties(user,userVO);
        return userVO;
    }

    /**
     * 获取脱敏后的用户信息列表
     * @param userList
     * @return
     */
    public List<UserVO> getUserVOList(List<User> userList) {
        return userList.stream().map(this::getUserVO).collect(Collectors.toList());
    }
接口开发

com.zhaochao.aicodeplatform.controller.UserController

java
@PostMapping("/add")
    @AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
    public BaseResponse<Long> addUser(@RequestBody UserAddRequest userAddRequest) {
        ThrowUtils.throwIf(userAddRequest == null, ErrorCode.PARAMS_ERROR);
        Long result = userService.addUser(userAddRequest);
        return ResultUtils.success(result);
    }

    /**
     * 管理员根据id获取用户信息
     * @param id
     * @return
     */
    @GetMapping("/get")
    @AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
    public BaseResponse<User> getUserById(@RequestParam("id") long id) {
        ThrowUtils.throwIf(id <= 0, ErrorCode.PARAMS_ERROR);
        User user = userService.getById(id);
        ThrowUtils.throwIf(user == null,ErrorCode.NOT_FOUND_ERROR);
        return ResultUtils.success(user);
    }

    /**
     * 根据id获取用户包装类
     * @param id
     * @return
     */
    @GetMapping("/get/vo")
    public BaseResponse<UserVO> getUserVOById(@RequestParam("id") long id) {
        BaseResponse<User> response = getUserById(id);
        User user = response.getData();
        return ResultUtils.success(userService.getUserVO(user));
    }

    /**
     * 删除用户
     */
    @PostMapping("/delete")
    @AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
    public BaseResponse<Boolean> deleteUser(@RequestBody DeleteRequest deleteRequest) {
        if (deleteRequest == null || deleteRequest.getId() <= 0) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR);
        }
        boolean b = userService.removeById(deleteRequest.getId());
        return ResultUtils.success(b);
    }

    /**
     * 更新用户
     */
    @PostMapping("/update")
    @AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
    public BaseResponse<Boolean> updateUser(@RequestBody UserUpdateRequest userUpdateRequest) {
        if (userUpdateRequest == null || userUpdateRequest.getId() == null) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR);
        }
        User user = new User();
        BeanUtil.copyProperties(userUpdateRequest, user);
        boolean result = userService.updateById(user);
        ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
        return ResultUtils.success(true);
    }

    /**
     * 分页获取用户封装列表(仅管理员)
     *
     * @param userQueryRequest 查询请求参数
     */
    @PostMapping("/list/page/vo")
    @AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
    public BaseResponse<Page<UserVO>> listUserVOByPage(@RequestBody UserQueryRequest userQueryRequest) {
        ThrowUtils.throwIf(userQueryRequest == null, ErrorCode.PARAMS_ERROR);
        // 分页查询user列表数据
        Page<User> userPage = userService.getPageList(userQueryRequest);
        // 数据脱敏
        Page<UserVO> userVOPage = new Page<>(userQueryRequest.getPageNum(), userQueryRequest.getPageSize(), userPage.getTotalRow());
        List<UserVO> userVOList = userService.getUserVOList(userPage.getRecords());
        userVOPage.setRecords(userVOList);
        return ResultUtils.success(userVOPage);
    }

com.zhaochao.aicodeplatform.service.UserService

java
 /**
     * 管理员添加普通用户
     * @param addUserRequest
     * @return
     */
    Long addUser(UserAddRequest addUserRequest);

    /**
     * 返回user的包装类
     * @param user
     * @return
     */
    UserVO getUserVO(User user);

    /**
     * 获取user包装类列表
     * @param userList
     * @return
     */
    List<UserVO> getUserVOList(List<User> userList);

    /**
     * 分页查询列表
     * @param userQueryRequest
     * @return
     */
    Page<User> getPageList(UserQueryRequest userQueryRequest);

com.zhaochao.aicodeplatform.service.impl.UserServiceImpl

java
/**
     * 获取脱敏后的用户信息
     *
     * @param user
     * @return
     */
    public UserVO getUserVO(User user) {
        if (user == null) {
            return null;
        }
        UserVO userVO = new UserVO();
        BeanUtils.copyProperties(user, userVO);
        return userVO;
    }

    /**
     * 分页查询用户列表信息
     * @param userQueryRequest
     * @return
     */
    @Override
    public Page<User> getPageList(UserQueryRequest userQueryRequest) {
        ThrowUtils.throwIf(userQueryRequest==null, ErrorCode.PARAMS_ERROR);
        Page<User> page = this.page(Page.of(userQueryRequest.getPageNum(), userQueryRequest.getPageSize()), QueryWrapper.create()
                .eq(User::getId, userQueryRequest.getId())
                .eq(User::getUserRole, userQueryRequest.getUserRole())
                .like(User::getUserAccount, userQueryRequest.getUserAccount())
                .like(User::getUserName, userQueryRequest.getUserName())
                .like(User::getUserProfile, userQueryRequest.getUserProfile())
                .orderBy(userQueryRequest.getSortField(),"ascend".equals(userQueryRequest.getSortOrder())));
        return page;
    }

    /**
     * 获取脱敏后的用户信息列表
     *
     * @param userList
     * @return
     */
    public List<UserVO> getUserVOList(List<User> userList) {
        return userList.stream().map(this::getUserVO).collect(Collectors.toList());
    }

    /**
     * 保存用户
     *
     * @param addUserRequest
     * @return
     */
    @Override
    public Long addUser(UserAddRequest addUserRequest) {
        // 检查该账号是否重复
        QueryWrapper queryWrapper = new QueryWrapper();
        queryWrapper.eq("userAccount", addUserRequest.getUserAccount());
        long count = this.mapper.selectCountByQuery(queryWrapper);
        if (count > 0) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "账号重复");
        }

        User user = new User();
        BeanUtils.copyProperties(addUserRequest, user);
        final String DEFAULT_PASSWORD = "12345678";
        String encryptPassword = this.getEncryptPassword(DEFAULT_PASSWORD);
        user.setUserPassword(encryptPassword);
        user.setUserRole(UserRoleEnum.USER.getValue());
        boolean result = this.save(user);
        ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
        return user.getId();
    }

测试接口是否正常

修复精度问题

在测试获取用户信息接口的时候,发现,浏览器从接口返回数据中的id后两位总是变为0

这是由于前端 JS 的精度范围有限,我们⁢后端返回的 id 范围过大⁠,导致前端解析 JSON ​时出现精度丢失,会影响前端‍页面获取到的数据结果。

为了解决这个问题,可以在后端 config 包下新建一个全局 JSON 配置,将整个后端 Spring MVC 接口返回值的长整型数字转换为字符串进行返回,从而集中解决问题。

com.zhaochao.aicodeplatform.config.JsonConfig

java
package com.zhaochao.aicodeplatform.config;


import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import org.springframework.boot.jackson.JsonComponent;
import org.springframework.context.annotation.Bean;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;

/**
 * Spring MVC Json 配置
 */
@JsonComponent
public class JsonConfig {

    /**
     * 添加 Long 转 json 精度丢失的配置
     */
    @Bean
    public ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) {
        ObjectMapper objectMapper = builder.createXmlMapper(false).build();
        SimpleModule module = new SimpleModule();
        module.addSerializer(Long.class, ToStringSerializer.instance);
        module.addSerializer(Long.TYPE, ToStringSerializer.instance);
        objectMapper.registerModule(module);
        return objectMapper;
    }
}

重启项目以后,发现Long类型的数字返回前端的时候,都以字符串返回

接入模型能力

接下来将要实现的内容:

  • 设计 AI 应用生成方案

  • LangChain4j 框架入门

  • 实现 AI 应用生成(2 种生成模式)

  • 实现 SSE 流式输出提升用户体验

  • 通过多种设计模式优化代码架构

需求分析

完整需求:让⁢ AI 根据用户的⁠描述,自动生成完整的网​页应用。

AI 能够生成 原生网页代码,并将代码文件保存到本地,我们可以采用 2 种原生生成模式来满足不同的使用场景:

  • 原生 HTML 模式:将所有代码(HTML、CSS、JS)打包在一个 HTML 文件中,适合快速原型和简单应用

  • 原生多文件模式:按照标准的前端项目结构,分别生成 index.html、style.cssscript.js 文件

方案设计

核心流程:

用户输入描述AI 大模型生成提取生成内容写入本地文件

这个看似简单的流程,实际上涉及不少技术细节。比如:

如何实现和 AI 的对话?

如何设计有效的提示词?

如何确保 AI 输出的格式符合我们的要求?

如何处理生成的代码并保存到合适的位置?

编写系统提示词

提示词的质量直接决定了 AI 生成结果的好坏。在这个环节,我们可以参考网上的 Prompt 编写指南

可以直接让 AI 帮⁢我们根据需求来生成⁠提示词,比如我向 ​AI 提问:

我正在做一个 AI 零代码应用生成平台,根据用户的一段描述即可生成一个完整网站。生成的网站使用 HTML + CSS + JS 实现,帮我编写一个专业的 Prompt。

1)生成单个 HTML 文件模式的提示词:

你是一位资深的 Web 前端开发专家,精通 HTML、CSS 和原生 JavaScript。你擅长构建响应式、美观且代码整洁的单页面网站。

你的任务是根据用户提供的网站描述,生成一个完整、独立的单页面网站。你需要一步步思考,并最终将所有代码整合到一个 HTML 文件中。

约束:
1. 技术栈: 只能使用 HTML、CSS 和原生 JavaScript。
2. 禁止外部依赖: 绝对不允许使用任何外部 CSS 框架、JS 库或字体库。所有功能必须用原生代码实现。
3. 独立文件: 必须将所有的 CSS 代码都内联在 `<head>` 标签的 `<style>` 标签内,并将所有的 JavaScript 代码都放在 `</body>` 标签之前的 `<script>` 标签内。最终只输出一个 `.html` 文件,不包含任何外部文件引用。
4. 响应式设计: 网站必须是响应式的,能够在桌面和移动设备上良好显示。请优先使用 Flexbox 或 Grid 进行布局。
5. 内容填充: 如果用户描述中缺少具体文本或图片,请使用有意义的占位符。例如,文本可以使用 Lorem Ipsum,图片可以使用 https://picsum.photos 的服务 (例如 `<img src="https://picsum.photos/800/600" alt="Placeholder Image">`)。
6. 代码质量: 代码必须结构清晰、有适当的注释,易于阅读和维护。
7. 交互性: 如果用户描述了交互功能 (如 Tab 切换、图片轮播、表单提交提示等),请使用原生 JavaScript 来实现。
8. 安全性: 不要包含任何服务器端代码或逻辑。所有功能都是纯客户端的。
9. 输出格式: 你的最终输出必须包含 HTML 代码块,可以在代码块之外添加解释、标题或总结性文字。格式如下:

```html
... HTML 代码 ...

这里值得一提的是 Lorem ipsum 的使用。Lorem ipsum 是印刷排版行业使用的虚拟文本,主要用于测试文章或文字在不同字型、版型下的视觉效果。在我们的场景中,当用户描述比较简略时,AI 可以用这类占位符内容来完善页面结构

2)生成多文件模式的提示词:

你是一位资深的 Web 前端开发专家,你精⁢通编写结构化的 HTML、清⁠晰的 CSS 和高效的原生 ​JavaScript,遵循代‍码分离和模块化的最佳实践。

你的任务是根据用户提供的网站描述,创建构成一个完整单页网站所需的三个核心文件:HTML, CSS, 和 JavaScript。你需要在最终输出时,将这三部分代码分别放入三个独立的 Markdown 代码块中,并明确标注文件名。

约束:
1. 技术栈: 只能使用 HTML、CSS 和原生 JavaScript。
2. 文件分离:
- index.html: 只包含网页的结构和内容。它必须在 `<head>` 中通过 `<link>` 标签引用 `style.css`,并且在 `</body>` 结束标签之前通过 `<script>` 标签引用 `script.js`。
- style.css: 包含网站所有的样式规则。
- script.js: 包含网站所有的交互逻辑。
3. 禁止外部依赖: 绝对不允许使用任何外部 CSS 框架、JS 库或字体库。所有功能必须用原生代码实现。
4. 响应式设计: 网站必须是响应式的,能够在桌面和移动设备上良好显示。请在 CSS 中使用 Flexbox 或 Grid 进行布局。
5. 内容填充: 如果用户描述中缺少具体文本或图片,请使用有意义的占位符。例如,文本可以使用 Lorem Ipsum,图片可以使用 https://picsum.photos 的服务 (例如 `<img src="https://picsum.photos/800/600" alt="Placeholder Image">`)。
6. 代码质量: 代码必须结构清晰、有适当的注释,易于阅读和维护。
7. 输出格式: 每个代码块前要注明文件名。可以在代码块之外添加解释、标题或总结性文字。格式如下:

```html
... HTML 代码 ...


```css
... CSS 代码 ...


```javascript
... JavaScript 代码 ...

为什么这个 Prompt 会很有效?大家可以关注下面几点:

角色扮演:让 AI 的回答更具专业性。

严格约束:排除了所有可能导致问题的变量(如外部库、多文件),确保了输出的稳定性和可用性。

清晰的输出格式要求:使得程序可以轻松集成,无需复杂的解析逻辑。

Few-shot 示例:为 AI 提供了一个非常具体的模仿对象,大大提高了输出结果和预期结果的相似度,示例中包含了 HTML、CSSJS,完整地展示了最终产物的形态。细节考虑:Prompt 中考虑到了响应式设计、占位符等实际开发中常见的问题。不过这点需要发挥一下自己的网站开发经验。

AI开发框架选型

AI 开发框架的作用是 简化项目中开发 AI 应用的过程。实际企业开发中,可不是调用一下 AIAPI 接口这么简单,还有像 RAG 检索增强生成、Tools 工具调用、MCP模型上下文协议等典型场景,这些功能如果都自己开发,那成本可就太高了。

实际开发中应该如何选择 AI⁢ 开发框架呢?  ⁠         ​         ‍

目前主流的 Java AI 开发框架当属 Spring AILangChain4j,各有优缺,这个项目使用的是 LangChain4j

方案实现

引入框架

首先需要获取到deepseekapi key

然后是Spring Boot 集成 LangChain4j,可以按照LangChain4j官方文档进行集成

pom.xml

xml
        <dependency>
            <groupId>dev.langchain4j</groupId>
            <artifactId>langchain4j</artifactId>
            <version>1.1.0</version>
        </dependency>
        <dependency>
            <groupId>dev.langchain4j</groupId>
            <artifactId>langchain4j-open-ai-spring-boot-starter</artifactId>
            <version>1.1.0-beta7</version>
        </dependency>

为了保护敏感信息,我们需要在 .gitignore 中添加本地配置文件 application-local.yml,忽略该文件的提交,之后就可以放心地将敏感配置都写在这个文件里了。

### CUSTOM ###
application-local.yml

创建本地配置文件 application-local.yml,填写 Chat Model 配置。此外,为了调试方便,我们开启了详细的日志记录。这里参考了 LangChain4j日志配置文档

src/main/resources/application-local.yml

yml
# AI
langchain4j:
  open-ai:
    chat-model:
      base-url: https://api.deepseek.com
      api-key: sk-8981d6309e5c436e870abd1e4f398f39
      model-name: deepseek-chat
      log-requests: true
      log-responses: true
      timeout: PT300S
      max-tokens: 8192
      custom-headers:
        Content-Type: application/json
      strict-json-schema: true
      response-format: json_object

在主配置文件中激活本地环境:

application.yaml

yaml
spring:
  profiles:
    active: local
开发AI服务

我们使用 LangChain4j 推荐的 AI Service 开发模式,参考文档,在 ai 包下创建服务接口:

com.zhaochao.aicodeplatform.ai.AiCodeGeneratorService

java
public interface AiCodeGeneratorService {

    String generateCode(String userMessage);
}

考虑到系统提示词通常比较长,⁢将它们单独维护在资源文⁠件中。准备了两​种生成模式对应的系‍统提示词文件:

codegen-html-system-prompt.txt:原生 HTML 模式

codegen-multi-file-system-prompt.txt:原生三件套模式

在服务接口中添加 2 个生成代码的⁢方法,分别对应 2 种生成⁠模式。使用 Lan​gChain4j 的注‍解来指定系统提示词:

com.zhaochao.aicodeplatform.ai.AiCodeGeneratorService

java
public interface AiCodeGeneratorService {

    /**
     * 生成 HTML 代码
     *
     * @param userMessage 用户消息
     * @return 生成的代码结果
     */
    @SystemMessage(fromResource = "prompt/codegen-html-system-prompt.txt")
    String generateHtmlCode(String userMessage);

    /**
     * 生成多文件代码
     *
     * @param userMessage 用户消息
     * @return 生成的代码结果
     */
    @SystemMessage(fromResource = "prompt/codegen-multi-file-system-prompt.txt")
    String generateMultiFileCode(String userMessage);
}

com.zhaochao.aicodeplatform.factory.AiCodeGeneratorServiceFactory

java
@Configuration
public class AiCodeGeneratorServiceFactory {

    @Resource
    private ChatModel chatModel;

    @Bean
    public AiCodeGeneratorService aiCodeGeneratorService() {
        return AiServices.create(AiCodeGeneratorService.class, chatModel);
    }
}

最后,编写单元测试来验证功能:

com.zhaochao.aicodeplatform.factory.AiCodeGeneratorServiceFactoryTest

java
@SpringBootTest
class AiCodeGeneratorServiceTest {

    @Resource
    private AiCodeGeneratorService aiCodeGeneratorService;

    @Test
    void generateHtmlCode() {
        String result = aiCodeGeneratorService.generateHtmlCode("做个程序员鱼皮的工作记录小工具");
        Assertions.assertNotNull(result);
    }

    @Test
    void generateMultiFileCode() {
        String multiFileCode = aiCodeGeneratorService.generateMultiFileCode("做个程序员鱼皮的留言板");
        Assertions.assertNotNull(multiFileCode);
    }
}

Debug 方式运行测试⁢,可以在控制台看到⁠详细的请求和响应日​志,测试结果符合预期,AI 成功生成了完整的网页代码,但是现在的返回结果是一整个字符串,在实际的开发过程中,肯定需要返回结构化的数据

结构化输出数据

ai.model 下面分别创建生成结果类,用于封装AI返回的内容

com.zhaochao.aicodeplatform.ai.model.HtmlCodeResult

java
package com.zhaochao.aicodeplatform.ai.model;

import dev.langchain4j.model.output.structured.Description;
import lombok.Data;

@Description("生成 HTML 代码文件的结果")
@Data
public class HtmlCodeResult {

    @Description("HTML代码")
    private String htmlCode;

    @Description("生成代码的描述")
    private String description;
}

com.zhaochao.aicodeplatform.ai.model.MultiFileCodeResult

java
package com.zhaochao.aicodeplatform.ai.model;

import dev.langchain4j.model.output.structured.Description;
import lombok.Data;

@Description("生成多个代码文件的结果")
@Data
public class MultiFileCodeResult {

    @Description("HTML代码")
    private String htmlCode;

    @Description("CSS代码")
    private String cssCode;

    @Description("JS代码")
    private String jsCode;

    @Description("生成代码的描述")
    private String description;
}

设置了 Description 注解那么在请求的时候会在请求参数里面加上字段的描述

接下来修改 AI Service 接口,让方法返回对象

com.zhaochao.aicodeplatform.ai.AiCodeGeneratorService

java
public interface AiCodeGeneratorService {

    /**
     * 生成HTML代码
     * @param userMessage 用户消息
     * @return 生成的代码结果
     */
    @SystemMessage(fromResource = "prompt/codegen-html-system-prompt.txt")
    HtmlCodeResult generateCode(String userMessage);

    /**
     * 生成多文件html代码
     * @param userMessage 用户消息
     * @return 生成的代码结果
     */
    @SystemMessage(fromResource = "prompt/codegen-multi-file-system-prompt.txt")
    MultiFileCodeResult generateMultiFileCode(String userMessage);
}

测试类也需要修改一下 com.zhaochao.aicodeplatform.factory.AiCodeGeneratorServiceFactoryTest

java
@SpringBootTest
class AiCodeGeneratorServiceFactoryTest {

    @Resource
    private AiCodeGeneratorService aiCodeGeneratorService;

    @Test
    void generateHtmlCode() {
        HtmlCodeResult result = aiCodeGeneratorService.generateCode("做一个程序员李芹的工作记录小工具");
        Assertions.assertNotNull(result);
    }

    @Test
    void generateMultiFileCode() {
        MultiFileCodeResult result = aiCodeGeneratorService.generateMultiFileCode("做一个程序员赵超的博客网站");
        Assertions.assertNotNull(result);
    }
}

这里还有几个需要优化的点

设置max_tokens

参考 DeepSeek 官方文档的建议,设置一下输出长度,防止 AI 生成的 JSON 被半路截断:

在项目配置文件中添加:

yaml
langchain4j:
  open-ai:
    chat-model:
      max-tokens: 8192
设置JSON Schema

OpenAI 相关文档 提到了 response_format_json_schema 配置,可以严格确保结构化输出生效:

但经过测试发现,DeepSe⁢ek 不支持这种配⁠置,项目中使用​会报错。

不过官方文档提到了另外一种配置,设置 response-format 参数为 json_object

yaml
langchain4j:
  open-ai:
    chat-model:
      strict-json-schema: true
      response-format: json_object

在测试的时候发现不设置 timeout 可能请求会超时报错,最终的完整配置如下:

application-local.yml

yml
# AI
langchain4j:
  open-ai:
    chat-model:
      base-url: https://api.deepseek.com
      api-key: sk-xxxxxx
      model-name: deepseek-chat
      log-requests: true
      log-responses: true
      timeout: PT300S
      max-tokens: 8192
      strict-json-schema: true
      response-format: json_object

可以看到加上过了 json_object 限制以后,在请求参数的提示词中会要求模型以Json格式输出

测试效果

经过这些优化,结构化输出的准确度有了显著提升。虽然偶尔还会有不稳定的情况,但这是 DeepSeek 大模型的正常现象(DeepSeek官方也提到了)。LangChain4j 默认会自动重试,我们也可以通过调整 max-retries 参数来控制重试次数。

保存文件到本地

接下来就是将生成的代码保存在本地的文件

声明生成代码的类型枚举类 com.zhaochao.aicodeplatform.model.enums.CodeGenTypeEnum

java
@Getter
public enum CodeGenTypeEnum {

    HTML("原生 HTML 模式", "html"),
    MULTI_FILE("原生多文件模式", "multi_file");

    private final String text;
    private final String value;

    CodeGenTypeEnum(String text, String value) {
        this.text = text;
        this.value = value;
    }

    /**
     * 根据 value 获取枚举
     *
     * @param value 枚举值的value
     * @return 枚举值
     */
    public static CodeGenTypeEnum getEnumByValue(String value) {
        if (ObjUtil.isEmpty(value)) {
            return null;
        }
        for (CodeGenTypeEnum anEnum : CodeGenTypeEnum.values()) {
            if (anEnum.value.equals(value)) {
                return anEnum;
            }
        }
        return null;
    }
}

创建文件写入工具类,放到core包下面,生成的代码保存在 tmp 目录下面,并且 以文件类型_雪花ID作为目录名字来保证唯一性 com.zhaochao.aicodeplatform.core.CodeFileSaver

java
public class CodeFileSaver {
    // 文件保存根目录
    private static final String FILE_SAVE_ROOT_DIR = System.getProperty("user.dir") + "/tmp/code_output";


    /**
     * 保存 HtmlCodeResult
     */
    public static File saveHtmlCodeResult(HtmlCodeResult result) {
        String baseDirPath = buildUniqueDir(CodeGenTypeEnum.HTML.getValue());
        writeToFile(baseDirPath, "index.html", result.getHtmlCode());
        return new File(baseDirPath);
    }

    /**
     * 保存 MultiFileCodeResult
     */
    public static File saveMultiFileCodeResult(MultiFileCodeResult result) {
        String baseDirPath = buildUniqueDir(CodeGenTypeEnum.MULTI_FILE.getValue());
        writeToFile(baseDirPath, "index.html", result.getHtmlCode());
        writeToFile(baseDirPath, "style.css", result.getCssCode());
        writeToFile(baseDirPath, "script.js", result.getJsCode());
        return new File(baseDirPath);
    }
    /**
     * 构建唯一目录路径:tmp/code_output/bizType_雪花ID
     */
    private static String buildUniqueDir(String bizType) {
        String uniqueDirName = StrUtil.format("{}_{}", bizType, IdUtil.getSnowflakeNextIdStr());
        String dirPath = FILE_SAVE_ROOT_DIR + File.separator + uniqueDirName;
        FileUtil.mkdir(dirPath);
        return dirPath;
    }

    /**
     * 写入单个文件
     */
    private static void writeToFile(String dirPath, String filename, String content) {
        String filePath = dirPath + File.separator + filename;
        FileUtil.writeString(content, filePath, StandardCharsets.UTF_8);
    }
}

接下来封装一个统一的调用入口,同时完成生成代码和保存在本地目录的功能 com.zhaochao.aicodeplatform.core.AiCodeGeneratorFacade

java
/**
 * AI代码生成门面类,组合生成和保存功能
 */
@Service
public class AiCodeGeneratorFacade {
    @Resource
    private AiCodeGeneratorService aiCodeGeneratorService;

    /**
     * 统一入口,根据类型生成代码并保存代码
     * @param userMessage
     * @param codeGenTypeEnum
     * @return 保存的目录地址
     */
    public File generateAndSaveCode(String userMessage, CodeGenTypeEnum codeGenTypeEnum){
        if(codeGenTypeEnum == null){
            throw new BusinessException(ErrorCode.SYSTEM_ERROR,"生成类型为空");
        }
        return switch (codeGenTypeEnum){
            case HTML -> generateAndSaveHtmlCode(userMessage);
            case MULTI_FILE -> generateAndSaveMultiFileCode(userMessage);
            default -> {
                String errorMessage = "不支持的生成类型:" + codeGenTypeEnum.getValue();
                throw new BusinessException(ErrorCode.SYSTEM_ERROR, errorMessage);
            }
        };
    }

    /**
     * 根据用户信息生成html代码并保存在本地目录中
     * @param userMessage
     * @return 保存的文件地址
     */
    private File generateAndSaveHtmlCode(String userMessage){
        HtmlCodeResult result = aiCodeGeneratorService.generateCode(userMessage);
        return CodeFileSaver.saveHtmlCodeResult(result);
    }

    /**
     * 根据用户信息生成多文件并保存在本地
     * @param userMessage
     * @return
     */
    private File generateAndSaveMultiFileCode(String userMessage){
        MultiFileCodeResult result = aiCodeGeneratorService.generateMultiFileCode(userMessage);
        return CodeFileSaver.saveMultiFileCodeResult(result);
    }
}

最后进行单元测试 com.zhaochao.aicodeplatform.core.AiCodeGeneratorFacadeTest

java
@SpringBootTest
class AiCodeGeneratorFacadeTest {
    @Resource
    private AiCodeGeneratorFacade aiCodeGeneratorFacade;

    @Test
    void generateAndSaveCode(){
        File file = aiCodeGeneratorFacade.generateAndSaveCode("生成一个网站,叫做阿超的小店,网站经营商品主要是乌龟的各类用品", CodeGenTypeEnum.HTML);
        Assertions.assertNotNull(file);

        File file2 = aiCodeGeneratorFacade.generateAndSaveCode("生成一个博客网站,名字叫做Chao's Blog,主要分享的内容是前端技术",CodeGenTypeEnum.MULTI_FILE);
        Assertions.assertNotNull(file2);
    }
}

最后效果还是不错的,文件也正常的生成了

SSE流式输出

在实现 SSE 的技术方案上⁢, LangChai⁠n4j 提供了两种​方式:

Reactor(推荐)

Reactor 是指响应式编程, LangChain4j 提供了响应⁢式编程依赖包,可以直接把 AI 返回的内容封⁠装为更通用的 Flux,这里主要使用 Reactor

响应式对象。可以把 ​Flux 想象成一个数据流,有了这个对象后,‍上游发来一块数据,下游就能处理一块数据。

我们可以对 Flux 对象进行下列操作:

这种方案的优点是与前端集成更方⁢便,通过 Flux ⁠对象可以很容易地将流​式内容返回给前端。缺‍点是需要引入额外的依赖

xml
<dependency>
  <groupId>dev.langchain4j</groupId>
  <artifactId>langchain4j-reactor</artifactId>
  <version>1.1.0-beta7</version>
</dependency>
TokenStream

这是 LangChain4j 的原生实现方式,好处是提供了更多高⁢级回调,比如工具调用完成回调(onTool⁠Executed)、工具调用内容实时响应。​但缺点是使用起来相对复杂,而且要返回前端时‍还需要用 Flux 包装一层。

示例代码:

java
return Flux.create(sink -> {
    StringBuilder respContent = new StringBuilder();
    assistant.chat(finalUserPrompt) // 返回 tokenStream
    .onPartialResponse(partialResponse -> {
        log.info("partialResponse: {}", partialResponse);
        sink.next(partialResponse);
    })
    .onCompleteResponse(completeResponse -> {
        log.info("chatResponse: {}", completeResponse);
        sink.complete();
    })
    .onToolExecuted(toolExecution -> {
        log.info("tool executed successfully: {}", toolExecution);
    })
    .onError(sink::error)
    .start();
});
开发实现

首先需要引入上面的 Reactor 依赖

1、配置流式模型:

src/main/resources/application-local.yml

yaml
langchain4j:
  open-ai:
    streaming-chat-model:
      base-url: https://api.deepseek.com
      api-key: <Your API Key>
      model-name: deepseek-chat
      max-tokens: 8192
      log-requests: true
      log-responses: true

2、在创建 AI Service 的工厂类中注入流式模型:

com.zhaochao.aicodeplatform.factory.AiCodeGeneratorServiceFactory

java
@Configuration
public class AiCodeGeneratorServiceFactory {

    @Resource
    private ChatModel chatModel;

    @Resource
    private StreamingChatModel streamingChatModel;

    @Bean
    public AiCodeGeneratorService aiCodeGeneratorService() {
        return AiServices.builder(AiCodeGeneratorService.class)
                .chatModel(chatModel)
                .streamingChatModel(streamingChatModel)
                .build();
    }
}

3、在 AI Service⁢ 中新增流式方法,⁠跟之前方法的区别在​于返回值改为了 F‍lux 对象:

com.zhaochao.aicodeplatform.ai.AiCodeGeneratorService

java
/**
 * 生成 HTML 代码(流式)
 *
 * @param userMessage 用户消息
 * @return 生成的代码结果
 */
@SystemMessage(fromResource = "prompt/codegen-html-system-prompt.txt")
Flux<String> generateHtmlCodeStream(String userMessage);

/**
 * 生成多文件代码(流式)
 *
 * @param userMessage 用户消息
 * @return 生成的代码结果
 */
@SystemMessage(fromResource = "prompt/codegen-multi-file-system-prompt.txt")
Flux<String> generateMultiFileCodeStream(String userMessage);

4、编写解析逻辑。

由于流式输出返回的是字符串片⁢段,我们需要在 A⁠I 全部返回完成后进​行解析。

由于代码解析逻辑相对复杂,单独在 core 包下创建代码解析器 CodeParser 。核心逻辑是通过正则表达式从完整字符串中提取到对应的代码块,并返回结构化输出对象,这样可以复用之前的文件保存器。

com.zhaochao.aicodeplatform.core.CodeParser

java
/**
 * 代码解析器
 * 提供静态方法解析不同类型的代码内容
 */
public class CodeParser {

    private static final Pattern HTML_CODE_PATTERN = Pattern.compile("```html\\s*\\n([\\s\\S]*?)```", Pattern.CASE_INSENSITIVE);
    private static final Pattern CSS_CODE_PATTERN = Pattern.compile("```css\\s*\\n([\\s\\S]*?)```", Pattern.CASE_INSENSITIVE);
    private static final Pattern JS_CODE_PATTERN = Pattern.compile("```(?:js|javascript)\\s*\\n([\\s\\S]*?)```", Pattern.CASE_INSENSITIVE);

    /**
     * 解析 HTML 单文件代码
     */
    public static HtmlCodeResult parseHtmlCode(String codeContent) {
        HtmlCodeResult result = new HtmlCodeResult();
        // 提取 HTML 代码
        String htmlCode = extractHtmlCode(codeContent);
        if (htmlCode != null && !htmlCode.trim().isEmpty()) {
            result.setHtmlCode(htmlCode.trim());
        } else {
            // 如果没有找到代码块,将整个内容作为HTML
            result.setHtmlCode(codeContent.trim());
        }
        return result;
    }

    /**
     * 解析多文件代码(HTML + CSS + JS)
     */
    public static MultiFileCodeResult parseMultiFileCode(String codeContent) {
        MultiFileCodeResult result = new MultiFileCodeResult();
        // 提取各类代码
        String htmlCode = extractCodeByPattern(codeContent, HTML_CODE_PATTERN);
        String cssCode = extractCodeByPattern(codeContent, CSS_CODE_PATTERN);
        String jsCode = extractCodeByPattern(codeContent, JS_CODE_PATTERN);
        // 设置HTML代码
        if (htmlCode != null && !htmlCode.trim().isEmpty()) {
            result.setHtmlCode(htmlCode.trim());
        }
        // 设置CSS代码
        if (cssCode != null && !cssCode.trim().isEmpty()) {
            result.setCssCode(cssCode.trim());
        }
        // 设置JS代码
        if (jsCode != null && !jsCode.trim().isEmpty()) {
            result.setJsCode(jsCode.trim());
        }
        return result;
    }

    /**
     * 提取HTML代码内容
     *
     * @param content 原始内容
     * @return HTML代码
     */
    private static String extractHtmlCode(String content) {
        Matcher matcher = HTML_CODE_PATTERN.matcher(content);
        if (matcher.find()) {
            return matcher.group(1);
        }
        return null;
    }

    /**
     * 根据正则模式提取代码
     *
     * @param content 原始内容
     * @param pattern 正则模式
     * @return 提取的代码
     */
    private static String extractCodeByPattern(String content, Pattern pattern) {
        Matcher matcher = pattern.matcher(content);
        if (matcher.find()) {
            return matcher.group(1);
        }
        return null;
    }
}

建议编写一个单元测试类,来验证解析器的功能:

java
class CodeParserTest {
    @Test
    void parseHtmlCode() {
        String codeContent = """
                # 单页任务记录网站
                ```html
                <!DOCTYPE html>
                <html lang="zh-CN">
                <head>
                    <meta charset="UTF-8">
                    <meta name="viewport" content="width=device-width, initial-scale=1.0">
                    <title>任务记录管理器</title>
                    <link rel="stylesheet" href="style.css">
                    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
                </head>
                <body>
                    xxxx
                </body>
                </html>
                ```
                """;
        HtmlCodeResult result = CodeParser.parseHtmlCode(codeContent);
        assertNotNull(result);
        assertNotNull(result.getHtmlCode());
    }

    @Test
    void parseMultiFileCode() {
        String codeContent = """
                # 单页任务记录网站
                ```html
                <!DOCTYPE html>
                <html lang="zh-CN">
                <head>
                    <meta charset="UTF-8">
                    <meta name="viewport" content="width=device-width, initial-scale=1.0">
                    <title>任务记录管理器</title>
                    <link rel="stylesheet" href="style.css">
                    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
                </head>
                <body>
                    xxxx
                    <script src="script.js"></script>
                </body>
                </html>
                ```
                
                ```css
                /* 全局样式 */
                * {
                    margin: 0;
                    padding: 0;
                    box-sizing: border-box;
                    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
                }
                ```
                
                ```javascript
                // 任务记录管理器 - 主JavaScript文件
                
                // 更改任务状态
                function changeTaskStatus(taskId) {
                    const taskIndex = tasks.findIndex(task => task.id === taskId);
                
                    if (taskIndex === -1) return;
                
                    // 状态循环: pending -> in-progress -> completed -> pending
                    const statusOrder = ['pending', 'in-progress', 'completed'];
                    const currentStatus = tasks[taskIndex].status;
                    const currentIndex = statusOrder.indexOf(currentStatus);
                    const nextIndex = (currentIndex + 1) % statusOrder.length;
                }
                // 初始化应用
                document.addEventListener('DOMContentLoaded', initApp);
                ```
                """;
        MultiFileCodeResult result = CodeParser.parseMultiFileCode(codeContent);
        assertNotNull(result);
        assertNotNull(result.getHtmlCode());
        assertNotNull(result.getCssCode());
        assertNotNull(result.getJsCode());
    }
}

测试后可以正常解析

5、在 AiCodeGeneratorFacade 中添加流式调用 AI 的方法

com.zhaochao.aicodeplatform.core.AiCodeGeneratorFacade

java
/**
 * 生成 HTML 模式的代码并保存(流式)
 *
 * @param userMessage 用户提示词
 * @return 保存的目录
 */
private Flux<String> generateAndSaveHtmlCodeStream(String userMessage) {
    Flux<String> result = aiCodeGeneratorService.generateHtmlCodeStream(userMessage);
    // 当流式返回生成代码完成后,再保存代码
    StringBuilder codeBuilder = new StringBuilder();
    return result
            .doOnNext(chunk -> {
                // 实时收集代码片段
                codeBuilder.append(chunk);
            })
            .doOnComplete(() -> {
                // 流式返回完成后保存代码
                try {
                    String completeHtmlCode = codeBuilder.toString();
                    HtmlCodeResult htmlCodeResult = CodeParser.parseHtmlCode(completeHtmlCode);
                    // 保存代码到文件
                    File savedDir = CodeFileSaver.saveHtmlCodeResult(htmlCodeResult);
                    log.info("保存成功,路径为:" + savedDir.getAbsolutePath());
                } catch (Exception e) {
                    log.error("保存失败: {}", e.getMessage());
                }
            });
}

/**
 * 生成多文件模式的代码并保存(流式)
 *
 * @param userMessage 用户提示词
 * @return 保存的目录
 */
private Flux<String> generateAndSaveMultiFileCodeStream(String userMessage) {
    Flux<String> result = aiCodeGeneratorService.generateMultiFileCodeStream(userMessage);
    // 当流式返回生成代码完成后,再保存代码
    StringBuilder codeBuilder = new StringBuilder();
    return result
            .doOnNext(chunk -> {
                // 实时收集代码片段
                codeBuilder.append(chunk);
            })
            .doOnComplete(() -> {
                // 流式返回完成后保存代码
                try {
                    String completeMultiFileCode = codeBuilder.toString();
                    MultiFileCodeResult multiFileResult = CodeParser.parseMultiFileCode(completeMultiFileCode);
                    // 保存代码到文件
                    File savedDir = CodeFileSaver.saveMultiFileCodeResult(multiFileResult);
                    log.info("保存成功,路径为:" + savedDir.getAbsolutePath());
                } catch (Exception e) {
                    log.error("保存失败: {}", e.getMessage());
                }
            });
}

6、在 AiCodeGeneratorFacade 中编写统一入口,根据生成模式枚举选择对应的流式方法: com.zhaochao.aicodeplatform.core.AiCodeGeneratorFacade

java
/**
 * 统一入口:根据类型生成并保存代码(流式)
 *
 * @param userMessage     用户提示词
 * @param codeGenTypeEnum 生成类型
 */
public Flux<String> generateAndSaveCodeStream(String userMessage, CodeGenTypeEnum codeGenTypeEnum) {
    if (codeGenTypeEnum == null) {
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "生成类型为空");
    }
    return switch (codeGenTypeEnum) {
        case HTML -> generateAndSaveHtmlCodeStream(userMessage);
        case MULTI_FILE -> generateAndSaveMultiFileCodeStream(userMessage);
        default -> {
            String errorMessage = "不支持的生成类型:" + codeGenTypeEnum.getValue();
            throw new BusinessException(ErrorCode.SYSTEM_ERROR, errorMessage);
        }
    };
}

7、编写单元测试验证流式功能:

java
@SpringBootTest
class AiCodeGeneratorFacadeTest {
    @Resource
    private AiCodeGeneratorFacade aiCodeGeneratorFacade;

    @Test
    void generateAndSaveCode(){
        File file = aiCodeGeneratorFacade.generateAndSaveCode("生成一个网站,叫做阿超的小店,网站经营商品主要是乌龟的各类用品", CodeGenTypeEnum.HTML);
        Assertions.assertNotNull(file);

        File file2 = aiCodeGeneratorFacade.generateAndSaveCode("生成一个博客网站,名字叫做Chao's Blog,主要分享的内容是前端技术",CodeGenTypeEnum.MULTI_FILE);
        Assertions.assertNotNull(file2);
    }

    @Test
    void generateAndSaveCodeStream() {
        Flux<String> codeStream = aiCodeGeneratorFacade.generateAndSaveCodeStream("任务记录网站", CodeGenTypeEnum.MULTI_FILE);
        // 阻塞等待所有数据收集完成
        List<String> result = codeStream.collectList().block();
        // 验证结果
        Assertions.assertNotNull(result);
        String completeContent = String.join("", result);
        Assertions.assertNotNull(completeContent);
    }
}

可以发现最终各个类型文件都被正常解析了,网站预览效果也正常

代码优化

优化方案:

  • 解析器部分:使用策略模式,不同类型的解析策略独立维护(难点是不同解析策略的返回值不同)

  • 文件保存部分:使用模板方法模式,统一保存流程(难点是不同保存方式的方法参数不同)

  • SSE 流式处理:抽象出通用的流式处理逻辑(目前每种生成模式都写了一套处理代码)

策略模式

策略模式定义了一系列算法,将每个算⁢法封装起来,并让它们可⁠以相互替换,使得算法的​变化不会影响使用算法的‍代码,让项目更好维护和扩展。这里解析策略是一样的,只是返回值可能不同:单文件、多文件返回值,所以这里可以使用策略模式。

模板方法模式

模板方法模式在抽象父类中定义了操作⁢的标准流程,将一些具体⁠实现步骤交给子类,使得​子类可以在不改变流程的‍情况下重新定义某些特定步骤。这里构建保存目录,保存文件的流程都是一样的,但是具体的保存文件内容是不一样的,单文件只保存 html,多文件保存 htmljscss,所以这个类中一部分函数代码通用,一部分不通用需要单独实现,那么就适合使用 模板方法模式

执行器模式 正常情况下,可以通过工厂模式来创建不同的策略或模板方法,但由⁢于每种生成模式的参数和返回值不同(Htm⁠lCodeResultMultiF​ileCodeResult),很难对通过‍工厂模式创建出来的对象进行统一的调用。

java
public HtmlCodeResult parseCode(String codeContent) {}

public MultiFileCodeResult parseCode(String codeContent) {}

void saveFiles(HtmlCodeResult result, String baseDirPath) {}

void saveFiles(MultiFileCodeResult result, String baseDirPath) {}

对于方法参数不同的策略模式和⁢模板方法模式,建议⁠使用执行器模式(E​xecutor)。

执行器模式提供统一的执行入口来协调不同策⁢略和模板的调用,特别适合处⁠理参数类型不同但业务逻辑相​似的场景,避免了工厂模式在‍处理不同参数类型时的局限性。

混合模式

  • 执行器模式:提供统一的执行入口,根据生成类型执行不同的操作

  • 策略模式:每种模式对应的解析方法单独作为一个类来维护

  • 模板方法模式:抽象模板类定义了通用的文件保存流程,子类可以有自己的实现(比如多文件生成模式需要保存 3 个文件,而原生 HTML 模式只需要保存 1 个文件)

优化封装解析器代码

core 包下面新建 parser 包:

添加解析器接口类

java
/**
 * 代码解析器策略接口
 */
public interface CodeParser<T> {

    /**
     * 解析代码内容
     * 
     * @param codeContent 原始代码内容
     * @return 解析后的结果对象
     */
    T parseCode(String codeContent);
}

编写 HTML 单文件解析器代码

java
/**
 * 单文件解析器 html
 */
public class HtmlCodeParser implements CodeParser<HtmlCodeResult> {

    private static final Pattern HTML_CODE_PATTERN = Pattern.compile("```html\\s*\\n([\\s\\S]*?)```", Pattern.CASE_INSENSITIVE);

    @Override
    public HtmlCodeResult parseCode(String codeContent) {
        HtmlCodeResult result = new HtmlCodeResult();
        // 提取 HTML 代码
        String htmlCode = extractHtmlCode(codeContent);
        if (htmlCode != null && !htmlCode.trim().isEmpty()) {
            result.setHtmlCode(htmlCode.trim());
        } else {
            // 如果没有找到代码块,将整个内容作为HTML
            result.setHtmlCode(codeContent.trim());
        }
        return result;
    }

    /**
     * 提取HTML代码内容
     *
     * @param content 原始内容
     * @return HTML代码
     */
    private String extractHtmlCode(String content) {
        Matcher matcher = HTML_CODE_PATTERN.matcher(content);
        if (matcher.find()) {
            return matcher.group(1);
        }
        return null;
    }
}

编写多文件解析器代码

java
/**
 * 多文件解析器 html、js、css
 */
public class MultiFileCodeParser implements CodeParser<MultiFileCodeResult> {

    private static final Pattern HTML_CODE_PATTERN = Pattern.compile("```html\\s*\\n([\\s\\S]*?)```", Pattern.CASE_INSENSITIVE);
    private static final Pattern CSS_CODE_PATTERN = Pattern.compile("```css\\s*\\n([\\s\\S]*?)```", Pattern.CASE_INSENSITIVE);
    private static final Pattern JS_CODE_PATTERN = Pattern.compile("```(?:js|javascript)\\s*\\n([\\s\\S]*?)```", Pattern.CASE_INSENSITIVE);

    @Override
    public MultiFileCodeResult parseCode(String codeContent) {
        MultiFileCodeResult result = new MultiFileCodeResult();
        // 提取各类代码
        String htmlCode = extractCodeByPattern(codeContent, HTML_CODE_PATTERN);
        String cssCode = extractCodeByPattern(codeContent, CSS_CODE_PATTERN);
        String jsCode = extractCodeByPattern(codeContent, JS_CODE_PATTERN);
        // 设置HTML代码
        if (htmlCode != null && !htmlCode.trim().isEmpty()) {
            result.setHtmlCode(htmlCode.trim());
        }
        // 设置CSS代码
        if (cssCode != null && !cssCode.trim().isEmpty()) {
            result.setCssCode(cssCode.trim());
        }
        // 设置JS代码
        if (jsCode != null && !jsCode.trim().isEmpty()) {
            result.setJsCode(jsCode.trim());
        }
        return result;
    }

    /**
     * 根据正则模式提取代码
     *
     * @param content 原始内容
     * @param pattern 正则模式
     * @return 提取的代码
     */
    private String extractCodeByPattern(String content, Pattern pattern) {
        Matcher matcher = pattern.matcher(content);
        if (matcher.find()) {
            return matcher.group(1);
        }
        return null;
    }
}

编写解析器执行类

java
/**
 * 代码解析执行器
 * 根据代码生成类型执行相应的解析逻辑
 *
 * @author yupi
 */
public class CodeParserExecutor {

    private static final HtmlCodeParser htmlCodeParser = new HtmlCodeParser();

    private static final MultiFileCodeParser multiFileCodeParser = new MultiFileCodeParser();

    /**
     * 执行代码解析
     *
     * @param codeContent 代码内容
     * @param codeGenType 代码生成类型
     * @return 解析结果(HtmlCodeResult 或 MultiFileCodeResult)
     */
    public static Object executeParser(String codeContent, CodeGenTypeEnum codeGenType) {
        return switch (codeGenType) {
            case HTML -> htmlCodeParser.parseCode(codeContent);
            case MULTI_FILE -> multiFileCodeParser.parseCode(codeContent);
            default -> throw new BusinessException(ErrorCode.SYSTEM_ERROR, "不支持的代码生成类型: " + codeGenType);
        };
    }
}
优化封装文件保存器代码

core包下面新建saver包,用于保存文件保存器相关代码:

创建文件保存模板抽象类,通过⁢泛型统一了方法的调用⁠参数。其中 sa​veCode 方法‍定义了保存代码的通用流程,具体的 saveFiles 方法逻辑不一样则是由不同的子类去实现

java
/**
 * 抽象文件保存器 - 模板方法模式
 * @param <T>
 */
public abstract class CodeFileSaverTemplate<T> {
    // 文件保存根目录
    protected static final String FILE_SAVE_ROOT_DIR = System.getProperty("user.dir") + "/tmp/code_output";

    /**
     * 模板方法:保存代码的标准流程
     *
     * @param result 代码结果对象
     * @return 保存的目录
     */
    public final File saveCode(T result) {
        // 1. 验证输入
        validateInput(result);
        // 2. 构建唯一目录
        String baseDirPath = buildUniqueDir();
        // 3. 保存文件(具体实现由子类提供)
        saveFiles(result, baseDirPath);
        // 4. 返回目录文件对象
        return new File(baseDirPath);
    }

    /**
     * 构建唯一目录路径
     *
     * @return 目录路径
     */
    protected final String buildUniqueDir() {
        String codeType = getCodeType().getValue();
        String uniqueDirName = StrUtil.format("{}_{}", codeType, IdUtil.getSnowflakeNextIdStr());
        String dirPath = FILE_SAVE_ROOT_DIR + File.separator + uniqueDirName;
        FileUtil.mkdir(dirPath);
        return dirPath;
    }

    /**
     * 写入单个文件的工具方法
     *
     * @param dirPath  目录路径
     * @param filename 文件名
     * @param content  文件内容
     */
    protected final void writeToFile(String dirPath, String filename, String content) {
        if (StrUtil.isNotBlank(content)) {
            String filePath = dirPath + File.separator + filename;
            FileUtil.writeString(content, filePath, StandardCharsets.UTF_8);
        }
    }

    /**
     * 验证输入参数(可由子类覆盖)
     *
     * @param result 代码结果对象
     */
    protected void validateInput(T result) {
        if (result == null) {
            throw new BusinessException(ErrorCode.SYSTEM_ERROR, "代码结果对象不能为空");
        }
    }

    /**
     * 获取代码类型(由子类实现)
     *
     * @return 代码生成类型
     */
    protected abstract CodeGenTypeEnum getCodeType();

    /**
     * 保存文件的具体实现(由子类实现)
     *
     * @param result      代码结果对象
     * @param baseDirPath 基础目录路径
     */
    protected abstract void saveFiles(T result, String baseDirPath);
}

单文件模板保存器代码:

java
/**
 * 单文件代码保存器
 */
public class HtmlCodeFileSaverTemplate extends CodeFileSaverTemplate<HtmlCodeResult>{
    @Override
    protected CodeGenTypeEnum getCodeType() {
        return CodeGenTypeEnum.HTML;
    }

    @Override
    protected void saveFiles(HtmlCodeResult result, String baseDirPath) {
        // 保存 HTML 文件
        writeToFile(baseDirPath, "index.html", result.getHtmlCode());
    }

    @Override
    protected void validateInput(HtmlCodeResult result) {
        super.validateInput(result);
        // HTML 代码不能为空
        if (StrUtil.isBlank(result.getHtmlCode())) {
            throw new BusinessException(ErrorCode.SYSTEM_ERROR, "HTML代码内容不能为空");
        }
    }
}

多文件模板保存器代码:

java
/**
 * 多文件代码保存器
 */
public class MultiFileCodeFileSaverTemplate extends CodeFileSaverTemplate<MultiFileCodeResult> {
    @Override
    protected CodeGenTypeEnum getCodeType() {
        return CodeGenTypeEnum.MULTI_FILE;
    }

    @Override
    protected void saveFiles(MultiFileCodeResult result, String baseDirPath) {
        // 保存 HTML 文件
        writeToFile(baseDirPath, "index.html", result.getHtmlCode());
        // 保存 CSS 文件
        writeToFile(baseDirPath, "style.css", result.getCssCode());
        // 保存 JavaScript 文件
        writeToFile(baseDirPath, "script.js", result.getJsCode());
    }

    @Override
    protected void validateInput(MultiFileCodeResult result) {
        super.validateInput(result);
        // 至少要有 HTML 代码,CSS 和 JS 可以为空
        if (StrUtil.isBlank(result.getHtmlCode())) {
            throw new BusinessException(ErrorCode.SYSTEM_ERROR, "HTML代码内容不能为空");
        }
    }
}

编写文件保存执行器,根据不同的文件类型执行不同的保存逻辑:

java
/**
 * 代码保存执行器
 * 根据代码类型执行不同的代码保存逻辑
 */
public class CodeFileSaverExecutor {
    private static final HtmlCodeFileSaverTemplate htmlCodeFileSaver = new HtmlCodeFileSaverTemplate();

    private static final MultiFileCodeFileSaverTemplate multiFileCodeFileSaver = new MultiFileCodeFileSaverTemplate();

    /**
     * 执行代码保存
     *
     * @param codeResult  代码结果对象
     * @param codeGenType 代码生成类型
     * @return 保存的目录
     */
    public static File executeSaver(Object codeResult, CodeGenTypeEnum codeGenType) {
        return switch (codeGenType) {
            case HTML -> htmlCodeFileSaver.saveCode((HtmlCodeResult) codeResult);
            case MULTI_FILE -> multiFileCodeFileSaver.saveCode((MultiFileCodeResult) codeResult);
            default -> throw new BusinessException(ErrorCode.SYSTEM_ERROR, "不支持的代码生成类型: " + codeGenType);
        };
    }
}
优化修改门面类
java
/**
 * 通用流式代码处理方法
 *
 * @param codeStream  代码流
 * @param codeGenType 代码生成类型
 * @return 流式响应
 */
private Flux<String> processCodeStream(Flux<String> codeStream, CodeGenTypeEnum codeGenType) {
    StringBuilder codeBuilder = new StringBuilder();
    return codeStream.doOnNext(chunk -> {
        // 实时收集代码片段
        codeBuilder.append(chunk);
    }).doOnComplete(() -> {
        // 流式返回完成后保存代码
        try {
            String completeCode = codeBuilder.toString();
            // 使用执行器解析代码
            Object parsedResult = CodeParserExecutor.executeParser(completeCode, codeGenType);
            // 使用执行器保存代码
            File savedDir = CodeFileSaverExecutor.executeSaver(parsedResult, codeGenType);
            log.info("保存成功,路径为:" + savedDir.getAbsolutePath());
        } catch (Exception e) {
            log.error("保存失败: {}", e.getMessage());
        }
    });
}

优化 AiCodeGeneratorFacade 门面类,可以使代码变得更加简介高效:

这里的 yield 相当于 return 一个返回值并跳出 case

com.zhaochao.aicodeplatform.core.AiCodeGeneratorFacade

java
/**
 * 统一入口:根据类型生成并保存代码
 *
 * @param userMessage     用户提示词
 * @param codeGenTypeEnum 生成类型
 * @return 保存的目录
 */
public File generateAndSaveCode(String userMessage, CodeGenTypeEnum codeGenTypeEnum) {
    if (codeGenTypeEnum == null) {
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "生成类型为空");
    }
    return switch (codeGenTypeEnum) {
        case HTML -> {
            HtmlCodeResult result = aiCodeGeneratorService.generateHtmlCode(userMessage);
            yield CodeFileSaverExecutor.executeSaver(result, CodeGenTypeEnum.HTML);
        }
        case MULTI_FILE -> {
            MultiFileCodeResult result = aiCodeGeneratorService.generateMultiFileCode(userMessage);
            yield CodeFileSaverExecutor.executeSaver(result, CodeGenTypeEnum.MULTI_FILE);
        }
        default -> {
            String errorMessage = "不支持的生成类型:" + codeGenTypeEnum.getValue();
            throw new BusinessException(ErrorCode.SYSTEM_ERROR, errorMessage);
        }
    };
}

/**
 * 统一入口:根据类型生成并保存代码(流式)
 *
 * @param userMessage     用户提示词
 * @param codeGenTypeEnum 生成类型
 */
public Flux<String> generateAndSaveCodeStream(String userMessage, CodeGenTypeEnum codeGenTypeEnum) {
    if (codeGenTypeEnum == null) {
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "生成类型为空");
    }
    return switch (codeGenTypeEnum) {
        case HTML -> {
            Flux<String> codeStream = aiCodeGeneratorService.generateHtmlCodeStream(userMessage);
            yield processCodeStream(codeStream, CodeGenTypeEnum.HTML);
        }
        case MULTI_FILE -> {
            Flux<String> codeStream = aiCodeGeneratorService.generateMultiFileCodeStream(userMessage);
            yield processCodeStream(codeStream, CodeGenTypeEnum.MULTI_FILE);
        }
        default -> {
            String errorMessage = "不支持的生成类型:" + codeGenTypeEnum.getValue();
            throw new BusinessException(ErrorCode.SYSTEM_ERROR, errorMessage);
        }
    };
}

测试类测试解析效果:

java
    @Test
    void generateAndSaveCodeStream() {
        Flux<String> codeStream = aiCodeGeneratorFacade.generateAndSaveCodeStream("任务记录网站", CodeGenTypeEnum.MULTI_FILE);
        // 阻塞等待所有数据收集完成
        List<String> result = codeStream.collectList().block();
        // 验证结果
        Assertions.assertNotNull(result);
        String completeContent = String.join("", result);
        Assertions.assertNotNull(completeContent);
    }

应用模块开发

前面已经实现了AI生成代码的基本能力,接下来是AI生成应用的基础功能:创建应用、生成应用、预览应用、部署应用等功能

主要内容:

  • 基础应用能力(增删改查)

  • 应用管理

  • 应用生成

  • 应用部署(3种方案)

  • 前端页面生成和优化

需求分析

现在我们只是在本地生成代码,需要将其升级为平台化的系统,需要支持多用户、应用管理、在线部署等功能

需要支持的具体功能:

  • 用户基础功能

  • 创建应用

  • 编辑应用信息

  • 删除自己的应用

  • 查看应用详情

  • 分页查询自己的应用列表

  • 分页查看精选应用列表

  • 用户高级功能

  • ⭐️ 实时查看应用效果

  • ⭐️ 应用部署

  • 管理功能

  • 管理所有应用(删除、查询、修改)

  • 设置精选应用

方案设计

核心业务设计

核心业务流程:

用户在主页输入提示词 -> 系统创建一个应用 -> 跳转到该应用的AI对话,交互生成网站 -> 用户可以预览、满意后部署网站

中间涉及到很多的流程,数据存储权限判断文件管理网站部署等多个环节

库表设计

应用表是整个项目的核心,需要⁢记录应用的基本信息⁠、生成配置部署​信息等。

其中最关键的是 deployKey 字段。由于每个网站应用文件的部署都是隔离的(想象成沙箱),需要用唯一字段来区分,可以作为应用的存储和访问路径;而且为了便于访问,每个应用的访问路径不能太长。

这里我们参考美团 NoCode⁢ 等平台的设计,将 ⁠deployKey ​设置为 6 位英文数‍字组成的唯一标识符。

app 表的建表 SQL 如下:

应用表

sql
create table app
(
    id           bigint auto_increment comment 'id' primary key,
    appName      varchar(256)                       null comment '应用名称',
    cover        varchar(512)                       null comment '应用封面',
    initPrompt   text                               null comment '应用初始化的 prompt',
    codeGenType  varchar(64)                        null comment '代码生成类型(枚举)',
    deployKey    varchar(64)                        null comment '部署标识',
    deployedTime datetime                           null comment '部署时间',
    priority     int      default 0                 not null comment '优先级',
    userId       bigint                             not null comment '创建用户id',
    editTime     datetime default CURRENT_TIMESTAMP not null comment '编辑时间',
    createTime   datetime default CURRENT_TIMESTAMP not null comment '创建时间',
    updateTime   datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
    isDelete     tinyint  default 0                 not null comment '是否删除',
    UNIQUE KEY uk_deployKey (deployKey), -- 确保部署标识唯一
    INDEX idx_appName (appName),         -- 提升基于应用名称的查询性能
    INDEX idx_userId (userId)            -- 提升基于用户 ID 的查询性能
) comment '应用' collate = utf8mb4_unicode_ci;

1)priority 优先级字段:我们约定 99 表示精选应用,这样可以在主页展示高质量的应用,避免用户看到大量测试内容。

为什么用数字而不用枚举类型呢?原因⁢是这样更利于扩展,比如⁠约定 999 表示置顶​;还可以根据数字灵活调‍整各个应用的具体展示顺序。

2)添加索引:给 deployKe⁢y、appName、u⁠serId 三个经常用​于作为查询条件的字段增‍加索引,提高查询性能。

注意,我们暂时不考虑将应用代码直接保存到数⁢据库字段中,而是保存在文件系⁠统里。这样可以避免数据库和文​件存储不一致的问题,也便于后‍续扩展到对象存储等方案。

基础应用接口

接下来实现一些基础的应用增上改查接口:

后端开发最终的目标是提供接口⁢,我们先来实现一些⁠基础的接口,也就是​ “增删改查”:mo3ACHQfozGUgKYMGTRr50kmIuL9WLeWW398q2e6wlI=

*【用户】创建应用(须填写 initPrompt) *【用户】根据 id 修改自己的应用(目前只支持修改应用名称) *【用户】根据 id 删除自己的应用 *【用户】根据 id 查看应用详情 *【用户】分页查询自己的应用列表(支持根据名称查询,每页最多 20 个) *【用户】分页查询精选的应用列表(支持根据名称查询,每页最多 20 个) *【管理员】根据 id 删除任意应用 *【管理员】根据 id 更新任意应用(支持更新应用名称、应用封面、优先级) *【管理员】分页查询应用列表(支持根据除时间外的任何字段查询,每页数量不限) *【管理员】根据 id 查看应用详情

基础代码生成

首先使用 MyBatis F⁢lex 代码生成器⁠生成基础的 CRU​D 代码,这能大大提高‍开发效率。

生成代码后,移动代码到对应的位置:

然后修改 App 实体类中 ⁢id 的生成方式,⁠改为雪花算法:

java
@Id(keyType = KeyType.Generator, value = KeyGenerators.snowFlakeId)
private Long id;
业务代码生成 - Vibe Coding

接下来让 Cursor 参考我们的业务代码风格,自动生成后端基础代码,提示词如下:

markdown
请参考项目中已有的 User 模块的文件和代码风格,帮我根据下列需求,生成完整的 App 模块的代码。

需要的功能如下:
-【用户】创建应用(须填写 initPrompt)
-【用户】根据 id 修改自己的应用(目前只支持修改应用名称)
-【用户】根据 id 删除自己的应用
-【用户】根据 id 查看应用详情
-【用户】分页查询自己的应用列表(支持根据名称查询,每页最多 20 个)
-【用户】分页查询精选的应用列表(支持根据名称查询,每页最多 20 个)
-【管理员】根据 id 删除任意应用
-【管理员】根据 id 更新任意应用(支持更新应用名称、应用封面、优先级)
-【管理员】分页查询应用列表(支持根据除时间外的任何字段查询,每页数量不限)
-【管理员】根据 id 查看应用详情
核心代码实现
创建应用

用户创建应用时,只需要填写初⁢始化提示词。系统会⁠自动生成应用名称(​取提示词前 12 ‍位)和默认的代码生成类型。

请求类

java
/**
 * 用户创建应用请求(对应表 app,userId 由当前登录用户写入;initPrompt 可选)。
 */
@Data
public class AppAddRequest implements Serializable {

    private String initPrompt;

    private static final long serialVersionUID = 1L;
}

接口代码:

com.zhaochao.aicodeplatform.controller.AppController

java
    /**
     * 当前登录用户创建应用(initPrompt 可选;userId 写入为当前用户 id)。
     */
    @PostMapping("/add")
    public BaseResponse<Long> addApp(@RequestBody AppAddRequest appAddRequest, HttpServletRequest request) {
        ThrowUtils.throwIf(appAddRequest == null, ErrorCode.PARAMS_ERROR);
        User loginUser = userService.getLoginUser(request);
        Long id = appService.addApp(appAddRequest, loginUser);
        return ResultUtils.success(id);
    }

com.zhaochao.aicodeplatform.service.impl.AppServiceImpl

java
    @Override
    public Long addApp(AppAddRequest appAddRequest, User loginUser) {
        ThrowUtils.throwIf(appAddRequest == null, ErrorCode.PARAMS_ERROR);
        ThrowUtils.throwIf(loginUser == null || loginUser.getId() == null, ErrorCode.NOT_LOGIN_ERROR);

        App app = new App();
        if (StrUtil.isNotBlank(appAddRequest.getInitPrompt())) {
            app.setInitPrompt(appAddRequest.getInitPrompt().trim());
        }
        // 暂时设置为多文件生成
        app.setCodeGenType(CodeGenTypeEnum.MULTI_FILE.getValue());
        // 关联用户
        app.setUserId(loginUser.getId());
        // 应用名称暂时为 initPrompt 前 12 位
        app.setAppName(appAddRequest.getInitPrompt().substring(0, Math.min(appAddRequest.getInitPrompt().length(), 12)));
        // 插入数据库
        boolean saved = this.save(app);
        ThrowUtils.throwIf(!saved, ErrorCode.OPERATION_ERROR);
        return app.getId();
    }
更新应用

更新应用要确保只能修改自己的应用,请求体

java
/**
 * 用户更新自己的应用(仅 appName)。
 */
@Data
public class AppUpdateRequest implements Serializable {

    private Long id;

    private String appName;

    private static final long serialVersionUID = 1L;
}
java
    /**
     * 当前用户根据 id 修改自己的应用(仅应用名称)。
     */
    @PostMapping("/update")
    public BaseResponse<Boolean> updateMyApp(@RequestBody AppUpdateRequest appUpdateRequest, HttpServletRequest request) {
        ThrowUtils.throwIf(appUpdateRequest == null, ErrorCode.PARAMS_ERROR);
        User loginUser = userService.getLoginUser(request);
        boolean result = appService.updateMyApp(appUpdateRequest, loginUser);
        return ResultUtils.success(result);
    }

editTime 是为了区分用户编辑的时间和系统时间 com.zhaochao.aicodeplatform.service.impl.AppServiceImpl

java
    @Override
    public boolean updateMyApp(AppUpdateRequest appUpdateRequest, User loginUser) {
        ThrowUtils.throwIf(appUpdateRequest == null || appUpdateRequest.getId() == null || appUpdateRequest.getId() <= 0,
                ErrorCode.PARAMS_ERROR);
        ThrowUtils.throwIf(StrUtil.isBlank(appUpdateRequest.getAppName()), ErrorCode.PARAMS_ERROR, "应用名称不能为空");
        ThrowUtils.throwIf(loginUser == null || loginUser.getId() == null, ErrorCode.NOT_LOGIN_ERROR);

        App old = this.getById(appUpdateRequest.getId());
        ThrowUtils.throwIf(old == null, ErrorCode.NOT_FOUND_ERROR);
        ThrowUtils.throwIf(!old.getUserId().equals(loginUser.getId()), ErrorCode.NO_AUTH_ERROR);

        old.setAppName(appUpdateRequest.getAppName().trim());
        old.setEditTime(LocalDateTime.now());
        boolean ok = this.updateById(old);
        ThrowUtils.throwIf(!ok, ErrorCode.OPERATION_ERROR);
        return true;
    }
用户删除应用

需要进行权限校验,确保只能修改自己的应用。接口代码如下:

用户删除应用请求体:

java
@Data
public class DeleteRequest implements Serializable {

    /**
     * id
     */
    private Long id;

    private static final long serialVersionUID = 1L;
}
java
    /**
     * 当前用户根据 id 删除自己的应用。
     */
    @PostMapping("/delete")
    public BaseResponse<Boolean> deleteMyApp(@RequestBody DeleteRequest deleteRequest, HttpServletRequest request) {
        if (deleteRequest == null || deleteRequest.getId() == null || deleteRequest.getId() <= 0) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR);
        }
        User loginUser = userService.getLoginUser(request);
        boolean result = appService.deleteMyApp(deleteRequest.getId(), loginUser);
        return ResultUtils.success(result);
    }

com.zhaochao.aicodeplatform.service.impl.AppServiceImpl

java
    @Override
    public boolean deleteMyApp(long id, User loginUser) {
        ThrowUtils.throwIf(id <= 0, ErrorCode.PARAMS_ERROR);
        ThrowUtils.throwIf(loginUser == null || loginUser.getId() == null, ErrorCode.NOT_LOGIN_ERROR);

        App old = this.getById(id);
        ThrowUtils.throwIf(old == null, ErrorCode.NOT_FOUND_ERROR);
        ThrowUtils.throwIf(!old.getUserId().equals(loginUser.getId()), ErrorCode.NO_AUTH_ERROR);

        boolean ok = this.removeById(id);
        ThrowUtils.throwIf(!ok, ErrorCode.OPERATION_ERROR);
        return true;
    }
用户查看应用详情

应用查询涉及到关联查询用户信⁢息,需要创建 Ap⁠p 的封装类,包含​ UserVO 用‍户信息字段:

java
@Data
public class AppVO implements Serializable {

    /**
     * id
     */
    private Long id;

    /**
     * 应用名称
     */
    private String appName;

    /**
     * 应用封面
     */
    private String cover;

    /**
     * 应用初始化的 prompt
     */
    private String initPrompt;

    /**
     * 代码生成类型(枚举)
     */
    private String codeGenType;

    /**
     * 部署标识
     */
    private String deployKey;

    /**
     * 部署时间
     */
    private LocalDateTime deployedTime;

    /**
     * 优先级
     */
    private Integer priority;

    /**
     * 创建用户id
     */
    private Long userId;

    /**
     * 创建时间
     */
    private LocalDateTime createTime;

    /**
     * 更新时间
     */
    private LocalDateTime updateTime;

    /**
     * 创建用户信息
     */
    private UserVO user;

    private static final long serialVersionUID = 1L;
}
java
    /**
     * 当前用户根据 id 查看应用详情(含创建者脱敏信息)。
     */
    @GetMapping(value = {"/get", "/get/vo"})
    public BaseResponse<AppVO> getAppVOById(@RequestParam("id") long id, HttpServletRequest request) {
        ThrowUtils.throwIf(id <= 0, ErrorCode.PARAMS_ERROR);
        User loginUser = userService.getLoginUser(request);
        App app = appService.getAppDetailForUser(id, loginUser);
        return ResultUtils.success(appService.getAppVO(app));
    }

getAppVO 方法传入 App 然后补充 User 的信息,随后进行脱敏

java
    @Override
    public AppVO getAppVO(App app) {
        if (app == null) {
            return null;
        }
        AppVO appVO = new AppVO();
        BeanUtil.copyProperties(app, appVO);
        Long userId = app.getUserId();
        if (userId != null) {
            User user = userService.getById(userId);
            appVO.setUser(userService.getUserVO(user));
        }
        return appVO;
    }

com.zhaochao.aicodeplatform.service.impl.AppServiceImpl

java
    @Override
    public App getAppDetailForUser(long id, User loginUser) {
        ThrowUtils.throwIf(id <= 0, ErrorCode.PARAMS_ERROR);
        ThrowUtils.throwIf(loginUser == null || loginUser.getId() == null, ErrorCode.NOT_LOGIN_ERROR);

        App app = this.getById(id);
        ThrowUtils.throwIf(app == null, ErrorCode.NOT_FOUND_ERROR);

        boolean isOwner = app.getUserId().equals(loginUser.getId());
        ThrowUtils.throwIf(!isOwner, ErrorCode.NO_AUTH_ERROR);
        return app;
    }
用户分页查询应用

查询请求类,主要定义了可作为查询条件的字段:

java
@EqualsAndHashCode(callSuper = true)
@Data
public class AppQueryRequest extends PageRequest implements Serializable {

    /**
     * id
     */
    private Long id;

    /**
     * 应用名称
     */
    private String appName;

    /**
     * 应用封面
     */
    private String cover;

    /**
     * 应用初始化的 prompt
     */
    private String initPrompt;

    /**
     * 代码生成类型(枚举)
     */
    private String codeGenType;

    /**
     * 部署标识
     */
    private String deployKey;

    /**
     * 优先级
     */
    private Integer priority;

    /**
     * 创建用户id
     */
    private Long userId;

    private static final long serialVersionUID = 1L;
}
java
    /**
     * 当前用户分页查询自己的应用列表(按 appName 模糊;每页最多 20 条)。
     */
    @PostMapping("/my/list/page/vo")
    public BaseResponse<Page<AppVO>> listMyAppPage(@RequestBody AppQueryRequest appQueryRequest, HttpServletRequest request) {
        ThrowUtils.throwIf(appQueryRequest == null, ErrorCode.PARAMS_ERROR);
        User loginUser = userService.getLoginUser(request);
        Page<AppVO> page = appService.pageMyApps(appQueryRequest, loginUser);
        return ResultUtils.success(page);
    }

getAppVOList 是分页查询性能优化方法:

分页查询应用时,也需要额外获取创建⁢应用的用户信息,这会涉⁠及到关联查询多个用户信​息,我们需要优化查询性‍能。优化查询逻辑如下:

  • 先收集所有 userId 到集合中

  • 根据 userId 集合批量查询所有用户信息

  • 构建 Map 映射关系 userId => UserVO

  • 一次性组装所有 AppVO,根据 userIdMap 中取到需要的用户信息

com.zhaochao.aicodeplatform.service.impl.AppServiceImpl

java
    /**
     * 分页查询性能优化
     * 先收集所有 userId 到集合中
     * 根据 userId 集合批量查询所有用户信息
     * 构建 Map 映射关系 userId => UserVO
     * 一次性组装所有 AppVO,根据 userId 从 Map 中取到需要的用户信息
     * @param appList
     * @return
     */
    @Override
    public List<AppVO> getAppVOList(List<App> appList) {
        if (CollUtil.isEmpty(appList)) {
            return new ArrayList<>();
        }
        // 批量获取用户信息,避免 N+1 查询问题
        Set<Long> userIds = appList.stream()
                .map(App::getUserId)
                .collect(Collectors.toSet());
        Map<Long, UserVO> userVOMap = userService.listByIds(userIds).stream()
                .collect(Collectors.toMap(User::getId, userService::getUserVO));
        return appList.stream().map(app -> {
            AppVO appVO = new AppVO();
            BeanUtil.copyProperties(app, appVO);
            UserVO userVO = userVOMap.get(app.getUserId());
            appVO.setUser(userVO);
            return appVO;
        }).collect(Collectors.toList());
    }

    @Override
    public Page<AppVO> pageMyApps(AppQueryRequest appQueryRequest, User loginUser) {
        ThrowUtils.throwIf(appQueryRequest == null, ErrorCode.PARAMS_ERROR);
        ThrowUtils.throwIf(loginUser == null || loginUser.getId() == null, ErrorCode.NOT_LOGIN_ERROR);

        int pageSize = Math.min(Math.max(appQueryRequest.getPageSize(), 1), USER_PAGE_SIZE_MAX);
        int pageNum = Math.max(appQueryRequest.getPageNum(), 1);

        // 分页参数
        Long id = appQueryRequest.getId();
        String appName = appQueryRequest.getAppName();
        String cover = appQueryRequest.getCover();
        String initPrompt = appQueryRequest.getInitPrompt();
        String codeGenType = appQueryRequest.getCodeGenType();
        String deployKey = appQueryRequest.getDeployKey();
        Integer priority = appQueryRequest.getPriority();
        Long userId = loginUser.getId();
        String sortField = appQueryRequest.getSortField();
        String sortOrder = appQueryRequest.getSortOrder();

        // 构建查询条件
        QueryWrapper qw = QueryWrapper.create()
                .eq("id", id)
                .like("appName", appName)
                .like("cover", cover)
                .like("initPrompt", initPrompt)
                .eq("codeGenType", codeGenType)
                .eq("deployKey", deployKey)
                .eq("priority", priority)
                .eq("userId", userId)
                .orderBy(sortField, "ascend".equals(sortOrder));
        Page<App> appPage = this.page(Page.of(pageNum, pageSize), qw);
        // 数据封装
        Page<AppVO> appVOPage = new Page<>(pageNum, pageSize, appPage.getTotalRow());
        List<AppVO> appVOList = this.getAppVOList(appPage.getRecords());
        appVOPage.setRecords(appVOList);
        return appVOPage;
    }
用户分页查询精选应用
java
public interface AppConstant {

    /**
     * 精选应用的优先级
     */
    Integer GOOD_APP_PRIORITY = 99;

    /**
     * 默认应用优先级
     */
    Integer DEFAULT_APP_PRIORITY = 0;
}
java
    /**
     * 分页查询精选应用列表(priority 等于 {@link AppConstant#GOOD_APP_PRIORITY};按 appName 模糊;每页最多 20 条;含创建者信息)。
     */
    @PostMapping("/good/list/page/vo")
    public BaseResponse<Page<AppVO>> listFeaturedAppPage(@RequestBody AppQueryRequest appQueryRequest) {
        ThrowUtils.throwIf(appQueryRequest == null, ErrorCode.PARAMS_ERROR);
        Page<AppVO> page = appService.pageFeaturedApps(appQueryRequest);
        return ResultUtils.success(page);
    }

com.zhaochao.aicodeplatform.service.impl.AppServiceImpl

java
    @Override
    public Page<AppVO> pageFeaturedApps(AppQueryRequest appQueryRequest) {
        ThrowUtils.throwIf(appQueryRequest == null, ErrorCode.PARAMS_ERROR);

        int pageSize = Math.min(Math.max(appQueryRequest.getPageSize(), 1), USER_PAGE_SIZE_MAX);
        int pageNum = Math.max(appQueryRequest.getPageNum(), 1);

        QueryWrapper qw = QueryWrapper.create()
                .eq(App::getPriority, AppConstant.GOOD_APP_PRIORITY)
                .like(App::getAppName, appQueryRequest.getAppName(), StrUtil.isNotBlank(appQueryRequest.getAppName()))
                .orderBy(appQueryRequest.getSortField(), "ascend".equals(appQueryRequest.getSortOrder()));

        Page<App> appPage = this.page(Page.of(pageNum, pageSize), qw);
        Page<AppVO> appVOPage = new Page<>(pageNum, pageSize, appPage.getTotalRow());
        appVOPage.setRecords(this.getAppVOList(appPage.getRecords()));
        return appVOPage;
    }
管理员删除应用

跟用户删除应用接口类似,但是⁢管理员可以删除任意⁠应用,可以通过权限​注解校验权限:

java
    /**
     * 管理员根据 id 删除任意应用。
     */
    @PostMapping("/admin/delete")
    @AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
    public BaseResponse<Boolean> adminDeleteApp(@RequestBody DeleteRequest deleteRequest) {
        if (deleteRequest == null || deleteRequest.getId() == null || deleteRequest.getId() <= 0) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR);
        }
        boolean result = appService.adminDeleteApp(deleteRequest.getId());
        return ResultUtils.success(result);
    }

com.zhaochao.aicodeplatform.service.impl.AppServiceImpl

java
    @Override
    public boolean adminDeleteApp(long id) {
        ThrowUtils.throwIf(id <= 0, ErrorCode.PARAMS_ERROR);
        App old = this.getById(id);
        ThrowUtils.throwIf(old == null, ErrorCode.NOT_FOUND_ERROR);
        boolean ok = this.removeById(id);
        ThrowUtils.throwIf(!ok, ErrorCode.OPERATION_ERROR);
        return true;
    }
管理员更新应用

管理员可以更新任意应用的应用⁢名称、应用封面和优⁠先级,更新优先级的​操作其实就是精选。

java
@Data
public class AppAdminUpdateRequest implements Serializable {

    /**
     * id
     */
    private Long id;

    /**
     * 应用名称
     */
    private String appName;

    /**
     * 应用封面
     */
    private String cover;

    /**
     * 优先级
     */
    private Integer priority;

    private static final long serialVersionUID = 1L;
}
java
    /**
     * 管理员根据 id 更新任意应用(应用名称、封面、优先级)。
     */
    @PostMapping("/admin/update")
    @AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
    public BaseResponse<Boolean> adminUpdateApp(@RequestBody AppAdminUpdateRequest appAdminUpdateRequest) {
        if (appAdminUpdateRequest == null || appAdminUpdateRequest.getId() == null) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR);
        }
        boolean result = appService.adminUpdateApp(appAdminUpdateRequest);
        return ResultUtils.success(result);
    }

com.zhaochao.aicodeplatform.service.impl.AppServiceImpl

java
    @Override
    public boolean adminUpdateApp(AppAdminUpdateRequest appAdminUpdateRequest) {
        ThrowUtils.throwIf(appAdminUpdateRequest == null || appAdminUpdateRequest.getId() == null
                || appAdminUpdateRequest.getId() <= 0, ErrorCode.PARAMS_ERROR);

        App old = this.getById(appAdminUpdateRequest.getId());
        ThrowUtils.throwIf(old == null, ErrorCode.NOT_FOUND_ERROR);

        if (appAdminUpdateRequest.getAppName() != null) {
            old.setAppName(appAdminUpdateRequest.getAppName());
        }
        if (appAdminUpdateRequest.getCover() != null) {
            old.setCover(appAdminUpdateRequest.getCover());
        }
        if (appAdminUpdateRequest.getPriority() != null) {
            old.setPriority(appAdminUpdateRequest.getPriority());
        }
        old.setEditTime(LocalDateTime.now());

        boolean ok = this.updateById(old);
        ThrowUtils.throwIf(!ok, ErrorCode.OPERATION_ERROR);
        return true;
    }
管理员分页查询应用
java
    /**
     * 管理员分页获取应用列表(除时间字段外均可筛选;分页大小不限;含创建者信息)。
     */
    @PostMapping("/admin/list/page/vo")
    @AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
    public BaseResponse<Page<AppVO>> adminListAppVOByPage(@RequestBody AppAdminQueryRequest appAdminQueryRequest) {
        ThrowUtils.throwIf(appAdminQueryRequest == null, ErrorCode.PARAMS_ERROR);
        int pageNum = Math.max(appAdminQueryRequest.getPageNum(), 1);
        int pageSize = appAdminQueryRequest.getPageSize() > 0 ? appAdminQueryRequest.getPageSize() : 10;
        QueryWrapper queryWrapper = appService.getQueryWrapper(appAdminQueryRequest);
        Page<App> appPage = appService.page(Page.of(pageNum, pageSize), queryWrapper);
        Page<AppVO> appVOPage = new Page<>(pageNum, pageSize, appPage.getTotalRow());
        List<AppVO> appVOList = appService.getAppVOList(appPage.getRecords());
        appVOPage.setRecords(appVOList);
        return ResultUtils.success(appVOPage);
    }
java

    @Override
    public QueryWrapper getQueryWrapper(AppAdminQueryRequest appAdminQueryRequest) {
        ThrowUtils.throwIf(appAdminQueryRequest == null, ErrorCode.PARAMS_ERROR);
        return QueryWrapper.create()
                .eq(App::getId, appAdminQueryRequest.getId(), appAdminQueryRequest.getId() != null)
                .like(App::getAppName, appAdminQueryRequest.getAppName(), StrUtil.isNotBlank(appAdminQueryRequest.getAppName()))
                .like(App::getCover, appAdminQueryRequest.getCover(), StrUtil.isNotBlank(appAdminQueryRequest.getCover()))
                .like(App::getInitPrompt, appAdminQueryRequest.getInitPrompt(), StrUtil.isNotBlank(appAdminQueryRequest.getInitPrompt()))
                .like(App::getCodeGenType, appAdminQueryRequest.getCodeGenType(), StrUtil.isNotBlank(appAdminQueryRequest.getCodeGenType()))
                .eq(App::getDeployKey, appAdminQueryRequest.getDeployKey(), StrUtil.isNotBlank(appAdminQueryRequest.getDeployKey()))
                .eq(App::getUserId, appAdminQueryRequest.getUserId(), appAdminQueryRequest.getUserId() != null)
                .eq(App::getPriority, appAdminQueryRequest.getPriority(), appAdminQueryRequest.getPriority() != null)
                .orderBy(appAdminQueryRequest.getSortField(), "ascend".equals(appAdminQueryRequest.getSortOrder()));
    }

    /**
     * 分页查询性能优化
     * 先收集所有 userId 到集合中
     * 根据 userId 集合批量查询所有用户信息
     * 构建 Map 映射关系 userId => UserVO
     * 一次性组装所有 AppVO,根据 userId 从 Map 中取到需要的用户信息
     * @param appList
     * @return
     */
    @Override
    public List<AppVO> getAppVOList(List<App> appList) {
        if (CollUtil.isEmpty(appList)) {
            return new ArrayList<>();
        }
        // 批量获取用户信息,避免 N+1 查询问题
        Set<Long> userIds = appList.stream()
                .map(App::getUserId)
                .collect(Collectors.toSet());
        Map<Long, UserVO> userVOMap = userService.listByIds(userIds).stream()
                .collect(Collectors.toMap(User::getId, userService::getUserVO));
        return appList.stream().map(app -> {
            AppVO appVO = new AppVO();
            BeanUtil.copyProperties(app, appVO);
            UserVO userVO = userVOMap.get(app.getUserId());
            appVO.setUser(userVO);
            return appVO;
        }).collect(Collectors.toList());
    }
管理员查看应用详情
java
    /**
     * 管理员根据 id 获取应用详情(含创建者脱敏信息)。
     */
    @GetMapping("/admin/get/vo")
    @AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
    public BaseResponse<AppVO> adminGetAppVOById(@RequestParam("id") long id) {
        ThrowUtils.throwIf(id <= 0, ErrorCode.PARAMS_ERROR);
        App app = appService.getById(id);
        ThrowUtils.throwIf(app == null, ErrorCode.NOT_FOUND_ERROR);
        return ResultUtils.success(appService.getAppVO(app));
    }

com.zhaochao.aicodeplatform.service.impl.AppServiceImpl

java
    @Override
    public AppVO getAppVO(App app) {
        if (app == null) {
            return null;
        }
        AppVO appVO = new AppVO();
        BeanUtil.copyProperties(app, appVO);
        Long userId = app.getUserId();
        if (userId != null) {
            User user = userService.getById(userId);
            appVO.setUser(userService.getUserVO(user));
        }
        return appVO;
    }

应用生成接口

现在我们需要将之前实现的 A⁢I 生成功能与应用⁠管理系统进行集成。

参考大厂平台,我们的整个业务流程是:

  • 1、用户在主页输入提示词创建应用(入库)

  • 2、获得应用 ID 后跳转到对话页面

  • 3、系统自动使用初始提示词与 AI 对话生成网站代码

由于应用的生成过程和 AI 对话是绑定的,我们可以提供一个名为 chatToGenCode的应用生成接口,调用之前开发的 AI代码生成门面完成任务,并且流式返回给前端。前端不需要区分用户是否是第一次和该应用对话,始终调用这个接口即可,需要怎么做都交给后端来判断。

一定要确保生成的文件能够与应用正确关联,因此这次生成的网站目录名称不再是之前的 codeType_雪花算法,而是codeGenType_appId,这样就能通过 appId查数据库获取应用信息、再根据应用信息找到对应的网站目录了。

服务开发

首先需要修改 CodeFileSaverTemplatesaveCodebuildUniqueDir 方法,使其支持基于 appId 的目录命名:

java
/**
 * 模板方法:保存代码的标准流程(使用 appId)
 *
 * @param result 代码结果对象
 * @param appId  应用 ID
 * @return 保存的目录
 */
public final File saveCode(T result, Long appId) {
    // 1. 验证输入
    validateInput(result);
    // 2. 构建基于 appId 的目录
    String baseDirPath = buildUniqueDir(appId);
    // 3. 保存文件(具体实现由子类提供)
    saveFiles(result, baseDirPath);
    // 4. 返回目录文件对象
    return new File(baseDirPath);
}

/**
 * 构建基于 appId 的目录路径
 *
 * @param appId 应用 ID
 * @return 目录路径
 */
protected final String buildUniqueDir(Long appId) {
    if (appId == null) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR, "应用 ID 不能为空");
    }
    String codeType = getCodeType().getValue();
    String uniqueDirName = StrUtil.format("{}_{}", codeType, appId);
    String dirPath = FILE_SAVE_ROOT_DIR + File.separator + uniqueDirName;
    FileUtil.mkdir(dirPath);
    return dirPath;
}

修改 CodeFileSaverExecutor 的执行方法,补充 appId 参数:

java
/**
 * 执行代码保存(使用 appId)
 *
 * @param codeResult  代码结果对象
 * @param codeGenType 代码生成类型
 * @param appId       应用 ID
 * @return 保存的目录
 */
public static File executeSaver(Object codeResult, CodeGenTypeEnum codeGenType, Long appId) {
    return switch (codeGenType) {
        case HTML -> htmlCodeFileSaver.saveCode((HtmlCodeResult) codeResult, appId);
        case MULTI_FILE -> multiFileCodeFileSaver.saveCode((MultiFileCodeResult) codeResult, appId);
        default -> throw new BusinessException(ErrorCode.SYSTEM_ERROR, "不支持的代码生成类型: " + codeGenType);
    };
}

修改 AiCodeGeneratorFacade 的所有对话方法,都添加 appId 参数:

java
/**
 * 统一入口:根据类型生成并保存代码(使用 appId)
 *
 * @param userMessage     用户提示词
 * @param codeGenTypeEnum 生成类型
 * @return 保存的目录
 */
public File generateAndSaveCode(String userMessage, CodeGenTypeEnum codeGenTypeEnum, Long appId) {
    if (codeGenTypeEnum == null) {
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "生成类型为空");
    }
    return switch (codeGenTypeEnum) {
        case HTML -> {
            HtmlCodeResult result = aiCodeGeneratorService.generateHtmlCode(userMessage);
            yield CodeFileSaverExecutor.executeSaver(result, CodeGenTypeEnum.HTML, appId);
        }
        case MULTI_FILE -> {
            MultiFileCodeResult result = aiCodeGeneratorService.generateMultiFileCode(userMessage);
            yield CodeFileSaverExecutor.executeSaver(result, CodeGenTypeEnum.MULTI_FILE, appId);
        }
        default -> {
            String errorMessage = "不支持的生成类型:" + codeGenTypeEnum.getValue();
            throw new BusinessException(ErrorCode.SYSTEM_ERROR, errorMessage);
        }
    };
}

/**
 * 统一入口:根据类型生成并保存代码(流式,使用 appId)
 *
 * @param userMessage     用户提示词
 * @param codeGenTypeEnum 生成类型
 * @param appId           应用 ID
 */
public Flux<String> generateAndSaveCodeStream(String userMessage, CodeGenTypeEnum codeGenTypeEnum, Long appId) {
    if (codeGenTypeEnum == null) {
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "生成类型为空");
    }
    return switch (codeGenTypeEnum) {
        case HTML -> {
            Flux<String> codeStream = aiCodeGeneratorService.generateHtmlCodeStream(userMessage);
            yield processCodeStream(codeStream, CodeGenTypeEnum.HTML, appId);
        }
        case MULTI_FILE -> {
            Flux<String> codeStream = aiCodeGeneratorService.generateMultiFileCodeStream(userMessage);
            yield processCodeStream(codeStream, CodeGenTypeEnum.MULTI_FILE, appId);
        }
        default -> {
            String errorMessage = "不支持的生成类型:" + codeGenTypeEnum.getValue();
            throw new BusinessException(ErrorCode.SYSTEM_ERROR, errorMessage);
        }
    };
}

/**
 * 通用流式代码处理方法(使用 appId)
 *
 * @param codeStream  代码流
 * @param codeGenType 代码生成类型
 * @param appId       应用 ID
 * @return 流式响应
 */
private Flux<String> processCodeStream(Flux<String> codeStream, CodeGenTypeEnum codeGenType, Long appId) {
    StringBuilder codeBuilder = new StringBuilder();
    return codeStream.doOnNext(chunk -> {
        // 实时收集代码片段
        codeBuilder.append(chunk);
    }).doOnComplete(() -> {
        // 流式返回完成后保存代码
        try {
            String completeCode = codeBuilder.toString();
            // 使用执行器解析代码
            Object parsedResult = CodeParserExecutor.executeParser(completeCode, codeGenType);
            // 使用执行器保存代码
            File savedDir = CodeFileSaverExecutor.executeSaver(parsedResult, codeGenType, appId);
            log.info("保存成功,路径为:" + savedDir.getAbsolutePath());
        } catch (Exception e) {
            log.error("保存失败: {}", e.getMessage());
        }
    });
}

同步修改单元测试,补充 appId 参数:

java
@Test
void generateAndSaveCode() {
    File file = aiCodeGeneratorFacade.generateAndSaveCode("任务记录网站", CodeGenTypeEnum.MULTI_FILE, 1L);
    Assertions.assertNotNull(file);
}

@Test
void generateAndSaveCodeStream() {
    Flux<String> codeStream = aiCodeGeneratorFacade.generateAndSaveCodeStream("任务记录网站", CodeGenTypeEnum.MULTI_FILE, 1L);
    // 阻塞等待所有数据收集完成
    List<String> result = codeStream.collectList().block();
    // 验证结果
    Assertions.assertNotNull(result);
    String completeContent = String.join("", result);
    Assertions.assertNotNull(completeContent);
}

AppService 中⁢编写 chatTo⁠GenCode 方​法,调用门面生成代‍码:

java
@Override
public Flux<String> chatToGenCode(Long appId, String message, User loginUser) {
    // 1. 参数校验
    ThrowUtils.throwIf(appId == null || appId <= 0, ErrorCode.PARAMS_ERROR, "应用 ID 不能为空");
    ThrowUtils.throwIf(StrUtil.isBlank(message), ErrorCode.PARAMS_ERROR, "用户消息不能为空");
    // 2. 查询应用信息
    App app = this.getById(appId);
    ThrowUtils.throwIf(app == null, ErrorCode.NOT_FOUND_ERROR, "应用不存在");
    // 3. 验证用户是否有权限访问该应用,仅本人可以生成代码
    if (!app.getUserId().equals(loginUser.getId())) {
        throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "无权限访问该应用");
    }
    // 4. 获取应用的代码生成类型
    String codeGenTypeStr = app.getCodeGenType();
    CodeGenTypeEnum codeGenTypeEnum = CodeGenTypeEnum.getEnumByValue(codeGenTypeStr);
    if (codeGenTypeEnum == null) {
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "不支持的代码生成类型");
    }
    // 5. 调用 AI 生成代码
    return aiCodeGeneratorFacade.generateAndSaveCodeStream(message, codeGenTypeEnum, appId);
}

最后在 AppController 类添加流式的返回接口

java
/**
 * 应用聊天生成代码(流式 SSE)
 *
 * @param appId   应用 ID
 * @param message 用户消息
 * @param request 请求对象
 * @return 生成结果流
 */
@GetMapping(value = "/chat/gen/code", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chatToGenCode(@RequestParam Long appId,
                                  @RequestParam String message,
                                  HttpServletRequest request) {
    // 参数校验
    ThrowUtils.throwIf(appId == null || appId <= 0, ErrorCode.PARAMS_ERROR, "应用ID无效");
    ThrowUtils.throwIf(StrUtil.isBlank(message), ErrorCode.PARAMS_ERROR, "用户消息不能为空");
    // 获取当前登录用户
    User loginUser = userService.getLoginUser(request);
    // 调用服务生成代码(流式)
    return appService.chatToGenCode(appId, message, loginUser);
}
接口测试

postman测试接口,接口正常流式输出

流式接口输出完毕以后,文件也正常生成的

SSE流式接口优化

目前虽然能流式输出了,但其实⁢获取到的数据是有问⁠题的!我们还要对 ​SSE 接口进行优‍化,解决 2 个问题。

解决空格丢失问题

前端使用 EventSour⁢ce 对接目前的接⁠口时,会出现空格丢​失问题。

解决方案是在后端封装数据,可⁢以参考 DeepS⁠eek 的做法,将​原本的返回值封装到‍ JSON 中,按照封装的思路,我们可以编写下列代码,将 Flux 额外封装成 ServerSentEvent,把原始数据放到 JSONd 字段内:

java
@GetMapping(value = "/chat/gen/code", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> chatToGenCode(@RequestParam Long appId,
                                                   @RequestParam String message,
                                                   HttpServletRequest request) {
    // 参数校验
    ThrowUtils.throwIf(appId == null || appId <= 0, ErrorCode.PARAMS_ERROR, "应用ID无效");
    ThrowUtils.throwIf(StrUtil.isBlank(message), ErrorCode.PARAMS_ERROR, "用户消息不能为空");
    // 获取当前登录用户
    User loginUser = userService.getLoginUser(request);
    // 调用服务生成代码(流式)
    Flux<String> contentFlux = appService.chatToGenCode(appId, message, loginUser);
    // 转换为 ServerSentEvent 格式
    return contentFlux
            .map(chunk -> {
                // 将内容包装成JSON对象
                Map<String, String> wrapper = Map.of("d", chunk);
                String jsonData = JSONUtil.toJsonStr(wrapper);
                return ServerSentEvent.<String>builder()
                        .data(jsonData)
                        .build();
            });
}
添加流式接口完成标识

SSE 中,当服务器关闭连接时,会触发客户端的 onclose 事件,这是前端判断流结束的标准方式。但是, onclose 事件会在连接正常结束(服务器主动关闭)和异常中断(如网络问题)时都触发,前端就很难区分到底后端是正常响应了所有数据、还是异常中断了。

因此,我们最好在后端添加一个明确的 done 事件,这样可以更清晰地区分流的正常结束和异常中断。

修改接口代码,额外追加结束事件:

java
@GetMapping(value = "/chat/gen/code", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> chatToGenCode(@RequestParam Long appId,
                                                   @RequestParam String message,
                                                   HttpServletRequest request) {
    // 参数校验
    ThrowUtils.throwIf(appId == null || appId <= 0, ErrorCode.PARAMS_ERROR, "应用ID无效");
    ThrowUtils.throwIf(StrUtil.isBlank(message), ErrorCode.PARAMS_ERROR, "用户消息不能为空");
    // 获取当前登录用户
    User loginUser = userService.getLoginUser(request);
    // 调用服务生成代码(流式)
    Flux<String> contentFlux = appService.chatToGenCode(appId, message, loginUser);
    // 转换为 ServerSentEvent 格式
    return contentFlux
            .map(chunk -> {
                // 将内容包装成JSON对象
                Map<String, String> wrapper = Map.of("d", chunk);
                String jsonData = JSONUtil.toJsonStr(wrapper);
                return ServerSentEvent.<String>builder()
                        .data(jsonData)
                        .build();
            })
            .concatWith(Mono.just(
                    // 发送结束事件
                    ServerSentEvent.<String>builder()
                            .event("done")
                            .data("")
                            .build()
            ));
}

测试一下接口:

空格已经可以正常展示:

当流式接口结束后也会给前端发送一个 done 的结束标识

应用部署(后端)

部署方案

部署的整体思路是:把本地生成的文件同步到一个 Web 服务器上。可以是同一个服务器的不同目录,也可以是不同服务器,但显然前者成本更低。

使用Serve工具

这是最简单的方案,通过 Node.jsserve 包可以快速启动一个 web 服务器,为指定目录提供 Web 访问服务。

先安装 serve 工具:

bash
npm i -g serve

假设 code_output 目录就是要部署的文件目录,只需要在这个目录内运行 serve 命令:

使用时,只需提前在服务器上启动 serve⁢ 服务器,就能为特定部署目录⁠提供 web 服务(比如 d​eployed ),然后部署时‍将代码文件移动到这个目录下即可。

这种方案的优点是配置简单;缺点是依赖 Node.js 环境,需要独立启动 Web 服务进程,而且性能相对较低。

当然,你也可以让 se⁢rve 服务器跟随⁠ Spring B​oot 项目启动或‍关闭,示例代码如下:

1、使用命令行运行serve

java
@Service
public class ServeDeployService {
    
    private static final String CODE_BASE_DIR = "/tmp/deploy";
    private static final int SERVE_PORT = 3000;
    private static Process serveProcess;
    
    /**
     * 启动 Serve 服务
     */
    public void startServeService() {
        try {
            if (serveProcess == null || !serveProcess.isAlive()) {
                ProcessBuilder pb = new ProcessBuilder(
                    "npx", "serve", CODE_BASE_DIR, "-p", String.valueOf(SERVE_PORT)
                );
                pb.redirectErrorStream(true);
                serveProcess = pb.start();
                System.out.println("Serve service started on port " + SERVE_PORT);
            }
        } catch (Exception e) {
            throw new RuntimeException("Failed to start serve service", e);
        }
    }
    
    /**
     * 关闭 Serve 服务
     */
    public void stopServeService() {
        if (serveProcess != null && serveProcess.isAlive()) {
            serveProcess.destroy();
            try {
                serveProcess.waitFor(5, TimeUnit.SECONDS);
                System.out.println("Serve service stopped");
            } catch (InterruptedException e) {
                serveProcess.destroyForcibly();
                System.out.println("Serve service force stopped");
            }
        }
    }
}

2、控制 serve 进程的生命周期:

java
@Component
public class ServeLifecycleManager {
    
    @Autowired
    private ServeDeployService serveDeployService;
    
    /**
     * Spring Boot 启动完成后启动 Serve 服务
     */
    @EventListener(ApplicationReadyEvent.class)
    public void onApplicationReady() {
        serveDeployService.startServeService();
    }
    
    /**
     * Spring Boot 关闭时停止 Serve 服务
     */
    @PreDestroy
    public void onApplicationShutdown() {
        System.out.println("Shutting down Serve service...");
        serveDeployService.stopServeService();
    }
}
使用Spring Boot启动服务代理静态资源

我们可以直接在后端项目中实现⁢一个静态资源服务接⁠口,输入部署路径,​返回相应的文件:

java
@RestController
@RequestMapping("/static")
public class StaticResourceController {

    // 应用生成根目录(用于浏览)
    private static final String PREVIEW_ROOT_DIR = System.getProperty("user.dir") + "/tmp/code_output";

    /**
     * 提供静态资源访问,支持目录重定向
     * 访问格式:http://localhost:8123/api/static/{deployKey}[/{fileName}]
     */
    @GetMapping("/{deployKey}/**")
    public ResponseEntity<Resource> serveStaticResource(
            @PathVariable("deployKey") String deployKey,
            HttpServletRequest request) {
        try {
            // 获取资源路径
            String resourcePath = (String) request.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE);
            resourcePath = resourcePath.substring(("/static/" + deployKey).length());
            // 如果是目录访问(不带斜杠),重定向到带斜杠的URL
            if (resourcePath.isEmpty()) {
                HttpHeaders headers = new HttpHeaders();
                headers.add("Location", request.getRequestURI() + "/");
                return new ResponseEntity<>(headers, HttpStatus.MOVED_PERMANENTLY);
            }
            // 默认返回 index.html
            if (resourcePath.equals("/")) {
                resourcePath = "/index.html";
            }
            // 构建文件路径
            String filePath = PREVIEW_ROOT_DIR + "/" + deployKey + resourcePath;
            File file = new File(filePath);
            // 检查文件是否存在
            if (!file.exists()) {
                return ResponseEntity.notFound().build();
            }
            // 返回文件资源
            Resource resource = new FileSystemResource(file);
            return ResponseEntity.ok()
                    .header("Content-Type", getContentTypeWithCharset(filePath))
                    .body(resource);
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
        }
    }

    /**
     * 根据文件扩展名返回带字符编码的 Content-Type
     */
    private String getContentTypeWithCharset(String filePath) {
        if (filePath.endsWith(".html")) return "text/html; charset=UTF-8";
        if (filePath.endsWith(".css")) return "text/css; charset=UTF-8";
        if (filePath.endsWith(".js")) return "application/javascript; charset=UTF-8";
        if (filePath.endsWith(".png")) return "image/png";
        if (filePath.endsWith(".jpg")) return "image/jpeg";
        return "application/octet-stream";
    }
}

这种方案的优点是无需额外进程⁢,非常方便;缺点是⁠功能相对简单,性能​也不如专业的 Web‍ 服务器。

测试结果:

使用Nginx映射

修改 Nginx 配置,http 块中添加⁢ server 块,配置 ro⁠ot 为项目部署根目录:   ​               ‍

conf
server {
    listen       80;
    server_name  localhost;
    charset      utf-8;
    charset_types text/css application/javascript text/plain text/xml application/json;
    # 项目部署根目录
    root         /Users/yupi/Code/yu-ai-code-mother/tmp/code_deploy;
    
    # 处理所有请求
    location ~ ^/([^/]+)/(.*)$ {
        try_files /$1/$2 /$1/index.html =404;
    }
}

上述配置中使用了 try_files指令,能够按顺序尝试多个文件路径,从而更灵活地处理文件访问。举个例子,当访问 /app/style.css时,会先尝试找到 /app/style.css 文件,如果不存在则返回/app/index.html,最后才返回 404 错误,这样能够适配后续我们要生成的 Vue 单页面应用。

try_files 指令的具体解释:

  • /$1/$2:第一个尝试的路径
  • /$1/index.html:第二个尝试的路径
  • =404:如果都找不到,返回 404 错误 举些例子:
访问URL$1$2尝试路径1尝试路径2
/app/app""/app//app/index.html
/app/style.cssappstyle.css/app/style.css/app/index.html
/app/js/main.jsappjs/main.js/app/js/main.js/app/index.html

这种方案的性能最佳,最适合生⁢产环境;缺点是需要⁠额外引入 Nginx​ 组件。

最终方案

基于以上分析,我们最终选择了混合方案⁢:使用 Spring B⁠oot 接口实现 AI ​生成的网页预览,使用 N‍ginx 提供网站部署服务。

部署接口接受 appId 作为请求参数,返回可访问的 URL 地址: ${部署域名}/{deployKey}

部署流程如下:

  • 参数校验:比如是否存在 App、用户是否有权限部署该应用(仅本人可以部署)

  • 生成 deployKey:之前设计库表时已经提到了 deployKey 的生成逻辑(6 位大小写字母 + 数字),还要注意不能跟已有的 key 重复;此外,每个 app 只生成一次 deployKey,已有则不生成。

  • 部署操作:本质是将 code_output 目录下的临时文件复制到 code_deploy 目录下,为了简化访问地址,直接将 deployKey 作为文件名。

首先在 AppConstant 中定义常量:

java
/**
 * 应用生成目录
 */
String CODE_OUTPUT_ROOT_DIR = System.getProperty("user.dir") + "/tmp/code_output";

/**
 * 应用部署目录
 */
String CODE_DEPLOY_ROOT_DIR = System.getProperty("user.dir") + "/tmp/code_deploy";

/**
 * 应用部署域名
 */
String CODE_DEPLOY_HOST = "http://localhost";

CodeFileSaverT⁢emplate 中⁠使用文件保存根目录​常量(用于保存生成‍的文件):

java
// 文件保存根目录
protected static final String FILE_SAVE_ROOT_DIR = AppConstant.CODE_OUTPUT_ROOT_DIR;

StaticResource⁢Controlle⁠r 中使用文件保存​根目录常量,因为要‍在生成时就预览效果:

java
// 应用生成根目录(用于浏览)
private static final String PREVIEW_ROOT_DIR = AppConstant.CODE_OUTPUT_ROOT_DIR;

编写部署请求类:

java
@Data
public class AppDeployRequest implements Serializable {

    /**
     * 应用 id
     */
    private Long appId;

    private static final long serialVersionUID = 1L;
}

基于上述流程,在 App⁢Service 中⁠编写部署服务的代码​:

java
@Override
public String deployApp(Long appId, User loginUser) {
    // 1. 参数校验
    ThrowUtils.throwIf(appId == null || appId <= 0, ErrorCode.PARAMS_ERROR, "应用 ID 不能为空");
    ThrowUtils.throwIf(loginUser == null, ErrorCode.NOT_LOGIN_ERROR, "用户未登录");
    // 2. 查询应用信息
    App app = this.getById(appId);
    ThrowUtils.throwIf(app == null, ErrorCode.NOT_FOUND_ERROR, "应用不存在");
    // 3. 验证用户是否有权限部署该应用,仅本人可以部署
    if (!app.getUserId().equals(loginUser.getId())) {
        throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "无权限部署该应用");
    }
    // 4. 检查是否已有 deployKey
    String deployKey = app.getDeployKey();
    // 没有则生成 6 位 deployKey(大小写字母 + 数字)
    if (StrUtil.isBlank(deployKey)) {
        deployKey = RandomUtil.randomString(6);
    }
    // 5. 获取代码生成类型,构建源目录路径
    String codeGenType = app.getCodeGenType();
    String sourceDirName = codeGenType + "_" + appId;
    String sourceDirPath = AppConstant.CODE_OUTPUT_ROOT_DIR + File.separator + sourceDirName;
    // 6. 检查源目录是否存在
    File sourceDir = new File(sourceDirPath);
    if (!sourceDir.exists() || !sourceDir.isDirectory()) {
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "应用代码不存在,请先生成代码");
    }
    // 7. 复制文件到部署目录
    String deployDirPath = AppConstant.CODE_DEPLOY_ROOT_DIR + File.separator + deployKey;
    try {
        FileUtil.copyContent(sourceDir, new File(deployDirPath), true);
    } catch (Exception e) {
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "部署失败:" + e.getMessage());
    }
    // 8. 更新应用的 deployKey 和部署时间
    App updateApp = new App();
    updateApp.setId(appId);
    updateApp.setDeployKey(deployKey);
    updateApp.setDeployedTime(LocalDateTime.now());
    boolean updateResult = this.updateById(updateApp);
    ThrowUtils.throwIf(!updateResult, ErrorCode.OPERATION_ERROR, "更新应用部署信息失败");
    // 9. 返回可访问的 URL
    return String.format("%s/%s/", AppConstant.CODE_DEPLOY_HOST, deployKey);
}

这个实现的优点在于支持重复部署。如果应用已经有 deplo⁢yKey,就直接使用现有的;如果没有,⁠就生成一个新的。这样既保证了 URL​的稳定性,又支持了代码的更新。缺点是不‍支持区分同一个应用多次部署的版本。

java
/**
 * 应用部署
 *
 * @param appDeployRequest 部署请求
 * @param request          请求
 * @return 部署 URL
 */
@PostMapping("/deploy")
public BaseResponse<String> deployApp(@RequestBody AppDeployRequest appDeployRequest, HttpServletRequest request) {
    ThrowUtils.throwIf(appDeployRequest == null, ErrorCode.PARAMS_ERROR);
    Long appId = appDeployRequest.getAppId();
    ThrowUtils.throwIf(appId == null || appId <= 0, ErrorCode.PARAMS_ERROR, "应用 ID 不能为空");
    // 获取当前登录用户
    User loginUser = userService.getLoginUser(request);
    // 调用服务部署应用
    String deployUrl = appService.deployApp(appId, loginUser);
    return ResultUtils.success(deployUrl);
}

找一个 appId 调用接口进行测试

可以看到部署文件已经移动过去了,并且这个应用的deployKey也成功关联了这个app

前端开发

前端主要是使用 Vibe Coding ,接口文档可以在 Swagger 中导出离线的 json 文档,然后补上几张参考图片就可以了,剩下的交给 AI

提示词

你是一位专业的前端开发,帮我根据原型图、页面介绍、需求介绍、业务流程和后端接口信息,参考项目已有的代码风格,生成符合要求的完整代码。

## 页面介绍

1)主页(参考原型图 1、2):从上到下,分别是网站标题、用户提示词输入框、我的应用分页列表、精选应用分页列表
2)应用生成对话页(参考原型图 3):
- 顶部栏的左侧是应用名称,右侧是部署按钮,顶部栏下方是核心内容区域
- 核心内容区域:
  - 左侧是对话区域,左侧自然而下分别是消息区域(用户消息在右、AI 消息在左)、用户消息输入框
  - 右侧是网页展示区域,当网站文件生成完成(流式接口全部返回后)展示
3)应用管理页:仅管理员可进入、可以在菜单项上看到,管理页样式和用户管理页面相同。
操作栏提供按钮:
- 编辑:新开页面跳转到应用信息修改页进行编辑
- 删除
- 精选:设置应用优先级为 99,本质上也是编辑
4)应用信息修改页:用户和管理员都可以进入,但普通用户只能编辑自己的应用

## 需求介绍

用户可以在本网站通过和 AI 对话创建网站应用、查看生成的网站应用效果、部署应用、管理个人应用、查看精选应用;管理员可以对整个网站的任意应用进行管理。

具体需求如下:
- 【用户】输入用户提示词来创建应用
- 【用户】修改自己的应用信息(目前只支持修改应用名称)
- 【用户】删除自己的应用
- 【用户】查看应用详情
- 【用户】通过和 AI 对话生成网站应用,并查看效果
- 【用户】部署应用
- 【用户】分页查询自己的应用列表(支持根据名称查询,每页最多 20 个)
- 【用户】分页查询精选的应用列表(支持根据名称查询,每页最多 20 个)
- 【管理员】删除任意应用
- 【管理员】更新任意应用信息(支持更新应用名称、应用封面、优先级)
- 【管理员】分页查询应用列表(支持根据除时间外的任何字段查询,每页数量不限)
- 【管理员】查看任意应用详情

## 业务流程

1)用户在主页输入框输入提示词后,调用创建应用接口得到应用 id,然后跳转到对话页面;自动将应用的初始提示词作为消息发送给 AI,并且通过 SSE 对话接口实时输出 AI 的回复;当 AI 回复完后,自动在右侧展示生成的网站效果。(本地域名为 http://localhost:8123/api/static/{codeGenType}_{appId}/)

2)用户可以在对话页面部署网站,调用后端部署接口,得到可访问的 URL 地址。

3)其他业务参考需求介绍,调用对应的后端接口实现

## 后端接口

已经在 @api 目录下生成了后端请求代码和数据类型信息,详细的接口文档我也作为文件提供给了你 @接口文档.md。

聊天历史模块开发

需求分析

  • 1、对话历史的持久化存储:用户发送消息时,⁢需要保存用户消息;AI 成功⁠回复后,需要保存 AI 消息​。即使 AI 回复失败,也要‍记录错误信息,确保对话的完整性。

  • 2、应用级别的数据隔离:每个应⁢用的对话历史都是独⁠立的。删除应用时,需要​关联删除该应用的所有‍对话历史,避免数据冗余。

  • 3、对话历史查询:支持分页查看某个应用的对话历史,需要区分用户和 AI 消息。类似聊天软件的消息加载机制,每次加载最新 10 条消息,支持 向前加载更多历史记录。(仅应用创建者和管理员可见)详细来说,进入应用页面时,前端根据应用 id 先加载一次对话历史⁢消息,关联查询最新 10条消息。如果存在⁠历史对话,直接展示;如果没有历史记录,才自​动发送初始化提示词。这样就解决了之前浏览别‍人的应用时意外触发对话的问题。

  • 4、管理对话历史:管理员可以⁢查看所有应用的对话⁠历史,按照时间降序​排序,便于内容监管‍。

方案设计

对于对话历史(聊天记录)的分⁢页查询,不建议使用⁠传统分页查询。

传统分页查询问题

在传统分页中,数据通常是 基于页码或偏移量进行加载的。如果数据在分页过程发生了变化,比如插入新数据、删除老数据,用户看到的分页数据可能会出现不一致,导致用户错过或重复某些数据。

举个例子,假设用户会持续收到新的消息。如果按照传统分页基于偏移量⁢加载,第一页已经加载了第 1 - 5 行的⁠数据,本来要查询的第二页数据是第 6 - ​10 行(对应的 SQL 语句为 limi‍t 5, 5

结果在查询第二页前,突然用户又⁢收到了 5 条新消息⁠,数据库记录就变成了下​面这样。原本的第一‍页,变成了当前的第二页!

这样就导致查询出的第二页数据⁢,正好是之前已经查⁠询出的第一页的数据​,造成了消息重复加‍载。

此外,传统的 offset 分页方式在处理大量对话数据时存在严重的性能问题。假设一个热门应用积累了几万条对话记录后,如果用户想查看较早的历史消息,执行 LIMIT 10000, 10 这样的查询会非常缓慢。

这是因为数据库需要先扫描和跳过前面的 10000 条⁢记录,才能返回用户真正需要的 10 条⁠数据。随着 offset值的​增大,查询性能会线性下降,在高并发‍场景下很容易成为系统瓶颈。

游标查询

为了解决这些问题,可以使用游标分页。使用一个游标来跟踪分页位置,而不是基于页码,每次请求从上一次请求的游标开始加载数据。

一般我们会选择数据记录的唯一标识符(主键)、时间戳、或者具有排序能力的字⁢段作为游标。比如即时通讯系统中的每个消息,通常都⁠有一个唯一自增的 id ,就可以作为游标。每次查询​完当前页面的数据后,可以将最后一条消息记录的 i‍d 作为游标值传递给前端(客户端)。

当要加载下一页时,前端携带游标值发起⁢查询,后端操作数据库从 ⁠id 小于当前游标值的数​据开始查询,这样查询结果‍就不会受到新增数据的影响。

标准实践建议优先使用 id 作为游标,因为主键性能最优且不重复。但针对我们的场景,按⁢时间排序是核心需求,而且同一个 appId 下时间重复的⁠可能性极低,所以直接使用对话历史的创建时间 create​Time 作为游标是完全可行的。不需要额外带上对话历史的‍ id 作为复合游标,简化了游标查询的逻辑。

示例 SQL 语句如下:

sql
SELECT * FROM chat_history 
WHERE appId = 123 AND createTime < '2025-07-29 10:30:00'
ORDER BY createTime DESC 
LIMIT 10;

而且还可以给 appId 和⁢ createTi⁠me 增加复合索引​,进一步提高检索效‍率。这样执行过程就变成了:

  • 直接定位到 (appId=123, createTime<'2025-07-29 10:30:00') 的索引位置
  • 顺序读取 10 条记录
  • 完成查询

几乎只有 10 次读取成本!

再举个明显的对比例子,假设一个热门⁢聊天室有 10 万条对⁠话记录,用户要查看 1​ 个月前的消息(大约在‍第 3000 页):

查询方式SQL语句执行时间资源消耗
Offset 分页LIMIT 30000, 10800ms需要扫描 30000 条记录
游标查询createTi⁢me < '2025⁠-07-29'​12ms直接定‍位,仅读取 10 条
库表设计

根据我们的方案,可以设计出对话历史表的结构。为了避免与 AI 库的 ChatMessage 冲突,表名采用 chat_history:

sql
-- 对话历史表
create table chat_history
(
   id          bigint auto_increment comment 'id' primary key,
   message     text                               not null comment '消息',
   messageType varchar(32)                        not null comment 'user/ai',
   appId       bigint                             not null comment '应用id',
   userId      bigint                             not null comment '创建用户id',
   createTime  datetime default CURRENT_TIMESTAMP not null comment '创建时间',
   updateTime  datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
   isDelete    tinyint  default 0                 not null comment '是否删除',
   INDEX idx_appId (appId),                       -- 提升基于应用的查询性能
   INDEX idx_createTime (createTime),             -- 提升基于时间的查询性能
   INDEX idx_appId_createTime (appId, createTime) -- 游标查询核心索引
) comment '对话历史' collate = utf8mb4_unicode_ci;

这个设计中最关键的是复合索引 idx_appId_createTime ,能够大幅提高游标查询的效率。

业务代码生成 - Vibe Coding

Vibe Coding 提示词

请参考项目中已有的 User 和 APP 模块的文件和代码风格,帮我根据下列需求,生成完整的 ChatHistory 模块的后端代码。

## 需要的功能如下

1)对话历史的持久化存储:用户发送消息时,需要保存用户消息;AI 成功回复后,需要保存 AI 消息。即使 AI 回复失败,也要记录错误信息,确保对话的完整性。
2)应用级别的数据隔离:每个应用的对话历史都是独立的。删除应用时,需要关联删除该应用的所有对话历史,避免数据冗余。
3)对话历史查询:支持分页查看某个应用的对话历史,需要区分用户和 AI 消息。类似聊天软件的消息加载机制,每次加载最新 10 条消息,支持 向前加载 更多历史记录。(仅应用创建者和管理员可见)
详细来说,进入应用页面时,前端根据应用 id 先加载一次对话历史消息,关联查询最新 10 条消息。如果存在历史对话,直接展示;如果没有历史记录,才自动发送初始化提示词。这样就解决了之前浏览别人的应用时意外触发对话的问题。
4)管理对话历史:管理员可以查看所有应用的对话历史,按照时间降序排序,便于内容监管。

## 实现提示

1)需要为 messageType 创建一个枚举类

AI 生成了完整的枚举类,代码如下:

java
@Getter
public enum ChatHistoryMessageTypeEnum {

    USER("用户", "user"),
    AI("AI", "ai");

    private final String text;

    private final String value;

    ChatHistoryMessageTypeEnum(String text, String value) {
        this.text = text;
        this.value = value;
    }

    /**
     * 根据 value 获取枚举
     *
     * @param value 枚举值的value
     * @return 枚举值
     */
    public static ChatHistoryMessageTypeEnum getEnumByValue(String value) {
        if (ObjUtil.isEmpty(value)) {
            return null;
        }
        for (ChatHistoryMessageTypeEnum anEnum : ChatHistoryMessageTypeEnum.values()) {
            if (anEnum.value.equals(value)) {
                return anEnum;
            }
        }
        return null;
    }
}

新增对话历史

对话历史的保存需要在 用户发送消息 和 AI 回复完成 这两个时机进行。无论 AI 回复成功还是失败,都要留下完整的对话记录,确保用户能够了解完整的交互历史。可以在 ChatHistoryServiceImpl 中提供统一的保存接口:

java
@Override
public boolean addChatMessage(Long appId, String message, String messageType, Long userId) {
    ThrowUtils.throwIf(appId == null || appId <= 0, ErrorCode.PARAMS_ERROR, "应用ID不能为空");
    ThrowUtils.throwIf(StrUtil.isBlank(message), ErrorCode.PARAMS_ERROR, "消息内容不能为空");
    ThrowUtils.throwIf(StrUtil.isBlank(messageType), ErrorCode.PARAMS_ERROR, "消息类型不能为空");
    ThrowUtils.throwIf(userId == null || userId <= 0, ErrorCode.PARAMS_ERROR, "用户ID不能为空");
    // 验证消息类型是否有效
    ChatHistoryMessageTypeEnum messageTypeEnum = ChatHistoryMessageTypeEnum.getEnumByValue(messageType);
    ThrowUtils.throwIf(messageTypeEnum == null, ErrorCode.PARAMS_ERROR, "不支持的消息类型: " + messageType);
    ChatHistory chatHistory = ChatHistory.builder()
            .appId(appId)
            .message(message)
            .messageType(messageType)
            .userId(userId)
            .build();
    return this.save(chatHistory);
}

然后在 AppServiceImplchatToGenCode 方法中集成对话历史保存逻辑。这里利用了 Flux 响应式编程的特性,可以在流式响应的过程中收集完整的 AI 回复:

java
@Resource
private ChatHistoryService chatHistoryService;

@Override
public Flux<String> chatToGenCode(Long appId, String message, User loginUser) {
    // ... 前面省略
    // 4. 获取应用的代码生成类型
    String codeGenTypeStr = app.getCodeGenType();
    CodeGenTypeEnum codeGenTypeEnum = CodeGenTypeEnum.getEnumByValue(codeGenTypeStr);
    if (codeGenTypeEnum == null) {
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "不支持的代码生成类型");
    }
    // 5. 通过校验后,添加用户消息到对话历史
    chatHistoryService.addChatMessage(appId, message, ChatHistoryMessageTypeEnum.USER.getValue(), loginUser.getId());
    // 6. 调用 AI 生成代码(流式)
    Flux<String> contentFlux = aiCodeGeneratorFacade.generateAndSaveCodeStream(message, codeGenTypeEnum, appId);
    // 7. 收集AI响应内容并在完成后记录到对话历史
    StringBuilder aiResponseBuilder = new StringBuilder();
    return contentFlux
            .map(chunk -> {
                // 收集AI响应内容
                aiResponseBuilder.append(chunk);
                return chunk;
            })
            .doOnComplete(() -> {
                // 流式响应完成后,添加AI消息到对话历史
                String aiResponse = aiResponseBuilder.toString();
                if (StrUtil.isNotBlank(aiResponse)) {
                    chatHistoryService.addChatMessage(appId, aiResponse, ChatHistoryMessageTypeEnum.AI.getValue(), loginUser.getId());
                }
            })
            .doOnError(error -> {
                // 如果AI回复失败,也要记录错误消息
                String errorMessage = "AI回复失败: " + error.getMessage();
                chatHistoryService.addChatMessage(appId, errorMessage, ChatHistoryMessageTypeEnum.AI.getValue(), loginUser.getId());
            });
}

关联删除 当应用被删除时,需要同步清理⁢对话历史数据。在 ChatHistoryServiceImpl 中提供根据 appId 删除的方法:

java
@Override
public boolean deleteByAppId(Long appId) {
    ThrowUtils.throwIf(appId == null || appId <= 0, ErrorCode.PARAMS_ERROR, "应用ID不能为空");
    QueryWrapper queryWrapper = QueryWrapper.create()
            .eq("appId", appId);
    return this.remove(queryWrapper);
}

然后在 AppServiceImpl 中重写 removeById 方法,实现关联删除:

java
/**
 * 删除应用时关联删除对话历史
 *
 * @param id 应用ID
 * @return 是否成功
 */
@Override
public boolean removeById(Serializable id) {
    if (id == null) {
        return false;
    }
    // 转换为 Long 类型
    Long appId = Long.valueOf(id.toString());
    if (appId <= 0) {
        return false;
    }
    // 先删除关联的对话历史
    try {
        chatHistoryService.deleteByAppId(appId);
    } catch (Exception e) {
        // 记录日志但不阻止应用删除
        log.error("删除应用关联对话历史失败: {}", e.getMessage());
    }
    // 删除应用
    return super.removeById(id);
}

这里采用了容错设计,即使对话⁢历史删除失败,也不⁠会阻止应用的删除操​作,只是记录错误日‍志,确保核心业务的稳定性。

游标查询 游标查询是本节的技术重点,开发时一定要仔细。

1)先在 model.dto.chathistory 包下新建包含游标字段的请求对象:

java
@EqualsAndHashCode(callSuper = true)
@Data
public class ChatHistoryQueryRequest extends PageRequest implements Serializable {

    /**
     * id
     */
    private Long id;

    /**
     * 消息内容
     */
    private String message;

    /**
     * 消息类型(user/ai)
     */
    private String messageType;

    /**
     * 应用id
     */
    private Long appId;

    /**
     * 创建用户id
     */
    private Long userId;

    /**
     * 游标查询 - 最后一条记录的创建时间
     * 用于分页查询,获取早于此时间的记录
     */
    private LocalDateTime lastCreateTime;

    private static final long serialVersionUID = 1L;
}

ChatHistoryServiceImpl 开发查询包装类的构造方法:

java
/**
 * 获取查询包装类
 *
 * @param chatHistoryQueryRequest
 * @return
 */
@Override
public QueryWrapper getQueryWrapper(ChatHistoryQueryRequest chatHistoryQueryRequest) {
    QueryWrapper queryWrapper = QueryWrapper.create();
    if (chatHistoryQueryRequest == null) {
        return queryWrapper;
    }
    Long id = chatHistoryQueryRequest.getId();
    String message = chatHistoryQueryRequest.getMessage();
    String messageType = chatHistoryQueryRequest.getMessageType();
    Long appId = chatHistoryQueryRequest.getAppId();
    Long userId = chatHistoryQueryRequest.getUserId();
    LocalDateTime lastCreateTime = chatHistoryQueryRequest.getLastCreateTime();
    String sortField = chatHistoryQueryRequest.getSortField();
    String sortOrder = chatHistoryQueryRequest.getSortOrder();
    // 拼接查询条件
    queryWrapper.eq("id", id)
            .like("message", message)
            .eq("messageType", messageType)
            .eq("appId", appId)
            .eq("userId", userId);
    // 游标查询逻辑 - 只使用 createTime 作为游标
    if (lastCreateTime != null) {
        queryWrapper.lt("createTime", lastCreateTime);
    }
    // 排序
    if (StrUtil.isNotBlank(sortField)) {
        queryWrapper.orderBy(sortField, "ascend".equals(sortOrder));
    } else {
        // 默认按创建时间降序排列
        queryWrapper.orderBy("createTime", false);
    }
    return queryWrapper;
}

ChatHistoryServiceImpl 编写核心的游标查询服务方法:

java
@Override
public Page<ChatHistory> listAppChatHistoryByPage(Long appId, int pageSize,
                                                  LocalDateTime lastCreateTime,
                                                  User loginUser) {
    ThrowUtils.throwIf(appId == null || appId <= 0, ErrorCode.PARAMS_ERROR, "应用ID不能为空");
    ThrowUtils.throwIf(pageSize <= 0 || pageSize > 50, ErrorCode.PARAMS_ERROR, "页面大小必须在1-50之间");
    ThrowUtils.throwIf(loginUser == null, ErrorCode.NOT_LOGIN_ERROR);
    // 验证权限:只有应用创建者和管理员可以查看
    App app = appService.getById(appId);
    ThrowUtils.throwIf(app == null, ErrorCode.NOT_FOUND_ERROR, "应用不存在");
    boolean isAdmin = UserConstant.ADMIN_ROLE.equals(loginUser.getUserRole());
    boolean isCreator = app.getUserId().equals(loginUser.getId());
    ThrowUtils.throwIf(!isAdmin && !isCreator, ErrorCode.NO_AUTH_ERROR, "无权查看该应用的对话历史");
    // 构建查询条件
    ChatHistoryQueryRequest queryRequest = new ChatHistoryQueryRequest();
    queryRequest.setAppId(appId);
    queryRequest.setLastCreateTime(lastCreateTime);
    QueryWrapper queryWrapper = this.getQueryWrapper(queryRequest);
    // 查询数据
    return this.page(Page.of(1, pageSize), queryWrapper);
}

最后在 ChatHistoryController 开发游标查询接口:

java
/**
 * 分页查询某个应用的对话历史(游标查询)
 *
 * @param appId          应用ID
 * @param pageSize       页面大小
 * @param lastCreateTime 最后一条记录的创建时间
 * @param request        请求
 * @return 对话历史分页
 */
@GetMapping("/app/{appId}")
public BaseResponse<Page<ChatHistory>> listAppChatHistory(@PathVariable Long appId,
                                                          @RequestParam(defaultValue = "10") int pageSize,
                                                          @RequestParam(required = false) LocalDateTime lastCreateTime,
                                                          HttpServletRequest request) {
    User loginUser = userService.getLoginUser(request);
    Page<ChatHistory> result = chatHistoryService.listAppChatHistoryByPage(appId, pageSize, lastCreateTime, loginUser);
    return ResultUtils.success(result);
}

管理员查询功能

管理员可以分页查询所有应用的⁢对话历史消息列表,⁠按照时间降序排序。这个功能比较简单,直接开发分⁢页查询接口就好: ⁠

java
/**
 * 管理员分页查询所有对话历史
 *
 * @param chatHistoryQueryRequest 查询请求
 * @return 对话历史分页
 */
@PostMapping("/admin/list/page/vo")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Page<ChatHistory>> listAllChatHistoryByPageForAdmin(@RequestBody ChatHistoryQueryRequest chatHistoryQueryRequest) {
    ThrowUtils.throwIf(chatHistoryQueryRequest == null, ErrorCode.PARAMS_ERROR);
    long pageNum = chatHistoryQueryRequest.getPageNum();
    long pageSize = chatHistoryQueryRequest.getPageSize();
    // 查询数据
    QueryWrapper queryWrapper = chatHistoryService.getQueryWrapper(chatHistoryQueryRequest);
    Page<ChatHistory> result = chatHistoryService.page(Page.of(pageNum, pageSize), queryWrapper);
    return ResultUtils.success(result);
}

接下来就可以测试了,如果在开发过程中遇到循环依赖问题导致项目无法启动,可以使用 @Lazy 注解解决。比如在 ChatHistoryServiceImpl 引入 AppService 时添加注解:

java
@Resource
@Lazy
private AppService appService;

对话历史的前端代码也是使用Vibe Coding 生成的,先执行 openapi 命令,根据接口生成前端请求和数据模型代码。

这次的前端开发量并不大,核心就 2 点:

  • 1、修改应用对话页面,加载对话历史
  • 2、新增对话管理页面

可以直接交给 AI 来生成:

提示词

你是一位专业的前端开发,帮我根据下列信息,参考项目已有的代码风格,生成符合要求的完整代码。

## 需求

1)修改应用对话页面。
- 进入应用对话页面时,前端调用游标查询对话历史接口,根据应用 id 加载一次最近 10 条对话历史消息,按照消息创建时间的升序展示在对话区域(区分 AI 和用户消息)。
- 如果消息数量超过 10 条(10 条为一页),可以点消息上方的加载更多,利用游标加载下一页历史消息。(我想前端需要维护已加载的消息列表)
- 修改自动发送初始消息的逻辑。移除之前页面 url 的 view 参数,如果是自己的 app,并且没有对话历史,才自动将 initPrompt 作为第一条消息触发对话。
- 修改网站展示的逻辑。进入页面时,如果 app 有至少 2 条对话记录,也展示对应的网站。

2)新增对话管理页面。完全参考应用管理页面实现

## 后端接口

已经在 @api 目录下生成了后端请求代码和数据类型信息。

对话记忆

很多时候 AI 生成的网站没⁢办法一次性满足用户⁠的需求,因此需要提​供网站修改功能。

但是目前我们的 AI 对话会断片儿⁢,无法记住之前的对话内⁠容,每次修改实际上都是​重新生成完整的网站,而‍不是在原有基础上进行修改,要解决这个问题,就要给 AI 增加对话记忆能力。

方案设计

LangChain4j ⁢提供了对话记忆能力⁠,而且还能结合 R​edis 持久化对‍话记忆

方案很简单,之前我们已经在数据库中保存了用⁢户和 AI 的消息,只需要在⁠初始化会话记忆时,加载最新的​对话记录到 Redis 中,‍就能确保 AI 了解交互历史。

流程:AI对话 => 从数⁢据库中加载对话历史⁠到 Redis =​> Redis 为‍ AI 提供对话记忆

开发实现

参考 LangChain4j 官方文档,引入必要的依赖:

xml
<dependency>
  <groupId>dev.langchain4j</groupId>
  <artifactId>langchain4j-community-redis-spring-boot-starter</artifactId>
  <version>1.1.0-beta7</version>
</dependency>

这个依赖会引入 Redis ⁢的 Jedis 客⁠户端,以及与 La​ngChain4j 的‍整合组件。

在配置文件中添加 Redis 连接信息:

yaml
spring:
  # redis
  data:
    redis:
      host: localhost
      port: 6379
      password: 
      ttl: 3600

注意,这里的 ttl 不是连接 Redis 的超时时间(timeout),而是过期时间(单位:秒),我这里设置 1 小时。前面也提到,如果消息数比较多,不设置的话 Redis 内存很容易占满。

config 下新建 Redis 对话记忆存储配置类,初始化 RedisChatMemoryStoreBean

java
@Configuration
@ConfigurationProperties(prefix = "spring.data.redis")
@Data
public class RedisChatMemoryStoreConfig {

    private String host;

    private int port;

    private String password;

    private long ttl;

    @Bean
    public RedisChatMemoryStore redisChatMemoryStore() {
        return RedisChatMemoryStore.builder()
                .host(host)
                .port(port)
                .password(password)
                .ttl(ttl)
                .build();
    }
}

注意,如果你的 Redis ⁢密码不为空,上述配⁠置中还要添加 us​er 用户名配置:

java
RedisChatMemoryStore.builder()
    .user("你的用户名")

如果你的密码为空,那么就不用添加了,根据情况选择就好。

3)在启动类中排除 embe⁢dding 的自动⁠装配,因为本项目用​不到:

java
@SpringBootApplication(exclude = {RedisEmbeddingStoreAutoConfiguration.class})
对话隔离

不同 appId 的对话记忆⁢是独立隔离的,利用⁠ LangChai​n4j 可以有 2 种‍实现方案。

方案1-内置隔离机制

参考 官方文档,可以给 AI 服务方法增加 memoryId 注解和参数,然后通过 chatMemoryProvider 为每个 appId 分配对话记忆。

由于我们目前使用的是同一个 ⁢AiService⁠ 实例,先采用这种​方式,好像修改‍成本更低。

修改 AiService 方法:

java
interface AiCodeGeneratorService  {
    HtmlCodeResult generateHtmlCode(@MemoryId int memoryId, @UserMessage String userMessage);
}

在工厂类中创建 AI Service 时,我们必须通过 chatMemoryProvider 为每个 memoryId 来构造专属的 MessageWindowChatMemory 。注意,必须为 MessageWindowChatMemory 设置 id,因为使用的是同一个 Redis 存储实例,否则 Redis 中的存储 key 都是 default,无法区分不同的对话。

java
private final RedisChatMemoryStore redisChatMemoryStore;

@Bean
public AiCodeGeneratorService aiCodeGeneratorService() {
    return AiServices.builder(AiCodeGeneratorService.class)
            .chatModel(chatModel)
            .streamingChatModel(streamingChatModel)
            // 根据 id 构建独立的对话记忆
            .chatMemoryProvider(memoryId -> MessageWindowChatMemory
                    .builder()
                    .id(memoryId)
                    .chatMemoryStore(redisChatMemoryStore)
                    .maxMessages(20)
                    .build())
            .build();
}

现在调用 AI 生成方法时,要多传一个 id 参数:

java
generateHtmlCode(1, "生成博客网站");
generateHtmlCode(2, "生成电商网站");

编写测试用例

java
@Test
void testChatMemory() {
    HtmlCodeResult result = aiCodeGeneratorService.generateHtmlCode(1, "做个程序员鱼皮的工具网站,总代码量不超过 20 行");
    Assertions.assertNotNull(result);
    result = aiCodeGeneratorService.generateHtmlCode(1, "不要生成网站,告诉我你刚刚做了什么?");
    Assertions.assertNotNull(result);
    result = aiCodeGeneratorService.generateHtmlCode(2, "做个程序员鱼皮的工具网站,总代码量不超过 20 行");
    Assertions.assertNotNull(result);
    result = aiCodeGeneratorService.generateHtmlCode(2, "不要生成网站,告诉我你刚刚做了什么?");
    Assertions.assertNotNull(result);
}
方案2- AI Service隔离

之前所有应用共用同一个 AI Service 实⁢例,如果想隔离会话记忆,可以给每⁠个应用分配一个专属的 AI Se​rvice,每个 AI Serv‍ice 绑定独立的对话记忆。

修改 AI Service ⁢工厂类,提供根据 ⁠appId 获取 ​AI Servic‍e 服务的方法:

java
@Configuration
public class AiCodeGeneratorServiceFactory {

    @Resource
    private ChatModel chatModel;

    @Resource
    private StreamingChatModel streamingChatModel;

    @Resource
    private RedisChatMemoryStore redisChatMemoryStore;

    /**
     * 根据 appId 获取服务
     */
    public AiCodeGeneratorService getAiCodeGeneratorService(long appId) {
        // 根据 appId 构建独立的对话记忆
        MessageWindowChatMemory chatMemory = MessageWindowChatMemory
                .builder()
                .id(appId)
                .chatMemoryStore(redisChatMemoryStore)
                .maxMessages(20)
                .build();
        return AiServices.builder(AiCodeGeneratorService.class)
                .chatModel(chatModel)
                .streamingChatModel(streamingChatModel)
                .chatMemory(chatMemory)
                .build();
    }
}

为了保证跟之前的代码兼容,仍⁢然默认提供一个 A⁠I Service​ 的 Bean :

java
/**
 * 默认提供一个 Bean
 */
@Bean
public AiCodeGeneratorService aiCodeGeneratorService() {
    return getAiCodeGeneratorService(0L);
}

基于方案 2,我们可以利用 Caffeine 本地缓存 进一步优化性能。

每次构造完 appId 对应的 AI 服务实例后,利用 Caff⁢eine 缓存来存储,之后相同 appId⁠ 就能直接获取到 AI 服务实例,避免重​复构造。注意,本地缓存占用的是内存,所以必须‍设置合理的过期策略防止内存泄漏。先引入 Caffeine 依⁢赖:       ⁠

xml
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

优化 AiCodeGener⁢atorServi⁠ceFactory ​,增加缓存逻辑:

java
/**
 * AI 服务实例缓存
 * 缓存策略:
 * - 最大缓存 1000 个实例
 * - 写入后 30 分钟过期
 * - 访问后 10 分钟过期
 */
private final Cache<Long, AiCodeGeneratorService> serviceCache = Caffeine.newBuilder()
        .maximumSize(1000)
        .expireAfterWrite(Duration.ofMinutes(30))
        .expireAfterAccess(Duration.ofMinutes(10))
        .removalListener((key, value, cause) -> {
            log.debug("AI 服务实例被移除,appId: {}, 原因: {}", key, cause);
        })
        .build();

/**
 * 根据 appId 获取服务(带缓存)
 */
public AiCodeGeneratorService getAiCodeGeneratorService(long appId) {
    return serviceCache.get(appId, this::createAiCodeGeneratorService);
}

/**
 * 创建新的 AI 服务实例
 */
private AiCodeGeneratorService createAiCodeGeneratorService(long appId) {
    log.info("为 appId: {} 创建新的 AI 服务实例", appId);
    // 根据 appId 构建独立的对话记忆
    MessageWindowChatMemory chatMemory = MessageWindowChatMemory
            .builder()
            .id(appId)
            .chatMemoryStore(redisChatMemoryStore)
            .maxMessages(20)
            .build();
    return AiServices.builder(AiCodeGeneratorService.class)
            .chatModel(chatModel)
            .streamingChatModel(streamingChatModel)
            .chatMemory(chatMemory)
            .build();
}

getAiCodeGeneratorService 方法现在的作用就是根据 appId 获取 AI 服务实例:缓存里有就直接拿,没有就创建一个新的,并且自动存进缓存

最后修改 AiCodeGeneratorFacade,所有方法使用的 AI Service 改为通过工厂根据 appId 获取 AI Service

java
@Resource
private AiCodeGeneratorServiceFactory aiCodeGeneratorServiceFactory;

// 根据 appId 获取对应的 AI 服务实例
AiCodeGeneratorService aiCodeGeneratorService = aiCodeGeneratorServiceFactory.getAiCodeGeneratorService(appId);

使用这种方案,不需要改动 A⁢I Service⁠ 本身的代码,只是在创建aiCodeGeneratorService 示例的时候传入appId,根据appId隔离AI Service的实例

两种方案对比
  • 方案一:共用同一个AI Service实例,不同的对话通过 chatMemoryProvider 提供的 memoryId 进行区分隔离

  • 方案二:根据不同应用的 appId 创建不同的 AI Service 实例,配合 Caffeine 缓存来提高性能,每个应用都有自己独立的 AI Service 实例

总结:方案二的隔离性、可扩展性都更好,适合多租户,多应用的场景下使用,所以我们主要采用方案二

历史对话加载

根据方案,对话记忆初始化时,⁢需要从数据库中加载⁠对话历史到记忆中。

ChatHistoryService 中开发加载方法:

java
@Override
public int loadChatHistoryToMemory(Long appId, MessageWindowChatMemory chatMemory, int maxCount) {
    try {
        // 直接构造查询条件,起始点为 1 而不是 0,用于排除最新的用户消息
        QueryWrapper queryWrapper = QueryWrapper.create()
                .eq(ChatHistory::getAppId, appId)
                .orderBy(ChatHistory::getCreateTime, false)
                .limit(1, maxCount);
        List<ChatHistory> historyList = this.list(queryWrapper);
        if (CollUtil.isEmpty(historyList)) {
            return 0;
        }
        // 反转列表,确保按时间正序(老的在前,新的在后)
        historyList = historyList.reversed();
        // 按时间顺序添加到记忆中
        int loadedCount = 0;
        // 先清理历史缓存,防止重复加载
        chatMemory.clear();
        for (ChatHistory history : historyList) {
            if (ChatHistoryMessageTypeEnum.USER.getValue().equals(history.getMessageType())) {
                chatMemory.add(UserMessage.from(history.getMessage()));
                loadedCount++;
            } else if (ChatHistoryMessageTypeEnum.AI.getValue().equals(history.getMessageType())) {
                chatMemory.add(AiMessage.from(history.getMessage()));
                loadedCount++;
            }
        }
        log.info("成功为 appId: {} 加载了 {} 条历史对话", appId, loadedCount);
        return loadedCount;
    } catch (Exception e) {
        log.error("加载历史对话失败,appId: {}, error: {}", appId, e.getMessage(), e);
        // 加载失败不影响系统运行,只是没有历史上下文
        return 0;
    }
}

注意上述代码中的几个重要细节:

  • 查询起始点设置为 1 而不是 0,这是为了排除最新的用户消息。因为在对话流程中,用户消息被添加到数据库后,AI 服务也会自动将用户消息添加到记忆中,如果不排除会导致消息重复。
  • 注意反转从数据库中查到的消息列表,确保加载到记忆中的消息是按时间正序的。
  • 加载前先清理 Redis 中的历史对话记忆,防止重复加载。

然后就可以在初始化 AI Se⁢rvice 的对话记⁠忆时调用了,这相当于​是懒加载,对话时才会‍加载记忆,节约内存。

java
private AiCodeGeneratorService createAiCodeGeneratorService(long appId) {
    log.info("为 appId: {} 创建新的 AI 服务实例", appId);
    // 根据 appId 构建独立的对话记忆
    MessageWindowChatMemory chatMemory = MessageWindowChatMemory
            .builder()
            .id(appId)
            .chatMemoryStore(redisChatMemoryStore)
            .maxMessages(20)
            .build();
    // 从数据库加载历史对话到记忆中
    chatHistoryService.loadChatHistoryToMemory(appId, chatMemory, 20);
    return AiServices.builder(AiCodeGeneratorService.class)
            .chatModel(chatModel)
            .streamingChatModel(streamingChatModel)
            .chatMemory(chatMemory)
            .build();
}

整个流程总结:

plaintext
用户发消息

根据 AppId 获取/创建 AI 实例

【创建时 → 加载数据库历史 → 写入Redis】

AI 带着记忆回答

新对话自动保存到 Redis + 数据库

完成

效果测试

现在进行了多轮对话,可以看到 AI 知道了历史聊天记录,并且正确的对网站完成了迭代和修改

历史聊天记录也成功的存入到了 redis 中,然后在每次调用实例的时候都会自动加载最新的聊天记录到 redis 中,供AI使用

优化登录Session

既然已经整合了 Redis,我们可以顺便优化一下⁢用户登录态的管理。之前每次重启服⁠务器都需要重新登录,现在可以使用​ Redis 管理 Sessio‍n 登录态,实现分布式会话管理。

操作方式也很简单,1 分钟就能完成。

1)先在 Maven 中引入 spring-session-data-redis 库:

xml
<!-- Spring Session + Redis -->
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>

2)修改 application.yml 配置文件,更改 Session 的存储方式和过期时间:

yaml
spring: 
  # session 配置
  session:
    store-type: redis
    # session 30 天过期
    timeout: 2592000
server:
  port: 8123
  servlet:
    context-path: /api
    # cookie 30 天过期
    session:
      cookie:
        max-age: 2592000

这就搞定了,现在用户的登录状态会保存⁢在 Redis 中。重启⁠服务器后,不需要重新登陆​,并且在 Redis 中‍可以看到登录相关的 key:

上次更新于: