sycommon-python-lib 0.2.5a1__py3-none-any.whl → 0.2.5a2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -72,8 +72,36 @@ def wrap_tool_with_error_handler(
72
72
  return tool
73
73
 
74
74
 
75
+ def _make_user_id_interceptor(user_id: str):
76
+ """创建 MCP 工具调用拦截器,注入 X-User-Id 和 X-OA-Session-Id header"""
77
+ from langchain_mcp_adapters.interceptors import MCPToolCallRequest, MCPToolCallResult
78
+ from typing import Callable, Awaitable
79
+
80
+ async def inject_user_id(
81
+ request: MCPToolCallRequest,
82
+ handler: Callable[[MCPToolCallRequest], Awaitable[MCPToolCallResult]],
83
+ ) -> MCPToolCallResult:
84
+ headers = dict(request.headers or {})
85
+ headers["X-User-Id"] = user_id
86
+
87
+ # 自动获取 OA session_id 注入 header
88
+ try:
89
+ from sycommon.auth.oa_cache import oa_login_with_cache
90
+ success, _, session_id = await oa_login_with_cache(user_id)
91
+ if success and session_id:
92
+ headers["X-OA-Session-Id"] = session_id
93
+ except Exception:
94
+ pass
95
+
96
+ request = request.override(headers=headers)
97
+ return await handler(request)
98
+
99
+ return inject_user_id
100
+
101
+
75
102
  async def load_mcp_tools(
76
103
  configs: List[MCPServerConfig],
104
+ user_id: str = None,
77
105
  on_tool_success: Optional[Callable[[str, str], Awaitable[None]]] = None,
78
106
  on_tool_error: Optional[Callable[[str, str, str], Awaitable[None]]] = None,
79
107
  on_batch_available: Optional[Callable[[str, list], Awaitable[None]]] = None,
@@ -85,6 +113,7 @@ async def load_mcp_tools(
85
113
 
86
114
  Args:
87
115
  configs: MCP 服务器配置列表(仅 enabled 的会被处理)
116
+ user_id: 当前用户 ID,通过 interceptor 注入到每次 MCP 工具调用的 header 中
88
117
  on_tool_success: 单个工具调用成功回调
89
118
  on_tool_error: 单个工具调用失败回调
90
119
  on_batch_available: 服务器连接成功后批量标记工具可用回调 (config_id, tool_names)
@@ -94,10 +123,16 @@ async def load_mcp_tools(
94
123
  if not enabled_configs:
95
124
  return []
96
125
 
126
+ # 构建 interceptor 链
127
+ interceptors = []
128
+ if user_id:
129
+ interceptors.append(_make_user_id_interceptor(user_id))
130
+
97
131
  all_tools = []
98
132
  for config in enabled_configs:
99
133
  try:
100
134
  from langchain_mcp_adapters.client import MultiServerMCPClient
135
+ from langchain_mcp_adapters.tools import load_mcp_tools as _load_tools
101
136
 
102
137
  key = sanitize_name(config.name)
103
138
  client_config = {
@@ -109,22 +144,37 @@ async def load_mcp_tools(
109
144
  }
110
145
 
111
146
  client = MultiServerMCPClient(client_config)
112
- tools = await client.get_tools()
147
+ session = await client.session_for(key)
148
+ await session.initialize()
149
+
150
+ from langchain_mcp_adapters.tools import _list_all_tools
151
+ raw_tools = await _list_all_tools(session)
113
152
 
114
153
  original_names = []
115
- for tool in tools:
154
+ for tool in raw_tools:
116
155
  original_name = tool.name
117
156
  original_names.append(original_name)
118
- tool.name = f"mcp__{key}__{original_name}"
119
- if tool.description and not tool.description.startswith("[MCP"):
120
- tool.description = f"[MCP:{config.name}] {tool.description}"
157
+
158
+ # 使用 convert_mcp_tool_to_langchain_tool interceptors
159
+ from langchain_mcp_adapters.tools import convert_mcp_tool_to_langchain_tool
160
+ lc_tool = convert_mcp_tool_to_langchain_tool(
161
+ session,
162
+ tool,
163
+ connection=client_config[key],
164
+ tool_interceptors=interceptors if interceptors else None,
165
+ server_name=config.name,
166
+ tool_name_prefix=False,
167
+ )
168
+
169
+ lc_tool.name = f"mcp__{key}__{original_name}"
170
+ if lc_tool.description and not lc_tool.description.startswith("[MCP"):
171
+ lc_tool.description = f"[MCP:{config.name}] {lc_tool.description}"
121
172
  wrap_tool_with_error_handler(
122
- tool, config.id, config.name, original_name,
173
+ lc_tool, config.id, config.name, original_name,
123
174
  on_tool_success=on_tool_success,
124
175
  on_tool_error=on_tool_error,
125
176
  )
126
-
127
- all_tools.extend(tools)
177
+ all_tools.append(lc_tool)
128
178
 
129
179
  if on_batch_available:
130
180
  try:
@@ -132,7 +182,7 @@ async def load_mcp_tools(
132
182
  except Exception as e:
133
183
  SYLogger.warning(f"[MCP] 写入工具状态失败: {e}")
134
184
 
135
- SYLogger.info(f"[MCP] 服务器 '{config.name}' 加载了 {len(tools)} 个工具")
185
+ SYLogger.info(f"[MCP] 服务器 '{config.name}' 加载了 {len(raw_tools)} 个工具")
136
186
 
137
187
  except Exception as e:
138
188
  SYLogger.warning(f"[MCP] 服务器 '{config.name}' ({config.server_url}) 连接失败,跳过: {e}")
@@ -110,6 +110,10 @@ class HTTPSandboxBackend(FileOperationsMixin, SandboxBackendProtocol):
110
110
  self._auto_sync = auto_sync
111
111
  self._synced = False
112
112
  self._workspace_verified = False
113
+ self._local_mode: Optional[bool] = None
114
+
115
+ # Token authentication
116
+ self._token = os.environ.get("SANDBOX_TOKEN", "")
113
117
 
114
118
  # Nacos 配置(用于故障转移)
115
119
  self._nacos_service_name = nacos_service_name
@@ -309,6 +313,16 @@ class HTTPSandboxBackend(FileOperationsMixin, SandboxBackendProtocol):
309
313
  finally:
310
314
  self._in_failover = False
311
315
 
316
+ def _build_headers(self) -> dict:
317
+ """Build common headers for all requests (trace ID + token auth)."""
318
+ headers = {}
319
+ trace_id = SYLogger.get_trace_id()
320
+ if trace_id:
321
+ headers["x-traceid-header"] = str(trace_id)
322
+ if self._token:
323
+ headers["X-Sandbox-Token"] = self._token
324
+ return headers
325
+
312
326
  def _post_sync_with_failover(self, endpoint: str, data: dict, timeout: int = None) -> dict:
313
327
  """带故障转移的同步 POST 请求"""
314
328
  actual_timeout = timeout if timeout is not None else self._timeout
@@ -320,10 +334,7 @@ class HTTPSandboxBackend(FileOperationsMixin, SandboxBackendProtocol):
320
334
  http_timeout = actual_timeout + 30
321
335
  max_retries = 3
322
336
 
323
- trace_id = SYLogger.get_trace_id()
324
- headers = {}
325
- if trace_id:
326
- headers["x-traceid-header"] = str(trace_id)
337
+ headers = self._build_headers()
327
338
 
328
339
  url = f"{self._base_url}{endpoint}"
329
340
  SYLogger.info(f"[Sandbox] POST 请求开始: {url}, timeout={http_timeout}s, command_timeout={actual_timeout}s")
@@ -376,6 +387,8 @@ class HTTPSandboxBackend(FileOperationsMixin, SandboxBackendProtocol):
376
387
  headers = {"Content-Type": "application/json"}
377
388
  if trace_id:
378
389
  headers["x-traceid-header"] = str(trace_id)
390
+ if self._token:
391
+ headers["X-Sandbox-Token"] = self._token
379
392
 
380
393
  url = f"{self._base_url}{endpoint}"
381
394
  SYLogger.info(f"[Sandbox] Async POST 请求开始: {url}, timeout={http_timeout}s")
@@ -1275,10 +1288,7 @@ class HTTPSandboxBackend(FileOperationsMixin, SandboxBackendProtocol):
1275
1288
 
1276
1289
  def health_check(self, timeout: int = 10) -> dict:
1277
1290
  """健康检查(快速检查,默认10秒超时)"""
1278
- trace_id = SYLogger.get_trace_id()
1279
- headers = {}
1280
- if trace_id:
1281
- headers["x-traceid-header"] = str(trace_id)
1291
+ headers = self._build_headers()
1282
1292
 
1283
1293
  url = f"{self._base_url}{SANDBOX_API_PREFIX}/health"
1284
1294
  SYLogger.info(f"[Sandbox] 健康检查: {url}, timeout={timeout}s")
@@ -1299,11 +1309,12 @@ class HTTPSandboxBackend(FileOperationsMixin, SandboxBackendProtocol):
1299
1309
  async def ahealth_check(self, timeout: int = 10) -> dict:
1300
1310
  """异步健康检查"""
1301
1311
  try:
1312
+ headers = self._build_headers()
1302
1313
  url = f"{self._base_url}{SANDBOX_API_PREFIX}/health"
1303
1314
  timeout_obj = aiohttp.ClientTimeout(total=timeout)
1304
1315
  connector = aiohttp.TCPConnector(ssl=_SSL_CONTEXT)
1305
1316
  async with aiohttp.ClientSession(connector=connector) as session:
1306
- async with session.get(url, timeout=timeout_obj) as resp:
1317
+ async with session.get(url, headers=headers, timeout=timeout_obj) as resp:
1307
1318
  resp.raise_for_status()
1308
1319
  result = await resp.json()
1309
1320
  SYLogger.info(f"[Sandbox] 异步健康检查成功: {result}")
@@ -1320,3 +1331,25 @@ class HTTPSandboxBackend(FileOperationsMixin, SandboxBackendProtocol):
1320
1331
  except Exception as e:
1321
1332
  SYLogger.warning(f"[Sandbox] 沙箱不可用: {e}")
1322
1333
  return False
1334
+
1335
+ def is_local_mode(self) -> bool:
1336
+ """检查沙箱是否运行在 LocalMode(本地直接访问文件系统,跳过上传/同步)"""
1337
+ if self._local_mode is not None:
1338
+ return self._local_mode
1339
+ try:
1340
+ result = self.health_check(timeout=5)
1341
+ self._local_mode = result.get("mode") == "local"
1342
+ except Exception:
1343
+ self._local_mode = False
1344
+ return self._local_mode
1345
+
1346
+ async def ais_local_mode(self) -> bool:
1347
+ """异步检查沙箱是否运行在 LocalMode"""
1348
+ if self._local_mode is not None:
1349
+ return self._local_mode
1350
+ try:
1351
+ result = await self.ahealth_check(timeout=5)
1352
+ self._local_mode = result.get("mode") == "local"
1353
+ except Exception:
1354
+ self._local_mode = False
1355
+ return self._local_mode
sycommon/auth/__init__.py CHANGED
@@ -34,6 +34,20 @@ from sycommon.auth.wecom_ldap_service import (
34
34
  LDAPFullUser,
35
35
  )
36
36
 
37
+ from sycommon.auth.oa_service import (
38
+ OAConfig,
39
+ OAAuthService,
40
+ OAUser,
41
+ OAAuthResult,
42
+ )
43
+ from sycommon.auth.oa_cache import (
44
+ set_oa_credential,
45
+ get_oa_credential,
46
+ delete_oa_credential,
47
+ has_oa_credential,
48
+ oa_login_with_cache,
49
+ )
50
+
37
51
  __all__ = [
38
52
  "LDAPConfig",
39
53
  "LDAPAuthService",
@@ -43,4 +57,13 @@ __all__ = [
43
57
  "WeComLDAPService",
44
58
  "WeComLDAPUser",
45
59
  "LDAPFullUser",
60
+ "OAConfig",
61
+ "OAAuthService",
62
+ "OAUser",
63
+ "OAAuthResult",
64
+ "set_oa_credential",
65
+ "get_oa_credential",
66
+ "delete_oa_credential",
67
+ "has_oa_credential",
68
+ "oa_login_with_cache",
46
69
  ]
@@ -0,0 +1,95 @@
1
+ """OA 凭据 Redis 缓存服务
2
+
3
+ 加密存储用户 LDAP 凭据到 Redis,供 OA 登录使用。
4
+ 每次写入覆盖旧值。
5
+ OA session_id 也缓存到 Redis,避免重复登录。
6
+ """
7
+
8
+ from typing import Optional
9
+
10
+ from sycommon.auth.oa_crypto import encrypt_credential, decrypt_credential
11
+ from sycommon.logging.kafka_log import SYLogger
12
+
13
+ _CRED_PREFIX = "digital_work:oa_credential"
14
+ _SESSION_PREFIX = "digital_work:oa_session"
15
+
16
+
17
+ def _cred_key(user_id: str) -> str:
18
+ return f"{_CRED_PREFIX}:{user_id}"
19
+
20
+
21
+ def _session_key(user_id: str) -> str:
22
+ return f"{_SESSION_PREFIX}:{user_id}"
23
+
24
+
25
+ async def set_oa_credential(user_id: str, login_id: str, password: str) -> None:
26
+ """加密存储 LDAP 凭据到 Redis(覆盖)"""
27
+ from sycommon.database.redis_service import RedisService
28
+ encrypted = encrypt_credential({"login_id": login_id, "password": password})
29
+ await RedisService.set(_cred_key(user_id), encrypted)
30
+ SYLogger.info(f"[OA] 已缓存用户 {user_id} 的 LDAP 凭据")
31
+
32
+
33
+ async def get_oa_credential(user_id: str) -> Optional[dict]:
34
+ """从 Redis 读取并解密 LDAP 凭据"""
35
+ from sycommon.database.redis_service import RedisService
36
+ raw = await RedisService.get(_cred_key(user_id))
37
+ if not raw:
38
+ return None
39
+ return decrypt_credential(raw)
40
+
41
+
42
+ async def delete_oa_credential(user_id: str) -> None:
43
+ """删除 Redis 中的 LDAP 凭据和 session"""
44
+ from sycommon.database.redis_service import RedisService
45
+ await RedisService.delete(_cred_key(user_id))
46
+ await RedisService.delete(_session_key(user_id))
47
+ SYLogger.info(f"[OA] 已删除用户 {user_id} 的 LDAP 凭据")
48
+
49
+
50
+ async def has_oa_credential(user_id: str) -> bool:
51
+ """检查用户是否已有缓存的 LDAP 凭据"""
52
+ from sycommon.database.redis_service import RedisService
53
+ raw = await RedisService.get(_cred_key(user_id))
54
+ return raw is not None
55
+
56
+
57
+ async def set_oa_session(user_id: str, session_id: str) -> None:
58
+ """缓存 OA session_id 到 Redis(30 分钟过期,与 OA 服务端保持一致)"""
59
+ from sycommon.database.redis_service import RedisService
60
+ await RedisService.set(_session_key(user_id), session_id, ex=1800)
61
+
62
+
63
+ async def get_cached_oa_session(user_id: str) -> Optional[str]:
64
+ """获取缓存的 OA session_id(可能已过期)"""
65
+ from sycommon.database.redis_service import RedisService
66
+ return await RedisService.get(_session_key(user_id))
67
+
68
+
69
+ async def oa_login_with_cache(user_id: str) -> tuple[bool, str, Optional[str]]:
70
+ """从 Redis 获取 OA session,过期则重新登录。
71
+
72
+ Returns:
73
+ (success, message, session_id)
74
+ """
75
+ # 优先使用缓存的 session
76
+ cached_session = await get_cached_oa_session(user_id)
77
+ if cached_session:
78
+ return True, "OA session有效", cached_session
79
+
80
+ # 缓存过期,重新登录
81
+ cred = await get_oa_credential(user_id)
82
+ if not cred:
83
+ return False, "NEED_OA_AUTH", None
84
+
85
+ from sycommon.auth.oa_service import OAConfig, OAAuthService
86
+ config = OAConfig(login_id=cred["login_id"], password=cred["password"])
87
+ result = await OAAuthService(config).login()
88
+
89
+ if result.success:
90
+ await set_oa_session(user_id, result.user.session_id)
91
+ SYLogger.info(f"[OA] 用户 {user_id} 登录成功")
92
+ return True, "OA登录成功", result.user.session_id
93
+ else:
94
+ SYLogger.warning(f"[OA] 用户 {user_id} 登录失败: {result.error}")
95
+ return False, f"OA登录失败: {result.error}", None
@@ -0,0 +1,60 @@
1
+ """OA 凭据加密工具
2
+
3
+ 使用 AES-256-GCM 加密 LDAP 凭据,密钥从 Nacos 配置或环境变量读取。
4
+ """
5
+
6
+ import base64
7
+ import json
8
+ import os
9
+ from typing import Optional
10
+
11
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
12
+
13
+ from sycommon.logging.kafka_log import SYLogger
14
+
15
+ NONCE_SIZE = 12
16
+ _ENV_KEY = "OA_CREDENTIAL_ENCRYPT_KEY"
17
+
18
+
19
+ def _get_key() -> bytes:
20
+ """获取加密密钥(优先环境变量,其次 Nacos 配置)"""
21
+ b64 = os.environ.get(_ENV_KEY)
22
+ if not b64:
23
+ try:
24
+ from sycommon.config.Config import Config
25
+ b64 = Config().config.get("llm", {}).get("OA", {}).get("credential_encrypt_key", "")
26
+ except Exception:
27
+ pass
28
+ if not b64:
29
+ raise ValueError(
30
+ f"未配置 OA 凭据加密密钥,请设置环境变量 {_ENV_KEY} 或 Nacos 配置 OA.credential_encrypt_key"
31
+ )
32
+ key = base64.b64decode(b64)
33
+ if len(key) != 32:
34
+ raise ValueError("加密密钥长度必须为 32 字节 (AES-256)")
35
+ return key
36
+
37
+
38
+ def encrypt_credential(data: dict) -> str:
39
+ """加密凭据字典,返回 base64 编码的密文"""
40
+ key = _get_key()
41
+ plaintext = json.dumps(data, ensure_ascii=False).encode("utf-8")
42
+ aesgcm = AESGCM(key)
43
+ nonce = os.urandom(NONCE_SIZE)
44
+ ciphertext = aesgcm.encrypt(nonce, plaintext, None)
45
+ return base64.b64encode(nonce + ciphertext).decode("ascii")
46
+
47
+
48
+ def decrypt_credential(raw: str) -> Optional[dict]:
49
+ """解密 base64 编码的密文,返回凭据字典"""
50
+ try:
51
+ key = _get_key()
52
+ blob = base64.b64decode(raw)
53
+ nonce = blob[:NONCE_SIZE]
54
+ ciphertext = blob[NONCE_SIZE:]
55
+ aesgcm = AESGCM(key)
56
+ plaintext = aesgcm.decrypt(nonce, ciphertext, None)
57
+ return json.loads(plaintext)
58
+ except Exception as e:
59
+ SYLogger.error(f"[OA] 凭据解密失败: {e}")
60
+ return None
@@ -0,0 +1,201 @@
1
+ """
2
+ OA 登录服务
3
+
4
+ 提供基于 SOAP 协议的 OA 系统登录认证功能。
5
+ """
6
+
7
+ import asyncio
8
+ from typing import Optional
9
+ from pydantic import BaseModel
10
+
11
+ from sycommon.logging.kafka_log import SYLogger
12
+
13
+
14
+ class OAConfig(BaseModel):
15
+ """OA 配置"""
16
+
17
+ base_url: str = "https://oa.syholdings.com"
18
+ doc_service: Optional[str] = None
19
+ login_id: str = ""
20
+ password: str = ""
21
+
22
+ @property
23
+ def doc_service_url(self) -> str:
24
+ if self.doc_service:
25
+ return self.doc_service
26
+ return f"{self.base_url}/services/DocService"
27
+
28
+ @classmethod
29
+ def from_config(cls) -> "OAConfig":
30
+ from sycommon.config.Config import Config
31
+
32
+ oa_config = Config().config.get("OA", {})
33
+ return cls(
34
+ base_url=oa_config.get("base_url", "https://oa.syholdings.com"),
35
+ doc_service=oa_config.get("doc_service"),
36
+ login_id=oa_config.get("login_id", ""),
37
+ password=oa_config.get("password", ""),
38
+ )
39
+
40
+
41
+ class OAUser(BaseModel):
42
+ """OA 用户信息"""
43
+
44
+ session_id: Optional[str] = None
45
+ login_id: str
46
+
47
+
48
+ class OAAuthResult(BaseModel):
49
+ """OA 登录结果"""
50
+
51
+ success: bool
52
+ user: Optional[OAUser] = None
53
+ error: Optional[str] = None
54
+
55
+
56
+ class OAAuthService:
57
+ """OA 认证服务
58
+
59
+ 使用示例::
60
+
61
+ config = OAConfig(
62
+ base_url="https://oa.syholdings.com",
63
+ login_id="username",
64
+ password="password",
65
+ )
66
+ service = OAAuthService(config)
67
+ result = await service.login()
68
+ if result.success:
69
+ print(f"登录成功, session: {result.user.session_id}")
70
+ """
71
+
72
+ def __init__(self, config: OAConfig):
73
+ self.config = config
74
+
75
+ def _build_login_xml(self, login_id: Optional[str] = None,
76
+ password: Optional[str] = None) -> str:
77
+ """构建登录 SOAP 请求体"""
78
+ lid = login_id or self.config.login_id
79
+ pwd = password or self.config.password
80
+ return f"""<?xml version="1.0" encoding="UTF-8"?>
81
+ <soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
82
+ xmlns:doc="http://localhost/services/DocService">
83
+ <soapenv:Body>
84
+ <doc:login>
85
+ <doc:in0>{lid}</doc:in0>
86
+ <doc:in1>{pwd}</doc:in1>
87
+ <doc:in2>2</doc:in2>
88
+ <doc:in3>127.0.0.1</doc:in3>
89
+ </doc:login>
90
+ </soapenv:Body>
91
+ </soapenv:Envelope>"""
92
+
93
+ def _parse_login_response(self, response_text: str) -> OAAuthResult:
94
+ """解析登录响应"""
95
+ import xml.etree.ElementTree as ET
96
+
97
+ try:
98
+ root = ET.fromstring(response_text)
99
+ except ET.ParseError as e:
100
+ SYLogger.error(f"[OA] 响应 XML 解析失败: {e}")
101
+ return OAAuthResult(success=False, error=f"XML 解析失败: {e}")
102
+
103
+ # SOAP 命名空间
104
+ ns = {
105
+ "soapenv": "http://schemas.xmlsoap.org/soap/envelope/",
106
+ }
107
+
108
+ body = root.find("soapenv:Body", ns)
109
+ if body is None:
110
+ return OAAuthResult(success=False, error="响应缺少 Body")
111
+
112
+ # 查找登录返回值(可能是 loginReturn 或 out)
113
+ login_return = None
114
+ for child in body:
115
+ for sub in child:
116
+ if sub.tag.endswith("loginReturn") or sub.tag.endswith("out"):
117
+ login_return = sub
118
+ break
119
+
120
+ if login_return is None:
121
+ return OAAuthResult(success=False, error="响应缺少 loginReturn")
122
+
123
+ session_id = login_return.text
124
+
125
+ if not session_id:
126
+ return OAAuthResult(success=False, error="登录返回空 session")
127
+
128
+ SYLogger.info(f"[OA] 用户 {self.config.login_id} 登录成功")
129
+ return OAAuthResult(
130
+ success=True,
131
+ user=OAUser(session_id=session_id, login_id=self.config.login_id),
132
+ )
133
+
134
+ async def login(self, login_id: Optional[str] = None,
135
+ password: Optional[str] = None) -> OAAuthResult:
136
+ """执行 OA 登录
137
+
138
+ Args:
139
+ login_id: 登录账号(可选,默认使用配置中的账号)
140
+ password: 登录密码(可选,默认使用配置中的密码)
141
+
142
+ Returns:
143
+ OAAuthResult: 登录结果
144
+ """
145
+ try:
146
+ result = await asyncio.get_event_loop().run_in_executor(
147
+ None,
148
+ self._login_sync,
149
+ login_id,
150
+ password,
151
+ )
152
+ return result
153
+ except Exception as e:
154
+ SYLogger.error(f"[OA] 登录异常: {e}")
155
+ return OAAuthResult(success=False, error=str(e))
156
+
157
+ def _login_sync(self, login_id: Optional[str],
158
+ password: Optional[str]) -> OAAuthResult:
159
+ """同步执行 OA 登录"""
160
+ import urllib.request
161
+ import urllib.error
162
+
163
+ soap_xml = self._build_login_xml(login_id, password)
164
+
165
+ headers = {
166
+ "Content-Type": "text/xml; charset=UTF-8",
167
+ "SOAPAction": "",
168
+ }
169
+
170
+ req = urllib.request.Request(
171
+ self.config.doc_service_url,
172
+ data=soap_xml.encode("utf-8"),
173
+ headers=headers,
174
+ method="POST",
175
+ )
176
+
177
+ try:
178
+ # 禁用 SSL 验证(OA 可能使用自签名证书)
179
+ import ssl
180
+ ctx = ssl.create_default_context()
181
+ ctx.check_hostname = False
182
+ ctx.verify_mode = ssl.CERT_NONE
183
+
184
+ with urllib.request.urlopen(req, context=ctx, timeout=15) as resp:
185
+ response_text = resp.read().decode("utf-8")
186
+
187
+ return self._parse_login_response(response_text)
188
+
189
+ except urllib.error.HTTPError as e:
190
+ body = e.read().decode("utf-8", errors="replace")
191
+ SYLogger.error(f"[OA] HTTP 错误 {e.code}: {body[:500]}")
192
+ return OAAuthResult(
193
+ success=False,
194
+ error=f"HTTP {e.code}: {body[:200]}",
195
+ )
196
+ except urllib.error.URLError as e:
197
+ SYLogger.error(f"[OA] 连接失败: {e.reason}")
198
+ return OAAuthResult(success=False, error=f"连接失败: {e.reason}")
199
+ except Exception as e:
200
+ SYLogger.error(f"[OA] 登录请求异常: {e}")
201
+ return OAAuthResult(success=False, error=str(e))
@@ -0,0 +1,229 @@
1
+ """
2
+ OA 登录功能测试
3
+
4
+ 使用方式:
5
+ cd sycommon-python-lib
6
+ python -m src.sycommon.tests.test_oa_login
7
+ """
8
+
9
+ import asyncio
10
+ from sycommon.auth.oa_service import OAConfig, OAAuthService
11
+
12
+ # TODO: 改成你的 OA 账号密码
13
+ OA_LOGIN_ID = "Osulcode.xiao"
14
+ OA_PASSWORD = "Keb$7wq^"
15
+
16
+ OA_BASE = "https://oa.syholdings.com"
17
+ DOC_SERVICE = f"{OA_BASE}/services/DocService"
18
+
19
+
20
+ def test_config():
21
+ """测试 OAConfig 基本配置"""
22
+ config = OAConfig(
23
+ base_url=OA_BASE,
24
+ login_id=OA_LOGIN_ID,
25
+ password=OA_PASSWORD,
26
+ )
27
+ assert config.base_url == OA_BASE
28
+ assert config.login_id == OA_LOGIN_ID
29
+ assert config.password == OA_PASSWORD
30
+ assert config.doc_service_url == f"{OA_BASE}/services/DocService"
31
+ print("[PASS] OAConfig 基本配置正确")
32
+
33
+
34
+ def test_config_custom_doc_service():
35
+ """测试自定义 DocService URL"""
36
+ config = OAConfig(
37
+ base_url=OA_BASE,
38
+ doc_service="http://custom/service",
39
+ login_id="test",
40
+ password="test",
41
+ )
42
+ assert config.doc_service_url == "http://custom/service"
43
+ print("[PASS] 自定义 DocService URL 正确")
44
+
45
+
46
+ def test_build_login_xml():
47
+ """测试 SOAP XML 构建"""
48
+ config = OAConfig(
49
+ base_url=OA_BASE,
50
+ login_id="testuser",
51
+ password="testpass",
52
+ )
53
+ service = OAAuthService(config)
54
+ xml = service._build_login_xml()
55
+
56
+ assert "testuser" in xml
57
+ assert "testpass" in xml
58
+ assert "soapenv:Envelope" in xml
59
+ assert "doc:login" in xml
60
+ assert "doc:in0" in xml
61
+ assert "doc:in1" in xml
62
+ assert "doc:in2" in xml
63
+ assert "doc:in3" in xml
64
+ print("[PASS] SOAP XML 构建正确")
65
+
66
+
67
+ def test_parse_login_response_success():
68
+ """测试成功响应解析"""
69
+ config = OAConfig(
70
+ base_url=OA_BASE,
71
+ login_id="testuser",
72
+ password="testpass",
73
+ )
74
+ service = OAAuthService(config)
75
+
76
+ mock_response = """<?xml version="1.0" encoding="UTF-8"?>
77
+ <soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
78
+ <soapenv:Body>
79
+ <ns:loginResponse xmlns:ns="http://localhost/services/DocService">
80
+ <ns:loginReturn>abc123sessionid</ns:loginReturn>
81
+ </ns:loginResponse>
82
+ </soapenv:Body>
83
+ </soapenv:Envelope>"""
84
+
85
+ result = service._parse_login_response(mock_response)
86
+ assert result.success is True
87
+ assert result.user is not None
88
+ assert result.user.session_id == "abc123sessionid"
89
+ assert result.user.login_id == "testuser"
90
+ print("[PASS] 成功响应解析正确")
91
+
92
+
93
+ def test_parse_login_response_empty_session():
94
+ """测试空 session 响应"""
95
+ config = OAConfig(
96
+ base_url=OA_BASE,
97
+ login_id="testuser",
98
+ password="testpass",
99
+ )
100
+ service = OAAuthService(config)
101
+
102
+ mock_response = """<?xml version="1.0" encoding="UTF-8"?>
103
+ <soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
104
+ <soapenv:Body>
105
+ <ns:loginResponse xmlns:ns="http://localhost/services/DocService">
106
+ <ns:loginReturn></ns:loginReturn>
107
+ </ns:loginResponse>
108
+ </soapenv:Body>
109
+ </soapenv:Envelope>"""
110
+
111
+ result = service._parse_login_response(mock_response)
112
+ assert result.success is False
113
+ assert "空 session" in result.error
114
+ print("[PASS] 空 session 响应处理正确")
115
+
116
+
117
+ def test_parse_login_response_invalid_xml():
118
+ """测试无效 XML 响应"""
119
+ config = OAConfig(
120
+ base_url=OA_BASE,
121
+ login_id="testuser",
122
+ password="testpass",
123
+ )
124
+ service = OAAuthService(config)
125
+
126
+ result = service._parse_login_response("not xml at all")
127
+ assert result.success is False
128
+ assert "XML 解析失败" in result.error
129
+ print("[PASS] 无效 XML 响应处理正确")
130
+
131
+
132
+ async def test_real_login():
133
+ """测试真实 OA 登录"""
134
+ config = OAConfig(
135
+ base_url=OA_BASE,
136
+ login_id=OA_LOGIN_ID,
137
+ password=OA_PASSWORD,
138
+ )
139
+ service = OAAuthService(config)
140
+
141
+ print(f"\n正在登录 OA: {OA_LOGIN_ID}@{OA_BASE} ...")
142
+ result = await service.login()
143
+
144
+ if result.success:
145
+ print(f"[PASS] 登录成功!")
146
+ print(f" session_id: {result.user.session_id}")
147
+ print(f" login_id: {result.user.login_id}")
148
+ else:
149
+ print(f"[FAIL] 登录失败: {result.error}")
150
+
151
+ return result
152
+
153
+
154
+ async def test_real_login_with_params():
155
+ """测试使用参数覆盖配置的登录"""
156
+ config = OAConfig(
157
+ base_url=OA_BASE,
158
+ login_id="placeholder",
159
+ password="placeholder",
160
+ )
161
+ service = OAAuthService(config)
162
+
163
+ print(f"\n正在使用参数登录 OA: {OA_LOGIN_ID} ...")
164
+ result = await service.login(
165
+ login_id=OA_LOGIN_ID,
166
+ password=OA_PASSWORD,
167
+ )
168
+
169
+ if result.success:
170
+ print(f"[PASS] 参数登录成功!")
171
+ print(f" session_id: {result.user.session_id}")
172
+ else:
173
+ print(f"[FAIL] 参数登录失败: {result.error}")
174
+
175
+ return result
176
+
177
+
178
+ async def test_real_login_wrong_password():
179
+ """测试错误密码登录"""
180
+ config = OAConfig(
181
+ base_url=OA_BASE,
182
+ login_id=OA_LOGIN_ID,
183
+ password="wrong_password_123",
184
+ )
185
+ service = OAAuthService(config)
186
+
187
+ print("\n正在使用错误密码登录 OA ...")
188
+ result = await service.login()
189
+
190
+ if not result.success:
191
+ print(f"[PASS] 错误密码被正确拒绝: {result.error}")
192
+ else:
193
+ print(f"[WARN] 错误密码竟然登录成功了? session: {result.user.session_id}")
194
+
195
+ return result
196
+
197
+
198
+ def run_unit_tests():
199
+ """运行单元测试(不需要网络)"""
200
+ print("=" * 60)
201
+ print("OA 登录 - 单元测试")
202
+ print("=" * 60)
203
+
204
+ test_config()
205
+ test_config_custom_doc_service()
206
+ test_build_login_xml()
207
+ test_parse_login_response_success()
208
+ test_parse_login_response_empty_session()
209
+ test_parse_login_response_invalid_xml()
210
+
211
+ print("\n全部单元测试通过!\n")
212
+
213
+
214
+ async def run_integration_tests():
215
+ """运行集成测试(需要网络连接 OA)"""
216
+ print("=" * 60)
217
+ print("OA 登录 - 集成测试(真实 OA 请求)")
218
+ print("=" * 60)
219
+
220
+ await test_real_login()
221
+ await test_real_login_with_params()
222
+ await test_real_login_wrong_password()
223
+
224
+ print("\n集成测试完成!")
225
+
226
+
227
+ if __name__ == "__main__":
228
+ run_unit_tests()
229
+ asyncio.run(run_integration_tests())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sycommon-python-lib
3
- Version: 0.2.5a1
3
+ Version: 0.2.5a2
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -134,16 +134,19 @@ sycommon/agent/multi_agent_team.py,sha256=NHmsUNwe3huguUUzbeoiOjgo9wh4-eXH0AjPDv
134
134
  sycommon/agent/summarization_utils.py,sha256=PRCIFtYBrH0bbSxsIc-qpC4iEXJzk72UuR7u5mQTt2w,7360
135
135
  sycommon/agent/mcp/__init__.py,sha256=iKrdDhIrFsNIkqG_kgcwNe-nOiM6uVfolKv44LfQ-FQ,636
136
136
  sycommon/agent/mcp/models.py,sha256=RBAIbGETNXkqD3wQZT7eKS4ozkgE9DQEneF1WKZf1C0,1355
137
- sycommon/agent/mcp/tool_loader.py,sha256=SEny14f7Bm9I17pT-9PJWMbhi9Ki77wvCR0KRNEJmyM,6428
137
+ sycommon/agent/mcp/tool_loader.py,sha256=3c9jt4-kx78JunG62SBHoiSJTG5NhxQ_4lOqFJD3Tjc,8484
138
138
  sycommon/agent/sandbox/__init__.py,sha256=jR7LlkD4J4Y6QYyRXQClkwmqDBCCPmycV_hQV9p9YHw,4621
139
139
  sycommon/agent/sandbox/file_ops.py,sha256=0PEhmK1OcMq1Qe33JmQwphj670Tih7HQLNuAb1snIjI,24028
140
- sycommon/agent/sandbox/http_sandbox_backend.py,sha256=kwuPEmrOMyxfrRu20AEGqWD9t38L-DrtKSFp6CWt44o,56877
140
+ sycommon/agent/sandbox/http_sandbox_backend.py,sha256=qDtJyGHY6tnQkTxi3CZiLXNuz8S_ynxSGzLB-HgFCxQ,58158
141
141
  sycommon/agent/sandbox/minio_sync.py,sha256=d1kuWllvyAvAMsFZCP0OdHEQtXN9BEIgHbupC31BjSk,20000
142
142
  sycommon/agent/sandbox/sandbox_pool.py,sha256=eMn8sLakCWf90l6ni2-333QM8oBdX1CflV-WzneFp_k,9133
143
143
  sycommon/agent/sandbox/sandbox_recovery.py,sha256=X-eDODx1tmGMh_iTngV6e1ppfDBHpTdkPreJusN5MHY,7358
144
144
  sycommon/agent/sandbox/session.py,sha256=TjzC3yFC-VaJ75UwCyL26QX4PRTGNNfQae1FKFuOsYI,2365
145
- sycommon/auth/__init__.py,sha256=W814cfHlLXFymmxeTi3pIreFb4nhKnQ7NY1H38x1Gic,974
145
+ sycommon/auth/__init__.py,sha256=3_wNNe-rt2qvAWjam2hBP1fIodTDDLIiHmhOeTn2u88,1439
146
146
  sycommon/auth/ldap_service.py,sha256=fOcpVov5LWJkBk62qbTaltks1c4la7JsbD104KfdBOI,10102
147
+ sycommon/auth/oa_cache.py,sha256=u673y-mK-xo24pSqvfjL68_YFASO2zoxd_iAQ0HetCo,3491
148
+ sycommon/auth/oa_crypto.py,sha256=xpY1R1Bj3KLENXB0TuThB6Eku1E9PYjcoSpOdgDmCgc,1898
149
+ sycommon/auth/oa_service.py,sha256=kLepV9zgqpZoaB73DRPpMA5tJJQjoaDtQPdzBcGXeak,6235
147
150
  sycommon/auth/wecom_ldap_service.py,sha256=eniwHP8FpTdOle4XMZDmAsnKrBYDEta1LtmNhcZI_SQ,17066
148
151
  sycommon/config/Config.py,sha256=J5VjolqHmhYTBZwTyfSHv9X5cHMfN6kqY2ryHF4LJPw,6785
149
152
  sycommon/config/DatabaseConfig.py,sha256=ILiUuYT9_xJZE2W-RYuC3JCt_YLKc1sbH13-MHIOPhg,804
@@ -252,6 +255,7 @@ sycommon/tests/deep_agent_server.py,sha256=t7snT2J6KWZrjJsYVi4TU5jpfvXNz4iuzMk4c
252
255
  sycommon/tests/test_deep_agent.py,sha256=34gP2KL8SSmtqqhaA9OV97OAl_I0T4TfEutZrR_0srM,5874
253
256
  sycommon/tests/test_email.py,sha256=-oPtYVGQzJ3Cv-op3ZNRMeyYOF-UNidGJC35CHRgjGQ,5442
254
257
  sycommon/tests/test_mq.py,sha256=Gpr9Eep-osRkcnlwGeshROEf83Ai3qYbAMHwpoML68o,4366
258
+ sycommon/tests/test_oa_login.py,sha256=5psNnmUst20x-LdjPa_liunhMLGky2uTDVpQzefBnQE,6239
255
259
  sycommon/tests/test_real_summarization.py,sha256=7B89es7-UwULk-kq9xUiWH1ylXUO3QDJm4oZWzJNPk0,6193
256
260
  sycommon/tests/test_summarization_config.py,sha256=Ztb-eJXt2NrpBXNp7xST4Cwq4x8DK9pFuC7-bXUUOaE,16860
257
261
  sycommon/tests/test_summarization_real.py,sha256=iTwwA_xQd5Zgkn849lu2M0zIHPnMp-3szsIGrg6G9J4,12406
@@ -265,8 +269,8 @@ sycommon/tools/syemail.py,sha256=BDFhgf7WDOQeTcjxJEQdu0dQhnHFPO_p3eI0-Ni3LhQ,561
265
269
  sycommon/tools/timing.py,sha256=OiiE7P07lRoMzX9kzb8sZU9cDb0zNnqIlY5pWqHcnkY,2064
266
270
  sycommon/xxljob/__init__.py,sha256=7eoBlQxv-B39IfRSCY2bkqdGYs1QRe1umAWd88VMEEM,86
267
271
  sycommon/xxljob/xxljob_service.py,sha256=JIEJaGXhqrTLcyxlyynSrsHg9bBnDNzX-D4qIWLRPUE,6815
268
- sycommon_python_lib-0.2.5a1.dist-info/METADATA,sha256=vBcO5P_E9TQkwz2NeIX9FYdobYd1uFECsIAQWwcDEkk,7879
269
- sycommon_python_lib-0.2.5a1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
270
- sycommon_python_lib-0.2.5a1.dist-info/entry_points.txt,sha256=gsR4SssKxDWjRU8ggidzNcdMXDPRSKRS7UaGyNP84Qg,92
271
- sycommon_python_lib-0.2.5a1.dist-info/top_level.txt,sha256=RgphKrg7nJyZ7irJqbxFr-5H2LUYTvI7ivoWZH2hcD0,29
272
- sycommon_python_lib-0.2.5a1.dist-info/RECORD,,
272
+ sycommon_python_lib-0.2.5a2.dist-info/METADATA,sha256=Al8QcJtx8mGbEePjfphPJ827JvyX_shWGbxwIKY1taU,7879
273
+ sycommon_python_lib-0.2.5a2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
274
+ sycommon_python_lib-0.2.5a2.dist-info/entry_points.txt,sha256=gsR4SssKxDWjRU8ggidzNcdMXDPRSKRS7UaGyNP84Qg,92
275
+ sycommon_python_lib-0.2.5a2.dist-info/top_level.txt,sha256=RgphKrg7nJyZ7irJqbxFr-5H2LUYTvI7ivoWZH2hcD0,29
276
+ sycommon_python_lib-0.2.5a2.dist-info/RECORD,,