固定资产借给同事以后,如果系统只把资产主表中的“使用人”从张三改成李四,页面看起来已经完成交接,但几个最重要的问题没有答案:谁发起的?原使用人是否确认?预计什么时候归还?资产附带了哪些配件?重复点击提交会不会生成两次记录?两个人同时借同一台设备时谁应该成功?
领用、借用、归还和调拨都可能改变资产当前状态,但它们不是一次普通的表单更新,而是带有角色、条件、过程和结果的业务动作。
本文以Spring Boot + MySQL固定资产系统为例,讨论如何使用业务单据、状态机、事务、行锁、幂等键和变更流水,把资产流转设计成一条可以验证和追溯的闭环。
目录
- 先分清领用借用归还和调拨
- 资产状态与业务单状态不要混用
- 主表保存当前结果单据解释变化原因
- 状态迁移必须同时校验角色与当前资产
- 用事务和锁处理多人并发
- 幂等控制不能只靠前端禁用按钮
- 归还不是简单地把状态改成在库
- 用反向用例验证流转边界
- 最低资产流转设计清单
一、先分清领用、借用、归还和调拨
四个动作都可能改变责任人或位置,但业务语义不同:
| 动作 | 典型特点 | 关键字段 |
|---|---|---|
| 领用 | 较长期交给人员或部门使用 | 领用人、领用部门、领用时间 |
| 借用 | 临时使用,通常有预计归还时间 | 借用人、借出人、预计归还日 |
| 归还 | 结束领用或借用,并完成实物验收 | 归还人、接收人、验收结果、配件 |
| 调拨 | 在组织、部门、位置或责任主体之间转移 | 调出方、调入方、原位置、目标位置 |
图1:字段变化可能相似,但四种业务的期限、确认方和结束条件不同。
如果系统把这些动作统一成“编辑资产”,就无法为每个动作设置独立权限、审批和验收规则,也很难统计借出未还、跨部门调拨和离职未交接等问题。
二、资产状态与业务单状态不要混用
资产状态回答“这项资产现在处于什么状态”,业务单状态回答“这次操作走到了哪一步”。
资产状态可以包括:
IN_STOCK 在库 IN_USE 在用 ON_LOAN 借出 IN_TRANSFER 调拨中 IN_REPAIR 维修中 IDLE 闲置 DISPOSED 已处置借用单状态则可以是:
DRAFT → SUBMITTED → APPROVED → HANDED_OVER → RETURN_PENDING → RETURNED └──────────────→ REJECTED并不是每次单据状态变化都立即改变资产状态。例如借用申请提交后,资产可以继续保持IN_STOCK;只有实物交接完成后才进入ON_LOAN。归还申请提交后也不应立即变成IN_STOCK,要等接收人完成实物和配件验收。
图2:业务单控制过程,资产状态只在关键业务节点更新。
这种双状态设计可以避免审批通过但尚未交付时,其他人误以为设备已经借出;也能避免用户点击归还后,设备尚未入库就被再次分配。
三、主表保存当前结果,单据解释变化原因
资产主表只保存当前有效状态和归属:
CREATETABLEasset_info(idBIGINTPRIMARYKEY,tenant_idBIGINTNOTNULL,asset_codeVARCHAR(64)NOTNULL,asset_statusVARCHAR(24)NOTNULL,use_dept_idBIGINTNULL,custodian_idBIGINTNULL,location_idBIGINTNULL,versionINTNOTNULLDEFAULT0,UNIQUEKEYuk_asset_code(tenant_id,asset_code))ENGINE=InnoDB;业务单头保存一次操作的整体信息,明细保存本次涉及哪些资产:
CREATETABLEasset_operation_order(idBIGINTPRIMARYKEY,tenant_idBIGINTNOTNULL,order_noVARCHAR(64)NOTNULL,operation_typeVARCHAR(24)NOTNULL,statusVARCHAR(24)NOTNULL,applicant_idBIGINTNOTNULL,target_dept_idBIGINTNULL,target_custodian_idBIGINTNULL,target_location_idBIGINTNULL,expected_return_atDATETIMENULL,request_idVARCHAR(64)NOTNULL,created_timeDATETIMENOTNULL,UNIQUEKEYuk_order_no(tenant_id,order_no),UNIQUEKEYuk_request(tenant_id,request_id))ENGINE=InnoDB;CREATETABLEasset_operation_detail(idBIGINTPRIMARYKEY,tenant_idBIGINTNOTNULL,order_idBIGINTNOTNULL,asset_idBIGINTNOTNULL,before_statusVARCHAR(24)NOTNULL,before_dept_idBIGINTNULL,before_custodian_idBIGINTNULL,before_location_idBIGINTNULL,result_statusVARCHAR(24)NULL,UNIQUEKEYuk_order_asset(tenant_id,order_id,asset_id),KEYidx_asset_order(tenant_id,asset_id,order_id))ENGINE=InnoDB;明细中的before_*字段保存业务发生时的快照。否则单据查看时如果始终关联资产当前状态,资产再次调拨后,历史单据上的“原位置”也会一起变化。
图3:资产主表保存当前值,单据和明细保存过程,变更流水记录字段前后值。
四、状态迁移必须同时校验角色与当前资产
状态机不能只检查按钮来自哪个页面,还要在服务端同时验证:
- 当前用户是否有执行该动作的权限;
- 单据是否处于允许迁移的状态;
- 资产是否仍满足操作条件;
- 资产是否属于当前用户可管理的数据范围;
- 是否存在互斥的未完成业务。
例如借用交接前,可以使用明确的条件更新:
UPDATEasset_operation_orderSETstatus='HANDED_OVER'WHEREtenant_id=?ANDid=?ANDstatus='APPROVED';更新行数必须等于1。更新0行不是“数据库没有变化所以成功”,而是单据可能已处理、状态不允许或租户边界不匹配,应返回明确业务冲突。
资产也需要从预期状态迁移:
UPDATEasset_infoSETasset_status='ON_LOAN',custodian_id=?,version=version+1WHEREtenant_id=?ANDid=?ANDasset_status='IN_STOCK'ANDversion=?;五、用事务和锁处理多人并发
两个人同时提交同一台设备的借用申请时,前端查询都可能看到“在库”。真正执行交接时,系统必须保证只有一个请求成功。
@Transactional(rollbackFor=Exception.class)publicvoidhandOver(LongorderId,LongoperatorId){OperationOrderorder=orderMapper.selectForUpdate(orderId);requireStatus(order,"APPROVED");List<OperationDetail>details=detailMapper.selectByOrderId(orderId);List<Long>assetIds=details.stream().map(OperationDetail::getAssetId).sorted().toList();List<Asset>assets=assetMapper.selectForUpdate(assetIds);validateAllAvailable(order,assets,operatorId);assetMapper.markOnLoan(assetIds,order.getTargetCustodianId());orderMapper.markHandedOver(orderId,operatorId);changeLogService.recordBatch(order,assets,operatorId);}锁定多项资产时按统一顺序读取,可以降低不同事务以相反顺序加锁产生死锁的概率。MySQL官方文档也建议,多事务更新相同的多表或多行集合时保持一致顺序,并为锁定查询建立合适索引。
图4:权限、状态、互斥业务、并发锁和幂等键共同决定一次流转能否提交。
事务边界应放在业务服务层。创建单据、更新资产、写变更流水如果分别提交,中间任何一步失败都可能留下“单据成功但资产未变”或“资产已变但没有流水”的半完成状态。
六、幂等控制不能只靠前端禁用按钮
用户重复点击、移动网络重试、网关超时后重发,都可能让同一个请求到达服务端多次。前端按钮置灰只能减少误操作,不能形成服务端边界。
一种简单做法是客户端为一次业务提交生成request_id,数据库建立租户内唯一约束:
UNIQUEKEYuk_request(tenant_id,request_id)服务端收到重复键时,应查询第一次请求的处理结果并返回同一业务单号,而不是再次创建单据。
需要区分“同一请求重试”和“用户再次发起相同业务”。前者复用同一个request_id,后者必须产生新请求标识并重新校验业务条件。
七、归还不是简单地把状态改成在库
归还通常包含两个动作:使用人提交归还,资产管理员完成接收验收。
最低验收信息可以包括:
- 实际归还时间和接收人;
- 资产标签和序列号是否匹配;
- 电源、适配器、钥匙等配件是否齐全;
- 外观和功能是否正常;
- 归还后进入在库、维修还是待确认状态;
- 原借用单是否完整关闭。
如果设备损坏,归还单可以完成实物接收,但资产状态应进入IN_REPAIR或PENDING_INSPECTION,不能为了关闭借用单统一改为IN_STOCK。
借用明细和归还明细最好一一关联,这样可以发现一张借用单中部分归还、分批归还和配件缺失。
八、用反向用例验证流转边界
上线前除了验证正常流程,还要证明以下请求会失败:
| 反向用例 | 预期结果 |
|---|---|
| 普通用户借用已借出的资产 | 拒绝,不新增第二条有效借用 |
重复提交相同request_id | 返回原处理结果,不重复写入 |
| 无调拨权限用户调用调拨接口 | 拒绝,资产归属不变 |
| A部门管理员调拨B部门资产 | 拒绝且不泄露敏感详情 |
| 已完成单据再次执行交接 | 返回状态冲突 |
| 归还数量或资产集合不匹配 | 拒绝并指出未匹配明细 |
| 其中一项资产更新失败 | 整个批次按约定回滚或进入明确的部分处理机制 |
测试不仅检查HTTP状态,还要核对数据库没有出现不应有的主表变化、重复单据或孤立流水。
九、最低资产流转设计清单
- 领用、借用、归还和调拨拥有明确业务语义;
- 资产状态和业务单状态分别建模;
- 主表只保存当前有效归属,历史变化由单据解释;
- 单据明细保存操作发生时的资产快照;
- 状态迁移同时校验角色、数据范围和资产条件;
- 同一资产的互斥未完成业务能够被识别;
- 批量流转按固定顺序锁定或使用可靠版本校验;
- 服务端使用幂等键处理重复请求;
- 单据、资产更新和变更流水处于明确事务边界;
- 归还包含实物、配件和状态验收;
- 关键拒绝路径有自动化或可重复测试;
- 所有失败都不会留下半完成业务数据。
图5:一项资产从申请到交接、归还和归档,每个节点都应有条件、证据和失败处理。
固定资产流转不是“把使用人改掉”,而是把一次真实交接映射成可以验证的状态变化。
业务单据解释为什么变化,资产主表保存变化后的结果,变更流水提供审计证据;事务、锁和幂等控制则保证多人并发和网络重试时,这三类数据仍然一致。
你的资产系统最容易出现哪类问题:重复借用、归还未验收、调拨无记录,还是资产状态长期不准确?
参考资料
- MySQL 8.4 Reference Manual:InnoDB Locking Reads
- MySQL 8.4 Reference Manual:How to Minimize and Handle Deadlocks
- MySQL 8.4 Reference Manual:PRIMARY KEY and UNIQUE Index Constraints
- Spring Framework Reference:Using @Transactional