AttachmentQueryService.java 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. package com.dms.service;
  2. import com.dms.common.DmsException;
  3. import com.dms.common.Page;
  4. import com.dms.common.Payloads;
  5. import com.dms.common.TimeRanges;
  6. import com.dms.mapper.AttachmentBindingMapper;
  7. import com.dms.mapper.DocumentMapper;
  8. import com.dms.model.AttachmentBindingEntity;
  9. import com.dms.model.DocumentEntity;
  10. import com.dms.security.AuthContext;
  11. import com.dms.service.AuthorizationService.AttachmentAccess;
  12. import com.dms.service.AuthorizationService.PlanAccess;
  13. import jakarta.servlet.http.HttpServletRequest;
  14. import java.util.ArrayList;
  15. import java.util.HashMap;
  16. import java.util.List;
  17. import java.util.Locale;
  18. import java.util.Map;
  19. import java.util.Set;
  20. import org.springframework.stereotype.Service;
  21. /**
  22. * 共享附件及其有效挂载关系只读查询,
  23. * 对齐 backend/dms/services/attachment_query_service.py。
  24. *
  25. * <p>附件无 ACL(固定 PUBLIC / ALL_AUTHENTICATED / PUBLISHED),
  26. * 普通用户仅能通过至少一个有权有效主案访问附件;
  27. * 主案挂载列表全量可见(allowedActions 为空集、正文片段抑制)。</p>
  28. */
  29. @Service
  30. public class AttachmentQueryService {
  31. /** 对齐 AttachmentType 枚举全集(阶段11新增 ACCOMPANYING_PLAN 配套方案)。 */
  32. public static final Set<String> ATTACHMENT_TYPES = Set.of(
  33. "SUPPORT_ACHIEVEMENT", "TRAINING_MATERIAL", "TYPICAL_CASE",
  34. "ACCOMPANYING_PLAN");
  35. private final AttachmentBindingMapper attachmentBindingMapper;
  36. private final DocumentMapper documentMapper;
  37. private final AuthorizationService authorizationService;
  38. private final DocumentQueryService documentQueryService;
  39. public AttachmentQueryService(AttachmentBindingMapper attachmentBindingMapper,
  40. DocumentMapper documentMapper,
  41. AuthorizationService authorizationService,
  42. DocumentQueryService documentQueryService) {
  43. this.attachmentBindingMapper = attachmentBindingMapper;
  44. this.documentMapper = documentMapper;
  45. this.authorizationService = authorizationService;
  46. this.documentQueryService = documentQueryService;
  47. }
  48. // ------------------------------------------------------------ 序列化
  49. /** 对齐 mounted_plan_count。 */
  50. public long mountedPlanCount(long attachmentId) {
  51. return attachmentBindingMapper.countActiveByAttachment(attachmentId);
  52. }
  53. /** 对齐 attachment_summary。 */
  54. public Map<String, Object> attachmentSummary(DocumentEntity attachment, AuthContext context,
  55. PlanAccess planAccess, String keyword) {
  56. Map<String, Object> result = Payloads.orderedMap();
  57. result.put("id", String.valueOf(attachment.getId()));
  58. result.put("documentName", attachment.getDocumentName());
  59. result.put("summary", attachment.getSummary());
  60. result.put("description", attachment.getDescription());
  61. result.put("documentType", attachment.getDocumentType());
  62. result.put("status", attachment.getDocumentStatus());
  63. result.put("securityLevel", attachment.getSecurityLevel());
  64. // 附件不可授权(AttachmentHasNoAcl),借阅摘要固定占位
  65. result.put("borrowSummary", "未配置");
  66. result.put("attachmentType", attachment.getAttachmentType());
  67. result.put("categoryId", null);
  68. result.put("categoryName", null);
  69. result.put("categoryPath", null);
  70. result.put("organizationId", attachment.getOrganizationId() == null
  71. ? null : String.valueOf(attachment.getOrganizationId()));
  72. result.put("organizationName", attachment.getOrganizationName());
  73. result.put("parentDocumentId", null);
  74. result.put("rootDocumentId", null);
  75. result.put("tags", documentQueryService.tagsOf(attachment.getTags()));
  76. result.put("fileExtension", attachment.getFileExtension());
  77. result.put("childCount", attachment.getChildCount());
  78. result.put("attachmentCount", attachment.getAttachmentCount());
  79. result.put("viewCount", attachment.getViewCount());
  80. result.put("downloadCount", attachment.getDownloadCount());
  81. result.put("createdByName", attachment.getCreatedByName());
  82. result.put("createdAt", DocumentQueryService.iso(attachment.getCreatedAt()));
  83. result.put("updatedAt", DocumentQueryService.iso(attachment.getUpdatedAt()));
  84. result.put("rowVersion", attachment.getRowVersion());
  85. result.put("allowedActions",
  86. authorizationService.attachmentAllowedActions(context, attachment, planAccess));
  87. result.put("mountedPlanCount", mountedPlanCount(attachment.getId()));
  88. String snippet = DocumentQueryService.contentSnippet(attachment.getContentText(), keyword);
  89. if (snippet != null) {
  90. result.put("contentSnippet", snippet);
  91. }
  92. return result;
  93. }
  94. /** 对齐 attachment_detail:summary 之上补充文件字段与固定权限摘要。 */
  95. public Map<String, Object> attachmentDetail(DocumentEntity attachment, AuthContext context) {
  96. Map<String, Object> result = attachmentSummary(attachment, context, null, null);
  97. result.put("originalFileName", attachment.getOriginalFileName());
  98. result.put("mimeType", attachment.getMimeType());
  99. result.put("fileSize", attachment.getFileSize());
  100. result.put("fileHash", attachment.getFileHash());
  101. Map<String, Object> permissionSummary = Payloads.orderedMap();
  102. permissionSummary.put("borrowerCount", 0);
  103. permissionSummary.put("inheritedFromMainPlan", false);
  104. result.put("permissionSummary", permissionSummary);
  105. result.put("contentText", attachment.getContentText());
  106. // 提取状态/失败原因仅供管理员排障("编辑"界面提示与重新解析)
  107. if ("ADMIN".equals(context.roleCode())) {
  108. result.put("contentExtractStatus", attachment.getContentExtractStatus());
  109. result.put("contentExtractError", attachment.getContentExtractError());
  110. result.put("contentExtractedAt", attachment.getContentExtractedAt() == null
  111. ? null : DocumentQueryService.iso(attachment.getContentExtractedAt()));
  112. }
  113. return result;
  114. }
  115. // ------------------------------------------------------------ 查询入口
  116. /**
  117. * 对齐 list_attachments:列表全量可见(全局检索需要)——
  118. * 普通用户同样返回全部附件标题,但 allowedActions 为空集,
  119. * 且不返回详细说明与正文片段;内容访问由详情/预览/下载接口
  120. * 按挂载主案权限独立拦截。
  121. */
  122. public Map<String, Object> listAttachments(Map<String, String> params) {
  123. AuthContext context = AuthContext.get();
  124. boolean isAdmin = "ADMIN".equals(context.roleCode());
  125. Page.Params page = Page.of(
  126. DocumentQueryService.positiveIntParam(params, "page", 1),
  127. DocumentQueryService.positiveIntParam(params, "pageSize", 20));
  128. Map<String, Object> filters = Payloads.orderedMap();
  129. filters.put("types", List.of("ATTACHMENT"));
  130. // 高级检索·数据内容/提供单位(契约1.55):附件列表与方案列表同口径
  131. // (keywordScope 缺省 TITLE,与原 keyword→titlePattern 行为一致)
  132. String keyword = params.get("keyword");
  133. DocumentQueryService.applyKeywordScope(filters, keyword,
  134. params.get("keywordScope"), false);
  135. DocumentQueryService.applyAdvancedFilters(filters, params);
  136. documentQueryService.applyOrganizationFilter(filters, params.get("organizationId"));
  137. // 高级检索·数据密级(契约1.55):单选过滤(securityLevels 多选保留兼容),
  138. // 修复漏接线导致的密级筛选不生效
  139. String securityLevel = DocumentQueryService.enumParam(
  140. params.get("securityLevel"), DocumentQueryService.SECURITY_LEVELS,
  141. "securityLevel");
  142. if (securityLevel != null) {
  143. filters.put("securityLevel", securityLevel);
  144. }
  145. String attachmentType = DocumentQueryService.enumParam(
  146. params.get("attachmentType"), ATTACHMENT_TYPES, "attachmentType");
  147. if (attachmentType != null) {
  148. filters.put("attachmentType", attachmentType);
  149. }
  150. String extension = params.get("fileExtension");
  151. if (extension != null && !extension.isEmpty()) {
  152. // 对齐 Python:truthy 即过滤(含纯空白),归一 strip/lower/lstrip('.')
  153. String normalized = extension.strip().toLowerCase(Locale.ROOT);
  154. while (normalized.startsWith(".")) {
  155. normalized = normalized.substring(1);
  156. }
  157. filters.put("fileExtension", normalized);
  158. }
  159. TimeRanges.UpdatedRange range = TimeRanges.parseUpdatedRange(params);
  160. if (range.from() != null) {
  161. filters.put("updatedFrom", range.from());
  162. }
  163. if (range.to() != null) {
  164. filters.put("updatedTo", range.to());
  165. }
  166. filters.put("orderBy", DocumentQueryService.orderByParam(params));
  167. List<DocumentEntity> attachments = documentMapper.listOverviewByFilters(filters);
  168. long total = attachments.size();
  169. int fromIndex = Math.min(page.offset(), attachments.size());
  170. int toIndex = Math.min(page.offset() + page.pageSize(), attachments.size());
  171. List<DocumentEntity> selected = attachments.subList(fromIndex, toIndex);
  172. // 列表主体查询不携带 content_text 大字段;仅管理员 contentKeyword
  173. // 检索生成当前页摘要时批量补查。契约1.55:数据内容为 CONTENT/ALL
  174. // 时摘要关键词回落到检索关键词(正文命中需展示片段)。
  175. String snippetKeyword = params.get("contentKeyword");
  176. if ((snippetKeyword == null || snippetKeyword.strip().isEmpty())
  177. && DocumentQueryService.keywordScopeContentLike(params)
  178. && keyword != null && !keyword.strip().isEmpty()) {
  179. snippetKeyword = keyword.strip();
  180. }
  181. if (isAdmin
  182. && snippetKeyword != null && !snippetKeyword.strip().isEmpty()) {
  183. documentQueryService.fillContentText(selected);
  184. }
  185. List<Map<String, Object>> items = new ArrayList<>();
  186. for (DocumentEntity item : selected) {
  187. Map<String, Object> summary = attachmentSummary(item, context, null,
  188. isAdmin ? snippetKeyword : null);
  189. if (!isAdmin) {
  190. summary.put("description", null);
  191. }
  192. items.add(summary);
  193. }
  194. return Page.result(items, page, total);
  195. }
  196. /** 对齐 get_attachment:访问前置 + 详情 + 独立事务查看计数。 */
  197. public Map<String, Object> getAttachment(long attachmentId, HttpServletRequest request) {
  198. AuthContext context = AuthContext.get();
  199. DocumentEntity attachment = activeAttachment(attachmentId);
  200. AttachmentAccess access;
  201. try {
  202. access = authorizationService.requireAttachmentAccess(attachment, context, false);
  203. } catch (DmsException e) {
  204. // 操作位隔离补全:查看位不足但具备附件操作位(编辑/删除等)时放行
  205. // "编辑视图"——详情仅元数据(查看计数与审计不记录);密级不足维持拒绝
  206. if (!"DOCUMENT_VIEW_FORBIDDEN".equals(e.code())
  207. || authorizationService.attachmentAllowedActions(context, attachment, null)
  208. .isEmpty()) {
  209. throw e;
  210. }
  211. access = authorizationService.evaluateAttachmentAccess(attachment, context);
  212. }
  213. Map<String, Object> result = attachmentDetail(attachment, context);
  214. if ("USER".equals(context.roleCode())) {
  215. List<String> actions = new ArrayList<>();
  216. // 编辑视图(无查看权)不下发 VIEW 位
  217. if (access.canView()) {
  218. actions.add("VIEW");
  219. }
  220. if (access.canDownload()) {
  221. actions.add("DOWNLOAD");
  222. }
  223. // 契约1.41:矩阵附件编辑位独立下发(详情访问已含挂载聚合查看)
  224. if (authorizationService.attachmentEditAllowed(attachment, context)) {
  225. actions.add("EDIT");
  226. }
  227. result.put("allowedActions", actions);
  228. }
  229. if (access.canView()) {
  230. result.put("viewCount", documentQueryService.recordView(attachment, request));
  231. } else {
  232. // 编辑视图:回显库中当前计数(查询未携带时按 0),不增计不审计
  233. result.put("viewCount",
  234. attachment.getViewCount() == null ? 0L : attachment.getViewCount());
  235. }
  236. return result;
  237. }
  238. /** 对齐 list_attachment_main_plans:附件可达且有权主案清单。 */
  239. public Map<String, Object> listAttachmentMainPlans(long attachmentId) {
  240. AuthContext context = AuthContext.get();
  241. activeAttachment(attachmentId);
  242. AccessMemo memo = new AccessMemo();
  243. List<Map<String, Object>> items = new ArrayList<>();
  244. for (DocumentEntity main : attachmentBindingMapper
  245. .listActiveMainsByAttachmentOrdered(attachmentId)) {
  246. if (!authorizationService.evaluatePlanAccess(main, context, memo).allowed()) {
  247. continue;
  248. }
  249. Map<String, Object> item = Payloads.orderedMap();
  250. item.put("id", String.valueOf(main.getId()));
  251. item.put("documentName", main.getDocumentName());
  252. item.put("categoryName", main.getCategoryName());
  253. item.put("securityLevel", main.getSecurityLevel());
  254. items.add(item);
  255. }
  256. Map<String, Object> result = Payloads.orderedMap();
  257. result.put("items", items);
  258. result.put("total", items.size());
  259. return result;
  260. }
  261. /**
  262. * 对齐 list_main_plan_attachments:主案挂载列表全量可见
  263. * (主案无权限时 allowedActions 为空集、正文片段同步抑制)。
  264. */
  265. public Map<String, Object> listMainPlanAttachments(long mainDocumentId,
  266. Map<String, String> params) {
  267. AuthContext context = AuthContext.get();
  268. DocumentEntity main = documentMapper.findActiveMainById(mainDocumentId);
  269. if (main == null) {
  270. throw new DmsException("MAIN_PLAN_NOT_FOUND", 404, "主案不存在", null);
  271. }
  272. PlanAccess mainAccess = authorizationService.evaluatePlanAccess(main, context);
  273. Page.Params page = Page.of(
  274. DocumentQueryService.positiveIntParam(params, "page", 1),
  275. DocumentQueryService.positiveIntParam(params, "pageSize", 20));
  276. List<AttachmentBindingEntity> bindings =
  277. attachmentBindingMapper.listActiveByMain(main.getId());
  278. if (bindings.isEmpty()) {
  279. return Page.result(List.of(), page, 0);
  280. }
  281. List<Long> attachmentIds = new ArrayList<>();
  282. for (AttachmentBindingEntity binding : bindings) {
  283. attachmentIds.add(binding.getAttachmentDocumentId());
  284. }
  285. Map<String, Object> filters = Payloads.orderedMap();
  286. filters.put("types", List.of("ATTACHMENT"));
  287. filters.put("ids", attachmentIds);
  288. String keyword = params.get("keyword");
  289. if (keyword != null && !keyword.strip().isEmpty()) {
  290. filters.put("titlePattern",
  291. "%" + DocumentQueryService.escapeLike(keyword) + "%");
  292. }
  293. DocumentQueryService.applyAdvancedFilters(filters, params);
  294. String attachmentType = DocumentQueryService.enumParam(
  295. params.get("attachmentType"), ATTACHMENT_TYPES, "attachmentType");
  296. if (attachmentType != null) {
  297. filters.put("attachmentType", attachmentType);
  298. }
  299. TimeRanges.UpdatedRange range = TimeRanges.parseUpdatedRange(params);
  300. if (range.from() != null) {
  301. filters.put("updatedFrom", range.from());
  302. }
  303. if (range.to() != null) {
  304. filters.put("updatedTo", range.to());
  305. }
  306. Map<Long, DocumentEntity> kept = new HashMap<>();
  307. for (DocumentEntity document : documentMapper.listOverviewByFilters(filters)) {
  308. kept.put(document.getId(), document);
  309. }
  310. // 列表主体查询不携带 content_text 大字段;仅主案有权限且
  311. // contentKeyword 检索生成摘要时,对涉及的挂载附件批量补查。
  312. String contentKeyword = params.get("contentKeyword");
  313. if (mainAccess.allowed() && contentKeyword != null
  314. && !contentKeyword.strip().isEmpty()) {
  315. List<DocumentEntity> pageAttachments = new ArrayList<>();
  316. for (AttachmentBindingEntity binding : bindings) {
  317. DocumentEntity attachment = kept.get(binding.getAttachmentDocumentId());
  318. if (attachment != null) {
  319. pageAttachments.add(attachment);
  320. }
  321. }
  322. documentQueryService.fillContentText(pageAttachments);
  323. }
  324. List<Map<String, Object>> items = new ArrayList<>();
  325. for (AttachmentBindingEntity binding : bindings) {
  326. DocumentEntity attachment = kept.get(binding.getAttachmentDocumentId());
  327. if (attachment == null) {
  328. continue;
  329. }
  330. Map<String, Object> item = attachmentSummary(attachment, context, mainAccess,
  331. mainAccess.allowed() ? params.get("contentKeyword") : null);
  332. item.put("bindingId", String.valueOf(binding.getId()));
  333. item.put("bindingSortNo", binding.getSortNo());
  334. items.add(item);
  335. }
  336. long total = items.size();
  337. int fromIndex = Math.min(page.offset(), items.size());
  338. int toIndex = Math.min(page.offset() + page.pageSize(), items.size());
  339. return Page.result(items.subList(fromIndex, toIndex), page, total);
  340. }
  341. // ------------------------------------------------------------ 内部实现
  342. /** 对齐 _attachment。 */
  343. private DocumentEntity activeAttachment(long attachmentId) {
  344. DocumentEntity attachment = documentMapper.findActiveAttachmentById(attachmentId);
  345. if (attachment == null) {
  346. throw new DmsException("RESOURCE_NOT_FOUND", 404, "共享附件不存在", null);
  347. }
  348. return attachment;
  349. }
  350. }