一、流程文档(按 AIcoding 六步落地,供新会话从交接文档开工) - 新增 docs/PRD/PRD-架构改进与稳定性加固.md:6 条 FR(文档勘误、非缺陷说明、 无密钥启动告警、审计失败告警、Redis 分布式锁、中间件顺序测试) - 新增 docs/项目框架设计/改进方案评审-问题清单与对比.md:24 项问题分档 A~G, 经两轮独立 AI 评审,无阻断级错误 - 新增 docs/项目框架设计/开发计划-架构改进.md:HOW 层设计,含合并前只做低风险 11 项的批次策略 - 新增 docs/项目框架设计/TODO-架构改进.md:T-101~T-109、T-201~T-202 可勾选项 - 新增 docs/交接文档-架构改进.md:自包含交接入口,hy3 新会话可直接开工 - 新增 docs/项目框架设计/架构设计说明书.md:按模块/分层逐一讲解的全量架构说明 二、代码改动(T-107/108/109、T-201.1、T-201.2) - app/main.py:启动时 DEEPSEEK_API_KEY 缺失告警,明确告知将走降级回复 - app/utils/authz.py:越权审计失败日志补 trace_id,便于串联全链路 - app/api/audit_middleware.py:审计失败日志补 status/path/request_id - app/service/risk/redis_gateway.py:新增 acquire_lock(SET NX EX)与 release_lock(Lua 原子释放,只删自己的锁) - app/service/risk/locks.py:run_locked 改为双层锁,Redis 为主、进程内锁为备; Redis 超时沿用 fn(locked=False) 降级语义,Redis 不可用(含测试 Fake 缺方法的 AttributeError)安全退回进程内锁,绝不抛异常 三、文档勘误(A1/A2/A3) - MEMORY.md:文件数 42→45、Tools 4→5 - 02-mysql-agent专用.sql:会话表 5→6 - 架构设计-风控模块.md:同步更正 四、测试 - 新增 tests/test_locks_redis.py:覆盖抢锁成功、占用超时、Redis 故障降级、 Fake 缺方法降级、只删自己锁、三处调用点 key 前缀 - tests/test_audit_middleware.py:补充告警字段断言 - 全量 pytest 510 passed(原基线 503)
139 lines
4.9 KiB
Python
139 lines
4.9 KiB
Python
"""T-201 · Redis 分布式锁双层(Redis 为主、进程内为备)单元测试。
|
||
|
||
不依赖本机 Redis:所有用例通过 monkeypatch `redis_gateway._gateway` 注入
|
||
可控假网关,覆盖「抢到 / 占用超时 / 不可用降级 / 释放只删自己锁 / 三处 key 前缀」。
|
||
降级路径任意异常(ConnectionError、Fake 缺方法 AttributeError)一律归类为
|
||
unavailable,绝不向上抛——保证 Redis 故障时安全退回进程内锁、业务照常完成。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
from app.service.risk import locks
|
||
from app.service.risk import redis_gateway
|
||
|
||
|
||
class FakeLockGateway:
|
||
"""可控假网关:内存字典模拟 SET NX;raise_on_acquire 模拟 Redis 故障。"""
|
||
|
||
def __init__(self, acquire_result: bool = True, raise_on_acquire: Exception | None = None) -> None:
|
||
self._store: dict[str, str] = {}
|
||
self.acquire_result = acquire_result
|
||
self.raise_on_acquire = raise_on_acquire
|
||
self.captured: list[tuple[str, str, int]] = []
|
||
|
||
def acquire_lock(self, key: str, token: str, ttl_seconds: int) -> bool:
|
||
self.captured.append((key, token, ttl_seconds))
|
||
if self.raise_on_acquire is not None:
|
||
raise self.raise_on_acquire
|
||
if not self.acquire_result:
|
||
return False
|
||
if key in self._store:
|
||
return False
|
||
self._store[key] = token
|
||
return True
|
||
|
||
def release_lock(self, key: str, token: str) -> bool:
|
||
if self._store.get(key) == token:
|
||
del self._store[key]
|
||
return True
|
||
return False
|
||
|
||
|
||
class NoAcquireGateway:
|
||
"""测试 Fake 缺方法场景:只有 publish 等旧方法,无 acquire_lock/release_lock。"""
|
||
|
||
def publish(self, channel, payload):
|
||
pass
|
||
|
||
|
||
@pytest.fixture()
|
||
def gateway(monkeypatch):
|
||
g = FakeLockGateway()
|
||
monkeypatch.setattr(redis_gateway, "_gateway", g)
|
||
return g
|
||
|
||
|
||
def test_redis_lock_acquired_runs_locked_true(gateway):
|
||
"""Redis 抢到锁 → fn(locked=True),且 key 带 TTL 与 lock: 前缀。"""
|
||
seen = {}
|
||
|
||
def _fn(locked: bool) -> dict:
|
||
seen["locked"] = locked
|
||
return {"ok": True}
|
||
|
||
result = locks.run_locked("agg:event:CUST-1:2026-09-09", _fn)
|
||
assert seen["locked"] is True
|
||
assert result == {"ok": True}
|
||
key, _token, ttl = gateway.captured[0]
|
||
assert key == "lock:agg:event:CUST-1:2026-09-09"
|
||
assert ttl == locks.LOCK_TTL_SECONDS
|
||
|
||
|
||
def test_redis_lock_occupied_times_out(gateway, monkeypatch):
|
||
"""锁被占用(抢不到)→ 等待超时降级 fn(locked=False),不抛异常。"""
|
||
gateway.acquire_result = False
|
||
monkeypatch.setattr(locks, "LOCK_TIMEOUT_SECONDS", 0.2)
|
||
monkeypatch.setattr(locks, "_RETRY_INTERVAL", 0.02)
|
||
seen = {}
|
||
|
||
def _fn(locked: bool) -> str:
|
||
seen["locked"] = locked
|
||
return "done"
|
||
|
||
assert locks.run_locked("agg:event:CUST-2:2026-09-09", _fn) == "done"
|
||
assert seen["locked"] is False
|
||
|
||
|
||
def test_redis_connection_error_falls_back_process_lock(gateway):
|
||
"""Redis 抛 ConnectionError → 归类 unavailable → 退回进程内锁,业务完成。"""
|
||
gateway.raise_on_acquire = ConnectionError("redis down")
|
||
seen = {}
|
||
|
||
def _fn(locked: bool) -> str:
|
||
seen["locked"] = locked
|
||
return "business-done"
|
||
|
||
assert locks.run_locked("agg:suitability:CUST-3:P-9:2026-09-09", _fn) == "business-done"
|
||
assert seen["locked"] is True # 进程内锁拿到 → locked=True
|
||
|
||
|
||
def test_redis_missing_method_attribute_error_falls_back(gateway, monkeypatch):
|
||
"""Fake 缺 acquire_lock(AttributeError)→ 归类 unavailable,测试不红。"""
|
||
monkeypatch.setattr(redis_gateway, "_gateway", NoAcquireGateway())
|
||
seen = {}
|
||
|
||
def _fn(locked: bool) -> str:
|
||
seen["locked"] = locked
|
||
return "ok"
|
||
|
||
assert locks.run_locked("l3:CUST-4", _fn) == "ok"
|
||
assert seen["locked"] is True
|
||
|
||
|
||
def test_release_only_owns_lock():
|
||
"""释放只删自己的锁:错误 token 返回 False 且锁仍在,正确 token 才删。"""
|
||
g = FakeLockGateway()
|
||
assert g.acquire_lock("lock:k", "tokA", 30) is True
|
||
assert g.release_lock("lock:k", "tokB") is False
|
||
assert "lock:k" in g._store # 锁仍在
|
||
assert g.release_lock("lock:k", "tokA") is True
|
||
assert "lock:k" not in g._store
|
||
|
||
|
||
def test_three_call_site_keys_have_lock_prefix(gateway):
|
||
"""三处调用点 key 形态全覆盖:agg:event: / agg:suitability: / l3: 均带 lock: 前缀。"""
|
||
keys = [
|
||
"agg:event:CUST-X:2026-09-09",
|
||
"agg:suitability:CUST-X:P-Y:2026-09-09",
|
||
"l3:CUST-X",
|
||
]
|
||
for k in keys:
|
||
locks.run_locked(k, lambda locked: True)
|
||
redis_keys = [c[0] for c in gateway.captured]
|
||
assert all(rk.startswith("lock:") for rk in redis_keys)
|
||
assert "lock:agg:event:CUST-X:2026-09-09" in redis_keys
|
||
assert "lock:agg:suitability:CUST-X:P-Y:2026-09-09" in redis_keys
|
||
assert "lock:l3:CUST-X" in redis_keys
|