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_SALORDER_API_URL`: defaults to the official `querySalOrder` endpoint.
- `KINGDEE_TOKEN_URL` and `KINGDEE_TOKEN_BODY_JSON`: login/access-token request. - `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_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_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. - `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 time
import uuid import uuid
import urllib.request import urllib.request
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
@@ -43,9 +44,50 @@ KINGDEE_ACCESS_TOKEN=
# 是否必填:自动登录时必填;如果只填 KINGDEE_ACCESS_TOKEN,则不用填。 # 是否必填:自动登录时必填;如果只填 KINGDEE_ACCESS_TOKEN,则不用填。
KINGDEE_TOKEN_URL= 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 # 变量:KINGDEE_TOKEN_BODY_JSON
# 用途:调用登录/token 接口时发送的 JSON 请求体,例如 app key、app secret、租户等。 # 用途:调用登录/token 接口时发送的完整 JSON 请求体。
# 是否必填:自动登录时必填;必须是一行合法 JSON。 # 是否必填:可选;只有不用拆分变量时才填写,必须是一行合法 JSON。
KINGDEE_TOKEN_BODY_JSON= KINGDEE_TOKEN_BODY_JSON=
# 变量:KINGDEE_TOKEN_HEADERS_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[new] = values[old]
values.setdefault("KINGDEE_SALORDER_API_URL", DEFAULT_SALORDER_API_URL) values.setdefault("KINGDEE_SALORDER_API_URL", DEFAULT_SALORDER_API_URL)
values.setdefault("KINGDEE_TOKEN_HEADERS_JSON", "{}") 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_FIELD", "data.access_token")
values.setdefault("KINGDEE_TOKEN_EXPIRES_IN_FIELD", "data.expires_in") values.setdefault("KINGDEE_TOKEN_EXPIRES_IN_FIELD", "data.expires_in")
values.setdefault("KINGDEE_REFRESH_TOKEN_FIELD", "data.refresh_token") values.setdefault("KINGDEE_REFRESH_TOKEN_FIELD", "data.refresh_token")
@@ -153,7 +199,10 @@ def get_kingdee_access_token(
try: try:
token_payload = _request_token( token_payload = _request_token(
config["KINGDEE_REFRESH_URL"], 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", "{}"), config.get("KINGDEE_TOKEN_HEADERS_JSON", "{}"),
urlopen=urlopen, 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") raise RuntimeError("KINGDEE_TOKEN_URL is required when no cached KINGDEE_ACCESS_TOKEN is available")
token_payload = _request_token( token_payload = _request_token(
token_url, token_url,
config.get("KINGDEE_TOKEN_BODY_JSON", ""), _build_token_body_json(config, now),
config.get("KINGDEE_TOKEN_HEADERS_JSON", "{}"), config.get("KINGDEE_TOKEN_HEADERS_JSON", "{}"),
urlopen=urlopen, urlopen=urlopen,
) )
@@ -289,6 +338,62 @@ def _request_token(
return payload 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: def _render_json_template(template: str, values: dict[str, str]) -> str:
if not template: if not template:
return "{}" return "{}"
+42
View File
@@ -67,6 +67,9 @@ class KingdeeApiTests(unittest.TestCase):
self.assertIn("用途:销售订单 API 要求的 x-acgw-identity 请求头。", text) self.assertIn("用途:销售订单 API 要求的 x-acgw-identity 请求头。", text)
self.assertIn("是否必填:可选,手动测试用;长期定时同步建议使用自动登录配置。", text) self.assertIn("是否必填:可选,手动测试用;长期定时同步建议使用自动登录配置。", text)
self.assertIn("是否必填:可选,默认从 data.access_token 读取。", 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): def test_default_config_path_accepts_none(self):
cwd = Path.cwd() cwd = Path.cwd()
@@ -126,6 +129,45 @@ class KingdeeApiTests(unittest.TestCase):
self.assertEqual(request.get_header("X-app"), "demo") self.assertEqual(request.get_header("X-app"), "demo")
self.assertEqual(stat.S_IMODE(cache_path.stat().st_mode), 0o600) 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): def test_expired_token_uses_refresh_endpoint(self):
cache_path = self.root / ".runtime" / "kingdee-token.json" cache_path = self.root / ".runtime" / "kingdee-token.json"
cache_path.parent.mkdir(parents=True) cache_path.parent.mkdir(parents=True)