"""P2-05 WebSocket 推送 helper 把好感度变化事件推到 `device_{user_id}` channel layer group, 设备端和手机端的 DeviceConsumer 都加入此分组,会同时收到(设计文档 §9.3)。 推送是「fire-and-forget」语义 — channel layer 故障或用户不在线时不应阻塞 service 主流程,全部用 try/except 包裹并仅日志记录。 """ from __future__ import annotations import logging from typing import Any, Dict, List, Optional from asgiref.sync import async_to_sync from channels.layers import get_channel_layer logger = logging.getLogger(__name__) def _send(user_id: int, payload: Dict[str, Any]) -> None: """内部统一推送入口。channel_layer 不可用 / group_send 异常时静默吞掉但记日志。""" try: channel_layer = get_channel_layer() if channel_layer is None: logger.warning('[affinity.ws] channel_layer 未配置,跳过推送 payload=%s', payload) return async_to_sync(channel_layer.group_send)(f'device_{user_id}', payload) except Exception as exc: # pragma: no cover — 推送失败不影响业务 logger.warning('[affinity.ws] group_send 失败 user_id=%s err=%s', user_id, exc) def push_affinity_update( user_id: int, device_id: Optional[int], *, change: int, before: int, after: int, rule_key: str, source: str, ) -> None: """好感度数值变化事件。所有触发点(设备 / 手机 / 衰减 / 管理员调整)共用此事件。""" _send(user_id, { 'type': 'affinity.update', # consumer 端 handler 名(消费者用 .replace('.', '_')) 'event': 'affinity_update', 'device_id': device_id, 'change': change, 'before': before, 'after': after, 'rule_key': rule_key, 'source': source, }) def push_level_up( user_id: int, device_id: Optional[int], *, old_level: int, new_level: int, rewards: List[Dict[str, Any]], ) -> None: """升级事件。rewards 是本次跨级一次性发放的奖励列表,每级一项。""" _send(user_id, { 'type': 'affinity.level.up', 'event': 'level_up', 'device_id': device_id, 'old_level': old_level, 'new_level': new_level, 'rewards': rewards, }) def push_level_down( user_id: int, device_id: Optional[int], *, old_level: int, new_level: int, ) -> None: """降级事件。衰减导致的等级回退,不追回奖励但取消等级解锁内容。""" _send(user_id, { 'type': 'affinity.level.down', 'event': 'level_down', 'device_id': device_id, 'old_level': old_level, 'new_level': new_level, })