diff --git a/README.md b/README.md index 06f2d4c..150e728 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,42 @@ ConnectHub 是一个轻量级企业集成中间件:统一管理多系统集成 - 位置:`app/integrations/base.py` - 规范:业务 Job 禁止直接写 HTTP;必须通过 Client 访问外部系统(统一超时、重试、日志)。 +#### SeeyonClient(致远 OA) + +- 位置:`app/integrations/seeyon.py` +- 认证方式:`POST /seeyon/rest/token` 获取 `id` 作为 token,并在业务请求 header 中携带 `token: `(参考:[调用Rest接口](https://open.seeyoncloud.com/seeyonapi/781/))。 +- 最小配置示例: + - `public_cfg`: + +```json +{"base_url":"https://oa.example.com"} +``` + + - `secret_cfg`(会被加密落库): + +```json +{"rest_user":"REST帐号","rest_password":"REST密码","loginName":"可选-模拟登录名"} +``` + +- token 失效处理:遇到 401 或响应包含 `Invalid token`,自动刷新 token 并重试一次。 + +#### 示例插件:sync_oa_to_didi(仅演示 token 获取日志) + +- 插件 Job:`extensions/sync_oa_to_didi/job.py` 的 `SyncOAToDidiTokenJob` +- 在 Admin 创建 Job 时可使用: + - `handler_path`: `extensions.sync_oa_to_didi.job:SyncOAToDidiTokenJob` + - `public_cfg`: + +```json +{"base_url":"https://oa.example.com"} +``` + + - `secret_cfg`(会被加密落库): + +```json +{"rest_user":"REST帐号","rest_password":"REST密码","loginName":"可选-模拟登录名"} +``` + #### Security(Fernet 加解密) - 位置:`app/security/fernet.py` diff --git a/app/integrations/__init__.py b/app/integrations/__init__.py index 62b5580..ebbf4bc 100644 --- a/app/integrations/__init__.py +++ b/app/integrations/__init__.py @@ -1,5 +1,6 @@ """系统集成适配器""" from app.integrations.base import BaseClient +from app.integrations.seeyon import SeeyonClient -__all__ = ["BaseClient"] \ No newline at end of file +__all__ = ["BaseClient", "SeeyonClient"] \ No newline at end of file diff --git a/app/integrations/__pycache__/__init__.cpython-312.pyc b/app/integrations/__pycache__/__init__.cpython-312.pyc index 17af24d..e5d50bc 100644 Binary files a/app/integrations/__pycache__/__init__.cpython-312.pyc and b/app/integrations/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/integrations/__pycache__/base.cpython-312.pyc b/app/integrations/__pycache__/base.cpython-312.pyc index c1feeb4..3223a0d 100644 Binary files a/app/integrations/__pycache__/base.cpython-312.pyc and b/app/integrations/__pycache__/base.cpython-312.pyc differ diff --git a/app/integrations/__pycache__/seeyon.cpython-312.pyc b/app/integrations/__pycache__/seeyon.cpython-312.pyc new file mode 100644 index 0000000..a73e40d Binary files /dev/null and b/app/integrations/__pycache__/seeyon.cpython-312.pyc differ diff --git a/app/integrations/base.py b/app/integrations/base.py index bee6795..1e30afa 100644 --- a/app/integrations/base.py +++ b/app/integrations/base.py @@ -42,11 +42,13 @@ class BaseClient: def request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: url = path if path.startswith("/") else f"/{path}" + extra_headers = kwargs.pop("headers", None) or {} + merged_headers = {**self.headers, **extra_headers} if extra_headers else None last_exc: Exception | None = None for attempt in range(self.retries + 1): try: start = time.time() - resp = self._client.request(method=method, url=url, **kwargs) + resp = self._client.request(method=method, url=url, headers=merged_headers, **kwargs) elapsed_ms = int((time.time() - start) * 1000) logger.info("HTTP %s %s -> %s (%sms)", method, url, resp.status_code, elapsed_ms) resp.raise_for_status() diff --git a/app/integrations/seeyon.py b/app/integrations/seeyon.py new file mode 100644 index 0000000..2056b75 --- /dev/null +++ b/app/integrations/seeyon.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import logging +from typing import Any + +import httpx + +from app.integrations.base import BaseClient + + +logger = logging.getLogger("connecthub.integrations.seeyon") + + +class SeeyonClient(BaseClient): + """ + 致远 OA REST Client: + - POST /seeyon/rest/token 获取 token(id) + - 业务请求 header 自动携带 token + - 遇到 401/Invalid token 自动刷新 token 并重试一次 + """ + + def __init__(self, *, base_url: str, rest_user: str, rest_password: str, loginName: str | None = None) -> None: + super().__init__(base_url=base_url) + self.rest_user = rest_user + self.rest_password = rest_password + self.loginName = loginName + self._token: str | None = None + + def authenticate(self) -> str: + body: dict[str, Any] = { + "userName": self.rest_user, + "password": self.rest_password, + } + if self.loginName: + body["loginName"] = self.loginName + + # 文档:POST /seeyon/rest/token + resp = super().request( + "POST", + "/seeyon/rest/token", + json=body, + headers={"Accept": "application/json", "Content-Type": "application/json"}, + ) + data = resp.json() + token = str(data.get("id", "") or "") + if not token or token == "-1": + raise RuntimeError("Seeyon auth failed (token id missing or -1)") + + self._token = token + logger.info("Seeyon token acquired") + return token + + def _get_token(self) -> str: + return self._token or self.authenticate() + + def request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: # type: ignore[override] + token = self._get_token() + headers = dict(kwargs.pop("headers", {}) or {}) + headers["token"] = token + + try: + return super().request(method, path, headers=headers, **kwargs) + except httpx.HTTPStatusError as e: + # token 失效:401 或返回包含 Invalid token + resp = e.response + text = "" + try: + text = resp.text or "" + except Exception: + text = "" + if resp.status_code == 401 or ("Invalid token" in text): + logger.info("Seeyon token invalid, refreshing and retrying once") + self._token = None + token2 = self._get_token() + headers["token"] = token2 + # 仅重试一次;仍失败则抛出 + return super().request(method, path, headers=headers, **kwargs) + raise + + diff --git a/data/logs/connecthub.log b/data/logs/connecthub.log index 71cd989..aec76ad 100644 --- a/data/logs/connecthub.log +++ b/data/logs/connecthub.log @@ -1562,3 +1562,28 @@ sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) table job_logs has n 2026-01-05 08:33:48 INFO celery.app.trace Task connecthub.dispatcher.tick[8cb1b138-e3e8-49ab-a292-ff17132f99b3] succeeded in 0.03212751899991417s: {'triggered': 0} 2026-01-05 08:34:48 INFO celery.app.trace Task connecthub.dispatcher.tick[75708a69-d07b-44eb-9fb6-8ef2a684f213] succeeded in 0.008938294999097707s: {'triggered': 0} 2026-01-05 08:35:48 INFO celery.app.trace Task connecthub.dispatcher.tick[5c5dbfe5-5cf0-4ddb-bdfb-382150fa7f06] succeeded in 0.021925684002781054s: {'triggered': 0} +2026-01-05 08:36:48 INFO celery.app.trace Task connecthub.dispatcher.tick[025cd0f2-5488-42b3-9cef-23ab24abbbee] succeeded in 0.009755851999216247s: {'triggered': 0} +2026-01-05 08:37:48 INFO celery.app.trace Task connecthub.dispatcher.tick[7ee0c72c-1ad1-4a45-9bc8-156f8fd1c804] succeeded in 0.002885051999328425s: {'triggered': 0} +2026-01-05 08:38:48 INFO celery.app.trace Task connecthub.dispatcher.tick[dc017644-0743-4b29-abf6-822f0e7e7641] succeeded in 0.011809330000687623s: {'triggered': 0} +2026-01-05 08:39:48 INFO celery.app.trace Task connecthub.dispatcher.tick[3e9e5624-aecf-40d7-8eea-6e39238e2a5b] succeeded in 0.048935678001726046s: {'triggered': 0} +2026-01-05 08:40:48 INFO celery.app.trace Task connecthub.dispatcher.tick[84ccbb27-cd52-4482-a489-ccef837921c0] succeeded in 0.002327479000086896s: {'triggered': 0} +2026-01-05 08:41:48 INFO celery.app.trace Task connecthub.dispatcher.tick[6d6737e4-01ab-4599-9181-7a3b72adf732] succeeded in 0.008618384999863338s: {'triggered': 0} +2026-01-05 08:42:48 INFO celery.app.trace Task connecthub.dispatcher.tick[eb711ffb-55fb-4a62-8529-994f25c972f5] succeeded in 0.007941265997942537s: {'triggered': 0} +2026-01-05 08:43:48 INFO celery.app.trace Task connecthub.dispatcher.tick[9000a6f2-4e12-461e-8975-c26168bd8878] succeeded in 0.004994874998374144s: {'triggered': 0} +2026-01-05 08:44:48 INFO celery.app.trace Task connecthub.dispatcher.tick[9d246e97-8b93-42c0-96e7-87ba2dc4bc49] succeeded in 0.030297451001388254s: {'triggered': 0} +2026-01-05 08:45:48 INFO celery.app.trace Task connecthub.dispatcher.tick[0409f390-9901-4772-9a6d-f0e4ba2c6bb5] succeeded in 0.03012071999910404s: {'triggered': 0} +2026-01-05 08:46:48 INFO celery.app.trace Task connecthub.dispatcher.tick[733fc7ba-8d96-4b31-bb96-3b8a495a3456] succeeded in 0.0390545119989838s: {'triggered': 0} +2026-01-05 08:47:48 INFO celery.app.trace Task connecthub.dispatcher.tick[42b214b8-7daf-4be4-95bf-f0a2c53743b0] succeeded in 0.010055696999188513s: {'triggered': 0} +2026-01-05 08:48:48 INFO celery.app.trace Task connecthub.dispatcher.tick[d6f5246a-7a4e-489a-91c7-f90d7e0e2ed0] succeeded in 0.010429486002976773s: {'triggered': 0} +2026-01-05 08:49:48 INFO celery.app.trace Task connecthub.dispatcher.tick[ba1e7651-a380-4f0f-a5c7-1bfafeb7b99a] succeeded in 0.015170816997851944s: {'triggered': 0} +2026-01-05 08:50:48 INFO celery.app.trace Task connecthub.dispatcher.tick[b23d74b4-5858-476e-985f-9dd395568793] succeeded in 0.013874788000975968s: {'triggered': 0} +2026-01-05 08:51:48 INFO celery.app.trace Task connecthub.dispatcher.tick[9edb6834-48e5-4e23-be0e-ff959fa5f99a] succeeded in 0.03663521199996467s: {'triggered': 0} +2026-01-05 08:52:48 INFO celery.app.trace Task connecthub.dispatcher.tick[57e5aa15-9c0f-4909-87b8-43ce16924b26] succeeded in 0.017162136999104405s: {'triggered': 0} +2026-01-05 08:53:48 INFO celery.app.trace Task connecthub.dispatcher.tick[f2ab541d-29e0-400a-ad19-0f91faad7374] succeeded in 0.017423885001335293s: {'triggered': 0} +2026-01-05 08:54:48 INFO celery.app.trace Task connecthub.dispatcher.tick[342c37cb-3061-4c79-981b-11e82cb5d6e8] succeeded in 0.005632615997456014s: {'triggered': 0} +2026-01-05 08:55:48 INFO celery.app.trace Task connecthub.dispatcher.tick[265d9d44-f002-47bc-b44c-529f854d8d84] succeeded in 0.015303112999390578s: {'triggered': 0} +2026-01-05 08:56:48 INFO celery.app.trace Task connecthub.dispatcher.tick[63483f4d-7e5f-4a67-80f3-f6c2f35ad8ad] succeeded in 0.009836078002990689s: {'triggered': 0} +2026-01-05 08:57:48 INFO celery.app.trace Task connecthub.dispatcher.tick[456f25f7-fc48-4317-b147-99ec4370c5ca] succeeded in 0.0016178609985217918s: {'triggered': 0} +2026-01-05 08:58:48 INFO celery.app.trace Task connecthub.dispatcher.tick[e54a515a-8966-4b43-849e-4d4a8bedacc5] succeeded in 0.016467269000713713s: {'triggered': 0} +2026-01-05 08:59:48 INFO celery.app.trace Task connecthub.dispatcher.tick[71bfd1ed-1b1d-45d2-9e44-08b12cbaf810] succeeded in 0.014499245000479277s: {'triggered': 0} +2026-01-05 09:00:48 INFO celery.app.trace Task connecthub.dispatcher.tick[55377f57-2326-46cf-aee6-3639b9285453] succeeded in 0.027614209000603296s: {'triggered': 0} diff --git a/extensions/sync_oa_to_didi/__init__.py b/extensions/sync_oa_to_didi/__init__.py new file mode 100644 index 0000000..339bcee --- /dev/null +++ b/extensions/sync_oa_to_didi/__init__.py @@ -0,0 +1,3 @@ +# + +"""OA 到滴滴的同步任务包""" diff --git a/extensions/sync_oa_to_didi/__pycache__/job.cpython-312.pyc b/extensions/sync_oa_to_didi/__pycache__/job.cpython-312.pyc new file mode 100644 index 0000000..c421819 Binary files /dev/null and b/extensions/sync_oa_to_didi/__pycache__/job.cpython-312.pyc differ diff --git a/extensions/sync_oa_to_didi/job.py b/extensions/sync_oa_to_didi/job.py new file mode 100644 index 0000000..5f9d6bd --- /dev/null +++ b/extensions/sync_oa_to_didi/job.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import logging +from typing import Any + +from app.integrations.seeyon import SeeyonClient +from app.jobs.base import BaseJob + + +logger = logging.getLogger("connecthub.extensions.sync_oa_to_didi") + + +def _mask_token(token: str) -> str: + token = token or "" + if len(token) <= 12: + return "***" + return f"{token[:6]}***{token[-4:]}" + + +class SyncOAToDidiTokenJob(BaseJob): + """ + 示例 Job:演示致远 OA 的 token 获取与日志记录(不做任何业务同步)。 + + public_cfg: + - base_url: "https://oa.example.com" + + secret_cfg (解密后): + - rest_user + - rest_password + - loginName (可选) + """ + + job_id = "sync_oa_to_didi.token_demo" + + def run(self, params: dict[str, Any], secrets: dict[str, Any]) -> dict[str, Any]: + base_url = str(params.get("base_url") or "").strip() + if not base_url: + raise ValueError("public_cfg.base_url is required") + + rest_user = str(secrets.get("rest_user") or "").strip() + rest_password = str(secrets.get("rest_password") or "").strip() + login_name = secrets.get("loginName") + login_name = str(login_name).strip() if login_name else None + + if not rest_user or not rest_password: + raise ValueError("secret_cfg.rest_user and secret_cfg.rest_password are required") + + client = SeeyonClient(base_url=base_url, rest_user=rest_user, rest_password=rest_password, loginName=login_name) + try: + token = client.authenticate() + finally: + client.close() + + masked = _mask_token(token) + logger.info("Seeyon token acquired (masked) token=%s loginName=%s base_url=%s", masked, login_name, base_url) + return {"token_masked": masked, "loginName": login_name or "", "base_url": base_url} + +