oa_instance_comment.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. """OA 审批实例评论:附件枚举、校验、CRUD"""
  2. from __future__ import annotations
  3. import json
  4. from datetime import datetime
  5. from typing import Any, Dict, List, Optional
  6. from sqlalchemy.orm import Session
  7. from app.core.utils import generate_uuid
  8. from app.models.models import (
  9. OaAppVersion,
  10. OaInstance,
  11. OaInstanceComment,
  12. OaInstanceViewGrant,
  13. SysUser,
  14. )
  15. from app.services.oa_engine import OaEngineError, _get_instance, _loads, _user_name_map
  16. from app.services.oa_related_approval import (
  17. UPLOAD_TYPES,
  18. _extract_upload_urls,
  19. _schema_rules,
  20. _upload_display_name,
  21. )
  22. CONTENT_MAX = 1024
  23. MENTION_MAX = 20
  24. def enumerate_form_attachments(
  25. schema: Any, form_data: Optional[Dict[str, Any]]
  26. ) -> List[Dict[str, Any]]:
  27. data = form_data or {}
  28. items: List[Dict[str, Any]] = []
  29. for rule in _schema_rules(schema):
  30. t = str(rule.get("type") or "")
  31. if t not in UPLOAD_TYPES:
  32. continue
  33. field = str(rule.get("field") or rule.get("name") or "").strip()
  34. if not field:
  35. continue
  36. label = str(rule.get("title") or rule.get("label") or field).strip() or field
  37. raw = data.get(field)
  38. files: List[Dict[str, str]] = []
  39. if isinstance(raw, list):
  40. for it in raw:
  41. if isinstance(it, dict):
  42. u = (_extract_upload_urls(it) or [""])[0]
  43. if not u:
  44. continue
  45. files.append(
  46. {
  47. "url": u,
  48. "fileName": _upload_display_name(it, u),
  49. }
  50. )
  51. else:
  52. for u in _extract_upload_urls(it):
  53. files.append({"url": u, "fileName": _upload_display_name(it, u)})
  54. else:
  55. for u in _extract_upload_urls(raw):
  56. files.append({"url": u, "fileName": _upload_display_name(raw, u)})
  57. if not files:
  58. continue
  59. items.append({"fieldKey": field, "fieldLabel": label, "files": files})
  60. return items
  61. def _allowed_url_index(
  62. schema: Any, form_data: Optional[Dict[str, Any]]
  63. ) -> Dict[str, Dict[str, str]]:
  64. """url -> {fieldKey, fieldLabel, fileName}"""
  65. index: Dict[str, Dict[str, str]] = {}
  66. for group in enumerate_form_attachments(schema, form_data):
  67. for f in group["files"]:
  68. u = str(f.get("url") or "").strip()
  69. if not u:
  70. continue
  71. index[u] = {
  72. "fieldKey": group["fieldKey"],
  73. "fieldLabel": group["fieldLabel"],
  74. "fileName": str(f.get("fileName") or "").strip() or u,
  75. }
  76. return index
  77. def _attachment_url_candidates(url: str) -> List[str]:
  78. raw = (url or "").strip()
  79. path = raw.split("?", 1)[0].strip()
  80. found: List[str] = []
  81. for item in (raw, path, path.lstrip("/")):
  82. if item and item not in found:
  83. found.append(item)
  84. try:
  85. from app.core.config import settings
  86. base = (getattr(settings, "MINIO_BASE_URL", None) or "").rstrip("/")
  87. except Exception:
  88. base = ""
  89. if base and path.startswith(base + "/"):
  90. rel = path[len(base) + 1 :]
  91. if rel and rel not in found:
  92. found.append(rel)
  93. return found
  94. def _find_uploaded_attachment(db: Session, tenant_no: str, url: str):
  95. from app.models.models import BizAttachment
  96. q = db.query(BizAttachment).filter(
  97. BizAttachment.del_flag == 0,
  98. BizAttachment.tenant_no == tenant_no,
  99. )
  100. for candidate in _attachment_url_candidates(url):
  101. row = q.filter(BizAttachment.file_url == candidate).first()
  102. if row is not None:
  103. return row
  104. return None
  105. def normalize_attachment_refs(
  106. schema: Any,
  107. form_data: Optional[Dict[str, Any]],
  108. refs: Optional[List[Dict[str, Any]]],
  109. db: Optional[Session] = None,
  110. tenant_no: str = "",
  111. ) -> List[Dict[str, str]]:
  112. if not refs:
  113. return []
  114. if not isinstance(refs, list):
  115. raise OaEngineError("attachmentRefs 格式无效")
  116. index = _allowed_url_index(schema, form_data)
  117. out: List[Dict[str, str]] = []
  118. seen = set()
  119. for raw in refs:
  120. if not isinstance(raw, dict):
  121. raise OaEngineError("引用的附件不存在或不属于本表单")
  122. url = str(raw.get("url") or "").strip()
  123. source = str(raw.get("source") or "form").strip() or "form"
  124. if not url or url in seen:
  125. if not url:
  126. raise OaEngineError("引用的附件不存在或不属于本表单")
  127. continue
  128. if source == "upload":
  129. if db is None:
  130. raise OaEngineError("上传的附件不存在")
  131. row = _find_uploaded_attachment(db, tenant_no, url)
  132. if row is None:
  133. raise OaEngineError("上传的附件不存在")
  134. seen.add(url)
  135. out.append(
  136. {
  137. "source": "upload",
  138. "fieldKey": "",
  139. "fieldLabel": "",
  140. "url": str(row.file_url or url),
  141. "fileName": str(row.original_name or "").strip() or url,
  142. }
  143. )
  144. continue
  145. if source != "form":
  146. raise OaEngineError("attachmentRefs 格式无效")
  147. if url not in index:
  148. raise OaEngineError("引用的附件不存在或不属于本表单")
  149. seen.add(url)
  150. meta = index[url]
  151. out.append(
  152. {
  153. "source": "form",
  154. "fieldKey": meta["fieldKey"],
  155. "fieldLabel": meta["fieldLabel"],
  156. "url": url,
  157. "fileName": meta["fileName"],
  158. }
  159. )
  160. return out
  161. def _fmt_time(dt: Optional[datetime]) -> str:
  162. return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else ""
  163. def _mention_ids(row: OaInstanceComment) -> List[str]:
  164. raw = _loads(getattr(row, "mention_user_ids_json", None), [])
  165. if not isinstance(raw, list):
  166. return []
  167. out: List[str] = []
  168. for item in raw:
  169. uid = str(item or "").strip()
  170. if uid and uid not in out:
  171. out.append(uid)
  172. return out
  173. def _mentions_payload(row: OaInstanceComment, name_map: Dict[str, str]) -> List[Dict[str, str]]:
  174. return [
  175. {"userId": uid, "userName": name_map.get(uid, uid)}
  176. for uid in _mention_ids(row)
  177. ]
  178. def _comment_to_dict(
  179. row: OaInstanceComment,
  180. *,
  181. name_map: Dict[str, str],
  182. viewer_user_id: str,
  183. instance_status: str,
  184. ) -> Dict[str, Any]:
  185. refs = _loads(row.attachment_refs_json, [])
  186. if not isinstance(refs, list):
  187. refs = []
  188. status = (row.status or "normal").strip() or "normal"
  189. can_revoke = status == "normal" and (row.create_by or "") == (viewer_user_id or "")
  190. return {
  191. "id": row.id,
  192. "instanceId": row.instance_id,
  193. "content": row.content or "",
  194. "attachmentRefs": refs,
  195. "status": status,
  196. "revokedTime": _fmt_time(row.revoked_time),
  197. "createBy": row.create_by or "",
  198. "createByName": name_map.get(row.create_by or "", row.create_by or ""),
  199. "createTime": _fmt_time(row.create_time),
  200. "canRevoke": can_revoke,
  201. "mentions": _mentions_payload(row, name_map),
  202. }
  203. def _load_version_schema(db: Session, inst: OaInstance) -> Any:
  204. ver = (
  205. db.query(OaAppVersion)
  206. .filter(OaAppVersion.id == inst.app_version_id, OaAppVersion.del_flag == 0)
  207. .first()
  208. )
  209. if not ver:
  210. return {}
  211. return _loads(ver.form_schema_json, {})
  212. def list_comments(
  213. db: Session,
  214. *,
  215. instance_id: str,
  216. tenant_no: str,
  217. viewer_user_id: str,
  218. ) -> List[Dict[str, Any]]:
  219. inst = _get_instance(db, instance_id, tenant_no)
  220. rows = (
  221. db.query(OaInstanceComment)
  222. .filter(
  223. OaInstanceComment.instance_id == instance_id,
  224. OaInstanceComment.tenant_no == tenant_no,
  225. OaInstanceComment.del_flag == 0,
  226. )
  227. .order_by(OaInstanceComment.create_time.asc())
  228. .all()
  229. )
  230. name_ids = [r.create_by for r in rows if r.create_by]
  231. for r in rows:
  232. name_ids.extend(_mention_ids(r))
  233. name_map = _user_name_map(db, name_ids)
  234. return [
  235. _comment_to_dict(
  236. r,
  237. name_map=name_map,
  238. viewer_user_id=viewer_user_id,
  239. instance_status=inst.status or "",
  240. )
  241. for r in rows
  242. ]
  243. def list_form_attachments(
  244. db: Session, *, instance_id: str, tenant_no: str
  245. ) -> List[Dict[str, Any]]:
  246. inst = _get_instance(db, instance_id, tenant_no)
  247. schema = _load_version_schema(db, inst)
  248. form = _loads(inst.form_data_json, {})
  249. if not isinstance(form, dict):
  250. form = {}
  251. return enumerate_form_attachments(schema, form)
  252. def normalize_mention_user_ids(
  253. db: Session,
  254. *,
  255. tenant_no: str,
  256. actor_user_id: str,
  257. user_ids: Optional[List[str]],
  258. ) -> List[str]:
  259. if not user_ids:
  260. return []
  261. if not isinstance(user_ids, list):
  262. raise OaEngineError("mentionUserIds 格式无效")
  263. actor = (actor_user_id or "").strip()
  264. seen: List[str] = []
  265. for raw in user_ids:
  266. uid = str(raw or "").strip()
  267. if not uid or uid in seen or uid == actor:
  268. continue
  269. seen.append(uid)
  270. if len(seen) > MENTION_MAX:
  271. raise OaEngineError(f"一次最多@{MENTION_MAX}人")
  272. if not seen:
  273. return []
  274. rows = (
  275. db.query(SysUser.id)
  276. .filter(
  277. SysUser.id.in_(seen),
  278. SysUser.tenant_no == tenant_no,
  279. SysUser.del_flag == 0,
  280. SysUser.status == "1",
  281. )
  282. .all()
  283. )
  284. found = {str(r[0]) for r in rows}
  285. if any(uid not in found for uid in seen):
  286. raise OaEngineError("提及的用户不存在或已停用")
  287. return seen
  288. def _ensure_view_grants(
  289. db: Session,
  290. *,
  291. instance_id: str,
  292. tenant_no: str,
  293. user_ids: List[str],
  294. comment_id: str,
  295. actor_user_id: str,
  296. dept_id: str,
  297. ) -> None:
  298. if not user_ids:
  299. return
  300. existing = (
  301. db.query(OaInstanceViewGrant.user_id)
  302. .filter(
  303. OaInstanceViewGrant.instance_id == instance_id,
  304. OaInstanceViewGrant.tenant_no == tenant_no,
  305. OaInstanceViewGrant.del_flag == 0,
  306. OaInstanceViewGrant.user_id.in_(user_ids),
  307. )
  308. .all()
  309. )
  310. have = {str(r[0]) for r in existing}
  311. now = datetime.now()
  312. for uid in user_ids:
  313. if uid in have:
  314. continue
  315. db.add(
  316. OaInstanceViewGrant(
  317. id=generate_uuid(),
  318. instance_id=instance_id,
  319. user_id=uid,
  320. source="comment_mention",
  321. comment_id=comment_id,
  322. granted_by=actor_user_id,
  323. tenant_no=tenant_no,
  324. create_by=actor_user_id,
  325. create_dept_id=dept_id or "",
  326. create_time=now,
  327. update_by=actor_user_id,
  328. update_time=now,
  329. del_flag=0,
  330. )
  331. )
  332. def list_mentioned_instance_ids(
  333. db: Session, *, tenant_no: str, user_id: str
  334. ) -> List[str]:
  335. """评论@授权的实例。被@即出现在「提到我的」,不因发起人/审批人/抄送身份而隐藏。"""
  336. uid = (user_id or "").strip()
  337. if not uid:
  338. return []
  339. grants = (
  340. db.query(OaInstanceViewGrant)
  341. .filter(
  342. OaInstanceViewGrant.tenant_no == tenant_no,
  343. OaInstanceViewGrant.user_id == uid,
  344. OaInstanceViewGrant.del_flag == 0,
  345. )
  346. .order_by(OaInstanceViewGrant.create_time.desc(), OaInstanceViewGrant.id.desc())
  347. .all()
  348. )
  349. inst_ids: List[str] = []
  350. seen = set()
  351. for g in grants:
  352. iid = (g.instance_id or "").strip()
  353. if iid and iid not in seen:
  354. seen.add(iid)
  355. inst_ids.append(iid)
  356. if not inst_ids:
  357. return []
  358. allowed = {
  359. str(r[0])
  360. for r in db.query(OaInstance.id)
  361. .filter(
  362. OaInstance.tenant_no == tenant_no,
  363. OaInstance.del_flag == 0,
  364. OaInstance.id.in_(inst_ids),
  365. )
  366. .all()
  367. }
  368. return [iid for iid in inst_ids if iid in allowed]
  369. def create_comment(
  370. db: Session,
  371. *,
  372. instance_id: str,
  373. tenant_no: str,
  374. user_id: str,
  375. dept_id: str,
  376. content: str,
  377. attachment_refs: Optional[List[Dict[str, Any]]],
  378. mention_user_ids: Optional[List[str]] = None,
  379. ) -> Dict[str, Any]:
  380. inst = _get_instance(db, instance_id, tenant_no)
  381. text = (content or "").strip()
  382. if len(text) > CONTENT_MAX:
  383. raise OaEngineError(f"评论内容不能超过{CONTENT_MAX}字")
  384. schema = _load_version_schema(db, inst)
  385. form = _loads(inst.form_data_json, {})
  386. if not isinstance(form, dict):
  387. form = {}
  388. refs = normalize_attachment_refs(schema, form, attachment_refs, db=db, tenant_no=tenant_no)
  389. mentions = normalize_mention_user_ids(
  390. db,
  391. tenant_no=tenant_no,
  392. actor_user_id=user_id,
  393. user_ids=mention_user_ids,
  394. )
  395. if not text and not mentions and not refs:
  396. raise OaEngineError("请填写评论、@人员或添加附件")
  397. now = datetime.now()
  398. row = OaInstanceComment(
  399. id=generate_uuid(),
  400. instance_id=instance_id,
  401. content=text,
  402. attachment_refs_json=json.dumps(refs, ensure_ascii=False) if refs else None,
  403. mention_user_ids_json=json.dumps(mentions, ensure_ascii=False) if mentions else None,
  404. status="normal",
  405. revoked_time=None,
  406. tenant_no=tenant_no,
  407. create_by=user_id,
  408. create_dept_id=dept_id or "",
  409. create_time=now,
  410. update_by=user_id,
  411. update_time=now,
  412. del_flag=0,
  413. )
  414. db.add(row)
  415. _ensure_view_grants(
  416. db,
  417. instance_id=instance_id,
  418. tenant_no=tenant_no,
  419. user_ids=mentions,
  420. comment_id=row.id,
  421. actor_user_id=user_id,
  422. dept_id=dept_id or "",
  423. )
  424. db.commit()
  425. db.refresh(row)
  426. name_map = _user_name_map(db, [user_id, *mentions])
  427. return _comment_to_dict(
  428. row,
  429. name_map=name_map,
  430. viewer_user_id=user_id,
  431. instance_status=inst.status or "",
  432. )
  433. def revoke_comment(
  434. db: Session,
  435. *,
  436. instance_id: str,
  437. comment_id: str,
  438. tenant_no: str,
  439. user_id: str,
  440. ) -> Dict[str, Any]:
  441. inst = _get_instance(db, instance_id, tenant_no)
  442. row = (
  443. db.query(OaInstanceComment)
  444. .filter(
  445. OaInstanceComment.id == comment_id,
  446. OaInstanceComment.instance_id == instance_id,
  447. OaInstanceComment.tenant_no == tenant_no,
  448. OaInstanceComment.del_flag == 0,
  449. )
  450. .first()
  451. )
  452. if not row:
  453. raise OaEngineError("评论不存在")
  454. if (row.create_by or "") != (user_id or ""):
  455. raise OaEngineError("只能撤回自己的评论")
  456. now = datetime.now()
  457. if (row.status or "") != "revoked":
  458. row.status = "revoked"
  459. row.revoked_time = now
  460. row.update_by = user_id
  461. row.update_time = now
  462. db.add(row)
  463. db.commit()
  464. db.refresh(row)
  465. name_map = _user_name_map(db, [row.create_by, *_mention_ids(row)] if row.create_by else _mention_ids(row))
  466. return _comment_to_dict(
  467. row,
  468. name_map=name_map,
  469. viewer_user_id=user_id,
  470. instance_status=inst.status or "",
  471. )