technician_reviews.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. from __future__ import annotations
  2. import asyncio
  3. from html import escape
  4. from typing import Any
  5. from aiohttp import ClientError, ClientSession, ClientTimeout
  6. from wbb.utils.dbservice import (
  7. get_review,
  8. get_service_settings,
  9. get_technician_review_topic,
  10. get_technician_service_profile,
  11. mark_review_topic_publish_error,
  12. mark_review_topic_published,
  13. save_technician_review_topic,
  14. )
  15. class ReviewTopicError(RuntimeError):
  16. pass
  17. _topic_locks: dict[int, asyncio.Lock] = {}
  18. async def _bot_api(
  19. bot_token: str,
  20. method: str,
  21. payload: dict[str, Any],
  22. ) -> dict[str, Any]:
  23. if not bot_token:
  24. raise ReviewTopicError("当前 Bot 凭据不可用。")
  25. url = f"https://api.telegram.org/bot{bot_token}/{method}"
  26. try:
  27. async with ClientSession(timeout=ClientTimeout(total=20)) as session:
  28. async with session.post(url, json=payload) as response:
  29. data = await response.json(content_type=None)
  30. except (ClientError, TimeoutError, ValueError) as exc:
  31. raise ReviewTopicError("Telegram 评价话题请求失败。") from exc
  32. if response.status >= 400 or not data.get("ok"):
  33. description = str(data.get("description") or "Telegram 返回未知错误")
  34. raise ReviewTopicError(description[:240])
  35. result = data.get("result")
  36. return result if isinstance(result, dict) else {}
  37. def _topic_url(forum_username: str, message_thread_id: int) -> str:
  38. username = str(forum_username or "").strip().lstrip("@")
  39. if not username:
  40. raise ReviewTopicError("请先配置公开评价群用户名。")
  41. return f"https://t.me/{username}/{int(message_thread_id)}"
  42. async def ensure_technician_review_topic(
  43. bot_token: str,
  44. technician_id: int,
  45. ) -> dict[str, Any] | None:
  46. settings = await get_service_settings()
  47. forum_chat_id = str(settings.get("review_forum_chat_id") or "").strip()
  48. forum_username = str(settings.get("review_forum_username") or "").strip()
  49. if not forum_chat_id or not forum_username or not bot_token:
  50. return None
  51. technician_id = int(technician_id)
  52. current = await get_technician_review_topic(technician_id)
  53. if current and str(current.get("forum_chat_id")) == forum_chat_id:
  54. return current
  55. lock = _topic_locks.setdefault(technician_id, asyncio.Lock())
  56. async with lock:
  57. current = await get_technician_review_topic(technician_id)
  58. if current and str(current.get("forum_chat_id")) == forum_chat_id:
  59. return current
  60. profile = await get_technician_service_profile(technician_id)
  61. display_name = str(
  62. profile.get("display_name") or f"技师 {technician_id}"
  63. ).strip()
  64. topic_name = f"{display_name} · 评价"[:128]
  65. created = await _bot_api(
  66. bot_token,
  67. "createForumTopic",
  68. {
  69. "chat_id": forum_chat_id,
  70. "name": topic_name,
  71. },
  72. )
  73. message_thread_id = int(created.get("message_thread_id") or 0)
  74. if message_thread_id <= 0:
  75. raise ReviewTopicError("Telegram 未返回有效的话题 ID。")
  76. topic = await save_technician_review_topic(
  77. technician_id=technician_id,
  78. forum_chat_id=forum_chat_id,
  79. forum_username=forum_username,
  80. message_thread_id=message_thread_id,
  81. topic_name=topic_name,
  82. topic_url=_topic_url(forum_username, message_thread_id),
  83. )
  84. await _bot_api(
  85. bot_token,
  86. "sendMessage",
  87. {
  88. "chat_id": forum_chat_id,
  89. "message_thread_id": message_thread_id,
  90. "text": (
  91. f"<b>{escape(display_name)}的公开评价</b>\n"
  92. "管理员审核通过的评价会自动发布在这里,任何群成员均可查看。"
  93. ),
  94. "parse_mode": "HTML",
  95. "disable_web_page_preview": True,
  96. },
  97. )
  98. return topic
  99. def _review_message(review: dict[str, Any]) -> str:
  100. template = review.get("template_snapshot") or {}
  101. labels = {
  102. str(question.get("question_id")): str(question.get("label") or "")
  103. for question in template.get("questions", [])
  104. }
  105. author = (
  106. "匿名顾客"
  107. if review.get("anonymous", True)
  108. else str(review.get("customer_name") or "顾客")
  109. )
  110. package_name = str(
  111. (review.get("package_snapshot") or {}).get("name") or "其他服务"
  112. )
  113. lines = [
  114. f"<b>新评价 · {float(review.get('score') or 0):.1f} 分</b>",
  115. f"技师:{escape(str(review.get('technician_name') or review['technician_id']))}",
  116. f"服务:{escape(package_name)}",
  117. f"评价人:{escape(author)}",
  118. ]
  119. choice_answers = review.get("choice_answers") or {}
  120. text_answers = review.get("text_answers") or {}
  121. for question_id, answer in choice_answers.items():
  122. value = "、".join(str(item) for item in answer) if isinstance(answer, list) else str(answer)
  123. if value:
  124. lines.append(
  125. f"{escape(labels.get(str(question_id)) or '选择')}:{escape(value)}"
  126. )
  127. for question_id, answer in text_answers.items():
  128. if answer:
  129. lines.append(
  130. f"{escape(labels.get(str(question_id)) or '评价内容')}:"
  131. f"{escape(str(answer))}"
  132. )
  133. return "\n".join(lines)
  134. async def publish_approved_review(
  135. bot_token: str,
  136. review: dict[str, Any],
  137. ) -> dict[str, Any]:
  138. stored = await get_review(str(review["review_id"]))
  139. if stored:
  140. review = stored
  141. if review.get("topic_message_id"):
  142. return {
  143. "status": "published",
  144. "topic_url": review.get("topic_url"),
  145. "message_id": review.get("topic_message_id"),
  146. }
  147. try:
  148. topic = await ensure_technician_review_topic(
  149. bot_token,
  150. int(review["technician_id"]),
  151. )
  152. if not topic:
  153. return {
  154. "status": "unconfigured",
  155. "message": "尚未配置公开评价群。",
  156. }
  157. result = await _bot_api(
  158. bot_token,
  159. "sendMessage",
  160. {
  161. "chat_id": topic["forum_chat_id"],
  162. "message_thread_id": int(topic["message_thread_id"]),
  163. "text": _review_message(review),
  164. "parse_mode": "HTML",
  165. "disable_web_page_preview": True,
  166. },
  167. )
  168. message_id = int(result.get("message_id") or 0)
  169. if message_id <= 0:
  170. raise ReviewTopicError("Telegram 未返回有效的评价消息 ID。")
  171. await mark_review_topic_published(
  172. review["review_id"],
  173. topic=topic,
  174. message_id=message_id,
  175. )
  176. return {
  177. "status": "published",
  178. "topic_url": topic["topic_url"],
  179. "message_id": message_id,
  180. }
  181. except ReviewTopicError as exc:
  182. await mark_review_topic_publish_error(review["review_id"], str(exc))
  183. return {"status": "failed", "message": str(exc)}