energy_stone_service.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. """
  2. 能量石发放与事件记录服务
  3. """
  4. import uuid
  5. import logging
  6. import time
  7. from datetime import datetime, timezone
  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, UserBonusEnergyStone, 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. raw_ttl = settings.ENERGY_STONE_TTL_SECONDS
  21. # 兼容被误配置为毫秒的场景(例如 20000/30000),自动转换为秒
  22. if raw_ttl > 600: # 超过 10 分钟视为可能传入了毫秒
  23. converted = max(1, int(raw_ttl / 1000))
  24. logger.warning(
  25. "ENERGY_STONE_TTL_SECONDS=%s 看起来是毫秒,已按秒自动转换为 %s",
  26. raw_ttl,
  27. converted,
  28. )
  29. self.ttl_seconds = converted
  30. else:
  31. self.ttl_seconds = raw_ttl
  32. self.conversation_interval_ms = settings.CONVERSATION_STONE_INTERVAL_SECONDS * 1000
  33. self._mission_type_cache: Dict[str, Tuple[float, Optional[MissionType]]] = {}
  34. self._mission_type_cache_ttl = max(5, settings.MISSION_TYPE_CACHE_SECONDS)
  35. @staticmethod
  36. def _as_utc(dt: Optional[datetime]) -> Optional[datetime]:
  37. """将数据库取出的 naive 时间视为 UTC,避免本地时区偏移导致过长 TTL。"""
  38. if dt is None:
  39. return None
  40. if dt.tzinfo is None:
  41. return dt.replace(tzinfo=timezone.utc)
  42. return dt.astimezone(timezone.utc)
  43. async def set_db_manager(self, manager):
  44. """延迟注入数据库管理器"""
  45. self.db = manager
  46. async def create_mission_stone_if_needed(
  47. self,
  48. user_id: str,
  49. request_id: Optional[str],
  50. mission_type: str = "first_dialog",
  51. appear_reason: Optional[str] = None,
  52. metadata: Optional[Dict[str, Any]] = None,
  53. ) -> Optional[Dict[str, Any]]:
  54. """首次对话奖励石,单用户仅发放一次"""
  55. if not user_id:
  56. return None
  57. try:
  58. async with self.db.get_session() as session:
  59. mission_key = mission_type or "first_dialog"
  60. reason = appear_reason or mission_key
  61. mission_config: Optional[MissionType] = None
  62. is_repeatable = False
  63. async with session.begin():
  64. mission_config = await self._get_mission_type_config(session, mission_key)
  65. if mission_config is not None and mission_config.is_active is False:
  66. logger.debug(
  67. "任务类型已禁用,跳过发放: user=%s mission=%s", user_id, mission_key
  68. )
  69. return None
  70. is_repeatable = bool(mission_config.is_repeatable) if mission_config else False
  71. result = await session.execute(
  72. select(MissionEnergyStone.stone_id)
  73. .where(
  74. MissionEnergyStone.user_id == user_id,
  75. MissionEnergyStone.mission_type == mission_key,
  76. )
  77. .with_for_update()
  78. )
  79. existing = result.scalars().first()
  80. if existing and not is_repeatable:
  81. return None
  82. stone = MissionEnergyStone(
  83. stone_id=str(uuid.uuid4()),
  84. user_id=user_id,
  85. request_id=request_id,
  86. appear_reason=reason,
  87. mission_type=mission_key,
  88. metadata=metadata or {},
  89. )
  90. session.add(stone)
  91. await session.refresh(stone)
  92. spawn_meta = {
  93. "appear_reason": reason,
  94. "mission_type": mission_key,
  95. "is_repeatable": is_repeatable,
  96. }
  97. if mission_config:
  98. spawn_meta["mission_display_name"] = mission_config.display_name
  99. return self._to_spawn_payload(
  100. stone=stone,
  101. stone_type="mission",
  102. extra=spawn_meta,
  103. )
  104. except Exception as exc:
  105. logger.warning("创建任务能量石失败: user=%s error=%s", user_id, exc)
  106. return None
  107. async def create_conversation_stones_if_needed(
  108. self,
  109. user_id: str,
  110. request_id: Optional[str],
  111. total_duration_ms: int,
  112. duration_increment_ms: int,
  113. ) -> List[Dict[str, Any]]:
  114. """按累计说话时长发放对话能量石"""
  115. if not user_id or self.conversation_interval_ms <= 0:
  116. return []
  117. if total_duration_ms < self.conversation_interval_ms:
  118. return []
  119. target_index = total_duration_ms // self.conversation_interval_ms
  120. if target_index <= 0:
  121. return []
  122. stones: List[Dict[str, Any]] = []
  123. try:
  124. async with self.db.get_session() as session:
  125. result = await session.execute(
  126. select(func.max(ConversationEnergyStone.threshold_index)).where(
  127. ConversationEnergyStone.user_id == user_id
  128. )
  129. )
  130. last_index = result.scalar() or 0
  131. next_index = last_index + 1
  132. if next_index > target_index:
  133. return []
  134. created_instances: List[ConversationEnergyStone] = []
  135. for idx in range(next_index, target_index + 1):
  136. instance = ConversationEnergyStone(
  137. stone_id=str(uuid.uuid4()),
  138. user_id=user_id,
  139. request_id=request_id,
  140. threshold_index=idx,
  141. total_duration_ms=total_duration_ms,
  142. duration_increment_ms=duration_increment_ms,
  143. metadata={
  144. "target_total_ms": total_duration_ms,
  145. "interval_ms": self.conversation_interval_ms,
  146. },
  147. )
  148. session.add(instance)
  149. created_instances.append(instance)
  150. if not created_instances:
  151. return []
  152. await session.commit()
  153. for instance in created_instances:
  154. await session.refresh(instance)
  155. stones.append(
  156. self._to_spawn_payload(
  157. stone=instance,
  158. stone_type="conversation",
  159. extra={
  160. "threshold_index": instance.threshold_index,
  161. "total_duration_ms": instance.total_duration_ms,
  162. "duration_increment_ms": instance.duration_increment_ms,
  163. },
  164. )
  165. )
  166. return stones
  167. except IntegrityError:
  168. # 并发情况下可能已创建,忽略
  169. logger.debug("对话能量石已存在,忽略重复创建: user=%s target=%s", user_id, target_index)
  170. return []
  171. except Exception as exc:
  172. logger.warning("创建对话能量石失败: user=%s error=%s", user_id, exc)
  173. return []
  174. async def mark_event(
  175. self,
  176. stone_id: str,
  177. event: Literal["click", "timeout"],
  178. metadata: Optional[Dict[str, Any]] = None,
  179. ) -> bool:
  180. """记录点击或自动消失"""
  181. if not stone_id:
  182. return False
  183. try:
  184. async with self.db.get_session() as session:
  185. stone = await self._find_stone(session, stone_id)
  186. if not stone:
  187. return False
  188. if metadata:
  189. merged_meta = stone.metadata or {}
  190. merged_meta.update(metadata)
  191. stone.metadata = merged_meta
  192. now = datetime.now(timezone.utc)
  193. if event == "click":
  194. stone.clicked_at = now
  195. stone.auto_removed = False
  196. else:
  197. stone.auto_removed = True
  198. if not stone.clicked_at:
  199. stone.clicked_at = now
  200. await session.commit()
  201. return True
  202. except Exception as exc:
  203. logger.warning("记录能量石事件失败: stone=%s event=%s error=%s", stone_id, event, exc)
  204. return False
  205. async def _find_stone(self, session, stone_id: str):
  206. result = await session.execute(
  207. select(MissionEnergyStone).where(MissionEnergyStone.stone_id == stone_id)
  208. )
  209. stone = result.scalars().first()
  210. if stone:
  211. return stone
  212. result = await session.execute(
  213. select(ConversationEnergyStone).where(ConversationEnergyStone.stone_id == stone_id)
  214. )
  215. return result.scalars().first()
  216. def _to_spawn_payload(
  217. self,
  218. stone,
  219. stone_type: StoneType,
  220. extra: Optional[Dict[str, Any]] = None,
  221. ) -> Dict[str, Any]:
  222. appear_at = stone.appear_at or datetime.utcnow()
  223. appear_at_utc = self._as_utc(appear_at) or datetime.now(timezone.utc)
  224. ttl_seconds = max(1, int(self.ttl_seconds))
  225. payload = {
  226. "stone_id": stone.stone_id,
  227. "stone_type": stone_type,
  228. "ttl_seconds": ttl_seconds,
  229. "appear_at": int(appear_at_utc.timestamp() * 1000),
  230. "metadata": extra or {},
  231. }
  232. return payload
  233. async def get_stone_counts(self, user_id: str) -> Dict[str, int]:
  234. """获取用户的能量石数量"""
  235. if not user_id:
  236. return {"mission_count": 0, "conversation_count": 0}
  237. try:
  238. async with self.db.get_session() as session:
  239. # 获取任务能量石数量
  240. mission_result = await session.execute(
  241. select(func.count(MissionEnergyStone.stone_id)).where(
  242. MissionEnergyStone.user_id == user_id
  243. )
  244. )
  245. mission_count = mission_result.scalar() or 0
  246. # 对话能量石数量 = 对话获得的条数 + 赠送数量
  247. conversation_result = await session.execute(
  248. select(func.count(ConversationEnergyStone.stone_id)).where(
  249. ConversationEnergyStone.user_id == user_id
  250. )
  251. )
  252. conversation_earned = conversation_result.scalar() or 0
  253. bonus_result = await session.execute(
  254. select(UserBonusEnergyStone.bonus_conversation_count).where(
  255. UserBonusEnergyStone.user_id == user_id
  256. )
  257. )
  258. bonus_count = bonus_result.scalar() or 0
  259. conversation_count = conversation_earned + int(bonus_count)
  260. return {
  261. "mission_count": mission_count,
  262. "conversation_count": conversation_count,
  263. }
  264. except Exception as exc:
  265. logger.warning("获取能量石数量失败: user=%s error=%s", user_id, exc)
  266. return {"mission_count": 0, "conversation_count": 0}
  267. async def get_active_stones(self, user_id: str) -> List[Dict[str, Any]]:
  268. """获取用户未过期的能量石(用于页面恢复)"""
  269. if not user_id:
  270. return []
  271. try:
  272. async with self.db.get_session() as session:
  273. now = datetime.utcnow()
  274. active_stones: List[Dict[str, Any]] = []
  275. # 查询未过期的任务能量石
  276. # 条件:clicked_at IS NULL 且 appear_at + ttl_seconds > now
  277. mission_result = await session.execute(
  278. select(MissionEnergyStone).where(
  279. MissionEnergyStone.user_id == user_id,
  280. MissionEnergyStone.clicked_at.is_(None),
  281. )
  282. )
  283. for stone in mission_result.scalars().all():
  284. appear_at = self._as_utc(stone.appear_at or stone.created_at)
  285. if appear_at:
  286. expire_at = appear_at.timestamp() + self.ttl_seconds
  287. now_ts = now.timestamp()
  288. if expire_at > now_ts:
  289. # 计算剩余 TTL(秒),并对异常值打日志
  290. remaining_ttl = int(expire_at - now_ts)
  291. if remaining_ttl > self.ttl_seconds * 2:
  292. logger.warning(
  293. "Mission stone TTL too large, correcting: stone=%s appear_at=%s remaining=%s expected~%s",
  294. stone.stone_id,
  295. appear_at.isoformat(),
  296. remaining_ttl,
  297. self.ttl_seconds,
  298. )
  299. remaining_ttl = self.ttl_seconds
  300. payload = self._to_spawn_payload(
  301. stone=stone,
  302. stone_type="mission",
  303. extra={
  304. "appear_reason": stone.appear_reason,
  305. "mission_type": stone.mission_type,
  306. },
  307. )
  308. # 更新 TTL 为剩余时间
  309. payload["ttl_seconds"] = remaining_ttl
  310. active_stones.append(payload)
  311. # 查询未过期的对话能量石
  312. conversation_result = await session.execute(
  313. select(ConversationEnergyStone).where(
  314. ConversationEnergyStone.user_id == user_id,
  315. ConversationEnergyStone.clicked_at.is_(None),
  316. )
  317. )
  318. for stone in conversation_result.scalars().all():
  319. appear_at = self._as_utc(stone.appear_at or stone.created_at)
  320. if appear_at:
  321. expire_at = appear_at.timestamp() + self.ttl_seconds
  322. now_ts = now.timestamp()
  323. if expire_at > now_ts:
  324. # 计算剩余 TTL(秒),并对异常值打日志
  325. remaining_ttl = int(expire_at - now_ts)
  326. if remaining_ttl > self.ttl_seconds * 2:
  327. logger.warning(
  328. "Conversation stone TTL too large, correcting: stone=%s appear_at=%s remaining=%s expected~%s",
  329. stone.stone_id,
  330. appear_at.isoformat(),
  331. remaining_ttl,
  332. self.ttl_seconds,
  333. )
  334. remaining_ttl = self.ttl_seconds
  335. payload = self._to_spawn_payload(
  336. stone=stone,
  337. stone_type="conversation",
  338. extra={
  339. "threshold_index": stone.threshold_index,
  340. "total_duration_ms": stone.total_duration_ms,
  341. "duration_increment_ms": stone.duration_increment_ms,
  342. },
  343. )
  344. # 更新 TTL 为剩余时间
  345. payload["ttl_seconds"] = remaining_ttl
  346. active_stones.append(payload)
  347. return active_stones
  348. except Exception as exc:
  349. logger.warning("获取未过期能量石失败: user=%s error=%s", user_id, exc)
  350. return []
  351. async def _get_mission_type_config(
  352. self, session, mission_type_key: str
  353. ) -> Optional[MissionType]:
  354. """获取任务类型配置,带简单缓存"""
  355. now = time.monotonic()
  356. cached = self._mission_type_cache.get(mission_type_key)
  357. if cached:
  358. cached_ts, cached_value = cached
  359. if now - cached_ts < self._mission_type_cache_ttl:
  360. return cached_value
  361. result = await session.execute(
  362. select(MissionType).where(
  363. MissionType.type_key == mission_type_key,
  364. MissionType.is_active.is_(True),
  365. )
  366. )
  367. mission_type = result.scalars().first()
  368. self._mission_type_cache[mission_type_key] = (now, mission_type)
  369. return mission_type
  370. energy_stone_service = EnergyStoneService()