oa_instance_comment.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519
  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 = (
  190. status == "normal"
  191. and instance_status == "running"
  192. and (row.create_by or "") == (viewer_user_id or "")
  193. )
  194. return {
  195. "id": row.id,
  196. "instanceId": row.instance_id,
  197. "content": row.content or "",
  198. "attachmentRefs": refs,
  199. "status": status,
  200. "revokedTime": _fmt_time(row.revoked_time),
  201. "createBy": row.create_by or "",
  202. "createByName": name_map.get(row.create_by or "", row.create_by or ""),
  203. "createTime": _fmt_time(row.create_time),
  204. "canRevoke": can_revoke,
  205. "mentions": _mentions_payload(row, name_map),
  206. }
  207. def _load_version_schema(db: Session, inst: OaInstance) -> Any:
  208. ver = (
  209. db.query(OaAppVersion)
  210. .filter(OaAppVersion.id == inst.app_version_id, OaAppVersion.del_flag == 0)
  211. .first()
  212. )
  213. if not ver:
  214. return {}
  215. return _loads(ver.form_schema_json, {})
  216. def list_comments(
  217. db: Session,
  218. *,
  219. instance_id: str,
  220. tenant_no: str,
  221. viewer_user_id: str,
  222. ) -> List[Dict[str, Any]]:
  223. inst = _get_instance(db, instance_id, tenant_no)
  224. rows = (
  225. db.query(OaInstanceComment)
  226. .filter(
  227. OaInstanceComment.instance_id == instance_id,
  228. OaInstanceComment.tenant_no == tenant_no,
  229. OaInstanceComment.del_flag == 0,
  230. )
  231. .order_by(OaInstanceComment.create_time.asc())
  232. .all()
  233. )
  234. name_ids = [r.create_by for r in rows if r.create_by]
  235. for r in rows:
  236. name_ids.extend(_mention_ids(r))
  237. name_map = _user_name_map(db, name_ids)
  238. return [
  239. _comment_to_dict(
  240. r,
  241. name_map=name_map,
  242. viewer_user_id=viewer_user_id,
  243. instance_status=inst.status or "",
  244. )
  245. for r in rows
  246. ]
  247. def list_form_attachments(
  248. db: Session, *, instance_id: str, tenant_no: str
  249. ) -> List[Dict[str, Any]]:
  250. inst = _get_instance(db, instance_id, tenant_no)
  251. schema = _load_version_schema(db, inst)
  252. form = _loads(inst.form_data_json, {})
  253. if not isinstance(form, dict):
  254. form = {}
  255. return enumerate_form_attachments(schema, form)
  256. def normalize_mention_user_ids(
  257. db: Session,
  258. *,
  259. tenant_no: str,
  260. actor_user_id: str,
  261. user_ids: Optional[List[str]],
  262. ) -> List[str]:
  263. if not user_ids:
  264. return []
  265. if not isinstance(user_ids, list):
  266. raise OaEngineError("mentionUserIds 格式无效")
  267. actor = (actor_user_id or "").strip()
  268. seen: List[str] = []
  269. for raw in user_ids:
  270. uid = str(raw or "").strip()
  271. if not uid or uid in seen or uid == actor:
  272. continue
  273. seen.append(uid)
  274. if len(seen) > MENTION_MAX:
  275. raise OaEngineError(f"一次最多@{MENTION_MAX}人")
  276. if not seen:
  277. return []
  278. rows = (
  279. db.query(SysUser.id)
  280. .filter(
  281. SysUser.id.in_(seen),
  282. SysUser.tenant_no == tenant_no,
  283. SysUser.del_flag == 0,
  284. SysUser.status == "1",
  285. )
  286. .all()
  287. )
  288. found = {str(r[0]) for r in rows}
  289. if any(uid not in found for uid in seen):
  290. raise OaEngineError("提及的用户不存在或已停用")
  291. return seen
  292. def _ensure_view_grants(
  293. db: Session,
  294. *,
  295. instance_id: str,
  296. tenant_no: str,
  297. user_ids: List[str],
  298. comment_id: str,
  299. actor_user_id: str,
  300. dept_id: str,
  301. ) -> None:
  302. if not user_ids:
  303. return
  304. existing = (
  305. db.query(OaInstanceViewGrant.user_id)
  306. .filter(
  307. OaInstanceViewGrant.instance_id == instance_id,
  308. OaInstanceViewGrant.tenant_no == tenant_no,
  309. OaInstanceViewGrant.del_flag == 0,
  310. OaInstanceViewGrant.user_id.in_(user_ids),
  311. )
  312. .all()
  313. )
  314. have = {str(r[0]) for r in existing}
  315. now = datetime.now()
  316. for uid in user_ids:
  317. if uid in have:
  318. continue
  319. db.add(
  320. OaInstanceViewGrant(
  321. id=generate_uuid(),
  322. instance_id=instance_id,
  323. user_id=uid,
  324. source="comment_mention",
  325. comment_id=comment_id,
  326. granted_by=actor_user_id,
  327. tenant_no=tenant_no,
  328. create_by=actor_user_id,
  329. create_dept_id=dept_id or "",
  330. create_time=now,
  331. update_by=actor_user_id,
  332. update_time=now,
  333. del_flag=0,
  334. )
  335. )
  336. def list_mentioned_instance_ids(
  337. db: Session, *, tenant_no: str, user_id: str
  338. ) -> List[str]:
  339. """评论@授权的实例。被@即出现在「提到我的」,不因发起人/审批人/抄送身份而隐藏。"""
  340. uid = (user_id or "").strip()
  341. if not uid:
  342. return []
  343. grants = (
  344. db.query(OaInstanceViewGrant)
  345. .filter(
  346. OaInstanceViewGrant.tenant_no == tenant_no,
  347. OaInstanceViewGrant.user_id == uid,
  348. OaInstanceViewGrant.del_flag == 0,
  349. )
  350. .order_by(OaInstanceViewGrant.create_time.desc(), OaInstanceViewGrant.id.desc())
  351. .all()
  352. )
  353. inst_ids: List[str] = []
  354. seen = set()
  355. for g in grants:
  356. iid = (g.instance_id or "").strip()
  357. if iid and iid not in seen:
  358. seen.add(iid)
  359. inst_ids.append(iid)
  360. if not inst_ids:
  361. return []
  362. allowed = {
  363. str(r[0])
  364. for r in db.query(OaInstance.id)
  365. .filter(
  366. OaInstance.tenant_no == tenant_no,
  367. OaInstance.del_flag == 0,
  368. OaInstance.id.in_(inst_ids),
  369. )
  370. .all()
  371. }
  372. return [iid for iid in inst_ids if iid in allowed]
  373. def create_comment(
  374. db: Session,
  375. *,
  376. instance_id: str,
  377. tenant_no: str,
  378. user_id: str,
  379. dept_id: str,
  380. content: str,
  381. attachment_refs: Optional[List[Dict[str, Any]]],
  382. mention_user_ids: Optional[List[str]] = None,
  383. ) -> Dict[str, Any]:
  384. inst = _get_instance(db, instance_id, tenant_no)
  385. if (inst.status or "") != "running":
  386. raise OaEngineError("仅审批进行中可评论")
  387. text = (content or "").strip()
  388. if len(text) > CONTENT_MAX:
  389. raise OaEngineError(f"评论内容不能超过{CONTENT_MAX}字")
  390. schema = _load_version_schema(db, inst)
  391. form = _loads(inst.form_data_json, {})
  392. if not isinstance(form, dict):
  393. form = {}
  394. refs = normalize_attachment_refs(schema, form, attachment_refs, db=db, tenant_no=tenant_no)
  395. mentions = normalize_mention_user_ids(
  396. db,
  397. tenant_no=tenant_no,
  398. actor_user_id=user_id,
  399. user_ids=mention_user_ids,
  400. )
  401. if not text and not mentions and not refs:
  402. raise OaEngineError("请填写评论、@人员或添加附件")
  403. now = datetime.now()
  404. row = OaInstanceComment(
  405. id=generate_uuid(),
  406. instance_id=instance_id,
  407. content=text,
  408. attachment_refs_json=json.dumps(refs, ensure_ascii=False) if refs else None,
  409. mention_user_ids_json=json.dumps(mentions, ensure_ascii=False) if mentions else None,
  410. status="normal",
  411. revoked_time=None,
  412. tenant_no=tenant_no,
  413. create_by=user_id,
  414. create_dept_id=dept_id or "",
  415. create_time=now,
  416. update_by=user_id,
  417. update_time=now,
  418. del_flag=0,
  419. )
  420. db.add(row)
  421. _ensure_view_grants(
  422. db,
  423. instance_id=instance_id,
  424. tenant_no=tenant_no,
  425. user_ids=mentions,
  426. comment_id=row.id,
  427. actor_user_id=user_id,
  428. dept_id=dept_id or "",
  429. )
  430. db.commit()
  431. db.refresh(row)
  432. name_map = _user_name_map(db, [user_id, *mentions])
  433. return _comment_to_dict(
  434. row,
  435. name_map=name_map,
  436. viewer_user_id=user_id,
  437. instance_status=inst.status or "",
  438. )
  439. def revoke_comment(
  440. db: Session,
  441. *,
  442. instance_id: str,
  443. comment_id: str,
  444. tenant_no: str,
  445. user_id: str,
  446. ) -> Dict[str, Any]:
  447. inst = _get_instance(db, instance_id, tenant_no)
  448. if (inst.status or "") != "running":
  449. raise OaEngineError("仅审批进行中可撤回评论")
  450. row = (
  451. db.query(OaInstanceComment)
  452. .filter(
  453. OaInstanceComment.id == comment_id,
  454. OaInstanceComment.instance_id == instance_id,
  455. OaInstanceComment.tenant_no == tenant_no,
  456. OaInstanceComment.del_flag == 0,
  457. )
  458. .first()
  459. )
  460. if not row:
  461. raise OaEngineError("评论不存在")
  462. if (row.create_by or "") != (user_id or ""):
  463. raise OaEngineError("只能撤回自己的评论")
  464. now = datetime.now()
  465. if (row.status or "") != "revoked":
  466. row.status = "revoked"
  467. row.revoked_time = now
  468. row.update_by = user_id
  469. row.update_time = now
  470. db.add(row)
  471. db.commit()
  472. db.refresh(row)
  473. name_map = _user_name_map(db, [row.create_by, *_mention_ids(row)] if row.create_by else _mention_ids(row))
  474. return _comment_to_dict(
  475. row,
  476. name_map=name_map,
  477. viewer_user_id=user_id,
  478. instance_status=inst.status or "",
  479. )