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:
Codex
2026-07-06 20:03:28 +00:00
parent 451a39c30a
commit 4e34e5b3fe
3 changed files with 155 additions and 4 deletions
+4
View File
@@ -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.
+109 -4
View File
@@ -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 "{}"
+42
View File
@@ -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)