4、数据库设计与ORM实战:表结构设计(用户、部门、流程、表单)、MyBatis-Plus/JPA使用技巧、复杂查询优化
好,咱们直接进入正题。这一章聊的是OA系统的“地基”——数据库设计和ORM实战。说实话,我见过太多OA项目后期跑不动、改不动,根子都出在表结构上。你想想看,流程引擎每天要处理成千上万条审批记录,表单模板千变万化,如果表设计一开始就歪了,后面再怎么优化都是杯水车薪。
4.1 核心表结构设计:用户、部门、流程、表单
OA系统里,最核心的四个实体就是用户、部门、流程和表单。它们之间的关系,说白了就是“谁在哪个部门,发起了什么流程,填了哪张表单”。
4.1.1 用户表与部门表:多对多的现实映射
很多新手会直接把用户和部门设计成一对多。但现实是,一个人可能同时在多个部门任职。我在项目中就遇到过这种情况:一个技术总监,既属于研发部,又兼任项目管理办公室的成员。如果只用dept_id字段,那就尴尬了。
我的做法是引入一张关联表:
-- 用户表
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE,
real_name VARCHAR(50),
email VARCHAR(100),
phone VARCHAR(20),
status TINYINT DEFAULT 1, -- 1:启用 0:禁用
create_time DATETIME
);
-- 部门表
CREATE TABLE sys_dept (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
dept_name VARCHAR(100) NOT NULL,
parent_id BIGINT DEFAULT 0,
sort_order INT DEFAULT 0,
create_time DATETIME
);
-- 用户-部门关联表(支持多部门)
CREATE TABLE sys_user_dept (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
dept_id BIGINT NOT NULL,
is_primary TINYINT DEFAULT 0, -- 是否主部门
UNIQUE KEY uk_user_dept (user_id, dept_id)
);
避坑指南:我曾经在一个项目里忘了加is_primary字段,结果做组织架构树的时候,不知道用户到底该挂在哪棵树下。后来补数据补到崩溃。所以,多部门关联时,一定要明确哪个是主部门。
4.1.2 流程表与表单表:动态与静态的博弈
流程是动态的,表单是静态的。但两者又密不可分。我的设计思路是:流程模板定义“流转路径”,表单模板定义“数据字段”,而流程实例则记录“谁在什么时间走到了哪一步”。
-- 流程模板表
CREATE TABLE flow_definition (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
flow_code VARCHAR(50) NOT NULL UNIQUE, -- 比如 "leave_flow"
flow_name VARCHAR(100),
version INT DEFAULT 1,
form_id BIGINT, -- 关联表单模板
status TINYINT DEFAULT 0, -- 0:草稿 1:发布
create_time DATETIME
);
-- 流程节点表
CREATE TABLE flow_node (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
flow_id BIGINT NOT NULL,
node_name VARCHAR(50), -- 比如 "部门经理审批"
node_type TINYINT, -- 1:开始 2:审批 3:条件分支 4:结束
approver_type TINYINT, -- 审批人类型: 1:指定人 2:部门主管 3:角色
approver_ids VARCHAR(500), -- 审批人ID列表,逗号分隔
sort_order INT
);
-- 流程实例表(运行时的数据)
CREATE TABLE flow_instance (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
flow_id BIGINT NOT NULL,
form_data_id BIGINT, -- 关联表单数据
initiator_id BIGINT, -- 发起人
current_node_id BIGINT, -- 当前所在节点
status TINYINT, -- 0:进行中 1:通过 2:驳回 3:撤销
create_time DATETIME
);
这里有个细节:approver_ids我用了逗号分隔。有人可能会说“这不符合第三范式”。嗯,我承认。但在OA这种高频读写的场景下,为了减少一次关联查询,这种冗余是可以接受的。你想想看,每次审批都要去查一张审批人关联表,性能损耗不小。
4.1.3 表单设计的“杀手锏”:JSON字段存储
OA系统的表单,字段是动态的。今天加个“出差城市”,明天加个“预计天数”。如果用传统的关系型字段,表结构得改到崩溃。我的方案是:用JSON字段存储表单模板定义和表单数据。
-- 表单模板表
CREATE TABLE form_template (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
form_code VARCHAR(50) NOT NULL UNIQUE,
form_name VARCHAR(100),
form_config JSON, -- 存储字段定义,比如 [{"field":"city","label":"出差城市","type":"text"}, ...]
version INT DEFAULT 1,
create_time DATETIME
);
-- 表单数据表
CREATE TABLE form_data (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
form_id BIGINT NOT NULL,
data_content JSON, -- 存储实际填写的值,比如 {"city":"北京","days":3}
create_by BIGINT,
create_time DATETIME
);
核心观点:JSON字段是OA表单设计的“银弹”。它让表单模板和表单数据彻底解耦。你改表单字段,不需要改表结构,只需要改form_config里的JSON配置就行。我在一个项目中,用这个方案支撑了200多种不同类型的表单,数据库表只用了两张。
4.2 MyBatis-Plus / JPA 使用技巧
ORM框架的选择,其实是个老生常谈的话题。我个人习惯用MyBatis-Plus,因为它对复杂查询的支持更灵活。但JPA在简单CRUD上确实更省事。这里我重点讲MyBatis-Plus的几个实战技巧。
4.2.1 自动填充:别再手动set创建时间了
很多新手写代码,每个插入操作都要手动setCreateTime(new Date())。这太傻了。MyBatis-Plus的自动填充功能,能帮你搞定这些重复劳动。
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
this.strictInsertFill(metaObject, "createTime", Date.class, new Date());
this.strictInsertFill(metaObject, "updateTime", Date.class, new Date());
}
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updateTime", Date.class, new Date());
}
}
然后在实体类字段上加注解:
@TableField(fill = FieldFill.INSERT)
private Date createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
你看,这样每次插入或更新,时间字段自动就填上了。省心又规范。
4.2.2 分页查询:别再用PageHelper了
PageHelper虽然好用,但它是基于拦截器的,有时候会“误伤”其他查询。MyBatis-Plus自带的Page对象,既干净又高效。
// 前端传参:pageNum=1, pageSize=10
Page<FlowInstance> page = new Page<>(pageNum, pageSize);
Page<FlowInstance> result = flowInstanceMapper.selectPage(page,
Wrappers.<FlowInstance>lambdaQuery()
.eq(FlowInstance::getInitiatorId, userId)
.orderByDesc(FlowInstance::getCreateTime)
);
// 返回给前端
return Result.success(new PageVO<>(result.getRecords(), result.getTotal()));
这里有个小技巧:result.getTotal()会自动执行count查询,不需要你手动写。而且它和主查询是分开的,不会互相干扰。
4.2.3 条件构造器:复杂查询的利器
OA系统里,查询条件往往很复杂。比如“查询某个部门下,所有状态为进行中,且发起时间在最近7天内的请假流程”。用LambdaQueryWrapper,代码会非常清晰。
LambdaQueryWrapper<FlowInstance> wrapper = Wrappers.<FlowInstance>lambdaQuery()
.inSql(FlowInstance::getInitiatorId,
"SELECT user_id FROM sys_user_dept WHERE dept_id = " + deptId)
.eq(FlowInstance::getStatus, 0) // 进行中
.ge(FlowInstance::getCreateTime, DateUtils.addDays(new Date(), -7))
.le(FlowInstance::getCreateTime, new Date())
.orderByDesc(FlowInstance::getCreateTime);
List<FlowInstance> list = flowInstanceMapper.selectList(wrapper);
注意:inSql方法虽然方便,但要注意SQL注入风险。如果deptId是前端传过来的,一定要做参数校验或使用in配合selectList。我建议用in加子查询的方式,更安全。
4.3 复杂查询优化:从“能跑”到“跑得快”
表设计好了,ORM也用了,但查询还是慢?别急,优化是有套路的。
4.3.1 索引优化:别让数据库做全表扫描
OA系统里,最常见的慢查询就是“按时间范围查流程实例”。如果没有索引,数据量一上来,查询就是灾难。
我的索引设计原则:
- 联合索引优先:比如
(flow_id, status, create_time),能覆盖大部分查询场景 - 避免索引失效:不要在索引列上使用函数,比如
WHERE DATE(create_time) = '2024-01-01',这会让索引失效。应该写成WHERE create_time >= '2024-01-01' AND create_time < '2024-01-02' - 覆盖索引:如果查询只需要
id和status,那就建一个包含这两个字段的索引,避免回表查询
-- 推荐索引
ALTER TABLE flow_instance ADD INDEX idx_flow_status_time (flow_id, status, create_time);
4.3.2 分页优化:深度分页的“坑”
当数据量达到几十万条时,LIMIT 100000, 10这种分页会越来越慢。因为数据库需要先扫描前100000条数据,然后才取后面的10条。
我的解决方案是“游标分页”:
// 前端传参:lastId(上一页最后一条记录的ID), pageSize
Page<FlowInstance> page = new Page<>(1, pageSize, false); // 不查总记录数
LambdaQueryWrapper<FlowInstance> wrapper = Wrappers.<FlowInstance>lambdaQuery()
.gt(FlowInstance::getId, lastId) // 基于ID的游标
.eq(FlowInstance::getFlowId, flowId)
.orderByAsc(FlowInstance::getId);
Page<FlowInstance> result = flowInstanceMapper.selectPage(page, wrapper);
这种方式,每次查询都只扫描pageSize条数据,性能非常稳定。不过它只适合按主键排序的场景。
4.3.3 避免N+1查询:用JOIN或批量查询
ORM框架最容易犯的错误就是N+1查询。比如查10条流程实例,然后循环去查每条实例的发起人信息。这就变成了1次主查询 + 10次子查询。
我的做法是:
- 能用JOIN就用JOIN:MyBatis-Plus的
selectPage支持自定义xml,可以写JOIN查询一次性查出关联数据 - 批量查询:如果必须分步查,那就先查出所有流程实例,然后收集所有
initiatorId,用IN查询一次性查出所有用户信息
// 批量查询示例
List<Long> userIds = flowInstances.stream()
.map(FlowInstance::getInitiatorId)
.collect(Collectors.toList());
List<User> users = userMapper.selectBatchIds(userIds);
Map<Long, User> userMap = users.stream()
.collect(Collectors.toMap(User::getId, u -> u));
个人经验:我曾经在一个报表查询中,因为N+1问题,接口响应时间从200ms飙升到了5秒。后来改成JOIN查询,直接降到了300ms。所以,写代码时多想想“我是不是在循环里查数据库了?”
4.4 本章小结
数据库设计和ORM优化,是OA系统二次开发的“内功”。表结构设计要兼顾灵活性和性能,ORM框架要用对技巧,复杂查询要懂得优化策略。说白了,就是让数据库“少干活、干快活”。
下一章,我们会聊流程引擎的核心——节点流转与条件分支。到时候我会分享一个我踩过的“死循环”坑,保证让你印象深刻。