刘光辉
119 分钟以前 06f314545ac3514d7ce28939920866ae8188d31c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
package jnpf.dmsBusinessOccupancy;
 
import org.mybatis.spring.SqlSessionTemplate;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.HashSet;
 
/**
 * Shared admission conditions for NEW competing lifecycle applications against an existing file.
 * No native form CRUD, no HTTP controller and no automatic runtime registration.
 * First creation, trusted chain continuation, expiry, errata and usage tasks use their own contracts.
 */
public final class BusinessFileEligibilityService {
    public enum Operation { REVISION, REVOCATION, INVALIDATION, EXTENSION, REVIEW_EXTENSION }
    public enum Reason {
        FILE_NOT_FOUND, AMBIGUOUS_FILE_NUMBER, FILE_NOT_EFFECTIVE, VERSION_CHANGED,
        INCONSISTENT_CURRENT_VERSION, PENDING_VERSION, REVOCATION_IN_PROGRESS_OR_SUCCEEDED,
        REVOCATION_STATE_UNKNOWN, FILE_OCCUPIED, NUMBER_BUSY
    }
    /** tenantId comes from authenticated server context; IDs are resource/business-version IDs. */
    public record Query(String tenantId, String fileNumber, String fileId,
                        String businessVersionId, Operation operation) {
        public Query {
            valid(tenantId, 50); valid(fileNumber, 200); valid(fileId, 50); valid(businessVersionId, 50);
            Objects.requireNonNull(operation, "operation");
        }
        private static void valid(String value, int max) {
            if (value == null || value.isBlank() || !value.equals(value.strip()) || value.indexOf('\0') >= 0
                    || value.codePointCount(0, value.length()) > max)
                throw new IllegalArgumentException("Invalid eligibility query identity");
        }
    }
    /** Machine codes only; business display text remains online-configurable. */
    public record Decision(List<Reason> reasons, String fileId, String businessVersionId, String centerVersionId) {
        public Decision { reasons = List.copyOf(reasons); }
        public boolean allowed() { return reasons.isEmpty(); }
    }
    private final BusinessOccupancyTransactions transactions;
    private final BusinessFileOccupancyCoordinator coordinator;
    public BusinessFileEligibilityService(SqlSessionTemplate sessions) {
        transactions = new BusinessOccupancyTransactions(sessions);
        coordinator = new BusinessFileOccupancyCoordinator(sessions);
    }
    /** Read-only hint. It neither acquires occupancy nor promises a later submission will succeed. */
    public Decision query(Query query) {
        Objects.requireNonNull(query);
        return evaluate(query, transactions.reader().eligibilityFacts(query.tenantId(), query.fileNumber()));
    }
    /** Single-file form of the batch recheck. No occupancy or application is written here. */
    public Decision recheckForSubmission(Query query) {
        return recheckForSubmission(List.of(Objects.requireNonNull(query))).get(0);
    }
    /**
     * Lock the entire number set in the coordinator's stable order, then reread all facts in the
     * caller's verified transaction, BEFORE document/resource locks. Caller must require ALL
     * decisions allowed, acquire occupancy and persist natively in this same transaction.
     * No own-chain exclusion is accepted from the caller. Continuation is a separate trusted path.
     */
    public List<Decision> recheckForSubmission(List<Query> queries) {
        List<Query> batch = List.copyOf(queries);
        if (batch.isEmpty()) throw new IllegalArgumentException("Empty eligibility batch");
        String tenantId = batch.get(0).tenantId();
        var numbers = new HashSet<String>();
        for (Query query : batch) {
            if (!tenantId.equals(query.tenantId()) || !numbers.add(query.fileNumber()))
                throw new IllegalArgumentException("Batch must contain unique numbers in one tenant");
        }
        if (!coordinator.tryLockNumbers(tenantId, numbers))
            return batch.stream().map(q -> new Decision(List.of(Reason.NUMBER_BUSY), null, null, null)).toList();
        return transactions.execute(mapper -> batch.stream().map(query -> evaluate(query,
                mapper.eligibilityFacts(query.tenantId(), query.fileNumber()))).toList());
    }
    static Decision evaluate(Query query, BusinessFileEligibilityFacts facts) {
        Objects.requireNonNull(facts, "Missing database facts");
        List<Reason> reasons = new ArrayList<>();
        if (facts.getFileCount() == 0) reasons.add(Reason.FILE_NOT_FOUND);
        else if (facts.getFileCount() != 1) reasons.add(Reason.AMBIGUOUS_FILE_NUMBER);
        else {
            if (!facts.isEffective()) reasons.add(Reason.FILE_NOT_EFFECTIVE);
            if (!Objects.equals(query.fileId(), facts.getFileId())
                    || !Objects.equals(query.businessVersionId(), facts.getBusinessVersionId()))
                reasons.add(Reason.VERSION_CHANGED);
            if (facts.getEffectiveVersionCount() != 1) reasons.add(Reason.INCONSISTENT_CURRENT_VERSION);
            if (facts.isPendingVersion()) reasons.add(Reason.PENDING_VERSION);
            if (query.operation() == Operation.REVISION) {
                if (facts.isRevocationBlocking()) reasons.add(Reason.REVOCATION_IN_PROGRESS_OR_SUCCEEDED);
                if (facts.isRevocationUnknown()) reasons.add(Reason.REVOCATION_STATE_UNKNOWN);
            }
        }
        if (facts.isOccupied()) reasons.add(Reason.FILE_OCCUPIED);
        // Do not choose a winner or expose an arbitrary target when a number is ambiguous.
        return new Decision(reasons, facts.getFileCount() == 1 ? facts.getFileId() : null,
                facts.getFileCount() == 1 ? facts.getBusinessVersionId() : null,
                facts.getFileCount() == 1 ? facts.getCenterVersionId() : null);
    }
}