刘光辉
8 小时以前 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
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
package jnpf.base.service;
 
import jnpf.base.entity.VisualdevEntity;
import jnpf.base.model.flow.DataModel;
import jnpf.base.model.uploadedit.UploadEditDraft;
import jnpf.base.util.FlowFormDataUtil;
import jnpf.base.util.UploadEditStateMachine;
import jnpf.base.util.UploadEditWorkingCopy;
import jnpf.exception.DataException;
import jnpf.flowable.model.filepermission.FilePermissionPolicy.Action;
import jnpf.flowable.model.filepermission.FilePermissionView;
import jnpf.flowable.util.filepermission.FileAttachmentChanges;
import jnpf.model.visualjson.FieLdsModel;
import jnpf.model.visualjson.TableModel;
import jnpf.model.visualjson.analysis.*;
import jnpf.onlinedev.model.OnlineInfoModel;
import jnpf.util.JsonUtil;
import jnpf.util.TicketUtil;
import jnpf.util.UserProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.function.Predicate;
 
/** 只校验受控实例的附件副作用;业务主子表仍由原生保存事务持久化。 */
@Service
@RequiredArgsConstructor
public class UploadFilePermissionService {
    private final ObjectProvider<VisualdevService> visualdevService;
    private final ObjectProvider<FlowFormDataUtil> formDataUtil;
    private final UploadFilePermissionQuery filePermissions;
 
    public static final class SaveContext {
        final Map<String, Object> baseline;
        final FilePermissionView permissions;
        final Set<String> sharedDraftIds = new HashSet<>();
        final Set<String> nativeDraftIds = new HashSet<>();
        SaveContext(Map<String, Object> baseline, FilePermissionView permissions) {
            this.baseline = baseline;
            this.permissions = permissions;
        }
    }
 
    /** 使用完整原生详情读取真实实例;请求体的 taskId/flowTaskId 不能开启或关闭校验。 */
    public SaveContext prepare(DataModel model) {
        if (model.getFormAllModel() == null || model.getFormAllModel().stream().noneMatch(this::hasUpload)) return null;
        VisualdevEntity released = visualdevService.getObject().getReleaseInfo(model.getVisualId());
        if (released == null) throw new DataException("附件所属发布表单不存在");
        Map<String, Object> baseline = formDataUtil.getObject().getFilePermissionDataInfo(released, model.getMainId(),
                OnlineInfoModel.builder().formAllModel(model.getFormAllModel()).build());
        if (baseline == null || baseline.isEmpty() || !contains(baseline, "flowTaskId")) throw new DataException("附件所属表单记录不存在");
        String taskId = Objects.toString(value(baseline, "flowTaskId"), "");
        if (taskId.trim().isEmpty()) return null;
        FilePermissionView view = filePermissions.current(taskId, model.getVisualId());
        if (FilePermissionNativeDraft.taskAbsent(view, taskId, model.getVisualId())
                && FilePermissionNativeDraft.matches(baseline, model.getMainId(), taskId)) return null;
        if (view == null || !Objects.equals(taskId, view.getTaskId()) || !Objects.equals(model.getVisualId(), view.getFormId())
                || !"ready".equals(view.getStatus()) || view.isEnabled() && (view.getRevision() == null || view.getRevision().isEmpty())) {
            throw new DataException("当前流程文件权限暂不可用,请稍后重试");
        }
        if (view.isEnabled() && !Objects.equals(model.getMainId(), Objects.toString(value(baseline, "id"), ""))) {
            throw new DataException("附件基准与原生保存记录不匹配");
        }
        return view.isEnabled() ? new SaveContext(baseline, view) : null;
    }
 
    /** DMS原生保存继承来源不要求打开在线编辑;调用方已确认目标表单和原生字段权限。 */
    List<Map<String, Object>> dmsMainAttachmentBaseline(DataModel model) {
        VisualdevEntity released = visualdevService.getObject().getReleaseInfo(model.getVisualId());
        if (released == null) throw new DataException("附件所属发布表单不存在");
        Map<String, Object> baseline = formDataUtil.getObject().getFilePermissionDataInfo(released, model.getMainId(),
                OnlineInfoModel.builder().formAllModel(model.getFormAllModel()).build());
        if (baseline == null || baseline.isEmpty()
                || !Objects.equals(model.getMainId(), Objects.toString(value(baseline, "id"), "")))
            throw new DataException("附件来源基准与原生记录不匹配");
        return UploadEditStateMachine.files(value(baseline, "wenjian"));
    }
 
    /** 在 prepareSave 已锁定并核验工作稿后、任何原生写入前调用。 */
    public void validate(SaveContext context, DataModel model, List<UploadEditDraft> drafts, Predicate<String> nativeDenied) {
        if (context == null) return;
        for (FormAllModel item : model.getFormAllModel()) {
            if (item.getChildList() != null) {
                validateChildren(context, model, item.getChildList(), drafts, nativeDenied);
            } else {
                FieLdsModel field = mainField(item);
                if (!upload(field) || !model.isWorkflowFileCheckpoint() && nativeDenied.test(field.getVModel())) continue;
                String key = field.getVModel();
                validateField(context, model, key, null, null, value(context.baseline, key), model.getDataNewMap(), key, drafts);
                if (model.isWorkflowFileCheckpoint()) model.getWorkflowWorkingFields().add(key);
            }
        }
    }
 
    private void validateChildren(SaveContext context, DataModel model, FormColumnTableModel child,
                                  List<UploadEditDraft> drafts, Predicate<String> nativeDenied) {
        if (Boolean.TRUE.equals(child.getDisabled()) || child.getChildList().stream().noneMatch(c -> upload(c.getFieLdsModel()))) return;
        String parent = child.getTableModel();
        TableModel table = model.getTableModelList().stream().filter(t -> Objects.equals(t.getTable(), child.getTableName()))
                .findFirst().orElseThrow(() -> new DataException("无法核验附件子表主键"));
        String primary = formDataUtil.getObject().getKey(table, model.getDbType());
        List<Map<String, Object>> before = rows(value(context.baseline, parent));
        List<Map<String, Object>> after = rows(model.getDataNewMap().get(parent));
        Map<String, Map<String, Object>> oldRows = index(before, primary, true);
        Map<String, Map<String, Object>> newRows = index(after, primary, false);
        for (FormColumnModel column : child.getChildList()) {
            FieLdsModel field = column.getFieLdsModel();
            if (!upload(field)) continue;
            String key = field.getVModel();
            String permissionKey = parent + "-" + key;
            // 原生整行删除不经过列 write 过滤,必须独立校验 REMOVE。
            if (!Boolean.TRUE.equals(model.getOnlyUpdate())) {
                for (Map.Entry<String, Map<String, Object>> old : oldRows.entrySet()) {
                    if (!newRows.containsKey(old.getKey()) && !UploadEditStateMachine.files(value(old.getValue(), key)).isEmpty()) {
                        require(context, permissionKey, Action.REMOVE);
                    }
                }
            }
            if (!model.isWorkflowFileCheckpoint() && nativeDenied.test(permissionKey)) continue;
            for (Map<String, Object> row : after) {
                String rowId = Objects.toString(value(row, primary), "");
                Map<String, Object> old = oldRows.get(rowId);
                validateField(context, model, permissionKey, parent, rowId, old == null ? null : value(old, key), row, key, drafts);
            }
            if (model.isWorkflowFileCheckpoint()) model.getWorkflowWorkingFields().add(permissionKey);
        }
    }
 
    private void validateField(SaveContext context, DataModel model, String permissionKey, String parent, String rowId,
                               Object oldValue, Map<String, Object> target, String key, List<UploadEditDraft> drafts) {
        List<Map<String, Object>> before = UploadEditStateMachine.files(oldValue);
        // 原生 SQL UPDATE 对省略的可写列写 NULL,不把缺少字段误判为 PATCH。
        Object submittedValue = parent == null ? target.get(key) : value(target, key);
        List<Map<String, Object>> after = UploadEditStateMachine.files(submittedValue);
        UploadEditDraft trusted = drafts.stream().filter(d -> !context.nativeDraftIds.contains(d.getId()) && Objects.equals(d.getRecordId(), model.getMainId())
                && (Objects.equals(d.getOwnerId(), UserProvider.getUser().getUserId()) || context.sharedDraftIds.contains(d.getId()))
                && Objects.equals(d.getModelId(), model.getVisualId()) && Objects.equals(d.getField(), key)
                && Objects.toString(d.getParentField(), "").equals(Objects.toString(parent, ""))
                && Objects.toString(d.getRowId(), "").equals(Objects.toString(rowId, ""))
                && UploadEditStateMachine.sameFiles(d.getBaselineJson(), before)
                && UploadEditStateMachine.sameFiles(d.getCurrentJson(), after)).findFirst().orElse(null);
        Map<String, String> origins = trusted == null ? Collections.emptyMap() : UploadEditStateMachine.editOrigins(trusted);
        List<String> effectiveAfter = new ArrayList<>();
        List<Map<String, Object>> canonical = new ArrayList<>();
        for (Map<String, Object> file : after) {
            String id = (String) file.get("fileId");
            String origin = origins.get(id);
            if (origin != null && !origin.equals(id)) {
                if (trusted == null || !context.sharedDraftIds.contains(trusted.getId())) require(context, permissionKey, Action.EDIT);
                require(context, permissionKey, Action.VIEW);
            }
            effectiveAfter.add(origin == null ? id : origin);
            Map<String, Object> existing = before.stream().filter(f -> id.equals(f.get("fileId"))).findFirst().orElse(null);
            if (existing == null && trusted != null && origin == null && id.contains("onlyoffice-edit-temp,")) {
                throw new DataException("旧工作稿缺少编辑来源,暂不能采用");
            }
            // 工作稿值经过版本匹配;新的普通文件必须提供本人本租户的上传凭据。
            boolean templateChanged = "wenjian".equals(key) && (parent == null || parent.isBlank())
                    && existing != null && !Objects.equals(existing.get(jnpf.base.util.UploadTemplateSource.FIELD),
                    file.get(jnpf.base.util.UploadTemplateSource.FIELD));
            if (templateChanged) require(context, permissionKey, Action.REPLACE);
            canonical.add(templateChanged ? trusted != null ? file : uploaded(file)
                    : existing != null ? existing : trusted != null ? file : uploaded(file));
        }
        try {
            for (Action action : FileAttachmentChanges.classify(ids(before), effectiveAfter)) require(context, permissionKey, action);
        } catch (IllegalArgumentException invalid) {
            throw new DataException(invalid.getMessage());
        }
        // 保留原生 NULL 语义;只规范实际提交的附件,避免同一 fileId 伪造回源 URL。
        if (submittedValue != null && !"".equals(submittedValue)) put(target, key, canonical);
        for (UploadEditDraft draft : drafts) {
            if (context.nativeDraftIds.contains(draft.getId()) && Objects.equals(draft.getField(), key)
                    && Objects.toString(draft.getParentField(), "").equals(Objects.toString(parent, ""))
                    && Objects.toString(draft.getRowId(), "").equals(Objects.toString(rowId, ""))) {
                draft.setCurrentJson(JsonUtil.getObjectToString(canonical));
            }
        }
    }
 
    /** 工作稿中上传/移除/替换也需实时裁决;打开编辑权限不能代替其他动作。 */
    public void validateDraftChange(UploadEditDraft draft, List<Map<String, Object>> next) {
        SaveContext context = draftContext(draft);
        if (context == null) return;
        List<Map<String, Object>> current = UploadEditStateMachine.files(draft.getCurrentJson());
        for (Action action : FileAttachmentChanges.classify(ids(current), ids(next))) {
            require(context, permissionField(draft), action);
        }
        requireTemplateReplacement(context, draft, current, next);
    }
 
    private void requireTemplateReplacement(SaveContext context, UploadEditDraft draft,
                                            List<Map<String, Object>> before, List<Map<String, Object>> after) {
        if (!"wenjian".equals(draft.getField()) || org.springframework.util.StringUtils.hasText(draft.getParentField())) return;
        for (Map<String, Object> file : after) for (Map<String, Object> old : before) {
            if (Objects.equals(file.get("fileId"), old.get("fileId"))
                    && !Objects.equals(file.get(jnpf.base.util.UploadTemplateSource.FIELD), old.get(jnpf.base.util.UploadTemplateSource.FIELD)))
                require(context, permissionField(draft), Action.REPLACE);
        }
    }
 
    /** 恢复正文要求 EDIT,恢复引用按真实增删替换检查,不能通过“放弃”绕过流程限制。 */
    public void validateDraftDiscard(UploadEditDraft draft) {
        validateDraftDiscard(draft, draftContext(draft));
    }
    private void validateDraftDiscard(UploadEditDraft draft, SaveContext context) {
        if (context == null) return;
        List<String> baseline = ids(UploadEditStateMachine.files(draft.getBaselineJson()));
        Map<String, String> origins = UploadEditStateMachine.editOrigins(draft);
        List<String> current = new ArrayList<>();
        for (String id : ids(UploadEditStateMachine.files(draft.getCurrentJson()))) {
            String origin = origins.get(id);
            if (origin != null && !id.equals(origin) && baseline.contains(origin)) {
                require(context, permissionField(draft), Action.EDIT);
                current.add(origin);
            } else {
                if (origin == null && !baseline.contains(id) && id.contains("onlyoffice-edit-temp,")) {
                    throw new DataException("旧工作稿缺少编辑来源,请重新核验附件权限");
                }
                current.add(id);
            }
        }
        for (Action action : FileAttachmentChanges.classify(current, baseline)) require(context, permissionField(draft), action);
        requireTemplateReplacement(context, draft, UploadEditStateMachine.files(draft.getCurrentJson()), UploadEditStateMachine.files(draft.getBaselineJson()));
    }
 
    /** 按钮提示复用实际恢复校验;不可用时关闭按钮,不使已成功的工作稿更新报错。 */
    public boolean canDiscardDraft(UploadEditDraft draft) {
        try {
            if (UploadEditStateMachine.sameFiles(draft.getBaselineJson(), draft.getCurrentJson())) return false;
            validateDraftDiscard(draft);
            return true;
        } catch (RuntimeException unavailable) {
            return false;
        }
    }
 
    /** Reuse only the current batch's already authenticated permission view. */
    public boolean canDiscardDraft(UploadEditDraft draft, FilePermissionView permissions) {
        if (permissions == null || !permissions.isEnabled()) return false;
        try {
            if (UploadEditStateMachine.sameFiles(draft.getBaselineJson(), draft.getCurrentJson())) return false;
            validateDraftDiscard(draft, new SaveContext(Collections.emptyMap(), permissions));
            return true;
        } catch (RuntimeException denied) { return false; }
    }
 
    private SaveContext draftContext(UploadEditDraft draft) {
        // 以真实表单详情解析实例,不能只依赖历史工作稿的 taskId。
        VisualdevEntity released = visualdevService.getObject().getReleaseInfo(draft.getModelId());
        if (released == null) throw new DataException("附件所属发布表单不存在");
        Map<String, Object> data = formDataUtil.getObject().getFilePermissionDataInfo(released, draft.getRecordId(), OnlineInfoModel.builder().build());
        if (data == null || data.isEmpty() || !contains(data, "flowTaskId")) throw new DataException("附件所属表单记录不存在");
        String taskId = Objects.toString(value(data, "flowTaskId"), "");
        if (taskId.isEmpty()) return null;
        FilePermissionView view = filePermissions.current(taskId, draft.getModelId());
        if (FilePermissionNativeDraft.taskAbsent(view, taskId, draft.getModelId())
                && FilePermissionNativeDraft.matches(data, draft.getRecordId(), taskId)) return null;
        if (view == null || !Objects.equals(taskId, view.getTaskId()) || !Objects.equals(draft.getModelId(), view.getFormId())
                || !"ready".equals(view.getStatus())) throw new DataException("当前流程文件权限暂不可用");
        return view.isEnabled() ? new SaveContext(data, view) : null;
    }
 
    private static String permissionField(UploadEditDraft draft) {
        return draft.getParentField() == null || draft.getParentField().isEmpty() ? draft.getField() : draft.getParentField() + "-" + draft.getField();
    }
 
    private static Map<String, Object> uploaded(Map<String, Object> file) {
        Object receipt = file.get("uploadReceipt");
        String payload = receipt instanceof String ? TicketUtil.parseTicket((String) receipt) : null;
        Map<?, ?> proof = payload == null ? Collections.emptyMap() : JsonUtil.getJsonToBean(payload, Map.class);
        if (proof == null || !"upload-edit-source".equals(proof.get("purpose"))
                || !UploadEditDraftService.tenant(UserProvider.getUser()).equals(proof.get("tenantId"))
                || !Objects.equals(UserProvider.getUser().getUserId(), proof.get("ownerId"))
                || !Objects.equals(file.get("fileId"), proof.get("fileId"))) {
            throw new DataException("无法核验新附件的上传来源,请重新上传");
        }
        Map<String, Object> accepted = new LinkedHashMap<>();
        for (String name : Arrays.asList("fileId", "name", "fileSize", "fileExtension", "url", "contentSavedAt")) accepted.put(name, proof.get(name));
        jnpf.base.util.UploadTemplateSource.inherit(accepted, proof);
        return accepted;
    }
 
    private static void require(SaveContext context, String field, Action action) {
        if (!context.permissions.allows(field, action)) throw new DataException("当前流程不允许附件操作:" + action);
    }
    private boolean hasUpload(FormAllModel item) {
        return item.getChildList() != null ? item.getChildList().getChildList().stream().anyMatch(c -> upload(c.getFieLdsModel())) : upload(mainField(item));
    }
    private static FieLdsModel mainField(FormAllModel item) {
        return item.getFormColumnModel() != null ? item.getFormColumnModel().getFieLdsModel()
                : item.getFormMastTableModel() != null ? item.getFormMastTableModel().getMastTable().getFieLdsModel() : null;
    }
    private static boolean upload(FieLdsModel field) {
        return field != null && field.getConfig() != null && "uploadFile".equals(field.getConfig().getJnpfKey());
    }
    private static List<String> ids(List<Map<String, Object>> files) {
        List<String> result = new ArrayList<>();
        for (Map<String, Object> file : files) result.add((String) file.get("fileId"));
        return result;
    }
    @SuppressWarnings("unchecked")
    private static List<Map<String, Object>> rows(Object value) {
        if (value == null) return Collections.emptyList();
        if (!(value instanceof List<?>)) throw new DataException("附件子表数据不是有效列表");
        for (Object row : (List<?>) value) if (!(row instanceof Map)) throw new DataException("附件子表行无效");
        return (List<Map<String, Object>>) value;
    }
    private static Map<String, Map<String, Object>> index(List<Map<String, Object>> rows, String primary, boolean required) {
        Map<String, Map<String, Object>> result = new LinkedHashMap<>();
        for (Map<String, Object> row : rows) {
            String id = Objects.toString(value(row, primary), "");
            if (id.isEmpty()) { if (required) throw new DataException("附件子表缺少主键"); else continue; }
            if (result.put(id, row) != null) throw new DataException("附件子表主键重复");
        }
        return result;
    }
    private static boolean contains(Map<String, Object> map, String key) {
        return map.keySet().stream().anyMatch(k -> k.equalsIgnoreCase(key));
    }
    private static Object value(Map<String, Object> map, String key) {
        boolean found = false;
        Object result = null;
        for (Map.Entry<String, Object> e : map.entrySet()) {
            if (!e.getKey().equalsIgnoreCase(key)) continue;
            if (found) throw new DataException("附件字段存在大小写冲突");
            found = true;
            result = e.getValue();
        }
        return result;
    }
    private static void put(Map<String, Object> map, String key, Object value) {
        String existing = map.keySet().stream().filter(k -> k.equalsIgnoreCase(key)).findFirst().orElse(key);
        map.put(existing, value);
    }
}