| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085 |
- """B5文档与共享附件写操作及文件事务补偿。"""
- from __future__ import annotations
- from datetime import datetime, timezone
- import logging
- import tempfile
- from typing import Any
- from flask import current_app
- from sqlalchemy import func, or_, select
- from werkzeug.datastructures import FileStorage
- from dms.common.enums import (
- AttachmentType,
- AuditAction,
- AuditTarget,
- DocumentStatus,
- DocumentType,
- EnabledStatus,
- SecurityLevel,
- VisibilityType,
- )
- from dms.common.errors import (
- AttachmentInUseError,
- CategoryNotLeafError,
- ConflictError,
- DocumentNameConflictError,
- InvalidArgumentError,
- MainPlanHasChildrenError,
- ResourceNotFoundError,
- )
- from dms.common.response import serialize_id
- from dms.database.transaction import execute_with_deadlock_retry, transaction
- from dms.extensions import db
- from dms.models import AttachmentBinding, Category, Document, Organization, Permission
- from dms.security.auth_context import get_auth_context
- from dms.services.attachment_query_service import attachment_detail
- from dms.services.audit_service import business_audit
- from dms.services.authorization_service import evaluate_plan_access
- from dms.services.document_password_service import (
- DocumentPasswordAction,
- _pop_password_spec,
- apply_password_lock,
- get_request_password,
- require_document_password,
- )
- from dms.services.document_query_service import document_detail
- from dms.services.document_content_service import (
- ContentExtraction,
- build_search_text,
- extract_document_content,
- )
- from dms.services.preview_service import extract_doc_content_via_pdf
- from dms.storage.uploads import StagedUpload, stage_upload
- from dms.storage.paths import UnsafeStoragePathError, resolve_storage_path
- logger = logging.getLogger(__name__)
- MAIN_CREATE_FIELDS = {
- "documentName",
- "documentType",
- "summary",
- "categoryId",
- "securityLevel",
- "tags",
- }
- OVERWRITE_FIELDS = {"overwriteDocumentId", "rowVersion"}
- SUB_PLAN_CREATE_FIELDS = {
- "documentName",
- "documentType",
- "summary",
- "parentDocumentId",
- "securityLevel",
- "tags",
- }
- MAIN_EDIT_FIELDS = {
- "documentName",
- "summary",
- "categoryId",
- "securityLevel",
- "tags",
- "rowVersion",
- }
- SUB_PLAN_EDIT_FIELDS = {
- "documentName",
- "summary",
- "securityLevel",
- "tags",
- "rowVersion",
- }
- ATTACHMENT_CREATE_FIELDS = {"documentName", "attachmentType", "summary", "tags"}
- ATTACHMENT_EDIT_FIELDS = ATTACHMENT_CREATE_FIELDS | {"rowVersion"}
- # 详细说明:独立于元数据编辑的备注字段(主案/子案/附件通用),
- # 仅 {"description"},不参与 rowVersion 乐观锁(备注型轻量更新)
- DESCRIPTION_EDIT_FIELDS = {"description"}
- def _now() -> datetime:
- return datetime.now(timezone.utc).replace(tzinfo=None)
- def _exact(payload: Any, fields: set[str]) -> dict[str, Any]:
- if not isinstance(payload, dict) or set(payload) != fields:
- raise InvalidArgumentError(
- "metadata字段必须且只能包含:" + "、".join(sorted(fields))
- )
- return payload
- def _text(value: Any, name: str, limit: int, *, nullable: bool = False):
- if value is None and nullable:
- return None
- if not isinstance(value, str) or not value.strip():
- raise InvalidArgumentError(f"{name}不能为空")
- result = value.strip()
- if len(result) > limit:
- raise InvalidArgumentError(f"{name}长度不能超过{limit}")
- return result
- def _tags(value: Any) -> list[str]:
- if not isinstance(value, list) or len(value) > 50:
- raise InvalidArgumentError("tags必须是最多50项的字符串数组")
- result: list[str] = []
- for item in value:
- normalized = _text(item, "tags元素", 64)
- if normalized not in result:
- result.append(normalized)
- return result
- def _enum(value: Any, enum_type, name: str) -> str:
- if not isinstance(value, str):
- raise InvalidArgumentError(f"{name}必须是字符串枚举")
- try:
- return enum_type(value).value
- except ValueError as exc:
- raise InvalidArgumentError(f"{name}不是有效枚举值") from exc
- def _string_id(value: Any, name: str) -> int:
- if not isinstance(value, str) or not value.isdecimal() or int(value) <= 0:
- raise InvalidArgumentError(f"{name}必须是正整数形式的字符串ID")
- return int(value)
- def _version(value: Any) -> int:
- if type(value) is not int or value < 0:
- raise InvalidArgumentError("rowVersion必须是非负整数")
- return value
- def _category(category_id: int) -> Category:
- category = db.session.scalar(
- select(Category).where(
- Category.id == category_id,
- Category.is_deleted.is_(False),
- Category.status == EnabledStatus.ENABLED.value,
- )
- )
- if category is None:
- raise ResourceNotFoundError("方案分类不存在或不可用")
- return category
- def _leaf_category(category_id: int) -> Category:
- category = _category(category_id)
- child_count = db.session.scalar(
- select(func.count(Category.id)).where(
- Category.parent_id == category.id,
- Category.is_deleted.is_(False),
- Category.status == EnabledStatus.ENABLED.value,
- )
- ) or 0
- if child_count:
- raise CategoryNotLeafError(
- details={
- "categoryId": serialize_id(category.id),
- "childCategoryCount": int(child_count),
- }
- )
- return category
- def _resolve_organization(
- session,
- organization_id_input: int | None,
- context,
- ) -> tuple[int | None, str | None]:
- """解析文档归属部门。
- - 输入为空:取当前用户的部门(与创建者当前部门一致)。
- - 输入与当前用户部门相同:直接复用上下文冗余字段,免一次查询。
- - 输入为其他部门:按 ID 校验部门可用性并取最新名称。
- """
- if organization_id_input is None:
- return context.organization_id, context.organization_name
- if (
- context.organization_id is not None
- and organization_id_input == context.organization_id
- ):
- return context.organization_id, context.organization_name
- organization = session.scalar(
- select(Organization).where(
- Organization.id == organization_id_input,
- Organization.is_deleted.is_(False),
- Organization.status == EnabledStatus.ENABLED.value,
- )
- )
- if organization is None:
- raise ResourceNotFoundError("所属部门不存在或不可用")
- return organization.id, organization.org_name
- def _serialize(document: Document) -> dict[str, object]:
- context = get_auth_context()
- if document.document_type == DocumentType.ATTACHMENT.value:
- return attachment_detail(document, context)
- return document_detail(document, context, evaluate_plan_access(document, context))
- def _create_record(
- upload: StagedUpload,
- payload: dict[str, Any],
- *,
- attachment: bool,
- batch: bool,
- ) -> dict[str, object]:
- if not isinstance(payload, dict):
- raise InvalidArgumentError("metadata必须是JSON对象")
- context = get_auth_context()
- actor_id = context.user_id
- document_type = DocumentType.ATTACHMENT.value
- category = None
- parent = None
- attachment_type = None
- organization_id_input: int | None = None
- if "organizationId" in payload:
- organization_id_input = _string_id(
- payload.pop("organizationId"), "organizationId"
- )
- password, protected_actions = _pop_password_spec(
- payload, attachment=attachment
- )
- if attachment:
- _exact(payload, ATTACHMENT_CREATE_FIELDS)
- attachment_type = _enum(
- payload["attachmentType"], AttachmentType, "attachmentType"
- )
- security = SecurityLevel.PUBLIC.value
- visibility = VisibilityType.ALL_AUTHENTICATED.value
- status = DocumentStatus.PUBLISHED.value
- else:
- raw_type = payload.get("documentType")
- try:
- document_type = DocumentType(raw_type).value
- except (ValueError, TypeError) as exc:
- raise InvalidArgumentError("documentType只允许MAIN或SUB_PLAN") from exc
- if document_type not in {
- DocumentType.MAIN.value,
- DocumentType.SUB_PLAN.value,
- }:
- raise InvalidArgumentError("documentType只允许MAIN或SUB_PLAN")
- create_fields = (
- SUB_PLAN_CREATE_FIELDS
- if document_type == DocumentType.SUB_PLAN.value
- else MAIN_CREATE_FIELDS
- )
- if frozenset(payload) not in {
- frozenset(create_fields),
- frozenset(create_fields | OVERWRITE_FIELDS),
- }:
- raise InvalidArgumentError(
- "metadata字段必须为新增字段全集,或额外同时包含overwriteDocumentId和rowVersion"
- )
- security = _enum(payload["securityLevel"], SecurityLevel, "securityLevel")
- visibility = VisibilityType.CUSTOM.value
- status = DocumentStatus.PUBLISHED.value
- if document_type == DocumentType.SUB_PLAN.value:
- parent_id = _string_id(payload["parentDocumentId"], "parentDocumentId")
- parent = db.session.scalar(
- select(Document).where(
- Document.id == parent_id,
- Document.document_type == DocumentType.MAIN.value,
- Document.is_deleted.is_(False),
- )
- )
- if parent is None:
- raise ResourceNotFoundError("父文档必须是有效主案")
- visibility = parent.visibility_type
- category = _category(parent.category_id)
- else:
- category = _leaf_category(
- _string_id(payload["categoryId"], "categoryId")
- )
- name = _text(payload["documentName"], "documentName", 255)
- summary = _text(payload["summary"], "summary", 20000, nullable=True)
- tags = _tags(payload["tags"])
- if upload.extension in {"doc", "wps"}:
- temp_pdf: Path | None = None
- try:
- from dms.services.document_converter import create_converter
- converter = create_converter(current_app.config)
- if not converter.available:
- extraction = ContentExtraction(
- text=None,
- status="FAILED",
- extracted_at=_now(),
- )
- else:
- pdf_bytes = converter.convert_to_pdf(
- upload.temporary_path, upload.file_hash
- )
- with tempfile.NamedTemporaryFile(
- suffix=".pdf", delete=False
- ) as temp_file:
- temp_file.write(pdf_bytes)
- temp_pdf = Path(temp_file.name)
- extraction = extract_document_content(temp_pdf, "pdf")
- except Exception:
- logger.exception("DOC/WPS转换PDF提取正文失败")
- extraction = ContentExtraction(
- text=None,
- status="FAILED",
- extracted_at=_now(),
- )
- finally:
- if temp_pdf is not None:
- temp_pdf.unlink(missing_ok=True)
- else:
- extraction = extract_document_content(upload.temporary_path, upload.extension)
- final_created = False
- committed = False
- old_file_path = None
- try:
- with transaction() as session:
- organization_id, organization_name = _resolve_organization(
- session, organization_id_input, context
- )
- duplicate = None
- if not attachment:
- duplicate_conditions = [
- Document.document_name == name,
- Document.document_type == document_type,
- Document.is_deleted.is_(False),
- ]
- if document_type == DocumentType.SUB_PLAN.value:
- duplicate_conditions.append(Document.parent_document_id == parent.id)
- duplicate = session.scalar(
- select(Document).where(*duplicate_conditions).with_for_update()
- )
- overwrite_id = payload.get("overwriteDocumentId")
- if duplicate is not None and overwrite_id is None:
- raise DocumentNameConflictError(
- details={
- "existingDocumentId": serialize_id(duplicate.id),
- "existingDocumentName": duplicate.document_name,
- "existingRowVersion": duplicate.row_version,
- "documentType": duplicate.document_type,
- "parentDocumentId": serialize_id(duplicate.parent_document_id),
- }
- )
- if overwrite_id is not None:
- expected_id = _string_id(overwrite_id, "overwriteDocumentId")
- expected_version = _version(payload["rowVersion"])
- if duplicate is None or duplicate.id != expected_id:
- raise ConflictError(
- "同名方案已发生变化,请重新确认",
- details={"currentRowVersion": duplicate.row_version if duplicate else None},
- )
- _check_version(duplicate, expected_version)
- document = duplicate
- old_category_id = document.category_id
- if document_type == DocumentType.MAIN.value and old_category_id != category.id:
- old_category = session.get(Category, old_category_id)
- if old_category is not None:
- old_category.document_count = max(0, old_category.document_count - 1)
- old_category.row_version += 1
- category.document_count += 1
- category.row_version += 1
- try:
- old_file_path = resolve_storage_path(
- document.file_relative_path,
- current_app.config["DMS_STORAGE_ROOT"],
- )
- except UnsafeStoragePathError:
- logger.error("覆盖方案时检测到异常旧文件路径:document_id=%s", document.id)
- old_file_path = None
- document.summary = summary
- document.security_level = security
- document.category_id = category.id if category else None
- document.category_name = category.category_name if category else None
- document.category_path = category.category_path if category else None
- document.organization_id = organization_id
- document.organization_name = organization_name
- document.tags = tags
- document.original_file_name = upload.original_file_name
- document.file_relative_path = upload.relative_path
- document.file_extension = upload.extension
- document.mime_type = upload.mime_type
- document.file_size = upload.file_size
- document.file_hash = upload.file_hash
- document.content_text = extraction.text
- document.content_extract_status = extraction.status
- document.content_extracted_at = extraction.extracted_at
- document.search_text = build_search_text(name, summary, tags, extraction.text)
- document.updated_by = actor_id
- document.updated_by_name = context.real_name
- document.updated_at = _now()
- document.row_version += 1
- if document_type == DocumentType.MAIN.value and old_category_id != category.id:
- session.query(Document).filter(
- Document.parent_document_id == document.id,
- Document.document_type == DocumentType.SUB_PLAN.value,
- Document.is_deleted.is_(False),
- ).update(
- {
- Document.category_id: category.id,
- Document.category_name: category.category_name,
- Document.category_path: category.category_path,
- Document.updated_by: actor_id,
- Document.updated_by_name: context.real_name,
- Document.updated_at: _now(),
- Document.row_version: Document.row_version + 1,
- },
- synchronize_session=False,
- )
- session.add(
- business_audit(
- action=AuditAction.UPLOAD_DOCUMENT,
- target=AuditTarget.DOCUMENT,
- target_id=document.id,
- target_name=document.document_name,
- detail={
- "overwrite": True,
- "originalFileName": document.original_file_name,
- "fileSize": document.file_size,
- "rowVersion": document.row_version,
- },
- )
- )
- upload.promote()
- final_created = True
- else:
- # 新建文档追加到同范围排序末尾:主案全局排序、子方案在所属主案内排序;共享附件不参与。
- if document_type == DocumentType.MAIN.value:
- next_sort_no = (session.scalar(
- select(func.max(Document.sort_no)).where(
- Document.document_type == DocumentType.MAIN.value,
- Document.parent_document_id.is_(None),
- Document.is_deleted.is_(False),
- )
- ) or 0) + 10
- elif document_type == DocumentType.SUB_PLAN.value:
- next_sort_no = (session.scalar(
- select(func.max(Document.sort_no)).where(
- Document.parent_document_id == parent.id,
- Document.document_type == DocumentType.SUB_PLAN.value,
- Document.is_deleted.is_(False),
- )
- ) or 0) + 10
- else:
- next_sort_no = 0
- document = Document(
- document_name=name,
- summary=summary,
- document_type=document_type,
- document_status=status,
- security_level=security,
- visibility_type=visibility,
- attachment_type=attachment_type,
- category_id=category.id if category else None,
- category_name=category.category_name if category else None,
- category_path=category.category_path if category else None,
- organization_id=organization_id,
- organization_name=organization_name,
- parent_document_id=parent.id if parent else None,
- root_document_id=parent.id if parent else None,
- tags=tags,
- original_file_name=upload.original_file_name,
- file_relative_path=upload.relative_path,
- file_extension=upload.extension,
- mime_type=upload.mime_type,
- file_size=upload.file_size,
- file_hash=upload.file_hash,
- sort_no=next_sort_no,
- content_text=extraction.text,
- content_extract_status=extraction.status,
- content_extracted_at=extraction.extracted_at,
- search_text=build_search_text(
- name, summary, tags, extraction.text
- ),
- created_by=actor_id,
- updated_by=actor_id,
- created_by_name=context.real_name,
- updated_by_name=context.real_name,
- )
- session.add(document)
- session.flush()
- if password is not None:
- # 上传主案时同步启用密码锁(覆盖上传已在入口拒绝携带密码字段)
- apply_password_lock(document, password, protected_actions or [])
- session.add(
- business_audit(
- action=AuditAction.DOCUMENT_PASSWORD_SET,
- target=AuditTarget.DOCUMENT,
- target_id=document.id,
- target_name=document.document_name,
- detail={
- "onUpload": True,
- "protectedActions": protected_actions or [],
- },
- )
- )
- if category is not None and document_type == DocumentType.MAIN.value:
- category.document_count += 1
- category.row_version += 1
- category.updated_by = actor_id
- if parent is not None:
- parent.child_count += 1
- parent.row_version += 1
- parent.updated_by = actor_id
- parent.updated_by_name = context.real_name
- session.add(
- business_audit(
- action=(
- AuditAction.BATCH_IMPORT if batch else AuditAction.UPLOAD_DOCUMENT
- ),
- target=(
- AuditTarget.ATTACHMENT
- if attachment
- else AuditTarget.DOCUMENT
- ),
- target_id=document.id,
- target_name=document.document_name,
- detail={
- "documentType": document.document_type,
- "originalFileName": document.original_file_name,
- "fileSize": document.file_size,
- },
- )
- )
- upload.promote()
- final_created = True
- committed = True
- if old_file_path is not None and old_file_path != upload.final_path:
- try:
- old_file_path.unlink(missing_ok=True)
- except OSError:
- logger.exception("覆盖成功后清理旧文件失败:document_id=%s", document.id)
- except Exception:
- if final_created and not committed:
- upload.final_path.unlink(missing_ok=True)
- raise
- finally:
- upload.cleanup()
- return _serialize(document)
- def create_document(file: FileStorage, payload: dict[str, Any], *, batch=False):
- upload = stage_upload(file)
- return _create_record(upload, payload, attachment=False, batch=batch)
- def create_attachment(file: FileStorage, payload: dict[str, Any], *, batch=False):
- upload = stage_upload(file)
- return _create_record(upload, payload, attachment=True, batch=batch)
- def _active(document_id: int, types: set[str]) -> Document:
- document = db.session.scalar(
- select(Document).where(
- Document.id == document_id,
- Document.document_type.in_(types),
- Document.is_deleted.is_(False),
- )
- )
- if document is None:
- raise ResourceNotFoundError("文档不存在或类型不匹配")
- return document
- def _check_version(document: Document, expected: int) -> None:
- if document.row_version != expected:
- raise ConflictError(
- "数据已被其他用户修改,请刷新后重试",
- details={"currentRowVersion": document.row_version},
- )
- REORDER_FIELDS = {"orderedIds"}
- def _reorder_payload(payload: Any) -> list[int]:
- if not isinstance(payload, dict) or set(payload) != REORDER_FIELDS:
- raise InvalidArgumentError("请求字段必须且只能包含orderedIds")
- raw_ids = payload["orderedIds"]
- if not isinstance(raw_ids, list) or len(raw_ids) < 2:
- raise InvalidArgumentError("orderedIds必须是至少包含2个ID的数组")
- ordered_ids: list[int] = []
- seen: set[int] = set()
- for index, raw_id in enumerate(raw_ids):
- document_id = _string_id(raw_id, f"orderedIds[{index}]")
- if document_id not in seen:
- ordered_ids.append(document_id)
- seen.add(document_id)
- return ordered_ids
- def reorder_documents(payload: Any) -> dict[str, int]:
- """管理员拖动重排方案:提交ID确定排序范围,槽位整体重排并按步长10重编号。
- - 主案在全局范围排序,子方案在所属主案内排序,共享附件不支持;
- - ``orderedIds`` 为拖动后的目标顺序(允许只提交范围内的部分ID,
- 未提交的槽位保持相对顺序,仅被提交ID"置换"填充)。
- """
- ordered_ids = _reorder_payload(payload)
- context = get_auth_context()
- def operation() -> dict[str, int]:
- with transaction() as session:
- submitted = session.scalars(
- select(Document)
- .where(Document.id.in_(sorted(ordered_ids)))
- .order_by(Document.id.asc())
- .with_for_update()
- ).all()
- by_id = {document.id: document for document in submitted}
- if any(
- document_id not in by_id or by_id[document_id].is_deleted
- for document_id in ordered_ids
- ):
- raise ResourceNotFoundError("待排序方案不存在或已删除")
- first = by_id[ordered_ids[0]]
- document_type = first.document_type
- if document_type == DocumentType.MAIN.value:
- scope = (
- Document.document_type == DocumentType.MAIN.value,
- Document.parent_document_id.is_(None),
- )
- scope_key = "GLOBAL"
- elif document_type == DocumentType.SUB_PLAN.value:
- scope = (
- Document.document_type == DocumentType.SUB_PLAN.value,
- Document.parent_document_id == first.parent_document_id,
- )
- scope_key = serialize_id(first.parent_document_id)
- else:
- raise InvalidArgumentError("共享附件不支持拖动排序")
- if any(
- by_id[document_id].document_type != document_type
- for document_id in ordered_ids
- ):
- raise InvalidArgumentError("orderedIds包含不同范围的方案")
- siblings = session.scalars(
- select(Document)
- .where(Document.is_deleted.is_(False), *scope)
- .order_by(Document.sort_no.asc(), Document.id.asc())
- .with_for_update()
- ).all()
- all_ids = [document.id for document in siblings]
- sibling_ids = set(all_ids)
- if any(document_id not in sibling_ids for document_id in ordered_ids):
- raise ResourceNotFoundError("待排序方案不存在或已删除")
- slot_ids = sorted(ordered_ids, key=all_ids.index)
- new_order = list(all_ids)
- for position, document_id in zip(
- (all_ids.index(did) for did in slot_ids), ordered_ids
- ):
- new_order[position] = document_id
- target_sort_by_id = {
- document_id: (index + 1) * 10
- for index, document_id in enumerate(new_order)
- }
- now = _now()
- reordered_count = 0
- for document in siblings:
- target = target_sort_by_id[document.id]
- if document.sort_no == target:
- continue
- document.sort_no = target
- document.updated_by = context.user_id
- document.updated_by_name = context.real_name
- document.updated_at = now
- document.row_version += 1
- reordered_count += 1
- session.add(
- business_audit(
- action=AuditAction.REORDER_DOCUMENT,
- target=AuditTarget.DOCUMENT,
- target_id=first.id,
- target_name=first.document_name,
- detail={
- "documentType": document_type,
- "scope": scope_key,
- "orderedIds": [serialize_id(did) for did in ordered_ids],
- "reorderedCount": reordered_count,
- },
- )
- )
- return {"reorderedCount": reordered_count}
- return execute_with_deadlock_retry(operation)
- def update_document(document_id: int, payload: dict[str, Any]):
- context = get_auth_context()
- organization_id_input: int | None = None
- if "organizationId" in payload:
- organization_id_input = _string_id(
- payload.pop("organizationId"), "organizationId"
- )
- with transaction() as session:
- document = session.scalar(
- select(Document)
- .where(
- Document.id == document_id,
- Document.document_type.in_(
- [DocumentType.MAIN.value, DocumentType.SUB_PLAN.value]
- ),
- Document.is_deleted.is_(False),
- )
- .with_for_update()
- )
- if document is None:
- raise ResourceNotFoundError("方案文档不存在或类型不匹配")
- require_document_password(
- document,
- DocumentPasswordAction.EDIT.value,
- get_request_password(),
- context,
- )
- _exact(
- payload,
- MAIN_EDIT_FIELDS
- if document.document_type == DocumentType.MAIN.value
- else SUB_PLAN_EDIT_FIELDS,
- )
- _check_version(document, _version(payload["rowVersion"]))
- organization_id, organization_name = _resolve_organization(
- session, organization_id_input, context
- )
- if document.document_type == DocumentType.MAIN.value:
- category = _leaf_category(
- _string_id(payload["categoryId"], "categoryId")
- )
- else:
- parent = session.scalar(
- select(Document).where(
- Document.id == document.parent_document_id,
- Document.document_type == DocumentType.MAIN.value,
- Document.is_deleted.is_(False),
- )
- )
- if parent is None:
- raise ResourceNotFoundError("父文档必须是有效主案")
- category = _category(parent.category_id)
- before = {
- "documentName": document.document_name,
- "categoryId": serialize_id(document.category_id),
- "organizationId": serialize_id(document.organization_id),
- "securityLevel": document.security_level,
- "visibilityType": document.visibility_type,
- "status": document.document_status,
- }
- category_changed = document.category_id != category.id
- if document.document_type == DocumentType.MAIN.value and category_changed:
- old = session.get(Category, document.category_id)
- if old is not None:
- old.document_count = max(0, old.document_count - 1)
- old.row_version += 1
- category.document_count += 1
- category.row_version += 1
- document.document_name = _text(
- payload["documentName"], "documentName", 255
- )
- document.summary = _text(
- payload["summary"], "summary", 20000, nullable=True
- )
- document.tags = _tags(payload["tags"])
- document.category_id = category.id
- document.category_name = category.category_name
- document.category_path = category.category_path
- document.organization_id = organization_id
- document.organization_name = organization_name
- document.security_level = _enum(
- payload["securityLevel"], SecurityLevel, "securityLevel"
- )
- if document.document_type == DocumentType.SUB_PLAN.value:
- document.visibility_type = parent.visibility_type
- document.search_text = build_search_text(
- document.document_name,
- document.summary,
- document.tags,
- document.content_text,
- )
- document.updated_by = context.user_id
- document.updated_by_name = context.real_name
- document.updated_at = _now()
- document.row_version += 1
- if document.document_type == DocumentType.MAIN.value and category_changed:
- session.query(Document).filter(
- Document.parent_document_id == document.id,
- Document.document_type == DocumentType.SUB_PLAN.value,
- Document.is_deleted.is_(False),
- ).update(
- {
- Document.category_id: category.id,
- Document.category_name: category.category_name,
- Document.category_path: category.category_path,
- Document.updated_by: context.user_id,
- Document.updated_by_name: context.real_name,
- Document.updated_at: _now(),
- Document.row_version: Document.row_version + 1,
- },
- synchronize_session=False,
- )
- session.add(
- business_audit(
- action=AuditAction.EDIT_DOCUMENT,
- target=AuditTarget.DOCUMENT,
- target_id=document.id,
- target_name=document.document_name,
- detail={"before": before, "rowVersion": document.row_version},
- )
- )
- return _serialize(document)
- def update_document_description(document_id: int, payload: dict[str, Any]):
- """维护文档详细说明("查看"对话框右侧备注区,主案/子案/附件通用)。"""
- context = get_auth_context()
- _exact(payload, DESCRIPTION_EDIT_FIELDS)
- description = _text(
- payload["description"], "description", 20000, nullable=True
- )
- with transaction() as session:
- document = session.scalar(
- select(Document)
- .where(
- Document.id == document_id,
- Document.is_deleted.is_(False),
- )
- .with_for_update()
- )
- if document is None:
- raise ResourceNotFoundError("文档不存在或已删除")
- require_document_password(
- document,
- DocumentPasswordAction.EDIT.value,
- get_request_password(),
- context,
- )
- before = {"description": document.description}
- document.description = description
- document.updated_by = context.user_id
- document.updated_by_name = context.real_name
- document.updated_at = _now()
- document.row_version += 1
- session.add(
- business_audit(
- action=AuditAction.EDIT_DOCUMENT,
- target=AuditTarget.DOCUMENT,
- target_id=document.id,
- target_name=document.document_name,
- detail={"field": "description", "before": before, "rowVersion": document.row_version},
- )
- )
- return _serialize(document)
- def update_attachment(document_id: int, payload: dict[str, Any]):
- context = get_auth_context()
- organization_id_input: int | None = None
- if "organizationId" in payload:
- organization_id_input = _string_id(
- payload.pop("organizationId"), "organizationId"
- )
- with transaction() as session:
- document = session.scalar(
- select(Document)
- .where(
- Document.id == document_id,
- Document.document_type == DocumentType.ATTACHMENT.value,
- Document.is_deleted.is_(False),
- )
- .with_for_update()
- )
- if document is None:
- raise ResourceNotFoundError("共享附件不存在或类型不匹配")
- _exact(payload, ATTACHMENT_EDIT_FIELDS)
- _check_version(document, _version(payload["rowVersion"]))
- organization_id, organization_name = _resolve_organization(
- session, organization_id_input, context
- )
- before = {
- "documentName": document.document_name,
- "attachmentType": document.attachment_type,
- "organizationId": serialize_id(document.organization_id),
- }
- document.document_name = _text(
- payload["documentName"], "documentName", 255
- )
- document.attachment_type = _enum(
- payload["attachmentType"], AttachmentType, "attachmentType"
- )
- document.summary = _text(
- payload["summary"], "summary", 20000, nullable=True
- )
- document.tags = _tags(payload["tags"])
- document.organization_id = organization_id
- document.organization_name = organization_name
- document.search_text = build_search_text(
- document.document_name,
- document.summary,
- document.tags,
- document.content_text,
- )
- document.updated_by = context.user_id
- document.updated_by_name = context.real_name
- document.updated_at = _now()
- document.row_version += 1
- session.add(
- business_audit(
- action=AuditAction.EDIT_DOCUMENT,
- target=AuditTarget.ATTACHMENT,
- target_id=document.id,
- target_name=document.document_name,
- detail={"before": before, "rowVersion": document.row_version},
- )
- )
- return _serialize(document)
- def delete_document(document_id: int, row_version: int):
- context = get_auth_context()
- now = _now()
- with transaction() as session:
- document = session.scalar(
- select(Document)
- .where(
- Document.id == document_id,
- Document.document_type.in_(
- [DocumentType.MAIN.value, DocumentType.SUB_PLAN.value]
- ),
- Document.is_deleted.is_(False),
- )
- .with_for_update()
- )
- if document is None:
- raise ResourceNotFoundError("方案文档不存在或类型不匹配")
- require_document_password(
- document,
- DocumentPasswordAction.DELETE.value,
- get_request_password(),
- context,
- )
- _check_version(document, row_version)
- if document.document_type == DocumentType.MAIN.value:
- child_count = session.scalar(
- select(func.count(Document.id)).where(
- or_(
- Document.parent_document_id == document.id,
- Document.root_document_id == document.id,
- ),
- Document.document_type == DocumentType.SUB_PLAN.value,
- Document.is_deleted.is_(False),
- )
- )
- if child_count:
- raise MainPlanHasChildrenError(
- details={"childCount": child_count}
- )
- for relation in session.scalars(
- select(Permission).where(
- Permission.document_id == document.id,
- Permission.is_deleted.is_(False),
- )
- ):
- relation.is_deleted = True
- relation.deleted_at = now
- relation.updated_at = now
- relation.updated_by = context.user_id
- relation.row_version += 1
- for binding in session.scalars(
- select(AttachmentBinding).where(
- AttachmentBinding.main_document_id == document.id,
- AttachmentBinding.is_deleted.is_(False),
- )
- ):
- binding.is_deleted = True
- binding.deleted_at = now
- binding.updated_at = now
- binding.updated_by = context.user_id
- binding.row_version += 1
- document.attachment_count = 0
- else:
- parent = session.get(Document, document.root_document_id)
- if parent is not None and not parent.is_deleted:
- parent.child_count = max(0, parent.child_count - 1)
- parent.updated_at = now
- parent.updated_by = context.user_id
- parent.updated_by_name = context.real_name
- parent.row_version += 1
- category = session.get(Category, document.category_id)
- if category is not None and document.document_type == DocumentType.MAIN.value:
- category.document_count = max(0, category.document_count - 1)
- category.row_version += 1
- category.updated_by = context.user_id
- document.is_deleted = True
- document.deleted_at = now
- document.updated_at = now
- document.updated_by = context.user_id
- document.updated_by_name = context.real_name
- document.row_version += 1
- session.add(
- business_audit(
- action=AuditAction.DELETE_DOCUMENT,
- target=AuditTarget.DOCUMENT,
- target_id=document.id,
- target_name=document.document_name,
- detail={"documentType": document.document_type},
- )
- )
- return {"id": serialize_id(document.id), "deleted": True}
- def delete_attachment(document_id: int, row_version: int):
- context = get_auth_context()
- now = _now()
- with transaction() as session:
- document = session.scalar(
- select(Document)
- .where(
- Document.id == document_id,
- Document.document_type == DocumentType.ATTACHMENT.value,
- Document.is_deleted.is_(False),
- )
- .with_for_update()
- )
- if document is None:
- raise ResourceNotFoundError("共享附件不存在或类型不匹配")
- _check_version(document, row_version)
- rows = session.execute(
- select(AttachmentBinding, Document)
- .join(Document, Document.id == AttachmentBinding.main_document_id)
- .where(
- AttachmentBinding.attachment_document_id == document.id,
- AttachmentBinding.is_deleted.is_(False),
- Document.document_type == DocumentType.MAIN.value,
- Document.is_deleted.is_(False),
- )
- ).all()
- if rows:
- raise AttachmentInUseError(
- details={
- "mountedPlanCount": len(rows),
- "mainPlans": [
- {
- "id": serialize_id(main.id),
- "documentName": main.document_name,
- }
- for _, main in rows
- ],
- }
- )
- document.is_deleted = True
- document.deleted_at = now
- document.updated_at = now
- document.updated_by = context.user_id
- document.updated_by_name = context.real_name
- document.row_version += 1
- session.add(
- business_audit(
- action=AuditAction.DELETE_DOCUMENT,
- target=AuditTarget.ATTACHMENT,
- target_id=document.id,
- target_name=document.document_name,
- detail={"attachmentType": document.attachment_type},
- )
- )
- return {"id": serialize_id(document.id), "deleted": True}
|