ticket_service.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. from datetime import datetime
  2. from typing import List, Optional
  3. from redis.asyncio import Redis
  4. from sqlalchemy import select, case
  5. from sqlalchemy.ext.asyncio import AsyncSession
  6. from app.utils.search import apply_keyword_search_stmt
  7. from app.utils.pagination import paginate
  8. from app.core.biz_exception import NotFoundError, PermissionDeniedError, BizLogicError
  9. from app.models.user import VasUser
  10. from app.models.order import VasOrder
  11. from app.models.vas_task import VasTask
  12. from app.models.payment import VasPayment
  13. from app.models.ticket import VasTicket
  14. from app.models.ticket_message import VasTicketMessage
  15. from app.schemas.ticket import VasTicketCreate
  16. from app.services.notification_service import NotificationService
  17. from app.services.webhook_service import WebhookService
  18. class TicketService:
  19. @staticmethod
  20. async def create(
  21. db: AsyncSession,
  22. data: VasTicketCreate,
  23. current_user: VasUser,
  24. redis_client: Redis
  25. ):
  26. ticket = VasTicket(
  27. **data.dict(),
  28. user_id=current_user.id,
  29. status="pending",
  30. created_at=datetime.utcnow(),
  31. updated_at=datetime.utcnow(),
  32. )
  33. db.add(ticket)
  34. await db.commit()
  35. await db.refresh(ticket)
  36. formatted_time = ticket.created_at.strftime('%Y-%m-%d %H:%M') + " (UTC)"
  37. await NotificationService.post_message(
  38. db=db,
  39. channel="email",
  40. payload={
  41. "template_id": "ticket_created",
  42. "receiver": current_user.email,
  43. "payload": {
  44. "app_name": "TextSkin",
  45. "username": current_user.email,
  46. "ticket_id": ticket.id,
  47. "ticket_type": ticket.type,
  48. "ticket_url": "https://text.skin/dashboard",
  49. "created_at": formatted_time
  50. }
  51. },
  52. )
  53. return ticket
  54. @staticmethod
  55. async def update_status(
  56. db: AsyncSession,
  57. ticket_id: int,
  58. status: str,
  59. comment: str,
  60. admin_id: str,
  61. ) -> VasTicket:
  62. result = await db.execute(
  63. select(VasTicket)
  64. .where(VasTicket.id == ticket_id)
  65. .with_for_update()
  66. )
  67. ticket = result.scalar_one_or_none()
  68. if not ticket:
  69. raise NotFoundError("Ticket not exist")
  70. ticket.status = status
  71. ticket.admin_comment = comment
  72. ticket.updated_at = datetime.utcnow()
  73. db.add(
  74. VasTicketMessage(
  75. ticket_id=ticket.id,
  76. sender_type="admin",
  77. sender_id=str(admin_id),
  78. content=comment,
  79. created_at=datetime.utcnow(),
  80. )
  81. )
  82. if status == "resolved":
  83. await TicketService._handle_resolution(db, ticket)
  84. elif status == "rejected":
  85. await TicketService._handle_rejection(db, ticket)
  86. await db.commit()
  87. await db.refresh(ticket)
  88. return ticket
  89. # =========================
  90. # 工单解决逻辑
  91. # =========================
  92. @staticmethod
  93. async def _handle_resolution(
  94. db: AsyncSession,
  95. ticket: VasTicket,
  96. ) -> None:
  97. if not ticket.order_id:
  98. return
  99. result = await db.execute(
  100. select(VasOrder)
  101. .where(VasOrder.id == ticket.order_id)
  102. .with_for_update()
  103. )
  104. order = result.scalar_one_or_none()
  105. if not order:
  106. return
  107. # ---------- 退款 ----------
  108. if ticket.type == "refund":
  109. order.status = "closed"
  110. pay_res = await db.execute(
  111. select(VasPayment).where(
  112. VasPayment.order_id == order.id,
  113. VasPayment.status.in_(["succeeded", "late_paid"]),
  114. )
  115. )
  116. payment = pay_res.scalar_one_or_none()
  117. if payment:
  118. payment.status = "refunded"
  119. payment.refunded_at = datetime.utcnow()
  120. payment.refund_reason = ticket.reason
  121. task_res = await db.execute(
  122. select(VasTask).where(
  123. VasTask.order_id == order.id,
  124. VasTask.status.in_(["pending", "grabbed", "running", "pause", "completed"]),
  125. )
  126. )
  127. for task in task_res.scalars().all():
  128. task.status = "cancelled"
  129. # =========================
  130. # 工单拒绝逻辑
  131. # =========================
  132. @staticmethod
  133. async def _handle_rejection(
  134. db: AsyncSession,
  135. ticket: VasTicket,
  136. ) -> None:
  137. if not ticket.order_id:
  138. return
  139. result = await db.execute(
  140. select(VasOrder)
  141. .where(VasOrder.id == ticket.order_id)
  142. .with_for_update()
  143. )
  144. order = result.scalar_one_or_none()
  145. if not order:
  146. return
  147. # if ticket.type == "refund" and order.status == "refund_pending":
  148. # order.status = "paid"
  149. @staticmethod
  150. async def add_message(
  151. db: AsyncSession,
  152. ticket_id: int,
  153. sender_type: str, # "user" | "admin" | "system"
  154. sender_id: Optional[str] = None,
  155. content: str = "",
  156. attachments: Optional[dict] = None,
  157. ):
  158. ticket = await db.get(VasTicket, ticket_id)
  159. if not ticket:
  160. raise NotFoundError("Ticket not exist")
  161. message = VasTicketMessage(
  162. ticket_id=ticket_id,
  163. sender_type=sender_type,
  164. sender_id=sender_id,
  165. content=content,
  166. attachments=attachments,
  167. created_at=datetime.utcnow(),
  168. )
  169. db.add(message)
  170. ticket.updated_at = datetime.utcnow()
  171. await db.commit()
  172. await db.refresh(message)
  173. return message
  174. @staticmethod
  175. async def list_messages(
  176. db: AsyncSession,
  177. ticket_id: int,
  178. page: int = 1,
  179. size: int = 20,
  180. ):
  181. # 校验 ticket 是否存在
  182. exists = await db.scalar(
  183. select(VasTicket.id).where(VasTicket.id == ticket_id)
  184. )
  185. if not exists:
  186. raise NotFoundError("Ticket not exist")
  187. stmt = (
  188. select(VasTicketMessage)
  189. .where(VasTicketMessage.ticket_id == ticket_id)
  190. .order_by(VasTicketMessage.created_at.desc())
  191. )
  192. return await paginate(db, stmt, page, size)
  193. @staticmethod
  194. async def list_by_user(
  195. db: AsyncSession,
  196. user_id: str,
  197. page: int = 1,
  198. size: int = 20,
  199. keyword: Optional[str] = None,
  200. ):
  201. stmt = select(VasTicket).where(VasTicket.user_id == user_id)
  202. stmt = apply_keyword_search_stmt(
  203. stmt=stmt,
  204. model=VasTicket,
  205. keyword=keyword,
  206. fields=["order_id", "user_id", "reason", "admin_comment"],
  207. )
  208. stmt = stmt.order_by(VasTicket.id.desc())
  209. return await paginate(db, stmt, page, size)
  210. @staticmethod
  211. async def list_all(
  212. db: AsyncSession,
  213. page: int = 1,
  214. size: int = 20,
  215. keyword: Optional[str] = None,
  216. ):
  217. stmt = select(VasTicket)
  218. # 关键词搜索
  219. stmt = apply_keyword_search_stmt(
  220. stmt=stmt,
  221. model=VasTicket,
  222. keyword=keyword,
  223. fields=["order_id", "user_id", "reason", "admin_comment"],
  224. )
  225. # 排序:未解决优先 ('pending','info_required') -> 0, 已解决 ('resolved','rejected') -> 1
  226. stmt = stmt.order_by(
  227. case(
  228. (VasTicket.status.in_(["pending", "info_required"]), 0),
  229. else_=1
  230. ),
  231. VasTicket.id.desc()
  232. )
  233. return await paginate(db, stmt, page, size)