| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368 |
- 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。
- *
- * <p>附件无 ACL(固定 PUBLIC / ALL_AUTHENTICATED / PUBLISHED),
- * 普通用户仅能通过至少一个有权有效主案访问附件;
- * 主案挂载列表全量可见(allowedActions 为空集、正文片段抑制)。</p>
- */
- @Service
- public class AttachmentQueryService {
- /** 对齐 AttachmentType 枚举全集(阶段11新增 ACCOMPANYING_PLAN 配套方案)。 */
- public static final Set<String> 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<String, Object> attachmentSummary(DocumentEntity attachment, AuthContext context,
- PlanAccess planAccess, String keyword) {
- Map<String, Object> 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<String, Object> attachmentDetail(DocumentEntity attachment, AuthContext context) {
- Map<String, Object> 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<String, Object> 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<String, Object> listAttachments(Map<String, String> 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<String, Object> 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<DocumentEntity> 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<DocumentEntity> 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<Map<String, Object>> items = new ArrayList<>();
- for (DocumentEntity item : selected) {
- Map<String, Object> 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<String, Object> 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<String, Object> result = attachmentDetail(attachment, context);
- if ("USER".equals(context.roleCode())) {
- List<String> 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<String, Object> listAttachmentMainPlans(long attachmentId) {
- AuthContext context = AuthContext.get();
- activeAttachment(attachmentId);
- AccessMemo memo = new AccessMemo();
- List<Map<String, Object>> items = new ArrayList<>();
- for (DocumentEntity main : attachmentBindingMapper
- .listActiveMainsByAttachmentOrdered(attachmentId)) {
- if (!authorizationService.evaluatePlanAccess(main, context, memo).allowed()) {
- continue;
- }
- Map<String, Object> 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<String, Object> result = Payloads.orderedMap();
- result.put("items", items);
- result.put("total", items.size());
- return result;
- }
- /**
- * 对齐 list_main_plan_attachments:主案挂载列表全量可见
- * (主案无权限时 allowedActions 为空集、正文片段同步抑制)。
- */
- public Map<String, Object> listMainPlanAttachments(long mainDocumentId,
- Map<String, String> 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<AttachmentBindingEntity> bindings =
- attachmentBindingMapper.listActiveByMain(main.getId());
- if (bindings.isEmpty()) {
- return Page.result(List.of(), page, 0);
- }
- List<Long> attachmentIds = new ArrayList<>();
- for (AttachmentBindingEntity binding : bindings) {
- attachmentIds.add(binding.getAttachmentDocumentId());
- }
- Map<String, Object> 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<Long, DocumentEntity> 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<DocumentEntity> pageAttachments = new ArrayList<>();
- for (AttachmentBindingEntity binding : bindings) {
- DocumentEntity attachment = kept.get(binding.getAttachmentDocumentId());
- if (attachment != null) {
- pageAttachments.add(attachment);
- }
- }
- documentQueryService.fillContentText(pageAttachments);
- }
- List<Map<String, Object>> items = new ArrayList<>();
- for (AttachmentBindingEntity binding : bindings) {
- DocumentEntity attachment = kept.get(binding.getAttachmentDocumentId());
- if (attachment == null) {
- continue;
- }
- Map<String, Object> 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;
- }
- }
|