document_service.py 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. import re
  2. from datetime import datetime, timezone
  3. from sqlalchemy import func, select
  4. from sqlalchemy.ext.asyncio import AsyncSession
  5. from app.core.exceptions import ContentTooLargeError, DocumentNotFoundError
  6. from app.models.document import Document
  7. from app.schemas.document import CreateDocumentRequest, UpdateDocumentRequest
  8. CONTENT_MAX_BYTES = 200_000 # 200KB
  9. class DocumentService:
  10. def __init__(self, db: AsyncSession) -> None:
  11. self.db = db
  12. # ------------------------------------------------------------------ #
  13. # CREATE
  14. # ------------------------------------------------------------------ #
  15. async def create_document(
  16. self,
  17. data: CreateDocumentRequest,
  18. user_id: str | None = None,
  19. ) -> Document:
  20. size = len(data.content.encode("utf-8"))
  21. if size > CONTENT_MAX_BYTES:
  22. raise ContentTooLargeError(size)
  23. doc = Document(
  24. title=data.title,
  25. content=data.content,
  26. format=data.format,
  27. session_id=data.session_id,
  28. template_id=data.template_id,
  29. created_by=user_id,
  30. )
  31. self.db.add(doc)
  32. await self.db.commit()
  33. await self.db.refresh(doc)
  34. return doc
  35. # ------------------------------------------------------------------ #
  36. # READ
  37. # ------------------------------------------------------------------ #
  38. async def get_document(self, document_id: str) -> Document:
  39. result = await self.db.execute(
  40. select(Document).where(Document.id == document_id)
  41. )
  42. doc = result.scalar_one_or_none()
  43. if doc is None:
  44. raise DocumentNotFoundError(document_id)
  45. return doc
  46. async def list_documents(
  47. self,
  48. page: int = 1,
  49. page_size: int = 20,
  50. session_id: str | None = None,
  51. sort_by: str = "updated_at",
  52. sort_order: str = "desc",
  53. ) -> tuple[list[Document], int]:
  54. query = select(Document)
  55. if session_id:
  56. query = query.where(Document.session_id == session_id)
  57. # 排序
  58. sort_col = getattr(Document, sort_by, Document.updated_at)
  59. if sort_order == "asc":
  60. query = query.order_by(sort_col.asc())
  61. else:
  62. query = query.order_by(sort_col.desc())
  63. # 总数:SELECT COUNT(*) 而非拉全部 id 再 len()
  64. count_q = select(func.count()).select_from(query.subquery())
  65. total: int = (await self.db.execute(count_q)).scalar_one()
  66. # 分页
  67. offset = (page - 1) * page_size
  68. result = await self.db.execute(query.offset(offset).limit(page_size))
  69. docs = list(result.scalars().all())
  70. return docs, total
  71. # ------------------------------------------------------------------ #
  72. # UPDATE
  73. # ------------------------------------------------------------------ #
  74. async def update_document(
  75. self, document_id: str, data: UpdateDocumentRequest
  76. ) -> Document:
  77. doc = await self.get_document(document_id)
  78. if data.title is not None:
  79. doc.title = data.title
  80. if data.content is not None:
  81. size = len(data.content.encode("utf-8"))
  82. if size > CONTENT_MAX_BYTES:
  83. raise ContentTooLargeError(size)
  84. doc.content = data.content
  85. if data.blocks is not None:
  86. doc.content = self._apply_block_updates(doc.content, data.blocks)
  87. doc.updated_at = datetime.now(timezone.utc)
  88. await self.db.commit()
  89. await self.db.refresh(doc)
  90. return doc
  91. # ------------------------------------------------------------------ #
  92. # DELETE
  93. # ------------------------------------------------------------------ #
  94. async def delete_document(self, document_id: str) -> None:
  95. doc = await self.get_document(document_id)
  96. await self.db.delete(doc)
  97. await self.db.commit()
  98. # ------------------------------------------------------------------ #
  99. # 局部块更新:按 level + index 定位并替换对应的标题块
  100. # ------------------------------------------------------------------ #
  101. @staticmethod
  102. def _apply_block_updates(content: str, blocks: list) -> str:
  103. """
  104. 将文档按标题行拆分成若干块,按 level+index 替换对应块后重新拼接。
  105. 块的定义:以标题行(# / ## / ...)为分割点,
  106. 每个标题及其下属正文构成一个块。
  107. """
  108. lines = content.split("\n")
  109. # 找出所有标题行的位置
  110. heading_pattern = re.compile(r"^(#{1,6})\s+")
  111. heading_positions: list[tuple[int, int]] = [] # (line_index, level)
  112. for i, line in enumerate(lines):
  113. m = heading_pattern.match(line)
  114. if m:
  115. level = len(m.group(1))
  116. heading_positions.append((i, level))
  117. # 统计每个 level 已出现的次数,得到 index
  118. level_counter: dict[int, int] = {}
  119. heading_info: list[tuple[int, int, int]] = [] # (line_idx, level, index)
  120. for line_idx, level in heading_positions:
  121. idx = level_counter.get(level, 0)
  122. heading_info.append((line_idx, level, idx))
  123. level_counter[level] = idx + 1
  124. # 将 lines 拆成块
  125. # 块边界 = 各标题行的 line_idx
  126. split_points = sorted({line_idx for line_idx, _, _ in heading_info})
  127. split_points.append(len(lines)) # 末尾哨兵
  128. # 前置正文(首个标题之前的内容)
  129. pre_content_end = split_points[0] if split_points else len(lines)
  130. chunks: list[str] = ["\n".join(lines[:pre_content_end])]
  131. # 各标题块,同时构建 (level, index) → chunk_idx 映射,O(1) 定位
  132. key_to_chunk: dict[tuple[int, int], int] = {}
  133. for i, sp in enumerate(split_points[:-1]):
  134. end = split_points[i + 1]
  135. chunks.append("\n".join(lines[sp:end]))
  136. _, level, index = heading_info[i]
  137. key_to_chunk[(level, index)] = len(chunks) - 1
  138. # 执行替换
  139. for block_update in blocks:
  140. key = (block_update.level, block_update.index)
  141. if key in key_to_chunk:
  142. chunks[key_to_chunk[key]] = block_update.content
  143. return "\n".join(chunks).strip() + "\n"