oa_instance_comment.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604
  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_rows = (
  301. db.query(OaInstanceViewGrant)
  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. by_uid = {str(r.user_id): r for r in existing_rows}
  311. now = datetime.now()
  312. for uid in user_ids:
  313. row = by_uid.get(uid)
  314. if row is not None:
  315. # 再 @:重置未读
  316. row.is_read = 0
  317. row.comment_id = comment_id
  318. row.granted_by = actor_user_id
  319. row.update_by = actor_user_id
  320. row.update_time = now
  321. continue
  322. db.add(
  323. OaInstanceViewGrant(
  324. id=generate_uuid(),
  325. instance_id=instance_id,
  326. user_id=uid,
  327. source="comment_mention",
  328. comment_id=comment_id,
  329. granted_by=actor_user_id,
  330. is_read=0,
  331. tenant_no=tenant_no,
  332. create_by=actor_user_id,
  333. create_dept_id=dept_id or "",
  334. create_time=now,
  335. update_by=actor_user_id,
  336. update_time=now,
  337. del_flag=0,
  338. )
  339. )
  340. def list_mentioned_instance_ids(
  341. db: Session, *, tenant_no: str, user_id: str
  342. ) -> List[str]:
  343. """评论@授权的实例。被@即出现在「提到我的」,不因发起人/审批人/抄送身份而隐藏。"""
  344. uid = (user_id or "").strip()
  345. if not uid:
  346. return []
  347. grants = (
  348. db.query(OaInstanceViewGrant)
  349. .filter(
  350. OaInstanceViewGrant.tenant_no == tenant_no,
  351. OaInstanceViewGrant.user_id == uid,
  352. OaInstanceViewGrant.del_flag == 0,
  353. )
  354. .order_by(OaInstanceViewGrant.create_time.desc(), OaInstanceViewGrant.id.desc())
  355. .all()
  356. )
  357. inst_ids: List[str] = []
  358. seen = set()
  359. for g in grants:
  360. iid = (g.instance_id or "").strip()
  361. if iid and iid not in seen:
  362. seen.add(iid)
  363. inst_ids.append(iid)
  364. if not inst_ids:
  365. return []
  366. allowed = {
  367. str(r[0])
  368. for r in db.query(OaInstance.id)
  369. .filter(
  370. OaInstance.tenant_no == tenant_no,
  371. OaInstance.del_flag == 0,
  372. OaInstance.id.in_(inst_ids),
  373. )
  374. .all()
  375. }
  376. return [iid for iid in inst_ids if iid in allowed]
  377. def mention_read_map(
  378. db: Session, *, tenant_no: str, user_id: str, instance_ids: List[str]
  379. ) -> Dict[str, bool]:
  380. """instance_id → isRead(无 grant 视为已读)。"""
  381. ids = [str(x).strip() for x in (instance_ids or []) if str(x).strip()]
  382. uid = (user_id or "").strip()
  383. if not ids or not uid:
  384. return {}
  385. rows = (
  386. db.query(OaInstanceViewGrant)
  387. .filter(
  388. OaInstanceViewGrant.tenant_no == tenant_no,
  389. OaInstanceViewGrant.user_id == uid,
  390. OaInstanceViewGrant.del_flag == 0,
  391. OaInstanceViewGrant.instance_id.in_(ids),
  392. )
  393. .all()
  394. )
  395. out: Dict[str, bool] = {iid: True for iid in ids}
  396. for g in rows:
  397. iid = (g.instance_id or "").strip()
  398. if iid:
  399. out[iid] = bool(int(getattr(g, "is_read", 0) or 0) == 1)
  400. return out
  401. def count_mention_unread(db: Session, *, tenant_no: str, user_id: str) -> int:
  402. uid = (user_id or "").strip()
  403. if not uid:
  404. return 0
  405. q = (
  406. db.query(OaInstanceViewGrant)
  407. .join(
  408. OaInstance,
  409. OaInstance.id == OaInstanceViewGrant.instance_id,
  410. )
  411. .filter(
  412. OaInstanceViewGrant.tenant_no == tenant_no,
  413. OaInstanceViewGrant.user_id == uid,
  414. OaInstanceViewGrant.del_flag == 0,
  415. OaInstanceViewGrant.is_read == 0,
  416. OaInstance.tenant_no == tenant_no,
  417. OaInstance.del_flag == 0,
  418. )
  419. )
  420. return int(q.count())
  421. def mark_mention_instance_read(
  422. db: Session, *, tenant_no: str, user_id: str, instance_id: str
  423. ) -> Dict[str, Any]:
  424. uid = (user_id or "").strip()
  425. iid = (instance_id or "").strip()
  426. if not uid or not iid:
  427. raise OaEngineError("参数无效")
  428. row = (
  429. db.query(OaInstanceViewGrant)
  430. .filter(
  431. OaInstanceViewGrant.tenant_no == tenant_no,
  432. OaInstanceViewGrant.user_id == uid,
  433. OaInstanceViewGrant.instance_id == iid,
  434. OaInstanceViewGrant.del_flag == 0,
  435. )
  436. .first()
  437. )
  438. if not row:
  439. raise OaEngineError("提到记录不存在或无权操作")
  440. now = datetime.now()
  441. if int(getattr(row, "is_read", 0) or 0) != 1:
  442. row.is_read = 1
  443. row.update_by = uid
  444. row.update_time = now
  445. db.commit()
  446. db.refresh(row)
  447. return {"instanceId": iid, "isRead": True}
  448. def create_comment(
  449. db: Session,
  450. *,
  451. instance_id: str,
  452. tenant_no: str,
  453. user_id: str,
  454. dept_id: str,
  455. content: str,
  456. attachment_refs: Optional[List[Dict[str, Any]]],
  457. mention_user_ids: Optional[List[str]] = None,
  458. ) -> Dict[str, Any]:
  459. inst = _get_instance(db, instance_id, tenant_no)
  460. text = (content or "").strip()
  461. if len(text) > CONTENT_MAX:
  462. raise OaEngineError(f"评论内容不能超过{CONTENT_MAX}字")
  463. schema = _load_version_schema(db, inst)
  464. form = _loads(inst.form_data_json, {})
  465. if not isinstance(form, dict):
  466. form = {}
  467. refs = normalize_attachment_refs(schema, form, attachment_refs, db=db, tenant_no=tenant_no)
  468. mentions = normalize_mention_user_ids(
  469. db,
  470. tenant_no=tenant_no,
  471. actor_user_id=user_id,
  472. user_ids=mention_user_ids,
  473. )
  474. if not text and not mentions and not refs:
  475. raise OaEngineError("请填写评论、@人员或添加附件")
  476. now = datetime.now()
  477. row = OaInstanceComment(
  478. id=generate_uuid(),
  479. instance_id=instance_id,
  480. content=text,
  481. attachment_refs_json=json.dumps(refs, ensure_ascii=False) if refs else None,
  482. mention_user_ids_json=json.dumps(mentions, ensure_ascii=False) if mentions else None,
  483. status="normal",
  484. revoked_time=None,
  485. tenant_no=tenant_no,
  486. create_by=user_id,
  487. create_dept_id=dept_id or "",
  488. create_time=now,
  489. update_by=user_id,
  490. update_time=now,
  491. del_flag=0,
  492. )
  493. db.add(row)
  494. _ensure_view_grants(
  495. db,
  496. instance_id=instance_id,
  497. tenant_no=tenant_no,
  498. user_ids=mentions,
  499. comment_id=row.id,
  500. actor_user_id=user_id,
  501. dept_id=dept_id or "",
  502. )
  503. db.commit()
  504. db.refresh(row)
  505. if mentions:
  506. from app.services.oa_wechat_notify import schedule_oa_mention_notifies
  507. schedule_oa_mention_notifies(
  508. instance_id=instance_id,
  509. commenter_user_id=user_id,
  510. mentioned_user_ids=mentions,
  511. )
  512. name_map = _user_name_map(db, [user_id, *mentions])
  513. return _comment_to_dict(
  514. row,
  515. name_map=name_map,
  516. viewer_user_id=user_id,
  517. instance_status=inst.status or "",
  518. )
  519. def revoke_comment(
  520. db: Session,
  521. *,
  522. instance_id: str,
  523. comment_id: str,
  524. tenant_no: str,
  525. user_id: str,
  526. ) -> Dict[str, Any]:
  527. inst = _get_instance(db, instance_id, tenant_no)
  528. row = (
  529. db.query(OaInstanceComment)
  530. .filter(
  531. OaInstanceComment.id == comment_id,
  532. OaInstanceComment.instance_id == instance_id,
  533. OaInstanceComment.tenant_no == tenant_no,
  534. OaInstanceComment.del_flag == 0,
  535. )
  536. .first()
  537. )
  538. if not row:
  539. raise OaEngineError("评论不存在")
  540. if (row.create_by or "") != (user_id or ""):
  541. raise OaEngineError("只能撤回自己的评论")
  542. now = datetime.now()
  543. if (row.status or "") != "revoked":
  544. row.status = "revoked"
  545. row.revoked_time = now
  546. row.update_by = user_id
  547. row.update_time = now
  548. db.add(row)
  549. db.commit()
  550. db.refresh(row)
  551. name_map = _user_name_map(db, [row.create_by, *_mention_ids(row)] if row.create_by else _mention_ids(row))
  552. return _comment_to_dict(
  553. row,
  554. name_map=name_map,
  555. viewer_user_id=user_id,
  556. instance_status=inst.status or "",
  557. )