fix(backend): consume webview tickets atomically and fail closed on Redis errors.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-09-14 16:07:45 +08:00
1 parent 62161e2905
commit 98292cb914
2 files changed
+49 -10

No files matched your search

+40 -9
View File
@@ -2,7 +2,9 @@
from __future__ import annotations
import json
from typing import Optional
from typing import Callable, Optional, TypeVar
from redis import exceptions as redis_exceptions
from app.core.redis_client import get_sync_redis_client
from app.core.utils import generate_uuid
@@ -10,15 +12,43 @@ from app.core.utils import generate_uuid
TICKET_TTL_SEC = 60
KEY_PREFIX = "mp_wv_ticket:"
_GETDEL_LUA = """
local val = redis.call('GET', KEYS[1])
if val then
redis.call('DEL', KEYS[1])
end
return val
"""
T = TypeVar("T")
def _key(ticket: str) -> str:
return f"{KEY_PREFIX}{ticket}"
def issue_ticket(user_id: str, session_id: str, refresh_jti: str = "") -> str:
def _atomic_getdel(r, key: str):
if hasattr(r, "getdel"):
return r.getdel(key)
return r.eval(_GETDEL_LUA, 1, key)
def _redis_call(fn: Callable[..., T], unavailable_msg: str) -> T:
r = get_sync_redis_client()
if r is None:
raise RuntimeError("Redis 不可用,无法签发 WebView ticket")
raise RuntimeError(unavailable_msg)
try:
return fn(r)
except (
redis_exceptions.TimeoutError,
redis_exceptions.ConnectionError,
redis_exceptions.RedisError,
) as exc:
raise RuntimeError(unavailable_msg) from exc
def issue_ticket(user_id: str, session_id: str, refresh_jti: str = "") -> str:
def _issue(r) -> str:
ticket = generate_uuid()
body = json.dumps(
{"userId": user_id, "sessionId": session_id, "refreshJti": refresh_jti or ""},
@@ -29,17 +59,16 @@ def issue_ticket(user_id: str, session_id: str, refresh_jti: str = "") -> str:
raise RuntimeError("签发 WebView ticket 失败")
return ticket
return _redis_call(_issue, "Redis 不可用,无法签发 WebView ticket")
def consume_ticket(ticket: str) -> Optional[dict]:
raw = (ticket or "").strip()
if not raw:
return None
r = get_sync_redis_client()
if r is None:
raise RuntimeError("Redis 不可用,无法兑换 WebView ticket")
key = _key(raw)
payload = r.get(key)
r.delete(key)
def _consume(r) -> Optional[dict]:
payload = _atomic_getdel(r, _key(raw))
if not payload:
return None
if isinstance(payload, bytes):
@@ -48,3 +77,5 @@ def consume_ticket(ticket: str) -> Optional[dict]:
if not isinstance(data, dict):
return None
return data
return _redis_call(_consume, "Redis 不可用,无法兑换 WebView ticket")
+9 -1
View File
@@ -1,4 +1,3 @@
import json
from unittest.mock import patch
import pytest
@@ -22,6 +21,9 @@ class FakeRedis:
def delete(self, key):
return 1 if self.data.pop(key, None) is not None else 0
def getdel(self, key):
return self.data.pop(key, None)
def test_issue_and_consume_once():
fake = FakeRedis()
@@ -44,3 +46,9 @@ def test_issue_without_redis_raises():
with patch.object(svc, "get_sync_redis_client", return_value=None):
with pytest.raises(RuntimeError, match="Redis"):
svc.issue_ticket("u1", "sid-1")
def test_consume_without_redis_raises():
with patch.object(svc, "get_sync_redis_client", return_value=None):
with pytest.raises(RuntimeError, match="Redis"):
svc.consume_ticket("any-ticket")