- Added a new visitor chat API endpoint (`/api/chat/visitor`) to allow unauthenticated users to engage in conversations without requiring customer data. - Introduced a visitor context dependency to manage visitor interactions seamlessly. - Enhanced the chat API to support explicit session termination and improved response handling for customer service interactions. - Updated the database configuration to include Redis client support for caching visitor data. - Added a new customer note repository to persist user notes independently of the L1 profile slots. This update significantly improves the customer service experience by enabling visitor interactions and ensuring efficient data handling for both registered and unregistered users.
393 lines
14 KiB
Python
393 lines
14 KiB
Python
"""profile_l3 单测(B3 · 最高档合并防降级 / AML 后大额不回落 / 并发首单)。
|
||
|
||
sqlite StaticPool 单连接共享内存库(同 test_alert_service 模式);
|
||
IntegrityError / 乐观锁丢竞态用 monkeypatch 模拟跨进程交错。
|
||
risk_score 口径:一期不写(恒 NULL,归 R-05 评分模型首写,评审 P3-4 用户拍板)。
|
||
"""
|
||
|
||
from datetime import datetime
|
||
|
||
import pytest
|
||
from sqlalchemy import text
|
||
|
||
from _ddl import create_sqlite_engine
|
||
|
||
from app.repository.risk_repository import RiskRepository
|
||
from app.service.risk import redis_gateway
|
||
from app.service.risk.profile_l3 import (
|
||
AML_PENDING_TAG,
|
||
ALERT_TYPE_TIER,
|
||
get_profile_l3,
|
||
highest_tier,
|
||
merge_l3,
|
||
tier_of,
|
||
upsert_profile_l3,
|
||
)
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _fake_redis(monkeypatch):
|
||
"""L3 写侧 DEL 钩子隔离(B7 挂账②)+ 行为断言记录(B7 复审 P2-1)。"""
|
||
|
||
class _FakeGateway:
|
||
def __init__(self):
|
||
self.messages = []
|
||
self.deletes = []
|
||
self.store: dict[str, str] = {}
|
||
|
||
def publish(self, channel, payload):
|
||
self.messages.append((channel, payload))
|
||
|
||
def delete(self, *keys):
|
||
self.deletes.append(keys)
|
||
for key in keys:
|
||
self.store.pop(key, None)
|
||
|
||
def get(self, key):
|
||
return self.store.get(key)
|
||
|
||
def set_ex(self, key, value, ttl_seconds):
|
||
self.store[key] = value
|
||
|
||
fake = _FakeGateway()
|
||
monkeypatch.setattr(redis_gateway, "_gateway", fake)
|
||
return fake
|
||
|
||
|
||
@pytest.fixture()
|
||
def env():
|
||
engine = create_sqlite_engine() # DDL 单一事实源(B4 评审 P3-12)
|
||
repo = RiskRepository(engine=engine)
|
||
yield repo, engine
|
||
engine.dispose()
|
||
|
||
|
||
def _upsert(repo, cid, alert_type, alert_id=None, tags=None, dims=None, computed_at=None):
|
||
return upsert_profile_l3(
|
||
cid,
|
||
alert_type,
|
||
monitor_tags=tags,
|
||
last_alert_id=alert_id,
|
||
score_dimensions=dims,
|
||
computed_at=computed_at,
|
||
risk_repo=repo,
|
||
)
|
||
|
||
|
||
def _row(engine, cid):
|
||
with engine.connect() as conn:
|
||
return conn.execute(
|
||
text(
|
||
"SELECT monitor_tier, risk_score, monitor_tags, last_alert_id, computed_at"
|
||
" FROM customer_profile_l3 WHERE customer_id = :cid"
|
||
),
|
||
{"cid": cid},
|
||
).mappings().one()
|
||
|
||
|
||
# ---------- 映射与纯函数 ----------
|
||
|
||
|
||
@pytest.mark.parametrize(("alert_type", "tier"), sorted(ALERT_TYPE_TIER.items()))
|
||
def test_alert_type_tier_mapping(alert_type, tier):
|
||
assert tier_of(alert_type) == tier
|
||
|
||
|
||
def test_unknown_alert_type_rejected(env):
|
||
repo, _ = env
|
||
with pytest.raises(ValueError):
|
||
_upsert(repo, "C1", "unknown_type")
|
||
|
||
|
||
def test_highest_tier_order():
|
||
assert highest_tier("normal", "watch") == "watch"
|
||
assert highest_tier("watch", "high") == "high"
|
||
assert highest_tier("high", "normal") == "high"
|
||
assert highest_tier("normal", "normal") == "normal"
|
||
|
||
|
||
def test_merge_new_customer():
|
||
merged = merge_l3(None, mapped_tier="watch", monitor_tags=["t1"], last_alert_id="ALT-1")
|
||
assert merged["monitor_tier"] == "watch"
|
||
assert merged["risk_score"] is None # 一期不写(P3-4 口径)
|
||
assert merged["monitor_tags"] == ["t1"]
|
||
assert merged["last_alert_id"] == "ALT-1"
|
||
assert merged["computed_at"] is not None
|
||
|
||
|
||
def test_merge_risk_score_stays_null_even_if_existing_has_value():
|
||
"""历史行若已有 score(异常数据),合并时也不维护/不传播。"""
|
||
existing = {"monitor_tier": "watch", "risk_score": 90, "monitor_tags": [],
|
||
"score_dimensions": {}}
|
||
merged = merge_l3(existing, mapped_tier="watch", monitor_tags=[], last_alert_id="ALT-2")
|
||
assert merged["risk_score"] is None
|
||
|
||
|
||
# ---------- upsert:首写与合并 ----------
|
||
|
||
|
||
def test_first_event_inserts(env):
|
||
repo, engine = env
|
||
merged = _upsert(repo, "C1", "large_amount", alert_id="ALT-1")
|
||
assert merged["monitor_tier"] == "watch" and merged["customer_id"] == "C1"
|
||
row = _row(engine, "C1")
|
||
assert row["monitor_tier"] == "watch" and row["risk_score"] is None
|
||
assert row["last_alert_id"] == "ALT-1" and row["computed_at"] is not None
|
||
assert get_profile_l3("C1", risk_repo=repo)["monitor_tier"] == "watch"
|
||
|
||
|
||
def test_normal_upgrades_to_watch(env):
|
||
repo, _ = env
|
||
_upsert(repo, "C1", "suitability", alert_id="ALT-0")
|
||
merged = _upsert(repo, "C1", "pattern", alert_id="ALT-1")
|
||
assert merged["monitor_tier"] == "watch" # normal → watch 升档
|
||
|
||
|
||
def test_watch_does_not_degrade_to_normal(env):
|
||
"""suitability 映射 normal:已 watch 的客户不被 suitability 事件拉低。"""
|
||
repo, engine = env
|
||
_upsert(repo, "C1", "pattern", alert_id="ALT-1")
|
||
merged = _upsert(repo, "C1", "suitability", alert_id="ALT-2")
|
||
assert merged["monitor_tier"] == "watch"
|
||
assert _row(engine, "C1")["monitor_tier"] == "watch"
|
||
|
||
|
||
def test_aml_marks_high_with_pending_review_tag(env):
|
||
repo, engine = env
|
||
merged = _upsert(repo, "C1", "aml", alert_id="ALT-1")
|
||
assert merged["monitor_tier"] == "high"
|
||
assert AML_PENDING_TAG in merged["monitor_tags"]
|
||
assert AML_PENDING_TAG in _row(engine, "C1")["monitor_tags"]
|
||
|
||
|
||
def test_high_does_not_degrade_to_normal(env):
|
||
"""B3 验收(评审 P2-4):aml 后 suitability(映射 normal)不回落。"""
|
||
repo, engine = env
|
||
_upsert(repo, "C1", "aml", alert_id="ALT-1")
|
||
merged = _upsert(repo, "C1", "suitability", alert_id="ALT-2")
|
||
assert merged["monitor_tier"] == "high"
|
||
assert _row(engine, "C1")["monitor_tier"] == "high"
|
||
|
||
|
||
def test_large_amount_after_aml_does_not_fall_back(env):
|
||
"""B3 验收:AML 后大额 → tier 仍 high、tags 并集、risk_score 保持 NULL。"""
|
||
repo, engine = env
|
||
_upsert(repo, "C1", "aml", alert_id="ALT-1")
|
||
merged = _upsert(repo, "C1", "large_amount", alert_id="ALT-2", tags=["manual_review"])
|
||
assert merged["monitor_tier"] == "high"
|
||
assert merged["risk_score"] is None
|
||
assert set(merged["monitor_tags"]) == {AML_PENDING_TAG, "manual_review"}
|
||
assert merged["last_alert_id"] == "ALT-2" # 联动最新
|
||
row = _row(engine, "C1")
|
||
assert row["monitor_tier"] == "high" and row["risk_score"] is None
|
||
|
||
|
||
def test_tags_accumulate_not_overwrite(env):
|
||
repo, _ = env
|
||
_upsert(repo, "C1", "pattern", alert_id="ALT-1", tags=["freq"])
|
||
merged = _upsert(repo, "C1", "pattern", alert_id="ALT-2", tags=["manual_review"])
|
||
assert merged["monitor_tags"] == ["freq", "manual_review"] # 不同 tag 跨事件追加
|
||
|
||
|
||
def test_last_alert_id_kept_when_not_passed(env):
|
||
"""评审 P2-2:漏传 last_alert_id 不抹掉旧值(FR-7 联动语义防御)。"""
|
||
repo, engine = env
|
||
_upsert(repo, "C1", "aml", alert_id="ALT-1")
|
||
merged = upsert_profile_l3("C1", "large_amount", risk_repo=repo)
|
||
assert merged["last_alert_id"] == "ALT-1"
|
||
assert _row(engine, "C1")["last_alert_id"] == "ALT-1"
|
||
|
||
|
||
def test_computed_at_refreshed_on_each_write(env):
|
||
"""评审 P3-2:每次写 computed_at 均刷新(FR-7)。"""
|
||
repo, engine = env
|
||
_upsert(repo, "C1", "pattern", alert_id="ALT-1")
|
||
upsert_profile_l3("C1", "large_amount", last_alert_id="ALT-2",
|
||
computed_at=datetime(2027, 1, 1, 8, 0, 0), risk_repo=repo)
|
||
# sqlite 读回为字符串,格式无关断言(核心是值已从首写的 now 刷新为传入时间)
|
||
assert str(_row(engine, "C1")["computed_at"]).startswith("2027-01-01 08:00")
|
||
|
||
|
||
def test_score_dimensions_replaced_only_when_passed(env):
|
||
repo, _ = env
|
||
_upsert(repo, "C1", "aml", alert_id="ALT-1", dims={"amount": 1})
|
||
merged = _upsert(repo, "C1", "large_amount", alert_id="ALT-2")
|
||
assert merged["score_dimensions"] == {"amount": 1} # 未传保留旧值
|
||
merged = _upsert(repo, "C1", "large_amount", alert_id="ALT-3", dims={"amount": 2})
|
||
assert merged["score_dimensions"] == {"amount": 2} # 传入则替换
|
||
|
||
|
||
# ---------- 并发与竞态 ----------
|
||
|
||
|
||
def test_concurrent_first_upsert_single_row(env):
|
||
"""并发冒烟:两线程同客户首单(aml + 大额)→ 1 行、high、tags 并集。"""
|
||
import json
|
||
from threading import Thread
|
||
|
||
repo, engine = env
|
||
errors = []
|
||
|
||
def worker(alert_type, alert_id):
|
||
try:
|
||
_upsert(repo, "C1", alert_type, alert_id=alert_id)
|
||
except Exception as exc: # pragma: no cover
|
||
errors.append(exc)
|
||
|
||
threads = [
|
||
Thread(target=worker, args=("aml", "ALT-A")),
|
||
Thread(target=worker, args=("large_amount", "ALT-B")),
|
||
]
|
||
for t in threads:
|
||
t.start()
|
||
for t in threads:
|
||
t.join()
|
||
assert not errors, errors
|
||
with engine.connect() as conn:
|
||
count, payload = conn.execute(
|
||
text(
|
||
"SELECT COUNT(*), GROUP_CONCAT(monitor_tags) FROM customer_profile_l3"
|
||
" WHERE customer_id = 'C1'"
|
||
)
|
||
).fetchone()
|
||
assert count == 1
|
||
tags = json.loads(payload)
|
||
assert AML_PENDING_TAG in tags
|
||
|
||
|
||
def test_integrity_error_falls_back_to_remerge(env, monkeypatch):
|
||
"""跨进程竞态(评审 P2-7③):首读 None、insert 撞主键 → 重读合并转更新,不丢对方写入。"""
|
||
repo, engine = env
|
||
|
||
# 对方进程已写入 high(模拟 AML 先落库)
|
||
repo.insert_l3("C1", "high", None, {}, [AML_PENDING_TAG], "ALT-AML", datetime.now())
|
||
|
||
calls = {"n": 0}
|
||
orig_get = type(repo).get_l3
|
||
|
||
def racing_get(self, cid):
|
||
calls["n"] += 1
|
||
if calls["n"] == 1:
|
||
return None # 本进程读写在对方 insert 之前发生
|
||
return orig_get(self, cid)
|
||
|
||
monkeypatch.setattr(type(repo), "get_l3", racing_get)
|
||
try:
|
||
merged = _upsert(repo, "C1", "large_amount", alert_id="ALT-2")
|
||
finally:
|
||
monkeypatch.undo()
|
||
|
||
assert merged["monitor_tier"] == "high" # 重读后合并,不降级
|
||
assert merged["last_alert_id"] == "ALT-2"
|
||
row = _row(engine, "C1")
|
||
assert row["monitor_tier"] == "high" and row["last_alert_id"] == "ALT-2"
|
||
|
||
|
||
def test_update_lost_race_retries_and_converges(env, monkeypatch):
|
||
"""评审 P1-1:update 路径丢更新——本进程读旧值后对方先写 high,乐观锁未命中
|
||
触发重读重试,最终收敛 high(修复前会被覆盖回退 watch)。"""
|
||
repo, engine = env
|
||
repo.insert_l3("C1", "watch", None, {}, [], "ALT-1", datetime.now())
|
||
|
||
calls = {"n": 0}
|
||
orig_update = type(repo).update_l3
|
||
|
||
def racing_update(self, *args, **kwargs):
|
||
calls["n"] += 1
|
||
if calls["n"] == 1:
|
||
orig_update(self, "C1", "high", None, {}, [AML_PENDING_TAG], "ALT-AML", datetime.now())
|
||
return False # 对方抢先提交,本进程乐观锁未命中
|
||
return orig_update(self, *args, **kwargs)
|
||
|
||
monkeypatch.setattr(type(repo), "update_l3", racing_update)
|
||
merged = _upsert(repo, "C1", "large_amount", alert_id="ALT-2")
|
||
|
||
assert calls["n"] >= 2 # 确实走了重试
|
||
assert merged["monitor_tier"] == "high"
|
||
assert AML_PENDING_TAG in merged["monitor_tags"]
|
||
row = _row(engine, "C1")
|
||
assert row["monitor_tier"] == "high"
|
||
assert row["last_alert_id"] == "ALT-2"
|
||
|
||
|
||
def test_persistent_conflict_raises_not_silent(env, monkeypatch):
|
||
"""重试耗尽抛错(宁失败不静默覆盖),不留半写状态。"""
|
||
repo, engine = env
|
||
repo.insert_l3("C1", "watch", None, {}, [], "ALT-1", datetime.now())
|
||
monkeypatch.setattr(type(repo), "update_l3", lambda self, *a, **k: False)
|
||
with pytest.raises(RuntimeError, match="conflicted"):
|
||
_upsert(repo, "C1", "large_amount", alert_id="ALT-2")
|
||
row = _row(engine, "C1")
|
||
assert row["monitor_tier"] == "watch" # 未被静默改写
|
||
|
||
|
||
# ---------- 写侧缓存 DEL 钩子行为(B7 复审 P2-1) ----------
|
||
|
||
|
||
def test_upsert_deletes_l3_cache_key(env, _fake_redis):
|
||
"""upsert 成功后 DEL profile:l3:{customer_id}(PRD §5.1,B7 挂账②)。"""
|
||
repo, _ = env
|
||
_upsert(repo, "CUST-DEL-1", "pattern", alert_id="ALT-1")
|
||
assert ("profile:l3:CUST-DEL-1",) in _fake_redis.deletes
|
||
|
||
|
||
def test_cache_delete_failure_degrades_to_ttl(env, monkeypatch):
|
||
"""降级路径:DEL 失败只告警不阻塞 upsert(TTL 兜底,redis_gateway 契约)。"""
|
||
repo, _ = env
|
||
|
||
class _BrokenGateway:
|
||
def publish(self, channel, payload):
|
||
pass
|
||
|
||
def delete(self, *keys):
|
||
raise ConnectionError("redis down")
|
||
|
||
def get(self, key):
|
||
return None
|
||
|
||
def set_ex(self, key, value, ttl_seconds):
|
||
pass
|
||
|
||
monkeypatch.setattr(redis_gateway, "_gateway", _BrokenGateway())
|
||
merged = _upsert(repo, "C1", "pattern", alert_id="ALT-1") # 不抛即降级成功
|
||
assert merged["monitor_tier"] == "watch"
|
||
row = _row(env[1], "C1")
|
||
assert row["monitor_tier"] == "watch" # DB 权威数据不受缓存失败影响
|
||
|
||
|
||
# ---------- 读侧 cache-aside(L3 热读 · 2026-09-09) ----------
|
||
|
||
|
||
def test_get_profile_l3_miss_backfills_cache(env, _fake_redis):
|
||
repo, _ = env
|
||
_upsert(repo, "C1", "pattern", alert_id="ALT-1")
|
||
_fake_redis.store.clear()
|
||
row = get_profile_l3("C1", risk_repo=repo)
|
||
assert row["monitor_tier"] == "watch"
|
||
assert "profile:l3:C1" in _fake_redis.store
|
||
|
||
|
||
def test_get_profile_l3_hit_skips_mysql(env, _fake_redis, monkeypatch):
|
||
repo, _ = env
|
||
_upsert(repo, "C1", "pattern", alert_id="ALT-1")
|
||
calls = {"n": 0}
|
||
orig_get = type(repo).get_l3
|
||
|
||
def counting_get(self, cid):
|
||
calls["n"] += 1
|
||
return orig_get(self, cid)
|
||
|
||
monkeypatch.setattr(type(repo), "get_l3", counting_get)
|
||
assert get_profile_l3("C1", risk_repo=repo)["monitor_tier"] == "watch"
|
||
assert calls["n"] == 1 # 首次 miss 回源
|
||
assert get_profile_l3("C1", risk_repo=repo)["monitor_tier"] == "watch"
|
||
assert calls["n"] == 1 # 二次命中 Redis,不再查库
|
||
|
||
|
||
def test_upsert_then_get_reads_fresh_after_del(env, _fake_redis):
|
||
repo, _ = env
|
||
_upsert(repo, "C1", "pattern", alert_id="ALT-1")
|
||
assert get_profile_l3("C1", risk_repo=repo)["monitor_tier"] == "watch"
|
||
_upsert(repo, "C1", "aml", alert_id="ALT-2")
|
||
assert get_profile_l3("C1", risk_repo=repo)["monitor_tier"] == "high"
|