new job
This commit is contained in:
parent
ceff46c47a
commit
e55619b632
36
README.md
36
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: <id>`(参考:[调用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`
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""系统集成适配器"""
|
||||
|
||||
from app.integrations.base import BaseClient
|
||||
from app.integrations.seeyon import SeeyonClient
|
||||
|
||||
__all__ = ["BaseClient"]
|
||||
__all__ = ["BaseClient", "SeeyonClient"]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
#
|
||||
|
||||
"""OA 到滴滴的同步任务包"""
|
||||
Binary file not shown.
|
|
@ -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}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue