feat(codegen): 代码生成新增 AI 配置推断并迁移模板资源

- 模板与外部文件移至 resources/codegen、excel、ip2region
- 新增 AI 配置推断接口、表单与返回对象
- 字典项与菜单 SQL、IP 工具、日志配置调整
- docker-compose 与英文 README 更新
- 版本 4.11.0
This commit is contained in:
Ray.Hao
2026-09-26 09:13:16 +08:00
parent 9fc49583d9
commit 607b6c7bb3
40 changed files with 313 additions and 85 deletions

View File

@@ -8,6 +8,7 @@ import com.youlai.boot.codegen.property.CodegenProperties;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.codegen.service.CodegenService;
import com.youlai.boot.codegen.model.form.AiFillConfigForm;
import com.youlai.boot.codegen.model.form.GenConfigForm;
import com.youlai.boot.codegen.model.query.TableQuery;
import com.youlai.boot.codegen.model.vo.CodegenPreviewVO;
@@ -111,4 +112,16 @@ public class CodegenController {
throw new BusinessException("代码生成压缩包响应写入失败", e);
}
}
// AI 辅助:未开启 AI 时调用返回业务异常
@Operation(summary = "AI 填充代码生成配置")
@PostMapping("/{tableName}/ai-config")
public Result<GenConfigForm> fillConfig(
@Parameter(description = "表名", example = "sys_user") @PathVariable String tableName,
@RequestBody(required = false) @Valid AiFillConfigForm form
) {
String requirement = form == null ? null : form.getRequirement();
return Result.success(genTableService.aiFillConfig(tableName, requirement));
}
}

View File

@@ -53,7 +53,7 @@ public class GenTable extends BaseEntity {
private String author;
/**
* 页面类型 classic|curd
* 页面类型 classic|crud
*/
private String pageType;

View File

@@ -0,0 +1,18 @@
package com.youlai.boot.codegen.model.form;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* AI 填充生成配置入参
*
* @author Ray.Hao
* @since 0.1.0
*/
@Schema(description = "AI 填充生成配置入参")
@Data
public class AiFillConfigForm {
@Schema(description = "补充需求描述,可为空", example = "状态字段用下拉框,创建时间不出现在表单")
private String requirement;
}

View File

@@ -19,36 +19,36 @@ import java.util.List;
@Data
public class GenConfigForm {
@Schema(description = "主键",example = "1")
@Schema(description = "主键", example = "1")
private Long id;
@Schema(description = "表名",example = "sys_user")
@Schema(description = "表名", example = "sys_user")
@NotBlank(message = "表名不能为空")
private String tableName;
@Schema(description = "业务名",example = "用户")
@Schema(description = "业务名", example = "用户")
@NotBlank(message = "业务名不能为空")
private String businessName;
@Schema(description = "模块名",example = "system")
@Schema(description = "模块名", example = "system")
@NotBlank(message = "模块名不能为空")
@Pattern(regexp = "[a-zA-Z][a-zA-Z0-9_]*", message = "模块名只能包含字母、数字和下划线,且以字母开头")
private String moduleName;
@Schema(description = "包名",example = "com.youlai")
@Schema(description = "包名", example = "com.youlai")
@NotBlank(message = "包名不能为空")
@Pattern(regexp = "([a-zA-Z_][a-zA-Z0-9_]*\\.)*[a-zA-Z_][a-zA-Z0-9_]*", message = "包名格式不正确")
private String packageName;
@Schema(description = "实体名",example = "User")
@Schema(description = "实体名", example = "User")
@NotBlank(message = "实体名不能为空")
@Pattern(regexp = "[a-zA-Z][a-zA-Z0-9]*", message = "实体名只能包含字母和数字,且以字母开头")
private String entityName;
@Schema(description = "作者",example = "youlaitech")
@Schema(description = "作者", example = "youlaitech")
private String author;
@Schema(description = "上级菜单ID",example = "1")
@Schema(description = "上级菜单ID", example = "1")
private Long parentMenuId;
@Schema(description = "字段配置列表")
@@ -60,7 +60,7 @@ public class GenConfigForm {
@Schema(description = "前端应用名")
private String frontendAppName;
@Schema(description = "页面类型 classic|curd", example = "classic")
@Schema(description = "页面类型 classic|crud", example = "classic")
private String pageType;
@Schema(description = "要移除的表前缀,如: sys_", example = "sys_")

View File

@@ -0,0 +1,55 @@
package com.youlai.boot.codegen.model.vo;
import lombok.Data;
import java.util.List;
/**
* AI 推断的代码生成配置
*
* @author Ray.Hao
* @since 0.1.0
*/
@Data
public class GenConfigAiVO {
/** 业务名 */
private String businessName;
/** 字段配置列表 */
private List<FieldAiVO> fieldConfigs;
/**
* AI 推断的字段配置
*/
@Data
public static class FieldAiVO {
/** 列名,须与入参一致 */
private String columnName;
/** 字段描述 */
private String fieldComment;
/** 表单类型枚举名 */
private String formType;
/** 查询类型枚举名 */
private String queryType;
/** 是否必填 */
private Integer isRequired;
/** 是否在列表显示 */
private Integer isShowInList;
/** 是否在表单显示 */
private Integer isShowInForm;
/** 是否在查询条件显示 */
private Integer isShowInQuery;
/** 字典类型编码 */
private String dictType;
}
}

View File

@@ -58,7 +58,7 @@ public class CodegenProperties {
public static class TemplateConfig {
/**
* 模板路径 (e.g. /templates/codegen/controller.java.vm)
* 模板路径,相对模板根 classpath:codegen (e.g. backend/controller.java.vm)
*/
private String templatePath;

View File

@@ -36,4 +36,13 @@ public interface GenTableService extends IService<GenTable> {
*/
void deleteGenConfig(String tableName);
/**
* 用 AI 推断代码生成配置,未推断出的字段沿用原有默认值
*
* @param tableName 表名
* @param requirement 补充需求描述,可为空
* @return 回填了 AI 推断结果的配置
*/
GenConfigForm aiFillConfig(String tableName, String requirement);
}

View File

@@ -85,10 +85,10 @@ public class CodegenServiceImpl implements CodegenService {
return templateConfig.getTemplatePath();
}
if ("API".equals(templateName)) {
return "codegen/frontend/js/api.js.vm";
return "frontend/js/api.js.vm";
}
if ("VIEW".equals(templateName)) {
return "codegen/frontend/js/index.js.vue.vm";
return "frontend/js/index.js.vue.vm";
}
return templateConfig.getTemplatePath();
}
@@ -352,14 +352,14 @@ public class CodegenServiceImpl implements CodegenService {
bindMap.put("hasBigDecimal", hasBigDecimal);
bindMap.put("hasRequiredField", hasRequiredField);
TemplateEngine templateEngine = TemplateUtil.createEngine(new TemplateConfig("templates", TemplateConfig.ResourceMode.CLASSPATH));
// 根据 ui 选择不同的前端页面模板:默认 index.vue.vm;封装版使用 index.curd.vue.vm
TemplateEngine templateEngine = TemplateUtil.createEngine(new TemplateConfig("codegen", TemplateConfig.ResourceMode.CLASSPATH));
// 根据 ui 选择不同的前端页面模板:默认 index.vue.vm;封装版使用 index.crud.vue.vm
String path = templatePath;
if ("curd".equalsIgnoreCase(pageType)) {
if ("crud".equalsIgnoreCase(pageType)) {
if (path.endsWith("index.js.vue.vm")) {
path = path.replace("index.js.vue.vm", "index.curd.js.vue.vm");
path = path.replace("index.js.vue.vm", "index.crud.js.vue.vm");
} else if (path.endsWith("index.vue.vm")) {
path = path.replace("index.vue.vm", "index.curd.vue.vm");
path = path.replace("index.vue.vm", "index.crud.vue.vm");
}
}
Template template = templateEngine.getTemplate(path);

View File

@@ -3,6 +3,7 @@ package com.youlai.boot.codegen.service.impl;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.youlai.boot.YouLaiBootApplication;
@@ -20,8 +21,11 @@ import com.youlai.boot.codegen.model.vo.TableMetaVO;
import com.youlai.boot.codegen.model.entity.GenTable;
import com.youlai.boot.codegen.model.entity.GenTableColumn;
import com.youlai.boot.codegen.model.form.GenConfigForm;
import com.youlai.boot.codegen.model.vo.GenConfigAiVO;
import com.youlai.boot.codegen.service.GenTableService;
import com.youlai.boot.codegen.service.GenTableColumnService;
import com.youlai.boot.support.ai.AiChatService;
import com.youlai.boot.support.ai.PromptLoader;
import com.youlai.boot.system.model.dto.MenuCreateDTO;
import com.youlai.boot.system.service.MenuService;
import lombok.RequiredArgsConstructor;
@@ -29,9 +33,14 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 数据库服务实现类
@@ -43,10 +52,15 @@ import java.util.Objects;
@RequiredArgsConstructor
public class GenTableServiceImpl extends ServiceImpl<GenTableMapper, GenTable> implements GenTableService {
/** 系统提示词名称,对应 resources/prompts 下的相对路径 */
private static final String SYSTEM_PROMPT_PATH = "codegen/system.md";
private final DatabaseMapper databaseMapper;
private final CodegenProperties codegenProperties;
private final GenTableColumnService genTableColumnService;
private final CodegenConverter codegenConverter;
private final AiChatService aiChatService;
private final PromptLoader promptLoader;
@Value("${spring.profiles.active}")
private String springProfilesActive;
@@ -229,6 +243,137 @@ public class GenTableServiceImpl extends ServiceImpl<GenTableMapper, GenTable> i
}
}
//---------------------------------------------------
// AI 辅助:未开启 AI 时调用返回业务异常
//---------------------------------------------------
/**
* 用 AI 推断代码生成配置
*
* @param tableName 表名 eg: sys_user
* @param requirement 补充需求描述,可为空
* @return 回填了 AI 推断结果的配置
*/
@Override
public GenConfigForm aiFillConfig(String tableName, String requirement) {
GenConfigForm config = getGenTableFormData(tableName);
String systemPrompt = promptLoader.render(SYSTEM_PROMPT_PATH, buildAiPromptVariables());
GenConfigAiVO aiConfig = aiChatService.chatEntity(systemPrompt, buildAiUserPrompt(config, requirement), GenConfigAiVO.class);
if (StrUtil.isNotBlank(aiConfig.getBusinessName())) {
config.setBusinessName(aiConfig.getBusinessName().trim());
}
applyAiFieldConfigs(config, aiConfig.getFieldConfigs());
return config;
}
/**
* 构造系统提示词的变量值,表单类型与查询类型清单取自枚举,新增枚举值无需改动提示词
*
* @return 变量值
*/
private Map<String, String> buildAiPromptVariables() {
String formTypes = Arrays.stream(FormTypeEnum.values())
.map(type -> type.name() + "(" + type.getLabel() + ")")
.collect(Collectors.joining("|"));
String queryTypes = Arrays.stream(QueryTypeEnum.values())
.map(Enum::name)
.collect(Collectors.joining("|"));
return Map.of("formTypes", formTypes, "queryTypes", queryTypes);
}
/**
* 按列名匹配回填字段配置
*
* @param config 原配置
* @param aiFields AI 返回的字段列表
*/
private void applyAiFieldConfigs(GenConfigForm config, List<GenConfigAiVO.FieldAiVO> aiFields) {
if (CollectionUtil.isEmpty(aiFields) || CollectionUtil.isEmpty(config.getFieldConfigs())) {
return;
}
Map<String, GenConfigAiVO.FieldAiVO> aiFieldMap = aiFields.stream()
.filter(field -> StrUtil.isNotBlank(field.getColumnName()))
.collect(Collectors.toMap(GenConfigAiVO.FieldAiVO::getColumnName, Function.identity(),
(existing, replacement) -> existing, LinkedHashMap::new));
config.getFieldConfigs().forEach(field -> {
GenConfigAiVO.FieldAiVO aiField = aiFieldMap.get(field.getColumnName());
if (aiField == null) {
return;
}
if (StrUtil.isNotBlank(aiField.getFieldComment())) {
field.setFieldComment(aiField.getFieldComment().trim());
}
if (StrUtil.isNotBlank(aiField.getDictType())) {
field.setDictType(aiField.getDictType().trim());
}
field.setFormType(parseAiEnum(aiField.getFormType(), FormTypeEnum::valueOf, field.getFormType()));
field.setQueryType(parseAiEnum(aiField.getQueryType(), QueryTypeEnum::valueOf, field.getQueryType()));
field.setIsRequired(resolveAiValue(aiField.getIsRequired(), field.getIsRequired()));
field.setIsShowInList(resolveAiValue(aiField.getIsShowInList(), field.getIsShowInList()));
field.setIsShowInForm(resolveAiValue(aiField.getIsShowInForm(), field.getIsShowInForm()));
field.setIsShowInQuery(resolveAiValue(aiField.getIsShowInQuery(), field.getIsShowInQuery()));
});
}
/**
* 解析枚举名,非法或未给出时沿用原值
*
* @param name 枚举名
* @param parser 枚举解析器
* @param fallback 原值
* @return 枚举或原值
*/
private <T> T parseAiEnum(String name, Function<String, T> parser, T fallback) {
if (StrUtil.isBlank(name)) {
return fallback;
}
try {
return parser.apply(name.trim());
} catch (IllegalArgumentException e) {
return fallback;
}
}
/**
* AI 值缺失时沿用原值
*
* @param aiValue AI 值
* @param fallback 原值
* @return 生效值
*/
private <T> T resolveAiValue(T aiValue, T fallback) {
return aiValue != null ? aiValue : fallback;
}
/**
* 构造用户提示词
*
* @param config 当前配置
* @param requirement 补充需求描述
* @return 用户提示词
*/
private String buildAiUserPrompt(GenConfigForm config, String requirement) {
List<Map<String, Object>> columns = new ArrayList<>();
if (CollectionUtil.isNotEmpty(config.getFieldConfigs())) {
config.getFieldConfigs().forEach(field -> {
Map<String, Object> column = new LinkedHashMap<>();
column.put("columnName", field.getColumnName());
column.put("columnType", field.getColumnType());
column.put("fieldComment", field.getFieldComment());
column.put("isRequired", field.getIsRequired());
columns.add(column);
});
}
return """
表名:%s
当前业务名:%s
补充说明:%s
字段列表:
%s
""".formatted(config.getTableName(), config.getBusinessName(),
StrUtil.blankToDefault(requirement, "无"), JSONUtil.toJsonStr(columns));
}
}

View File

@@ -25,7 +25,7 @@ import java.nio.file.StandardCopyOption;
@Component
public class IPUtils {
private static final String DB_PATH = "/data/ip2region.xdb";
private static final String DB_PATH = "/ip2region/ip2region.xdb";
private static Searcher searcher;
@PostConstruct

View File

@@ -28,7 +28,7 @@ import java.util.stream.Collectors;
public class RoleMenuServiceImpl extends ServiceImpl<RoleMenuMapper, RoleMenu> implements RoleMenuService {
/**
* 权限缓存TTL:与 Caffeine 菜单缓存 expireAfterWrite=600s 对齐。
* 权限缓存 TTL(秒)。
* Hash 写入默认永不过期,直接改库(SQL 脚本/手工修数)不触发任何刷新点时会永久陈旧;
* 加 TTL 后过期整 Key 消失,Read-Through 按角色逐个回源重建,实现最终一致自愈
*/