package com.dms.service; import com.dms.common.DmsException; import com.dms.common.Errors; import com.dms.common.Payloads; import com.dms.mapper.BorrowRequestMapper; import com.dms.mapper.DocumentMapper; import com.dms.mapper.NotificationMapper; import com.dms.mapper.OrganizationMapper; import com.dms.mapper.PermissionMapper; import com.dms.mapper.UserMapper; import com.dms.model.BorrowRequestEntity; import com.dms.model.BorrowRequestItemEntity; import com.dms.model.DocumentEntity; import com.dms.model.NotificationEntity; import com.dms.model.OrganizationEntity; import com.dms.model.PermissionEntity; import com.dms.model.UserEntity; import com.dms.security.AuthContext; import com.dms.security.Reauth; import com.dms.storage.StoragePaths; import com.dms.storage.UploadStorage.StagedUpload; import jakarta.servlet.http.HttpServletRequest; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.time.LocalDateTime; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; /** * 借阅申请审批流(V22迁移,契约1.37)。 * *

借阅授权入口由手工配置改为申请-审批:无查看且无下载权限(密级足够)的用户 * 发起借阅申请,一批用户共用一张审批单(必传 PDF/图片,契约1.72);申请以 PENDING 待办 * 通知所有对该文档有授权权限(CONFIG_PERMISSION,矩阵反查+内置超管)的用户, * 任一审批人同意/拒绝即定(先到先得),其余待办自动失效;通过后逐条目落地 * doc_permission(复用借阅 ACL 模型,到期/回收站语义自动继承),凭证以引用 * 方式挂接各条目;申请人收到结果通知。

*/ @Service public class BorrowRequestService { private static final Set REQUEST_FIELDS = Set.of("entries", "expiresAt"); private static final Set ENTRY_FIELDS = Set.of("subjectId", "canView", "canDownload"); private static final String[] ACTION_FIELDS = {"canView", "canDownload"}; /** 与借阅授权同口径的借阅时限:最长7天 + 60秒拾取舍容忍差。 */ private static final Duration MAX_BORROW = Duration.ofDays(7); private static final Duration BORROW_TOLERANCE = Duration.ofSeconds(60); /** 对齐 Python isoformat(timespec="milliseconds") + "Z"。 */ private static final DateTimeFormatter MILLIS_ISO_UTC = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); /** 申请条目(校验归一后的视图)。 */ private record EntryRequest(long subjectId, boolean canView, boolean canDownload) { } private final DocumentMapper documentMapper; private final PermissionMapper permissionMapper; private final UserMapper userMapper; private final OrganizationMapper organizationMapper; private final BorrowRequestMapper borrowRequestMapper; private final NotificationMapper notificationMapper; private final AuthorizationService authorizationService; private final AuditService auditService; private final Reauth reauth; private final VoucherService voucherService; private final StoragePaths storagePaths; public BorrowRequestService(DocumentMapper documentMapper, PermissionMapper permissionMapper, UserMapper userMapper, OrganizationMapper organizationMapper, BorrowRequestMapper borrowRequestMapper, NotificationMapper notificationMapper, AuthorizationService authorizationService, AuditService auditService, Reauth reauth, VoucherService voucherService, StoragePaths storagePaths) { this.documentMapper = documentMapper; this.permissionMapper = permissionMapper; this.userMapper = userMapper; this.organizationMapper = organizationMapper; this.borrowRequestMapper = borrowRequestMapper; this.notificationMapper = notificationMapper; this.authorizationService = authorizationService; this.auditService = auditService; this.reauth = reauth; this.voucherService = voucherService; this.storagePaths = storagePaths; } // ------------------------------------------------------------ 提交申请 /** * 提交借阅申请:一批用户共用一张审批单(必传 PDF/图片,契约1.72),落库后向所有 * 有授权权限的用户发 PENDING 待办通知。文件提升放事务成功之后。 */ public Map createRequest(long documentId, Map payload, MultipartFile file, HttpServletRequest request) { // 契约1.66:调阅申请不再要求二次认证,改为强制上传审批单(无凭证不受理) if (file == null || file.isEmpty()) { throw Errors.invalidArgument("调阅申请必须上传审批单(PDF 或图片)"); } List entries = parseEntries(payload); LocalDateTime expiresAt = parseExpires(payload.get("expiresAt")); // 非锁定预读仅取密级定凭证存储域;完整校验在 doCreate 事务内(审批单已必传) DocumentEntity preview = documentMapper.findByIdNotDeleted(documentId); StagedUpload staged = voucherService.stageVoucher(file, preview == null ? null : preview.getSecurityLevel()); Map result; try { result = doCreate(documentId, entries, expiresAt, staged, request); if (staged != null) { staged.promote(); } } catch (RuntimeException ex) { if (staged != null) { staged.cleanupAll(); } throw ex; } return result; } @Transactional protected Map doCreate(long documentId, List entries, LocalDateTime expiresAt, StagedUpload staged, HttpServletRequest request) { AuthContext context = AuthContext.get(); DocumentEntity document = documentMapper.findByIdNotDeleted(documentId); if (document == null) { throw Errors.resourceNotFound("文档不存在"); } if ("ATTACHMENT".equals(document.getDocumentType())) { throw Errors.attachmentHasNoAcl(); } if ("SUB_PLAN".equals(document.getDocumentType())) { throw Errors.subPlanPermissionInherited(); } authorizationService.requireSecurityClearance(document, context); // 申请门槛:仅对文档既无查看又无下载权限的用户可发起(前端按钮同口径) List allowed = authorizationService.planAllowedActions(document, context, authorizationService.evaluatePlanAccess(document, context)); if (allowed.contains("VIEW") || allowed.contains("DOWNLOAD")) { throw Errors.invalidArgument("你已拥有该文档的查看或下载权限,无需调阅"); } Map subjects = lockedSubjects(entries); for (Map.Entry row : subjects.entrySet()) { UserEntity user = row.getValue(); if (!authorizationService.clearanceSatisfied(user.getSecurityLevel(), document.getSecurityLevel())) { Map details = Payloads.orderedMap(); details.put("subjectId", String.valueOf(row.getKey())); details.put("subjectName", user.getRealName()); throw Errors.securityLevelForbidden(); } if (borrowRequestMapper.countPendingBySubject(document.getId(), row.getKey()) > 0) { throw new DmsException("BORROW_REQUEST_DUPLICATE", 409, "该用户对该文档已有待审批的调阅申请", Payloads.orderedMap()); } } LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); BorrowRequestEntity entity = new BorrowRequestEntity(); entity.setDocumentId(document.getId()); entity.setRequesterId(context.userId()); entity.setRequesterName(context.realName()); entity.setExpiresAt(expiresAt); if (staged != null) { entity.setVoucherFileName(staged.originalFileName()); entity.setVoucherStoragePath(staged.relativePath()); entity.setVoucherFileSize(staged.fileSize()); entity.setVoucherUploadedAt(now); } entity.setStatus("PENDING"); borrowRequestMapper.insertRequest(entity); List> auditEntries = new ArrayList<>(); for (EntryRequest entry : entries) { BorrowRequestItemEntity item = new BorrowRequestItemEntity(); item.setRequestId(entity.getId()); item.setSubjectId(entry.subjectId()); item.setSubjectName(subjects.get(entry.subjectId()).getRealName()); item.setCanView(entry.canView()); item.setCanDownload(entry.canDownload()); borrowRequestMapper.insertItem(item); Map auditItem = Payloads.orderedMap(); auditItem.put("subjectId", String.valueOf(entry.subjectId())); auditItem.put("subjectName", subjects.get(entry.subjectId()).getRealName()); auditItem.put("canView", entry.canView()); auditItem.put("canDownload", entry.canDownload()); auditEntries.add(auditItem); } Map approvers = resolveApprovers(document, context.userId()); for (Map.Entry approver : approvers.entrySet()) { notificationMapper.insert(pendingNotification(entity, document, approver.getKey(), approver.getValue())); } Map auditDetail = Payloads.orderedMap(); auditDetail.put("documentId", String.valueOf(document.getId())); auditDetail.put("requesterId", String.valueOf(context.userId())); auditDetail.put("requesterName", context.realName()); auditDetail.put("entries", auditEntries); auditDetail.put("expiresAt", PermissionService.iso(expiresAt)); auditDetail.put("voucherFileName", entity.getVoucherFileName()); auditDetail.put("notified", approvers.size()); auditService.insert(auditService.businessAudit(request, AuditService.Action.BORROW_REQUEST_CREATE, "BORROW_REQUEST", entity.getId(), document.getDocumentName(), auditService.toJson(auditDetail))); Map result = Payloads.orderedMap(); result.put("requestId", String.valueOf(entity.getId())); result.put("notified", approvers.size()); return result; } // ------------------------------------------------------------ 查询 /** 申请详情:申请人本人或对该文档有授权权限的用户可见(审批人查看审批单)。 */ @Transactional(readOnly = true) public Map getRequestDetail(long requestId, HttpServletRequest request) { AuthContext context = AuthContext.get(); BorrowRequestEntity entity = borrowRequestMapper.findById(requestId); if (entity == null) { throw Errors.resourceNotFound("调阅申请不存在"); } DocumentEntity document = requireVisibleDocument(entity, context); List> items = new ArrayList<>(); for (BorrowRequestItemEntity item : borrowRequestMapper.findItems(entity.getId())) { Map values = Payloads.orderedMap(); values.put("subjectId", String.valueOf(item.getSubjectId())); values.put("subjectName", item.getSubjectName()); values.put("canView", Boolean.TRUE.equals(item.getCanView())); values.put("canDownload", Boolean.TRUE.equals(item.getCanDownload())); items.add(values); } Map result = Payloads.orderedMap(); result.put("requestId", String.valueOf(entity.getId())); result.put("documentId", String.valueOf(document.getId())); result.put("documentName", document.getDocumentName()); result.put("requesterId", String.valueOf(entity.getRequesterId())); result.put("requesterName", entity.getRequesterName()); result.put("entries", items); result.put("expiresAt", PermissionService.iso(entity.getExpiresAt())); result.put("voucherFileName", entity.getVoucherFileName()); result.put("voucherFileSize", entity.getVoucherFileSize()); result.put("status", entity.getStatus()); result.put("handledById", entity.getHandledBy() == null ? null : String.valueOf(entity.getHandledBy())); result.put("handledByName", entity.getHandledByName()); result.put("handledAt", entity.getHandledAt() == null ? null : PermissionService.iso(entity.getHandledAt())); result.put("createdAt", PermissionService.iso(entity.getCreatedAt())); return result; } /** 申请审批单下载(attachment 直出,PDF/图片按扩展名映射类型,权限同详情)。 */ @Transactional(readOnly = true) public ResponseEntity downloadRequestVoucher(long requestId, HttpServletRequest request) { AuthContext context = AuthContext.get(); BorrowRequestEntity entity = borrowRequestMapper.findById(requestId); if (entity == null) { throw Errors.resourceNotFound("调阅申请不存在"); } requireVisibleDocument(entity, context); if (entity.getVoucherStoragePath() == null || entity.getVoucherStoragePath().isEmpty()) { throw Errors.resourceNotFound("该申请未上传审批单"); } Path path = storagePaths.resolveStoragePath(entity.getVoucherStoragePath()); if (!Files.isRegularFile(path)) { throw Errors.resourceNotFound("审批单文件不存在或已被清理"); } String name = entity.getVoucherFileName() != null ? entity.getVoucherFileName() : "voucher.pdf"; return ResponseEntity.ok() .contentType(VoucherService.voucherMediaType(name)) .header(HttpHeaders.CONTENT_DISPOSITION, FileReadService.attachmentDisposition(name)) .header("X-Content-Type-Options", "nosniff") .body(new FileSystemResource(path)); } // ------------------------------------------------------------ 审批联动(通知 handle 触发) /** * 任一审批人处理即定(先到先得):PENDING 行锁守卫 + 审批人实时鉴权 * (防止通知发出后角色/密级变化导致越权);通过则逐条目替换式落地借阅 * ACL 并以引用挂接审批单;其余审批人待办同步失效;申请人收结果通知。 */ @Transactional public void handleApproval(long requestId, String action, long notificationId, HttpServletRequest request) { reauth.require(request); AuthContext context = AuthContext.get(); BorrowRequestEntity entity = borrowRequestMapper.findForUpdate(requestId); if (entity == null) { throw Errors.resourceNotFound("调阅申请不存在"); } if (!"PENDING".equals(entity.getStatus())) { throw Errors.dataVersionConflict("该调阅申请已被处理"); } DocumentEntity document = documentMapper.findByIdNotDeleted(entity.getDocumentId()); if (document == null) { throw Errors.resourceNotFound("文档不存在"); } // 审批人运行时鉴权:与详情/授权配置同口径(密级前置 + CONFIG_PERMISSION 位) authorizationService.requirePlanAction(document, context, "CONFIG_PERMISSION"); LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); boolean approved = "AGREE".equals(action); String finalStatus = approved ? "APPROVED" : "REJECTED"; int updated = borrowRequestMapper.updateHandled(requestId, finalStatus, context.userId(), context.realName(), now); if (updated == 0) { throw Errors.dataVersionConflict("该调阅申请已被处理"); } List items = borrowRequestMapper.findItems(requestId); if (approved) { grantPermissions(document, entity, items, context, now); } notificationMapper.settlePendingByBiz(requestId, "APPROVAL", notificationId, action, now); NotificationEntity result = new NotificationEntity(); result.setUserId(entity.getRequesterId()); result.setTitle(approved ? "调阅申请已通过" : "调阅申请已拒绝"); result.setContent("你申请调阅的《" + document.getDocumentName() + "》已被 " + context.realName() + (approved ? " 通过" : " 拒绝")); result.setNotifyType("APPROVAL"); result.setAction("NONE"); result.setStatus("UNREAD"); result.setBizId(requestId); notificationMapper.insert(result); Map auditDetail = Payloads.orderedMap(); auditDetail.put("requestId", String.valueOf(requestId)); auditDetail.put("documentId", String.valueOf(document.getId())); auditDetail.put("requesterId", String.valueOf(entity.getRequesterId())); auditDetail.put("granted", approved ? items.size() : 0); auditService.insert(auditService.businessAudit(request, approved ? AuditService.Action.BORROW_REQUEST_APPROVE : AuditService.Action.BORROW_REQUEST_REJECT, "BORROW_REQUEST", requestId, document.getDocumentName(), auditService.toJson(auditDetail))); } // ------------------------------------------------------------ 内部实现 /** 锁读申请用户并校验存在/启用,返回主体ID→用户(对齐 PermissionService.lockedSubjectNames,无实例状态)。 */ private Map lockedSubjects(List entries) { Set userIds = new LinkedHashSet<>(); for (EntryRequest entry : entries) { userIds.add(entry.subjectId()); } if (userIds.isEmpty()) { throw Errors.invalidArgument("entries不能为空"); } Map users = new LinkedHashMap<>(); for (UserEntity user : userMapper.listActiveByIdsForUpdate(userIds)) { if ("ENABLED".equals(user.getStatus())) { users.put(user.getId(), user); } } if (!users.keySet().equals(userIds)) { throw Errors.resourceNotFound("调阅用户不存在、已删除或已禁用"); } return users; } /** 审批人反查:矩阵 can_config_permission=1(密级×OWN/OTHER 口径对齐)+ 内置超管。 */ private Map resolveApprovers(DocumentEntity document, long requesterId) { String docOrgPath = null; if (document.getOrganizationId() != null) { OrganizationEntity docOrg = organizationMapper.findByIdNotDeleted(document.getOrganizationId()); docOrgPath = docOrg == null ? null : docOrg.getOrgPath(); } Map approvers = new LinkedHashMap<>(); for (Map row : borrowRequestMapper.listConfigApprovers(document.getSecurityLevel(), docOrgPath, requesterId)) { approvers.put(((Number) row.get("id")).longValue(), (String) row.get("real_name")); } UserEntity admin = userMapper.findActiveByUsername( AdminPermission.SUPER_ADMIN_USERNAME); if (admin != null && "ENABLED".equals(admin.getStatus()) && admin.getId() != requesterId) { approvers.put(admin.getId(), admin.getRealName()); } if (approvers.isEmpty()) { throw Errors.invalidArgument("暂无可审批该申请的用户,请联系管理员"); } return approvers; } /** 审批通过:软删同人旧活跃 ACL 后按申请条目替换式插入,凭证引用挂接各条目。 */ private void grantPermissions(DocumentEntity document, BorrowRequestEntity entity, List items, AuthContext context, LocalDateTime now) { Map activeBySubject = new LinkedHashMap<>(); for (PermissionEntity permission : permissionMapper.listActiveByDocumentForUpdate(document.getId())) { activeBySubject.put(permission.getSubjectId(), permission); } for (BorrowRequestItemEntity item : items) { PermissionEntity existing = activeBySubject.get(item.getSubjectId()); if (existing != null) { permissionMapper.softDeleteById(existing.getId(), now, context.userId()); } PermissionEntity granted = new PermissionEntity(); granted.setDocumentId(document.getId()); granted.setSubjectId(item.getSubjectId()); granted.setSubjectName(item.getSubjectName()); granted.setCanView(Boolean.TRUE.equals(item.getCanView())); granted.setCanDownload(Boolean.TRUE.equals(item.getCanDownload())); granted.setExpiresAt(entity.getExpiresAt()); granted.setVoucherFileName(entity.getVoucherFileName()); granted.setVoucherStoragePath(entity.getVoucherStoragePath()); granted.setVoucherFileSize(entity.getVoucherFileSize()); granted.setVoucherUploadedAt(entity.getVoucherUploadedAt()); granted.setCreatedBy(context.userId()); granted.setUpdatedBy(context.userId()); permissionMapper.insert(granted, now); } documentMapper.touchDocument(document.getId(), context.userId(), context.realName(), now); } /** 申请人可见性判定:本人直通;否则按文档授权位正向校验(与通知触达口径一致)。 */ private DocumentEntity requireVisibleDocument(BorrowRequestEntity entity, AuthContext context) { DocumentEntity document = documentMapper.findByIdNotDeleted(entity.getDocumentId()); if (document == null) { throw Errors.resourceNotFound("文档不存在"); } if (entity.getRequesterId() != context.userId()) { // 审批流不走文档密码锁:审批人在通知内查看/处理申请属于配置通道 authorizationService.requirePlanAction(document, context, "CONFIG_PERMISSION"); } return document; } private static NotificationEntity pendingNotification(BorrowRequestEntity entity, DocumentEntity document, long approverId, String approverName) { NotificationEntity notification = new NotificationEntity(); notification.setUserId(approverId); notification.setTitle("调阅审批申请"); notification.setContent(entity.getRequesterName() + " 申请调阅《" + document.getDocumentName() + "》,请审批"); notification.setNotifyType("APPROVAL"); notification.setAction("PENDING"); notification.setStatus("UNREAD"); notification.setBizId(entity.getId()); return notification; } /** 对齐 PermissionService.parseExpires:Z 结尾 UTC 解析 + 借阅时限校验(顶层字段)。 */ private static LocalDateTime parseExpires(Object value) { if (!(value instanceof String text) || !text.endsWith("Z")) { throw Errors.invalidArgument("expiresAt必须是UTC时间字符串(Z结尾)"); } OffsetDateTime parsed; try { parsed = OffsetDateTime.parse( text.substring(0, text.length() - 1) + "+00:00", DateTimeFormatter.ISO_OFFSET_DATE_TIME); } catch (DateTimeParseException ex) { throw Errors.invalidArgument("expiresAt不是有效的时间格式"); } LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); LocalDateTime expires = parsed.withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime(); if (!expires.isAfter(now)) { throw Errors.invalidArgument("expiresAt必须晚于当前时间"); } if (expires.isAfter(now.plus(MAX_BORROW).plus(BORROW_TOLERANCE))) { throw Errors.invalidArgument("expiresAt调阅时限最长为7天"); } return expires; } /** 条目解析:字段集精确匹配、字符串ID/布尔位校验、主体去重。 */ private static List parseEntries(Map payload) { if (payload == null || !payload.keySet().equals(REQUEST_FIELDS)) { throw Errors.invalidArgument("请求字段必须且只能包含entries和expiresAt"); } if (!(payload.get("entries") instanceof List rawEntries)) { throw Errors.invalidArgument("entries必须是数组"); } List entries = new ArrayList<>(); Set seen = new LinkedHashSet<>(); for (int index = 0; index < rawEntries.size(); index++) { Object rawEntry = rawEntries.get(index); if (!(rawEntry instanceof Map item) || !item.keySet().equals(ENTRY_FIELDS)) { throw Errors.invalidArgument( "entries[" + index + "]字段必须完整且不能包含未知字段"); } long subjectId = Payloads.stringId(item.get("subjectId"), "entries[" + index + "].subjectId", false); for (String field : ACTION_FIELDS) { if (!(item.get(field) instanceof Boolean)) { throw Errors.invalidArgument( "entries[" + index + "]." + field + "必须是boolean"); } } if (seen.contains(subjectId)) { Map details = Payloads.orderedMap(); details.put("subjectId", String.valueOf(subjectId)); throw new DmsException("DUPLICATE_PERMISSION_SUBJECT", 400, "权限主体不能重复", details); } seen.add(subjectId); entries.add(new EntryRequest(subjectId, (Boolean) item.get("canView"), (Boolean) item.get("canDownload"))); } return entries; } }