| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511 |
- """OA 审批实例评论:附件枚举、校验、CRUD"""
- from __future__ import annotations
- import json
- from datetime import datetime
- from typing import Any, Dict, List, Optional
- from sqlalchemy.orm import Session
- from app.core.utils import generate_uuid
- from app.models.models import (
- OaAppVersion,
- OaInstance,
- OaInstanceComment,
- OaInstanceViewGrant,
- SysUser,
- )
- from app.services.oa_engine import OaEngineError, _get_instance, _loads, _user_name_map
- from app.services.oa_related_approval import (
- UPLOAD_TYPES,
- _extract_upload_urls,
- _schema_rules,
- _upload_display_name,
- )
- CONTENT_MAX = 1024
- MENTION_MAX = 20
- def enumerate_form_attachments(
- schema: Any, form_data: Optional[Dict[str, Any]]
- ) -> List[Dict[str, Any]]:
- data = form_data or {}
- items: List[Dict[str, Any]] = []
- for rule in _schema_rules(schema):
- t = str(rule.get("type") or "")
- if t not in UPLOAD_TYPES:
- continue
- field = str(rule.get("field") or rule.get("name") or "").strip()
- if not field:
- continue
- label = str(rule.get("title") or rule.get("label") or field).strip() or field
- raw = data.get(field)
- files: List[Dict[str, str]] = []
- if isinstance(raw, list):
- for it in raw:
- if isinstance(it, dict):
- u = (_extract_upload_urls(it) or [""])[0]
- if not u:
- continue
- files.append(
- {
- "url": u,
- "fileName": _upload_display_name(it, u),
- }
- )
- else:
- for u in _extract_upload_urls(it):
- files.append({"url": u, "fileName": _upload_display_name(it, u)})
- else:
- for u in _extract_upload_urls(raw):
- files.append({"url": u, "fileName": _upload_display_name(raw, u)})
- if not files:
- continue
- items.append({"fieldKey": field, "fieldLabel": label, "files": files})
- return items
- def _allowed_url_index(
- schema: Any, form_data: Optional[Dict[str, Any]]
- ) -> Dict[str, Dict[str, str]]:
- """url -> {fieldKey, fieldLabel, fileName}"""
- index: Dict[str, Dict[str, str]] = {}
- for group in enumerate_form_attachments(schema, form_data):
- for f in group["files"]:
- u = str(f.get("url") or "").strip()
- if not u:
- continue
- index[u] = {
- "fieldKey": group["fieldKey"],
- "fieldLabel": group["fieldLabel"],
- "fileName": str(f.get("fileName") or "").strip() or u,
- }
- return index
- def _attachment_url_candidates(url: str) -> List[str]:
- raw = (url or "").strip()
- path = raw.split("?", 1)[0].strip()
- found: List[str] = []
- for item in (raw, path, path.lstrip("/")):
- if item and item not in found:
- found.append(item)
- try:
- from app.core.config import settings
- base = (getattr(settings, "MINIO_BASE_URL", None) or "").rstrip("/")
- except Exception:
- base = ""
- if base and path.startswith(base + "/"):
- rel = path[len(base) + 1 :]
- if rel and rel not in found:
- found.append(rel)
- return found
- def _find_uploaded_attachment(db: Session, tenant_no: str, url: str):
- from app.models.models import BizAttachment
- q = db.query(BizAttachment).filter(
- BizAttachment.del_flag == 0,
- BizAttachment.tenant_no == tenant_no,
- )
- for candidate in _attachment_url_candidates(url):
- row = q.filter(BizAttachment.file_url == candidate).first()
- if row is not None:
- return row
- return None
- def normalize_attachment_refs(
- schema: Any,
- form_data: Optional[Dict[str, Any]],
- refs: Optional[List[Dict[str, Any]]],
- db: Optional[Session] = None,
- tenant_no: str = "",
- ) -> List[Dict[str, str]]:
- if not refs:
- return []
- if not isinstance(refs, list):
- raise OaEngineError("attachmentRefs 格式无效")
- index = _allowed_url_index(schema, form_data)
- out: List[Dict[str, str]] = []
- seen = set()
- for raw in refs:
- if not isinstance(raw, dict):
- raise OaEngineError("引用的附件不存在或不属于本表单")
- url = str(raw.get("url") or "").strip()
- source = str(raw.get("source") or "form").strip() or "form"
- if not url or url in seen:
- if not url:
- raise OaEngineError("引用的附件不存在或不属于本表单")
- continue
- if source == "upload":
- if db is None:
- raise OaEngineError("上传的附件不存在")
- row = _find_uploaded_attachment(db, tenant_no, url)
- if row is None:
- raise OaEngineError("上传的附件不存在")
- seen.add(url)
- out.append(
- {
- "source": "upload",
- "fieldKey": "",
- "fieldLabel": "",
- "url": str(row.file_url or url),
- "fileName": str(row.original_name or "").strip() or url,
- }
- )
- continue
- if source != "form":
- raise OaEngineError("attachmentRefs 格式无效")
- if url not in index:
- raise OaEngineError("引用的附件不存在或不属于本表单")
- seen.add(url)
- meta = index[url]
- out.append(
- {
- "source": "form",
- "fieldKey": meta["fieldKey"],
- "fieldLabel": meta["fieldLabel"],
- "url": url,
- "fileName": meta["fileName"],
- }
- )
- return out
- def _fmt_time(dt: Optional[datetime]) -> str:
- return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else ""
- def _mention_ids(row: OaInstanceComment) -> List[str]:
- raw = _loads(getattr(row, "mention_user_ids_json", None), [])
- if not isinstance(raw, list):
- return []
- out: List[str] = []
- for item in raw:
- uid = str(item or "").strip()
- if uid and uid not in out:
- out.append(uid)
- return out
- def _mentions_payload(row: OaInstanceComment, name_map: Dict[str, str]) -> List[Dict[str, str]]:
- return [
- {"userId": uid, "userName": name_map.get(uid, uid)}
- for uid in _mention_ids(row)
- ]
- def _comment_to_dict(
- row: OaInstanceComment,
- *,
- name_map: Dict[str, str],
- viewer_user_id: str,
- instance_status: str,
- ) -> Dict[str, Any]:
- refs = _loads(row.attachment_refs_json, [])
- if not isinstance(refs, list):
- refs = []
- status = (row.status or "normal").strip() or "normal"
- can_revoke = status == "normal" and (row.create_by or "") == (viewer_user_id or "")
- return {
- "id": row.id,
- "instanceId": row.instance_id,
- "content": row.content or "",
- "attachmentRefs": refs,
- "status": status,
- "revokedTime": _fmt_time(row.revoked_time),
- "createBy": row.create_by or "",
- "createByName": name_map.get(row.create_by or "", row.create_by or ""),
- "createTime": _fmt_time(row.create_time),
- "canRevoke": can_revoke,
- "mentions": _mentions_payload(row, name_map),
- }
- def _load_version_schema(db: Session, inst: OaInstance) -> Any:
- ver = (
- db.query(OaAppVersion)
- .filter(OaAppVersion.id == inst.app_version_id, OaAppVersion.del_flag == 0)
- .first()
- )
- if not ver:
- return {}
- return _loads(ver.form_schema_json, {})
- def list_comments(
- db: Session,
- *,
- instance_id: str,
- tenant_no: str,
- viewer_user_id: str,
- ) -> List[Dict[str, Any]]:
- inst = _get_instance(db, instance_id, tenant_no)
- rows = (
- db.query(OaInstanceComment)
- .filter(
- OaInstanceComment.instance_id == instance_id,
- OaInstanceComment.tenant_no == tenant_no,
- OaInstanceComment.del_flag == 0,
- )
- .order_by(OaInstanceComment.create_time.asc())
- .all()
- )
- name_ids = [r.create_by for r in rows if r.create_by]
- for r in rows:
- name_ids.extend(_mention_ids(r))
- name_map = _user_name_map(db, name_ids)
- return [
- _comment_to_dict(
- r,
- name_map=name_map,
- viewer_user_id=viewer_user_id,
- instance_status=inst.status or "",
- )
- for r in rows
- ]
- def list_form_attachments(
- db: Session, *, instance_id: str, tenant_no: str
- ) -> List[Dict[str, Any]]:
- inst = _get_instance(db, instance_id, tenant_no)
- schema = _load_version_schema(db, inst)
- form = _loads(inst.form_data_json, {})
- if not isinstance(form, dict):
- form = {}
- return enumerate_form_attachments(schema, form)
- def normalize_mention_user_ids(
- db: Session,
- *,
- tenant_no: str,
- actor_user_id: str,
- user_ids: Optional[List[str]],
- ) -> List[str]:
- if not user_ids:
- return []
- if not isinstance(user_ids, list):
- raise OaEngineError("mentionUserIds 格式无效")
- actor = (actor_user_id or "").strip()
- seen: List[str] = []
- for raw in user_ids:
- uid = str(raw or "").strip()
- if not uid or uid in seen or uid == actor:
- continue
- seen.append(uid)
- if len(seen) > MENTION_MAX:
- raise OaEngineError(f"一次最多@{MENTION_MAX}人")
- if not seen:
- return []
- rows = (
- db.query(SysUser.id)
- .filter(
- SysUser.id.in_(seen),
- SysUser.tenant_no == tenant_no,
- SysUser.del_flag == 0,
- SysUser.status == "1",
- )
- .all()
- )
- found = {str(r[0]) for r in rows}
- if any(uid not in found for uid in seen):
- raise OaEngineError("提及的用户不存在或已停用")
- return seen
- def _ensure_view_grants(
- db: Session,
- *,
- instance_id: str,
- tenant_no: str,
- user_ids: List[str],
- comment_id: str,
- actor_user_id: str,
- dept_id: str,
- ) -> None:
- if not user_ids:
- return
- existing = (
- db.query(OaInstanceViewGrant.user_id)
- .filter(
- OaInstanceViewGrant.instance_id == instance_id,
- OaInstanceViewGrant.tenant_no == tenant_no,
- OaInstanceViewGrant.del_flag == 0,
- OaInstanceViewGrant.user_id.in_(user_ids),
- )
- .all()
- )
- have = {str(r[0]) for r in existing}
- now = datetime.now()
- for uid in user_ids:
- if uid in have:
- continue
- db.add(
- OaInstanceViewGrant(
- id=generate_uuid(),
- instance_id=instance_id,
- user_id=uid,
- source="comment_mention",
- comment_id=comment_id,
- granted_by=actor_user_id,
- tenant_no=tenant_no,
- create_by=actor_user_id,
- create_dept_id=dept_id or "",
- create_time=now,
- update_by=actor_user_id,
- update_time=now,
- del_flag=0,
- )
- )
- def list_mentioned_instance_ids(
- db: Session, *, tenant_no: str, user_id: str
- ) -> List[str]:
- """评论@授权的实例。被@即出现在「提到我的」,不因发起人/审批人/抄送身份而隐藏。"""
- uid = (user_id or "").strip()
- if not uid:
- return []
- grants = (
- db.query(OaInstanceViewGrant)
- .filter(
- OaInstanceViewGrant.tenant_no == tenant_no,
- OaInstanceViewGrant.user_id == uid,
- OaInstanceViewGrant.del_flag == 0,
- )
- .order_by(OaInstanceViewGrant.create_time.desc(), OaInstanceViewGrant.id.desc())
- .all()
- )
- inst_ids: List[str] = []
- seen = set()
- for g in grants:
- iid = (g.instance_id or "").strip()
- if iid and iid not in seen:
- seen.add(iid)
- inst_ids.append(iid)
- if not inst_ids:
- return []
- allowed = {
- str(r[0])
- for r in db.query(OaInstance.id)
- .filter(
- OaInstance.tenant_no == tenant_no,
- OaInstance.del_flag == 0,
- OaInstance.id.in_(inst_ids),
- )
- .all()
- }
- return [iid for iid in inst_ids if iid in allowed]
- def create_comment(
- db: Session,
- *,
- instance_id: str,
- tenant_no: str,
- user_id: str,
- dept_id: str,
- content: str,
- attachment_refs: Optional[List[Dict[str, Any]]],
- mention_user_ids: Optional[List[str]] = None,
- ) -> Dict[str, Any]:
- inst = _get_instance(db, instance_id, tenant_no)
- text = (content or "").strip()
- if len(text) > CONTENT_MAX:
- raise OaEngineError(f"评论内容不能超过{CONTENT_MAX}字")
- schema = _load_version_schema(db, inst)
- form = _loads(inst.form_data_json, {})
- if not isinstance(form, dict):
- form = {}
- refs = normalize_attachment_refs(schema, form, attachment_refs, db=db, tenant_no=tenant_no)
- mentions = normalize_mention_user_ids(
- db,
- tenant_no=tenant_no,
- actor_user_id=user_id,
- user_ids=mention_user_ids,
- )
- if not text and not mentions and not refs:
- raise OaEngineError("请填写评论、@人员或添加附件")
- now = datetime.now()
- row = OaInstanceComment(
- id=generate_uuid(),
- instance_id=instance_id,
- content=text,
- attachment_refs_json=json.dumps(refs, ensure_ascii=False) if refs else None,
- mention_user_ids_json=json.dumps(mentions, ensure_ascii=False) if mentions else None,
- status="normal",
- revoked_time=None,
- tenant_no=tenant_no,
- create_by=user_id,
- create_dept_id=dept_id or "",
- create_time=now,
- update_by=user_id,
- update_time=now,
- del_flag=0,
- )
- db.add(row)
- _ensure_view_grants(
- db,
- instance_id=instance_id,
- tenant_no=tenant_no,
- user_ids=mentions,
- comment_id=row.id,
- actor_user_id=user_id,
- dept_id=dept_id or "",
- )
- db.commit()
- db.refresh(row)
- name_map = _user_name_map(db, [user_id, *mentions])
- return _comment_to_dict(
- row,
- name_map=name_map,
- viewer_user_id=user_id,
- instance_status=inst.status or "",
- )
- def revoke_comment(
- db: Session,
- *,
- instance_id: str,
- comment_id: str,
- tenant_no: str,
- user_id: str,
- ) -> Dict[str, Any]:
- inst = _get_instance(db, instance_id, tenant_no)
- row = (
- db.query(OaInstanceComment)
- .filter(
- OaInstanceComment.id == comment_id,
- OaInstanceComment.instance_id == instance_id,
- OaInstanceComment.tenant_no == tenant_no,
- OaInstanceComment.del_flag == 0,
- )
- .first()
- )
- if not row:
- raise OaEngineError("评论不存在")
- if (row.create_by or "") != (user_id or ""):
- raise OaEngineError("只能撤回自己的评论")
- now = datetime.now()
- if (row.status or "") != "revoked":
- row.status = "revoked"
- row.revoked_time = now
- row.update_by = user_id
- row.update_time = now
- db.add(row)
- db.commit()
- db.refresh(row)
- name_map = _user_name_map(db, [row.create_by, *_mention_ids(row)] if row.create_by else _mention_ids(row))
- return _comment_to_dict(
- row,
- name_map=name_map,
- viewer_user_id=user_id,
- instance_status=inst.status or "",
- )
|