attachment_query_service.py 11 KB

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