package jnpf.dmsBusinessOccupancy; import org.mybatis.spring.SqlSessionTemplate; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.*; import static jnpf.dmsBusinessOccupancy.BusinessOccupancyException.Reason.*; /** * Local primitive, explicitly wired by each business adapter after its transaction is verified. * Does not start/commit transactions, save native forms, infer workflow completion or alter expiry. * Call tryLockNumbers BEFORE resource/document locks, then reread business facts in that transaction. */ public final class BusinessFileOccupancyCoordinator { private final BusinessOccupancyTransactions transactions; public BusinessFileOccupancyCoordinator(SqlSessionTemplate sessions) { this.transactions = new BusinessOccupancyTransactions(sessions); } /** A UI hint only; callers must recheck under the numbered lock before any mutation. */ public BusinessFileOccupancy current(String tenantId, String fileNumber) { identity(tenantId, 50); identity(fileNumber, 200); return transactions.reader().active(tenantId, fileNumber); } /** * False means waiting. The caller must end its transaction before retrying. * Partial locks remain until that transaction ends, as with all transaction-scoped DB locks. */ public boolean tryLockNumbers(String tenantId, Collection fileNumbers) { return transactions.execute(mapper -> lockNumbers(mapper, tenantId, fileNumbers)); } private boolean lockNumbers(BusinessFileOccupancyMapper mapper, String tenantId, Collection fileNumbers) { identity(tenantId, 50); if (fileNumbers == null || fileNumbers.isEmpty()) throw new IllegalArgumentException("文件编号不能为空"); SortedSet keys = new TreeSet<>(); for (String number : fileNumbers) { identity(number, 200); keys.add(lockKey(tenantId, number)); } for (Long key : keys) if (!mapper.tryLock(key)) return false; return true; } /** * Expiry's lowest-priority gate. A false result is a skip, not a business failure. * On true the caller must reread current version/deadline and execute the ORIGINAL expiry * operation before ending this same transaction. This method never changes file state. */ public boolean tryEnterExpiry(String tenantId, Collection fileNumbers) { List numbers = fileNumbers == null ? List.of() : List.copyOf(fileNumbers); return transactions.execute(mapper -> { if (!lockNumbers(mapper, tenantId, numbers)) return false; for (String number : numbers) if (mapper.active(tenantId, number) != null) return false; return true; }); } /** Called only after authoritative eligibility checks while holding the same numbered lock. */ public BusinessFileOccupancy acquire(BusinessFileOccupancy requested) { Objects.requireNonNull(requested); // Copy to keep the caller's mutable form/snapshot from changing identity mid-operation. BusinessFileOccupancy row = copy(requested); validate(row); return transactions.execute(mapper -> { requireLock(mapper, row.getTenantId(), row.getFileNumber()); BusinessFileOccupancy previous = mapper.chain(row.getTenantId(), row.getFileNumber(), row.getChainId()); if (previous != null) return sameActiveReceipt(previous, row); row.setId(UUID.randomUUID().toString()); if (mapper.insert(row) != 1) throw new BusinessOccupancyException(ALREADY_OCCUPIED); return Objects.requireNonNull(mapper.chain(row.getTenantId(), row.getFileNumber(), row.getChainId())); }); } /** Completion/termination evidence belongs to the trusted adapter, never a client-supplied flag. */ public boolean release(String tenantId, String number, String chainId, String occupancyId, String reason) { identity(chainId, 50); identity(occupancyId, 50); identity(reason, Integer.MAX_VALUE); return transactions.execute(mapper -> { requireLock(mapper, tenantId, number); if (mapper.release(tenantId, number, chainId, occupancyId, reason) == 1) return true; BusinessFileOccupancy old = mapper.chain(tenantId, number, chainId); return old != null && occupancyId.equals(old.getId()) && old.getReleasedAt() != null; }); } /** Compare-and-set prevents a delayed callback regressing a newer stage. */ public boolean advance(String tenantId, String number, String chainId, String occupancyId, String expectedStage, String nextStage) { identity(chainId, 50); identity(occupancyId, 50); identity(expectedStage, 100); identity(nextStage, 100); return transactions.execute(mapper -> { requireLock(mapper, tenantId, number); if (mapper.advance(tenantId, number, chainId, occupancyId, expectedStage, nextStage) == 1) return true; BusinessFileOccupancy old = mapper.chain(tenantId, number, chainId); return old != null && occupancyId.equals(old.getId()) && old.getReleasedAt() == null && nextStage.equals(old.getStage()); }); } private void requireLock(BusinessFileOccupancyMapper mapper, String tenantId, String number) { if (!lockNumbers(mapper, tenantId, List.of(number))) throw new BusinessOccupancyException(NUMBER_BUSY); } private static BusinessFileOccupancy sameActiveReceipt(BusinessFileOccupancy old, BusinessFileOccupancy row) { if (old.getReleasedAt() != null) throw new BusinessOccupancyException(CHAIN_ALREADY_RELEASED); if (!Objects.equals(old.getOperation(), row.getOperation()) || !Objects.equals(old.getApplicationId(), row.getApplicationId()) || !Objects.equals(old.getFileId(), row.getFileId()) || !Objects.equals(old.getBusinessVersionId(), row.getBusinessVersionId()) || !Objects.equals(old.getCenterVersionId(), row.getCenterVersionId()) || !Objects.equals(old.getWorkflowTaskId(), row.getWorkflowTaskId())) throw new BusinessOccupancyException(TARGET_CHANGED); // Do not rewind the stage on a repeated submission. return old; } private static BusinessFileOccupancy copy(BusinessFileOccupancy source) { BusinessFileOccupancy row = new BusinessFileOccupancy(); row.setTenantId(source.getTenantId()); row.setFileNumber(source.getFileNumber()); row.setOperation(source.getOperation()); row.setChainId(source.getChainId()); row.setApplicationId(source.getApplicationId()); row.setWorkflowTaskId(source.getWorkflowTaskId()); row.setFileId(source.getFileId()); row.setBusinessVersionId(source.getBusinessVersionId()); row.setCenterVersionId(source.getCenterVersionId()); row.setStage(source.getStage()); return row; } private static void validate(BusinessFileOccupancy row) { identity(row.getTenantId(), 50); identity(row.getFileNumber(), 200); identity(row.getOperation(), 100); identity(row.getChainId(), 50); identity(row.getApplicationId(), 50); identity(row.getStage(), 100); if (row.getFileId() == null && "CREATE".equals(row.getOperation())) { if (row.getBusinessVersionId() != null || row.getCenterVersionId() != null) throw new IllegalArgumentException("首次建档尚无文件资源,不能携带来源版本"); } else identity(row.getFileId(), 50); optionalIdentity(row.getWorkflowTaskId()); optionalIdentity(row.getBusinessVersionId()); optionalIdentity(row.getCenterVersionId()); } private static void optionalIdentity(String value) { if (value != null) identity(value, 50); } private static void identity(String value, int maxLength) { if (value == null || value.isBlank() || !value.equals(value.strip()) || value.codePointCount(0, value.length()) > maxLength || value.indexOf('\0') >= 0) throw new IllegalArgumentException("业务占用标识不能为空、超长或包含首尾空白"); } // Stable across JVMs; length-prefixing prevents tenant/number separator ambiguity. // Hash collisions only serialize unrelated numbers. Table uniqueness uses the full exact number. static long lockKey(String tenantId, String number) { try { MessageDigest hash = MessageDigest.getInstance("SHA-256"); hash.update("dms-business-file-occupancy:v1\0".getBytes(StandardCharsets.UTF_8)); for (String part : List.of(tenantId, number)) { byte[] bytes = part.getBytes(StandardCharsets.UTF_8); hash.update(ByteBuffer.allocate(4).putInt(bytes.length).array()); hash.update(bytes); } return ByteBuffer.wrap(hash.digest()).getLong(); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } } }