刘光辉
3 小时以前 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
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
package jnpf.flowable.service;
 
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jnpf.dmsBusinessOccupancy.BusinessFileEligibilityService.Operation;
import jnpf.dmsBusinessOccupancy.BusinessFileSubmissionService;
import jnpf.flowable.mapper.DmsLifecycleNativeFormMapper;
import jnpf.exception.WorkFlowException;
import jnpf.util.UserProvider;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.*;
 
/** Only native launch/relaunch, before engine start. Approval writes do not call this method. */
@Service
public class DmsLifecycleSubmissionGuard implements jnpf.flowable.model.filepermission.DmsRevisionAttachmentCheck {
    @org.springframework.beans.factory.annotation.Autowired
    private jnpf.file.FileContentApi fileContentApi;
    private static final ObjectMapper JSON = new ObjectMapper();
    // Only approved original versions and immutable DMS edit copies may reuse a comparison.
    // Ordinary file IDs can be overwritten by legacy editors and are deliberately not cached.
    private final Map<String, Boolean> contentChecks = Collections.synchronizedMap(new LinkedHashMap<>(128, .75f, true) {
        @Override protected boolean removeEldestEntry(Map.Entry<String, Boolean> entry) { return size() > 2048; }
    });
    private final DmsLifecycleNativeFormMapper mapper;
    private final BusinessFileSubmissionService submissions;
    private final jnpf.dmsBusinessOccupancy.BusinessRevisionSubmissionService revisions;
    private final boolean enabled;
    private final DmsLifecycleNativeFormAdapter nativeForms;
    public DmsLifecycleSubmissionGuard(DmsLifecycleNativeFormMapper mapper, SqlSessionTemplate sessions, DmsLifecycleNativeFormAdapter nativeForms,
            @Value("${dms.business-occupancy.enabled:false}") boolean enabled) {
        this.mapper=mapper;this.enabled=enabled;this.nativeForms=nativeForms;
        revisions=enabled ? new jnpf.dmsBusinessOccupancy.BusinessRevisionSubmissionService(sessions) : null;
        submissions=enabled ? new BusinessFileSubmissionService(sessions) : null;
    }
 
    /** Native launch/relaunch entry; the result freezes DMS identity before the engine is started. */
    public boolean beforeSave(String formId,String taskId,Integer status,Map<String,Object> data) throws WorkFlowException {
        if(!enabled || (!Integer.valueOf(0).equals(status) && !Integer.valueOf(1).equals(status))) return false;
        var form=mapper.publishedLifecycleForm(formId);
        if(form==null) return false;
        try { nativeForms.select(formId,null,true); }
        catch (RuntimeException failure) { throw new WorkFlowException(failure.getMessage()); }
        Operation operation=switch(form.getMainTable()) {
            case "dms_wenjian_xiuding_shenqing" -> Operation.REVISION;
            case "dms_wenjian_chexiao_tiyi" -> Operation.REVOCATION;
            case "dms_wenjian_shixiao_shenqing" -> Operation.INVALIDATION;
            case "wenjian_fushen_yanqi_shenqing" -> Operation.EXTENSION;
            default -> null;
        };
        try {
            String tenant=UserProvider.getUser().getTenantId();
            tenant=tenant==null || tenant.isBlank() ? "0" : tenant;
            var ids=mapper.applicationIds(tenant,taskId,form.getMainTable());
            if(ids.size()>1) throw new IllegalStateException("流程任务关联多个生命周期申请,不能选择任意一条");
            // Native non-incrementing forms initially persist the server-generated task ID as main ID.
            JsonNode config=JSON.readTree(form.getFormData());
            if(config.path("primaryKeyPolicy").asInt(1)!=1)
                throw new IllegalStateException("生命周期申请需要原生非自增主键,不能猜测业务主键");
            String applicationId=ids.isEmpty()?taskId:ids.get(0);
            if("dms_wenjian_xinzeng_shenhe".equals(form.getMainTable())) {
                String numberId=string(data.get("wenjian_bianhao_id"));
                // Empty initial drafts remain valid; once a number is selected its use is atomic
                // with native persistence and the existing name-change callback's row lock.
                String number=numberId==null && status==0 ? null : submissions.guardCreationNumber(
                        tenant,numberId,string(data.get("wenjian_mingcheng")));
                if(status==0 && !submissions.hasOccupancy(tenant,taskId)) return true;
                if(number==null || !number.equals(string(data.get("wenjian_bianhao"))))
                    throw new IllegalArgumentException("新增审核编号与已发放编号不一致");
                List<String> numbers=new ArrayList<>();numbers.add(number);
                String recordKey=detailKey(config.path("fields"),"dms_wenjian_xinzeng_shenhe_jilu_mingxi");
                Object records=recordKey==null ? null : data.get(recordKey);
                if(records!=null && !(records instanceof List<?>)) throw new IllegalArgumentException("新增审核记录明细格式错误");
                if(records instanceof List<?> list) for(Object raw:list) {
                    if(!(raw instanceof Map<?,?> row)) throw new IllegalArgumentException("新增审核记录明细格式错误");
                    String change=string(row.get("biangeng_fangshi"));
                    if(change!=null && !"xinjian".equals(change)) throw new IllegalArgumentException("新增记录只能选择新建");
                    numbers.add(string(row.get("fujian_bianhao")));
                }
                submissions.submitCreation(tenant,applicationId,taskId,numbers,status==0);
                return true;
            }
            if("dms_wenjian_xiuding_shenhe".equals(form.getMainTable())) {
                if(status==0 && !revisions.hasSubmission(tenant,applicationId,taskId)) return true;
                String recordKey=detailKey(config.path("fields"),"dms_wenjian_xiuding_shenhe_jilu_mingxi");
                if(recordKey==null) throw new IllegalArgumentException("修订审核缺少已发布的原生记录子表配置");
                Object records=data.get(recordKey);
                if(!(records instanceof List<?> list)) throw new IllegalArgumentException("修订审核必须提交完整记录明细,允许空数组");
                List<jnpf.dmsBusinessOccupancy.BusinessRevisionRecord> targets=new ArrayList<>();
                for(Object raw:list) {
                    if(!(raw instanceof Map<?,?> row)) throw new IllegalArgumentException("修订记录明细格式错误");
                    var target=new jnpf.dmsBusinessOccupancy.BusinessRevisionRecord();
                    target.setFileId(string(row.get("jilu_wenjian_id")));
                    target.setBusinessVersionId(string(row.get("yuan_wenjian_banben_id")));
                    target.setFileNumber(string(row.get("fujian_bianhao")));
                    target.setChange(string(row.get("biangeng_fangshi")));targets.add(target);
                }
                revisions.submit(tenant,applicationId,taskId,string(data.get("xiuding_shenqing_zixiang_id")),
                        string(data.get("biangeng_fangshi")),targets,status==0);
                if(status==1) data.put(REQUIRED, true);
                return true;
            }
            if(operation==null) throw new IllegalArgumentException("未知生命周期申请类型");
            String key=detailKey(config.path("fields"),form.getMainTable()+"_zixiang");
            Object rows=key==null || data==null ? null : data.get(key);
            List<BusinessFileSubmissionService.Target> targets=new ArrayList<>();
            if(rows instanceof List<?> list) for(Object raw:list) {
                if(!(raw instanceof Map<?,?> row)) throw new IllegalArgumentException("生命周期申请明细格式错误");
                String versionKey=operation==Operation.REVISION?"yuan_wenjian_banben_id":"wenjian_banben_id";
                String file=string(row.get("wenjian_id"));
                String version=operation==Operation.EXTENSION?null:string(row.get(versionKey));
                if(file==null || (operation!=Operation.EXTENSION && version==null)) {
                    if(status==0) continue; // never-submitted incomplete draft is checked by service ownership
                    throw new IllegalArgumentException("文件或来源版本缺失,请重新选择");
                }
                targets.add(new BusinessFileSubmissionService.Target(file,version));
            }
            submissions.submit(tenant,taskId,applicationId,taskId,operation,targets,status==0);
            return true;
        } catch(jnpf.dmsBusinessOccupancy.BusinessFileIneligibleException failure) {
            String tenant=UserProvider.getUser().getTenantId();
            tenant=tenant==null || tenant.isBlank() ? "0" : tenant;
            throw new WorkFlowException(DmsLifecycleEligibilityMessages.render(mapper,tenant,failure));
        } catch(Exception failure) {
            throw new WorkFlowException(failure.getMessage());
        }
    }
    @Override
    public void validate(String formId, Map<String,Object> data) {
        var form = mapper.publishedLifecycleForm(formId);
        if (form == null || !"dms_wenjian_xiuding_shenhe".equals(form.getMainTable())) return;
        try {
            String key = detailKey(JSON.readTree(form.getFormData()).path("fields"), "dms_wenjian_xiuding_shenhe_jilu_mingxi");
            if (key == null || !(data.get(key) instanceof List<?> records))
                throw new IllegalArgumentException("修订审核必须提交完整记录明细");
            String tenant = UserProvider.getUser().getTenantId();
            validateRevisionAttachments(tenant == null || tenant.isBlank() ? "0" : tenant, data, records);
        } catch (java.io.IOException failure) { throw new IllegalArgumentException("修订审核配置无效"); }
    }
    // Runs on canonical adopted attachments inside native save, after occupancy acquisition.
    void validateRevisionAttachments(String tenant, Map<String,Object> data, List<?> records) {
        if ("wubianhua".equals(string(data.get("biangeng_fangshi"))))
            requireUnchangedAttachment(mapper.revisionMainAttachment(tenant, string(data.get("xiuding_shenqing_zixiang_id"))),
                    data.get("wenjian"), "主文件", tenant);
        int index=0;
        for(Object raw:records) {
            index++;
            if(!(raw instanceof Map<?,?> row)) throw new IllegalArgumentException("修订记录明细格式错误");
            if("wubianhua".equals(string(row.get("biangeng_fangshi"))))
                requireUnchangedAttachment(mapper.revisionAttachment(tenant,string(row.get("jilu_wenjian_id")),
                        string(row.get("yuan_wenjian_banben_id"))),row.get("jilu_wenjian"),"第"+index+"行记录", tenant);
        }
    }
    private void requireUnchangedAttachment(String original,Object submitted,String label, String tenant) {
        String cacheKey = comparisonKey(tenant, original, submitted);
        Boolean equal = cacheKey == null ? null : contentChecks.get(cacheKey);
        if (equal == null) {
            equal = jnpf.file.FileContentEquality.same(original, submitted, fileContentApi);
            if (cacheKey != null) contentChecks.put(cacheKey, equal);
        }
        if (equal) return;
        throw new IllegalArgumentException(label+"选择了无变化,但附件与原版本不一致,请恢复原附件或将变更方式改为升级");
    }
    private static String comparisonKey(String tenant, String original, Object submitted) {
        try {
            JsonNode before = JSON.readTree(original);
            JsonNode after = submitted instanceof String text ? JSON.readTree(text) : JSON.valueToTree(submitted);
            if (!before.isArray() || before.isEmpty() || !after.isArray() || after.isEmpty() || before.size() != after.size()) return null;
            List<Object> identity = new ArrayList<>(); identity.add("dms-original-byte-equality-v1"); identity.add(tenant);
            for (JsonNode file : before) {
                if (file.path("fileId").asText().isBlank() || file.path("url").asText().isBlank()) return null;
                identity.add(Arrays.asList(file.path("fileId").asText(), file.path("url").asText()));
            }
            for (JsonNode file : after) {
                if (!file.path("fileId").asText().matches("onlyoffice-edit-temp,[a-f0-9]{32},(?:review_)?[a-f0-9]{32},[^,]+")
                        || file.path("url").asText().isBlank()) return null;
                identity.add(Arrays.asList(file.path("fileId").asText(), file.path("url").asText()));
            }
            return cn.hutool.crypto.digest.DigestUtil.sha256Hex(JSON.writeValueAsString(identity));
        } catch (Exception invalid) { return null; }
    }
 
    private static String string(Object value) {
        return value instanceof String s && !s.isBlank() ? s : null;
    }
    private static String detailKey(JsonNode fields,String table) {
        for(JsonNode field:fields) {
            JsonNode config=field.path("__config__");
            if("table".equals(config.path("jnpfKey").asText()) && table.equals(config.path("tableName").asText()))
                return field.path("__vModel__").asText();
            String child=detailKey(config.path("children"),table);if(child!=null)return child;
        }
        return null;
    }
}