SpringBoot+Vue3+Flowable 企业工单系统全流程设计——4张表、6种状态、3种分配策略,BPM审批后自动路由到工单池
🌐 文档地址:ruoyioffice.com | 📦 源码1:gitcode.com/zhouzhongya… | 📦 源码2:gitcode.com/zhouzhongya… | 📦 源码3:github.com/yuqing2026/… | 💬 微信:17156169080(备注「RuoYi Office」)
工单是企业内部服务协同的"毛细血管"——员工 VPN 坏了、打印机卡纸了、食堂餐标被投诉了、客户反馈官网报 502 了,每一件都要有人收、有人办、有人追进度。看起来就是"让员工提个单子、让客服去处理"这么简单,真做成系统却要回答一连串难题:**该派给谁?派错了谁转?多久必须响应?过时了要不要告警?审批通过前能不能动?同一张单能不能被两个人同时抢?**RuoYi Office 用 4 张表 + 6 种状态 + 3 种分配策略,把这些问题全部压进了数据模型,让 OA、客服、IT 运维三类工单共用一套骨架。
▲ 工单全生命周期:申请端创建/提交 → BPM 审批 → onProcessApproved 回调匹配 SLA + 自动分配 → 处理端接单/回复/转派/完成/关闭,所有状态最终都落回 oa_ticket_bill 一张表
引言:工单系统到底难在哪?
"不就是一张表加个状态字段吗?"很多人第一次做工单都这么想。做到第二周就会发现,真正吃劲的不是"如何存工单",而是如何在"一张单"里同时满足申请人、处理人、主管三方截然不同的诉求:
申请人要"看得见、催得动":提交后不想听人说"还没轮到",需要看到处理人是谁、什么时候会回我、超没超时。
处理人要"收得到、分得清":当我点开工单池,只应该看到"我负责那一类"或"暂时没人管"的单,而不是全公司所有工单一起砸过来。
主管要"管得住、查得到":谁在拖工单、哪类工单响应最慢、哪个人负载最高,都需要有数据支持,不能靠问。
BPM 审批要"压得住":工单可能包含敏感信息(投诉、财务相关问题),提交后必须先走审批,审批通过前处理端根本不能看到这张单;同时审批通过后要立刻路由给处理组,不能让 HR 再手动派一次。
| 痛点 | 传统做法 | 后果 |
|---|---|---|
| 状态字段混为一谈 | 用一个 status 同时表示"审批中 / 处理中 / 已关闭" | BPM 状态和业务状态打架,看不出是"谁卡住了" |
| 工单池随便打开 | 全公司工单都能看到 | 申请人隐私泄漏、处理人被淹没 |
| 派单靠组长手工 | 主管每天早上按经验分派 | 主管请假 = 系统停摆 |
| 没有 SLA | "尽快处理"写在合同上 | 没人知道到底还剩多久 |
| 回复只有一种 | 所有沟通都是"公开消息" | 内部备注、系统记录全都暴露给申请人 |
| 转派丢状态 | 换人时把状态清零重新走一遍 | 上一段处理记录不连续 |
本文就以 RuoYi Office 的工单模块为例,拆解它如何通过 4 张表 + 双状态字段 + 处理组路由 + 三段式权限 把上面这些痛点一次性解决。
一、业务设计:为什么需要 4 张表?
1.1 业务全景:一张单的 3 段人生
一张工单从提出到终结,最多穿越 3 个角色 + 6 种状态:
| 阶段 | 角色 | 操作 | process_status | ticket_status |
|---|---|---|---|---|
| 申请 | 申请人 | 保存草稿 / 提交 | 0 草稿 → 1 审批中 | 0 未启动 |
| 审批 | 审批人(BPM) | 通过 / 拒绝 | 2 审批通过 / 3 拒绝 | 0 未启动 |
| 路由 | 系统自动 | 匹配 SLA + 自动分配 | 2 审批通过 | 1 待分配 → 2 待处理 |
| 处理 | 处理人 | 接单 / 回复 / 转派 / 完成 | 2 审批通过 | 3 处理中 → 4 已完成 |
| 关闭 | 申请人 / 系统 | 确认 / 激活 | 2 审批通过 | 5 已关闭(可反激活回 3) |
注意这里最关键的设计:process_status 描述"审批走到哪了",ticket_status 描述"处理走到哪了"。两个字段彻底解耦,BPM 的同学改他的流程定义不会影响业务侧,业务侧加状态也不用动 BPM。
1.2 为什么分 4 张表?——职责彻底分离
| 表名 | 存什么 | 承担的职责 |
|---|---|---|
oa_ticket_bill | 工单主体,包含 BPM 状态、业务状态、SLA 时间戳 | 单据 + 状态机 + 时效快照 |
oa_ticket_handler_group | 处理组:成员、组长、分配策略 | 权限路由(谁能看到谁能接) |
oa_ticket_sla_rule | SLA 规则:优先级 × 分类 → 响应/解决时限 | 时效计算(deadline 的来源) |
oa_ticket_reply | 回复:公开 / 内部 / 系统三类 | 沟通+审计(带类型的消息流) |
这套拆分背后是一个朴素原则:"单据是快照,规则是参数,沟通是事件"。把这三件事混在同一张表里,未来任意一个维度想扩展都会牵一发动全身;拆开之后,增加一种 SLA 规则、增加一种回复类型、增加一种分配策略,都只动一张表。
1.3 两个状态字段的角色分工
process_status # BPM 流程状态,由 FlowBillService 回调更新
0: 草稿
1: 审批中
2: 审批通过
3: 审批拒绝
4: 已取消
ticket_status # 工单业务状态,由处理人和系统自动更新
0: 未启动
1: 待分配 # BPM 通过,还没指定处理人
2: 待处理 # 已分配处理人,但处理人没点"接单"
3: 处理中 # 处理人点了"接单" / "处理"
4: 已完成 # 处理人点了"标记完成"
5: 已关闭 # 申请人确认
一张单合法的"双状态组合"只有这么几种:
| process_status | ticket_status | 场景 |
|---|---|---|
| 0 草稿 | 0 未启动 | 新建保存未提交 |
| 1 审批中 | 0 未启动 | 提交后等审批 |
| 3 拒绝 | 0 未启动 | 审批被驳 |
| 2 审批通过 | 1 待分配 | 在工单池里等人抢 |
| 2 审批通过 | 2 待处理 | 自动分配到某人但他没点"接单" |
| 2 审批通过 | 3 处理中 | 在处理了 |
| 2 审批通过 | 4 已完成 | 提交人验收前 |
| 2 审批通过 | 5 已关闭 | 归档 |
这张表可以直接贴进测试用例——所有非法组合(比如 process=3 拒绝 却 ticket=3 处理中)都要被后端守住。
二、系统设计:三个 Service 把"单/组/时效"串起来
2.1 模块组成

▲ 工单模块系统结构:4 张表在数据层解耦,工单池路由、SLA 引擎、XXL-Job 定时器在应用层协作,对外暴露申请端/处理端两套接口
| 子模块 | 核心类 | 面向角色 |
|---|---|---|
| 工单主服务 | TicketBillServiceImpl | 申请人 + 处理人共用,负责 CRUD 与状态翻转 |
| 处理组服务 | TicketHandlerGroupServiceImpl | 管理员配置处理组,运行时被路由层查询 |
| SLA 规则服务 | TicketSlaRuleServiceImpl | 管理员配置规则,审批通过时由主服务调用 matchSlaRule |
| 自动分配服务 | TicketAssignServiceImpl | 实现 3 种策略,返回被选中的 userId |
| 回复服务 | TicketReplyServiceImpl | 申请端 / 处理端两种视角,过滤内部备注 |
| BPM 回调 | TicketBillServiceImpl implements FlowBillService | BPM 审批通过/拒绝时被框架反向调用 |
2.2 核心设计决策
| 决策点 | 方案 | 理由 |
|---|---|---|
| 状态字段如何分层 | process_status + ticket_status 双字段 | BPM 状态和业务状态各自演进互不影响 |
| 派单路由放在哪一层 | 路由 = 查询条件,不是中间件 | 工单池的 SQL 里直接 group_id IN (:myGroups) OR group_id IS NULL,零中间件成本 |
| SLA 是实时计算还是落库 | 审批通过时一次性算好并写入 deadline | 避免每次列表查询都跑一次规则匹配,也便于索引查超时 |
| 自动分配失败怎么办 | 落回工单池待抢单 | 宁可让人抢,也不能让工单无人认领 |
| 内部备注怎么对申请端隐藏 | Service 层按 source=apply 过滤 type=2 | 比"前端不显示"更安全,前端显示逻辑再复杂也防不了抓包 |
| 转派会不会丢历史 | 转派只改 handler_id,不清 replies | 回复表以 ticket_id 为外键,处理人换了历史对话还在 |
2.3 三层权限:申请端 / 工单池 / 详情
这是整个工单系统权限模型的精髓——不用新建角色,纯靠数据关系划分可见性。
| 页面 | 数据来源 | 过滤条件 |
|---|---|---|
| 工单列表(申请端) | getTicketBillPage(viewType=mine) | creator = currentUserId |
| 工单池-待接单 | getTicketPoolPage(viewType=pending) | ticket_status = 1 AND handler_group_id IN (myGroups) OR IS NULL |
| 工单池-我处理的 | getTicketPoolPage(viewType=my_handling) | handler_id = currentUserId AND ticket_status IN (2,3,4) |
| 工单池-全部 | getTicketPoolPage(viewType=all) | process_status=2 AND handler_group_id IN (myGroups) OR IS NULL |
| 工单详情 | getTicketPoolDetail | 额外验证"当前用户是否为处理人/组员" |
三段权限加一张 SQL 就搞定,没有任何一行代码去维护"用户X可见哪些工单"这种关系表——因为关系都藏在处理组的成员字段里了。
三、PC 端功能实现
3.1 工单列表(申请端视角)

▲ 申请端的工单列表:只显示当前用户创建的工单,区分流程状态(审批通过/待分配)与业务状态(待处理/处理中/已完成/已关闭),优先级用颜色徽标突出,"紧急"标红
设计要点:
- 左边菜单把"我的工单 / 工单池 / 处理组管理 / SLA 规则"四个入口分清,避免申请人误入后台配置
- 工单编号使用
OA120-{yyyyMMdd}{5位流水}规范(与公文、请假、差旅等单据统一),前 3 位是单据类型代码 - 流程状态用灰色药丸式 Tag,业务状态用彩色 Tag——颜色密度反过来映射"关注度":审批通过了状态可以"灰",处理中的工单必须"显眼"
3.2 工单新增表单

▲ 工单创建表单:标题/分类/优先级/处理组四要素 + 富文本问题描述 + 附件上传,申请部门/申请人自动从当前登录用户带入,避免手填
表单字段分组:
| 字段组 | 字段 | 是否必填 | 说明 |
|---|---|---|---|
| 基础信息 | 工单标题 | ✅ | 一句话说清楚 |
| 工单分类 | ❌ | 字典 oa_ticket_category(问题咨询/故障报修/需求建议/投诉/其他) | |
| 优先级 | ✅ | 字典 oa_ticket_priority(低/中/高/紧急) | |
| 处理组 | ❌ | 可以不选,由系统按分类默认路由或进入兜底工单池 | |
| 内容 | 问题描述 | ❌ | 富文本,支持图片粘贴 |
| 备注 | ❌ | 纯文本补充 | |
| 附件 | 附件列表 | ❌ | 通过 attachmentService 挂到 OA_TICKET_BILL 业务类型上 |
"优先级"和"分类"两个字段是整个 SLA 体系的入口——不是装饰,而是计算 deadline 的两个变量。
3.3 工单池(处理端视角)

▲ 工单池-待接单视图:只显示"已审批通过 + 待分配 + 属于我所在处理组(或无组)"的工单,SLA 响应/解决状态用红色"已超时"徽标直接标出,一眼看到最紧急的
工单池的三个 Tab 就是工单模块最重要的"视图抽象":
| Tab | viewType | 对应筛选条件 | 谁该关注 |
|---|---|---|---|
| 待接单 | pending | ticket_status=1 且在我组 | 组员、组长(抢单) |
| 我处理的 | my_handling | handler_id=me 且状态 2/3/4 | 处理人(跟进) |
| 全部 | all | 审批通过 + 在我组 | 组长(审查) |
四、关键业务逻辑
4.1 提交工单:走 BPM
提交工单本质是"创建单据 + 启动流程 + 绑定 businessKey":
@Override
@Transactional(rollbackFor = Exception.class)
public Long submitTicketBill(TicketBillSaveReqVO saveReqVO) {
if (StringUtils.isBlank(saveReqVO.getBillCode())) {
saveReqVO.setBillCode(BillCodeUtils.generateBillCode(
SystemEnum.OA, OaBillTypeEnum.OA_TICKET_BILL));
}
TicketBillDO ticketBill = BeanUtils.toBean(saveReqVO, TicketBillDO.class)
.setProcessStatus(BpmTaskStatusEnum.RUNNING.getStatus())
.setSubmittedTime(LocalDateTime.now());
ticketBillMapper.insertOrUpdate(ticketBill);
Map<String, Object> vars = BpmProcessVariableUtils.buildBillVariables(saveReqVO);
vars.put(BpmProcessVariableConstants.CAUSE,
saveReqVO.getCreatorName() + "提交工单:" + saveReqVO.getTitle());
String processInstanceId = processInstanceApi.submitProcessInstance(
Long.valueOf(saveReqVO.getCreator()),
new BpmProcessInstanceCreateReqDTO()
.setProcessDefinitionKey(OaBillTypeEnum.OA_TICKET_BILL.getProcessDefinitionKey())
.setVariables(vars)
.setBusinessKey(String.valueOf(ticketBill.getId()))
).getCheckedData();
ticketBillMapper.updateById(new TicketBillDO()
.setId(ticketBill.getId()).setProcessInstanceId(processInstanceId));
return ticketBill.getId();
}
三处值得注意:
- 单据先落库再启流程:保证即使流程启动失败,单据也能被清理掉;反过来则会产生孤儿流程
- businessKey = 单据 id:BPM 回调时用这个反查业务单
CAUSE变量:BPM 审批列表的"事由"列直接展示,不用审批人点进详情
4.2 审批通过回调:一次性搞定 SLA + 分配
这是整个工单系统最有设计感的一段代码——FlowBillService.onProcessApproved 是 BPM 框架在流程"审批通过"结束节点时自动回调的钩子:
@Override
@Transactional(rollbackFor = Exception.class)
public void onProcessApproved(String businessKey) {
Long id = Long.parseLong(businessKey);
TicketBillDO bill = ticketBillMapper.selectById(id);
if (bill == null) return;
TicketBillDO updateObj = new TicketBillDO();
updateObj.setId(id);
updateObj.setTicketStatus(TicketStatusEnum.PENDING_ASSIGN.getStatus()); // 先置"待分配"
// 1. 匹配 SLA 规则并算 deadline
TicketSlaRuleDO slaRule = ticketSlaRuleService.matchSlaRule(
bill.getPriority(), bill.getCategory());
if (slaRule != null) {
LocalDateTime baseTime = bill.getSubmittedTime() != null
? bill.getSubmittedTime() : LocalDateTime.now();
if (slaRule.getResponseHours() != null && slaRule.getResponseHours() > 0) {
updateObj.setResponseDeadline(baseTime.plusHours(slaRule.getResponseHours()));
}
if (slaRule.getResolveHours() != null && slaRule.getResolveHours() > 0) {
updateObj.setResolveDeadline(baseTime.plusHours(slaRule.getResolveHours()));
}
}
// 2. 尝试自动分配(如果工单指定了处理组)
if (bill.getHandlerGroupId() != null) {
TicketHandlerGroupDO group = ticketHandlerGroupService.getHandlerGroup(bill.getHandlerGroupId());
if (group != null) {
Long assignedUserId = ticketAssignService.tryAutoAssign(bill, group);
if (assignedUserId != null) {
updateObj.setHandlerId(assignedUserId);
AdminUserRespDTO user = adminUserApi.getUser(assignedUserId).getCheckedData();
if (user != null) updateObj.setHandlerName(user.getNickname());
updateObj.setAssignedTime(LocalDateTime.now());
updateObj.setTicketStatus(TicketStatusEnum.PENDING_PROCESS.getStatus()); // 自动分配成功则进"待处理"
}
}
}
ticketBillMapper.updateById(updateObj);
ticketReplyService.addSystemRecord(id, "工单审批通过,进入" +
(TicketStatusEnum.PENDING_PROCESS.getStatus().equals(updateObj.getTicketStatus())
? "待处理" : "工单池"));
}
三步合并成一个事务:
- 状态改为"待分配" —— 兜底:哪怕 SLA 和分配都失败,工单也已进入工单池
- 匹配 SLA 规则 —— 算出两个 deadline 并落库
- 尝试自动分配 —— 命中则升级为"待处理",没命中就保持"待分配"让组员抢
代码还藏了一个细节:SLA 匹配失败包 try-catch 但不抛异常。因为业务上"分配失败"比"SLA 配置错误"严重得多,不能让一条烂规则把整张单卡住。
4.3 接单:校验"能不能接"是重点
@Override
@Transactional(rollbackFor = Exception.class)
public void acceptTicket(Long id) {
TicketBillDO bill = validateTicketExists(id);
Long currentUserId = SecurityFrameworkUtils.getLoginUserId();
// 合法状态:待分配(抢单) 或 待处理(点名指派后的确认接单)
if (!PENDING_ASSIGN.equals(bill.getTicketStatus())
&& !PENDING_PROCESS.equals(bill.getTicketStatus())) {
throw exception(TICKET_BILL_STATUS_INVALID);
}
// 抢单必须是处理组成员
if (PENDING_ASSIGN.equals(bill.getTicketStatus()) && bill.getHandlerGroupId() != null) {
TicketHandlerGroupDO group = ticketHandlerGroupService.getHandlerGroup(bill.getHandlerGroupId());
if (group != null && isNotBlank(group.getMemberUserIds())) {
boolean isMember = Arrays.stream(group.getMemberUserIds().split(","))
.map(String::trim).anyMatch(uid -> uid.equals(String.valueOf(currentUserId)));
if (!isMember) throw exception(TICKET_BILL_NOT_IN_GROUP);
}
}
// 指派单必须是本人接单
if (PENDING_PROCESS.equals(bill.getTicketStatus())
&& bill.getHandlerId() != null
&& !bill.getHandlerId().equals(currentUserId)) {
throw exception(TICKET_BILL_NOT_HANDLER);
}
AdminUserRespDTO user = adminUserApi.getUser(currentUserId).getCheckedData();
ticketBillMapper.updateById(new TicketBillDO()
.setId(id)
.setHandlerId(currentUserId)
.setHandlerName(user != null ? user.getNickname() : String.valueOf(currentUserId))
.setAcceptedTime(LocalDateTime.now())
.setTicketStatus(TicketStatusEnum.PROCESSING.getStatus()));
ticketReplyService.addSystemRecord(id, (user != null ? user.getNickname() : "处理人") + " 已接单");
}
三种异常场景被分得一清二楚:状态非法 / 不在本组 / 不是本人。任何一项没守住,都会让"谁都能接别人的单"成真,工单系统的公信力就塌了。
4.4 三种回复:公开 / 内部 / 系统
回复服务的核心不是"存数据",而是"按身份过滤":
public List<TicketReplyRespVO> getReplyListByTicketId(Long ticketId, String source) {
List<TicketReplyDO> replies = ticketReplyMapper.selectListByTicketId(ticketId);
// 申请端过滤掉内部备注(type=2)
if ("apply".equals(source)) {
replies = replies.stream()
.filter(r -> r.getType() == null || r.getType() != 2)
.toList();
}
// ... 附带 creator 的 nickname
return BeanUtils.toBean(replies, TicketReplyRespVO.class);
}
| type | 用途 | 申请端可见 | 处理端可见 |
|---|---|---|---|
| 1 公开回复 | 处理人和申请人之间的正式对话 | ✅ | ✅ |
| 2 内部备注 | 处理组内部"我先查一下,8点前回他" | ❌ | ✅ |
| 3 系统记录 | "X 已接单"、"SLA 响应超时" | ✅ | ✅ |
安全点在 Service 层过滤而不在前端过滤——前端再怎么改模板也只是表象,接口层过滤才是真的"看不见"。
五、工单处理详情:三个页面合一的"处理中枢"

▲ 工单处理详情:左侧工单信息+回复区,右侧 SLA 时效+关键时间节点,顶部按钮组按当前状态动态出现(接单/转派/完成/关闭/激活)
这个页面的真正设计精髓在于 "按状态出按钮":
<Button v-if="formData.ticketStatus === 1" @click="handleAccept">接单</Button>
<Button v-if="formData.ticketStatus === 1" @click="handleAssign">分配</Button>
<Button v-if="formData.ticketStatus === 2 && isHandler" @click="handleAccept">接单</Button>
<Button v-if="formData.ticketStatus === 3 && isHandler" @click="handleComplete">标记完成</Button>
<Button v-if="[2, 3].includes(formData.ticketStatus)" @click="handleTransfer">转派</Button>
<Button v-if="[1, 4].includes(formData.ticketStatus)" @click="handleCloseTicket">关闭</Button>
<Button v-if="formData.ticketStatus === 5" @click="handleActivate">激活</Button>
这种写法的好处是状态机永远是"真相的唯一来源"——新增一种状态,只要在 Service 层定义好合法操作,前端加一行 v-if 就够了,不需要写复杂的 RBAC 权限表。
六、RuoYi Office 工单模块的 5 个创新设计
6.1 无组工单(兜底池)
handler_group_id IN (:myGroupIds) OR handler_group_id IS NULL
这行 SQL 是整个工单池的灵魂。当申请人没指定处理组,或者某个组被停用了,工单会进入"无组工单池",公司里所有组员都能看到并抢单——这是 RuoYi Office 避免工单无人认领的最后一道兜底。
6.2 双计时器:响应 + 解决
很多工单系统只有一个 SLA 时限,但这会漏掉"接了单但一直拖着不办"的场景。RuoYi Office 用两个独立时间戳:
response_deadline:接单截止时间——检验"接单速度"resolve_deadline:结案截止时间——检验"办结速度"
| 场景 | response_timeout | resolve_timeout |
|---|---|---|
| 接单慢但办得快 | ✅ | ❌ |
| 接单快但拖延 | ❌ | ✅ |
| 接单慢且拖延 | ✅ | ✅ |
| 合格 | ❌ | ❌ |
6.3 自动分配失败"主动降级"
自动分配不是"保证一定分出去",而是"如果能分就分,分不了就进工单池"。源码里:
try {
Long assignedUserId = ticketAssignService.tryAutoAssign(bill, group);
if (assignedUserId != null) { /* 升级为待处理 */ }
} catch (Exception e) {
log.warn("[onProcessApproved] 自动分配失败,billId: {}, 错误: {}", id, e.getMessage());
}
分配失败只打 WARN 日志,不抛异常——哪怕组成员 ID 烂了、数据库连接抖动了,工单也能按"待分配"落库等人抢。
6.4 转派不丢历史
public void transferTicket(TicketBillTransferReqVO reqVO) {
// 只改 handler_id / handler_group_id,不动 replies、不动 SLA
updateObj.setHandlerId(reqVO.getHandlerId());
updateObj.setHandlerName(reqVO.getHandlerName());
// ticketStatus 保持 PENDING_PROCESS 或 PROCESSING
ticketReplyService.addSystemRecord(reqVO.getId(), "工单已转派给 " + reqVO.getHandlerName());
}
转派只改两个字段,SLA 的两个 deadline 都不重置——因为从客户视角看,"换了谁处理"跟"你们还欠我多久回复"没关系,时限应该从最初提交算起。
6.5 反激活:关闭 ≠ 结束
public void activateTicket(Long id) {
// 只有已关闭的工单能被激活
if (!CLOSED.equals(bill.getTicketStatus())) throw ...;
updateObj.setTicketStatus(PROCESSING.getStatus());
updateObj.setClosedTime(null);
updateObj.setCompletedTime(null);
}
关闭后的工单可以被"反激活"回到处理中状态——这对于"关闭后客户又回来说没修好"的场景至关重要,不用新建工单、历史回复全在。
七、数据结构
7.1 oa_ticket_bill(工单主表)
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | BIGINT | PK | 主键 |
| bill_code | VARCHAR(50) | UNIQUE | OA120-{yyyyMMdd}{5位流水} |
| process_instance_id | VARCHAR(64) | BPM 流程实例 ID | |
| process_status | TINYINT | 0草稿/1审批中/2通过/3拒绝/4取消 | |
| title / content / category / priority | 工单本体 | ||
| ticket_status | TINYINT | 0未启动/1待分配/2待处理/3处理中/4已完成/5已关闭 | |
| handler_group_id / handler_id / handler_name | 处理组和当前处理人 | ||
| response_deadline / resolve_deadline | DATETIME | SLA 双计时器 | |
| response_timeout / resolve_timeout | BIT(1) | 被 XXL-Job 标记的超时标志 | |
| submitted_time / assigned_time / accepted_time / completed_time / closed_time | DATETIME | 生命周期 5 个关键时间戳 | |
| reply_count | INT | 回复数量缓存,避免每次 count | |
| tenant_id / creator / create_time / ... | 公共字段 |
设计要点:
- 单独建了
idx_ticket_status、idx_handler_id、idx_handler_group_id、idx_resolve_deadline四个索引,对应四类最高频查询(工单池按状态、我处理的按 handler、处理组过滤、SLA 扫描) reply_count是冗余字段:新增回复时+1,避免在列表里 count 子查询- 5 个时间戳字段看似冗余,实际是"可重建的审计日志"——不用翻历史事件表就能看到工单的全部节点
7.2 oa_ticket_handler_group(处理组)

▲ 处理组列表:IT 运维组(轮询分配)、综合客服组(负载最少)、投诉专项组(手动分配),三种策略对应三种业务场景

▲ 处理组编辑:成员用逗号分隔的用户 ID 存储,组长用于超时通知抄送,分配策略决定 onProcessApproved 怎么派单
| 字段 | 类型 | 说明 |
|---|---|---|
| name / description | 处理组基本信息 | |
| member_user_ids | VARCHAR(2000) | 核心字段:逗号分隔的成员用户 ID |
| leader_user_id | BIGINT | 组长,SLA 超时时抄送通知 |
| assign_strategy | TINYINT | 0 手动 / 1 轮询 / 2 负载最少 |
| last_assign_index | INT | 轮询游标,每次派单后 (+1) % size |
7.3 oa_ticket_sla_rule(SLA 规则)

▲ SLA 规则按 sort 升序匹配:投诉类 SLA(sort=5) → 紧急通用(10) → 故障报修-高(20) → 中优先级通用(40) → 低优先级咨询(50) → 兜底(999),最具体的规则排最前保证命中
| 字段 | 说明 |
|---|---|
| priority | 必须匹配的优先级(1-4) |
| category | 可选匹配的分类,为 NULL 表示"不限分类" |
| response_hours / resolve_hours | 两个 SLA 时限 |
| warn_before_minutes | 提前预警分钟数(默认 30) |
| sort | 越小越先匹配 |
7.4 oa_ticket_reply(回复)
| 字段 | 说明 |
|---|---|
| ticket_id | 外键指向工单 |
| content | TEXT,支持富文本 |
| type | 1 公开 / 2 内部 / 3 系统 |
没有 creator_name 字段——显示时按 creator 反查 system_users.nickname,保证员工改名后所有历史回复的名字同步更新。
八、技术亮点总结
| 设计要点 | 实现方式 | 价值 |
|---|---|---|
| 双状态字段解耦 | process_status + ticket_status 两字段独立 | BPM 和业务各自演进互不影响 |
| SLA 一次算好落库 | onProcessApproved 回调时一次性写入 response_deadline / resolve_deadline | 列表查询 0 成本,超时扫描可走索引 |
| 路由 = SQL 条件 | group_id IN (:myGroups) OR IS NULL | 不用中间件、不用多表 join |
| 自动分配主动降级 | 失败只打 WARN 不抛异常,落回工单池 | 工单永远不会因为分配失败而卡住 |
| 回复三类 + 服务端过滤 | type=1/2/3,apply 视图过滤 type=2 | 前端再怎么改模板都看不到内部备注 |
| 转派不丢 SLA | 只更新 handler 字段,deadline 从提交时间算 | 换人不延期,客户感知一致 |
| 反激活 | 已关闭 → 处理中 | 不用新建工单,历史对话全保留 |
| 兜底工单池 | handler_group_id 可为 NULL | 无组工单全员可见,避免无人认领 |
| XXL-Job 扫描超时 | 每分钟扫一遍 + 提前 30min 预警 | 不用 MQ、不用延时队列,纯定时任务 |
| 时间戳驱动审计 | 5 个关键时间戳字段全落库 | 不用事件表就能重建完整生命周期 |
九、快速体验
操作路径:登录 → OA → 工单列表 / 工单池 / 处理组管理 / SLA 规则
推荐体验流程(5 分钟):
- 进入
处理组管理,编辑
