documents.py 15 KB

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