energy_stone_service.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. """
  2. 能量石发放与事件记录服务
  3. """
  4. import uuid
  5. import logging
  6. import time
  7. from datetime import datetime
  8. from typing import Any, Dict, List, Literal, Optional, Tuple
  9. from sqlalchemy import select, func
  10. from sqlalchemy.exc import IntegrityError
  11. from database import db_manager
  12. from models_db import MissionEnergyStone, ConversationEnergyStone, MissionType
  13. from config import settings
  14. logger = logging.getLogger(__name__)
  15. StoneType = Literal["mission", "conversation"]
  16. class EnergyStoneService:
  17. """能量石生成/状态同步"""
  18. def __init__(self):
  19. self.db = db_manager
  20. self.ttl_seconds = settings.ENERGY_STONE_TTL_SECONDS
  21. self.conversation_interval_ms = settings.CONVERSATION_STONE_INTERVAL_SECONDS * 1000
  22. self._mission_type_cache: Dict[str, Tuple[float, Optional[MissionType]]] = {}
  23. self._mission_type_cache_ttl = max(5, settings.MISSION_TYPE_CACHE_SECONDS)
  24. async def set_db_manager(self, manager):
  25. """延迟注入数据库管理器"""
  26. self.db = manager
  27. async def create_mission_stone_if_needed(
  28. self,
  29. user_id: str,
  30. request_id: Optional[str],
  31. mission_type: str = "first_dialog",
  32. appear_reason: Optional[str] = None,
  33. metadata: Optional[Dict[str, Any]] = None,
  34. ) -> Optional[Dict[str, Any]]:
  35. """首次对话奖励石,单用户仅发放一次"""
  36. if not user_id:
  37. return None
  38. try:
  39. async with self.db.get_session() as session:
  40. mission_key = mission_type or "first_dialog"
  41. reason = appear_reason or mission_key
  42. mission_config: Optional[MissionType] = None
  43. is_repeatable = False
  44. async with session.begin():
  45. mission_config = await self._get_mission_type_config(session, mission_key)
  46. if mission_config is not None and mission_config.is_active is False:
  47. logger.debug(
  48. "任务类型已禁用,跳过发放: user=%s mission=%s", user_id, mission_key
  49. )
  50. return None
  51. is_repeatable = bool(mission_config.is_repeatable) if mission_config else False
  52. result = await session.execute(
  53. select(MissionEnergyStone.stone_id)
  54. .where(
  55. MissionEnergyStone.user_id == user_id,
  56. MissionEnergyStone.mission_type == mission_key,
  57. )
  58. .with_for_update()
  59. )
  60. existing = result.scalars().first()
  61. if existing and not is_repeatable:
  62. return None
  63. stone = MissionEnergyStone(
  64. stone_id=str(uuid.uuid4()),
  65. user_id=user_id,
  66. request_id=request_id,
  67. appear_reason=reason,
  68. mission_type=mission_key,
  69. metadata=metadata or {},
  70. )
  71. session.add(stone)
  72. await session.refresh(stone)
  73. spawn_meta = {
  74. "appear_reason": reason,
  75. "mission_type": mission_key,
  76. "is_repeatable": is_repeatable,
  77. }
  78. if mission_config:
  79. spawn_meta["mission_display_name"] = mission_config.display_name
  80. return self._to_spawn_payload(
  81. stone=stone,
  82. stone_type="mission",
  83. extra=spawn_meta,
  84. )
  85. except Exception as exc:
  86. logger.warning("创建任务能量石失败: user=%s error=%s", user_id, exc)
  87. return None
  88. async def create_conversation_stones_if_needed(
  89. self,
  90. user_id: str,
  91. request_id: Optional[str],
  92. total_duration_ms: int,
  93. duration_increment_ms: int,
  94. ) -> List[Dict[str, Any]]:
  95. """按累计说话时长发放对话能量石"""
  96. if not user_id or self.conversation_interval_ms <= 0:
  97. return []
  98. if total_duration_ms < self.conversation_interval_ms:
  99. return []
  100. target_index = total_duration_ms // self.conversation_interval_ms
  101. if target_index <= 0:
  102. return []
  103. stones: List[Dict[str, Any]] = []
  104. try:
  105. async with self.db.get_session() as session:
  106. result = await session.execute(
  107. select(func.max(ConversationEnergyStone.threshold_index)).where(
  108. ConversationEnergyStone.user_id == user_id
  109. )
  110. )
  111. last_index = result.scalar() or 0
  112. next_index = last_index + 1
  113. if next_index > target_index:
  114. return []
  115. created_instances: List[ConversationEnergyStone] = []
  116. for idx in range(next_index, target_index + 1):
  117. instance = ConversationEnergyStone(
  118. stone_id=str(uuid.uuid4()),
  119. user_id=user_id,
  120. request_id=request_id,
  121. threshold_index=idx,
  122. total_duration_ms=total_duration_ms,
  123. duration_increment_ms=duration_increment_ms,
  124. metadata={
  125. "target_total_ms": total_duration_ms,
  126. "interval_ms": self.conversation_interval_ms,
  127. },
  128. )
  129. session.add(instance)
  130. created_instances.append(instance)
  131. if not created_instances:
  132. return []
  133. await session.commit()
  134. for instance in created_instances:
  135. await session.refresh(instance)
  136. stones.append(
  137. self._to_spawn_payload(
  138. stone=instance,
  139. stone_type="conversation",
  140. extra={
  141. "threshold_index": instance.threshold_index,
  142. "total_duration_ms": instance.total_duration_ms,
  143. "duration_increment_ms": instance.duration_increment_ms,
  144. },
  145. )
  146. )
  147. return stones
  148. except IntegrityError:
  149. # 并发情况下可能已创建,忽略
  150. logger.debug("对话能量石已存在,忽略重复创建: user=%s target=%s", user_id, target_index)
  151. return []
  152. except Exception as exc:
  153. logger.warning("创建对话能量石失败: user=%s error=%s", user_id, exc)
  154. return []
  155. async def mark_event(
  156. self,
  157. stone_id: str,
  158. event: Literal["click", "timeout"],
  159. metadata: Optional[Dict[str, Any]] = None,
  160. ) -> bool:
  161. """记录点击或自动消失"""
  162. if not stone_id:
  163. return False
  164. try:
  165. async with self.db.get_session() as session:
  166. stone = await self._find_stone(session, stone_id)
  167. if not stone:
  168. return False
  169. if metadata:
  170. merged_meta = stone.metadata or {}
  171. merged_meta.update(metadata)
  172. stone.metadata = merged_meta
  173. now = datetime.utcnow()
  174. if event == "click":
  175. stone.clicked_at = now
  176. stone.auto_removed = False
  177. else:
  178. stone.auto_removed = True
  179. if not stone.clicked_at:
  180. stone.clicked_at = now
  181. await session.commit()
  182. return True
  183. except Exception as exc:
  184. logger.warning("记录能量石事件失败: stone=%s event=%s error=%s", stone_id, event, exc)
  185. return False
  186. async def _find_stone(self, session, stone_id: str):
  187. result = await session.execute(
  188. select(MissionEnergyStone).where(MissionEnergyStone.stone_id == stone_id)
  189. )
  190. stone = result.scalars().first()
  191. if stone:
  192. return stone
  193. result = await session.execute(
  194. select(ConversationEnergyStone).where(ConversationEnergyStone.stone_id == stone_id)
  195. )
  196. return result.scalars().first()
  197. def _to_spawn_payload(
  198. self,
  199. stone,
  200. stone_type: StoneType,
  201. extra: Optional[Dict[str, Any]] = None,
  202. ) -> Dict[str, Any]:
  203. appear_at = stone.appear_at or datetime.utcnow()
  204. payload = {
  205. "stone_id": stone.stone_id,
  206. "stone_type": stone_type,
  207. "ttl_seconds": self.ttl_seconds,
  208. "appear_at": int(appear_at.timestamp() * 1000),
  209. "metadata": extra or {},
  210. }
  211. return payload
  212. async def get_stone_counts(self, user_id: str) -> Dict[str, int]:
  213. """获取用户的能量石数量"""
  214. if not user_id:
  215. return {"mission_count": 0, "conversation_count": 0}
  216. try:
  217. async with self.db.get_session() as session:
  218. # 获取任务能量石数量
  219. mission_result = await session.execute(
  220. select(func.count(MissionEnergyStone.stone_id)).where(
  221. MissionEnergyStone.user_id == user_id
  222. )
  223. )
  224. mission_count = mission_result.scalar() or 0
  225. # 获取对话能量石数量
  226. conversation_result = await session.execute(
  227. select(func.count(ConversationEnergyStone.stone_id)).where(
  228. ConversationEnergyStone.user_id == user_id
  229. )
  230. )
  231. conversation_count = conversation_result.scalar() or 0
  232. return {
  233. "mission_count": mission_count,
  234. "conversation_count": conversation_count,
  235. }
  236. except Exception as exc:
  237. logger.warning("获取能量石数量失败: user=%s error=%s", user_id, exc)
  238. return {"mission_count": 0, "conversation_count": 0}
  239. async def _get_mission_type_config(
  240. self, session, mission_type_key: str
  241. ) -> Optional[MissionType]:
  242. """获取任务类型配置,带简单缓存"""
  243. now = time.monotonic()
  244. cached = self._mission_type_cache.get(mission_type_key)
  245. if cached:
  246. cached_ts, cached_value = cached
  247. if now - cached_ts < self._mission_type_cache_ttl:
  248. return cached_value
  249. result = await session.execute(
  250. select(MissionType).where(
  251. MissionType.type_key == mission_type_key,
  252. MissionType.is_active.is_(True),
  253. )
  254. )
  255. mission_type = result.scalars().first()
  256. self._mission_type_cache[mission_type_key] = (now, mission_type)
  257. return mission_type
  258. energy_stone_service = EnergyStoneService()