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; private final ObjectProvider formDataUtil; private final UploadFilePermissionQuery filePermissions; public static final class SaveContext { final Map baseline; final FilePermissionView permissions; final Set sharedDraftIds = new HashSet<>(); final Set nativeDraftIds = new HashSet<>(); SaveContext(Map 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 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> dmsMainAttachmentBaseline(DataModel model) { VisualdevEntity released = visualdevService.getObject().getReleaseInfo(model.getVisualId()); if (released == null) throw new DataException("附件所属发布表单不存在"); Map 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 drafts, Predicate 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 drafts, Predicate 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> before = rows(value(context.baseline, parent)); List> after = rows(model.getDataNewMap().get(parent)); Map> oldRows = index(before, primary, true); Map> 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> 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 row : after) { String rowId = Objects.toString(value(row, primary), ""); Map 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 target, String key, List drafts) { List> before = UploadEditStateMachine.files(oldValue); // 原生 SQL UPDATE 对省略的可写列写 NULL,不把缺少字段误判为 PATCH。 Object submittedValue = parent == null ? target.get(key) : value(target, key); List> 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 origins = trusted == null ? Collections.emptyMap() : UploadEditStateMachine.editOrigins(trusted); List effectiveAfter = new ArrayList<>(); List> canonical = new ArrayList<>(); for (Map 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 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> next) { SaveContext context = draftContext(draft); if (context == null) return; List> 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> before, List> after) { if (!"wenjian".equals(draft.getField()) || org.springframework.util.StringUtils.hasText(draft.getParentField())) return; for (Map file : after) for (Map 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 baseline = ids(UploadEditStateMachine.files(draft.getBaselineJson())); Map origins = UploadEditStateMachine.editOrigins(draft); List 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 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 uploaded(Map 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 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 ids(List> files) { List result = new ArrayList<>(); for (Map file : files) result.add((String) file.get("fileId")); return result; } @SuppressWarnings("unchecked") private static List> 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>) value; } private static Map> index(List> rows, String primary, boolean required) { Map> result = new LinkedHashMap<>(); for (Map 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 map, String key) { return map.keySet().stream().anyMatch(k -> k.equalsIgnoreCase(key)); } private static Object value(Map map, String key) { boolean found = false; Object result = null; for (Map.Entry 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 map, String key, Object value) { String existing = map.keySet().stream().filter(k -> k.equalsIgnoreCase(key)).findFirst().orElse(key); map.put(existing, value); } }