attachment_query_service.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. """共享附件及其有效挂载关系只读查询。"""
  2. from __future__ import annotations
  3. from typing import Mapping
  4. from sqlalchemy import func, select
  5. from sqlalchemy.orm import defer
  6. from dms.common.enums import AttachmentType, DocumentType, RoleCode
  7. from dms.common.errors import ResourceNotFoundError
  8. from dms.common.pagination import page_result
  9. from dms.common.response import serialize_id
  10. from dms.common.time_range import parse_updated_range
  11. from dms.extensions import db
  12. from dms.models import AttachmentBinding, Document
  13. from dms.security.auth_context import AuthContext, get_auth_context
  14. from dms.services.authorization_service import (
  15. AccessMemo,
  16. attachment_allowed_actions,
  17. evaluate_plan_access,
  18. require_attachment_access,
  19. )
  20. from dms.services.document_query_service import (
  21. DOCUMENT_SORTS,
  22. _advanced_filters,
  23. _content_snippet,
  24. _enum,
  25. _escape_like,
  26. _fill_content_text,
  27. _iso,
  28. _page,
  29. _record_view,
  30. _sort,
  31. parse_string_id,
  32. )
  33. def mounted_plan_count(attachment_id: int) -> int:
  34. return (
  35. db.session.scalar(
  36. select(func.count(AttachmentBinding.id)).where(
  37. AttachmentBinding.attachment_document_id == attachment_id,
  38. AttachmentBinding.is_deleted.is_(False),
  39. )
  40. )
  41. or 0
  42. )
  43. def attachment_summary(
  44. attachment: Document,
  45. context: AuthContext,
  46. *,
  47. plan_access=None,
  48. keyword: str | None = None,
  49. ) -> dict[str, object]:
  50. result: dict[str, object] = {
  51. "id": serialize_id(attachment.id),
  52. "documentName": attachment.document_name,
  53. "summary": attachment.summary,
  54. "description": attachment.description,
  55. "documentType": attachment.document_type,
  56. "status": attachment.document_status,
  57. "securityLevel": attachment.security_level,
  58. # 附件不可授权(AttachmentHasNoAcl),借阅摘要固定占位
  59. "borrowSummary": "未配置",
  60. "attachmentType": attachment.attachment_type,
  61. "categoryId": None,
  62. "categoryName": None,
  63. "categoryPath": None,
  64. "organizationId": serialize_id(attachment.organization_id),
  65. "organizationName": attachment.organization_name,
  66. "parentDocumentId": None,
  67. "rootDocumentId": None,
  68. "tags": attachment.tags or [],
  69. "fileExtension": attachment.file_extension,
  70. "childCount": attachment.child_count,
  71. "attachmentCount": attachment.attachment_count,
  72. "viewCount": attachment.view_count,
  73. "downloadCount": attachment.download_count,
  74. "createdByName": attachment.created_by_name,
  75. "createdAt": _iso(attachment.created_at),
  76. "updatedAt": _iso(attachment.updated_at),
  77. "rowVersion": attachment.row_version,
  78. "allowedActions": attachment_allowed_actions(context, plan_access),
  79. "mountedPlanCount": mounted_plan_count(attachment.id),
  80. }
  81. # 列表查询 defer 了 content_text:仅在有正文关键词时才访问(此时已批量回填),
  82. # 无关键词时 _content_snippet 本就返回 None,守卫不改变契约且避免逐行 lazy load。
  83. if keyword:
  84. snippet = _content_snippet(attachment.content_text, keyword)
  85. if snippet is not None:
  86. result["contentSnippet"] = snippet
  87. return result
  88. def attachment_detail(
  89. attachment: Document,
  90. context: AuthContext,
  91. ) -> dict[str, object]:
  92. result = attachment_summary(attachment, context)
  93. result.update(
  94. {
  95. "originalFileName": attachment.original_file_name,
  96. "mimeType": attachment.mime_type,
  97. "fileSize": attachment.file_size,
  98. "fileHash": attachment.file_hash,
  99. "permissionSummary": {
  100. "borrowerCount": 0,
  101. "inheritedFromMainPlan": False,
  102. },
  103. "contentText": attachment.content_text,
  104. }
  105. )
  106. # 提取状态/失败原因仅供管理员排障("编辑"界面提示与重新解析)
  107. if context.role_code == RoleCode.ADMIN:
  108. result.update(
  109. {
  110. "contentExtractStatus": attachment.content_extract_status,
  111. "contentExtractError": attachment.content_extract_error,
  112. "contentExtractedAt": (
  113. _iso(attachment.content_extracted_at)
  114. if attachment.content_extracted_at
  115. else None
  116. ),
  117. }
  118. )
  119. return result
  120. def _attachment(attachment_id: int) -> Document:
  121. attachment = db.session.scalar(
  122. select(Document).where(
  123. Document.id == attachment_id,
  124. Document.document_type == DocumentType.ATTACHMENT.value,
  125. Document.is_deleted.is_(False),
  126. )
  127. )
  128. if attachment is None:
  129. raise ResourceNotFoundError("共享附件不存在")
  130. return attachment
  131. def _filtered_attachments(params: Mapping[str, str]):
  132. statement = select(Document).where(
  133. Document.document_type == DocumentType.ATTACHMENT.value,
  134. Document.is_deleted.is_(False),
  135. )
  136. keyword = params.get("keyword")
  137. if keyword and keyword.strip():
  138. pattern = f"%{_escape_like(keyword)}%"
  139. statement = statement.where(
  140. Document.document_name.like(pattern, escape="\\")
  141. )
  142. statement = _advanced_filters(statement, params)
  143. attachment_type = _enum(
  144. params.get("attachmentType"), AttachmentType, "attachmentType"
  145. )
  146. if attachment_type:
  147. statement = statement.where(Document.attachment_type == attachment_type)
  148. extension = params.get("fileExtension")
  149. if extension:
  150. statement = statement.where(
  151. Document.file_extension == extension.strip().lower().lstrip(".")
  152. )
  153. updated_from, updated_to = parse_updated_range(params)
  154. if updated_from:
  155. statement = statement.where(Document.updated_at >= updated_from)
  156. if updated_to:
  157. statement = statement.where(Document.updated_at <= updated_to)
  158. order, _ = _sort(params, allowed=DOCUMENT_SORTS)
  159. return statement.order_by(order, Document.id.asc())
  160. def list_attachments(params: Mapping[str, str]) -> dict[str, object]:
  161. context = get_auth_context()
  162. page = _page(params)
  163. attachments = db.session.scalars(
  164. _filtered_attachments(params).options(
  165. # 列表主体查询不携带正文大字段;仅管理员 contentKeyword 检索
  166. # 生成当前页摘要时按页 ID 批量补查(_fill_content_text)。
  167. defer(Document.content_text),
  168. defer(Document.search_text),
  169. )
  170. ).all()
  171. total = len(attachments)
  172. selected = attachments[page.offset : page.offset + page.page_size]
  173. # 列表全量可见(全局检索需要):普通用户同样返回全部附件标题,
  174. # 但 allowedActions 为空集,且不返回详细说明与正文片段;
  175. # 内容访问由详情/预览/下载接口按挂载主案权限独立拦截。
  176. is_admin = context.role_code == RoleCode.ADMIN
  177. content_keyword = params.get("contentKeyword")
  178. if is_admin and content_keyword and content_keyword.strip():
  179. _fill_content_text(list(selected))
  180. items: list[dict[str, object]] = []
  181. for item in selected:
  182. summary = attachment_summary(
  183. item,
  184. context,
  185. keyword=content_keyword if is_admin else None,
  186. )
  187. if not is_admin:
  188. summary["description"] = None
  189. items.append(summary)
  190. return page_result(
  191. items,
  192. page=page.page,
  193. page_size=page.page_size,
  194. total=total,
  195. )
  196. def get_attachment(attachment_id: int) -> dict[str, object]:
  197. context = get_auth_context()
  198. attachment = _attachment(attachment_id)
  199. can_view, can_download = require_attachment_access(
  200. attachment, context, download=False
  201. )
  202. result = attachment_detail(attachment, context)
  203. if context.role_code.value == "USER":
  204. result["allowedActions"] = ["VIEW"] + (
  205. ["DOWNLOAD"] if can_download else []
  206. )
  207. result["viewCount"] = _record_view(attachment)
  208. return result
  209. def list_attachment_main_plans(attachment_id: int) -> dict[str, object]:
  210. context = get_auth_context()
  211. _attachment(attachment_id)
  212. rows = db.session.execute(
  213. select(AttachmentBinding, Document)
  214. .join(Document, Document.id == AttachmentBinding.main_document_id)
  215. .where(
  216. AttachmentBinding.attachment_document_id == attachment_id,
  217. AttachmentBinding.is_deleted.is_(False),
  218. Document.document_type == DocumentType.MAIN.value,
  219. Document.is_deleted.is_(False),
  220. )
  221. .order_by(Document.document_name.asc(), Document.id.asc())
  222. ).all()
  223. items: list[dict[str, object]] = []
  224. memo = AccessMemo()
  225. for _, main in rows:
  226. if not evaluate_plan_access(main, context, memo).allowed:
  227. continue
  228. items.append(
  229. {
  230. "id": serialize_id(main.id),
  231. "documentName": main.document_name,
  232. "categoryName": main.category_name,
  233. "securityLevel": main.security_level,
  234. }
  235. )
  236. return {"items": items, "total": len(items)}
  237. def list_main_plan_attachments(
  238. main_document_id: int,
  239. params: Mapping[str, str],
  240. ) -> dict[str, object]:
  241. from dms.common.errors import MainPlanNotFoundError
  242. context = get_auth_context()
  243. main = db.session.scalar(
  244. select(Document).where(
  245. Document.id == main_document_id,
  246. Document.document_type == DocumentType.MAIN.value,
  247. Document.is_deleted.is_(False),
  248. )
  249. )
  250. if main is None:
  251. raise MainPlanNotFoundError()
  252. # 主案无权限时也返回挂载列表(列表全量可见);allowedActions 为空集,
  253. # 附件正文片段同步抑制,预览/下载接口仍独立拦截。
  254. main_access = evaluate_plan_access(main, context)
  255. page = _page(params)
  256. statement = (
  257. select(AttachmentBinding, Document)
  258. .join(Document, Document.id == AttachmentBinding.attachment_document_id)
  259. .where(
  260. AttachmentBinding.main_document_id == main.id,
  261. AttachmentBinding.is_deleted.is_(False),
  262. Document.document_type == DocumentType.ATTACHMENT.value,
  263. Document.is_deleted.is_(False),
  264. )
  265. # 列表主体查询不携带正文大字段;仅主案有权且 contentKeyword 检索
  266. # 生成摘要时对当前页挂载附件批量补查(_fill_content_text)。
  267. .options(defer(Document.content_text), defer(Document.search_text))
  268. )
  269. keyword = params.get("keyword")
  270. if keyword and keyword.strip():
  271. pattern = f"%{_escape_like(keyword)}%"
  272. statement = statement.where(
  273. Document.document_name.like(pattern, escape="\\")
  274. )
  275. statement = _advanced_filters(statement, params)
  276. attachment_type = _enum(
  277. params.get("attachmentType"), AttachmentType, "attachmentType"
  278. )
  279. if attachment_type:
  280. statement = statement.where(Document.attachment_type == attachment_type)
  281. updated_from, updated_to = parse_updated_range(params)
  282. if updated_from:
  283. statement = statement.where(Document.updated_at >= updated_from)
  284. if updated_to:
  285. statement = statement.where(Document.updated_at <= updated_to)
  286. rows = db.session.execute(
  287. statement.order_by(
  288. AttachmentBinding.sort_no.asc(),
  289. AttachmentBinding.id.asc(),
  290. )
  291. ).all()
  292. total = len(rows)
  293. selected = rows[page.offset : page.offset + page.page_size]
  294. content_keyword = params.get("contentKeyword")
  295. if main_access.allowed and content_keyword and content_keyword.strip():
  296. _fill_content_text([attachment for _, attachment in selected])
  297. items: list[dict[str, object]] = []
  298. for binding, attachment in selected:
  299. item = attachment_summary(
  300. attachment,
  301. context,
  302. plan_access=main_access,
  303. keyword=content_keyword if main_access.allowed else None,
  304. )
  305. item.update(
  306. {
  307. "bindingId": serialize_id(binding.id),
  308. "bindingSortNo": binding.sort_no,
  309. }
  310. )
  311. items.append(item)
  312. return page_result(
  313. items,
  314. page=page.page,
  315. page_size=page.page_size,
  316. total=total,
  317. )
  318. __all__ = [
  319. "get_attachment",
  320. "list_attachment_main_plans",
  321. "list_attachments",
  322. "list_main_plan_attachments",
  323. "parse_string_id",
  324. ]