Files
group_xinghuo_jinrong/tests/test_profile_l3.py
T
zhanghongyu_0626 b841f68295 feat(visitor): Implement visitor chat functionality and enhance customer service interactions
- 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.
2026-09-09 18:32:00 +08:00

393 lines
14 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.
"""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"