新增 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>
88 lines
3.0 KiB
Python
88 lines
3.0 KiB
Python
"""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)
|
||
若没匹配到任何 AffinityLevel,new_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
|