document_query_service.py 27 KB

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