document_query_service.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801
  1. """主案、子方案只读查询、DTO和查看审计。"""
  2. from __future__ import annotations
  3. import json
  4. import logging
  5. from datetime import datetime, timezone
  6. from typing import Any, Mapping
  7. from sqlalchemy import String, and_, cast, select
  8. from sqlalchemy.orm import defer
  9. from sqlalchemy.orm.attributes import set_committed_value
  10. from dms.common.enums import (
  11. AuditAction,
  12. AuditTarget,
  13. DocumentStatus,
  14. DocumentType,
  15. RoleCode,
  16. SecurityLevel,
  17. )
  18. from dms.common.errors import (
  19. DocumentViewForbiddenError,
  20. InvalidArgumentError,
  21. MainPlanNotFoundError,
  22. ResourceNotFoundError,
  23. SecurityLevelForbiddenError,
  24. )
  25. from dms.common.pagination import PageRequest, page_result
  26. from dms.common.response import serialize_id
  27. from dms.common.time_range import parse_datetime, parse_updated_range
  28. from dms.database.transaction import transaction
  29. from dms.extensions import db
  30. from dms.models import (
  31. LOCK_DEFER_LOB,
  32. AttachmentBinding,
  33. Category,
  34. Document,
  35. SubPlanBinding,
  36. )
  37. from dms.security.auth_context import AuthContext, get_auth_context
  38. from dms.services.audit_service import business_audit
  39. from dms.services.authorization_service import (
  40. AccessMemo,
  41. PlanAccess,
  42. _active_permissions,
  43. evaluate_plan_access,
  44. plan_allowed_actions,
  45. )
  46. from dms.services.document_password_service import (
  47. is_view_locked,
  48. protected_actions_of,
  49. view_password_verified,
  50. )
  51. logger = logging.getLogger(__name__)
  52. PLAN_TYPES = (DocumentType.MAIN.value, DocumentType.SUB_PLAN.value)
  53. # LOCK_DEFER_LOB 定义于 dms.models.document(供密码锁等下游服务复用,避免循环导入)。
  54. DOCUMENT_SORTS = {
  55. "documentName": Document.document_name,
  56. "createdAt": Document.created_at,
  57. "updatedAt": Document.updated_at,
  58. "viewCount": Document.view_count,
  59. "downloadCount": Document.download_count,
  60. "sortNo": Document.sort_no,
  61. }
  62. def parse_string_id(value: str, *, field: str = "id") -> int:
  63. if not value.isdecimal() or int(value) <= 0:
  64. raise InvalidArgumentError(f"{field}必须是正整数形式的字符串ID")
  65. return int(value)
  66. def _integer(
  67. params: Mapping[str, str],
  68. name: str,
  69. default: int,
  70. ) -> int:
  71. raw = params.get(name)
  72. if raw is None:
  73. return default
  74. if not raw.isdecimal():
  75. raise InvalidArgumentError(f"{name}必须是正整数")
  76. return int(raw)
  77. def _page(params: Mapping[str, str]) -> PageRequest:
  78. return PageRequest(
  79. page=_integer(params, "page", 1),
  80. page_size=_integer(params, "pageSize", 20),
  81. )
  82. def _enum(value: str | None, enum_type, name: str) -> str | None:
  83. if value is None or value == "":
  84. return None
  85. try:
  86. return enum_type(value).value
  87. except ValueError as exc:
  88. raise InvalidArgumentError(f"{name}不是有效枚举值") from exc
  89. def _date(value: str | None, name: str) -> datetime | None:
  90. return parse_datetime(value, name=name)
  91. def _sort(
  92. params: Mapping[str, str],
  93. *,
  94. allowed: dict[str, Any] = DOCUMENT_SORTS,
  95. default: str = "updatedAt",
  96. ) -> tuple[Any, str]:
  97. sort_by = params.get("sortBy", default)
  98. direction = params.get("sortDirection", "desc").lower()
  99. if sort_by not in allowed:
  100. raise InvalidArgumentError("sortBy不是允许的排序字段")
  101. if direction not in {"asc", "desc"}:
  102. raise InvalidArgumentError("sortDirection必须是asc或desc")
  103. column = allowed[sort_by]
  104. return (column.asc() if direction == "asc" else column.desc()), direction
  105. def _iso(value: datetime) -> str:
  106. return value.replace(tzinfo=timezone.utc).isoformat(timespec="milliseconds").replace(
  107. "+00:00", "Z"
  108. )
  109. def _permission_summary(
  110. access: PlanAccess,
  111. document: Document,
  112. memo: AccessMemo | None = None,
  113. ) -> tuple[dict[str, object], str]:
  114. """借阅授权摘要(契约1.19):当前生效的借阅人数与名单文本。
  115. 到期过滤与 evaluate_plan_access 惰性判定同口径(expires_at > now),
  116. 过期未清理的条目不计入摘要。
  117. """
  118. source = access.source
  119. if source is None:
  120. return {
  121. "borrowerCount": 0,
  122. "inheritedFromMainPlan": False,
  123. }, "未配置"
  124. now = datetime.now(timezone.utc).replace(tzinfo=None)
  125. borrowers = [
  126. item
  127. for item in _active_permissions(source.id, memo)
  128. if item.can_view and item.expires_at > now
  129. ]
  130. summary = (
  131. "、".join(item.subject_name for item in borrowers)
  132. if borrowers
  133. else "未配置"
  134. )
  135. return {
  136. "borrowerCount": len(borrowers),
  137. "inheritedFromMainPlan": document.document_type
  138. == DocumentType.SUB_PLAN.value,
  139. }, summary
  140. _SNIPPET_BEFORE = 40
  141. _SNIPPET_AFTER = 80
  142. def _content_snippet(content_text: str | None, keyword: str | None) -> str | None:
  143. """当 keyword 命中正文时,截取包含命中区域的片段。
  144. 前后各保留若干字符作为上下文;若未触达正文首尾则以"……"标识截断。
  145. 未命中或任一参数为空时返回 None。
  146. """
  147. if not content_text or not keyword or not keyword.strip():
  148. return None
  149. kw = keyword.strip()
  150. pos = content_text.lower().find(kw.lower())
  151. if pos < 0:
  152. return None
  153. start = max(pos - _SNIPPET_BEFORE, 0)
  154. end = min(pos + len(kw) + _SNIPPET_AFTER, len(content_text))
  155. snippet = content_text[start:end]
  156. if start > 0:
  157. snippet = "……" + snippet
  158. if end < len(content_text):
  159. snippet += "……"
  160. return snippet
  161. def _fill_content_text(documents: list[Document]) -> None:
  162. """批量补查正文:列表查询 defer 大字段后,仅当前页需要正文摘要时回填。
  163. 使用 set_committed_value 预填 deferred 属性,既不触发逐行 lazy load,
  164. 也不将实例标记为 dirty(避免误产生 UPDATE)。
  165. """
  166. if not documents:
  167. return
  168. by_id = {document.id: document for document in documents}
  169. rows = db.session.execute(
  170. select(Document.id, Document.content_text).where(Document.id.in_(by_id))
  171. ).all()
  172. for document_id, content_text in rows:
  173. set_committed_value(by_id[document_id], "content_text", content_text)
  174. def document_summary(
  175. document: Document,
  176. context: AuthContext,
  177. access: PlanAccess,
  178. keyword: str | None = None,
  179. memo: AccessMemo | None = None,
  180. ) -> dict[str, object]:
  181. # 无权限方案也在列表展示(需求:全员可见列表),但借阅名单属敏感信息,
  182. # borrowSummary 以占位符返回;正文片段同样只在有权限时生成。
  183. if access.allowed:
  184. _, borrow_summary = _permission_summary(access, document, memo)
  185. else:
  186. borrow_summary = "-"
  187. view_locked = is_view_locked(document, context)
  188. result: dict[str, object] = {
  189. "id": serialize_id(document.id),
  190. "documentName": document.document_name,
  191. "summary": document.summary,
  192. "description": document.description if access.allowed else None,
  193. "documentType": document.document_type,
  194. "status": document.document_status,
  195. "securityLevel": document.security_level,
  196. "borrowSummary": borrow_summary,
  197. "attachmentType": document.attachment_type,
  198. "categoryId": serialize_id(document.category_id),
  199. "categoryName": document.category_name,
  200. "categoryPath": document.category_path,
  201. "organizationId": serialize_id(document.organization_id),
  202. "organizationName": document.organization_name,
  203. "parentDocumentId": serialize_id(document.parent_document_id),
  204. "rootDocumentId": serialize_id(
  205. document.id
  206. if document.document_type == DocumentType.MAIN.value
  207. else document.root_document_id
  208. ),
  209. "tags": document.tags or [],
  210. "fileExtension": document.file_extension,
  211. "childCount": document.child_count,
  212. "attachmentCount": document.attachment_count,
  213. "viewCount": document.view_count,
  214. "downloadCount": document.download_count,
  215. "createdByName": document.created_by_name,
  216. "createdAt": _iso(document.created_at),
  217. "updatedAt": _iso(document.updated_at),
  218. "rowVersion": document.row_version,
  219. "allowedActions": plan_allowed_actions(document, context, access),
  220. "hasPassword": document.password_hash is not None,
  221. "passwordProtectedActions": protected_actions_of(document),
  222. "viewLocked": view_locked,
  223. }
  224. # 列表查询 defer 了 content_text:仅在有正文关键词时才访问(此时已批量回填),
  225. # 无关键词时 _content_snippet 本就返回 None,守卫不改变契约且避免逐行 lazy load。
  226. if not view_locked and keyword:
  227. snippet = _content_snippet(document.content_text, keyword)
  228. if snippet is not None:
  229. result["contentSnippet"] = snippet
  230. return result
  231. def document_detail(
  232. document: Document,
  233. context: AuthContext,
  234. access: PlanAccess,
  235. ) -> dict[str, object]:
  236. result = document_summary(document, context, access)
  237. permission_summary, _ = _permission_summary(access, document)
  238. verified = view_password_verified(document, context)
  239. result.update(
  240. {
  241. "originalFileName": document.original_file_name,
  242. "mimeType": document.mime_type,
  243. "fileSize": document.file_size,
  244. "fileHash": document.file_hash,
  245. "permissionSummary": permission_summary,
  246. "viewPasswordVerified": verified,
  247. "contentText": document.content_text if verified else None,
  248. }
  249. )
  250. # 提取状态/失败原因仅供管理员排障("编辑"界面提示与重新解析)
  251. if context.role_code == RoleCode.ADMIN:
  252. result.update(
  253. {
  254. "contentExtractStatus": document.content_extract_status,
  255. "contentExtractError": document.content_extract_error,
  256. "contentExtractedAt": (
  257. _iso(document.content_extracted_at)
  258. if document.content_extracted_at
  259. else None
  260. ),
  261. }
  262. )
  263. return result
  264. def _require_plan_access(document: Document, context: AuthContext) -> PlanAccess:
  265. access = evaluate_plan_access(document, context)
  266. if access.allowed:
  267. return access
  268. if access.reason == "SECURITY":
  269. raise SecurityLevelForbiddenError()
  270. raise DocumentViewForbiddenError()
  271. def _escape_like(keyword: str) -> str:
  272. return keyword.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
  273. def _title_keyword(statement, keyword: str | None):
  274. """简单关键词:仅模糊匹配标题(document_name)。"""
  275. if not keyword or not keyword.strip():
  276. return statement
  277. pattern = f"%{_escape_like(keyword)}%"
  278. return statement.where(Document.document_name.like(pattern, escape="\\"))
  279. def _security_levels(params: Mapping[str, str]) -> list[str] | None:
  280. """解析 ``securityLevels`` 逗号分隔多选(勾选项内 OR)。"""
  281. raw = params.get("securityLevels")
  282. if raw is None or not raw.strip():
  283. return None
  284. values = [item.strip() for item in raw.split(",") if item.strip()]
  285. if not values:
  286. return None
  287. for item in values:
  288. try:
  289. SecurityLevel(item)
  290. except ValueError as exc:
  291. raise InvalidArgumentError("securityLevels包含无效密级值") from exc
  292. if len(set(values)) != len(values):
  293. raise InvalidArgumentError("securityLevels不允许重复值")
  294. return values
  295. def _advanced_filters(statement, params: Mapping[str, str]):
  296. """高级检索三条件(正文关键词 / 密级多选 / 部门模糊),全部 AND 叠加。"""
  297. content_keyword = params.get("contentKeyword")
  298. if content_keyword and content_keyword.strip():
  299. pattern = f"%{_escape_like(content_keyword)}%"
  300. # 正文关键词严格匹配提取正文:search_text 含名称/概述/标签兜底词,
  301. # OR 匹配会把正文解析失败(content_text 为空)但名称撞词的方案
  302. # 错误召回,也会放大"仅元数据命中"的误报。
  303. statement = statement.where(
  304. Document.content_text.like(pattern, escape="\\")
  305. )
  306. levels = _security_levels(params)
  307. if levels:
  308. statement = statement.where(Document.security_level.in_(levels))
  309. organization_keyword = params.get("organizationKeyword")
  310. if organization_keyword and organization_keyword.strip():
  311. pattern = f"%{_escape_like(organization_keyword)}%"
  312. statement = statement.where(
  313. Document.organization_name.like(pattern, escape="\\")
  314. )
  315. return statement
  316. def _title_contains(document_name: str | None, keyword: str) -> bool:
  317. return keyword.lower() in (document_name or "").lower()
  318. def _child_title_hit_counts(
  319. context: AuthContext,
  320. main_ids: list[int],
  321. keyword: str,
  322. memo: AccessMemo | None = None,
  323. ) -> dict[int, list[int]]:
  324. """统计各主案下标题命中简单关键词的可见子案/附件数量。
  325. - 子案经 ``evaluate_plan_access`` 逐条校验,不可见的命中不计入;
  326. - 附件随挂载主案权限可见(主案本身已通过校验),直接计数;
  327. - 返回 ``{main_id: [子案命中数, 附件命中数]}``。
  328. """
  329. if not main_ids or not keyword.strip():
  330. return {}
  331. pattern = f"%{_escape_like(keyword)}%"
  332. counts: dict[int, list[int]] = {main_id: [0, 0] for main_id in main_ids}
  333. children = db.session.scalars(
  334. select(Document)
  335. .where(
  336. Document.parent_document_id.in_(main_ids),
  337. Document.document_type == DocumentType.SUB_PLAN.value,
  338. Document.is_deleted.is_(False),
  339. Document.document_name.like(pattern, escape="\\"),
  340. )
  341. .options(defer(Document.content_text), defer(Document.search_text))
  342. ).all()
  343. for child in children:
  344. if evaluate_plan_access(child, context, memo).allowed:
  345. counts[child.parent_document_id][0] += 1
  346. bindings = db.session.execute(
  347. select(AttachmentBinding.main_document_id, Document.id)
  348. .join(Document, Document.id == AttachmentBinding.attachment_document_id)
  349. .where(
  350. AttachmentBinding.main_document_id.in_(main_ids),
  351. AttachmentBinding.is_deleted.is_(False),
  352. Document.document_type == DocumentType.ATTACHMENT.value,
  353. Document.is_deleted.is_(False),
  354. Document.document_name.like(pattern, escape="\\"),
  355. )
  356. ).all()
  357. for main_id, _ in bindings:
  358. counts[main_id][1] += 1
  359. return counts
  360. def _tags_filter(
  361. statement,
  362. tags_value: str | None,
  363. match_value: str | None,
  364. ):
  365. """按 ``tags`` JSON 数组精确匹配;``tagsMatch=any|all`` 决定 OR/AND 语义。"""
  366. if not tags_value or not tags_value.strip():
  367. return statement
  368. tags = [item.strip() for item in tags_value.split(",") if item.strip()]
  369. if not tags:
  370. return statement
  371. match = (match_value or "any").strip().lower()
  372. if match not in {"any", "all"}:
  373. raise InvalidArgumentError("tagsMatch只允许any或all")
  374. # 达梦无 JSON_CONTAINS:对 JSON 编码后的标签(含引号边界)做 LIKE 包含。
  375. # ensure_ascii=False 保持原文中文,与达梦存量数据及 Java 版写入格式一致;
  376. # _escape_like 处理 %、_ 与反斜杠。
  377. conditions = [
  378. Document.tags.like(
  379. f"%{_escape_like(json.dumps(tag, ensure_ascii=False))}%", escape="\\"
  380. )
  381. for tag in tags
  382. ]
  383. combiner = and_ if match == "all" else or_
  384. return statement.where(combiner(*conditions))
  385. def _boolean(value: str | None, name: str, default: bool = False) -> bool:
  386. if value is None:
  387. return default
  388. if value not in {"true", "false"}:
  389. raise InvalidArgumentError(f"{name}必须是true或false")
  390. return value == "true"
  391. def _category_scope(category_id: int, include_descendants: bool) -> set[int]:
  392. categories = db.session.scalars(
  393. select(Category).where(
  394. Category.is_deleted.is_(False),
  395. Category.status == "ENABLED",
  396. )
  397. ).all()
  398. if not any(category.id == category_id for category in categories):
  399. raise ResourceNotFoundError("方案分类不存在或不可用")
  400. if not include_descendants:
  401. return {category_id}
  402. children: dict[int | None, list[int]] = {}
  403. for category in categories:
  404. children.setdefault(category.parent_id, []).append(category.id)
  405. result = {category_id}
  406. pending = [category_id]
  407. while pending:
  408. current = pending.pop()
  409. for child_id in children.get(current, []):
  410. if child_id not in result:
  411. result.add(child_id)
  412. pending.append(child_id)
  413. return result
  414. def list_documents(params: Mapping[str, str]) -> dict[str, object]:
  415. context = get_auth_context()
  416. page = _page(params)
  417. raw_types = params.get("documentType", "MAIN")
  418. document_types = [item.strip() for item in raw_types.split(",") if item.strip()]
  419. if (
  420. not document_types
  421. or any(item not in PLAN_TYPES for item in document_types)
  422. or len(set(document_types)) != len(document_types)
  423. ):
  424. raise InvalidArgumentError("documentType只允许MAIN、SUB_PLAN或二者组合")
  425. statement = select(Document).options(
  426. # 列表主体查询不携带正文大字段;仅 contentKeyword 检索生成当前页
  427. # 摘要时按页 ID 批量补查(_fill_content_text)。
  428. defer(Document.content_text),
  429. defer(Document.search_text),
  430. ).where(
  431. Document.is_deleted.is_(False),
  432. Document.document_type.in_(document_types),
  433. )
  434. include_descendants = _boolean(
  435. params.get("includeDescendants"), "includeDescendants"
  436. )
  437. category_id = params.get("categoryId")
  438. if category_id:
  439. parsed_category_id = parse_string_id(category_id, field="categoryId")
  440. statement = statement.where(
  441. Document.category_id.in_(
  442. _category_scope(parsed_category_id, include_descendants)
  443. )
  444. )
  445. aggregate = _boolean(params.get("aggregateHits"), "aggregateHits")
  446. if aggregate and document_types != ["MAIN"]:
  447. raise InvalidArgumentError("aggregateHits只允许在documentType=MAIN时使用")
  448. if not aggregate:
  449. statement = _title_keyword(statement, params.get("keyword"))
  450. statement = _advanced_filters(statement, params)
  451. statement = _tags_filter(
  452. statement, params.get("tags"), params.get("tagsMatch")
  453. )
  454. for name, enum_type, column in (
  455. ("securityLevel", SecurityLevel, Document.security_level),
  456. ("status", DocumentStatus, Document.document_status),
  457. ):
  458. value = _enum(params.get(name), enum_type, name)
  459. if value:
  460. statement = statement.where(column == value)
  461. updated_from, updated_to = parse_updated_range(params)
  462. if updated_from:
  463. statement = statement.where(Document.updated_at >= updated_from)
  464. if updated_to:
  465. statement = statement.where(Document.updated_at <= updated_to)
  466. order, _ = _sort(params)
  467. documents = db.session.scalars(statement.order_by(order, Document.id.asc())).all()
  468. # 列表全量可见(需求3):无权限方案同样返回,allowedActions 为空集,
  469. # 前端据此禁用查看类按钮;预览/详情/下载接口仍独立拦截。
  470. memo = AccessMemo()
  471. listed: list[tuple[Document, PlanAccess]] = []
  472. for document in documents:
  473. access = evaluate_plan_access(document, context, memo)
  474. listed.append((document, access))
  475. total = len(listed)
  476. keyword = (params.get("keyword") or "").strip()
  477. hit_counts: dict[int, list[int]] = {}
  478. if aggregate:
  479. if keyword:
  480. # 子案/附件命中统计只覆盖有权限主案,避免借计数泄露无权限主案的挂载信息。
  481. hit_counts = _child_title_hit_counts(
  482. context, [document.id for document, access in listed if access.allowed], keyword, memo
  483. )
  484. listed = [
  485. (document, access)
  486. for document, access in listed
  487. if _title_contains(document.document_name, keyword)
  488. or any(hit_counts.get(document.id))
  489. ]
  490. total = len(listed)
  491. selected = listed[page.offset : page.offset + page.page_size]
  492. content_keyword = params.get("contentKeyword")
  493. if content_keyword and content_keyword.strip():
  494. # 仅当前页有权行需要正文摘要,按页 ID 批量补查(避免逐行 lazy load)。
  495. _fill_content_text(
  496. [document for document, access in selected if access.allowed]
  497. )
  498. items = [
  499. document_summary(
  500. document, context, access,
  501. keyword=content_keyword if access.allowed else None,
  502. memo=memo,
  503. )
  504. for document, access in selected
  505. ]
  506. if aggregate:
  507. for item, (document, access) in zip(items, selected, strict=True):
  508. counts = hit_counts.get(document.id, [0, 0]) if access.allowed else [0, 0]
  509. item["titleHit"] = not keyword or _title_contains(
  510. document.document_name, keyword
  511. )
  512. item["subHitCount"] = counts[0]
  513. item["attachmentHitCount"] = counts[1]
  514. return page_result(
  515. items,
  516. page=page.page,
  517. page_size=page.page_size,
  518. total=total,
  519. )
  520. def _active_plan(document_id: int) -> Document:
  521. document = db.session.scalar(
  522. select(Document).where(
  523. Document.id == document_id,
  524. Document.is_deleted.is_(False),
  525. Document.document_type.in_(PLAN_TYPES),
  526. )
  527. )
  528. if document is None:
  529. raise ResourceNotFoundError("方案文档不存在")
  530. return document
  531. def _record_view(document: Document) -> int:
  532. old_count = document.view_count
  533. document_id = document.id
  534. document_name = document.document_name
  535. target = (
  536. AuditTarget.ATTACHMENT
  537. if document.document_type == DocumentType.ATTACHMENT.value
  538. else AuditTarget.DOCUMENT
  539. )
  540. db.session.rollback()
  541. try:
  542. with transaction() as session:
  543. current = session.scalar(
  544. select(Document)
  545. .where(
  546. Document.id == document_id,
  547. Document.is_deleted.is_(False),
  548. )
  549. .options(*LOCK_DEFER_LOB)
  550. .with_for_update()
  551. )
  552. if current is None:
  553. raise ResourceNotFoundError("文档不存在")
  554. current.view_count += 1
  555. session.add(
  556. business_audit(
  557. action=AuditAction.VIEW_DOCUMENT,
  558. target=target,
  559. target_id=document_id,
  560. target_name=document_name,
  561. detail={"documentType": current.document_type},
  562. )
  563. )
  564. new_count = current.view_count
  565. return new_count
  566. except Exception:
  567. db.session.rollback()
  568. logger.exception("文档查看计数或审计写入失败:document_id=%s", document_id)
  569. return old_count
  570. def get_document(document_id: int) -> dict[str, object]:
  571. context = get_auth_context()
  572. document = _active_plan(document_id)
  573. access = _require_plan_access(document, context)
  574. result = document_detail(document, context, access)
  575. result["viewCount"] = _record_view(document)
  576. return result
  577. def list_sub_plans(
  578. main_document_id: int,
  579. params: Mapping[str, str],
  580. ) -> dict[str, object]:
  581. context = get_auth_context()
  582. main = db.session.scalar(
  583. select(Document).where(
  584. Document.id == main_document_id,
  585. Document.document_type == DocumentType.MAIN.value,
  586. Document.is_deleted.is_(False),
  587. )
  588. )
  589. if main is None:
  590. raise MainPlanNotFoundError()
  591. # 主案无权限时也返回子案列表(列表全量可见,与主案列表语义一致);
  592. # 子案权限继承自原主案(root_document_id 不变),allowedActions 自然为
  593. # 空集,内容由预览/详情接口拦截。
  594. memo = AccessMemo()
  595. evaluate_plan_access(main, context, memo)
  596. page = _page(params)
  597. def _apply_filters(statement):
  598. statement = _title_keyword(statement, params.get("keyword"))
  599. statement = _advanced_filters(statement, params)
  600. status = _enum(params.get("status"), DocumentStatus, "status")
  601. if status:
  602. statement = statement.where(Document.document_status == status)
  603. updated_from, updated_to = parse_updated_range(params)
  604. if updated_from:
  605. statement = statement.where(Document.updated_at >= updated_from)
  606. if updated_to:
  607. statement = statement.where(Document.updated_at <= updated_to)
  608. return statement
  609. order, _ = _sort(params)
  610. # 自有子案:parent_document_id 指向该主案,按请求排序(默认 sortNo)。
  611. own_children = db.session.scalars(
  612. _apply_filters(
  613. select(Document).where(
  614. Document.parent_document_id == main.id,
  615. Document.document_type == DocumentType.SUB_PLAN.value,
  616. Document.is_deleted.is_(False),
  617. )
  618. )
  619. .order_by(order, Document.id.asc())
  620. .options(defer(Document.content_text), defer(Document.search_text))
  621. ).all()
  622. # 挂载子案:其他主案的子案经 doc_sub_plan_binding 建立展示关系,
  623. # 按挂载顺序(binding.sort_no)固定追加在自有子案之后,不参与拖动排序。
  624. mount_rows = db.session.execute(
  625. select(SubPlanBinding, Document)
  626. .join(Document, Document.id == SubPlanBinding.sub_document_id)
  627. .where(
  628. SubPlanBinding.main_document_id == main.id,
  629. SubPlanBinding.is_deleted.is_(False),
  630. Document.is_deleted.is_(False),
  631. Document.document_type == DocumentType.SUB_PLAN.value,
  632. )
  633. .order_by(SubPlanBinding.sort_no.asc(), SubPlanBinding.id.asc())
  634. .options(defer(Document.content_text), defer(Document.search_text))
  635. ).all()
  636. mounted: list[tuple[SubPlanBinding, Document]] = []
  637. if mount_rows:
  638. filtered_ids = set(
  639. db.session.scalars(
  640. _apply_filters(
  641. select(Document.id).where(
  642. Document.id.in_([doc.id for _, doc in mount_rows])
  643. )
  644. )
  645. ).all()
  646. )
  647. mounted = [row for row in mount_rows if row[0].sub_document_id in filtered_ids]
  648. merged: list[tuple[Document, int | None]] = [
  649. *[(child, None) for child in own_children],
  650. *[(doc, binding.id) for binding, doc in mounted],
  651. ]
  652. total = len(merged)
  653. selected = merged[page.offset : page.offset + page.page_size]
  654. # 逐条评估子案权限(memo 复用同根主案与授权列表缓存)。
  655. accesses = [evaluate_plan_access(child, context, memo) for child, _ in selected]
  656. content_keyword = params.get("contentKeyword")
  657. if content_keyword and content_keyword.strip():
  658. # 仅当前页有权行需要正文摘要,按页 ID 批量补查(避免逐行 lazy load)。
  659. _fill_content_text(
  660. [child for (child, _), access in zip(selected, accesses) if access.allowed]
  661. )
  662. items: list[dict[str, object]] = []
  663. for (child, mount_id), access in zip(selected, accesses):
  664. summary = document_summary(
  665. child, context, access,
  666. keyword=content_keyword if access.allowed else None,
  667. memo=memo,
  668. )
  669. if mount_id is not None:
  670. summary["mountId"] = serialize_id(mount_id)
  671. items.append(summary)
  672. return page_result(
  673. items,
  674. page=page.page,
  675. page_size=page.page_size,
  676. total=total,
  677. )
  678. def list_main_plan_tags() -> dict[str, object]:
  679. """聚合当前用户可见的所有主案标签。
  680. - 全局视角:标签集合不随分类、关键词等其他筛选变化;
  681. - 权限:通过 ``evaluate_plan_access`` 在 Python 层逐条过滤,
  682. 与 ``list_documents`` 保持一致的可见性语义,避免泄露被
  683. ACL/密级隔离的主案标签;
  684. - 顺序:按 Unicode 升序,便于前端稳定渲染。
  685. """
  686. context = get_auth_context()
  687. statement = select(Document).options(
  688. defer(Document.content_text),
  689. defer(Document.search_text),
  690. ).where(
  691. Document.is_deleted.is_(False),
  692. Document.document_type == DocumentType.MAIN.value,
  693. )
  694. documents = db.session.scalars(statement).all()
  695. tags_set: set[str] = set()
  696. memo = AccessMemo()
  697. for document in documents:
  698. access = evaluate_plan_access(document, context, memo)
  699. if not access.allowed:
  700. continue
  701. if document.tags:
  702. tags_set.update(document.tags)
  703. tags = sorted(tags_set)
  704. return {"items": tags, "total": len(tags)}
  705. __all__ = [
  706. "DOCUMENT_SORTS",
  707. "LOCK_DEFER_LOB",
  708. "_advanced_filters",
  709. "_content_snippet",
  710. "_date",
  711. "_escape_like",
  712. "_fill_content_text",
  713. "_enum",
  714. "_iso",
  715. "_page",
  716. "_record_view",
  717. "_sort",
  718. "_tags_filter",
  719. "_title_keyword",
  720. "document_detail",
  721. "document_summary",
  722. "get_document",
  723. "list_documents",
  724. "list_main_plan_tags",
  725. "list_sub_plans",
  726. "parse_string_id",
  727. ]