ソースを参照

修复workbuddy-32000报错redis已经过期但是mysql又显示没过期问题

jackson 3 日 前
コミット
b331f675bf

+ 3 - 2
.env.example

@@ -45,8 +45,9 @@ FMS_GATEWAY_MODE=public
 # Public gateway base URL (used for client configuration)
 FMS_GATEWAY_PUBLIC_BASE=https://mcp.example.com
 
-# Gateway session TTL in seconds (default: 2592000 = 30 days)
-FMS_GATEWAY_SESSION_TTL_SECONDS=2592000
+# Gateway Redis session TTL in seconds. 0 = no expiry (default; matches device records).
+# Set a positive value only if idle sessions must expire independently of revoke.
+FMS_GATEWAY_SESSION_TTL_SECONDS=0
 
 # Redis prefix for public mode (must differ from local mode to avoid conflicts)
 FMS_REDIS_PREFIX=fms:mcp:gateway:

+ 8 - 4
README.md

@@ -83,6 +83,7 @@ Gateway 在 `tools/call` 最终边界处理展示字段,不改变 ThinkPHP 内
 - 四个导出工具只返回 `task_ref + queued + retry_after_seconds`。客户端稍后在新的调用中使用 `query_export_task`;完成后才返回 `files[].label + files[].url`。Gateway 不在一次调用内等待或循环轮询。
 - `request_id` 位于 MCP 结果 `_meta`;参数错误使用业务名称,未知异常不透传后端细节。
 - stdio 与公网 `tools/call` 缺少有效工具名时返回 JSON-RPC `-32602 Invalid params`,不包装为业务 `isError`。
+- 公网 `tools/list` 在设备会话缺失或 Redis 映射不存在时返回 JSON-RPC `-32001` 和设备失效文案。
 - stdio 与 public 模式共用 `services/output_presenter.py`;未知工具或畸形响应关闭失败。
 
 详细设计见集中文档仓的 [技术规格 Presenter 分类](../all_project_docs/mcp/tech-specs.md#presenter-分类)。
@@ -212,9 +213,10 @@ Gateway 根据 `gateway_session_id` 从 Redis 读取当前员工的 `mcp_token`
 
 ### 3. 撤销与过期
 
-- 员工在后台取消 Workbuddy 授权后,ThinkPHP 会让对应 `mcp_token` 失效。
-- 公网 Gateway Redis session 会在 TTL 到期后自动过期。
-- 当前公网模式默认 TTL 为 30 天,可通过 `FMS_GATEWAY_SESSION_TTL_SECONDS` 调整。
+- 员工在后台取消 Workbuddy 授权后,ThinkPHP 会让对应 `mcp_token` 失效,并删除 Redis Gateway session。
+- 公网 Redis 映射默认不设 TTL,与设备记录的长期有效哨兵值一致;停用或轮换设备时才删除。
+- `FMS_GATEWAY_SESSION_TTL_SECONDS` 默认 `0`(永不过期)。只有明确需要空闲过期时才设为正整数秒。
+- 请求头带有合法 `GWS_*` 但 Redis 没有映射时,`tools/list` 返回 JSON-RPC `-32001` 和设备失效文案,不使用 `-32000`。
 - 如果 token 已失效但 Redis session 仍存在,下一次工具调用会被 ThinkPHP 拒绝。
 
 ## 重启公网 MCP 服务
@@ -358,7 +360,7 @@ FMS_REDIS_PREFIX=fms:mcp:workbuddy:
 ```dotenv
 FMS_GATEWAY_MODE=public
 FMS_GATEWAY_PUBLIC_BASE=https://mcp.example.com
-FMS_GATEWAY_SESSION_TTL_SECONDS=2592000
+FMS_GATEWAY_SESSION_TTL_SECONDS=0
 
 FMS_TOKEN_STORE=redis
 FMS_REDIS_HOST=127.0.0.1
@@ -470,6 +472,8 @@ Gateway 会调用以下路径:
 
 跨 Gateway、PHP 和数据库访问日志的排查顺序见集中文档仓的 [`mcp-team-sharing.md`](../all_project_docs/mcp/guides/mcp-team-sharing.md)“排错时怎么看日志”章节。
 
+若历史上 Redis Gateway session 被写成 30 天 TTL,发版后新的 `touch`/`save` 会去掉过期。对仍存在的旧 key 可一次性 `PERSIST`;已经消失的 key 无法从 MySQL 重建,员工必须重新生成设备配置。
+
 若 Support 页面只能看到 fmsoperate 事件、看不到 Gateway 前置阶段,依次确认:
 
 1. Gateway `.env` 的 `MCP_DIAGNOSIS_ENABLED=true`,URL 指向已部署的 internal collector。

+ 2 - 2
config.py

@@ -19,7 +19,7 @@ class GatewayConfig:
     redis_prefix: str = 'fms:mcp:workbuddy:'
     session_key: str = ''
     gateway_mode: str = 'local'
-    gateway_session_ttl_seconds: int = 2592000
+    gateway_session_ttl_seconds: int = 0
     rate_limit_enabled: bool = True
     rate_limit_max_requests: int = 60
     rate_limit_window_seconds: int = 60
@@ -80,7 +80,7 @@ class GatewayConfig:
             redis_prefix=redis_prefix,
             session_key=session_key or cls._build_default_session_key(primary_env),
             gateway_mode=gateway_mode,
-            gateway_session_ttl_seconds=int(cls._pick(dotenv_env, primary_env, 'FMS_GATEWAY_SESSION_TTL_SECONDS', 'MCP_GATEWAY_SESSION_TTL_SECONDS') or '2592000'),
+            gateway_session_ttl_seconds=int(cls._pick(dotenv_env, primary_env, 'FMS_GATEWAY_SESSION_TTL_SECONDS', 'MCP_GATEWAY_SESSION_TTL_SECONDS') or '0'),
             rate_limit_enabled=cls._parse_bool(cls._pick(dotenv_env, primary_env, 'FMS_RATE_LIMIT_ENABLED', 'MCP_RATE_LIMIT_ENABLED'), default=True),
             rate_limit_max_requests=cls._parse_int(cls._pick(dotenv_env, primary_env, 'FMS_RATE_LIMIT_MAX_REQUESTS', 'MCP_RATE_LIMIT_MAX_REQUESTS'), default=60),
             rate_limit_window_seconds=cls._parse_int(cls._pick(dotenv_env, primary_env, 'FMS_RATE_LIMIT_WINDOW_SECONDS', 'MCP_RATE_LIMIT_WINDOW_SECONDS'), default=60),

+ 2 - 0
public_gateway.py

@@ -154,6 +154,8 @@ class PublicGatewayApp:
 
     def list_tools(self, gateway_session_id, request_id=''):
         session = self._require_session(gateway_session_id)
+        if hasattr(self.session_store, 'touch_session'):
+            self.session_store.touch_session(gateway_session_id)
         request_id = self.build_request_id(request_id)
         enabled = self._load_enabled_tool_names(
             session['mcp_token'],

+ 28 - 0
public_server.py

@@ -397,6 +397,34 @@ class PublicMcpHttpHandler:
                     exc,
                     trace_request_id,
                 )
+            if str(exc) == DEVICE_INVALID_MESSAGE:
+                parsed = self.context_parser.parse(headers or {})
+                emitter.emit(
+                    stage='gateway_session',
+                    status='failed',
+                    event_code='GATEWAY_SESSION_NOT_FOUND',
+                    session_credential=(
+                        parsed.gateway_session_id if parsed.has_session() else None
+                    ),
+                    context={'transport': 'http'},
+                )
+                logger.warning(
+                    'MCP public device session unavailable',
+                    extra={
+                        'request_id': trace_request_id,
+                        'jsonrpc_id': request_id,
+                        'protocol_method': method,
+                        'tool_code': tool_name,
+                        'protocol_code': -32001,
+                        'diagnostic_reason': 'GATEWAY_SESSION_NOT_FOUND',
+                    },
+                )
+                return McpProtocolHandler._error_response(
+                    request_id,
+                    -32001,
+                    DEVICE_INVALID_MESSAGE,
+                    trace_request_id,
+                )
             logger.error(
                 "MCP public request failed",
                 extra={

+ 6 - 3
services/gateway_session_store.py

@@ -5,10 +5,10 @@ from utils.security import hash_gateway_session_id
 
 
 class GatewaySessionStore:
-    def __init__(self, client, prefix='fms:mcp:gateway:', ttl_seconds=2592000):
+    def __init__(self, client, prefix='fms:mcp:gateway:', ttl_seconds=0):
         self.client = client
         self.prefix = str(prefix or 'fms:mcp:gateway:')
-        self.ttl_seconds = int(ttl_seconds or 2592000)
+        self.ttl_seconds = int(ttl_seconds or 0)
 
     def key_for(self, gateway_session_id):
         return self.prefix.rstrip(':') + ':session:' + hash_gateway_session_id(gateway_session_id)
@@ -16,10 +16,13 @@ class GatewaySessionStore:
     def save(self, gateway_session_id, session):
         payload = dict(session or {})
         payload['gateway_session_id_hash'] = hash_gateway_session_id(gateway_session_id)
+        set_kwargs = {}
+        if self.ttl_seconds > 0:
+            set_kwargs['ex'] = self.ttl_seconds
         self.client.set(
             self.key_for(gateway_session_id),
             json.dumps(payload, ensure_ascii=False),
-            ex=self.ttl_seconds,
+            **set_kwargs,
         )
         return payload
 

+ 17 - 0
tests/test_config_compat.py

@@ -83,6 +83,23 @@ class GatewayConfigCompatTest(unittest.TestCase):
         self.assertEqual(600, config.gateway_session_ttl_seconds)
         self.assertEqual('fms:mcp:gateway:', config.redis_prefix)
 
+    def test_public_gateway_session_ttl_defaults_to_zero(self):
+        config = GatewayConfig.from_env(env={
+            'FMS_API_BASE': 'https://base.example.com',
+            'FMS_GATEWAY_MODE': 'public',
+        }, dotenv_path='missing.env')
+
+        self.assertEqual(0, config.gateway_session_ttl_seconds)
+
+    def test_public_gateway_session_ttl_zero_is_preserved(self):
+        config = GatewayConfig.from_env(env={
+            'FMS_API_BASE': 'https://base.example.com',
+            'FMS_GATEWAY_MODE': 'public',
+            'FMS_GATEWAY_SESSION_TTL_SECONDS': '0',
+        }, dotenv_path='missing.env')
+
+        self.assertEqual(0, config.gateway_session_ttl_seconds)
+
     def test_public_gateway_mode_defaults_to_gateway_redis_prefix(self):
         config = GatewayConfig.from_env(env={
             'FMS_API_BASE': 'https://base.example.com',

+ 22 - 0
tests/test_gateway_session_store.py

@@ -72,5 +72,27 @@ class GatewaySessionStoreTest(unittest.TestCase):
         self.assertTrue(touched['last_access_time'].endswith('Z'))
         self.assertEqual(touched['last_access_time'], store.get('GWS_employee_a')['last_access_time'])
 
+    def test_zero_ttl_is_not_coerced_to_thirty_days(self):
+        store = GatewaySessionStore(FakeRedis(), ttl_seconds=0)
+
+        self.assertEqual(0, store.ttl_seconds)
+
+    def test_save_without_ttl_omits_redis_expiry(self):
+        redis = FakeRedis()
+        store = GatewaySessionStore(redis, prefix='fms:mcp:gateway:', ttl_seconds=0)
+
+        store.save('GWS_employee_a', {'mcp_token': 'MT_A'})
+
+        self.assertIsNone(list(redis.values.values())[0]['ex'])
+
+    def test_touch_session_without_ttl_omits_redis_expiry(self):
+        redis = FakeRedis()
+        store = GatewaySessionStore(redis, prefix='fms:mcp:gateway:', ttl_seconds=0)
+
+        store.save('GWS_employee_a', {'mcp_token': 'MT_A'})
+        store.touch_session('GWS_employee_a')
+
+        self.assertIsNone(list(redis.values.values())[0]['ex'])
+
 if __name__ == '__main__':
     unittest.main()

+ 7 - 0
tests/test_gateway_session_store_unit.py

@@ -126,6 +126,13 @@ class TestGatewaySessionStore(unittest.TestCase):
         self.assertIsNone(result)
         self.mock_redis.set.assert_not_called()
 
+    def test_save_with_zero_ttl_does_not_pass_ex(self):
+        store = GatewaySessionStore(self.mock_redis, ttl_seconds=0)
+
+        store.save('GWS_permanent', {'mcp_token': 'MT_live'})
+
+        self.assertNotIn('ex', store.client.set.call_args[1])
+
 
 if __name__ == '__main__':
     unittest.main()

+ 9 - 0
tests/test_public_gateway.py

@@ -238,6 +238,15 @@ class PublicGatewayAppTest(unittest.TestCase):
         self.assertIn('query_order_exact', tool_names)
         self.assertIn('list_order_filter_options', tool_names)
 
+    def test_list_tools_touches_existing_session(self):
+        store = FakeSessionStore()
+        store.sessions['GWS_A'] = {'mcp_token': 'MT_A'}
+        app = PublicGatewayApp(session_store=store, api_client=FakeApiClient(), auth_client=None)
+
+        app.list_tools('GWS_A')
+
+        self.assertEqual(['GWS_A'], store.touched)
+
     def test_missing_session_returns_human_device_message(self):
         app = PublicGatewayApp(session_store=FakeSessionStore(), api_client=FakeApiClient(), auth_client=None)
 

+ 18 - 0
tests/test_public_gateway_unit.py

@@ -57,6 +57,7 @@ class TestPublicGatewayApp(unittest.TestCase):
             self.assertIn('name', tool)
             self.assertIn('description', tool)
             self.assertIn('input_schema', tool)
+        self.mock_session_store.touch_session.assert_called_once_with('GWS_test')
 
     def test_list_tools_intersects_enabled_codes_and_preserves_local_order(self):
         self.mock_session_store.get.return_value = {
@@ -91,6 +92,7 @@ class TestPublicGatewayApp(unittest.TestCase):
             self.app.list_tools('GWS_missing')
 
         self.mock_api_client.list_enabled_tools.assert_not_called()
+        self.mock_session_store.touch_session.assert_not_called()
 
     def test_list_tools_fails_closed_on_registry_error(self):
         self.mock_session_store.get.return_value = {'mcp_token': 'MT_token'}
@@ -214,6 +216,22 @@ class TestPublicGatewayApp(unittest.TestCase):
         self.assertEqual('MCP_0000', result['code'])
         api_client.call_tool.assert_called_once()
 
+    def test_list_tools_succeeds_when_store_has_no_touch_method(self):
+        class ReadOnlySessionStore:
+            def get(self, gateway_session_id):
+                return {'mcp_token': 'MT_read_only'}
+
+        api_client = MagicMock()
+        api_client.list_enabled_tools.return_value = {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['query_order']},
+        }
+        app = PublicGatewayApp(ReadOnlySessionStore(), api_client)
+
+        tools = app.list_tools('GWS_read_only')
+
+        self.assertEqual(['query_order'], [tool['name'] for tool in tools])
+
     def test_call_tool_rejects_dynamically_disabled_tool_before_forwarding(self):
         self.mock_session_store.get.return_value = {
             'mcp_token': 'MT_token',

+ 29 - 0
tests/test_public_server.py

@@ -5,6 +5,7 @@ from unittest.mock import Mock
 from public_server import PublicMcpHttpHandler, create_http_handler, extract_client_ip
 from services.diagnostic_event import RequestDiagnosticEmitter
 from utils.rate_limiter import SimpleRateLimiter
+from constants import DEVICE_INVALID_MESSAGE
 
 
 class FakeContext:
@@ -247,6 +248,34 @@ class PublicMcpHttpHandlerTest(unittest.TestCase):
         self.assertEqual('Gateway request failed. Please try again later.', response['error']['message'])
         self.assertNotIn('password', json.dumps(response))
 
+    def test_tools_list_redis_miss_returns_device_invalid(self):
+        class MissingSessionGateway(FakeGateway):
+            def list_tools(self, gateway_session_id, request_id=''):
+                raise RuntimeError(DEVICE_INVALID_MESSAGE)
+
+        reporter = RecordingReporter()
+        handler = PublicMcpHttpHandler(
+            MissingSessionGateway(),
+            context_parser=FakeParser(),
+            reporter=reporter,
+        )
+        with self.assertLogs('public_server', level='WARNING') as logs:
+            response = handler.handle_json_rpc(
+                headers={'X-Gateway-Session': 'GWS_A'},
+                message={'jsonrpc': '2.0', 'id': 32, 'method': 'tools/list', 'params': {}},
+                client_ip='10.0.0.5',
+            )
+
+        self.assertEqual(-32001, response['error']['code'])
+        self.assertEqual(DEVICE_INVALID_MESSAGE, response['error']['message'])
+        self.assertTrue(response['error']['data']['request_id'].startswith('rq_http_'))
+        event = next(item for item in reporter.events if item['stage'] == 'gateway_session')
+        self.assertEqual('failed', event['status'])
+        self.assertEqual('GATEWAY_SESSION_NOT_FOUND', event['event_code'])
+        record = logs.records[0]
+        self.assertEqual(-32001, record.protocol_code)
+        self.assertEqual('GATEWAY_SESSION_NOT_FOUND', record.diagnostic_reason)
+
     def test_handle_tools_call_passes_gateway_session_to_public_gateway(self):
         gateway = FakeGateway()
         handler = PublicMcpHttpHandler(gateway, context_parser=FakeParser())