"""主案、子方案和共享附件只读 HTTP 接口。""" from __future__ import annotations import json import logging from flask import current_app, request from dms.api.v1.blueprint import api_v1 from dms.common.errors import ( BatchManifestMismatchError, DmsError, InvalidArgumentError, PayloadTooLargeError, ) from dms.common.response import success_response from dms.extensions import db from dms.security.decorators import admin_required, bearer_auth_required from dms.services.attachment_query_service import ( get_attachment, list_attachment_main_plans, list_attachments, list_main_plan_attachments, ) from dms.services.attachment_binding_service import ( bind_attachments, unbind_attachment, ) from dms.services.document_query_service import ( get_document, list_documents, list_main_plan_tags, list_sub_plans, parse_string_id, ) from dms.services.document_mutation_service import ( create_attachment, create_document, delete_attachment, delete_document, update_attachment, update_document, ) from dms.services.file_read_service import download_document, preview_document from dms.services.permission_service import get_permissions, save_permissions logger = logging.getLogger(__name__) def _metadata() -> dict: if set(request.files) != {"file"} or set(request.form) != {"metadata"}: raise InvalidArgumentError("请求必须且只能包含file和metadata") try: payload = json.loads(request.form["metadata"]) except (json.JSONDecodeError, TypeError) as exc: raise InvalidArgumentError("metadata必须是JSON对象字符串") from exc if not isinstance(payload, dict): raise InvalidArgumentError("metadata必须是JSON对象字符串") return payload def _json_body() -> dict: if not request.is_json: raise InvalidArgumentError("请求体必须是JSON对象") payload = request.get_json(silent=True) if not isinstance(payload, dict): raise InvalidArgumentError("请求体必须是JSON对象") return payload def _row_version() -> int: value = request.args.get("rowVersion") if value is None or not value.isdecimal(): raise InvalidArgumentError("rowVersion必须是非负整数") return int(value) def _main_plan_row_version() -> int: if set(request.args) != {"mainPlanRowVersion"}: raise InvalidArgumentError( "查询参数必须且只能包含mainPlanRowVersion" ) value = request.args.get("mainPlanRowVersion") if value is None or not value.isdecimal(): raise InvalidArgumentError("mainPlanRowVersion必须是非负整数") return int(value) def _batch(*, attachment: bool): if set(request.files) != {"files"} or set(request.form) != {"items"}: raise InvalidArgumentError("批量请求必须且只能包含files和items") files = request.files.getlist("files") try: items = json.loads(request.form["items"]) except (json.JSONDecodeError, TypeError) as exc: raise InvalidArgumentError("items必须是JSON数组字符串") from exc if not isinstance(items, list): raise InvalidArgumentError("items必须是JSON数组字符串") if len(files) != len(items): raise BatchManifestMismatchError() if not files: raise InvalidArgumentError("批量文件不能为空") if len(files) > current_app.config["DMS_BATCH_MAX_FILES"]: raise PayloadTooLargeError("批量文件数量超过允许上限") results = [] success_count = 0 for index, (file, payload) in enumerate(zip(files, items, strict=True)): original_name = file.filename or "" try: document = ( create_attachment(file, payload, batch=True) if attachment else create_document(file, payload, batch=True) ) results.append( { "index": index, "originalFileName": original_name, "success": True, "document": document, } ) success_count += 1 except DmsError as exc: db.session.rollback() results.append( { "index": index, "originalFileName": original_name, "success": False, "errorCode": exc.code, "errorMessage": exc.message, } ) except Exception: db.session.rollback() logger.exception("批量导入单项发生未处理异常:index=%s", index) results.append( { "index": index, "originalFileName": original_name, "success": False, "errorCode": "INTERNAL_ERROR", "errorMessage": "服务器内部错误", } ) return { "total": len(files), "successCount": success_count, "failureCount": len(files) - success_count, "items": results, } @api_v1.get("/documents") @bearer_auth_required def documents_list_route(): return success_response(list_documents(request.args)) @api_v1.get("/documents/tags") @bearer_auth_required def documents_tags_route(): """聚合当前用户可见的主案标签,供前端左侧标签筛选条使用。""" return success_response(list_main_plan_tags()) @api_v1.post("/documents") @bearer_auth_required @admin_required def create_document_route(): payload = _metadata() return success_response( create_document(request.files["file"], payload), status=201, ) @api_v1.post("/documents/batch-import") @bearer_auth_required @admin_required def batch_import_documents_route(): return success_response(_batch(attachment=False)) @api_v1.get("/documents/") @bearer_auth_required def document_detail_route(id: str): return success_response(get_document(parse_string_id(id))) @api_v1.put("/documents/") @bearer_auth_required @admin_required def update_document_route(id: str): return success_response( update_document(parse_string_id(id), _json_body()) ) @api_v1.delete("/documents/") @bearer_auth_required @admin_required def delete_document_route(id: str): return success_response( delete_document(parse_string_id(id), _row_version()) ) @api_v1.get("/documents//preview") @bearer_auth_required def preview_document_route(id: str): return preview_document(parse_string_id(id)) @api_v1.get("/documents//download") @bearer_auth_required def download_document_route(id: str): return download_document(parse_string_id(id)) @api_v1.get("/main-plans//sub-plans") @bearer_auth_required def sub_plans_list_route(id: str): return success_response(list_sub_plans(parse_string_id(id), request.args)) @api_v1.get("/attachments") @bearer_auth_required @admin_required def attachments_list_route(): return success_response(list_attachments(request.args)) @api_v1.post("/attachments") @bearer_auth_required @admin_required def create_attachment_route(): payload = _metadata() return success_response( create_attachment(request.files["file"], payload), status=201, ) @api_v1.post("/attachments/batch-import") @bearer_auth_required @admin_required def batch_import_attachments_route(): return success_response(_batch(attachment=True)) @api_v1.get("/attachments/") @bearer_auth_required def attachment_detail_route(id: str): return success_response(get_attachment(parse_string_id(id))) @api_v1.put("/attachments/") @bearer_auth_required @admin_required def update_attachment_route(id: str): return success_response( update_attachment(parse_string_id(id), _json_body()) ) @api_v1.delete("/attachments/") @bearer_auth_required @admin_required def delete_attachment_route(id: str): return success_response( delete_attachment(parse_string_id(id), _row_version()) ) @api_v1.get("/attachments//main-plans") @bearer_auth_required def attachment_main_plans_route(id: str): return success_response( list_attachment_main_plans(parse_string_id(id)) ) @api_v1.get("/main-plans//attachments") @bearer_auth_required def main_plan_attachments_route(id: str): return success_response( list_main_plan_attachments(parse_string_id(id), request.args) ) @api_v1.post("/main-plans//attachments/bind") @bearer_auth_required @admin_required def bind_main_plan_attachments_route(id: str): return success_response( bind_attachments(parse_string_id(id), _json_body()) ) @api_v1.delete("/main-plans//attachments/") @bearer_auth_required @admin_required def unbind_main_plan_attachment_route(id: str, attachmentId: str): return success_response( unbind_attachment( parse_string_id(id), parse_string_id(attachmentId, field="attachmentId"), _main_plan_row_version(), ) ) @api_v1.get("/documents//permissions") @bearer_auth_required @admin_required def document_permissions_route(id: str): return success_response(get_permissions(parse_string_id(id))) @api_v1.put("/documents//permissions") @bearer_auth_required @admin_required def save_document_permissions_route(id: str): return success_response( save_permissions(parse_string_id(id), _json_body()) )