package com.dms.service; import com.dms.common.DmsException; import com.dms.common.Page; import com.dms.common.Payloads; import com.dms.common.TimeRanges; import com.dms.mapper.AttachmentBindingMapper; import com.dms.mapper.DocumentMapper; import com.dms.model.AttachmentBindingEntity; import com.dms.model.DocumentEntity; import com.dms.security.AuthContext; import com.dms.service.AuthorizationService.AttachmentAccess; import com.dms.service.AuthorizationService.PlanAccess; import jakarta.servlet.http.HttpServletRequest; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import org.springframework.stereotype.Service; /** * 共享附件及其有效挂载关系只读查询, * 对齐 backend/dms/services/attachment_query_service.py。 * *

附件无 ACL(固定 PUBLIC / ALL_AUTHENTICATED / PUBLISHED), * 普通用户仅能通过至少一个有权有效主案访问附件; * 主案挂载列表全量可见(allowedActions 为空集、正文片段抑制)。

*/ @Service public class AttachmentQueryService { /** 对齐 AttachmentType 枚举全集(阶段11新增 ACCOMPANYING_PLAN 配套方案)。 */ public static final Set ATTACHMENT_TYPES = Set.of( "SUPPORT_ACHIEVEMENT", "TRAINING_MATERIAL", "TYPICAL_CASE", "ACCOMPANYING_PLAN"); private final AttachmentBindingMapper attachmentBindingMapper; private final DocumentMapper documentMapper; private final AuthorizationService authorizationService; private final DocumentQueryService documentQueryService; public AttachmentQueryService(AttachmentBindingMapper attachmentBindingMapper, DocumentMapper documentMapper, AuthorizationService authorizationService, DocumentQueryService documentQueryService) { this.attachmentBindingMapper = attachmentBindingMapper; this.documentMapper = documentMapper; this.authorizationService = authorizationService; this.documentQueryService = documentQueryService; } // ------------------------------------------------------------ 序列化 /** 对齐 mounted_plan_count。 */ public long mountedPlanCount(long attachmentId) { return attachmentBindingMapper.countActiveByAttachment(attachmentId); } /** 对齐 attachment_summary。 */ public Map attachmentSummary(DocumentEntity attachment, AuthContext context, PlanAccess planAccess, String keyword) { Map result = Payloads.orderedMap(); result.put("id", String.valueOf(attachment.getId())); result.put("documentName", attachment.getDocumentName()); result.put("summary", attachment.getSummary()); result.put("description", attachment.getDescription()); result.put("documentType", attachment.getDocumentType()); result.put("status", attachment.getDocumentStatus()); result.put("securityLevel", attachment.getSecurityLevel()); // 附件不可授权(AttachmentHasNoAcl),借阅摘要固定占位 result.put("borrowSummary", "未配置"); result.put("attachmentType", attachment.getAttachmentType()); result.put("categoryId", null); result.put("categoryName", null); result.put("categoryPath", null); result.put("organizationId", attachment.getOrganizationId() == null ? null : String.valueOf(attachment.getOrganizationId())); result.put("organizationName", attachment.getOrganizationName()); result.put("parentDocumentId", null); result.put("rootDocumentId", null); result.put("tags", documentQueryService.tagsOf(attachment.getTags())); result.put("fileExtension", attachment.getFileExtension()); result.put("childCount", attachment.getChildCount()); result.put("attachmentCount", attachment.getAttachmentCount()); result.put("viewCount", attachment.getViewCount()); result.put("downloadCount", attachment.getDownloadCount()); result.put("createdByName", attachment.getCreatedByName()); result.put("createdAt", DocumentQueryService.iso(attachment.getCreatedAt())); result.put("updatedAt", DocumentQueryService.iso(attachment.getUpdatedAt())); result.put("rowVersion", attachment.getRowVersion()); result.put("allowedActions", authorizationService.attachmentAllowedActions(context, attachment, planAccess)); result.put("mountedPlanCount", mountedPlanCount(attachment.getId())); String snippet = DocumentQueryService.contentSnippet(attachment.getContentText(), keyword); if (snippet != null) { result.put("contentSnippet", snippet); } return result; } /** 对齐 attachment_detail:summary 之上补充文件字段与固定权限摘要。 */ public Map attachmentDetail(DocumentEntity attachment, AuthContext context) { Map result = attachmentSummary(attachment, context, null, null); result.put("originalFileName", attachment.getOriginalFileName()); result.put("mimeType", attachment.getMimeType()); result.put("fileSize", attachment.getFileSize()); result.put("fileHash", attachment.getFileHash()); Map permissionSummary = Payloads.orderedMap(); permissionSummary.put("borrowerCount", 0); permissionSummary.put("inheritedFromMainPlan", false); result.put("permissionSummary", permissionSummary); result.put("contentText", attachment.getContentText()); // 提取状态/失败原因仅供管理员排障("编辑"界面提示与重新解析) if ("ADMIN".equals(context.roleCode())) { result.put("contentExtractStatus", attachment.getContentExtractStatus()); result.put("contentExtractError", attachment.getContentExtractError()); result.put("contentExtractedAt", attachment.getContentExtractedAt() == null ? null : DocumentQueryService.iso(attachment.getContentExtractedAt())); } return result; } // ------------------------------------------------------------ 查询入口 /** * 对齐 list_attachments:列表全量可见(全局检索需要)—— * 普通用户同样返回全部附件标题,但 allowedActions 为空集, * 且不返回详细说明与正文片段;内容访问由详情/预览/下载接口 * 按挂载主案权限独立拦截。 */ public Map listAttachments(Map params) { AuthContext context = AuthContext.get(); boolean isAdmin = "ADMIN".equals(context.roleCode()); Page.Params page = Page.of( DocumentQueryService.positiveIntParam(params, "page", 1), DocumentQueryService.positiveIntParam(params, "pageSize", 20)); Map filters = Payloads.orderedMap(); filters.put("types", List.of("ATTACHMENT")); // 高级检索·数据内容/提供单位(契约1.55):附件列表与方案列表同口径 // (keywordScope 缺省 TITLE,与原 keyword→titlePattern 行为一致) String keyword = params.get("keyword"); DocumentQueryService.applyKeywordScope(filters, keyword, params.get("keywordScope"), false); DocumentQueryService.applyAdvancedFilters(filters, params); documentQueryService.applyOrganizationFilter(filters, params.get("organizationId")); // 高级检索·数据密级(契约1.55):单选过滤(securityLevels 多选保留兼容), // 修复漏接线导致的密级筛选不生效 String securityLevel = DocumentQueryService.enumParam( params.get("securityLevel"), DocumentQueryService.SECURITY_LEVELS, "securityLevel"); if (securityLevel != null) { filters.put("securityLevel", securityLevel); } String attachmentType = DocumentQueryService.enumParam( params.get("attachmentType"), ATTACHMENT_TYPES, "attachmentType"); if (attachmentType != null) { filters.put("attachmentType", attachmentType); } String extension = params.get("fileExtension"); if (extension != null && !extension.isEmpty()) { // 对齐 Python:truthy 即过滤(含纯空白),归一 strip/lower/lstrip('.') String normalized = extension.strip().toLowerCase(Locale.ROOT); while (normalized.startsWith(".")) { normalized = normalized.substring(1); } filters.put("fileExtension", normalized); } TimeRanges.UpdatedRange range = TimeRanges.parseUpdatedRange(params); if (range.from() != null) { filters.put("updatedFrom", range.from()); } if (range.to() != null) { filters.put("updatedTo", range.to()); } filters.put("orderBy", DocumentQueryService.orderByParam(params)); List attachments = documentMapper.listOverviewByFilters(filters); long total = attachments.size(); int fromIndex = Math.min(page.offset(), attachments.size()); int toIndex = Math.min(page.offset() + page.pageSize(), attachments.size()); List selected = attachments.subList(fromIndex, toIndex); // 列表主体查询不携带 content_text 大字段;仅管理员 contentKeyword // 检索生成当前页摘要时批量补查。契约1.55:数据内容为 CONTENT/ALL // 时摘要关键词回落到检索关键词(正文命中需展示片段)。 String snippetKeyword = params.get("contentKeyword"); if ((snippetKeyword == null || snippetKeyword.strip().isEmpty()) && DocumentQueryService.keywordScopeContentLike(params) && keyword != null && !keyword.strip().isEmpty()) { snippetKeyword = keyword.strip(); } if (isAdmin && snippetKeyword != null && !snippetKeyword.strip().isEmpty()) { documentQueryService.fillContentText(selected); } List> items = new ArrayList<>(); for (DocumentEntity item : selected) { Map summary = attachmentSummary(item, context, null, isAdmin ? snippetKeyword : null); if (!isAdmin) { summary.put("description", null); } items.add(summary); } return Page.result(items, page, total); } /** 对齐 get_attachment:访问前置 + 详情 + 独立事务查看计数。 */ public Map getAttachment(long attachmentId, HttpServletRequest request) { AuthContext context = AuthContext.get(); DocumentEntity attachment = activeAttachment(attachmentId); AttachmentAccess access; try { access = authorizationService.requireAttachmentAccess(attachment, context, false); } catch (DmsException e) { // 操作位隔离补全:查看位不足但具备附件操作位(编辑/删除等)时放行 // "编辑视图"——详情仅元数据(查看计数与审计不记录);密级不足维持拒绝 if (!"DOCUMENT_VIEW_FORBIDDEN".equals(e.code()) || authorizationService.attachmentAllowedActions(context, attachment, null) .isEmpty()) { throw e; } access = authorizationService.evaluateAttachmentAccess(attachment, context); } Map result = attachmentDetail(attachment, context); if ("USER".equals(context.roleCode())) { List actions = new ArrayList<>(); // 编辑视图(无查看权)不下发 VIEW 位 if (access.canView()) { actions.add("VIEW"); } if (access.canDownload()) { actions.add("DOWNLOAD"); } // 契约1.41:矩阵附件编辑位独立下发(详情访问已含挂载聚合查看) if (authorizationService.attachmentEditAllowed(attachment, context)) { actions.add("EDIT"); } result.put("allowedActions", actions); } if (access.canView()) { result.put("viewCount", documentQueryService.recordView(attachment, request)); } else { // 编辑视图:回显库中当前计数(查询未携带时按 0),不增计不审计 result.put("viewCount", attachment.getViewCount() == null ? 0L : attachment.getViewCount()); } return result; } /** 对齐 list_attachment_main_plans:附件可达且有权主案清单。 */ public Map listAttachmentMainPlans(long attachmentId) { AuthContext context = AuthContext.get(); activeAttachment(attachmentId); AccessMemo memo = new AccessMemo(); List> items = new ArrayList<>(); for (DocumentEntity main : attachmentBindingMapper .listActiveMainsByAttachmentOrdered(attachmentId)) { if (!authorizationService.evaluatePlanAccess(main, context, memo).allowed()) { continue; } Map item = Payloads.orderedMap(); item.put("id", String.valueOf(main.getId())); item.put("documentName", main.getDocumentName()); item.put("categoryName", main.getCategoryName()); item.put("securityLevel", main.getSecurityLevel()); items.add(item); } Map result = Payloads.orderedMap(); result.put("items", items); result.put("total", items.size()); return result; } /** * 对齐 list_main_plan_attachments:主案挂载列表全量可见 * (主案无权限时 allowedActions 为空集、正文片段同步抑制)。 */ public Map listMainPlanAttachments(long mainDocumentId, Map params) { AuthContext context = AuthContext.get(); DocumentEntity main = documentMapper.findActiveMainById(mainDocumentId); if (main == null) { throw new DmsException("MAIN_PLAN_NOT_FOUND", 404, "主案不存在", null); } PlanAccess mainAccess = authorizationService.evaluatePlanAccess(main, context); Page.Params page = Page.of( DocumentQueryService.positiveIntParam(params, "page", 1), DocumentQueryService.positiveIntParam(params, "pageSize", 20)); List bindings = attachmentBindingMapper.listActiveByMain(main.getId()); if (bindings.isEmpty()) { return Page.result(List.of(), page, 0); } List attachmentIds = new ArrayList<>(); for (AttachmentBindingEntity binding : bindings) { attachmentIds.add(binding.getAttachmentDocumentId()); } Map filters = Payloads.orderedMap(); filters.put("types", List.of("ATTACHMENT")); filters.put("ids", attachmentIds); String keyword = params.get("keyword"); if (keyword != null && !keyword.strip().isEmpty()) { filters.put("titlePattern", "%" + DocumentQueryService.escapeLike(keyword) + "%"); } DocumentQueryService.applyAdvancedFilters(filters, params); String attachmentType = DocumentQueryService.enumParam( params.get("attachmentType"), ATTACHMENT_TYPES, "attachmentType"); if (attachmentType != null) { filters.put("attachmentType", attachmentType); } TimeRanges.UpdatedRange range = TimeRanges.parseUpdatedRange(params); if (range.from() != null) { filters.put("updatedFrom", range.from()); } if (range.to() != null) { filters.put("updatedTo", range.to()); } Map kept = new HashMap<>(); for (DocumentEntity document : documentMapper.listOverviewByFilters(filters)) { kept.put(document.getId(), document); } // 列表主体查询不携带 content_text 大字段;仅主案有权限且 // contentKeyword 检索生成摘要时,对涉及的挂载附件批量补查。 String contentKeyword = params.get("contentKeyword"); if (mainAccess.allowed() && contentKeyword != null && !contentKeyword.strip().isEmpty()) { List pageAttachments = new ArrayList<>(); for (AttachmentBindingEntity binding : bindings) { DocumentEntity attachment = kept.get(binding.getAttachmentDocumentId()); if (attachment != null) { pageAttachments.add(attachment); } } documentQueryService.fillContentText(pageAttachments); } List> items = new ArrayList<>(); for (AttachmentBindingEntity binding : bindings) { DocumentEntity attachment = kept.get(binding.getAttachmentDocumentId()); if (attachment == null) { continue; } Map item = attachmentSummary(attachment, context, mainAccess, mainAccess.allowed() ? params.get("contentKeyword") : null); item.put("bindingId", String.valueOf(binding.getId())); item.put("bindingSortNo", binding.getSortNo()); items.add(item); } long total = items.size(); int fromIndex = Math.min(page.offset(), items.size()); int toIndex = Math.min(page.offset() + page.pageSize(), items.size()); return Page.result(items.subList(fromIndex, toIndex), page, total); } // ------------------------------------------------------------ 内部实现 /** 对齐 _attachment。 */ private DocumentEntity activeAttachment(long attachmentId) { DocumentEntity attachment = documentMapper.findActiveAttachmentById(attachmentId); if (attachment == null) { throw new DmsException("RESOURCE_NOT_FOUND", 404, "共享附件不存在", null); } return attachment; } }