documents.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. """主案、子方案和共享附件只读 HTTP 接口。"""
  2. from __future__ import annotations
  3. import json
  4. import logging
  5. from flask import current_app, request
  6. from dms.api.v1.blueprint import api_v1
  7. from dms.common.errors import (
  8. BatchManifestMismatchError,
  9. DmsError,
  10. InvalidArgumentError,
  11. PayloadTooLargeError,
  12. )
  13. from dms.common.response import success_response
  14. from dms.extensions import db
  15. from dms.security.decorators import admin_required, bearer_auth_required
  16. from dms.services.attachment_query_service import (
  17. get_attachment,
  18. list_attachment_main_plans,
  19. list_attachments,
  20. list_main_plan_attachments,
  21. )
  22. from dms.services.attachment_binding_service import (
  23. bind_attachments,
  24. reorder_main_plan_attachments,
  25. unbind_attachment,
  26. )
  27. from dms.services.document_query_service import (
  28. get_document,
  29. list_documents,
  30. list_main_plan_tags,
  31. list_sub_plans,
  32. parse_string_id,
  33. )
  34. from dms.services.document_relation_service import (
  35. list_document_relations,
  36. save_document_relations,
  37. )
  38. from dms.services.document_mutation_service import (
  39. create_attachment,
  40. create_document,
  41. delete_attachment,
  42. delete_document,
  43. re_extract_content,
  44. reorder_documents,
  45. update_attachment,
  46. update_document,
  47. update_document_description,
  48. )
  49. from dms.services.document_password_service import save_document_password
  50. from dms.services.file_read_service import download_document, preview_document
  51. from dms.services.permission_service import get_permissions, save_permissions
  52. from dms.services.sub_plan_binding_service import (
  53. bind_sub_plans,
  54. unbind_sub_plan,
  55. )
  56. logger = logging.getLogger(__name__)
  57. def _metadata() -> dict:
  58. if set(request.files) != {"file"} or set(request.form) != {"metadata"}:
  59. raise InvalidArgumentError("请求必须且只能包含file和metadata")
  60. try:
  61. payload = json.loads(request.form["metadata"])
  62. except (json.JSONDecodeError, TypeError) as exc:
  63. raise InvalidArgumentError("metadata必须是JSON对象字符串") from exc
  64. if not isinstance(payload, dict):
  65. raise InvalidArgumentError("metadata必须是JSON对象字符串")
  66. return payload
  67. def _json_body() -> dict:
  68. if not request.is_json:
  69. raise InvalidArgumentError("请求体必须是JSON对象")
  70. payload = request.get_json(silent=True)
  71. if not isinstance(payload, dict):
  72. raise InvalidArgumentError("请求体必须是JSON对象")
  73. return payload
  74. def _row_version() -> int:
  75. value = request.args.get("rowVersion")
  76. if value is None or not value.isdecimal():
  77. raise InvalidArgumentError("rowVersion必须是非负整数")
  78. return int(value)
  79. def _main_plan_row_version() -> int:
  80. if set(request.args) != {"mainPlanRowVersion"}:
  81. raise InvalidArgumentError(
  82. "查询参数必须且只能包含mainPlanRowVersion"
  83. )
  84. value = request.args.get("mainPlanRowVersion")
  85. if value is None or not value.isdecimal():
  86. raise InvalidArgumentError("mainPlanRowVersion必须是非负整数")
  87. return int(value)
  88. def _batch(*, attachment: bool):
  89. if set(request.files) != {"files"} or set(request.form) != {"items"}:
  90. raise InvalidArgumentError("批量请求必须且只能包含files和items")
  91. files = request.files.getlist("files")
  92. try:
  93. items = json.loads(request.form["items"])
  94. except (json.JSONDecodeError, TypeError) as exc:
  95. raise InvalidArgumentError("items必须是JSON数组字符串") from exc
  96. if not isinstance(items, list):
  97. raise InvalidArgumentError("items必须是JSON数组字符串")
  98. if len(files) != len(items):
  99. raise BatchManifestMismatchError()
  100. if not files:
  101. raise InvalidArgumentError("批量文件不能为空")
  102. if len(files) > current_app.config["DMS_BATCH_MAX_FILES"]:
  103. raise PayloadTooLargeError("批量文件数量超过允许上限")
  104. results = []
  105. success_count = 0
  106. for index, (file, payload) in enumerate(zip(files, items, strict=True)):
  107. original_name = file.filename or ""
  108. try:
  109. document = (
  110. create_attachment(file, payload, batch=True)
  111. if attachment
  112. else create_document(file, payload, batch=True)
  113. )
  114. results.append(
  115. {
  116. "index": index,
  117. "originalFileName": original_name,
  118. "success": True,
  119. "document": document,
  120. }
  121. )
  122. success_count += 1
  123. except DmsError as exc:
  124. db.session.rollback()
  125. results.append(
  126. {
  127. "index": index,
  128. "originalFileName": original_name,
  129. "success": False,
  130. "errorCode": exc.code,
  131. "errorMessage": exc.message,
  132. }
  133. )
  134. except Exception:
  135. db.session.rollback()
  136. logger.exception("批量导入单项发生未处理异常:index=%s", index)
  137. results.append(
  138. {
  139. "index": index,
  140. "originalFileName": original_name,
  141. "success": False,
  142. "errorCode": "INTERNAL_ERROR",
  143. "errorMessage": "服务器内部错误",
  144. }
  145. )
  146. return {
  147. "total": len(files),
  148. "successCount": success_count,
  149. "failureCount": len(files) - success_count,
  150. "items": results,
  151. }
  152. @api_v1.get("/documents")
  153. @bearer_auth_required
  154. def documents_list_route():
  155. return success_response(list_documents(request.args))
  156. @api_v1.get("/documents/tags")
  157. @bearer_auth_required
  158. def documents_tags_route():
  159. """聚合当前用户可见的主案标签,供前端左侧标签筛选条使用。"""
  160. return success_response(list_main_plan_tags())
  161. @api_v1.get("/documents/relation-presets")
  162. @bearer_auth_required
  163. def document_relation_presets_route():
  164. """方案关联关系类型预置清单(启动期加载,编辑弹窗下拉用)。"""
  165. presets = current_app.extensions["dms_relation_presets"]
  166. response, status = success_response(
  167. {"presets": [preset.to_dict() for preset in presets]}
  168. )
  169. response.headers["Cache-Control"] = "private, no-store"
  170. return response, status
  171. @api_v1.get("/documents/<id>/relations")
  172. @bearer_auth_required
  173. def document_relations_route(id: str):
  174. """查看方案声明的关联清单(与文档详情同套访问控制)。"""
  175. return success_response(list_document_relations(parse_string_id(id)))
  176. @api_v1.put("/documents/<id>/relations")
  177. @bearer_auth_required
  178. def save_document_relations_route(id: str):
  179. """整体替换方案的关联清单(管理员;设密主案需通过EDIT密码锁)。"""
  180. return success_response(
  181. save_document_relations(parse_string_id(id), _json_body())
  182. )
  183. @api_v1.put("/documents/reorder")
  184. @bearer_auth_required
  185. def documents_reorder_route():
  186. """管理员拖动重排主案/子方案(主案全局排序,子方案在所属主案内排序)。"""
  187. return success_response(reorder_documents(_json_body()))
  188. @api_v1.post("/documents")
  189. @bearer_auth_required
  190. @admin_required
  191. def create_document_route():
  192. payload = _metadata()
  193. return success_response(
  194. create_document(request.files["file"], payload),
  195. status=201,
  196. )
  197. @api_v1.post("/documents/batch-import")
  198. @bearer_auth_required
  199. @admin_required
  200. def batch_import_documents_route():
  201. return success_response(_batch(attachment=False))
  202. @api_v1.get("/documents/<id>")
  203. @bearer_auth_required
  204. def document_detail_route(id: str):
  205. return success_response(get_document(parse_string_id(id)))
  206. @api_v1.put("/documents/<id>")
  207. @bearer_auth_required
  208. def update_document_route(id: str):
  209. return success_response(
  210. update_document(parse_string_id(id), _json_body())
  211. )
  212. @api_v1.put("/documents/<id>/description")
  213. @bearer_auth_required
  214. def update_document_description_route(id: str):
  215. """维护文档详细说明("查看"对话框右侧备注区)。"""
  216. return success_response(
  217. update_document_description(parse_string_id(id), _json_body())
  218. )
  219. @api_v1.delete("/documents/<id>")
  220. @bearer_auth_required
  221. def delete_document_route(id: str):
  222. return success_response(
  223. delete_document(parse_string_id(id), _row_version())
  224. )
  225. @api_v1.post("/documents/<id>/re-extract")
  226. @bearer_auth_required
  227. @admin_required
  228. def re_extract_document_route(id: str):
  229. """管理员对存量文档重新提取正文(修复提取失败/空文本)。"""
  230. return success_response(re_extract_content(parse_string_id(id)))
  231. @api_v1.get("/documents/<id>/preview")
  232. @bearer_auth_required
  233. def preview_document_route(id: str):
  234. return preview_document(parse_string_id(id))
  235. @api_v1.get("/documents/<id>/download")
  236. @bearer_auth_required
  237. def download_document_route(id: str):
  238. return download_document(parse_string_id(id))
  239. @api_v1.get("/main-plans/<id>/sub-plans")
  240. @bearer_auth_required
  241. def sub_plans_list_route(id: str):
  242. return success_response(list_sub_plans(parse_string_id(id), request.args))
  243. @api_v1.post("/main-plans/<id>/sub-plans/bind")
  244. @bearer_auth_required
  245. def bind_main_plan_sub_plans_route(id: str):
  246. """挂载其他主案的子案到当前主案(建立展示关系,权限仍继承原主案)。"""
  247. return success_response(
  248. bind_sub_plans(parse_string_id(id), _json_body())
  249. )
  250. @api_v1.delete("/main-plans/<id>/sub-plans/<subPlanId>")
  251. @bearer_auth_required
  252. def unbind_main_plan_sub_plan_route(id: str, subPlanId: str):
  253. return success_response(
  254. unbind_sub_plan(
  255. parse_string_id(id),
  256. parse_string_id(subPlanId, field="subPlanId"),
  257. _main_plan_row_version(),
  258. )
  259. )
  260. @api_v1.get("/attachments")
  261. @bearer_auth_required
  262. def attachments_list_route():
  263. # 全量可见:普通用户全局检索依赖附件标题命中;allowedActions 为空集,
  264. # 内容访问由详情/预览/下载接口按挂载主案权限独立拦截。
  265. return success_response(list_attachments(request.args))
  266. @api_v1.post("/attachments")
  267. @bearer_auth_required
  268. @admin_required
  269. def create_attachment_route():
  270. payload = _metadata()
  271. return success_response(
  272. create_attachment(request.files["file"], payload),
  273. status=201,
  274. )
  275. @api_v1.post("/attachments/batch-import")
  276. @bearer_auth_required
  277. @admin_required
  278. def batch_import_attachments_route():
  279. return success_response(_batch(attachment=True))
  280. @api_v1.get("/attachments/<id>")
  281. @bearer_auth_required
  282. def attachment_detail_route(id: str):
  283. return success_response(get_attachment(parse_string_id(id)))
  284. @api_v1.put("/attachments/<id>")
  285. @bearer_auth_required
  286. def update_attachment_route(id: str):
  287. return success_response(
  288. update_attachment(parse_string_id(id), _json_body())
  289. )
  290. @api_v1.delete("/attachments/<id>")
  291. @bearer_auth_required
  292. def delete_attachment_route(id: str):
  293. return success_response(
  294. delete_attachment(parse_string_id(id), _row_version())
  295. )
  296. @api_v1.get("/attachments/<id>/main-plans")
  297. @bearer_auth_required
  298. def attachment_main_plans_route(id: str):
  299. return success_response(
  300. list_attachment_main_plans(parse_string_id(id))
  301. )
  302. @api_v1.get("/main-plans/<id>/attachments")
  303. @bearer_auth_required
  304. def main_plan_attachments_route(id: str):
  305. return success_response(
  306. list_main_plan_attachments(parse_string_id(id), request.args)
  307. )
  308. @api_v1.post("/main-plans/<id>/attachments/bind")
  309. @bearer_auth_required
  310. def bind_main_plan_attachments_route(id: str):
  311. return success_response(
  312. bind_attachments(parse_string_id(id), _json_body())
  313. )
  314. @api_v1.put("/main-plans/<id>/attachments/reorder")
  315. @bearer_auth_required
  316. def reorder_main_plan_attachments_route(id: str):
  317. """调整主案下挂载附件的显示顺序(顺序存于挂载关系,各主案独立)。"""
  318. return success_response(
  319. reorder_main_plan_attachments(parse_string_id(id), _json_body())
  320. )
  321. @api_v1.delete("/main-plans/<id>/attachments/<attachmentId>")
  322. @bearer_auth_required
  323. def unbind_main_plan_attachment_route(id: str, attachmentId: str):
  324. return success_response(
  325. unbind_attachment(
  326. parse_string_id(id),
  327. parse_string_id(attachmentId, field="attachmentId"),
  328. _main_plan_row_version(),
  329. )
  330. )
  331. @api_v1.get("/documents/<id>/permissions")
  332. @bearer_auth_required
  333. def document_permissions_route(id: str):
  334. return success_response(get_permissions(parse_string_id(id)))
  335. @api_v1.put("/documents/<id>/permissions")
  336. @bearer_auth_required
  337. def save_document_permissions_route(id: str):
  338. return success_response(
  339. save_permissions(parse_string_id(id), _json_body())
  340. )
  341. @api_v1.put("/documents/<id>/password")
  342. @bearer_auth_required
  343. def save_document_password_route(id: str):
  344. return success_response(
  345. save_document_password(parse_string_id(id), _json_body())
  346. )