AI可以很快写出一个管理后台页面:有列表、有表单、有新增按钮,看起来像一个系统。但企业真正上线时,最容易出问题的往往不是页面,而是权限、状态、数据一致性和异常处理。
本文不用抽象讨论“AI会不会替代开发”,而是固定一个小案例:销售部员工申请领用一台会议投影仪,主管审批通过后,资产管理员确认领用。页面演示只需要一个按钮,真实系统却要保证重复点击不会生成两条领用记录,资产不能被两个人同时领走,越权用户不能替别人确认,数据库和操作日志能解释每一步。
示例环境为 Java 17、Spring Boot 风格服务层、MySQL 8.x。代码不依赖某个前端框架,适合改造成 RuoYi、若依 Vue 或普通后台系统。本文重点不是把页面做漂亮,而是把一个“看起来能用”的演示功能,补成可以验收的企业业务闭环。
目录
一、先说清楚:演示系统到底少了什么
AI生成的系统通常能很好地完成第一层工作:把字段摆到页面上,把按钮连到接口上,把列表查出来。对演示来说,这已经足够;对上线来说,还差一层“业务事实”。
比如一个“确认领用”按钮,演示系统通常只会做:
点击按钮
修改资产状态
提示操作成功
真实系统至少要回答这些问题:
| 资产当前是否还能领用 | 已调拨、维修、报废的资产仍被领走 | 状态机校验 |
| 申请是否已经审批通过 | 员工绕过审批直接领用 | 申请状态校验 |
| 重复点击怎么处理 | 生成两条有效领用记录 | 幂等请求号和唯一键 |
| 谁可以确认 | 普通员工替别人确认 | 服务端权限校验 |
| 失败后留下什么 | 页面成功但数据库只写了一半 | 事务和审计日志 |
这就是演示页和真实系统的差距。页面回答“用户能不能点”,系统回答“这件业务事实能不能成立”。

图1:从能点击到能上线,需要补齐数据、权限、状态、异常和验收。
二、固定一个投影仪领用案例
为了让后面的代码和SQL能复现,本文固定以下输入:
资产编码:PJ-2026-001
资产名称:会议投影仪
资产当前状态:IDLE
所在公司:广州数友科技
所在部门:行政部
申请单号:REQ-20260919-001
申请人:销售部 王明
审批状态:APPROVED
确认人:资产管理员 admin
请求ID:REQ-ID-7788
确认时间:2026-09-19 10:30:00
业务目标也固定:
第一次确认领用:成功,资产状态从IDLE变为IN_USE
第二次使用相同请求ID确认:不重复写入,返回第一次结果
换一个请求ID再次确认:失败,因为资产已经IN_USE
普通员工替别人确认:失败,因为没有资产管理员权限
这种固定案例对 CSDN 技术文章也很重要。只讲“要做权限、要做状态、要做日志”,读者很难判断代码是否真的解决问题;把输入、状态和预期结果写清楚,后面的 Java、JUnit、SQL 才能形成一条证据链。

图2:投影仪领用案例固定了资产、申请人、审批状态和重复请求。
三、数据模型要能表达状态和唯一性
如果系统只有一张资产表,并且只在表里放一个 user_name 字段,很多问题都会变得无解。谁申请、谁审批、谁确认、什么时候领用、是否重复点击,都无法被清楚表达。
最小模型可以拆成三张表:
CREATE TABLE biz_asset_card (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
asset_code VARCHAR(64) NOT NULL,
asset_name VARCHAR(128) NOT NULL,
company_id BIGINT NOT NULL,
dept_id BIGINT NOT NULL,
asset_status VARCHAR(24) NOT NULL,
current_user_id BIGINT NULL,
version INT NOT NULL DEFAULT 0,
UNIQUE KEY uk_asset_code_company (company_id, asset_code),
CHECK (asset_status IN ('IDLE','IN_USE','REPAIRING','RETIRED'))
);
CREATE TABLE biz_asset_use_request (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
request_no VARCHAR(64) NOT NULL,
asset_id BIGINT NOT NULL,
applicant_id BIGINT NOT NULL,
applicant_dept_id BIGINT NOT NULL,
request_status VARCHAR(24) NOT NULL,
approved_by BIGINT NULL,
approved_at DATETIME NULL,
UNIQUE KEY uk_use_request_no (request_no),
KEY idx_use_request_asset (asset_id, request_status),
CHECK (request_status IN ('DRAFT','APPROVED','REJECTED','CONFIRMED','CANCELLED'))
);
CREATE TABLE biz_asset_use_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
request_id BIGINT NOT NULL,
asset_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
confirmed_by BIGINT NOT NULL,
request_idempotent_key VARCHAR(64) NOT NULL,
confirmed_at DATETIME NOT NULL,
record_status VARCHAR(24) NOT NULL,
UNIQUE KEY uk_confirm_request_key (request_idempotent_key),
UNIQUE KEY uk_active_asset_use (asset_id, record_status),
KEY idx_use_record_user (user_id, record_status)
);
这里有两个容易被忽略的点。
第一,uk_asset_code_company 说明资产编码在公司内唯一,而不是全库唯一。集团多公司系统里,不同公司可能沿用同一套旧编码规则,不能因为编码相同就误判为同一资产。
第二,uk_confirm_request_key 和 uk_active_asset_use 分别解决两个问题:同一个请求重复提交只能产生一条记录;同一资产同一时刻只能有一条有效领用记录。页面禁用按钮不能替代数据库唯一键,因为用户刷新、网络重试、接口并发都可能绕过前端状态。
四、Java实现:事务内完成校验、领用和日志
下面的代码是一个可理解的核心实现。它不展示Controller和ORM细节,只保留服务层最关键的业务判断。生产系统可以把 Repository 换成 MyBatis Mapper 或 JPA Repository。
import java.time.LocalDateTime;
import java.util.Optional;
public class AssetUseService {
private final AssetRepository assetRepository;
private final UseRequestRepository requestRepository;
private final UseRecordRepository recordRepository;
private final AuditLogRepository auditLogRepository;
private final PermissionService permissionService;
public AssetUseService(AssetRepository assetRepository,
UseRequestRepository requestRepository,
UseRecordRepository recordRepository,
AuditLogRepository auditLogRepository,
PermissionService permissionService) {
this.assetRepository = assetRepository;
this.requestRepository = requestRepository;
this.recordRepository = recordRepository;
this.auditLogRepository = auditLogRepository;
this.permissionService = permissionService;
}
@Transactional(rollbackFor = Exception.class)
public UseResult confirmUse(ConfirmUseCommand command) {
permissionService.requireAssetManager(command.operatorId());
Optional<UseRecord> existing =
recordRepository.findByRequestKey(command.requestKey());
if (existing.isPresent()) {
return UseResult.from(existing.get(), true);
}
UseRequest request = requestRepository.lockById(command.requestId())
.orElseThrow(() -> new BizException("领用申请不存在"));
if (!"APPROVED".equals(request.status())) {
throw new BizException("只有审批通过的申请才能确认领用");
}
AssetCard asset = assetRepository.lockById(request.assetId())
.orElseThrow(() -> new BizException("资产不存在"));
if (!"IDLE".equals(asset.status())) {
throw new BizException("资产当前不是可领用状态:" + asset.status());
}
UseRecord record = new UseRecord(
null,
request.id(),
asset.id(),
request.applicantId(),
command.operatorId(),
command.requestKey(),
command.confirmedAt(),
"ACTIVE");
Long recordId = recordRepository.insert(record);
assetRepository.updateStatus(
asset.id(), "IDLE", "IN_USE", request.applicantId(), asset.version());
requestRepository.updateStatus(request.id(), "APPROVED", "CONFIRMED");
auditLogRepository.insert(AuditLog.assetStatusChanged(
asset.id(), "IDLE", "IN_USE", command.operatorId(),
command.requestKey(), command.confirmedAt()));
return new UseResult(recordId, asset.assetCode(), "IN_USE", false);
}
public record ConfirmUseCommand(
Long requestId,
Long operatorId,
String requestKey,
LocalDateTime confirmedAt) {
}
public record UseResult(
Long recordId,
String assetCode,
String assetStatus,
boolean duplicatedRequest) {
static UseResult from(UseRecord record, boolean duplicated) {
return new UseResult(record.id(), record.assetCode(), "IN_USE", duplicated);
}
}
}
这段代码有几个设计取舍。
先检查幂等键,是为了让同一次请求在网络超时后可重试。用户第一次点击时服务端已经成功,但浏览器没收到响应;第二次请求使用同一个 requestKey,系统应返回第一次结果,而不是提示“资产已经被领用”。
对申请单和资产都使用 lockById,是为了避免两个管理员同时确认同一资产。应用层读取到 IDLE 不代表写入时仍然是 IDLE;事务内锁定和数据库唯一键要一起使用。
审计日志写在同一事务里,是为了保证业务状态和追溯记录一致。演示系统可以只弹一个“成功”,真实系统需要在一个月后还能回答:谁在什么时候把 PJ-2026-001 从可领用改成在用。

图3:问题、原因、模型、实现和验收要围绕同一个投影仪领用案例展开。
五、预期输出和JUnit测试要覆盖重复点击
使用固定输入调用服务,第一次确认的预期输出是:
recordId=10001
assetCode=PJ-2026-001
assetStatus=IN_USE
duplicatedRequest=false
同一个 requestKey=REQ-ID-7788 再提交一次,预期输出是:
recordId=10001
assetCode=PJ-2026-001
assetStatus=IN_USE
duplicatedRequest=true
换一个请求ID再次确认同一申请,应失败:
资产当前不是可领用状态:IN_USE
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 AssetUseServiceTest {
@Test
void shouldConfirmApprovedUseRequestOnce() {
TestFixture fx = TestFixture.approvedIdleAsset();
AssetUseService service = fx.service();
var result = service.confirmUse(new AssetUseService.ConfirmUseCommand(
2001L, 1L, "REQ-ID-7788",
LocalDateTime.of(2026, 9, 19, 10, 30)));
assertEquals("PJ-2026-001", result.assetCode());
assertEquals("IN_USE", result.assetStatus());
assertEquals(false, result.duplicatedRequest());
assertEquals("IN_USE", fx.assetRepository().get(3001L).status());
assertEquals(1, fx.auditLogRepository().countByAssetId(3001L));
}
@Test
void shouldReturnExistingRecordWhenSameRequestKeyIsRetried() {
TestFixture fx = TestFixture.approvedIdleAsset();
AssetUseService service = fx.service();
var command = new AssetUseService.ConfirmUseCommand(
2001L, 1L, "REQ-ID-7788",
LocalDateTime.of(2026, 9, 19, 10, 30));
var first = service.confirmUse(command);
var second = service.confirmUse(command);
assertEquals(first.recordId(), second.recordId());
assertEquals(true, second.duplicatedRequest());
assertEquals(1, fx.recordRepository().countActiveByAssetId(3001L));
}
@Test
void shouldRejectAnotherRequestAfterAssetIsInUse() {
TestFixture fx = TestFixture.approvedIdleAsset();
AssetUseService service = fx.service();
service.confirmUse(new AssetUseService.ConfirmUseCommand(
2001L, 1L, "REQ-ID-7788",
LocalDateTime.of(2026, 9, 19, 10, 30)));
BizException error = assertThrows(BizException.class, () ->
service.confirmUse(new AssetUseService.ConfirmUseCommand(
2001L, 1L, "REQ-ID-9999",
LocalDateTime.of(2026, 9, 19, 10, 31))));
assertEquals("资产当前不是可领用状态:IN_USE", error.getMessage());
}
@Test
void shouldRejectOperatorWithoutAssetManagerRole() {
TestFixture fx = TestFixture.approvedIdleAsset();
fx.permissionService().denyAssetManager(7L);
BizException error = assertThrows(BizException.class, () ->
fx.service().confirmUse(new AssetUseService.ConfirmUseCommand(
2001L, 7L, "REQ-ID-7788",
LocalDateTime.of(2026, 9, 19, 10, 30))));
assertEquals("当前用户没有资产确认权限", error.getMessage());
}
}
这里的测试不是为了追求覆盖率数字,而是对应上线风险:正常领用、重复请求、状态冲突和越权操作。四类用例都通过,才能说明按钮背后的业务规则是稳定的。

图4:后端必须重复校验状态、审批结果、幂等键和权限。
六、SQL验证:上线后查什么
上线后不要只看页面。可以准备几条固定 SQL,用来确认业务结果没有跑偏。
查询资产当前状态:
SELECT asset_code, asset_status, current_user_id
FROM biz_asset_card
WHERE asset_code = 'PJ-2026-001';
预期结果:
PJ-2026-001 | IN_USE | 501
检查同一资产是否有多条有效领用:
SELECT asset_id, COUNT(*) AS active_count
FROM biz_asset_use_record
WHERE record_status = 'ACTIVE'
GROUP BY asset_id
HAVING COUNT(*) > 1;
预期结果为空。只要查出记录,就说明并发或唯一约束出了问题。
检查重复请求是否只生成一条记录:
SELECT request_idempotent_key, COUNT(*) AS cnt
FROM biz_asset_use_record
WHERE request_idempotent_key = 'REQ-ID-7788'
GROUP BY request_idempotent_key;
预期结果:
REQ-ID-7788 | 1
检查审计日志是否能追溯状态变化:
SELECT business_id, before_status, after_status, operator_id, request_key
FROM biz_audit_log
WHERE business_type = 'ASSET'
AND business_id = 3001
ORDER BY id DESC
LIMIT 1;
预期结果:
3001 | IDLE | IN_USE | 1 | REQ-ID-7788
这些 SQL 是文章里很容易被低估的部分。它们能把“代码看起来合理”变成“结果可以被数据库验证”。
七、异常边界:页面不报错不等于系统正确
AI生成的演示系统经常能覆盖正常路径,但真实上线最怕异常路径被忽略。
至少要明确这些边界:
| 审批未通过 | 拒绝确认领用 | 前端隐藏按钮就算完成 |
| 资产已在用 | 阻断新领用 | 直接覆盖当前使用人 |
| 请求重复 | 返回第一次结果 | 再插入一条记录 |
| 操作人越权 | 服务端抛出业务异常 | 只靠菜单权限 |
| 日志写入失败 | 整体回滚或进入可靠补偿 | 业务成功但无法追溯 |
如果要让系统更稳,还可以加入乐观锁字段 version。更新资产状态时带上旧版本:
UPDATE biz_asset_card
SET asset_status = 'IN_USE',
current_user_id = 501,
version = version + 1
WHERE id = 3001
AND asset_status = 'IDLE'
AND version = 6;
若影响行数为0,就说明资产状态已经被其他事务改变。此时应返回明确错误,而不是继续写领用记录。

图5:页面成功、数据库一致、异常可复现,三者同时满足才适合上线。
八、上线验收清单
这类由 AI 辅助生成的系统,最小上线验收可以按下面清单执行:
| 固定输入 | 使用 PJ-2026-001 和 REQ-20260919-001 | 能复现本文结果 |
| 状态校验 | 资产为 REPAIRING 时确认领用 | 后端拒绝 |
| 审批校验 | 申请为 DRAFT 时确认领用 | 后端拒绝 |
| 幂等校验 | 同一 requestKey 连续提交两次 | 只生成一条记录 |
| 权限校验 | 普通员工调用确认接口 | 后端拒绝 |
| SQL核对 | 查询有效领用记录 | 同资产最多一条 |
| 审计追溯 | 查询最后一条状态变化日志 | 能看到前后状态和操作者 |
| 失败恢复 | 模拟日志写入失败 | 业务不留下半截数据 |
这份清单也说明了为什么企业系统不能只看“AI写得快不快”。AI可以提高页面和代码生成速度,但上线质量仍然取决于业务边界是否清楚、数据模型是否能表达事实、异常是否有可复现的验证。
参考资料
- Spring Framework:声明式事务管理
- MySQL 8.4:InnoDB事务模型
- MySQL 8.4:CREATE TABLE CHECK约束
网硕互联帮助中心




评论前必须登录!
注册