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);
|
}
|
}
|