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