energy_stone_service.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  1. """
  2. 能量石发放与事件记录服务
  3. """
  4. import math
  5. import uuid
  6. import logging
  7. import time
  8. from datetime import datetime, timezone
  9. from typing import Any, Dict, List, Literal, Optional, Tuple
  10. from sqlalchemy import select, func, text, update
  11. from sqlalchemy.exc import IntegrityError
  12. from database import db_manager
  13. from models_db import (
  14. MissionEnergyStone,
  15. ConversationEnergyStone,
  16. MissionType,
  17. User,
  18. )
  19. from config import settings
  20. logger = logging.getLogger(__name__)
  21. StoneType = Literal["mission", "conversation"]
  22. def _floor_non_negative_energy_stones(raw: object) -> int:
  23. """将 DECIMAL/浮点花费转为非负整数块数(向下取整)。"""
  24. if raw is None:
  25. return 0
  26. try:
  27. value = float(raw)
  28. except (TypeError, ValueError):
  29. return 0
  30. if value <= 0:
  31. return 0
  32. return int(math.floor(value))
  33. class EnergyStoneService:
  34. """能量石生成/状态同步"""
  35. def __init__(self):
  36. self.db = db_manager
  37. raw_ttl = settings.ENERGY_STONE_TTL_SECONDS
  38. # 兼容被误配置为毫秒的场景(例如 20000/30000),自动转换为秒
  39. if raw_ttl > 600: # 超过 10 分钟视为可能传入了毫秒
  40. converted = max(1, int(raw_ttl / 1000))
  41. logger.warning(
  42. "ENERGY_STONE_TTL_SECONDS=%s 看起来是毫秒,已按秒自动转换为 %s",
  43. raw_ttl,
  44. converted,
  45. )
  46. self.ttl_seconds = converted
  47. else:
  48. self.ttl_seconds = raw_ttl
  49. self.conversation_interval_ms = settings.CONVERSATION_STONE_INTERVAL_SECONDS * 1000
  50. self._mission_type_cache: Dict[str, Tuple[float, Optional[MissionType]]] = {}
  51. self._mission_type_cache_ttl = max(5, settings.MISSION_TYPE_CACHE_SECONDS)
  52. @staticmethod
  53. def _as_utc(dt: Optional[datetime]) -> Optional[datetime]:
  54. """将数据库取出的 naive 时间视为 UTC,避免本地时区偏移导致过长 TTL。"""
  55. if dt is None:
  56. return None
  57. if dt.tzinfo is None:
  58. return dt.replace(tzinfo=timezone.utc)
  59. return dt.astimezone(timezone.utc)
  60. async def set_db_manager(self, manager):
  61. """延迟注入数据库管理器"""
  62. self.db = manager
  63. async def create_mission_stone_if_needed(
  64. self,
  65. user_id: str,
  66. request_id: Optional[str],
  67. mission_type: str = "first_dialog",
  68. appear_reason: Optional[str] = None,
  69. metadata: Optional[Dict[str, Any]] = None,
  70. ) -> Optional[Dict[str, Any]]:
  71. """首次对话奖励石,单用户仅发放一次"""
  72. if not user_id:
  73. return None
  74. try:
  75. async with self.db.get_session() as session:
  76. mission_key = mission_type or "first_dialog"
  77. reason = appear_reason or mission_key
  78. mission_config: Optional[MissionType] = None
  79. is_repeatable = False
  80. async with session.begin():
  81. mission_config = await self._get_mission_type_config(session, mission_key)
  82. if mission_config is not None and mission_config.is_active is False:
  83. logger.debug(
  84. "任务类型已禁用,跳过发放: user=%s mission=%s", user_id, mission_key
  85. )
  86. return None
  87. is_repeatable = bool(mission_config.is_repeatable) if mission_config else False
  88. result = await session.execute(
  89. select(MissionEnergyStone.stone_id)
  90. .where(
  91. MissionEnergyStone.user_id == user_id,
  92. MissionEnergyStone.mission_type == mission_key,
  93. )
  94. .with_for_update()
  95. )
  96. existing = result.scalars().first()
  97. if existing and not is_repeatable:
  98. return None
  99. stone = MissionEnergyStone(
  100. stone_id=str(uuid.uuid4()),
  101. user_id=user_id,
  102. request_id=request_id,
  103. appear_reason=reason,
  104. mission_type=mission_key,
  105. metadata=metadata or {},
  106. )
  107. session.add(stone)
  108. await session.execute(
  109. update(User)
  110. .where(User.user_id == user_id)
  111. .values(crystal_stone_total=User.crystal_stone_total + 1)
  112. )
  113. await session.refresh(stone)
  114. spawn_meta = {
  115. "appear_reason": reason,
  116. "mission_type": mission_key,
  117. "is_repeatable": is_repeatable,
  118. }
  119. if mission_config:
  120. spawn_meta["mission_display_name"] = mission_config.display_name
  121. return self._to_spawn_payload(
  122. stone=stone,
  123. stone_type="mission",
  124. extra=spawn_meta,
  125. )
  126. except Exception as exc:
  127. logger.warning("创建任务能量石失败: user=%s error=%s", user_id, exc)
  128. return None
  129. async def create_conversation_stones_if_needed(
  130. self,
  131. user_id: str,
  132. request_id: Optional[str],
  133. total_duration_ms: int,
  134. duration_increment_ms: int,
  135. ) -> List[Dict[str, Any]]:
  136. """按累计说话时长发放对话能量石"""
  137. if not user_id or self.conversation_interval_ms <= 0:
  138. return []
  139. if total_duration_ms < self.conversation_interval_ms:
  140. return []
  141. target_index = total_duration_ms // self.conversation_interval_ms
  142. if target_index <= 0:
  143. return []
  144. stones: List[Dict[str, Any]] = []
  145. try:
  146. async with self.db.get_session() as session:
  147. result = await session.execute(
  148. select(func.max(ConversationEnergyStone.threshold_index)).where(
  149. ConversationEnergyStone.user_id == user_id
  150. )
  151. )
  152. last_index = result.scalar() or 0
  153. next_index = last_index + 1
  154. if next_index > target_index:
  155. return []
  156. created_instances: List[ConversationEnergyStone] = []
  157. for idx in range(next_index, target_index + 1):
  158. instance = ConversationEnergyStone(
  159. stone_id=str(uuid.uuid4()),
  160. user_id=user_id,
  161. request_id=request_id,
  162. threshold_index=idx,
  163. total_duration_ms=total_duration_ms,
  164. duration_increment_ms=duration_increment_ms,
  165. metadata={
  166. "target_total_ms": total_duration_ms,
  167. "interval_ms": self.conversation_interval_ms,
  168. },
  169. )
  170. session.add(instance)
  171. created_instances.append(instance)
  172. if not created_instances:
  173. return []
  174. n_new = len(created_instances)
  175. await session.execute(
  176. update(User)
  177. .where(User.user_id == user_id)
  178. .values(
  179. conversation_energy_stone_total=User.conversation_energy_stone_total
  180. + n_new,
  181. )
  182. )
  183. await session.commit()
  184. for instance in created_instances:
  185. await session.refresh(instance)
  186. stones.append(
  187. self._to_spawn_payload(
  188. stone=instance,
  189. stone_type="conversation",
  190. extra={
  191. "threshold_index": instance.threshold_index,
  192. "total_duration_ms": instance.total_duration_ms,
  193. "duration_increment_ms": instance.duration_increment_ms,
  194. },
  195. )
  196. )
  197. return stones
  198. except IntegrityError:
  199. # 并发情况下可能已创建,忽略
  200. logger.debug("对话能量石已存在,忽略重复创建: user=%s target=%s", user_id, target_index)
  201. return []
  202. except Exception as exc:
  203. logger.warning("创建对话能量石失败: user=%s error=%s", user_id, exc)
  204. return []
  205. async def mark_event(
  206. self,
  207. stone_id: str,
  208. event: Literal["click", "timeout"],
  209. metadata: Optional[Dict[str, Any]] = None,
  210. ) -> bool:
  211. """记录点击或自动消失"""
  212. if not stone_id:
  213. return False
  214. try:
  215. async with self.db.get_session() as session:
  216. stone = await self._find_stone(session, stone_id)
  217. if not stone:
  218. return False
  219. if metadata:
  220. merged_meta = stone.metadata or {}
  221. merged_meta.update(metadata)
  222. stone.metadata = merged_meta
  223. now = datetime.now(timezone.utc)
  224. if event == "click":
  225. stone.clicked_at = now
  226. stone.auto_removed = False
  227. else:
  228. stone.auto_removed = True
  229. if not stone.clicked_at:
  230. stone.clicked_at = now
  231. await session.commit()
  232. return True
  233. except Exception as exc:
  234. logger.warning("记录能量石事件失败: stone=%s event=%s error=%s", stone_id, event, exc)
  235. return False
  236. async def _find_stone(self, session, stone_id: str):
  237. result = await session.execute(
  238. select(MissionEnergyStone).where(MissionEnergyStone.stone_id == stone_id)
  239. )
  240. stone = result.scalars().first()
  241. if stone:
  242. return stone
  243. result = await session.execute(
  244. select(ConversationEnergyStone).where(ConversationEnergyStone.stone_id == stone_id)
  245. )
  246. return result.scalars().first()
  247. def _to_spawn_payload(
  248. self,
  249. stone,
  250. stone_type: StoneType,
  251. extra: Optional[Dict[str, Any]] = None,
  252. ) -> Dict[str, Any]:
  253. appear_at = stone.appear_at or datetime.utcnow()
  254. appear_at_utc = self._as_utc(appear_at) or datetime.now(timezone.utc)
  255. ttl_seconds = max(1, int(self.ttl_seconds))
  256. payload = {
  257. "stone_id": stone.stone_id,
  258. "stone_type": stone_type,
  259. "ttl_seconds": ttl_seconds,
  260. "appear_at": int(appear_at_utc.timestamp() * 1000),
  261. "metadata": extra or {},
  262. }
  263. return payload
  264. async def _sum_conversation_energy_spent_shopping(self, session, user_id: str) -> int:
  265. """
  266. 购物已消耗的对话能量石(块数,向下取整)。
  267. 优先汇总 asset_purchase_logs.cost_energy(流水明细);
  268. 若表不存在(未部署商城流水),再尝试 purchase_orders.total_cost_energy(已完成订单)。
  269. user_id 兼容 users.id 外键或 UUID 字符串两种存法(见项目文档 chatbot_mysql5_建表.sql)。
  270. """
  271. # 1) 购买流水:按笔累计能量石花费
  272. try:
  273. result = await session.execute(
  274. text(
  275. """
  276. SELECT COALESCE(SUM(apl.cost_energy), 0)
  277. FROM asset_purchase_logs apl
  278. WHERE apl.user_id = (SELECT id FROM users WHERE user_id = :uuid LIMIT 1)
  279. OR apl.user_id = :uuid
  280. """
  281. ),
  282. {"uuid": user_id},
  283. )
  284. spent = _floor_non_negative_energy_stones(result.scalar_one())
  285. return spent
  286. except Exception as exc:
  287. logger.debug(
  288. "对话能量石购物消耗汇总(asset_purchase_logs)不可用,将尝试订单表: user=%s err=%s",
  289. user_id,
  290. exc,
  291. )
  292. # 2) 订单主表:仅统计已完成订单的总能量石花费(表或流水未建时使用)
  293. try:
  294. result = await session.execute(
  295. text(
  296. """
  297. SELECT COALESCE(SUM(po.total_cost_energy), 0)
  298. FROM purchase_orders po
  299. WHERE po.status = 'completed'
  300. AND (
  301. po.user_id = (SELECT id FROM users WHERE user_id = :uuid LIMIT 1)
  302. OR po.user_id = :uuid
  303. )
  304. """
  305. ),
  306. {"uuid": user_id},
  307. )
  308. return _floor_non_negative_energy_stones(result.scalar_one())
  309. except Exception as exc:
  310. logger.debug("对话能量石购物消耗汇总(purchase_orders)不可用: user=%s err=%s", user_id, exc)
  311. return 0
  312. async def get_stone_counts(self, user_id: str) -> Dict[str, int]:
  313. """从 users 表读取展示余额(与发放/赠送/购物扣减同事务维护)。"""
  314. if not user_id:
  315. return {"mission_count": 0, "conversation_count": 0}
  316. try:
  317. async with self.db.get_session() as session:
  318. row = await session.execute(
  319. select(User.crystal_stone_total, User.conversation_energy_stone_total).where(
  320. User.user_id == user_id
  321. )
  322. )
  323. tup = row.one_or_none()
  324. if not tup:
  325. return {"mission_count": 0, "conversation_count": 0}
  326. crystal, conv = tup[0], tup[1]
  327. return {
  328. "mission_count": max(0, int(crystal or 0)),
  329. "conversation_count": max(0, int(conv or 0)),
  330. }
  331. except Exception as exc:
  332. logger.warning("获取能量石数量失败: user=%s error=%s", user_id, exc)
  333. return {"mission_count": 0, "conversation_count": 0}
  334. async def apply_shopping_deduction(
  335. self,
  336. user_id: str,
  337. energy_blocks: int = 0,
  338. crystal_blocks: int = 0,
  339. ) -> None:
  340. """
  341. 购物流水写入成功后调用:扣减 users 表上的对话能量石/水晶石余额(不低于 0)。
  342. energy_blocks / crystal_blocks 为整颗数(与 cost_energy / cost_crystal 向下取整后一致)。
  343. """
  344. if not user_id:
  345. return
  346. e = max(0, int(energy_blocks))
  347. c = max(0, int(crystal_blocks))
  348. if e == 0 and c == 0:
  349. return
  350. try:
  351. async with self.db.get_session() as session:
  352. async with session.begin():
  353. if e > 0:
  354. await session.execute(
  355. update(User)
  356. .where(User.user_id == user_id)
  357. .values(
  358. conversation_energy_stone_total=func.greatest(
  359. 0, User.conversation_energy_stone_total - e
  360. ),
  361. )
  362. )
  363. if c > 0:
  364. await session.execute(
  365. update(User)
  366. .where(User.user_id == user_id)
  367. .values(
  368. crystal_stone_total=func.greatest(0, User.crystal_stone_total - c),
  369. )
  370. )
  371. except Exception as exc:
  372. logger.warning(
  373. "购物扣减用户能量石余额失败: user=%s energy=%s crystal=%s error=%s",
  374. user_id,
  375. e,
  376. c,
  377. exc,
  378. )
  379. raise
  380. async def get_active_stones(self, user_id: str) -> List[Dict[str, Any]]:
  381. """获取用户未过期的能量石(用于页面恢复)"""
  382. if not user_id:
  383. return []
  384. try:
  385. async with self.db.get_session() as session:
  386. now = datetime.utcnow()
  387. active_stones: List[Dict[str, Any]] = []
  388. # 查询未过期的任务能量石
  389. # 条件:clicked_at IS NULL 且 appear_at + ttl_seconds > now
  390. mission_result = await session.execute(
  391. select(MissionEnergyStone).where(
  392. MissionEnergyStone.user_id == user_id,
  393. MissionEnergyStone.clicked_at.is_(None),
  394. )
  395. )
  396. for stone in mission_result.scalars().all():
  397. appear_at = self._as_utc(stone.appear_at or stone.created_at)
  398. if appear_at:
  399. expire_at = appear_at.timestamp() + self.ttl_seconds
  400. now_ts = now.timestamp()
  401. if expire_at > now_ts:
  402. # 计算剩余 TTL(秒),并对异常值打日志
  403. remaining_ttl = int(expire_at - now_ts)
  404. if remaining_ttl > self.ttl_seconds * 2:
  405. logger.warning(
  406. "Mission stone TTL too large, correcting: stone=%s appear_at=%s remaining=%s expected~%s",
  407. stone.stone_id,
  408. appear_at.isoformat(),
  409. remaining_ttl,
  410. self.ttl_seconds,
  411. )
  412. remaining_ttl = self.ttl_seconds
  413. payload = self._to_spawn_payload(
  414. stone=stone,
  415. stone_type="mission",
  416. extra={
  417. "appear_reason": stone.appear_reason,
  418. "mission_type": stone.mission_type,
  419. },
  420. )
  421. # 更新 TTL 为剩余时间
  422. payload["ttl_seconds"] = remaining_ttl
  423. active_stones.append(payload)
  424. # 查询未过期的对话能量石
  425. conversation_result = await session.execute(
  426. select(ConversationEnergyStone).where(
  427. ConversationEnergyStone.user_id == user_id,
  428. ConversationEnergyStone.clicked_at.is_(None),
  429. )
  430. )
  431. for stone in conversation_result.scalars().all():
  432. appear_at = self._as_utc(stone.appear_at or stone.created_at)
  433. if appear_at:
  434. expire_at = appear_at.timestamp() + self.ttl_seconds
  435. now_ts = now.timestamp()
  436. if expire_at > now_ts:
  437. # 计算剩余 TTL(秒),并对异常值打日志
  438. remaining_ttl = int(expire_at - now_ts)
  439. if remaining_ttl > self.ttl_seconds * 2:
  440. logger.warning(
  441. "Conversation stone TTL too large, correcting: stone=%s appear_at=%s remaining=%s expected~%s",
  442. stone.stone_id,
  443. appear_at.isoformat(),
  444. remaining_ttl,
  445. self.ttl_seconds,
  446. )
  447. remaining_ttl = self.ttl_seconds
  448. payload = self._to_spawn_payload(
  449. stone=stone,
  450. stone_type="conversation",
  451. extra={
  452. "threshold_index": stone.threshold_index,
  453. "total_duration_ms": stone.total_duration_ms,
  454. "duration_increment_ms": stone.duration_increment_ms,
  455. },
  456. )
  457. # 更新 TTL 为剩余时间
  458. payload["ttl_seconds"] = remaining_ttl
  459. active_stones.append(payload)
  460. return active_stones
  461. except Exception as exc:
  462. logger.warning("获取未过期能量石失败: user=%s error=%s", user_id, exc)
  463. return []
  464. async def _get_mission_type_config(
  465. self, session, mission_type_key: str
  466. ) -> Optional[MissionType]:
  467. """获取任务类型配置,带简单缓存"""
  468. now = time.monotonic()
  469. cached = self._mission_type_cache.get(mission_type_key)
  470. if cached:
  471. cached_ts, cached_value = cached
  472. if now - cached_ts < self._mission_type_cache_ttl:
  473. return cached_value
  474. result = await session.execute(
  475. select(MissionType).where(
  476. MissionType.type_key == mission_type_key,
  477. MissionType.is_active.is_(True),
  478. )
  479. )
  480. mission_type = result.scalars().first()
  481. self._mission_type_cache[mission_type_key] = (now, mission_type)
  482. return mission_type
  483. energy_stone_service = EnergyStoneService()