尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

审批系统表结构设计:4张表12字段支撑动态流程

审批系统表结构设计:4张表12字段支撑动态流程 1. 为什么“一个简单表结构”反而最难设计——从Java审批流程的业务本质说起你有没有遇到过这样的场景前端同事催着要接口后端刚写完CRUD测试环境一跑流程卡在“审批中”状态死活不往下走或者运营突然说“这个单子要加个紧急审批节点”你翻出数据库ER图发现加个字段得改七八张表连带三个服务重启更别提面试官冷不丁问一句“如果审批人能动态指定、流程能随时跳转、历史记录还要支持追溯你的表怎么设计”——这时候光会写Transactional和Async真没用。我做过6个不同行业的审批系统从电商售后工单、HR入职流程到制造业BOM变更、金融信贷风控最深的体会是审批流程的复杂度90%不在Java代码里而在那几张表的字段设计上。标题里那个“简单表结构设计方案By OU”不是指代码少、SQL短而是指用最少的表、最正交的字段、最可扩展的范式把“谁审、审什么、怎么审、审到哪、为什么卡住”这五个问题一次性说清楚。OU在这里不是缩写是“Owner-User”的实践共识——每个字段必须有明确的所有者Owner和使用者User不能是“大家都能改、谁都负责不了”的模糊地带。关键词里没给具体内容但热搜词暴露了真实痛点Java开发者天天刷八股文背线程池参数却对“如何让一张表既支持固定三级审批又兼容临时加签、会签、转审、撤回”毫无头绪学了Spring Boot自动装配一碰到“审批节点配置要热更新、不重启服务”就只能硬编码写死甚至有人把整个流程图存成JSON字段塞进process_config列里美其名曰“灵活”结果半年后没人敢动这条SQL因为解析逻辑散落在三个Service里。所以这篇不是教你怎么写ProcessService而是带你重新理解表结构是审批系统的骨架Java代码只是肌肉和神经。骨架歪了再强的肌肉也跑不快骨架太细一用力就断。接下来我会用真实项目中的三套方案对比告诉你为什么最终选型只用4张核心表、12个关键字段以及每个字段背后藏着的5个业务陷阱。2. 被90%开发者忽略的审批本质状态机不是流程图而是状态迁移规则集很多Java开发者一上来就画Activiti或Flowable的BPMN图或者直接手撸状态枚举类public enum ApprovalStatus { DRAFT, SUBMITTED, APPROVING, REJECTED, APPROVED, CANCELLED }这没错但错在把它当成了终点。真正的审批状态机从来不是静态枚举而是状态动作上下文权限时间戳的五元组。举个例子同样是APPROVING状态A员工提交的采购单审批人看到的是“待您审批剩余2小时”而B员工提交的合同审批人看到的是“需法务财务双签当前法务已通过”。这两个APPROVING表面相同底层数据结构却天差地别。我们拆解一个真实案例某SaaS平台的客户合同审批。初始需求只有“销售提交→总监审批→法务审核→生效”但上线3个月后新增了4种变体紧急合同跳过总监直送法务大额合同需财务额外会签跨国合同法务审核后自动触发翻译环节历史合同续签复用原审批路径但节点负责人可能已离职。如果按传统思路每种变体都建新表或加新字段很快就会出现approval_flow_type_v2、approval_flow_type_v3_bak这种命名。而我们的解法是把流程定义和实例执行彻底分离。前者存配置后者存快照中间靠“节点路由规则”动态绑定。具体到表结构这意味着approval_process_def表只存流程模板ID、名称、版本、是否启用不存任何节点逻辑approval_node_def表存节点定义节点ID、类型、角色、顺序、超时设置但不存审批人ID——那是运行时才确定的approval_instance表存每次审批的实例实例ID、关联业务ID、当前状态、创建人、创建时间这是唯一带业务主键的表approval_task表存每个待办任务任务ID、实例ID、节点ID、处理人ID、状态、开始时间、截止时间这才是真正驱动流程的“活数据”。提示很多团队把审批人ID直接存在approval_instance里导致无法支持“一人多岗”如总监兼管销售和产品、“岗位继承”A离职后B自动接替其审批权限、“动态指派”根据合同金额自动分配审批人。这违反了OU原则——approval_instance的Owner是业务单据User是流程引擎而审批人归属关系的Owner是组织架构模块User才是审批引擎。这种设计下新增“跨国合同”变体只需在approval_node_def里加一条记录节点IDtranslate_step类型AUTO前置节点legal_review再在approval_process_def里新建一个模板引用它。Java代码里不需要改一行更不用重启服务。这才是“简单”的真意复杂度被沉淀在数据层而非代码层。3. 四张核心表的字段级设计为什么12个字段撑起所有审批变体既然骨架决定一切我们就逐字段拆解这四张表的设计逻辑。所有字段命名遵循Java Bean规范驼峰但数据库列名用下划线node_type避免ORM映射歧义。重点不是“有哪些字段”而是“为什么必须是这个字段、不能是那个字段”。3.1 approval_process_def流程模板的“宪法”字段名类型是否为空默认值说明idBIGINT PKNOT NULL-主键自增codeVARCHAR(64)NOT NULL-流程编码如contract_approval_v1用于Java代码中switch判断比ID更易维护nameVARCHAR(128)NOT NULL-流程名称前台展示用versionINTNOT NULL1版本号每次修改模板时1旧实例仍按原版本执行is_activeTINYINTNOT NULL1是否启用停用旧版本时设为0不影响历史实例created_byBIGINTNOT NULL-创建人ID关联用户表created_timeDATETIMENOT NULLCURRENT_TIMESTAMP创建时间updated_byBIGINTNOT NULL-最后修改人IDupdated_timeDATETIMENOT NULLCURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP自动更新关键设计点code字段不可替代idJava服务启动时会预加载所有is_active1的流程定义到内存Map里Key是code。这样业务代码调用ApprovalEngine.start(contract_approval_v1, businessId)比传ID更安全——ID可能被误删code是业务语义标识。version必须显式管理不要依赖“最新一条就是最新版”。曾有个项目因DBA误操作把v2的is_active设为0但忘了设v1的is_active为1导致所有新单子找不到流程模板。加了version后启动时校验MAX(version)对应is_active1否则抛异常告警。created_by/updated_by必须存审批流程是强审计场景谁创建、谁修改、何时修改必须可追溯。别信“反正有操作日志”日志可能被清理而这张表是核心资产。3.2 approval_node_def节点定义的“交通规则”字段名类型是否为空默认值说明idBIGINT PKNOT NULL-主键process_codeVARCHAR(64)NOT NULL-关联approval_process_def.code实现一对多node_codeVARCHAR(64)NOT NULL-节点编码如director_review、legal_checkJava代码中用作策略分发Keynode_typeTINYINTNOT NULL-节点类型1人工审批2自动校验3条件分支4结束节点role_codeVARCHAR(64)NULL-审批角色编码如sales_director、legal_officer不是用户IDsort_orderINTNOT NULL0同一流程内节点顺序支持拖拽调整timeout_hoursINTNULL-超时小时数null表示不限时is_requiredTINYINTNOT NULL1是否必审节点0表示可跳过如大额合同才触发财务会签condition_scriptTEXTNULL-条件脚本Groovy用于动态分支如business.amount 100000关键设计点role_code代替user_id这是OU原则的核心体现。role_code由组织架构模块维护审批引擎只认角色。当总监离职HR在组织架构系统里把sales_director角色指派给新人所有待办任务自动流转过去Java代码零改动。node_type用数字而非字符串避免MANUAL/AUTO这种字符串比较Java里用enum NodeType { MANUAL(1), AUTO(2) }数据库存int查询快、索引友好、序列化小。condition_script存脚本而非SQL早期我们存SQL片段结果运维不敢改因为怕SQL注入。换成Groovy脚本后用沙箱执行超时自动中断且脚本能访问business对象业务单据实体比拼SQL灵活得多。例如return business.contractType.equals(OVERSEAS) business.countryCode.equals(US)。3.3 approval_instance审批实例的“身份证”字段名类型是否为空默认值说明idBIGINT PKNOT NULL-主键business_typeVARCHAR(32)NOT NULL-业务类型如CONTRACT、PURCHASE_ORDER用于区分不同业务单据business_idBIGINTNOT NULL-业务单据ID关联具体业务表process_codeVARCHAR(64)NOT NULL-关联approval_process_def.code实例创建时确定current_statusTINYINTNOT NULL10当前状态码10DRAFT20SUBMITTED30APPROVING...created_byBIGINTNOT NULL-提交人IDcreated_timeDATETIMENOT NULLCURRENT_TIMESTAMP提交时间last_updated_timeDATETIMENOT NULLCURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP最后状态变更时间variablesJSONNULL-业务变量快照如{amount: 150000, currency: USD}用于条件判断和通知模板关键设计点business_typebusiness_id构成复合外键不建物理外键约束但Java层强制校验。这样避免跨库关联业务表可能在分库中且business_type可扩展新业务无需改表结构。current_status用状态码而非字符串同node_typeJava里用enum InstanceStatus映射数据库存int状态变更时用UPDATE ... SET current_status ? WHERE id ? AND current_status ?做乐观锁防止并发覆盖。variables存JSON而非单独字段业务单据的字段千变万化采购单有供应商ID合同有签约方强行拆成business_field_1到business_field_10是灾难。JSON字段用MySQL 5.7的JSON类型支持索引和查询如SELECT * FROM approval_instance WHERE JSON_CONTAINS(variables, USD, $.currency)。3.4 approval_task待办任务的“作战指令”字段名类型是否为空默认值说明idBIGINT PKNOT NULL-主键instance_idBIGINTNOT NULL-关联approval_instance.idnode_codeVARCHAR(64)NOT NULL-关联approval_node_def.node_codeassignee_idBIGINTNOT NULL-实际处理人ID这里是真正的用户IDstatusTINYINTNOT NULL10任务状态10TODO20PROCESSING30COMPLETED40REJECTED50CANCELLEDstart_timeDATETIMENOT NULLCURRENT_TIMESTAMP任务生成时间due_timeDATETIMENULL-截止时间由timeout_hours计算得出completed_timeDATETIMENULL-完成时间commentsVARCHAR(1024)NULL-审批意见限制长度防SQL注入关键设计点assignee_id是唯一存用户ID的地方它来自role_code的实时解析。当流程走到legal_check节点引擎查role_codelegal_officer对应的所有用户按规则如轮询、最近处理量最少选一个写入此字段。这样既满足“动态指派”又保证数据落地。due_time必须计算后存储不能每次查时用start_time INTERVAL timeout_hours HOUR计算。因为timeout_hours可能后续修改而历史任务的截止时间必须固定。写入时算好存进去确保审计准确。comments长度设为1024够写两句话但防恶意长文本打爆数据库。曾有项目没限制用户粘贴整篇Word文档导致approval_task表单行超2MB备份失败。4. Java层的关键实现如何让这四张表“活”起来表结构是骨架Java代码是让骨架动起来的肌肉。但很多团队把ORM当万能胶OneToMany嵌套三层一个getApprovalInstanceWithTasks()方法查出200条SQL性能雪崩。我们的做法是严格分层各层只做一件事且用最朴素的方式。4.1 DAO层拒绝JPA/Hibernate手写MyBatis XML理由很现实审批流程的SQL极其复杂涉及多表关联、状态过滤、时间范围、分页排序。JPA的Query要么写一堆JPQL难调试要么写原生SQL失去JPA优势。而MyBatis XML能清晰看到SQL全貌DBA可直接优化且支持动态SQL。以查询“我的待办任务”为例含业务单据摘要!-- ApprovalTaskMapper.xml -- select idselectMyPendingTasks resultTypecom.ou.approval.dto.MyTaskDto SELECT t.id AS taskId, t.node_code, i.business_type, i.business_id, i.current_status, i.created_time, -- 关联业务单据摘要用LEFT JOIN避免漏掉无摘要的单据 COALESCE(b.summary, 未知单据) AS businessSummary, -- 计算剩余时间单位小时 CASE WHEN t.due_time IS NULL THEN 999999 ELSE TIMESTAMPDIFF(HOUR, NOW(), t.due_time) END AS hoursLeft FROM approval_task t INNER JOIN approval_instance i ON t.instance_id i.id LEFT JOIN ( -- 业务单据摘要表按业务类型分表这里简化为一张 SELECT id, summary FROM contract WHERE type CONTRACT UNION ALL SELECT id, summary FROM purchase_order WHERE type PURCHASE_ORDER ) b ON i.business_id b.id AND i.business_type b.type WHERE t.assignee_id #{userId} AND t.status 10 -- TODO AND i.current_status IN (20, 30) -- SUBMITTED or APPROVING ORDER BY t.start_time DESC LIMIT #{offset}, #{limit} /select注意COALESCE和UNION ALL确保即使业务表没有摘要也不影响任务列表TIMESTAMPDIFF直接算出剩余小时数前端不用再算status和current_status双重过滤避免任务已处理但实例状态未同步的脏数据。4.2 Service层状态变更的“原子操作”封装审批的核心是状态变更必须保证ACID。我们不依赖Spring事务传播而是用状态机乐观锁事件发布三重保障。Service public class ApprovalInstanceService { Transactional public void submit(Long instanceId, Long submitterId) { // 1. 乐观锁查询获取当前状态和version ApprovalInstance instance instanceMapper.selectByIdForUpdate(instanceId); if (!instance.getCurrentStatus().equals(InstanceStatus.DRAFT)) { throw new BusinessException(仅草稿状态可提交); } // 2. 更新实例状态 instance.setCurrentStatus(InstanceStatus.SUBMITTED); instance.setLastUpdatedTime(new Date()); instanceMapper.updateById(instance); // 带version校验 // 3. 创建第一个待办任务根据process_code找首个节点 ApprovalNodeDef firstNode nodeDefMapper.selectFirstNode(instance.getProcessCode()); ApprovalTask task new ApprovalTask(); task.setInstanceId(instanceId); task.setNodeCode(firstNode.getNodeCode()); task.setAssigneeId(resolveAssignee(firstNode.getRoleCode())); // 动态指派 task.setStatus(TaskStatus.TODO); task.setStartTime(new Date()); task.setDueTime(calculateDueTime(firstNode.getTimeoutHours())); taskMapper.insert(task); // 4. 发布事件触发通知、日志等异步操作 eventPublisher.publishEvent(new InstanceSubmittedEvent(instanceId)); } private Long resolveAssignee(String roleCode) { // 调用组织架构服务获取该角色下的可用用户 ListLong users orgService.getUsersByRole(roleCode); return users.stream() .min(Comparator.comparingLong(orgService::getTaskCount)) // 选任务最少的 .orElseThrow(() - new BusinessException(无可用审批人)); } }关键细节selectByIdForUpdate加行锁防止并发提交updateById内部校验version避免ABA问题resolveAssignee不查DB调用远程服务解耦组织架构publishEvent用Spring Event异步处理通知不阻塞主流程。4.3 Controller层RESTful API设计哲学API不是功能堆砌而是资源操作。我们只暴露4个核心端点HTTP MethodPath说明幂等性POST/api/v1/approval/instances提交新审批是idempotent keyPUT/api/v1/approval/tasks/{taskId}/approve审批通过是状态机校验PUT/api/v1/approval/tasks/{taskId}/reject审批拒绝是GET/api/v1/approval/tasks/mine查询我的待办是特别说明幂等性提交接口带X-Idempotent-Key请求头服务端用Redis缓存keyresult5分钟内重复提交返回相同响应审批接口用WHERE id ? AND status TODO更新若已处理则影响行为0行返回409 Conflict所有响应返回标准格式{ code: 0, message: success, data: {...} }错误码统一管理。5. 面试高频题实战解析为什么这个设计能扛住“八股文”拷问现在回到热搜词里的那些Java面试题看看这套设计如何直击要害5.1 “Java线程等待都完成”——审批中的并行会签怎么实现会签如财务法务必须都通过不是靠CountDownLatch或CompletableFuture.allOf()硬等而是状态聚合。当approval_task表中同一instance_id下多个node_code如finance_review、legal_review的状态都变为COMPLETED触发AggregationChecker定时任务每5秒扫一次检查所有会签节点是否完成。一旦满足自动创建下一个节点任务。Java代码里没有await()只有状态轮询和聚合判断。好处是不阻塞线程、可监控、可重试、超时可告警。5.2 “java动态代理”——如何实现审批节点的策略分发不用InvocationHandler而是用策略模式Spring容器Component public class NodeHandlerRegistry { private final MapString, NodeHandler handlers new HashMap(); Autowired public void setHandlers(ListNodeHandler handlers) { handlers.forEach(h - this.handlers.put(h.getNodeCode(), h)); } public NodeHandler getHandler(String nodeCode) { return handlers.get(nodeCode); } } // 具体处理器 Component public class LegalReviewHandler implements NodeHandler { Override public String getNodeCode() { return legal_review; } Override public void handle(ApprovalTask task) { // 调用法务系统API校验合同条款 legalApiClient.validateContract(task.getBusinessId()); } }NodeHandlerRegistry在Spring启动时自动注册所有NodeHandlergetHandler(nodeCode)拿到实例handle()执行业务逻辑。比动态代理更直观、更易测试、更易排查。5.3 “java八股文”常问的事务与锁——审批状态变更如何保证一致性答案是数据库行锁 乐观锁 状态机校验三重保险。行锁SELECT ... FOR UPDATE锁定approval_instance行乐观锁UPDATE ... SET status ?, version version 1 WHERE id ? AND version ?状态机校验if (oldStatus DRAFT newStatus SUBMITTED) { ... } else throw;三者缺一不可。只靠行锁高并发下可能状态错乱只靠乐观锁没锁住行可能被其他事务修改只靠状态机没锁可能读到脏数据。5.4 “java将rest接口发布为mcp”——如何让审批流程对接外部系统MCPMicroservice Communication Protocol本质是标准化通信。我们定义统一的Webhook回调格式{ event: APPROVAL_COMPLETED, instanceId: 12345, businessType: CONTRACT, businessId: 67890, result: APPROVED, approverId: 1001, timestamp: 2024-06-15T10:30:00Z }审批引擎在approval_task状态变更为COMPLETED时调用webhookService.push(event)发送到配置的URL。Java层只管发不关心对方怎么处理失败重试3次日志留痕。6. 踩过的坑与血泪经验那些文档里不会写的细节最后分享几个真实项目里踩过的坑都是血换来的教训6.1 “审批人离职待办任务石沉大海”——角色继承的兜底方案按理说role_code指向组织架构HR更新后自动生效。但现实中HR系统可能延迟同步或审批人正在休假。我们的兜底方案在approval_task表加fallback_assignee_id字段存备用审批人IDresolveAssignee()方法里先查主角色查不到则查fallback_assignee_id后台提供页面允许管理员手动为特定任务指定替补审批人。6.2 “流程改了历史单子还按旧规则走”——版本隔离的硬核实现approval_instance.process_code存的是模板编码不是ID。当新建contract_approval_v2时老单子仍用v1新单子用v2。但有个陷阱approval_node_def.process_code是外键如果v1被停用is_active0v1的节点定义还在不影响历史实例。关键是**approval_instance表不存process_id只存process_code**避免ID失效。6.3 “超时提醒发了100遍”——定时任务的去重与幂等用Quartz调度TimeoutChecker但集群部署时多个节点可能同时扫到同一条任务。解决方案SELECT ... FOR UPDATE SKIP LOCKEDMySQL 8.0跳过已被锁的行或用Redis分布式锁SET timeout_check_lock_{taskId} 1 NX EX 3030秒过期每次扫描加AND last_notify_time DATE_SUB(NOW(), INTERVAL 1 HOUR)1小时内只发一次提醒。6.4 “法务说合同条款变了要加个新校验”——热更新脚本的沙箱安全condition_script用Groovy但必须沙箱化。我们用GroovyShell配合CompilerConfigurationCompilerConfiguration config new CompilerConfiguration(); config.setScriptBaseClass(SecureScript); // 继承自SecurityManager config.addCompilationCustomizers(new SecureASTCustomizer()); // 禁用反射、IO等危险AST节点 GroovyShell shell new GroovyShell(config); Object result shell.evaluate(script);SecureScript重写getClassLoader()、getDeclaredMethods()等确保脚本无法逃逸沙箱。上线前用JUnit跑所有历史脚本验证兼容性。我在实际项目中发现最省事的不是写最炫的代码而是把这四张表的字段想透、写稳。后来带新人第一课不是讲Spring Cloud而是让他们手写approval_task的CREATE TABLE语句并解释每个字段为什么是NOT NULL、为什么用TINYINT、为什么JSON字段要加索引。当他们能说出“assignee_id必须存这里因为它是任务层面的唯一责任人而role_code在节点定义里代表能力要求”时才算真正入门。审批流程的优雅不在代码行数而在数据设计的克制与精准。
返回列表