document_query_service.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. """主案、子方案只读查询、DTO和查看审计。"""
  2. from __future__ import annotations
  3. import logging
  4. from datetime import datetime, timezone
  5. from typing import Any, Mapping
  6. from sqlalchemy import String, cast, or_, select
  7. from dms.common.enums import (
  8. AuditAction,
  9. AuditTarget,
  10. DocumentStatus,
  11. DocumentType,
  12. RoleCode,
  13. SecurityLevel,
  14. VisibilityType,
  15. )
  16. from dms.common.errors import (
  17. DocumentViewForbiddenError,
  18. InvalidArgumentError,
  19. MainPlanNotFoundError,
  20. ResourceNotFoundError,
  21. SecurityLevelForbiddenError,
  22. )
  23. from dms.common.pagination import PageRequest, page_result
  24. from dms.common.response import serialize_id
  25. from dms.common.time_range import parse_datetime, parse_updated_range
  26. from dms.database.transaction import transaction
  27. from dms.extensions import db
  28. from dms.models import Category, Document, Permission
  29. from dms.security.auth_context import AuthContext, get_auth_context
  30. from dms.services.audit_service import business_audit
  31. from dms.services.authorization_service import (
  32. PlanAccess,
  33. evaluate_plan_access,
  34. plan_allowed_actions,
  35. )
  36. logger = logging.getLogger(__name__)
  37. PLAN_TYPES = (DocumentType.MAIN.value, DocumentType.SUB_PLAN.value)
  38. DOCUMENT_SORTS = {
  39. "documentName": Document.document_name,
  40. "createdAt": Document.created_at,
  41. "updatedAt": Document.updated_at,
  42. "viewCount": Document.view_count,
  43. "downloadCount": Document.download_count,
  44. }
  45. def parse_string_id(value: str, *, field: str = "id") -> int:
  46. if not value.isdecimal() or int(value) <= 0:
  47. raise InvalidArgumentError(f"{field}必须是正整数形式的字符串ID")
  48. return int(value)
  49. def _integer(
  50. params: Mapping[str, str],
  51. name: str,
  52. default: int,
  53. ) -> int:
  54. raw = params.get(name)
  55. if raw is None:
  56. return default
  57. if not raw.isdecimal():
  58. raise InvalidArgumentError(f"{name}必须是正整数")
  59. return int(raw)
  60. def _page(params: Mapping[str, str]) -> PageRequest:
  61. return PageRequest(
  62. page=_integer(params, "page", 1),
  63. page_size=_integer(params, "pageSize", 20),
  64. )
  65. def _enum(value: str | None, enum_type, name: str) -> str | None:
  66. if value is None or value == "":
  67. return None
  68. try:
  69. return enum_type(value).value
  70. except ValueError as exc:
  71. raise InvalidArgumentError(f"{name}不是有效枚举值") from exc
  72. def _date(value: str | None, name: str) -> datetime | None:
  73. return parse_datetime(value, name=name)
  74. def _sort(
  75. params: Mapping[str, str],
  76. *,
  77. allowed: dict[str, Any] = DOCUMENT_SORTS,
  78. default: str = "updatedAt",
  79. ) -> tuple[Any, str]:
  80. sort_by = params.get("sortBy", default)
  81. direction = params.get("sortDirection", "desc").lower()
  82. if sort_by not in allowed:
  83. raise InvalidArgumentError("sortBy不是允许的排序字段")
  84. if direction not in {"asc", "desc"}:
  85. raise InvalidArgumentError("sortDirection必须是asc或desc")
  86. column = allowed[sort_by]
  87. return (column.asc() if direction == "asc" else column.desc()), direction
  88. def _iso(value: datetime) -> str:
  89. return value.replace(tzinfo=timezone.utc).isoformat(timespec="milliseconds").replace(
  90. "+00:00", "Z"
  91. )
  92. def _permission_summary(
  93. access: PlanAccess,
  94. document: Document,
  95. ) -> tuple[dict[str, object], str]:
  96. source = access.source
  97. if source is None:
  98. return {
  99. "organizationCount": 0,
  100. "userCount": 0,
  101. "inheritedFromMainPlan": False,
  102. }, "未配置"
  103. permissions = db.session.scalars(
  104. select(Permission).where(
  105. Permission.document_id == source.id,
  106. Permission.is_deleted.is_(False),
  107. )
  108. ).all()
  109. organization_count = sum(item.subject_type == "ORG" for item in permissions)
  110. user_count = sum(item.subject_type == "USER" for item in permissions)
  111. visibility = VisibilityType(source.visibility_type)
  112. if visibility == VisibilityType.ALL_AUTHENTICATED:
  113. visibility_summary = "全部已登录用户"
  114. else:
  115. names = [item.subject_name for item in permissions if item.can_view]
  116. visibility_summary = "、".join(names) if names else "未配置"
  117. return {
  118. "organizationCount": organization_count,
  119. "userCount": user_count,
  120. "inheritedFromMainPlan": document.document_type
  121. == DocumentType.SUB_PLAN.value,
  122. }, visibility_summary
  123. def document_summary(
  124. document: Document,
  125. context: AuthContext,
  126. access: PlanAccess,
  127. ) -> dict[str, object]:
  128. _, visibility_summary = _permission_summary(access, document)
  129. source = access.source or document
  130. return {
  131. "id": serialize_id(document.id),
  132. "documentName": document.document_name,
  133. "summary": document.summary,
  134. "documentType": document.document_type,
  135. "status": document.document_status,
  136. "securityLevel": document.security_level,
  137. "visibilityType": source.visibility_type,
  138. "visibilitySummary": visibility_summary,
  139. "attachmentType": document.attachment_type,
  140. "categoryId": serialize_id(document.category_id),
  141. "categoryName": document.category_name,
  142. "categoryPath": document.category_path,
  143. "parentDocumentId": serialize_id(document.parent_document_id),
  144. "rootDocumentId": serialize_id(
  145. document.id
  146. if document.document_type == DocumentType.MAIN.value
  147. else document.root_document_id
  148. ),
  149. "tags": document.tags or [],
  150. "fileExtension": document.file_extension,
  151. "childCount": document.child_count,
  152. "attachmentCount": document.attachment_count,
  153. "viewCount": document.view_count,
  154. "downloadCount": document.download_count,
  155. "createdByName": document.created_by_name,
  156. "createdAt": _iso(document.created_at),
  157. "updatedAt": _iso(document.updated_at),
  158. "rowVersion": document.row_version,
  159. "allowedActions": plan_allowed_actions(document, context, access),
  160. }
  161. def document_detail(
  162. document: Document,
  163. context: AuthContext,
  164. access: PlanAccess,
  165. ) -> dict[str, object]:
  166. result = document_summary(document, context, access)
  167. permission_summary, _ = _permission_summary(access, document)
  168. result.update(
  169. {
  170. "originalFileName": document.original_file_name,
  171. "mimeType": document.mime_type,
  172. "fileSize": document.file_size,
  173. "fileHash": document.file_hash,
  174. "permissionSummary": permission_summary,
  175. }
  176. )
  177. return result
  178. def _require_plan_access(document: Document, context: AuthContext) -> PlanAccess:
  179. access = evaluate_plan_access(document, context)
  180. if access.allowed:
  181. return access
  182. if access.reason == "SECURITY":
  183. raise SecurityLevelForbiddenError()
  184. raise DocumentViewForbiddenError()
  185. def _keyword(statement, keyword: str | None):
  186. if not keyword or not keyword.strip():
  187. return statement
  188. escaped = (
  189. keyword.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
  190. )
  191. pattern = f"%{escaped}%"
  192. return statement.where(
  193. or_(
  194. Document.document_name.like(pattern, escape="\\"),
  195. Document.summary.like(pattern, escape="\\"),
  196. Document.search_text.like(pattern, escape="\\"),
  197. cast(Document.tags, String).like(pattern, escape="\\"),
  198. )
  199. )
  200. def _boolean(value: str | None, name: str, default: bool = False) -> bool:
  201. if value is None:
  202. return default
  203. if value not in {"true", "false"}:
  204. raise InvalidArgumentError(f"{name}必须是true或false")
  205. return value == "true"
  206. def _category_scope(category_id: int, include_descendants: bool) -> set[int]:
  207. categories = db.session.scalars(
  208. select(Category).where(
  209. Category.is_deleted.is_(False),
  210. Category.status == "ENABLED",
  211. )
  212. ).all()
  213. if not any(category.id == category_id for category in categories):
  214. raise ResourceNotFoundError("方案分类不存在或不可用")
  215. if not include_descendants:
  216. return {category_id}
  217. children: dict[int | None, list[int]] = {}
  218. for category in categories:
  219. children.setdefault(category.parent_id, []).append(category.id)
  220. result = {category_id}
  221. pending = [category_id]
  222. while pending:
  223. current = pending.pop()
  224. for child_id in children.get(current, []):
  225. if child_id not in result:
  226. result.add(child_id)
  227. pending.append(child_id)
  228. return result
  229. def list_documents(params: Mapping[str, str]) -> dict[str, object]:
  230. context = get_auth_context()
  231. page = _page(params)
  232. raw_types = params.get("documentType", "MAIN")
  233. document_types = [item.strip() for item in raw_types.split(",") if item.strip()]
  234. if (
  235. not document_types
  236. or any(item not in PLAN_TYPES for item in document_types)
  237. or len(set(document_types)) != len(document_types)
  238. ):
  239. raise InvalidArgumentError("documentType只允许MAIN、SUB_PLAN或二者组合")
  240. statement = select(Document).where(
  241. Document.is_deleted.is_(False),
  242. Document.document_type.in_(document_types),
  243. )
  244. include_descendants = _boolean(
  245. params.get("includeDescendants"), "includeDescendants"
  246. )
  247. category_id = params.get("categoryId")
  248. if category_id:
  249. parsed_category_id = parse_string_id(category_id, field="categoryId")
  250. statement = statement.where(
  251. Document.category_id.in_(
  252. _category_scope(parsed_category_id, include_descendants)
  253. )
  254. )
  255. statement = _keyword(statement, params.get("keyword"))
  256. visibility_filter = _enum(
  257. params.get("visibilityType"), VisibilityType, "visibilityType"
  258. )
  259. for name, enum_type, column in (
  260. ("securityLevel", SecurityLevel, Document.security_level),
  261. ("status", DocumentStatus, Document.document_status),
  262. ):
  263. value = _enum(params.get(name), enum_type, name)
  264. if value:
  265. statement = statement.where(column == value)
  266. updated_from, updated_to = parse_updated_range(params)
  267. if updated_from:
  268. statement = statement.where(Document.updated_at >= updated_from)
  269. if updated_to:
  270. statement = statement.where(Document.updated_at <= updated_to)
  271. order, _ = _sort(params)
  272. documents = db.session.scalars(statement.order_by(order, Document.id.asc())).all()
  273. visible: list[tuple[Document, PlanAccess]] = []
  274. for document in documents:
  275. access = evaluate_plan_access(document, context)
  276. if (
  277. access.allowed
  278. and (
  279. visibility_filter is None
  280. or (
  281. access.source is not None
  282. and access.source.visibility_type == visibility_filter
  283. )
  284. )
  285. ):
  286. visible.append((document, access))
  287. total = len(visible)
  288. selected = visible[page.offset : page.offset + page.page_size]
  289. return page_result(
  290. [
  291. document_summary(document, context, access)
  292. for document, access in selected
  293. ],
  294. page=page.page,
  295. page_size=page.page_size,
  296. total=total,
  297. )
  298. def _active_plan(document_id: int) -> Document:
  299. document = db.session.scalar(
  300. select(Document).where(
  301. Document.id == document_id,
  302. Document.is_deleted.is_(False),
  303. Document.document_type.in_(PLAN_TYPES),
  304. )
  305. )
  306. if document is None:
  307. raise ResourceNotFoundError("方案文档不存在")
  308. return document
  309. def _record_view(document: Document) -> int:
  310. old_count = document.view_count
  311. document_id = document.id
  312. document_name = document.document_name
  313. target = (
  314. AuditTarget.ATTACHMENT
  315. if document.document_type == DocumentType.ATTACHMENT.value
  316. else AuditTarget.DOCUMENT
  317. )
  318. db.session.rollback()
  319. try:
  320. with transaction() as session:
  321. current = session.scalar(
  322. select(Document)
  323. .where(
  324. Document.id == document_id,
  325. Document.is_deleted.is_(False),
  326. )
  327. .with_for_update()
  328. )
  329. if current is None:
  330. raise ResourceNotFoundError("文档不存在")
  331. current.view_count += 1
  332. session.add(
  333. business_audit(
  334. action=AuditAction.VIEW_DOCUMENT,
  335. target=target,
  336. target_id=document_id,
  337. target_name=document_name,
  338. detail={"documentType": current.document_type},
  339. )
  340. )
  341. new_count = current.view_count
  342. return new_count
  343. except Exception:
  344. db.session.rollback()
  345. logger.exception("文档查看计数或审计写入失败:document_id=%s", document_id)
  346. return old_count
  347. def get_document(document_id: int) -> dict[str, object]:
  348. context = get_auth_context()
  349. document = _active_plan(document_id)
  350. access = _require_plan_access(document, context)
  351. result = document_detail(document, context, access)
  352. result["viewCount"] = _record_view(document)
  353. return result
  354. def list_sub_plans(
  355. main_document_id: int,
  356. params: Mapping[str, str],
  357. ) -> dict[str, object]:
  358. context = get_auth_context()
  359. main = db.session.scalar(
  360. select(Document).where(
  361. Document.id == main_document_id,
  362. Document.document_type == DocumentType.MAIN.value,
  363. Document.is_deleted.is_(False),
  364. )
  365. )
  366. if main is None:
  367. raise MainPlanNotFoundError()
  368. _require_plan_access(main, context)
  369. page = _page(params)
  370. statement = select(Document).where(
  371. Document.parent_document_id == main.id,
  372. Document.document_type == DocumentType.SUB_PLAN.value,
  373. Document.is_deleted.is_(False),
  374. )
  375. statement = _keyword(statement, params.get("keyword"))
  376. status = _enum(params.get("status"), DocumentStatus, "status")
  377. if status:
  378. statement = statement.where(Document.document_status == status)
  379. updated_from, updated_to = parse_updated_range(params)
  380. if updated_from:
  381. statement = statement.where(Document.updated_at >= updated_from)
  382. if updated_to:
  383. statement = statement.where(Document.updated_at <= updated_to)
  384. order, _ = _sort(params)
  385. children = db.session.scalars(
  386. statement.order_by(order, Document.id.asc())
  387. ).all()
  388. visible: list[tuple[Document, PlanAccess]] = []
  389. for child in children:
  390. access = evaluate_plan_access(child, context)
  391. if access.allowed:
  392. visible.append((child, access))
  393. total = len(visible)
  394. selected = visible[page.offset : page.offset + page.page_size]
  395. return page_result(
  396. [document_summary(child, context, access) for child, access in selected],
  397. page=page.page,
  398. page_size=page.page_size,
  399. total=total,
  400. )
  401. __all__ = [
  402. "DOCUMENT_SORTS",
  403. "_date",
  404. "_enum",
  405. "_iso",
  406. "_keyword",
  407. "_page",
  408. "_record_view",
  409. "_sort",
  410. "document_detail",
  411. "document_summary",
  412. "get_document",
  413. "list_documents",
  414. "list_sub_plans",
  415. "parse_string_id",
  416. ]