云计算百科
云计算领域专业知识百科平台

AI写的系统能不能直接上线?从演示页面到真实业务系统的差距

AI可以很快写出一个管理后台页面:有列表、有表单、有新增按钮,看起来像一个系统。但企业真正上线时,最容易出问题的往往不是页面,而是权限、状态、数据一致性和异常处理。

本文不用抽象讨论“AI会不会替代开发”,而是固定一个小案例:销售部员工申请领用一台会议投影仪,主管审批通过后,资产管理员确认领用。页面演示只需要一个按钮,真实系统却要保证重复点击不会生成两条领用记录,资产不能被两个人同时领走,越权用户不能替别人确认,数据库和操作日志能解释每一步。

示例环境为 Java 17、Spring Boot 风格服务层、MySQL 8.x。代码不依赖某个前端框架,适合改造成 RuoYi、若依 Vue 或普通后台系统。本文重点不是把页面做漂亮,而是把一个“看起来能用”的演示功能,补成可以验收的企业业务闭环。

目录

  • 先说清楚:演示系统到底少了什么
  • 固定一个投影仪领用案例
  • 数据模型要能表达状态和唯一性
  • Java实现:事务内完成校验、领用和日志
  • 预期输出和JUnit测试要覆盖重复点击
  • SQL验证:上线后查什么
  • 异常边界:页面不报错不等于系统正确
  • 上线验收清单
  • 一、先说清楚:演示系统到底少了什么

    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约束
    赞(0)
    未经允许不得转载:网硕互联帮助中心 » AI写的系统能不能直接上线?从演示页面到真实业务系统的差距
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!