demand_feedback.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. """Human feedback for demand-summary records."""
  2. from __future__ import annotations
  3. import json
  4. from typing import Any
  5. from pydantic import ValidationError
  6. from sqlalchemy.exc import IntegrityError
  7. from api.schemas.demand_feedback import CreateDemandFeedbackBody
  8. from api.services.video_discovery import _parse_video_ids
  9. from supply_infra.db.models.demand_feedback import DemandFeedback
  10. from supply_infra.db.repositories.demand_feedback_repo import DemandFeedbackRepository
  11. from supply_infra.db.repositories.demand_grade_repo import DemandGradeRepository
  12. from supply_infra.db.repositories.demand_video_expansion_repo import (
  13. DemandVideoExpansionRepository,
  14. )
  15. from supply_infra.db.repositories.multi_demand_video_detail_repo import (
  16. MultiDemandVideoDetailRepository,
  17. )
  18. from supply_infra.db.session import get_session
  19. _SOURCE_VIDEO_GRADES = frozenset({"B", "C", "D"})
  20. class FeedbackTargetNotFoundError(Exception):
  21. """Raised when the requested demand, video or hit content does not exist."""
  22. class FeedbackTargetConflictError(Exception):
  23. """Raised when target identifiers do not belong to the same record."""
  24. class FeedbackRequestConflictError(Exception):
  25. """Raised when an idempotency key is reused for a different request."""
  26. def _feedback_summary(count: int) -> dict[str, int]:
  27. return {"count": int(count)}
  28. def _serialize_feedback(row: DemandFeedback) -> dict[str, Any]:
  29. try:
  30. target_snapshot = json.loads(row.target_snapshot_json)
  31. except (TypeError, ValueError):
  32. target_snapshot = {}
  33. return {
  34. "id": int(row.id),
  35. "target_type": row.target_type,
  36. "biz_dt": row.biz_dt,
  37. "demand_grade_id": int(row.demand_grade_id),
  38. "video_id": row.video_id,
  39. "demand_video_expansion_id": (
  40. int(row.demand_video_expansion_id)
  41. if row.demand_video_expansion_id is not None
  42. else None
  43. ),
  44. "feedback_action": row.feedback_action,
  45. "reason_code": row.reason_code,
  46. "content": row.content,
  47. "target_snapshot": target_snapshot,
  48. "feedback_user": {
  49. "id": int(row.feedback_user_id),
  50. "username": row.feedback_username_snapshot,
  51. "display_name": row.feedback_user_name_snapshot,
  52. },
  53. "created_at": row.created_at.isoformat() if row.created_at else None,
  54. }
  55. def _same_request(
  56. row: DemandFeedback,
  57. body: CreateDemandFeedbackBody,
  58. feedback_user_id: int,
  59. ) -> bool:
  60. return (
  61. row.feedback_user_id == feedback_user_id
  62. and row.target_type == body.target_type
  63. and row.demand_grade_id == body.demand_grade_id
  64. and row.video_id == body.video_id
  65. and row.demand_video_expansion_id == body.demand_video_expansion_id
  66. and row.feedback_action == body.feedback_action
  67. and row.reason_code == body.reason_code
  68. and row.content == body.content
  69. )
  70. def _resolve_target(
  71. session: Any,
  72. body: CreateDemandFeedbackBody,
  73. ) -> tuple[Any, dict[str, Any]]:
  74. grade = DemandGradeRepository(session).get_by_id(body.demand_grade_id)
  75. if grade is None:
  76. raise FeedbackTargetNotFoundError("需求不存在")
  77. snapshot: dict[str, Any] = {
  78. "demand_name": grade.demand_name,
  79. "grade": grade.grade,
  80. }
  81. if body.target_type == "demand":
  82. snapshot["reason"] = grade.reason
  83. return grade, snapshot
  84. video_id = body.video_id or ""
  85. grade_code = str(grade.grade or "").upper()
  86. expansion_repo = DemandVideoExpansionRepository(session)
  87. if grade_code in _SOURCE_VIDEO_GRADES:
  88. video_exists = video_id in _parse_video_ids(grade.video_list)
  89. video_source = "pool"
  90. else:
  91. video_exists = expansion_repo.has_video(
  92. biz_dt=str(grade.biz_dt),
  93. source_demand_grade_id=int(grade.id),
  94. video_id=video_id,
  95. )
  96. video_source = "expansion"
  97. if not video_exists:
  98. raise FeedbackTargetConflictError("视频不属于当前需求")
  99. detail = MultiDemandVideoDetailRepository(session).list_by_vids([video_id]).get(
  100. video_id
  101. )
  102. snapshot.update(
  103. {
  104. "video_id": video_id,
  105. "video_title": detail.title if detail else None,
  106. "video_source": video_source,
  107. }
  108. )
  109. if body.target_type == "video":
  110. return grade, snapshot
  111. expansion = expansion_repo.get_active_by_id(body.demand_video_expansion_id or 0)
  112. if expansion is None:
  113. raise FeedbackTargetNotFoundError("命中内容不存在")
  114. if (
  115. int(expansion.source_demand_grade_id) != int(grade.id)
  116. or str(expansion.biz_dt) != str(grade.biz_dt)
  117. or str(expansion.video_id) != video_id
  118. ):
  119. raise FeedbackTargetConflictError("命中内容不属于当前需求和视频")
  120. snapshot.update(
  121. {
  122. "point_type": expansion.point_type,
  123. "expanded_text": expansion.expanded_text,
  124. "point_desc": expansion.point_desc,
  125. "reason": expansion.reason,
  126. }
  127. )
  128. return grade, snapshot
  129. def create_demand_feedback(
  130. body: CreateDemandFeedbackBody,
  131. current_user: dict[str, Any],
  132. ) -> dict[str, Any]:
  133. feedback_user_id = int(current_user["id"])
  134. feedback_username = str(current_user["username"])
  135. feedback_user_name = str(
  136. current_user.get("display_name") or current_user.get("username") or feedback_user_id
  137. )
  138. with get_session() as session:
  139. repo = DemandFeedbackRepository(session)
  140. existing = repo.get_by_client_request_id(body.client_request_id)
  141. if existing is not None:
  142. if not _same_request(existing, body, feedback_user_id):
  143. raise FeedbackRequestConflictError("请求标识已用于其他反馈")
  144. _, video_counts, expansion_counts = repo.count_for_demand(
  145. body.demand_grade_id
  146. )
  147. count = (
  148. repo.count_demands([body.demand_grade_id]).get(body.demand_grade_id, 0)
  149. if body.target_type == "demand"
  150. else video_counts.get(body.video_id or "", 0)
  151. if body.target_type == "video"
  152. else expansion_counts.get(body.demand_video_expansion_id or 0, 0)
  153. )
  154. return {
  155. "item": _serialize_feedback(existing),
  156. "feedback_summary": _feedback_summary(count),
  157. }
  158. grade, target_snapshot = _resolve_target(session, body)
  159. feedback = DemandFeedback(
  160. client_request_id=body.client_request_id,
  161. target_type=body.target_type,
  162. biz_dt=str(grade.biz_dt),
  163. demand_grade_id=int(grade.id),
  164. video_id=body.video_id,
  165. demand_video_expansion_id=body.demand_video_expansion_id,
  166. feedback_action=body.feedback_action,
  167. reason_code=body.reason_code,
  168. content=body.content,
  169. target_snapshot_json=json.dumps(
  170. target_snapshot,
  171. ensure_ascii=False,
  172. separators=(",", ":"),
  173. ),
  174. feedback_user_id=feedback_user_id,
  175. feedback_user_name_snapshot=feedback_user_name[:128],
  176. feedback_username_snapshot=feedback_username[:64],
  177. )
  178. try:
  179. repo.add(feedback)
  180. session.refresh(feedback)
  181. except IntegrityError:
  182. session.rollback()
  183. existing = repo.get_by_client_request_id(body.client_request_id)
  184. if existing is None or not _same_request(existing, body, feedback_user_id):
  185. raise FeedbackRequestConflictError("请求标识已用于其他反馈") from None
  186. feedback = existing
  187. rows, total = repo.list_for_target(
  188. target_type=body.target_type,
  189. demand_grade_id=body.demand_grade_id,
  190. video_id=body.video_id,
  191. demand_video_expansion_id=body.demand_video_expansion_id,
  192. limit=1,
  193. offset=0,
  194. )
  195. del rows
  196. return {
  197. "item": _serialize_feedback(feedback),
  198. "feedback_summary": _feedback_summary(total),
  199. }
  200. def list_demand_feedback(
  201. *,
  202. target_type: str,
  203. demand_grade_id: int,
  204. video_id: str | None,
  205. demand_video_expansion_id: int | None,
  206. limit: int,
  207. offset: int,
  208. ) -> dict[str, Any]:
  209. try:
  210. body = CreateDemandFeedbackBody(
  211. client_request_id="history-query",
  212. target_type=target_type,
  213. demand_grade_id=demand_grade_id,
  214. video_id=video_id,
  215. demand_video_expansion_id=demand_video_expansion_id,
  216. feedback_action="support",
  217. )
  218. except ValidationError as exc:
  219. raise FeedbackTargetConflictError("反馈目标参数不完整") from exc
  220. with get_session() as session:
  221. repo = DemandFeedbackRepository(session)
  222. rows, total = repo.list_for_target(
  223. target_type=target_type,
  224. demand_grade_id=demand_grade_id,
  225. video_id=video_id,
  226. demand_video_expansion_id=demand_video_expansion_id,
  227. limit=limit,
  228. offset=offset,
  229. )
  230. if total == 0:
  231. _resolve_target(session, body)
  232. return {
  233. "items": [_serialize_feedback(row) for row in rows],
  234. "total": total,
  235. "limit": limit,
  236. "offset": offset,
  237. }