应用导出
应用导出
业务用途
「应用导出」是本平台最强大的特性之一。它把平台里某个应用(多张设计器建的表 + 字典 + 菜单关系)渲染成一个可独立编译、独立运行、脱离平台的工程压缩包(zip)。客户拿到 zip 解压后,直接 mvn spring-boot:run + pnpm dev 就能跑起一套带 RBAC、JWT、MyBatis-Plus、动态 CRUD 后台 + Vue3 前端的完整系统。
一句话总结能力边界:
| 导出形态 | 后端 | 后台前端 | 移动端 | 适用场景 |
|---|---|---|---|---|
| 单体 SpringBoot | 单工程 backend/ | admin-ui/(Vue3+Vite) | 可选 | 中小应用,一个 jar 搞定 |
| 多模块 Maven | parent + system + biz-<group> + bootstrap | admin-ui/ | 可选 | 大型应用,按业务域拆模块 |
| 仅移动端 | 不出 | 不出 | mobile/(uni-app) | 只想要移动端壳 |
移动端导出(H5 / 小程序)的细节见 ,导出后的目录树见 。
涉及文件
| 角色 | 文件路径 |
|---|---|
| 导出控制器 | backend/src/main/java/com/lowcode/export/ApplicationExportController.java |
| 导出编排服务 | backend/src/main/java/com/lowcode/export/AppExportService.java |
| 元数据构建器 | backend/src/main/java/com/lowcode/export/EntityMetaBuilder.java |
| 实体元数据 | backend/src/main/java/com/lowcode/export/EntityMeta.java |
| 导出选项 | backend/src/main/java/com/lowcode/export/ExportOptions.java |
| FreeMarker 模板根 | backend/src/main/resources/templates/export/ |
| 前端 API | frontend/src/api/application.ts |
后端实现(关键代码)
导出端点一览
ApplicationExportController 挂在 /api/application 下,5 个端点(仅平台管理员可用):
| 方法 | 路径 | exportMode | 说明 |
|---|---|---|---|
POST | /api/application/{appId}/export-springboot | single | 单体 SpringBoot 工程 zip |
POST | /api/application/{appId}/export-springboot-multi | multiModule | 多模块 Maven 工程 zip |
POST | /api/application/{appId}/export-mobile-app | single + mobilePlatform=h5 | 后端 + admin-ui + uni-app(H5) |
POST | /api/application/{appId}/export-mobile-mp | single + mobilePlatform=mp-weixin | 后端 + admin-ui + uni-app(小程序) |
GET | /api/application/{appId}/export-defaults | - | 预览 ExportOptions 默认值(前端填表用) |
四个导出端点都接收可选的 ExportOptions(请求体),为空时新建默认对象;端点内部会强制覆盖 exportMode / mobilePlatform,再统一走私有方法 doExport(...)。
控制器
// ApplicationExportController.java
@RestController
@RequestMapping("/api/application")
public class ApplicationExportController {
@Autowired private AppExportService exportService;
@PostMapping("/{appId}/export-springboot")
public void exportSpringBoot(@PathVariable Long appId,
@RequestBody(required = false) ExportOptions opts,
HttpServletResponse response) throws Exception {
if (opts == null) opts = new ExportOptions();
opts.setExportMode("single");
doExport(appId, opts, "-springboot.zip", response);
}
@PostMapping("/{appId}/export-springboot-multi")
public void exportSpringBootMulti(@PathVariable Long appId,
@RequestBody(required = false) ExportOptions opts,
HttpServletResponse response) throws Exception {
if (opts == null) opts = new ExportOptions();
opts.setExportMode("multiModule");
doExport(appId, opts, "-springboot-multi.zip", response);
}
@GetMapping("/{appId}/export-defaults")
public Result<ExportOptions> defaults(@PathVariable Long appId) {
ExportOptions opts = new ExportOptions();
opts.normalize(exportService.applicationServiceForDefault(appId));
return Result.success(opts);
}
}doExport 调 exportService.export(appId, opts) 拿到临时 zip 路径,按 artifactId + suffix 设 Content-Disposition(UTF-8 编码文件名),用 Files.copy 流式写回响应;finally 删除临时 zip。失败时写 500 + JSON 错误体。
ExportOptions 全字段
ExportOptions 故意不使用 Lombok(手写 getter/setter),避免 annotation processor 配置差异导致导出工程编译失败。字段如下:
| 字段 | 默认值 | 说明 |
|---|---|---|
basePackage | 由 appCode 推导 com.example.<code> | Java 根包 |
groupId | com.example | Maven groupId |
artifactId | appCode 小写转短横 | Maven artifactId |
version | 1.0.0 | 版本号 |
appName / appClassName | 应用名 / 类名 | 启动类名 <AppClassName>Application |
dbName | <appCode>_db | 导出 application.yml 里的库名 |
serverPort | 8080 | 导出后端端口(注意不是平台 52856) |
jwtSecret | randomHex(64) 随机 | JWT 密钥,每次导出生成 |
includeBizData | false | 是否把当前业务表数据导成 INSERT IGNORE 种子 |
exportMode | single | single / multiModule |
mobilePlatform | 空 | h5 / mp-weixin / mp-alipay;空则不出移动端 |
miniProgramAppId | touristappid | 小程序 AppID(写入 manifest.json) |
mobileApiBase | /api | 移动端 H5 构建时的后端 API 基础地址 |
mobileOnly | false | 仅导出 uni-app,不导后端与 admin-ui |
splitControllerLayer | false | true 时 Controller 拆 controller/admin/ + controller/app/ 两层 |

normalize(ApplicationEntity app) 用应用元数据填充缺省值:
public void normalize(ApplicationEntity app) {
if (basePackage == null || basePackage.isBlank())
basePackage = "com.example." + sanitize(app.getAppCode()).replace('-', '_');
if (groupId == null || groupId.isBlank()) groupId = "com.example";
if (artifactId == null || artifactId.isBlank())
artifactId = app.getAppCode().toLowerCase().replace('_', '-');
if (dbName == null || dbName.isBlank())
dbName = app.getAppCode().toLowerCase() + "_db";
if (jwtSecret == null || jwtSecret.isBlank()) jwtSecret = randomHex(64);
}jwtSecret 每次不同
randomHex(64) 用 SecureRandom 生成 64 字节十六进制密钥,每次导出都不一样。导出工程首次启动后,已签发的 JWT 不会被旧平台复用。
AppExportService 编排主流程
export(appId, opts) 是整个导出的入口,分 5 步:
public Path export(Long appId, ExportOptions opts) throws Exception {
ApplicationEntity app = applicationService.getById(appId);
opts.normalize(app);
final ExportOptions finalOpts = opts;
// 1. 加载元数据:按 appId 取所有表,再逐表加载字段(按 sort 升序)
List<LcTableMeta> tables = schemaService.getTableListByAppId(appId);
for (LcTableMeta t : tables) {
t.setColumns(columnMetaMapper.selectList(new LambdaQueryWrapper<LcColumnMeta>()
.eq(LcColumnMeta::getTableId, t.getId()).orderByAsc(LcColumnMeta::getSort)));
}
// 2. 转换为 EntityMeta,并用 FoundationSchema.TableDef 补 menuGroup/menuIcon
Map<String, FoundationSchema.TableDef> defByName = new HashMap<>();
for (FoundationSchema.TableDef def : FoundationSchema.all()) defByName.put(def.tableName, def);
List<EntityMeta> entities = tables.stream().map(t -> {
EntityMeta em = EntityMetaBuilder.build(t, finalOpts);
FoundationSchema.TableDef def = defByName.get(t.getTableName());
if (def != null) { em.setMenuGroup(def.businessGroup); em.setMenuIcon(groupIcon(def.businessGroup)); }
else { em.setMenuGroup("content"); em.setMenuIcon("Document"); }
return em;
}).collect(Collectors.toList());
// 2.1 加载字典(本应用 + 平台 fnd_ 字典兜底)
List<SysDictType> dictTypes = dictTypeMapper.selectList(...);
// 3. 构建模板上下文 baseCtx
// 4. 按 exportMode / mobileOnly 分支渲染到临时目录
// 5. zipDir 打包返回临时 zip 路径
}三种渲染分支:
mobileOnly=true:只调renderMobileUi,跳过后端与 admin-ui(要求mobilePlatform非空)。exportMode=multiModule:renderMultiModuleBackend+renderAdminUi(+ 可选renderMobileUi)。single(默认):renderSystemModule+renderBizModule+writeRootReadme+renderAdminUi(+ 可选renderMobileUi)。
FreeMarker 配置
每次导出都新建 Configuration(VERSION_2_3_32),从 classpath templates/export 加载模板,UTF-8,异常策略 RETHROW_HANDLER:
private Configuration newFreemarker() throws IOException {
Configuration cfg = new Configuration(Configuration.VERSION_2_3_32);
cfg.setClassLoaderForTemplateLoading(getClass().getClassLoader(), "templates/export");
cfg.setDefaultEncoding("UTF-8");
cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
cfg.setWrapUncheckedExceptions(true);
return cfg;
}改模板即生效
模板从 classpath 加载。开发期用 mvn spring-boot:run,改 backend/src/main/resources/templates/export/ 下的 .ftl 后重新启动(或靠 target/classes 热加载)即可生效,无需改 Java 代码。
EntityMetaBuilder:表元数据 -> 模板模型
EntityMetaBuilder.build(LcTableMeta, opts) 把一张表转成 EntityMeta,是模板渲染的核心数据源。关键逻辑:
- 去前缀推导模块名:剥掉
fnd_/biz_/app_/tbl_前缀,剩余部分作为module(决定包路径biz/<module>/...)。 - className:蛇形转大驼峰。
- 三套 API 路径:
apiPath=/api/biz/<module>、adminApiPath=/api/admin/biz/<module>、appApiPath=/api/app/biz/<module>(后两者在splitControllerLayer=true时启用)。 - 权限键:
permsKey=biz:<module>,写入 Controller 的@PreAuthorize。 - 保留字段过滤:
RESERVED = {id, tenant_id, create_time, update_time, create_by, update_by, deleted}不写入 entity(已由BaseEntity承载)。 - Java 类型推导:
mapJavaType优先用lc_column_meta.java_type(如BigDecimal),兜底用columnType(如VARCHAR(200))反推全限定名;只对非java.lang类型收集 import。 - 前端类型推导:
inferFrontendType优先用平台htmlType(richtext/textarea/file/select/datetime/...),兜底用 Java 类型(Long/Integer->number,LocalDate->date...)。
public static EntityMeta build(LcTableMeta t, ExportOptions opts) {
EntityMeta em = new EntityMeta();
em.setTableName(t.getTableName());
String moduleName = t.getTableName();
for (String pfx : List.of("fnd_", "biz_", "app_", "tbl_")) {
if (moduleName.startsWith(pfx)) { moduleName = moduleName.substring(pfx.length()); break; }
}
em.setModule(moduleName);
em.setClassName(toPascal(moduleName));
em.setApiPath("/api/biz/" + moduleName.replace('_', '-'));
em.setPermsKey("biz:" + moduleName);
em.setExposeOpenList(true); // 默认暴露公开列表/详情
// 遍历字段,跳过 RESERVED,推导 javaType / frontendType / imports
...
}EntityMeta 结构
public class EntityMeta {
private String tableName, tableComment, module, className;
private String apiPath, adminApiPath, appApiPath, permsKey;
private String menuGroup, menuIcon;
private boolean exposeOpenList;
private List<FieldMeta> fields = new ArrayList<>();
private Set<String> imports = new LinkedHashSet<>();
public static class FieldMeta {
private String tableField, javaField, javaType, columnType, comment;
private boolean required;
private String platformHtmlType, frontendType; // 平台原始类型 / 推导后类型
private String dictType, defaultValueSql;
private String optionsApi, optionValue, optionLabel, displayProp; // 远端选项
}
}单体模式渲染
renderSystemModule:遍历system-module/**/*模板,把_resources/_root/Application.java.ftl渲染成<AppClassName>Application.java,application.yml.ftl->application.yml,pom.xml.ftl->pom.xml,db/schema-system.sql+data-system.sql.ftl拷到resources/db/,其余 Java 源(common/config/security/system 各层)渲染到src/main/java/<basePackage>/。renderBizModule:对每个EntityMeta渲染 5 个 Java 文件(Entity/Mapper/Service/ServiceImpl/Controller),再渲染一份汇总的schema-biz.sql(所有表 DDL)和data-biz.sql。renderAdminUi:遍历admin-ui/**/*约 30 个.ftl,渲染成标准 Vue3 + Vite 工程。writeRootReadme:写根README.md,说明如何分别启动 backend 与 admin-ui。
Controller 分层(splitControllerLayer)
splitControllerLayer=false(默认)时,每个实体只渲染一个 Controller.java.ftl(挂在 entity.apiPath)。
splitControllerLayer=true 时拆成两个类:
ControllerAdmin.java.ftl->controller/admin/<ClassName>AdminController.java(后台接口,带@PreAuthorize)ControllerApp.java.ftl->controller/app/<ClassName>AppController.java(公开接口,仅exposeOpenList=true的实体生成)
判定是否需要 App Controller 的方法是 hasExposedOpen(entities):只要有任意实体 exposeOpenList=true 就加载 App 模板。
多模块模式渲染
renderMultiModuleBackend 按 entity.menuGroup 把实体分桶(无 group 丢进 misc),每个 group 对应一个 Maven 子模块 <artifactId>-biz-<group>:
<artifactId>-parent/ pom.xml (packaging=pom), README.md, .gitignore
├─ <artifactId>-system/ 系统层 Java 源(复用 system-module 模板,跳过 _root 与 db)
├─ <artifactId>-biz-<group>/ 每个 menuGroup 一个;Entity/Mapper/Service/Controller
└─ <artifactId>-bootstrap/ main 入口 + application.yml + 全量 schema/data SQL关键点:
copyWithModule(src, group)把entity.module覆盖为 group 名,保证源码包路径、菜单、Maven 模块三处一致。groupTitle(group)把 group code 翻译成中文菜单名:purchase->采购管理、sale->销售管理、hr->人事管理、warehouse->仓库管理、finance->财务管理、content->内容管理、project->项目管理、disclosure->信息公开、recruit->招聘管理、leads->线索管理、config->站点配置、misc->其它业务。- bootstrap 模块持有所有 schema/data SQL:
schema-system.sql(从 system-module 原样拷)、data-system.sql.ftl(渲染)、schema-<group>.sql(按 group 各一份)、data-biz.sql(全量集中)。 - biz 模块只渲染 Java 源,不带 SQL(SQL 全在 bootstrap)。
bizGroups 上下文
父 pom、bootstrap pom、application.yml 渲染时都用 bizGroups 变量,它是 List<Map>,每项含 name(group code)、title(中文名)、tables(表数量),用来生成 <modules> 片段与 dependencyManagement。
data-biz.sql 生成逻辑
writeBizDataSql 根据 includeBizData 决定内容:
false(默认):只写SELECT 1;,避免 Spring SQL 初始化器报空脚本。true:用jdbcTemplate.queryForList("SELECT * FROM <表> WHERE deleted=0 ORDER BY id")取数据,逐行生成INSERT IGNORE INTO ... VALUES (...),固定导出id+ 业务字段 + 审计字段(create_time/update_time/create_by/update_by/deleted),保证关联数据可复用。
if (!Boolean.TRUE.equals(opts.getIncludeBizData())) {
w.write("SELECT 1;\n"); // 占位
return;
}
for (EntityMeta em : entities) {
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT * FROM `" + em.getTableName() + "` WHERE deleted = 0 ORDER BY id ASC");
// 逐行拼 INSERT IGNORE
}前端实现
frontend/src/api/application.ts 封装了导出接口,注意导出用 responseType: 'blob' 接收 zip 二进制流:
// application.ts
export function exportMobileApp(id: number, opts?: any) {
return request.post<any>(`/application/${id}/export-mobile-app`, opts || {}, { responseType: 'blob' })
}
export function exportMobileMiniProgram(id: number, opts?: any) {
return request.post<any>(`/application/${id}/export-mobile-mp`, opts || {}, { responseType: 'blob' })
}导出对话框打开时通常先调 GET /application/{appId}/export-defaults 拉默认值(basePackage / artifactId / dbName / serverPort 等自动填好),用户确认后再 POST 对应导出端点,前端把 blob 转成 <a download> 触发下载。

操作步骤
- 在平台里建好应用,确保表设计器里的表和字段已保存(导出靠
lc_table_meta+lc_column_meta)。 - 进入应用管理,打开导出对话框,先调
GET /api/application/{appId}/export-defaults取默认ExportOptions。 - 按需调整:
- 想要单体工程 -> 调
POST /api/application/{appId}/export-springboot。 - 想要多模块 Maven -> 调
POST /api/application/{appId}/export-springboot-multi。 - 想要带 H5 移动端 -> 调
POST /api/application/{appId}/export-mobile-app。 - 想要带微信小程序 -> 调
POST /api/application/{appId}/export-mobile-mp。
- 想要单体工程 -> 调
- 浏览器收到 zip,解压。
- 单体模式:
cd backend && mvn spring-boot:run(端口默认 8080),再cd admin-ui && pnpm install && pnpm dev。 - 多模块模式:在
*-parent目录mvn clean install -DskipTests,再cd *-bootstrap && mvn spring-boot:run。
首次启动会自动建表
导出工程的 application.yml 配置了 Spring SQL 初始化器,启动时执行 db/schema-system.sql + data-system.sql + schema-biz.sql + data-biz.sql,自动建表并写入 admin/123456 种子账号。改数据库连接后直接跑即可。
常见问题
导出失败 500
doExport 捕获异常后写 {"code":500,"message":"导出失败:<msg>"}。常见原因:应用下没有任何表(getTableListByAppId 返回空)、某张表没有字段、FreeMarker 模板语法错误。看后端日志的 [AppExport] 日志行定位。
多模块导出后 biz 模块包名不对?
多模块下 copyWithModule 会把 entity.module 覆盖为 group 名,所以 biz/purchase/entity/... 而不是 biz/<原表名前缀>/...。这是设计如此,保证 Maven 模块名与包路径一致。若想保留原 module 名,改 renderMultiModuleBackend 里的分桶逻辑。
导出的工程编译报 Lombok / TypeTag 错误
导出 pom 在 JDK21 + 高版本 mvn 下偶发 Lombok annotation processor 崩溃(TypeTag 相关)。解决:用 IntelliJ 内置编译器,或 javac --add-opens,详见平台记忆里的「导出工程 Lombok×JDK21 pom 问题」说明。多模块父 pom 已在 pluginManagement 里配了 Lombok annotationProcessorPaths。
includeBizData=true 但 data-biz.sql 没数据
writeBizDataSql 用 jdbcTemplate 直查当前平台库的 <表名>。如果该表在平台库里是空的(只有元数据没有业务数据),会写「<表名> 无数据」注释。先用平台跑几条业务数据再导出。
想改导出模板
所有模板在 backend/src/main/resources/templates/export/ 下,按 admin-ui/ / biz-module/ / multi-module/ / system-module/ / uni-app/ 分目录。改 .ftl 后重启后端即生效(模板从 classpath 加载)。改完务必同步检查 里列的三处定义是否一致。
