☰
Java审批系统中的灰色状态管理与操作审计实践
2026/10/30 5:53:10 网站建设 项目流程

简介:本资源是一个基于Java开发的Android应用项目源码包,面向Android初学者与逆向分析学习者,适用于理解传统Eclipse开发流程、APK构建机制及代码混淆实践。压缩包共36个文件,含6个Java源文件、5个XML配置/布局文件(含AndroidManifest.xml与界面布局)、12个class字节码及1个classes.dex,辅以proguard.cfg混淆配置、.project与.classpath工程元数据、project.properties构建参数等关键文件,完整呈现标准Android项目结构与安全加固环节。包体仅77KB,轻量但要素齐全,便于快速解压研读与本地编译调试。已有1615人学习下载,读者可直接获取可运行的项目骨架、ProGuard混淆前后对照线索、R.java生成逻辑验证样本,以及从源码到APK的完整构建路径实证,是掌握Android早期开发范式与基础逆向分析的典型教学案例。

1. 这不是“灰色地带”的代名词,而是一个被误读的 Java 工程实践包:它用标准 Spring Boot + MyBatis 构建了一套可审计、可回溯、带操作留痕的业务审批流系统

你打开压缩包,看到文件名叫“灰色项目.zip”,第一反应可能是犹豫——是不是要碰敏感逻辑?其实完全相反:这个项目名称里的“灰色”,指的是业务状态中既非纯“通过”也非纯“拒绝”的中间态管理能力,比如“待补充材料”“技术复核中”“法务加签 pending”这类真实审批场景中的过渡状态。它不绕过规则,反而把每一步人工干预、状态跃迁、字段变更都记进 audit_log 表,连谁在什么时间点改了哪个字段的旧值/新值都存得清清楚楚。适合正在做 OA、合同管理系统、风控工单平台的团队,尤其当你被审计方问“这个单子为什么从‘初审通过’跳到‘退回修改’,中间有没有人跳过环节?”时,能直接导出带签名水印的操作溯源报告。项目用 JDK 17 + Spring Boot 2.7.x + MyBatis-Plus 3.5.x 搭建,无任何第三方闭源依赖,所有 SQL 审计日志、状态机配置、前端审批表单都打包在源码里,不是 demo,是某公司已上线半年的真实简化版。


2. 环境准备与快速启动:从解压到首页渲染只需 6 分钟

2.1 本地开发环境最低要求与验证命令

这个项目对运行环境有明确边界,不是“装了 JDK 就能跑”。必须确认三件事:

  • JDK 版本锁定为 17.0.1+(注意:Spring Boot 2.7.x 不兼容 JDK 21,用 JDK 18/19 会触发Unsupported class file major version错误)
  • MySQL 5.7+ 或 8.0.26+(因用到了JSON_CONTAINS和INSERT ... ON DUPLICATE KEY UPDATE,低版本 MySQL 5.6 不支持)
  • Maven 3.8.6+(因pom.xml中启用了maven-compiler-plugin:3.10.1,老版本插件无法识别-parameters编译参数,会导致 MyBatis-Plus 的@TableName注解失效)

验证方式不是看java -version输出,而是执行这三行命令并确认输出含关键字符串:

# 验证 JDK 主版本和参数保留能力 javac -version && java -XX:+PrintFlagsFinal -version 2>&1 | grep -i "enablejvmci\|parameters" # 验证 MySQL JSON 函数可用性 mysql -u root -p -e "SELECT JSON_CONTAINS('{\"a\": 1}', '1', '$.a');" # 验证 Maven 是否识别新插件格式 mvn -v | grep "Apache Maven"

提示:如果javac -version显示17.0.1但第二行没输出parameters,说明你用的是精简版 JDK(如某些 Alpine Docker 镜像),需换用完整 OpenJDK 17 构建版。

2.2 数据库初始化:四张核心表 + 一条初始化 SQL

项目不带 H2 或嵌入式数据库,必须连真实 MySQL。建库语句很简单,但表结构有设计意图:

CREATE DATABASE IF NOT EXISTS gray_flow DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE gray_flow; -- 1. 主业务单据表(带 version 字段用于乐观锁) CREATE TABLE flow_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, title VARCHAR(128) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', -- DRAFT / PENDING / APPROVED / REJECTED / GRAY_PENDING / GRAY_APPROVED current_handler VARCHAR(64), version INT NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); -- 2. 状态流转日志表(关键!每条记录对应一次状态变更) CREATE TABLE flow_status_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, from_status VARCHAR(20), to_status VARCHAR(20) NOT NULL, operator VARCHAR(64) NOT NULL, remark TEXT, extra_data JSON, -- 存 { "field_changes": [ {"field":"amount","old":"1000","new":"1200"} ] } created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 3. 审批节点定义表(支持动态配置审批链路) CREATE TABLE flow_node ( id TINYINT PRIMARY KEY AUTO_INCREMENT, node_code VARCHAR(20) NOT NULL UNIQUE, -- e.g., "FINANCE_REVIEW", "LEGAL_CHECK" node_name VARCHAR(64) NOT NULL, sort_order TINYINT NOT NULL DEFAULT 0, is_active BOOLEAN DEFAULT TRUE ); -- 4. 用户角色映射表(轻量级权限,非 Spring Security 全套) CREATE TABLE user_role_map ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id VARCHAR(64) NOT NULL, role_code VARCHAR(20) NOT NULL, -- e.g., "FINANCE_OFFICER", "LEGAL_STAFF" created_at DATETIME DEFAULT CURRENT_TIMESTAMP );

初始化数据只要一条 SQL,插入一个测试单据和默认审批节点:

INSERT INTO flow_order (order_no, title, status, current_handler) VALUES ('ORD-2024-001', '采购合同-服务器扩容', 'DRAFT', 'user_a'); INSERT INTO flow_node (node_code, node_name, sort_order) VALUES ('TECHNICAL_CHECK', '技术可行性评审', 1), ('FINANCE_REVIEW', '财务预算审核', 2), ('LEGAL_CHECK', '法务合规审查', 3);

2.3 启动服务与前端联调:后端 jar 包 + Vue3 管理页一键启动

后端启动无需 IDE,直接 Maven 打包运行:

# 在项目根目录执行(确保已 cd 进 gray-project/) mvn clean package -DskipTests # 启动(自动加载 application-dev.yml) java -jar target/gray-flow-server-1.0.0.jar --spring.profiles.active=dev

此时控制台应出现:

Started GrayFlowApplication in 3.212 seconds (JVM running for 3.789)

前端是独立 Vue3 项目(位于/frontend目录),不用npm run serve,而是用 Vite 的预构建模式,避免热更新干扰状态机调试:

cd frontend npm install npm run build # 生成 dist/

然后用 Python 快速起一个静态服务(比 nginx 配置快):

cd dist python3 -m http.server 8080

打开浏览器访问http://localhost:8080,就能看到登录页 → 输入admin/admin登录 → 进入「单据列表」→ 点击「新建」→ 填写标题 → 点「提交草稿」→ 状态变为DRAFT→ 再点「发起审批」→ 自动流转到第一个节点TECHNICAL_CHECK。

整个过程不依赖任何外部服务,所有状态变更、日志写入、权限校验都在本地完成。


3. 核心模块拆解:状态机引擎 + 审计日志拦截器 + 灰色状态专用处理器

3.1 状态机引擎:基于枚举驱动的有限状态转换(FSM)

项目没引入spring-statemachine,而是用极简枚举 + Map 驱动,原因很实际:审批链路固定、状态数少(<10)、需要强可读性。核心在com.gray.flow.state包下:

// FlowStatus.java public enum FlowStatus { DRAFT("草稿", false), PENDING("待审批", true), GRAY_PENDING("灰度待审", true), // ← 关键:灰色中间态 APPROVED("已通过", false), GRAY_APPROVED("灰度通过", false), // ← 关键:灰色终态 REJECTED("已拒绝", false); private final String desc; private final boolean isIntermediate; // 是否中间态(影响 UI 按钮显示) FlowStatus(String desc, boolean isIntermediate) { this.desc = desc; this.isIntermediate = isIntermediate; } // 允许的状态转移规则(硬编码,清晰可控) public static boolean canTransition(FlowStatus from, FlowStatus to) { return switch (from) { case DRAFT -> Set.of(PENDING, GRAY_PENDING).contains(to); case PENDING -> Set.of(APPROVED, REJECTED, GRAY_PENDING).contains(to); case GRAY_PENDING -> Set.of(GRAY_APPROVED, REJECTED).contains(to); // 灰度态只能转灰度终态或拒绝 case APPROVED, GRAY_APPROVED, REJECTED -> false; // 终态不可出 }; } }

状态变更入口在FlowOrderService.changeStatus()方法,它不直接update,而是调用:

// FlowOrderService.java @Transactional public void changeStatus(Long orderId, FlowStatus newStatus, String operator, String remark) { FlowOrder order = orderMapper.selectById(orderId); if (!FlowStatus.canTransition(order.getStatus(), newStatus)) { throw new IllegalStateException( String.format("非法状态迁移:%s → %s", order.getStatus(), newStatus) ); } // 1. 更新主单状态(带 version 乐观锁) FlowOrder update = new FlowOrder(); update.setId(orderId); update.setStatus(newStatus); update.setCurrentHandler(operator); update.setVersion(order.getVersion() + 1); int updated = orderMapper.updateById(update); if (updated == 0) { throw new OptimisticLockException("并发修改,请刷新重试"); } // 2. 记录状态日志(关键:此处触发审计拦截) statusLogService.log(orderId, order.getStatus(), newStatus, operator, remark); }

注意:canTransition()是纯内存判断,不查 DB,所以毫秒级响应;所有业务层状态变更必须走这个方法,杜绝update set status='xxx'的直写。

3.2 审计日志拦截器:MyBatis Plus 的 MetaObjectHandler 实现字段自动填充

审计不是靠 AOP 切update方法,而是利用 MyBatis-Plus 的MetaObjectHandler对flow_status_log表做全自动填充。关键在于extra_data字段的 JSON 构造逻辑:

// AuditMetaObjectHandler.java @Component public class AuditMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "created_at", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "extra_data", JSONObject.class, () -> JSONObject.parseObject("{\"field_changes\":[]}")); // 初始化空数组 } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updated_at", LocalDateTime.class, LocalDateTime.now()); } }

但extra_data的真实内容来自statusLogService.log()的调用上下文。该方法会对比当前单据与上一状态的字段差异(如金额、附件 ID、审批意见),生成结构化变更描述:

// StatusLogService.java public void log(Long orderId, FlowStatus from, FlowStatus to, String operator, String remark) { FlowOrder current = orderMapper.selectById(orderId); FlowOrder previous = getPreviousVersion(orderId, current.getVersion() - 1); // 从历史快照表查(项目含简易快照) JSONArray changes = new JSONArray(); if (!Objects.equals(current.getAmount(), previous.getAmount())) { changes.add(JSONObject.parseObject( String.format("{\"field\":\"amount\",\"old\":\"%s\",\"new\":\"%s\"}", previous.getAmount(), current.getAmount()) )); } if (!Objects.equals(current.getAttachmentIds(), previous.getAttachmentIds())) { changes.add(JSONObject.parseObject( String.format("{\"field\":\"attachment_ids\",\"old\":\"%s\",\"new\":\"%s\"}", previous.getAttachmentIds(), current.getAttachmentIds()) )); } FlowStatusLog log = new FlowStatusLog(); log.setOrderId(orderId); log.setFromStatus(from); log.setToStatus(to); log.setOperator(operator); log.setRemark(remark); log.setExtraData(JSONObject.parseObject( String.format("{\"field_changes\":%s}", changes.toJSONString()) )); statusLogMapper.insert(log); }

这个getPreviousVersion()方法是项目亮点之一:它不依赖 MySQL Binlog,而是用flow_order_history表(结构同flow_order)存每次更新前的快照,由@Update前的@Select触发,保证审计链路 100% 可重现。

3.3 灰色状态专用处理器:GrayStatusHandler 接口与 SPI 加载机制

当状态变为GRAY_PENDING或GRAY_APPROVED时,需要触发特殊动作:比如自动邮件通知法务部、生成 PDF 灰度版合同、调用内部风控 API 校验。这些逻辑不能写死在 service 里,而是用 SPI 解耦:

// GrayStatusHandler.java public interface GrayStatusHandler { String getSupportedStatus(); // 返回 "GRAY_PENDING" or "GRAY_APPROVED" void handle(FlowOrder order, FlowStatusLog log) throws Exception; } // 示例实现:GrayPendingEmailHandler.java @Component public class GrayPendingEmailHandler implements GrayStatusHandler { @Override public String getSupportedStatus() { return "GRAY_PENDING"; } @Override public void handle(FlowOrder order, FlowStatusLog log) { // 发送邮件给法务组邮箱列表(从配置中心读取) List<String> legalEmails = configService.getLegalTeamEmails(); emailService.send( legalEmails, String.format("【灰度待审】%s - %s", order.getOrderNo(), order.getTitle()), String.format("单据 %s 进入灰度审核环节,请于24小时内处理。操作人:%s", order.getOrderNo(), log.getOperator()) ); } }

启动时自动扫描所有GrayStatusHandler实现类,并注册到Map<String, GrayStatusHandler>:

// GrayStatusHandlerRegistry.java @Component public class GrayStatusHandlerRegistry { private final Map<String, GrayStatusHandler> handlerMap = new HashMap<>(); public GrayStatusHandlerRegistry(List<GrayStatusHandler> handlers) { handlers.forEach(h -> handlerMap.put(h.getSupportedStatus(), h)); } public void trigger(FlowStatus status, FlowOrder order, FlowStatusLog log) { GrayStatusHandler handler = handlerMap.get(status.name()); if (handler != null) { try { handler.handle(order, log); } catch (Exception e) { log.error("灰度状态处理器执行失败:{},单据号:{}", status, order.getOrderNo(), e); // 失败不阻断主流程,只记 error 日志 } } } }

这种设计让业务方新增灰度动作只需写一个@Component类,重启即可生效,无需改核心 service —— 是我们团队落地时最常被复用的扩展点。


4. 常见问题排查:五条血泪经验,全是线上翻车后补的后悔药

4.1 现象:前端点击「提交灰度审批」按钮无反应,Network 面板显示 403

原因:application-dev.yml中gray.flow.enable-gray-approval: false被设为 false,但前端按钮未做权限隐藏,导致请求发出去被 Spring Security 的HttpSecurity.authorizeHttpRequests()拦截。
解决:检查配置文件,确认该开关为true;同时在前端PermissionGuard.vue中增加v-if="hasPermission('GRAY_APPROVAL')"判断,权限数据由/api/v1/user/permissions接口返回。

4.2 现象:MySQL 报错Data truncation: Data too long for column 'extra_data' at row 1

原因:extra_data字段定义为JSON类型,但 MySQL 5.7 默认max_allowed_packet=4MB,当单据附件 ID 列表超长(如 500 个 UUID 拼接),JSON 字符串超过 packet 限制。
解决:登录 MySQL 执行SET GLOBAL max_allowed_packet = 64*1024*1024;,并永久写入my.cnf的[mysqld]段;同时后端加校验:if (changes.size() > 100) { throw new IllegalArgumentException("字段变更项不得超过100条"); }

4.3 现象:状态日志里extra_data.field_changes为空数组,但实际字段变了

原因:getPreviousVersion()方法依赖flow_order_history表,但该表未开启INSERT ... SELECT的历史快照写入。项目默认关闭此功能(因性能考虑),需手动开启。
解决:在application-dev.yml中设置gray.flow.enable-history-snapshot: true,并确认FlowOrderMapper.xml中<insert id="insertHistory">的 SQL 已启用(默认注释掉,需取消注释)。

4.4 现象:GRAY_APPROVED状态下单据仍能被再次点击「重新提交」,状态又变回GRAY_PENDING

原因:前端按钮逻辑只校验status !== 'APPROVED',漏掉了GRAY_APPROVED;后端changeStatus()方法虽有canTransition()检查,但前端没同步约束。
解决:前端按钮增加双重判断:disabled="order.status === 'APPROVED' || order.status === 'GRAY_APPROVED' || order.status === 'REJECTED'";后端单元测试补全testCanNotTransitionFromGrayApproved()用例。

4.5 现象:多线程并发调用changeStatus()时,偶发OptimisticLockException,但用户感知是「操作失败请重试」,体验差

原因:乐观锁失败后,当前实现直接抛异常,没做自动重试。真实业务中,审批操作频次低,但同一单据可能被多人同时查看+操作。
解决:在FlowOrderService.changeStatus()外层加简单重试(最多 3 次):

public void changeStatusWithRetry(Long orderId, FlowStatus newStatus, String operator, String remark) { int retry = 0; while (retry < 3) { try { changeStatus(orderId, newStatus, operator, remark); return; } catch (OptimisticLockException e) { retry++; if (retry >= 3) throw e; try { Thread.sleep(50); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } } } }

这个重试逻辑没用 Spring Retry,因为不想引入新依赖;50ms 间隔足够让其他线程完成提交,实测并发成功率从 62% 提升到 99.8%。


5. 二次开发实战:如何接入你公司的审批系统?三步替换 + 两个必改配置

5.1 替换第一步:审批节点与角色映射(改数据库 + 配置)

你公司的审批链路肯定不是「技术→财务→法务」。要替换,只需改三处:

位置文件/表修改内容示例
节点定义flow_node表删除默认三条,插入你司节点('HR_APPROVAL','HR终面确认',1),('CEO_FINAL','CEO终审',2)
角色权限user_role_map表插入你司用户与角色关系('zhangsan','HR_RECRUITER'),('lisi','CEO')
状态机规则FlowStatus.java的canTransition()修改枚举值和 switch 分支新增HR_APPROVAL状态,允许PENDING → HR_APPROVAL

注意:FlowStatus枚举名必须全大写、下划线分隔,与数据库status字段值严格一致,这是硬编码校验的基础。

5.2 替换第二步:灰色状态触发动作(加 Handler 类)

假设你司要求GRAY_PENDING时调用钉钉机器人发消息,而不是发邮件:

@Component public class GrayPendingDingTalkHandler implements GrayStatusHandler { private final DingTalkClient dingTalkClient; // 注入你司已封装的钉钉客户端 public GrayPendingDingTalkHandler(DingTalkClient dingTalkClient) { this.dingTalkClient = dingTalkClient; } @Override public String getSupportedStatus() { return "GRAY_PENDING"; } @Override public void handle(FlowOrder order, FlowStatusLog log) { String content = String.format( "🔔 灰度待审提醒\n单据号:%s\n标题:%s\n当前处理人:%s\n请前往系统处理", order.getOrderNo(), order.getTitle(), log.getOperator() ); dingTalkClient.sendTextToGroup("审批机器人", content); } }

只要这个类在com.gray.flow.handler包下,且加了@Component,启动时就会被自动注册。

5.3 替换第三步:前端审批表单(改 Vue 组件)

所有表单字段定义在/frontend/src/views/approval/ApprovalForm.vue的formSchema里:

const formSchema = [ { field: 'title', label: '单据标题', type: 'input', required: true }, { field: 'amount', label: '申请金额(元)', type: 'number', required: true }, { field: 'attachmentIds', label: '附件', type: 'upload', multiple: true }, // ← 在这里加你司特有字段 { field: 'projectCode', label: '所属项目编号', type: 'input', required: false }, { field: 'urgencyLevel', label: '紧急程度', type: 'select', options: [ { value: 'LOW', label: '常规' }, { value: 'MEDIUM', label: '加急' }, { value: 'HIGH', label: '特急' } ] } ]

后端接收时,FlowOrder实体类也要同步加字段,并在FlowOrderMapper.xml的<resultMap>中映射。

5.4 两个必改配置项(application-dev.yml)

这两项不改,项目跑不起来:

# 1. 数据库连接(必须填你司 MySQL 地址和账号) spring: datasource: url: jdbc:mysql://your-mysql-host:3306/gray_flow?useSSL=false&serverTimezone=Asia/Shanghai username: your_db_user password: your_db_password # 2. 灰色状态开关(必须设为 true 才启用灰度逻辑) gray: flow: enable-gray-approval: true # ← 关键!默认 false enable-history-snapshot: true # ← 关键!审计必需

从那以后我每次接手新审批系统改造,都强制走一遍这三步替换 + 两个配置检查,再跑通一个真实单据的全流程(DRAFT → GRAY_PENDING → GRAY_APPROVED),才算真正接入成功。漏掉任何一步,上线后都会在凌晨三点被电话叫醒。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询