企业管理系统最容易低估的地方,是“页面能用”和“业务能闭环”之间的距离。页面能新增、能提交、能提示成功,不代表系统已经能上线;真正上线后,用户会遇到重复点击、越权操作、状态冲突、数据导出、日志追溯和异常恢复。
本文围绕一个固定案例展开:行政部上线设备维修申请,员工提交后没人处理,页面显示成功但工单停在待接单。我们不写泛泛的方法论,而是给出明确输入、MySQL表结构、Spring Boot风格服务层、预期输出、JUnit测试、SQL验证和上线验收清单。
示例环境为 Java 17、Spring Boot 风格服务层、MySQL 8.x。代码可改造成 RuoYi/若依、MyBatis 或 JPA 实现;重点是业务事实如何落到服务端边界和数据库约束中。
目录
一、先固定案例和输入数据
本篇固定输入如下:
业务对象:维修工单 WO-20260920-003
业务场景:行政部上线设备维修申请,员工提交后没人处理,页面显示成功但工单停在待接单
发起人:员工陈丽
责任人:行政主管周敏
关键指标:待办领取率
初始状态:SUBMITTED
目标状态:ACCEPTED
请求ID:REQ-03-001
预期正常结果:工单进入ACCEPTED,待办被周敏领取,资产状态变为REPAIRING
预期异常边界:同一待办重复领取、已关闭工单再次处理、无权限主管跨部门接单
固定输入不是凑字数,而是为了让后续所有技术证据有同一基准。没有固定案例的文章,很容易变成“应该、建议、最好”的经验列表;有了固定案例,读者才能判断代码和SQL到底解决了什么问题。

图1:行政部上线设备维修申请,员工提交后没人处理,页面显示成功但工单停在待接单
二、问题现象:页面成功不代表业务闭环
这个案例里的直接现象是:系统只记录了申请成功,没有把下一步责任人、截止时间和处理动作落到待办表。
如果只从页面角度处理,通常会做成:
用户点击按钮
前端调用接口
接口更新状态
页面提示成功
这条路径在演示时很顺,但在真实业务里会遇到三个问题。
第一,当前状态可能已经变化。用户打开页面时看到的是旧数据,提交时记录可能已经被其他人处理。第二,同一次请求可能被重复提交。浏览器重试、网关超时和用户连点都会触发。第三,页面权限不等于接口权限。隐藏菜单、隐藏按钮不能阻止别人直接调用接口。
因此,本文的目标不是写一个“能提交”的接口,而是写一个能被验证的业务动作:只有状态为 SUBMITTED 的记录才能执行 assignTask,执行后进入 ACCEPTED,重复请求返回同一结果,越权请求被服务端拒绝,并且操作日志能追溯。

图2:问题现象、原因、状态和验证要连起来
三、原因分析:缺少可验证的业务事实
造成问题的根源通常不是开发粗心,而是系统没有把业务事实拆清楚。
| 当前状态 | 业务主表 | 页面按钮颜色 |
| 操作请求 | request_key 或请求表 | 浏览器是否只点一次 |
| 操作权限 | 服务端权限判断 | 菜单是否可见 |
| 状态变化 | 审计日志 | 普通访问日志 |
| 验收结果 | SQL和测试 | 人工点页面看一遍 |
本篇案例中,至少要有一张业务主表和一张审计表。主表保存当前状态和唯一键,审计表保存动作、前后状态、操作者和请求ID。若业务更复杂,还可以拆出明细表、附件表或消息outbox表,但最小闭环不能少掉状态、幂等和审计。
四、MySQL表结构和约束
下面是一组可用于演示的最小表结构:
CREATE TABLE biz_task_work_order_demo (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
business_no VARCHAR(64) NOT NULL,
business_name VARCHAR(128) NOT NULL,
company_id BIGINT NOT NULL,
dept_id BIGINT NOT NULL,
owner_user_id BIGINT NULL,
current_status VARCHAR(32) NOT NULL,
previous_status VARCHAR(32) NULL,
request_key VARCHAR(64) NOT NULL,
business_amount DECIMAL(18,2) NULL,
evidence_json JSON NULL,
create_by BIGINT NOT NULL,
create_time DATETIME NOT NULL,
update_by BIGINT NULL,
update_time DATETIME NULL,
UNIQUE KEY uk_enterprise_task_closed_l_no (company_id, business_no),
UNIQUE KEY uk_enterprise_task_closed_l_request (request_key),
KEY idx_enterprise_task_closed_l_status (company_id, current_status),
CHECK (current_status IN ('DRAFT','SUBMITTED','ACCEPTED','REJECTED','CLOSED','OFFLINE'))
);
CREATE TABLE biz_task_work_order_demo_audit (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
source_id BIGINT NOT NULL,
action_code VARCHAR(64) NOT NULL,
before_status VARCHAR(32) NULL,
after_status VARCHAR(32) NULL,
operator_id BIGINT NOT NULL,
request_key VARCHAR(64) NOT NULL,
remark VARCHAR(500) NULL,
create_time DATETIME NOT NULL,
KEY idx_enterprise_task_closed_l_audit_source (source_id),
KEY idx_enterprise_task_closed_l_audit_key (request_key)
);
这段DDL里最关键的是三类约束。
第一,业务唯一键 company_id + business_no。企业系统常常是多公司、多部门共用,业务编号不能只看一个字符串。第二,请求唯一键 request_key。它用于处理重复提交,同一次业务动作重复到达时,应该返回原结果。第三,状态索引。上线后经常要查询某个公司下待处理、已发布、已关闭的数据,没有状态索引,数据量一上来就会慢。

图3:唯一键、状态、请求号和审计关系不能缺
五、Java实现:把规则放到服务层
下面给出核心服务层实现。它不是零散片段,而是包含输入命令、输出结果、状态校验、幂等处理、更新和审计日志。
import java.time.LocalDateTime;
import org.springframework.transaction.annotation.Transactional;
public class TaskClosedLoopService {
private final BusinessRepository businessRepository;
private final AuditRepository auditRepository;
private final PermissionChecker permissionChecker;
public TaskClosedLoopService(BusinessRepository businessRepository,
AuditRepository auditRepository,
PermissionChecker permissionChecker) {
this.businessRepository = businessRepository;
this.auditRepository = auditRepository;
this.permissionChecker = permissionChecker;
}
@Transactional(rollbackFor = Exception.class)
public BusinessResult assignTask(BusinessCommand command) {
permissionChecker.requireOperator(command.operatorId(), command.companyId());
BusinessRecord duplicated = businessRepository
.findByRequestKey(command.requestKey());
if (duplicated != null) {
return BusinessResult.from(duplicated, true);
}
BusinessRecord record = businessRepository.lockByBusinessNo(
command.companyId(), command.businessNo());
if (record == null) {
throw new ServiceException("业务记录不存在:" + command.businessNo());
}
if (!"SUBMITTED".equals(record.currentStatus())) {
throw new ServiceException("当前状态不允许执行assignTask:" + record.currentStatus());
}
int updated = businessRepository.updateStatus(
record.id(), "SUBMITTED", "ACCEPTED",
command.requestKey(), command.operatorId(), command.occurredAt());
if (updated != 1) {
throw new ServiceException("记录状态已变化,请刷新后重试");
}
auditRepository.insert(new AuditEvent(
record.id(), "assignTask", "SUBMITTED", "ACCEPTED",
command.operatorId(), command.requestKey(), command.occurredAt()));
BusinessRecord latest = businessRepository.getById(record.id());
return BusinessResult.from(latest, false);
}
public record BusinessCommand(
Long companyId,
String businessNo,
Long operatorId,
String requestKey,
LocalDateTime occurredAt) {
}
public record BusinessResult(
Long id,
String businessNo,
String status,
boolean duplicatedRequest) {
static BusinessResult from(BusinessRecord record, boolean duplicated) {
return new BusinessResult(record.id(), record.businessNo(),
record.currentStatus(), duplicated);
}
}
}
这段代码的设计重点有三个。
先查 requestKey,是为了支持安全重试。第一次请求已经成功但前端没有收到响应时,第二次同请求ID进入系统,应返回第一次结果。
再锁定业务记录,是为了保证状态判断和状态更新在同一个事务边界内完成。只在页面上判断状态,会被并发操作打穿。
最后写审计日志,是为了让上线后的问题可以回放。企业系统常见追问不是“有没有成功”,而是“谁在什么时候把什么从旧状态改成了新状态”。

图4:权限、状态、幂等和日志放在同一事务中
六、预期输出和JUnit测试
固定输入下,第一次执行 assignTask 的预期输出:
businessNo=维修工单
status=ACCEPTED
duplicatedRequest=false
auditLogCount=1
使用相同 REQ-03-001 重试,预期输出:
businessNo=维修工单
status=ACCEPTED
duplicatedRequest=true
auditLogCount=1
JUnit 5 测试:
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.time.LocalDateTime;
import org.junit.jupiter.api.Test;
class TaskClosedLoopTest {
@Test
void shouldMoveStatusAndWriteAuditLog() {
Fixture fx = Fixture.withRecord("维修工单 WO-20260920-003", "SUBMITTED");
var result = fx.service().assignTask(new BusinessCommand(
1L, "维修工单", 1001L, "REQ-03-001",
LocalDateTime.of(2026, 9, 28, 10, 0)));
assertEquals("ACCEPTED", result.status());
assertEquals(false, result.duplicatedRequest());
assertEquals(1, fx.auditRepository().countByRequestKey("REQ-03-001"));
}
@Test
void shouldReturnSameResultWhenRequestIsRetried() {
Fixture fx = Fixture.withRecord("维修工单 WO-20260920-003", "SUBMITTED");
BusinessCommand command = new BusinessCommand(
1L, "维修工单", 1001L, "REQ-03-001",
LocalDateTime.of(2026, 9, 28, 10, 0));
var first = fx.service().assignTask(command);
var second = fx.service().assignTask(command);
assertEquals(first.id(), second.id());
assertEquals(true, second.duplicatedRequest());
assertEquals(1, fx.auditRepository().countByRequestKey("REQ-03-001"));
}
@Test
void shouldRejectWrongStatus() {
Fixture fx = Fixture.withRecord("维修工单 WO-20260920-003", "CLOSED");
ServiceException error = assertThrows(ServiceException.class, () ->
fx.service().assignTask(new BusinessCommand(
1L, "维修工单", 1001L, "REQ-03-002",
LocalDateTime.of(2026, 9, 28, 10, 0))));
assertEquals("当前状态不允许执行assignTask:CLOSED", error.getMessage());
}
@Test
void shouldRejectOperatorOutsideCompany() {
Fixture fx = Fixture.withRecord("维修工单 WO-20260920-003", "SUBMITTED");
fx.permissionChecker().denyCompany(1001L, 1L);
ServiceException error = assertThrows(ServiceException.class, () ->
fx.service().assignTask(new BusinessCommand(
1L, "维修工单", 1001L, "REQ-03-003",
LocalDateTime.of(2026, 9, 28, 10, 0))));
assertEquals("当前用户没有该公司数据权限", error.getMessage());
}
}
这组测试覆盖正常路径、重复请求、错误状态和越权操作。它们不是为了装饰文章,而是把上线风险变成可执行断言。
七、SQL验证:上线后查什么
查询当前业务结果:
SELECT business_no, business_name, current_status, request_key
FROM biz_task_work_order_demo
WHERE company_id = 1
AND business_no = '维修工单';
预期结果:
维修工单 | 维修工单 WO-20260920-003 | ACCEPTED | REQ-03-001
检查重复请求是否只生成一条记录:
SELECT request_key, COUNT(*) AS cnt
FROM biz_task_work_order_demo
WHERE request_key = 'REQ-03-001'
GROUP BY request_key;
预期结果:
REQ-03-001 | 1
检查审计日志是否存在:
SELECT action_code, before_status, after_status, operator_id, request_key
FROM biz_task_work_order_demo_audit
WHERE request_key = 'REQ-03-001';
预期结果:
assignTask | SUBMITTED | ACCEPTED | 1001 | REQ-03-001

图5:正常结果、异常结果和审计记录一起查
八、异常边界和上线验收
上线前至少验证以下边界:
| 状态不允许 | 把记录改为CLOSED后再执行动作 | 服务端拒绝 |
| 重复请求 | 相同requestKey连续提交两次 | 只产生一次状态变化 |
| 越权操作 | 换成其他公司普通用户 | 服务端拒绝 |
| 审计失败 | 模拟日志写入异常 | 主业务回滚或进入补偿 |
| SQL核对 | 执行本文验证SQL | 结果和预期一致 |
本篇案例的验收重点是:工单进入ACCEPTED,待办被周敏领取,资产状态变为REPAIRING。同时还要验证:同一待办重复领取、已关闭工单再次处理、无权限主管跨部门接单。
只有这些边界都能解释,系统才不是一个只适合演示的页面。
参考资料
- Spring Framework:声明式事务管理
- MySQL 8.4:CREATE TABLE
- MySQL 8.4:InnoDB事务模型
网硕互联帮助中心






评论前必须登录!
注册