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

88 lines
3.0 KiB
Python
Raw 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-03 等级映射 + UserDevice.affinity_level 缓存更新
根据好感度数值映射到对应等级AffinityLevel并把结果写回 UserDevice.affinity_level
作为缓存。
设计依据:「好感度系统功能与规则设计.md」§6.3 等级变化规则
- 等级由好感度区间自动映射,每台设备独立判定
- 跨级判定:每次好感度变动后,取当前值所属区间,与上一次等级比较
"""
from __future__ import annotations
from typing import Optional, Tuple
from userapp.models import AffinityLevel
def map_value_to_level(value: int) -> Optional[AffinityLevel]:
"""根据好感度数值找出所属的 AffinityLevel。
匹配规则min_affinity <= value <= max_affinity 且 is_enabled=True 且 is_deleted=False
返回最高 level 优先(避免重叠区间时的歧义,但 P1 已加 clean 校验拦截重叠)。
若没有匹配到任何区间则返回 None理论上不应发生因为等级区间应覆盖 [0, max_affinity])。
"""
return (
AffinityLevel.objects
.filter(
min_affinity__lte=value,
max_affinity__gte=value,
is_enabled=True,
is_deleted=False,
)
.order_by('-level')
.first()
)
def progress_to_next_level(value: int, current_level: AffinityLevel) -> dict:
"""计算当前值在本等级区间内的进度百分比 + 到下一等级的距离。
返回:
{
'percent': 0~100 浮点(当前值在本等级区间内的位置百分比),
'next_level': AffinityLevel 或 None,
'points_to_next': int 或 None,
}
"""
span = max(current_level.max_affinity - current_level.min_affinity, 1)
percent = round((value - current_level.min_affinity) / span * 100, 2)
next_level = (
AffinityLevel.objects
.filter(level__gt=current_level.level, is_enabled=True, is_deleted=False)
.order_by('level')
.first()
)
points_to_next = None
if next_level is not None:
points_to_next = max(next_level.min_affinity - value, 0)
return {
'percent': max(0.0, min(100.0, percent)),
'next_level': next_level,
'points_to_next': points_to_next,
}
def update_device_level(user_device, save: bool = True) -> Tuple[int, int, Optional[AffinityLevel]]:
"""根据 user_device.favorability 重新计算并更新 affinity_level 缓存字段。
返回 (old_level, new_level, matched_level_obj)
若没匹配到任何 AffinityLevelnew_level 保持原值matched_level_obj=None。
save=False 时不调 .save()由调用方批量保存service 层)。
"""
old_level = user_device.affinity_level
matched = map_value_to_level(user_device.favorability)
if matched is None:
return old_level, old_level, None
new_level = matched.level
if new_level != old_level:
user_device.affinity_level = new_level
if save:
user_device.save(update_fields=['affinity_level'])
return old_level, new_level, matched