document.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. """主案、子方案和共享附件统一文档模型。"""
  2. from __future__ import annotations
  3. from datetime import datetime
  4. from sqlalchemy import (
  5. BigInteger,
  6. Boolean,
  7. CheckConstraint,
  8. ForeignKey,
  9. Index,
  10. Integer,
  11. JSON,
  12. String,
  13. Text,
  14. text,
  15. )
  16. from sqlalchemy.orm import Mapped, defer, mapped_column
  17. from sqlalchemy.dialects import mysql
  18. from dms.database.base import BusinessTableMixin
  19. from dms.extensions import db
  20. class Document(BusinessTableMixin, db.Model):
  21. __tablename__ = "doc_document"
  22. __table_args__ = (
  23. CheckConstraint(
  24. "document_type IN ('MAIN', 'SUB_PLAN', 'ATTACHMENT')",
  25. name="document_type",
  26. ),
  27. CheckConstraint(
  28. "document_status IN ('DRAFT', 'PUBLISHED', 'ARCHIVED')",
  29. name="document_status",
  30. ),
  31. CheckConstraint(
  32. "security_level IN "
  33. "('PUBLIC', 'INTERNAL', 'SECRET', 'CONFIDENTIAL', 'TOP_SECRET')",
  34. name="document_security_level",
  35. ),
  36. CheckConstraint(
  37. "attachment_type IS NULL OR attachment_type IN "
  38. "('SUPPORT_ACHIEVEMENT', 'TRAINING_MATERIAL', 'TYPICAL_CASE')",
  39. name="document_attachment_type",
  40. ),
  41. CheckConstraint(
  42. "content_extract_status IN "
  43. "('SUCCESS', 'EMPTY', 'UNSUPPORTED', 'FAILED')",
  44. name="document_content_extract_status",
  45. ),
  46. # 方案密码锁:仅主案可设密(password_hash 非空时其余类型被拒绝)
  47. CheckConstraint(
  48. "(password_hash IS NULL) OR (document_type = 'MAIN')",
  49. name="document_password_main_only",
  50. ),
  51. # shape v3(0026迁移对齐库内实际约束):附件允许任意密级(0020放宽)、
  52. # 可见范围维度下线(0026借阅改造,附件开放性由矩阵+密级控制)
  53. CheckConstraint(
  54. "("
  55. "document_type = 'MAIN' "
  56. "AND parent_document_id IS NULL "
  57. "AND category_id IS NOT NULL "
  58. "AND attachment_type IS NULL"
  59. ") OR ("
  60. "document_type = 'SUB_PLAN' "
  61. "AND parent_document_id IS NOT NULL "
  62. "AND root_document_id IS NOT NULL "
  63. "AND category_id IS NOT NULL "
  64. "AND attachment_type IS NULL"
  65. ") OR ("
  66. "document_type = 'ATTACHMENT' "
  67. "AND parent_document_id IS NULL "
  68. "AND root_document_id IS NULL "
  69. "AND category_id IS NULL "
  70. "AND attachment_type IS NOT NULL"
  71. ")",
  72. name="document_type_shape",
  73. ),
  74. Index(
  75. "ix_doc_document_type_active_updated",
  76. "document_type",
  77. "is_deleted",
  78. "updated_at",
  79. ),
  80. Index(
  81. "ix_doc_document_parent_type_active",
  82. "parent_document_id",
  83. "document_type",
  84. "is_deleted",
  85. ),
  86. Index(
  87. "ix_doc_document_root_active",
  88. "root_document_id",
  89. "is_deleted",
  90. ),
  91. Index(
  92. "ix_doc_document_category_active",
  93. "category_id",
  94. "is_deleted",
  95. ),
  96. Index(
  97. "ix_doc_document_organization_active",
  98. "organization_id",
  99. "is_deleted",
  100. ),
  101. Index(
  102. "ix_doc_document_status_active",
  103. "document_status",
  104. "is_deleted",
  105. ),
  106. Index(
  107. "ix_doc_document_security_active",
  108. "security_level",
  109. "is_deleted",
  110. ),
  111. Index("ix_doc_document_file_hash", "file_hash"),
  112. Index(
  113. "ix_doc_document_creator_active",
  114. "created_by",
  115. "is_deleted",
  116. ),
  117. Index(
  118. "ix_doc_document_attachment_type_active_updated",
  119. "attachment_type",
  120. "is_deleted",
  121. "updated_at",
  122. ),
  123. Index(
  124. "ix_doc_document_file_extension_active_updated",
  125. "file_extension",
  126. "is_deleted",
  127. "updated_at",
  128. ),
  129. Index(
  130. "ix_doc_document_recycle_deleted",
  131. "is_deleted",
  132. "deleted_at",
  133. "id",
  134. ),
  135. )
  136. document_name: Mapped[str] = mapped_column(String(255), nullable=False)
  137. summary: Mapped[str | None] = mapped_column(Text, nullable=True)
  138. # 详细说明:管理员在"查看"对话框维护,供普通用户阅读;不参与全文检索
  139. description: Mapped[str | None] = mapped_column(Text, nullable=True)
  140. document_type: Mapped[str] = mapped_column(String(32), nullable=False)
  141. document_status: Mapped[str] = mapped_column(
  142. String(32),
  143. nullable=False,
  144. server_default=text("'DRAFT'"),
  145. )
  146. security_level: Mapped[str] = mapped_column(
  147. String(32),
  148. nullable=False,
  149. server_default=text("'INTERNAL'"),
  150. )
  151. attachment_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
  152. category_id: Mapped[int | None] = mapped_column(
  153. BigInteger,
  154. ForeignKey("doc_category.id", ondelete="RESTRICT"),
  155. nullable=True,
  156. )
  157. category_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
  158. category_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
  159. organization_id: Mapped[int | None] = mapped_column(
  160. BigInteger,
  161. ForeignKey("sys_organization.id", ondelete="RESTRICT"),
  162. nullable=True,
  163. )
  164. organization_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
  165. parent_document_id: Mapped[int | None] = mapped_column(
  166. BigInteger,
  167. ForeignKey("doc_document.id", ondelete="RESTRICT"),
  168. nullable=True,
  169. )
  170. root_document_id: Mapped[int | None] = mapped_column(
  171. BigInteger,
  172. ForeignKey("doc_document.id", ondelete="RESTRICT"),
  173. nullable=True,
  174. )
  175. tags: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
  176. original_file_name: Mapped[str] = mapped_column(String(255), nullable=False)
  177. file_relative_path: Mapped[str] = mapped_column(String(1000), nullable=False)
  178. file_extension: Mapped[str] = mapped_column(String(20), nullable=False)
  179. mime_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
  180. file_size: Mapped[int] = mapped_column(
  181. BigInteger,
  182. nullable=False,
  183. server_default=text("0"),
  184. )
  185. file_hash: Mapped[str] = mapped_column(String(64), nullable=False)
  186. # 管理员拖动排序:主案范围内全局排序、子方案在所属主案内排序;步长10便于插入。
  187. sort_no: Mapped[int] = mapped_column(
  188. Integer,
  189. nullable=False,
  190. server_default=text("0"),
  191. )
  192. child_count: Mapped[int] = mapped_column(
  193. Integer,
  194. nullable=False,
  195. server_default=text("0"),
  196. )
  197. attachment_count: Mapped[int] = mapped_column(
  198. Integer,
  199. nullable=False,
  200. server_default=text("0"),
  201. )
  202. view_count: Mapped[int] = mapped_column(
  203. BigInteger,
  204. nullable=False,
  205. server_default=text("0"),
  206. )
  207. download_count: Mapped[int] = mapped_column(
  208. BigInteger,
  209. nullable=False,
  210. server_default=text("0"),
  211. )
  212. search_text: Mapped[str | None] = mapped_column(mysql.LONGTEXT, nullable=True)
  213. content_text: Mapped[str | None] = mapped_column(mysql.LONGTEXT, nullable=True)
  214. content_extract_status: Mapped[str] = mapped_column(
  215. String(32),
  216. nullable=False,
  217. server_default=text("'UNSUPPORTED'"),
  218. )
  219. # 提取失败原因(脱敏,仅供管理员排障展示;成功/空文本时为 NULL)
  220. content_extract_error: Mapped[str | None] = mapped_column(
  221. String(512),
  222. nullable=True,
  223. )
  224. content_extracted_at: Mapped[datetime | None] = mapped_column(
  225. mysql.DATETIME(fsp=3),
  226. nullable=True,
  227. )
  228. created_by_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
  229. updated_by_name: Mapped[str | None] = mapped_column(String(64), nullable=True)
  230. # 方案密码锁:Argon2id 哈希(NULL=未设密);保护位对普通用户生效,
  231. # 管理员对设密主案的除删除外全部操作均需密码(服务层判定)。
  232. password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
  233. protect_view: Mapped[bool] = mapped_column(
  234. Boolean,
  235. nullable=False,
  236. server_default=text("0"),
  237. )
  238. protect_download: Mapped[bool] = mapped_column(
  239. Boolean,
  240. nullable=False,
  241. server_default=text("0"),
  242. )
  243. protect_edit: Mapped[bool] = mapped_column(
  244. Boolean,
  245. nullable=False,
  246. server_default=text("0"),
  247. )
  248. protect_delete: Mapped[bool] = mapped_column(
  249. Boolean,
  250. nullable=False,
  251. server_default=text("0"),
  252. )
  253. protect_config_permission: Mapped[bool] = mapped_column(
  254. Boolean,
  255. nullable=False,
  256. server_default=text("0"),
  257. )
  258. protect_bind_attachment: Mapped[bool] = mapped_column(
  259. Boolean,
  260. nullable=False,
  261. server_default=text("0"),
  262. )
  263. protect_unbind_attachment: Mapped[bool] = mapped_column(
  264. Boolean,
  265. nullable=False,
  266. server_default=text("0"),
  267. )
  268. # FOR UPDATE 锁查询统一剔除 LOB:达梦含 LOB 行加锁代价极高(实测约 1.8s/次)。
  269. # 锁后确需正文的场景(如重建 search_text)由 defer 惰性补查,
  270. # 同事务行锁已持有,读取一致(对齐 Java 端 findContentTextById 补查语义)。
  271. LOCK_DEFER_LOB = (defer(Document.content_text), defer(Document.search_text))