documents.py 15 KB

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