新增 6 个模块,把好感度变化的全部副作用收敛到一个调用入口:
- counters.py (P2-02):Redis 三类计数器
- affinity💿{device_id}:{rule_key} 冷却
- affinity:daily:{device_id}:{rule_key}:{YYYYMMDD} 单规则日上限
- affinity:daily:{device_id}:_global:{YYYYMMDD} 全局正向日上限
- 自然日按 AffinitySetting.timezone (Asia/Shanghai 默认) 通过 zoneinfo 计算
- cache.add + cache.incr 实现 set-if-not-exists + atomic-incr 语义,TTL 48h
- event_id 60s 去重防客户端重复上报
- levels.py (P2-03):等级映射
- map_value_to_level / update_device_level / progress_to_next_level
- update_device_level 仅 level 变化时 save(update_fields=['affinity_level'])
- ws.py (P2-05):WebSocket 推送 helper
- 3 类事件 affinity_update / level_up / level_down
- asgiref.async_to_sync 包装 channel_layer.group_send
- 推送故障 fire-and-forget 仅日志记录,不阻塞主流程
- rewards.py (P2-04):跨级奖励发放(A3 方案 B)
- grant_levels(user_device, from_level, to_level) 逐级独立事务
- UserLevelRewardGrant 唯一约束保证幂等(决策 11:衰减回升不补发)
- _dispatch_reward_to_external_systems 是 STUB,P3/P4 接虚拟货币/道具 app 时实现
- services.py (P2-01):AffinityService 主入口
- apply(user_id, device_id, rule_key, source, event_id, metadata, operator_admin_id, reason)
- 10 步流水线 [event_id 去重 → 取规则 → 冷却 → 取 UserDevice.active → 计算 + single_cap 钳位 → 规则日上限 → 全局日上限 → 原子写库 → Redis 累加 → 奖励 → WS 推送]
- admin_adjust 绕过 rule 与冷却,但走 [0, max_affinity] 钳位 + log + 等级缓存 + 奖励 + WS
- 返回 ApplyResult dataclass 含 ApplyOutcome 枚举(applied / noop_no_rule / noop_cooldown / noop_*_daily_cap / noop_event_duplicate / noop_value_boundary / error)
- permissions.py:IsAdminUserStaff 复用 IsAuthenticated + is_staff 检查
Smoke test 6 项全 PASS:no_rule / chat applied / event_id 去重 / 冷却拦截 / admin_adjust / max_affinity 钳位。
AffinityLog 写库 / UserLevelRewardGrant 幂等 / level 缓存更新 均经事务原子保证。
设计依据:docs/好感度系统功能与规则设计.md §4.3 触发流程 + §6 等级规则 + §9 数据契约。
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
90 lines
2.7 KiB
Python
90 lines
2.7 KiB
Python
"""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,
|
||
})
|