api.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. import hashlib
  2. from typing import Annotated
  3. from uuid import UUID, uuid4
  4. from fastapi import APIRouter, Depends, Path, Query, Request
  5. from fastapi.security import HTTPBearer
  6. from langchain_core.messages import AIMessage, HumanMessage, message_to_dict
  7. from pydantic import Field
  8. from sqlalchemy import select
  9. from zhifu.core.api_docs import ConversationView, EventPage, EventView, Page, replies
  10. from zhifu.modules.after_sales.api import IdempotencyKey
  11. from zhifu.modules.after_sales.schemas import Command
  12. from zhifu.modules.after_sales.service import require
  13. from zhifu.modules.agent_runtime.models import AgentRun
  14. from zhifu.modules.agent_runtime.service import queue_run
  15. from zhifu.modules.conversations.models import Conversation, ConversationEvent
  16. from zhifu.modules.conversations.service import access, emit, event_view, view
  17. from zhifu.modules.identity.auth import authenticate, authorize_store, now
  18. from zhifu.modules.memory.service import enqueue as enqueue_memory
  19. from zhifu.modules.notifications.service import notify
  20. router = APIRouter(
  21. prefix="/openapi/v1/conversations",
  22. tags=["会话"],
  23. dependencies=[Depends(HTTPBearer(auto_error=False))],
  24. )
  25. class Create(Command):
  26. store_id: UUID = Field(description="会话所属店铺,需要该店铺 orders:read 权限")
  27. class Control(Command):
  28. expected_control_version: int = Field(ge=1, description="最近查询到的会话 control_version")
  29. class Message(Control):
  30. text: str = Field(
  31. min_length=1,
  32. max_length=4000,
  33. description="客户消息或接管客服的回复",
  34. examples=["请帮我查询订单状态"],
  35. )
  36. @router.get("", summary="分页查询店铺会话", responses=replies(Page[ConversationView]))
  37. async def list_conversations(
  38. request: Request,
  39. store_id: Annotated[UUID, Query(description="查询范围所属店铺")],
  40. cursor: Annotated[UUID | None, Query(description="上一页返回的 next_cursor")] = None,
  41. limit: Annotated[int, Query(ge=1, le=100, description="每页最多返回数量")] = 20,
  42. ):
  43. """CUSTOMER 使用 orders:read 且只查询本人会话;STAFF 使用 conversations:read。
  44. 按 UUID 升序分页,使用 next_cursor 请求下一页。每项返回实际 channel 和带 UTC 时区的
  45. created_at;创建时间不随消息或控制权变化更新。
  46. """
  47. async with request.app.state.sessions.begin() as session:
  48. auth = await authenticate(request, session)
  49. require(auth.kind in {"CUSTOMER", "STAFF"}, "FORBIDDEN", "需要客户或客服身份", 403)
  50. permission = "orders:read" if auth.kind == "CUSTOMER" else "conversations:read"
  51. await authorize_store(session, auth, store_id.bytes, permission)
  52. query = select(Conversation).where(
  53. Conversation.tenant_id == auth.tenant_id, Conversation.store_id == store_id.bytes
  54. )
  55. if auth.kind == "CUSTOMER":
  56. query = query.where(
  57. Conversation.subject_id == auth.actor_id, Conversation.channel == "h5")
  58. if cursor:
  59. query = query.where(Conversation.id > cursor.bytes)
  60. rows = (await session.scalars(query.order_by(Conversation.id).limit(limit + 1))).all()
  61. return {
  62. "items": [view(row) for row in rows[:limit]],
  63. "next_cursor": str(UUID(bytes=rows[limit - 1].id)) if len(rows) > limit else None,
  64. }
  65. @router.post(
  66. "",
  67. status_code=201,
  68. summary="客户创建会话",
  69. responses=replies(ConversationView, 201, errors=(401, 403, 404, 409, 422)),
  70. )
  71. async def create(body: Create, request: Request, key: IdempotencyKey):
  72. """需要 CUSTOMER 身份和店铺 orders:read 权限,初始控制权为 AGENT。
  73. Idempotency-Key 在租户和客户范围内唯一。重试返回原会话当前状态,换店铺重用该键返回 409。
  74. channel 为 h5,created_at 是带 UTC 时区的会话创建时间,重复请求保持原创建时间。
  75. 会话保存聊天与控制状态,每次创建 Agent 运行时读取店铺当前发布配置。
  76. """
  77. async with request.app.state.sessions.begin() as session:
  78. auth = await authenticate(request, session)
  79. require(auth.kind == "CUSTOMER", "FORBIDDEN", "需要客户身份", 403)
  80. await authorize_store(session, auth, body.store_id.bytes, "orders:read")
  81. existing = await session.scalar(
  82. select(Conversation).where(
  83. Conversation.tenant_id == auth.tenant_id,
  84. Conversation.subject_id == auth.actor_id,
  85. Conversation.request_key == key,
  86. )
  87. )
  88. if existing:
  89. require(
  90. existing.store_id == body.store_id.bytes, "IDEMPOTENCY_CONFLICT", "请求参数不同"
  91. )
  92. return view(existing)
  93. conversation = Conversation(
  94. id=uuid4().bytes,
  95. tenant_id=auth.tenant_id,
  96. subject_id=auth.actor_id,
  97. store_id=body.store_id.bytes,
  98. request_key=key,
  99. created_at=now(),
  100. )
  101. session.add(conversation)
  102. await session.flush()
  103. return view(conversation)
  104. @router.get("/{conversation_id}", summary="查询会话控制状态", responses=replies(ConversationView))
  105. async def get_conversation(conversation_id: UUID, request: Request):
  106. """客户可查询本人会话;客服需要 conversations:read。发送消息前读取 control_version。
  107. 同时返回实际 channel 与带 UTC 时区的 created_at,供会话来源和创建时间展示。
  108. """
  109. async with request.app.state.sessions.begin() as session:
  110. auth = await authenticate(request, session)
  111. return view(await access(session, auth, conversation_id.bytes, read=True))
  112. @router.get(
  113. "/{conversation_id}/messages", summary="按序号读取会话持久事件", responses=replies(EventPage)
  114. )
  115. async def history(
  116. conversation_id: UUID,
  117. request: Request,
  118. after: Annotated[int, Query(ge=0, description="仅返回严格大于此序号的事件")] = 0,
  119. limit: Annotated[int, Query(ge=1, le=100, description="本次最多返回的事件数量")] = 50,
  120. ):
  121. """身份要求同会话查询。items 包含消息、控制权变化及运行进度等全部持久事件。
  122. 顶层 sequence 是会话最新序号。继续分页时使用 items 最后一项的 sequence,
  123. 防止在事件较多时跳过尚未读取的消息。历史事件保留生成时的 control_version。
  124. created_at 是带 UTC 时区的事件原始创建时间;显示时间使用此字段,分页与去重继续使用 sequence。
  125. """
  126. async with request.app.state.sessions.begin() as session:
  127. auth = await authenticate(request, session)
  128. conversation = await access(session, auth, conversation_id.bytes, read=True)
  129. events = (
  130. await session.scalars(
  131. select(ConversationEvent)
  132. .where(
  133. ConversationEvent.tenant_id == auth.tenant_id,
  134. ConversationEvent.conversation_id == conversation.id,
  135. ConversationEvent.sequence > after,
  136. )
  137. .order_by(ConversationEvent.sequence)
  138. .limit(limit)
  139. )
  140. ).all()
  141. return {"items": [event_view(event) for event in events], "sequence": conversation.sequence}
  142. async def prior(session, conversation, auth, key, digest):
  143. event = await session.scalar(
  144. select(ConversationEvent).where(
  145. ConversationEvent.tenant_id == auth.tenant_id,
  146. ConversationEvent.conversation_id == conversation.id,
  147. ConversationEvent.actor_id == auth.actor_id,
  148. ConversationEvent.command_key == key,
  149. )
  150. )
  151. if event:
  152. require(event.request_hash == digest, "IDEMPOTENCY_CONFLICT", "请求参数不同")
  153. return event
  154. @router.post(
  155. "/{conversation_id}/messages",
  156. status_code=201,
  157. summary="发送客户消息或客服回复",
  158. responses=replies(EventView, 201, errors=(401, 403, 404, 409, 422, 429, 503)),
  159. )
  160. async def message(conversation_id: UUID, body: Message, request: Request, key: IdempotencyKey):
  161. """客户只能发送本人会话消息;客服需要 conversations:reply 且已接管该会话。
  162. 客户在 AGENT 模式发言会创建异步运行,payload.run_id 返回运行标识;HUMAN 模式仅保存消息。
  163. 新运行读取店铺当前已发布版本,原会话的历史消息继续作为上下文。
  164. 已接受的运行固定保存配置;幂等重试、补充资料、审批等待和重启恢复保留原运行配置。
  165. 同一会话只允许一个活动运行,否则返回 RUN_IN_PROGRESS。
  166. Agent 运行遵循租户并发上限与交互额度预留;额度不足时排队,账户任务已满返回 429。
  167. 每次模型请求前检查当日 token 预算;不足时运行失败,run.failed 事件包含
  168. code=TOKEN_BUDGET_EXCEEDED、MONEY_BUDGET_EXCEEDED 或 BILLING_PRICE_MISSING,
  169. 客户可在预算或价格配置完成后以新幂等键重新发送。
  170. 幂等键作用域为租户、会话及操作者;发送和控制权操作共用此作用域。
  171. 响应 created_at 为带 UTC 时区的事件原始创建时间,重复请求返回相同值。
  172. """
  173. async with request.app.state.sessions.begin() as session:
  174. auth = await authenticate(request, session)
  175. conversation = await access(session, auth, conversation_id.bytes, "conversations:reply")
  176. return await accept_message(session, auth, conversation, body, key)
  177. async def accept_message(session, auth, conversation, body, key):
  178. """H5与可信渠道共用消息服务,调用方持有身份及会话锁。"""
  179. digest = hashlib.sha256(body.model_dump_json().encode()).digest()
  180. previous = await prior(session, conversation, auth, key, digest)
  181. if previous:
  182. return event_view(previous)
  183. require(conversation.control_version == body.expected_control_version,
  184. "CONTROL_CONFLICT", "会话控制版本已变化")
  185. if auth.kind == "STAFF":
  186. require(conversation.control_owner == "HUMAN"
  187. and conversation.assigned_staff_id == auth.actor_id,
  188. "FORBIDDEN", "需要当前接管客服身份", 403)
  189. run_id = None
  190. if auth.kind == "CUSTOMER" and conversation.control_owner == "AGENT":
  191. require(conversation.active_run_id is None, "RUN_IN_PROGRESS", "当前回复仍在处理中")
  192. # 最近12条消息与24000字符上限在所有入口采用相同规则。
  193. events = (await session.scalars(select(ConversationEvent).where(
  194. ConversationEvent.tenant_id == auth.tenant_id,
  195. ConversationEvent.conversation_id == conversation.id,
  196. ConversationEvent.event_type.in_(
  197. ["message.user", "message.assistant", "message.staff"]),
  198. ).order_by(ConversationEvent.sequence.desc()).limit(12))).all()
  199. messages = []
  200. length = len(body.text)
  201. for event in events:
  202. text = event.payload["text"]
  203. if length + len(text) > 24000:
  204. break
  205. length += len(text)
  206. kind = HumanMessage if event.event_type == "message.user" else AIMessage
  207. messages.append(message_to_dict(kind(content=text)))
  208. messages.reverse()
  209. messages.append(message_to_dict(HumanMessage(content=body.text)))
  210. run = await queue_run(session, auth, conversation.store_id, "conv:" + uuid4().hex,
  211. digest, messages)
  212. run.conversation_id = conversation.id
  213. run.control_version = conversation.control_version
  214. conversation.active_run_id = run.id
  215. run_id = str(UUID(bytes=run.id))
  216. event = emit(session, conversation,
  217. "message.user" if auth.kind == "CUSTOMER" else "message.staff",
  218. {"text": body.text, "run_id": run_id}, auth.actor_id, key, digest)
  219. if auth.kind == "CUSTOMER":
  220. await enqueue_memory(session, auth, conversation, event)
  221. if conversation.assigned_staff_id is not None:
  222. await notify(session, tenant_id=auth.tenant_id, store_id=conversation.store_id,
  223. event_key=f"message:{conversation.id.hex()}:{event.sequence}",
  224. permission="conversations:read", kind="客户消息", title="客户有新消息",
  225. summary="当前接待的会话收到客户消息,请查看最新内容。", page="conversation",
  226. action="查看客户消息", target_id=str(UUID(bytes=conversation.id)),
  227. recipient_id=conversation.assigned_staff_id)
  228. return event_view(event)
  229. @router.post(
  230. "/{conversation_id}/{action}",
  231. summary="客服接管或释放会话",
  232. responses=replies(EventView, errors=(401, 403, 404, 409, 422)),
  233. )
  234. async def control(
  235. conversation_id: UUID,
  236. action: Annotated[str, Path(description="takeover 接管;release 交还 Agent,其他值返回 404")],
  237. body: Control,
  238. request: Request,
  239. key: IdempotencyKey,
  240. ):
  241. """需要 STAFF 身份及 conversations:manage 权限。release 仅允许当前接管客服调用。
  242. 成功增加 control_version,取消旧活动运行,并产生 control.changed 事件。
  243. 释放后需要客户再次发送消息才会创建新运行。迟到模型答复不能越过新控制版本提交。
  244. 响应 created_at 为本次控制事件的创建时间;payload.created_at 为会话创建时间,均带 UTC 时区。
  245. """
  246. require(action in {"takeover", "release"}, "RESOURCE_NOT_FOUND", "操作不存在", 404)
  247. async with request.app.state.sessions.begin() as session:
  248. auth = await authenticate(request, session)
  249. require(auth.kind == "STAFF", "FORBIDDEN", "需要客服身份", 403)
  250. conversation = await access(session, auth, conversation_id.bytes, "conversations:manage")
  251. digest = hashlib.sha256((action + body.model_dump_json()).encode()).digest()
  252. previous = await prior(session, conversation, auth, key, digest)
  253. if previous:
  254. return event_view(previous)
  255. require(
  256. conversation.control_version == body.expected_control_version,
  257. "CONTROL_CONFLICT",
  258. "会话控制版本已变化",
  259. )
  260. if action == "release":
  261. require(
  262. conversation.control_owner == "HUMAN"
  263. and conversation.assigned_staff_id == auth.actor_id,
  264. "FORBIDDEN",
  265. "需要当前接管客服身份",
  266. 403,
  267. )
  268. else:
  269. require(conversation.control_owner == "AGENT", "CONTROL_CONFLICT", "已有客服接管")
  270. if conversation.active_run_id:
  271. run = await session.get(AgentRun, conversation.active_run_id, with_for_update=True)
  272. if run.status in {"QUEUED", "RUNNING", "WAITING_USER", "WAITING_APPROVAL"}:
  273. run.status = "CANCELLED"
  274. run.version += 1
  275. run.waiting = None
  276. run.lease_until = None
  277. conversation.active_run_id = None
  278. conversation.control_version += 1
  279. conversation.control_owner = "HUMAN" if action == "takeover" else "AGENT"
  280. conversation.assigned_staff_id = auth.actor_id if action == "takeover" else None
  281. return event_view(
  282. emit(
  283. session,
  284. conversation,
  285. "control.changed",
  286. view(conversation),
  287. auth.actor_id,
  288. key,
  289. digest,
  290. )
  291. )