feat(business): split Kingdee token body config
Allow Kingdee token requests to be built from app key, app secret, configurable field names, optional timestamp fields, and extra JSON. Keep raw KINGDEE_TOKEN_BODY_JSON as an advanced override with current-time placeholders. Validation: python3 -m unittest discover -v; python3 -m compileall obsidian_wiki tests; git diff --check.
This commit is contained in:
@@ -39,6 +39,10 @@ The config template includes:
|
||||
|
||||
- `KINGDEE_SALORDER_API_URL`: defaults to the official `querySalOrder` endpoint.
|
||||
- `KINGDEE_TOKEN_URL` and `KINGDEE_TOKEN_BODY_JSON`: login/access-token request.
|
||||
- `KINGDEE_APP_KEY`, `KINGDEE_APP_SECRET`, `KINGDEE_TOKEN_APP_KEY_FIELD`, `KINGDEE_TOKEN_APP_SECRET_FIELD`: preferred split-field way to build the token request body without hand-writing the full JSON.
|
||||
- `KINGDEE_TOKEN_TIMESTAMP_FIELD`, `KINGDEE_TOKEN_TIMESTAMP_FORMAT`: optional current-time field for token APIs that require a timestamp. Supported formats are `epoch_ms`, `epoch_seconds`, `datetime_utc`, `iso_utc`, and `date_utc`.
|
||||
- `KINGDEE_TOKEN_BODY_EXTRA_JSON`: optional fixed extra fields merged into the generated token request body.
|
||||
- `KINGDEE_TOKEN_BODY_JSON`: advanced override for the full token request JSON. It supports `{now_ms}`, `{now_epoch}`, `{now_seconds}`, `{now_datetime_utc}`, `{now_iso_utc}`, and `{today_utc}` placeholders.
|
||||
- `KINGDEE_TOKEN_HEADERS_JSON`: optional token request headers, such as app-specific headers.
|
||||
- `KINGDEE_TOKEN_FIELD`, `KINGDEE_TOKEN_EXPIRES_IN_FIELD`, `KINGDEE_REFRESH_TOKEN_FIELD`: dot paths used to read token response fields.
|
||||
- `KINGDEE_REFRESH_URL` and `KINGDEE_REFRESH_BODY_JSON`: optional refresh request. Use `{refresh_token}` in the JSON body template.
|
||||
|
||||
@@ -8,6 +8,7 @@ import stat
|
||||
import time
|
||||
import uuid
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
@@ -43,9 +44,50 @@ KINGDEE_ACCESS_TOKEN=
|
||||
# 是否必填:自动登录时必填;如果只填 KINGDEE_ACCESS_TOKEN,则不用填。
|
||||
KINGDEE_TOKEN_URL=
|
||||
|
||||
# 推荐填法:用下面这些拆分变量让程序自动拼 token JSON body。
|
||||
# 如果你的 token 接口字段名不是 app_key/app_secret/timestamp,就改对应 FIELD。
|
||||
|
||||
# 变量:KINGDEE_APP_KEY
|
||||
# 用途:金蝶 OpenAPI 应用的 app key/client id。
|
||||
# 是否必填:自动登录时通常必填;具体以金蝶 token 文档为准。
|
||||
KINGDEE_APP_KEY=
|
||||
|
||||
# 变量:KINGDEE_APP_SECRET
|
||||
# 用途:金蝶 OpenAPI 应用的 app secret/client secret。
|
||||
# 是否必填:自动登录时通常必填;具体以金蝶 token 文档为准。
|
||||
KINGDEE_APP_SECRET=
|
||||
|
||||
# 变量:KINGDEE_TOKEN_APP_KEY_FIELD
|
||||
# 用途:token JSON body 中 app key 对应的字段名。
|
||||
# 是否必填:可选,默认 app_key;如果金蝶文档写 client_id,就改成 client_id。
|
||||
KINGDEE_TOKEN_APP_KEY_FIELD=app_key
|
||||
|
||||
# 变量:KINGDEE_TOKEN_APP_SECRET_FIELD
|
||||
# 用途:token JSON body 中 app secret 对应的字段名。
|
||||
# 是否必填:可选,默认 app_secret;如果金蝶文档写 client_secret,就改成 client_secret。
|
||||
KINGDEE_TOKEN_APP_SECRET_FIELD=app_secret
|
||||
|
||||
# 变量:KINGDEE_TOKEN_TIMESTAMP_FIELD
|
||||
# 用途:token JSON body 中当前时间/时间戳对应的字段名;留空则不发送时间。
|
||||
# 是否必填:仅当金蝶 token 文档要求当前时间时填写,例如 timestamp。
|
||||
KINGDEE_TOKEN_TIMESTAMP_FIELD=
|
||||
|
||||
# 变量:KINGDEE_TOKEN_TIMESTAMP_FORMAT
|
||||
# 用途:当前时间格式。可选值:epoch_ms、epoch_seconds、datetime_utc、iso_utc、date_utc。
|
||||
# 是否必填:可选,默认 epoch_ms。
|
||||
KINGDEE_TOKEN_TIMESTAMP_FORMAT=epoch_ms
|
||||
|
||||
# 变量:KINGDEE_TOKEN_BODY_EXTRA_JSON
|
||||
# 用途:token JSON body 的额外固定字段,例如租户、账套等;会合并到自动拼出的 body。
|
||||
# 是否必填:可选;没有额外字段时保持 {{}}。
|
||||
KINGDEE_TOKEN_BODY_EXTRA_JSON={{}}
|
||||
|
||||
# 高级填法:如果 token body 结构特殊,可直接填写完整 JSON。
|
||||
# 填了 KINGDEE_TOKEN_BODY_JSON 后,会覆盖上面的拆分变量。
|
||||
# 支持占位符:{{now_ms}}、{{now_epoch}}、{{now_seconds}}、{{now_datetime_utc}}、{{now_iso_utc}}、{{today_utc}}。
|
||||
# 变量:KINGDEE_TOKEN_BODY_JSON
|
||||
# 用途:调用登录/token 接口时发送的 JSON 请求体,例如 app key、app secret、租户等。
|
||||
# 是否必填:自动登录时必填;必须是一行合法 JSON。
|
||||
# 用途:调用登录/token 接口时发送的完整 JSON 请求体。
|
||||
# 是否必填:可选;只有不用拆分变量时才填写,必须是一行合法 JSON。
|
||||
KINGDEE_TOKEN_BODY_JSON=
|
||||
|
||||
# 变量:KINGDEE_TOKEN_HEADERS_JSON
|
||||
@@ -118,6 +160,10 @@ def load_kingdee_config(config_path: str | Path | None = None) -> dict[str, str]
|
||||
values[new] = values[old]
|
||||
values.setdefault("KINGDEE_SALORDER_API_URL", DEFAULT_SALORDER_API_URL)
|
||||
values.setdefault("KINGDEE_TOKEN_HEADERS_JSON", "{}")
|
||||
values.setdefault("KINGDEE_TOKEN_APP_KEY_FIELD", "app_key")
|
||||
values.setdefault("KINGDEE_TOKEN_APP_SECRET_FIELD", "app_secret")
|
||||
values.setdefault("KINGDEE_TOKEN_TIMESTAMP_FORMAT", "epoch_ms")
|
||||
values.setdefault("KINGDEE_TOKEN_BODY_EXTRA_JSON", "{}")
|
||||
values.setdefault("KINGDEE_TOKEN_FIELD", "data.access_token")
|
||||
values.setdefault("KINGDEE_TOKEN_EXPIRES_IN_FIELD", "data.expires_in")
|
||||
values.setdefault("KINGDEE_REFRESH_TOKEN_FIELD", "data.refresh_token")
|
||||
@@ -153,7 +199,10 @@ def get_kingdee_access_token(
|
||||
try:
|
||||
token_payload = _request_token(
|
||||
config["KINGDEE_REFRESH_URL"],
|
||||
_render_json_template(config.get("KINGDEE_REFRESH_BODY_JSON", ""), {"refresh_token": refresh_token}),
|
||||
_render_json_template(
|
||||
config.get("KINGDEE_REFRESH_BODY_JSON", ""),
|
||||
_template_values(now, {"refresh_token": refresh_token}),
|
||||
),
|
||||
config.get("KINGDEE_TOKEN_HEADERS_JSON", "{}"),
|
||||
urlopen=urlopen,
|
||||
)
|
||||
@@ -166,7 +215,7 @@ def get_kingdee_access_token(
|
||||
raise RuntimeError("KINGDEE_TOKEN_URL is required when no cached KINGDEE_ACCESS_TOKEN is available")
|
||||
token_payload = _request_token(
|
||||
token_url,
|
||||
config.get("KINGDEE_TOKEN_BODY_JSON", ""),
|
||||
_build_token_body_json(config, now),
|
||||
config.get("KINGDEE_TOKEN_HEADERS_JSON", "{}"),
|
||||
urlopen=urlopen,
|
||||
)
|
||||
@@ -289,6 +338,62 @@ def _request_token(
|
||||
return payload
|
||||
|
||||
|
||||
def _build_token_body_json(config: dict[str, str], now_epoch: float) -> str:
|
||||
body_json = config.get("KINGDEE_TOKEN_BODY_JSON", "")
|
||||
if body_json:
|
||||
return _render_json_template(body_json, _template_values(now_epoch))
|
||||
|
||||
body: dict[str, Any] = {}
|
||||
app_key = config.get("KINGDEE_APP_KEY", "")
|
||||
app_secret = config.get("KINGDEE_APP_SECRET", "")
|
||||
app_key_field = config.get("KINGDEE_TOKEN_APP_KEY_FIELD") or "app_key"
|
||||
app_secret_field = config.get("KINGDEE_TOKEN_APP_SECRET_FIELD") or "app_secret"
|
||||
if app_key:
|
||||
body[app_key_field] = app_key
|
||||
if app_secret:
|
||||
body[app_secret_field] = app_secret
|
||||
|
||||
timestamp_field = config.get("KINGDEE_TOKEN_TIMESTAMP_FIELD", "")
|
||||
if timestamp_field:
|
||||
body[timestamp_field] = _format_timestamp(now_epoch, config.get("KINGDEE_TOKEN_TIMESTAMP_FORMAT", "epoch_ms"))
|
||||
|
||||
extra = json.loads(config.get("KINGDEE_TOKEN_BODY_EXTRA_JSON", "{}") or "{}")
|
||||
if not isinstance(extra, dict):
|
||||
raise RuntimeError("KINGDEE_TOKEN_BODY_EXTRA_JSON must be a JSON object")
|
||||
body.update(extra)
|
||||
return json.dumps(body, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def _template_values(now_epoch: float, extra: dict[str, str] | None = None) -> dict[str, str]:
|
||||
dt = datetime.fromtimestamp(now_epoch, tz=timezone.utc)
|
||||
values = {
|
||||
"now_ms": str(int(now_epoch * 1000)),
|
||||
"now_epoch": str(int(now_epoch)),
|
||||
"now_seconds": str(int(now_epoch)),
|
||||
"now_datetime_utc": dt.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"now_iso_utc": dt.replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
||||
"today_utc": dt.strftime("%Y-%m-%d"),
|
||||
}
|
||||
values.update(extra or {})
|
||||
return values
|
||||
|
||||
|
||||
def _format_timestamp(now_epoch: float, timestamp_format: str) -> str | int:
|
||||
normalized = (timestamp_format or "epoch_ms").strip().lower()
|
||||
dt = datetime.fromtimestamp(now_epoch, tz=timezone.utc)
|
||||
if normalized in {"epoch_ms", "milliseconds", "millis"}:
|
||||
return int(now_epoch * 1000)
|
||||
if normalized in {"epoch_seconds", "seconds", "unix"}:
|
||||
return int(now_epoch)
|
||||
if normalized == "datetime_utc":
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
if normalized == "iso_utc":
|
||||
return dt.replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
if normalized == "date_utc":
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
raise RuntimeError(f"unsupported KINGDEE_TOKEN_TIMESTAMP_FORMAT: {timestamp_format}")
|
||||
|
||||
|
||||
def _render_json_template(template: str, values: dict[str, str]) -> str:
|
||||
if not template:
|
||||
return "{}"
|
||||
|
||||
@@ -67,6 +67,9 @@ class KingdeeApiTests(unittest.TestCase):
|
||||
self.assertIn("用途:销售订单 API 要求的 x-acgw-identity 请求头。", text)
|
||||
self.assertIn("是否必填:可选,手动测试用;长期定时同步建议使用自动登录配置。", text)
|
||||
self.assertIn("是否必填:可选,默认从 data.access_token 读取。", text)
|
||||
self.assertIn("变量:KINGDEE_APP_KEY", text)
|
||||
self.assertIn("变量:KINGDEE_TOKEN_TIMESTAMP_FIELD", text)
|
||||
self.assertIn("支持占位符:{now_ms}", text)
|
||||
|
||||
def test_default_config_path_accepts_none(self):
|
||||
cwd = Path.cwd()
|
||||
@@ -126,6 +129,45 @@ class KingdeeApiTests(unittest.TestCase):
|
||||
self.assertEqual(request.get_header("X-app"), "demo")
|
||||
self.assertEqual(stat.S_IMODE(cache_path.stat().st_mode), 0o600)
|
||||
|
||||
def test_access_token_body_can_be_built_from_split_fields_with_current_time(self):
|
||||
cache_path = self.root / ".runtime" / "kingdee-token.json"
|
||||
config = {
|
||||
"KINGDEE_TOKEN_URL": "https://token.example/access",
|
||||
"KINGDEE_APP_KEY": "abc",
|
||||
"KINGDEE_APP_SECRET": "secret",
|
||||
"KINGDEE_TOKEN_APP_KEY_FIELD": "client_id",
|
||||
"KINGDEE_TOKEN_APP_SECRET_FIELD": "client_secret",
|
||||
"KINGDEE_TOKEN_TIMESTAMP_FIELD": "timestamp",
|
||||
"KINGDEE_TOKEN_TIMESTAMP_FORMAT": "epoch_ms",
|
||||
"KINGDEE_TOKEN_BODY_EXTRA_JSON": '{"tenant":"demo"}',
|
||||
"KINGDEE_TOKEN_FIELD": "data.access_token",
|
||||
}
|
||||
http = RecordingUrlopen([{"data": {"access_token": "access-1"}}])
|
||||
|
||||
token = get_kingdee_access_token(config, cache_path=cache_path, urlopen=http, now_epoch=1000.5)
|
||||
|
||||
self.assertEqual(token, "access-1")
|
||||
body = json.loads(http.calls[0]["request"].data.decode("utf-8"))
|
||||
self.assertEqual(body["client_id"], "abc")
|
||||
self.assertEqual(body["client_secret"], "secret")
|
||||
self.assertEqual(body["timestamp"], 1000500)
|
||||
self.assertEqual(body["tenant"], "demo")
|
||||
|
||||
def test_token_body_json_supports_current_time_placeholders(self):
|
||||
cache_path = self.root / ".runtime" / "kingdee-token.json"
|
||||
config = {
|
||||
"KINGDEE_TOKEN_URL": "https://token.example/access",
|
||||
"KINGDEE_TOKEN_BODY_JSON": '{"app_key":"abc","timestamp":{now_ms},"requested_at":"{now_datetime_utc}"}',
|
||||
"KINGDEE_TOKEN_FIELD": "data.access_token",
|
||||
}
|
||||
http = RecordingUrlopen([{"data": {"access_token": "access-1"}}])
|
||||
|
||||
get_kingdee_access_token(config, cache_path=cache_path, urlopen=http, now_epoch=1000)
|
||||
|
||||
body = json.loads(http.calls[0]["request"].data.decode("utf-8"))
|
||||
self.assertEqual(body["timestamp"], 1000000)
|
||||
self.assertEqual(body["requested_at"], "1970-01-01 00:16:40")
|
||||
|
||||
def test_expired_token_uses_refresh_endpoint(self):
|
||||
cache_path = self.root / ".runtime" / "kingdee-token.json"
|
||||
cache_path.parent.mkdir(parents=True)
|
||||
|
||||
Reference in New Issue
Block a user