pmc f26e78c545 feat(affinity-P2): service 层落地 — 唯一写入入口 + Redis 计数器 + 等级映射 + 跨级奖励 + WS 推送 (P2-01~P2-05)
新增 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>
2026-05-14 09:35:53 +08:00

90 lines
2.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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,
})