后端分层架构
后端分层架构
业务用途
本篇解析后端工程的整体骨架:Spring Boot 启动入口、分层包结构、统一响应封装、自研分页工具、MyBatis-Plus 全局配置与字段自动填充机制。理解这些「地基」之后,再看 、 等子系统时,就能清楚每一层各自负责什么。
后端基于 Spring Boot 3 + MyBatis-Plus,JDK 17 编译,默认监听 52856 端口。整体采用经典四层结构:
Controller (@RestController, @RequestMapping("/api/xxx"))
↓ 组装/校验参数,返回 Result<T>
Service / ServiceImpl<Mapper, Entity> (业务逻辑)
↓
Mapper extends BaseMapper<Entity> (注解式,无 XML)
↓
MySQL 8 / H2 (+ Redis 缓存)除常规「静态实体通路」外,低代码引擎还有一条「动态表通路」:Controller -> SchemaService -> JdbcTemplate,表结构运行时才存在,SQL 由元数据动态拼装(详见 )。
涉及文件
| 文件 | 作用 |
|---|---|
backend/src/main/java/com/lowcode/LowCodeApplication.java | 启动类,@SpringBootApplication + @MapperScan |
backend/src/main/java/com/lowcode/common/Result.java | 统一响应 Result<T> |
backend/src/main/java/com/lowcode/common/PageQuery.java | 分页查询参数 |
backend/src/main/java/com/lowcode/common/PageHelper.java | 自研分页工具 doPage(...) |
backend/src/main/java/com/lowcode/common/BaseEntity.java | 实体基类(id/tenantId/审计字段/逻辑删除) |
backend/src/main/java/com/lowcode/config/MybatisPlusConfig.java | MyBatis-Plus 拦截器注册 |
backend/src/main/java/com/lowcode/config/MyMetaObjectHandler.java | 审计字段自动填充 |
backend/src/main/resources/application.yml | MyBatis-Plus 全局配置 |
包结构
启动类位于 com.lowcode 根包,其下按职责拆分为 14 个子包:
com.lowcode
├── LowCodeApplication # 启动类
├── common # Result / PageHelper / PageQuery / BaseEntity / ValidationException
├── config # 所有 @Configuration:MybatisPlus / Security / WebMvc / 拦截器 / 切面 / 缓存 / 初始化
├── controller # REST 控制器(含 system/ bpm/ erp/ hr/ 子包)
├── dto # 数据传输对象(如 OIDCUserInfo)
├── entity # 实体类(含 bpm/ erp/ system/ 子包)
├── export # 工程导出服务(AppExportService 等)
├── generator # 代码生成器(CodeGeneratorService)
├── mapper # MyBatis-Plus Mapper 接口(注解式,无 XML)
├── sdk # 移动端 SDK(MobileApiController / MobileAuthFilter)
├── security # JWT 工具、过滤器、UserDetailsServiceImpl
├── seeder # 内置示例数据播种(ERP / 基金会)
├── service # 业务 Service 接口与 impl 实现
└── sso # 单点登录客户端(OAuth2 / SsoProviderClient)Mapper 全部注解式
工程 src/main/resources 下没有 mapper/ 目录,所有 Mapper 都是继承 BaseMapper<Entity> 的 Java 接口,复杂查询用 @Select 注解或 LambdaQueryWrapper。application.yml 里的 mapper-locations: classpath*:/mapper/**/*.xml 只是保留位,实际无 XML 文件可加载。
后端实现
启动类 LowCodeApplication
// backend/src/main/java/com/lowcode/LowCodeApplication.java
@SpringBootApplication
@MapperScan("com.lowcode.mapper")
public class LowCodeApplication {
public static void main(String[] args) {
SpringApplication.run(LowCodeApplication.class, args);
System.out.println("""
==============================================
🚀 企业级低代码开发平台启动成功!
📚 后端服务: http://localhost:8080
🎨 前端服务: http://localhost:3000
==============================================
""");
}
}@MapperScan("com.lowcode.mapper")一次性扫描所有 Mapper 接口,无需在每个接口上加@Mapper。main里打印的8080是历史遗留的过期文案,实际端口由application.yml的server.port: 52856决定(见 )。登录与联调一律以52856为准。
统一响应 Result<T>
所有接口返回 com.lowcode.common.Result<T>,前端 axios 响应拦截器据此解包。
// backend/src/main/java/com/lowcode/common/Result.java
@JsonIgnoreProperties(ignoreUnknown = true)
public class Result<T> {
private Integer code; // 200 成功;500 失败;401 未授权;403 禁止访问
private String message;
private T data;
public static <T> Result<T> success() { return new Result<>(200, "操作成功", null); }
public static <T> Result<T> success(T data) { return new Result<>(200, "操作成功", data); }
public static <T> Result<T> success(String message, T data) { return new Result<>(200, message, data); }
public static <T> Result<T> error(String message) { return new Result<>(500, message, null); }
public static <T> Result<T> error(Integer code, String message) { return new Result<>(code, message, null); }
public static <T> Result<T> unauthorized(String message) { return new Result<>(401, message, null); }
public static <T> Result<T> forbidden(String message) { return new Result<>(403, message, null); }
}字段名是 message 不是 msg
Result 的消息字段叫 message(不是 msg)。@JsonIgnoreProperties(ignoreUnknown = true) 保证前端多传字段不报错。前端拦截器判断 code === 200 取 data,否则弹出 message。
分页参数 PageQuery
// backend/src/main/java/com/lowcode/common/PageQuery.java
@Data
public class PageQuery {
private Integer pageNum = 1; // 当前页码,默认 1
private Integer pageSize = 10; // 每页条数,默认 10
private String orderBy; // 排序字段
private String orderDirection = "desc"; // 排序方式,默认 desc
}自研分页 PageHelper.doPage
平台没有使用 MyBatis-Plus 官方的 PaginationInnerInterceptor(本地依赖里缺少 mybatis-plus-jsqlparser),而是自研 common.PageHelper.doPage(...),用 LIMIT/OFFSET + 一条 COUNT 实现分页。
// backend/src/main/java/com/lowcode/common/PageHelper.java
public static <T> Page<T> doPage(IService<T> service, QueryWrapper<T> wrapper,
int pageNum, int pageSize) {
// 1. 克隆一份 wrapper 专门用来 count,并清掉 orderBy(count 不需要排序)
QueryWrapper<T> countWrapper = wrapper.clone();
countWrapper.getExpression().getOrderBy().clear();
long total = service.count(countWrapper);
// 2. 原 wrapper 追加 LIMIT/OFFSET
int offset = (pageNum - 1) * pageSize;
wrapper.last("LIMIT " + pageSize + " OFFSET " + offset);
List<T> records = service.list(wrapper);
// 3. 组装 MyBatis-Plus 的 Page 对象返回
Page<T> page = new Page<>(pageNum, pageSize, total);
page.setRecords(records);
return page;
}- 提供两个重载,分别接收
QueryWrapper<T>与LambdaQueryWrapper<T>,逻辑一致。 - 动态表的分页由
SchemaService.queryDataList内部按相同的LIMIT/OFFSET + COUNT方式自行实现(见 )。
实体基类 BaseEntity
业务实体统一继承 BaseEntity,集中声明主键、租户、审计与逻辑删除字段:
// backend/src/main/java/com/lowcode/common/BaseEntity.java
@Data
public class BaseEntity implements Serializable {
@TableId(type = IdType.AUTO)
private Long id; // 主键,数据库自增
private Long tenantId; // 租户ID(多租户隔离用)
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime; // 创建时间,插入时自动填充
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime; // 更新时间,插入/更新时自动填充
@TableField(fill = FieldFill.INSERT)
private String createBy; // 创建人,插入时自动填充
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy; // 更新人,插入/更新时自动填充
@TableLogic
private Integer deleted; // 逻辑删除:0 正常 / 1 已删除
}@TableLogic配合application.yml的logic-delete-field: deleted/logic-delete-value: 1/logic-not-delete-value: 0,deleteById会变成UPDATE ... SET deleted=1,查询自动追加deleted=0。@TableField(fill = ...)声明由MyMetaObjectHandler自动填充(见下)。- 注意:
BaseEntity同时用 Lombok@Data又手写了 getter/setter,二者并存不影响运行。
MyBatis-Plus 全局配置
# backend/src/main/resources/application.yml
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml # 保留位,实际无 XML
type-aliases-package: com.lowcode.entity
configuration:
map-underscore-to-camel-case: true # 蛇形列 create_time ↔ 驼峰 createTime
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 控制台打印 SQL
global-config:
db-config:
id-type: auto # 主键自增
logic-delete-field: deleted # 逻辑删除字段
logic-delete-value: 1
logic-not-delete-value: 0拦截器注册 MybatisPlusConfig
// backend/src/main/java/com/lowcode/config/MybatisPlusConfig.java
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 租户拦截器 —— 行级租户隔离(自定义实现,见多租户篇)
interceptor.addInnerInterceptor(new TenantInterceptor());
return interceptor;
}
}这里只注册了 TenantInterceptor
MybatisPlusInterceptor 当前只添加了自定义的 TenantInterceptor,没有注册:
PaginationInnerInterceptor(分页改由PageHelper.doPage手动实现);SlowQueryInterceptor(虽然类存在并标注@Component,但未在此处addInnerInterceptor,因此处于未启用状态,详见 )。
二次开发若要启用官方分页插件或慢查询插件,需在此 Bean 内手动追加。
自动填充 MyMetaObjectHandler
// backend/src/main/java/com/lowcode/config/MyMetaObjectHandler.java
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
LocalDateTime now = LocalDateTime.now();
String userId = "1"; // ⚠ 当前硬编码为 "1"
setFieldValByName("createTime", now, metaObject);
setFieldValByName("updateTime", now, metaObject);
setFieldValByName("createBy", userId, metaObject);
setFieldValByName("updateBy", userId, metaObject);
// BPM 表(芋道风格)使用 creator / updater 命名
setFieldValByName("creator", userId, metaObject);
setFieldValByName("updater", userId, metaObject);
}
@Override
public void updateFill(MetaObject metaObject) {
LocalDateTime now = LocalDateTime.now();
String userId = "1";
setFieldValByName("updateTime", now, metaObject);
setFieldValByName("updateBy", userId, metaObject);
setFieldValByName("updater", userId, metaObject);
}
}- 同时兼容两套命名:标准表
create_by/update_by(如sys_user)与 BPM 表creator/updater(芋道风格)。 setFieldValByName是「软设置」——只有实体里存在该字段时才写入,不存在则忽略,因此同一套处理器可服务所有实体。
createBy/updateBy 当前硬编码为 "1"
userId 目前写死成字符串 "1",并未从 SecurityContextHolder 读取真实登录用户。这意味着审计字段的「操作人」一律显示为 1。若需准确记录操作人,应在此处从安全上下文或 TenantContext/请求属性中取真实用户 ID 后赋值。
操作步骤
- 定位分层:找一个业务功能时,按
controller -> service(impl) -> mapper -> entity顺序查找。例如「用户管理」对应controller/system/SysUserController.java->service/ISysUserService.java/service/impl/SysUserServiceImpl.java->mapper/SysUserMapper.java->entity/SysUser.java。 - 新增接口:在 Controller 写方法返回
Result.success(data);涉及列表用PageQuery接收分页参数,调用PageHelper.doPage(service, wrapper, pageNum, pageSize)。 - 新增实体:继承
BaseEntity,加@TableName;字段用蛇形列名 +map-underscore-to-camel-case自动映射,无需手写@TableField(除非要指定填充策略)。 - 观察 SQL:开发时
log-impl: StdOutImpl会把每条 SQL 打到控制台,配合logging.level.com.lowcode: debug排查问题。
常见问题
启动类打印的端口是 8080,但实际访问 52856 才通?
LowCodeApplication.main 里的 http://localhost:8080 是早期文案未更新。真实端口以 application.yml 的 server.port: 52856 为准,请忽略启动横幅里的 8080。
为什么 deleteById 后数据还在?
BaseEntity.deleted 标了 @TableLogic,删除是逻辑删除(UPDATE ... SET deleted=1),查询会自动带 deleted=0。若在数据库直接查表会看到 deleted=1 的「已删除」记录。如需物理删除,用 Mapper 的原生 SQL 或 JdbcTemplate。
新增的实体字段没自动填充 createTime/updateTime?
确认实体继承 BaseEntity,且对应字段的 @TableField(fill = ...) 注解保留。MyMetaObjectHandler 通过 setFieldValByName 按字段名匹配,字段名必须严格是 createTime / updateTime / createBy / updateBy(或 BPM 的 creator / updater)。
想用 MyBatis-Plus 官方分页插件可以吗?
可以,但需在 MybatisPlusConfig.mybatisPlusInterceptor() 里 addInnerInterceptor(new PaginationInnerInterceptor(...)),并引入 mybatis-plus-jsqlparser 依赖。当前工程未引入该依赖,故自研了 PageHelper.doPage。两套方案不要混用,以免 LIMIT 被重复追加。
