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