BorrowRequestService.java 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550
  1. package com.dms.service;
  2. import com.dms.common.DmsException;
  3. import com.dms.common.Errors;
  4. import com.dms.common.Payloads;
  5. import com.dms.mapper.BorrowRequestMapper;
  6. import com.dms.mapper.DocumentMapper;
  7. import com.dms.mapper.NotificationMapper;
  8. import com.dms.mapper.OrganizationMapper;
  9. import com.dms.mapper.PermissionMapper;
  10. import com.dms.mapper.UserMapper;
  11. import com.dms.model.BorrowRequestEntity;
  12. import com.dms.model.BorrowRequestItemEntity;
  13. import com.dms.model.DocumentEntity;
  14. import com.dms.model.NotificationEntity;
  15. import com.dms.model.OrganizationEntity;
  16. import com.dms.model.PermissionEntity;
  17. import com.dms.model.UserEntity;
  18. import com.dms.security.AuthContext;
  19. import com.dms.security.Reauth;
  20. import com.dms.storage.StoragePaths;
  21. import com.dms.storage.UploadStorage.StagedUpload;
  22. import jakarta.servlet.http.HttpServletRequest;
  23. import java.nio.file.Files;
  24. import java.nio.file.Path;
  25. import java.time.Duration;
  26. import java.time.LocalDateTime;
  27. import java.time.OffsetDateTime;
  28. import java.time.ZoneOffset;
  29. import java.time.format.DateTimeFormatter;
  30. import java.time.format.DateTimeParseException;
  31. import java.util.ArrayList;
  32. import java.util.LinkedHashMap;
  33. import java.util.LinkedHashSet;
  34. import java.util.List;
  35. import java.util.Map;
  36. import java.util.Set;
  37. import org.springframework.core.io.FileSystemResource;
  38. import org.springframework.core.io.Resource;
  39. import org.springframework.http.HttpHeaders;
  40. import org.springframework.http.ResponseEntity;
  41. import org.springframework.stereotype.Service;
  42. import org.springframework.transaction.annotation.Transactional;
  43. import org.springframework.web.multipart.MultipartFile;
  44. /**
  45. * 借阅申请审批流(V22迁移,契约1.37)。
  46. *
  47. * <p>借阅授权入口由手工配置改为申请-审批:无查看且无下载权限(密级足够)的用户
  48. * 发起借阅申请,一批用户共用一张审批单(必传 PDF/图片,契约1.72);申请以 PENDING 待办
  49. * 通知所有对该文档有授权权限(CONFIG_PERMISSION,矩阵反查+内置超管)的用户,
  50. * 任一审批人同意/拒绝即定(先到先得),其余待办自动失效;通过后逐条目落地
  51. * doc_permission(复用借阅 ACL 模型,到期/回收站语义自动继承),凭证以引用
  52. * 方式挂接各条目;申请人收到结果通知。</p>
  53. */
  54. @Service
  55. public class BorrowRequestService {
  56. private static final Set<String> REQUEST_FIELDS = Set.of("entries", "expiresAt");
  57. private static final Set<String> ENTRY_FIELDS = Set.of("subjectId", "canView", "canDownload");
  58. private static final String[] ACTION_FIELDS = {"canView", "canDownload"};
  59. /** 与借阅授权同口径的借阅时限:最长7天 + 60秒拾取舍容忍差。 */
  60. private static final Duration MAX_BORROW = Duration.ofDays(7);
  61. private static final Duration BORROW_TOLERANCE = Duration.ofSeconds(60);
  62. /** 对齐 Python isoformat(timespec="milliseconds") + "Z"。 */
  63. private static final DateTimeFormatter MILLIS_ISO_UTC =
  64. DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
  65. /** 申请条目(校验归一后的视图)。 */
  66. private record EntryRequest(long subjectId, boolean canView, boolean canDownload) {
  67. }
  68. private final DocumentMapper documentMapper;
  69. private final PermissionMapper permissionMapper;
  70. private final UserMapper userMapper;
  71. private final OrganizationMapper organizationMapper;
  72. private final BorrowRequestMapper borrowRequestMapper;
  73. private final NotificationMapper notificationMapper;
  74. private final AuthorizationService authorizationService;
  75. private final AuditService auditService;
  76. private final Reauth reauth;
  77. private final VoucherService voucherService;
  78. private final StoragePaths storagePaths;
  79. public BorrowRequestService(DocumentMapper documentMapper,
  80. PermissionMapper permissionMapper,
  81. UserMapper userMapper,
  82. OrganizationMapper organizationMapper,
  83. BorrowRequestMapper borrowRequestMapper,
  84. NotificationMapper notificationMapper,
  85. AuthorizationService authorizationService,
  86. AuditService auditService,
  87. Reauth reauth,
  88. VoucherService voucherService,
  89. StoragePaths storagePaths) {
  90. this.documentMapper = documentMapper;
  91. this.permissionMapper = permissionMapper;
  92. this.userMapper = userMapper;
  93. this.organizationMapper = organizationMapper;
  94. this.borrowRequestMapper = borrowRequestMapper;
  95. this.notificationMapper = notificationMapper;
  96. this.authorizationService = authorizationService;
  97. this.auditService = auditService;
  98. this.reauth = reauth;
  99. this.voucherService = voucherService;
  100. this.storagePaths = storagePaths;
  101. }
  102. // ------------------------------------------------------------ 提交申请
  103. /**
  104. * 提交借阅申请:一批用户共用一张审批单(必传 PDF/图片,契约1.72),落库后向所有
  105. * 有授权权限的用户发 PENDING 待办通知。文件提升放事务成功之后。
  106. */
  107. public Map<String, Object> createRequest(long documentId, Map<String, Object> payload,
  108. MultipartFile file,
  109. HttpServletRequest request) {
  110. // 契约1.66:调阅申请不再要求二次认证,改为强制上传审批单(无凭证不受理)
  111. if (file == null || file.isEmpty()) {
  112. throw Errors.invalidArgument("调阅申请必须上传审批单(PDF 或图片)");
  113. }
  114. List<EntryRequest> entries = parseEntries(payload);
  115. LocalDateTime expiresAt = parseExpires(payload.get("expiresAt"));
  116. // 非锁定预读仅取密级定凭证存储域;完整校验在 doCreate 事务内(审批单已必传)
  117. DocumentEntity preview = documentMapper.findByIdNotDeleted(documentId);
  118. StagedUpload staged = voucherService.stageVoucher(file,
  119. preview == null ? null : preview.getSecurityLevel());
  120. Map<String, Object> result;
  121. try {
  122. result = doCreate(documentId, entries, expiresAt, staged, request);
  123. if (staged != null) {
  124. staged.promote();
  125. }
  126. } catch (RuntimeException ex) {
  127. if (staged != null) {
  128. staged.cleanupAll();
  129. }
  130. throw ex;
  131. }
  132. return result;
  133. }
  134. @Transactional
  135. protected Map<String, Object> doCreate(long documentId, List<EntryRequest> entries,
  136. LocalDateTime expiresAt, StagedUpload staged,
  137. HttpServletRequest request) {
  138. AuthContext context = AuthContext.get();
  139. DocumentEntity document = documentMapper.findByIdNotDeleted(documentId);
  140. if (document == null) {
  141. throw Errors.resourceNotFound("文档不存在");
  142. }
  143. if ("ATTACHMENT".equals(document.getDocumentType())) {
  144. throw Errors.attachmentHasNoAcl();
  145. }
  146. if ("SUB_PLAN".equals(document.getDocumentType())) {
  147. throw Errors.subPlanPermissionInherited();
  148. }
  149. authorizationService.requireSecurityClearance(document, context);
  150. // 申请门槛:仅对文档既无查看又无下载权限的用户可发起(前端按钮同口径)
  151. List<String> allowed = authorizationService.planAllowedActions(document, context,
  152. authorizationService.evaluatePlanAccess(document, context));
  153. if (allowed.contains("VIEW") || allowed.contains("DOWNLOAD")) {
  154. throw Errors.invalidArgument("你已拥有该文档的查看或下载权限,无需调阅");
  155. }
  156. Map<Long, UserEntity> subjects = lockedSubjects(entries);
  157. for (Map.Entry<Long, UserEntity> row : subjects.entrySet()) {
  158. UserEntity user = row.getValue();
  159. if (!authorizationService.clearanceSatisfied(user.getSecurityLevel(),
  160. document.getSecurityLevel())) {
  161. Map<String, Object> details = Payloads.orderedMap();
  162. details.put("subjectId", String.valueOf(row.getKey()));
  163. details.put("subjectName", user.getRealName());
  164. throw Errors.securityLevelForbidden();
  165. }
  166. if (borrowRequestMapper.countPendingBySubject(document.getId(), row.getKey()) > 0) {
  167. throw new DmsException("BORROW_REQUEST_DUPLICATE", 409,
  168. "该用户对该文档已有待审批的调阅申请", Payloads.orderedMap());
  169. }
  170. }
  171. LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
  172. BorrowRequestEntity entity = new BorrowRequestEntity();
  173. entity.setDocumentId(document.getId());
  174. entity.setRequesterId(context.userId());
  175. entity.setRequesterName(context.realName());
  176. entity.setExpiresAt(expiresAt);
  177. if (staged != null) {
  178. entity.setVoucherFileName(staged.originalFileName());
  179. entity.setVoucherStoragePath(staged.relativePath());
  180. entity.setVoucherFileSize(staged.fileSize());
  181. entity.setVoucherUploadedAt(now);
  182. }
  183. entity.setStatus("PENDING");
  184. borrowRequestMapper.insertRequest(entity);
  185. List<Map<String, Object>> auditEntries = new ArrayList<>();
  186. for (EntryRequest entry : entries) {
  187. BorrowRequestItemEntity item = new BorrowRequestItemEntity();
  188. item.setRequestId(entity.getId());
  189. item.setSubjectId(entry.subjectId());
  190. item.setSubjectName(subjects.get(entry.subjectId()).getRealName());
  191. item.setCanView(entry.canView());
  192. item.setCanDownload(entry.canDownload());
  193. borrowRequestMapper.insertItem(item);
  194. Map<String, Object> auditItem = Payloads.orderedMap();
  195. auditItem.put("subjectId", String.valueOf(entry.subjectId()));
  196. auditItem.put("subjectName", subjects.get(entry.subjectId()).getRealName());
  197. auditItem.put("canView", entry.canView());
  198. auditItem.put("canDownload", entry.canDownload());
  199. auditEntries.add(auditItem);
  200. }
  201. Map<Long, String> approvers = resolveApprovers(document, context.userId());
  202. for (Map.Entry<Long, String> approver : approvers.entrySet()) {
  203. notificationMapper.insert(pendingNotification(entity, document,
  204. approver.getKey(), approver.getValue()));
  205. }
  206. Map<String, Object> auditDetail = Payloads.orderedMap();
  207. auditDetail.put("documentId", String.valueOf(document.getId()));
  208. auditDetail.put("requesterId", String.valueOf(context.userId()));
  209. auditDetail.put("requesterName", context.realName());
  210. auditDetail.put("entries", auditEntries);
  211. auditDetail.put("expiresAt", PermissionService.iso(expiresAt));
  212. auditDetail.put("voucherFileName", entity.getVoucherFileName());
  213. auditDetail.put("notified", approvers.size());
  214. auditService.insert(auditService.businessAudit(request,
  215. AuditService.Action.BORROW_REQUEST_CREATE, "BORROW_REQUEST",
  216. entity.getId(), document.getDocumentName(),
  217. auditService.toJson(auditDetail)));
  218. Map<String, Object> result = Payloads.orderedMap();
  219. result.put("requestId", String.valueOf(entity.getId()));
  220. result.put("notified", approvers.size());
  221. return result;
  222. }
  223. // ------------------------------------------------------------ 查询
  224. /** 申请详情:申请人本人或对该文档有授权权限的用户可见(审批人查看审批单)。 */
  225. @Transactional(readOnly = true)
  226. public Map<String, Object> getRequestDetail(long requestId, HttpServletRequest request) {
  227. AuthContext context = AuthContext.get();
  228. BorrowRequestEntity entity = borrowRequestMapper.findById(requestId);
  229. if (entity == null) {
  230. throw Errors.resourceNotFound("调阅申请不存在");
  231. }
  232. DocumentEntity document = requireVisibleDocument(entity, context);
  233. List<Map<String, Object>> items = new ArrayList<>();
  234. for (BorrowRequestItemEntity item : borrowRequestMapper.findItems(entity.getId())) {
  235. Map<String, Object> values = Payloads.orderedMap();
  236. values.put("subjectId", String.valueOf(item.getSubjectId()));
  237. values.put("subjectName", item.getSubjectName());
  238. values.put("canView", Boolean.TRUE.equals(item.getCanView()));
  239. values.put("canDownload", Boolean.TRUE.equals(item.getCanDownload()));
  240. items.add(values);
  241. }
  242. Map<String, Object> result = Payloads.orderedMap();
  243. result.put("requestId", String.valueOf(entity.getId()));
  244. result.put("documentId", String.valueOf(document.getId()));
  245. result.put("documentName", document.getDocumentName());
  246. result.put("requesterId", String.valueOf(entity.getRequesterId()));
  247. result.put("requesterName", entity.getRequesterName());
  248. result.put("entries", items);
  249. result.put("expiresAt", PermissionService.iso(entity.getExpiresAt()));
  250. result.put("voucherFileName", entity.getVoucherFileName());
  251. result.put("voucherFileSize", entity.getVoucherFileSize());
  252. result.put("status", entity.getStatus());
  253. result.put("handledById", entity.getHandledBy() == null
  254. ? null : String.valueOf(entity.getHandledBy()));
  255. result.put("handledByName", entity.getHandledByName());
  256. result.put("handledAt", entity.getHandledAt() == null
  257. ? null : PermissionService.iso(entity.getHandledAt()));
  258. result.put("createdAt", PermissionService.iso(entity.getCreatedAt()));
  259. return result;
  260. }
  261. /** 申请审批单下载(attachment 直出,PDF/图片按扩展名映射类型,权限同详情)。 */
  262. @Transactional(readOnly = true)
  263. public ResponseEntity<Resource> downloadRequestVoucher(long requestId,
  264. HttpServletRequest request) {
  265. AuthContext context = AuthContext.get();
  266. BorrowRequestEntity entity = borrowRequestMapper.findById(requestId);
  267. if (entity == null) {
  268. throw Errors.resourceNotFound("调阅申请不存在");
  269. }
  270. requireVisibleDocument(entity, context);
  271. if (entity.getVoucherStoragePath() == null
  272. || entity.getVoucherStoragePath().isEmpty()) {
  273. throw Errors.resourceNotFound("该申请未上传审批单");
  274. }
  275. Path path = storagePaths.resolveStoragePath(entity.getVoucherStoragePath());
  276. if (!Files.isRegularFile(path)) {
  277. throw Errors.resourceNotFound("审批单文件不存在或已被清理");
  278. }
  279. String name = entity.getVoucherFileName() != null
  280. ? entity.getVoucherFileName() : "voucher.pdf";
  281. return ResponseEntity.ok()
  282. .contentType(VoucherService.voucherMediaType(name))
  283. .header(HttpHeaders.CONTENT_DISPOSITION,
  284. FileReadService.attachmentDisposition(name))
  285. .header("X-Content-Type-Options", "nosniff")
  286. .body(new FileSystemResource(path));
  287. }
  288. // ------------------------------------------------------------ 审批联动(通知 handle 触发)
  289. /**
  290. * 任一审批人处理即定(先到先得):PENDING 行锁守卫 + 审批人实时鉴权
  291. * (防止通知发出后角色/密级变化导致越权);通过则逐条目替换式落地借阅
  292. * ACL 并以引用挂接审批单;其余审批人待办同步失效;申请人收结果通知。
  293. */
  294. @Transactional
  295. public void handleApproval(long requestId, String action, long notificationId,
  296. HttpServletRequest request) {
  297. reauth.require(request);
  298. AuthContext context = AuthContext.get();
  299. BorrowRequestEntity entity = borrowRequestMapper.findForUpdate(requestId);
  300. if (entity == null) {
  301. throw Errors.resourceNotFound("调阅申请不存在");
  302. }
  303. if (!"PENDING".equals(entity.getStatus())) {
  304. throw Errors.dataVersionConflict("该调阅申请已被处理");
  305. }
  306. DocumentEntity document = documentMapper.findByIdNotDeleted(entity.getDocumentId());
  307. if (document == null) {
  308. throw Errors.resourceNotFound("文档不存在");
  309. }
  310. // 审批人运行时鉴权:与详情/授权配置同口径(密级前置 + CONFIG_PERMISSION 位)
  311. authorizationService.requirePlanAction(document, context, "CONFIG_PERMISSION");
  312. LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
  313. boolean approved = "AGREE".equals(action);
  314. String finalStatus = approved ? "APPROVED" : "REJECTED";
  315. int updated = borrowRequestMapper.updateHandled(requestId, finalStatus,
  316. context.userId(), context.realName(), now);
  317. if (updated == 0) {
  318. throw Errors.dataVersionConflict("该调阅申请已被处理");
  319. }
  320. List<BorrowRequestItemEntity> items =
  321. borrowRequestMapper.findItems(requestId);
  322. if (approved) {
  323. grantPermissions(document, entity, items, context, now);
  324. }
  325. notificationMapper.settlePendingByBiz(requestId, "APPROVAL", notificationId, action, now);
  326. NotificationEntity result = new NotificationEntity();
  327. result.setUserId(entity.getRequesterId());
  328. result.setTitle(approved ? "调阅申请已通过" : "调阅申请已拒绝");
  329. result.setContent("你申请调阅的《" + document.getDocumentName() + "》已被 "
  330. + context.realName() + (approved ? " 通过" : " 拒绝"));
  331. result.setNotifyType("APPROVAL");
  332. result.setAction("NONE");
  333. result.setStatus("UNREAD");
  334. result.setBizId(requestId);
  335. notificationMapper.insert(result);
  336. Map<String, Object> auditDetail = Payloads.orderedMap();
  337. auditDetail.put("requestId", String.valueOf(requestId));
  338. auditDetail.put("documentId", String.valueOf(document.getId()));
  339. auditDetail.put("requesterId", String.valueOf(entity.getRequesterId()));
  340. auditDetail.put("granted", approved ? items.size() : 0);
  341. auditService.insert(auditService.businessAudit(request,
  342. approved ? AuditService.Action.BORROW_REQUEST_APPROVE
  343. : AuditService.Action.BORROW_REQUEST_REJECT,
  344. "BORROW_REQUEST", requestId, document.getDocumentName(),
  345. auditService.toJson(auditDetail)));
  346. }
  347. // ------------------------------------------------------------ 内部实现
  348. /** 锁读申请用户并校验存在/启用,返回主体ID→用户(对齐 PermissionService.lockedSubjectNames,无实例状态)。 */
  349. private Map<Long, UserEntity> lockedSubjects(List<EntryRequest> entries) {
  350. Set<Long> userIds = new LinkedHashSet<>();
  351. for (EntryRequest entry : entries) {
  352. userIds.add(entry.subjectId());
  353. }
  354. if (userIds.isEmpty()) {
  355. throw Errors.invalidArgument("entries不能为空");
  356. }
  357. Map<Long, UserEntity> users = new LinkedHashMap<>();
  358. for (UserEntity user : userMapper.listActiveByIdsForUpdate(userIds)) {
  359. if ("ENABLED".equals(user.getStatus())) {
  360. users.put(user.getId(), user);
  361. }
  362. }
  363. if (!users.keySet().equals(userIds)) {
  364. throw Errors.resourceNotFound("调阅用户不存在、已删除或已禁用");
  365. }
  366. return users;
  367. }
  368. /** 审批人反查:矩阵 can_config_permission=1(密级×OWN/OTHER 口径对齐)+ 内置超管。 */
  369. private Map<Long, String> resolveApprovers(DocumentEntity document, long requesterId) {
  370. String docOrgPath = null;
  371. if (document.getOrganizationId() != null) {
  372. OrganizationEntity docOrg =
  373. organizationMapper.findByIdNotDeleted(document.getOrganizationId());
  374. docOrgPath = docOrg == null ? null : docOrg.getOrgPath();
  375. }
  376. Map<Long, String> approvers = new LinkedHashMap<>();
  377. for (Map<String, Object> row
  378. : borrowRequestMapper.listConfigApprovers(document.getSecurityLevel(),
  379. docOrgPath, requesterId)) {
  380. approvers.put(((Number) row.get("id")).longValue(),
  381. (String) row.get("real_name"));
  382. }
  383. UserEntity admin = userMapper.findActiveByUsername(
  384. AdminPermission.SUPER_ADMIN_USERNAME);
  385. if (admin != null && "ENABLED".equals(admin.getStatus())
  386. && admin.getId() != requesterId) {
  387. approvers.put(admin.getId(), admin.getRealName());
  388. }
  389. if (approvers.isEmpty()) {
  390. throw Errors.invalidArgument("暂无可审批该申请的用户,请联系管理员");
  391. }
  392. return approvers;
  393. }
  394. /** 审批通过:软删同人旧活跃 ACL 后按申请条目替换式插入,凭证引用挂接各条目。 */
  395. private void grantPermissions(DocumentEntity document, BorrowRequestEntity entity,
  396. List<BorrowRequestItemEntity> items,
  397. AuthContext context, LocalDateTime now) {
  398. Map<Long, PermissionEntity> activeBySubject = new LinkedHashMap<>();
  399. for (PermissionEntity permission
  400. : permissionMapper.listActiveByDocumentForUpdate(document.getId())) {
  401. activeBySubject.put(permission.getSubjectId(), permission);
  402. }
  403. for (BorrowRequestItemEntity item : items) {
  404. PermissionEntity existing = activeBySubject.get(item.getSubjectId());
  405. if (existing != null) {
  406. permissionMapper.softDeleteById(existing.getId(), now, context.userId());
  407. }
  408. PermissionEntity granted = new PermissionEntity();
  409. granted.setDocumentId(document.getId());
  410. granted.setSubjectId(item.getSubjectId());
  411. granted.setSubjectName(item.getSubjectName());
  412. granted.setCanView(Boolean.TRUE.equals(item.getCanView()));
  413. granted.setCanDownload(Boolean.TRUE.equals(item.getCanDownload()));
  414. granted.setExpiresAt(entity.getExpiresAt());
  415. granted.setVoucherFileName(entity.getVoucherFileName());
  416. granted.setVoucherStoragePath(entity.getVoucherStoragePath());
  417. granted.setVoucherFileSize(entity.getVoucherFileSize());
  418. granted.setVoucherUploadedAt(entity.getVoucherUploadedAt());
  419. granted.setCreatedBy(context.userId());
  420. granted.setUpdatedBy(context.userId());
  421. permissionMapper.insert(granted, now);
  422. }
  423. documentMapper.touchDocument(document.getId(), context.userId(),
  424. context.realName(), now);
  425. }
  426. /** 申请人可见性判定:本人直通;否则按文档授权位正向校验(与通知触达口径一致)。 */
  427. private DocumentEntity requireVisibleDocument(BorrowRequestEntity entity,
  428. AuthContext context) {
  429. DocumentEntity document = documentMapper.findByIdNotDeleted(entity.getDocumentId());
  430. if (document == null) {
  431. throw Errors.resourceNotFound("文档不存在");
  432. }
  433. if (entity.getRequesterId() != context.userId()) {
  434. // 审批流不走文档密码锁:审批人在通知内查看/处理申请属于配置通道
  435. authorizationService.requirePlanAction(document, context, "CONFIG_PERMISSION");
  436. }
  437. return document;
  438. }
  439. private static NotificationEntity pendingNotification(BorrowRequestEntity entity,
  440. DocumentEntity document,
  441. long approverId,
  442. String approverName) {
  443. NotificationEntity notification = new NotificationEntity();
  444. notification.setUserId(approverId);
  445. notification.setTitle("调阅审批申请");
  446. notification.setContent(entity.getRequesterName() + " 申请调阅《"
  447. + document.getDocumentName() + "》,请审批");
  448. notification.setNotifyType("APPROVAL");
  449. notification.setAction("PENDING");
  450. notification.setStatus("UNREAD");
  451. notification.setBizId(entity.getId());
  452. return notification;
  453. }
  454. /** 对齐 PermissionService.parseExpires:Z 结尾 UTC 解析 + 借阅时限校验(顶层字段)。 */
  455. private static LocalDateTime parseExpires(Object value) {
  456. if (!(value instanceof String text) || !text.endsWith("Z")) {
  457. throw Errors.invalidArgument("expiresAt必须是UTC时间字符串(Z结尾)");
  458. }
  459. OffsetDateTime parsed;
  460. try {
  461. parsed = OffsetDateTime.parse(
  462. text.substring(0, text.length() - 1) + "+00:00",
  463. DateTimeFormatter.ISO_OFFSET_DATE_TIME);
  464. } catch (DateTimeParseException ex) {
  465. throw Errors.invalidArgument("expiresAt不是有效的时间格式");
  466. }
  467. LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
  468. LocalDateTime expires = parsed.withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime();
  469. if (!expires.isAfter(now)) {
  470. throw Errors.invalidArgument("expiresAt必须晚于当前时间");
  471. }
  472. if (expires.isAfter(now.plus(MAX_BORROW).plus(BORROW_TOLERANCE))) {
  473. throw Errors.invalidArgument("expiresAt调阅时限最长为7天");
  474. }
  475. return expires;
  476. }
  477. /** 条目解析:字段集精确匹配、字符串ID/布尔位校验、主体去重。 */
  478. private static List<EntryRequest> parseEntries(Map<String, Object> payload) {
  479. if (payload == null || !payload.keySet().equals(REQUEST_FIELDS)) {
  480. throw Errors.invalidArgument("请求字段必须且只能包含entries和expiresAt");
  481. }
  482. if (!(payload.get("entries") instanceof List<?> rawEntries)) {
  483. throw Errors.invalidArgument("entries必须是数组");
  484. }
  485. List<EntryRequest> entries = new ArrayList<>();
  486. Set<Long> seen = new LinkedHashSet<>();
  487. for (int index = 0; index < rawEntries.size(); index++) {
  488. Object rawEntry = rawEntries.get(index);
  489. if (!(rawEntry instanceof Map<?, ?> item)
  490. || !item.keySet().equals(ENTRY_FIELDS)) {
  491. throw Errors.invalidArgument(
  492. "entries[" + index + "]字段必须完整且不能包含未知字段");
  493. }
  494. long subjectId = Payloads.stringId(item.get("subjectId"),
  495. "entries[" + index + "].subjectId", false);
  496. for (String field : ACTION_FIELDS) {
  497. if (!(item.get(field) instanceof Boolean)) {
  498. throw Errors.invalidArgument(
  499. "entries[" + index + "]." + field + "必须是boolean");
  500. }
  501. }
  502. if (seen.contains(subjectId)) {
  503. Map<String, Object> details = Payloads.orderedMap();
  504. details.put("subjectId", String.valueOf(subjectId));
  505. throw new DmsException("DUPLICATE_PERMISSION_SUBJECT", 400,
  506. "权限主体不能重复", details);
  507. }
  508. seen.add(subjectId);
  509. entries.add(new EntryRequest(subjectId,
  510. (Boolean) item.get("canView"), (Boolean) item.get("canDownload")));
  511. }
  512. return entries;
  513. }
  514. }