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<String> fileNumbers) {
|
return transactions.execute(mapper -> lockNumbers(mapper, tenantId, fileNumbers));
|
}
|
|
private boolean lockNumbers(BusinessFileOccupancyMapper mapper, String tenantId, Collection<String> fileNumbers) {
|
identity(tenantId, 50);
|
if (fileNumbers == null || fileNumbers.isEmpty()) throw new IllegalArgumentException("文件编号不能为空");
|
SortedSet<Long> 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<String> fileNumbers) {
|
List<String> 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); }
|
}
|
}
|