Files
Codex 72c666f165 fix(business): follow Kingdee token request body
Align the generated Kingdee token request with the official enhanced-token document: client_id, client_secret, username, accountId, language, automatic nonce, and automatic current timestamp. Treat expires_in as milliseconds by default and update docs/tests accordingly.

Validation: python3 -m unittest discover -v; python3 -m compileall obsidian_wiki tests; git diff --check.
2026-07-06 20:11:01 +00:00

482 lines
19 KiB
Python

"""Kingdee sales-order API adapter with local token/config management."""
from __future__ import annotations
import json
import os
import stat
import time
import uuid
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
from obsidian_wiki.config import parse_config_file
from obsidian_wiki.file_assets import clean_config_value
DEFAULT_SALORDER_API_URL = "https://beijida.kdgalaxy.com/kapi/v2/h5a3/sm/sm_salorder/querySalOrder"
DEFAULT_TOKEN_API_URL = "https://beijida.kdgalaxy.com/kapi/oauth2/getToken"
DEFAULT_CONFIG_PATH = Path(".runtime") / "kingdee.env"
DEFAULT_TOKEN_CACHE_PATH = Path(".runtime") / "kingdee-token.json"
DEFAULT_PAGE_SIZE = 100
DEFAULT_MAX_PAGES = 100
TOKEN_SKEW_SECONDS = 60
Urlopen = Callable[[urllib.request.Request], Any]
KINGDEE_CONFIG_TEMPLATE = f"""# 金蝶销售订单 API 本地配置。
# 这个文件只保存在本机;.runtime/ 已被 git 忽略,请不要提交密钥或 token。
# 变量:KINGDEE_SALORDER_API_URL
# 用途:查询销售订单 API 地址。
# 是否必填:可选,默认已是当前项目使用的官方 querySalOrder 接口。
KINGDEE_SALORDER_API_URL={DEFAULT_SALORDER_API_URL}
# 变量:KINGDEE_ACCESS_TOKEN
# 用途:直接指定 access token,绕过自动登录/刷新流程。
# 是否必填:可选,手动测试用;长期定时同步建议使用自动登录配置。
KINGDEE_ACCESS_TOKEN=
# 变量:KINGDEE_TOKEN_URL
# 用途:官方增强型 Token 接口 /kapi/oauth2/getToken。
# 是否必填:可选,默认已按当前项目域名填写;如果只填 KINGDEE_ACCESS_TOKEN,则不会调用它。
KINGDEE_TOKEN_URL={DEFAULT_TOKEN_API_URL}
# 下面 5 项对应官方 getToken 请求 Body 的固定身份字段。
# 程序会按官方文档自动生成 nonce 和 timestamp;不用手写当前时间。
# 变量:KINGDEE_CLIENT_ID
# 用途:官方 client_id,第三方应用系统编码,即 appId。
# 是否必填:自动登录时必填;如果只填 KINGDEE_ACCESS_TOKEN,则不用填。
KINGDEE_CLIENT_ID=
# 变量:KINGDEE_CLIENT_SECRET
# 用途:官方 client_secret,第三方应用 AccessToken 认证密钥,即 appSecret。
# 是否必填:自动登录时必填;如果只填 KINGDEE_ACCESS_TOKEN,则不用填。
KINGDEE_CLIENT_SECRET=
# 变量:KINGDEE_USERNAME
# 用途:官方 username,第三方应用代理用户的用户名。
# 是否必填:自动登录时必填;如果只填 KINGDEE_ACCESS_TOKEN,则不用填。
KINGDEE_USERNAME=
# 变量:KINGDEE_ACCOUNT_ID
# 用途:官方 accountId,数据中心 id。
# 是否必填:自动登录时必填;如果只填 KINGDEE_ACCESS_TOKEN,则不用填。
KINGDEE_ACCOUNT_ID=
# 变量:KINGDEE_LANGUAGE
# 用途:官方 language,返回多语言文本字段时使用。
# 是否必填:可选;默认 zh_CN。
KINGDEE_LANGUAGE=zh_CN
# 说明:nonce 和 timestamp 会按官方文档自动生成。
# nonce 每次请求随机生成;timestamp 默认使用零时区 ISO 格式,例如 2025-06-12T00:00:10.009Z。
# 变量:KINGDEE_TOKEN_BODY_EXTRA_JSON
# 用途:token JSON body 的额外固定字段;一般不需要。
# 是否必填:可选;没有额外字段时保持 {{}}。
KINGDEE_TOKEN_BODY_EXTRA_JSON={{}}
# 变量:KINGDEE_TOKEN_TIMESTAMP_FORMAT
# 用途:timestamp 格式。可选值:iso_utc、datetime_utc。
# 是否必填:可选,默认 iso_utc;不确定时不要改。
KINGDEE_TOKEN_TIMESTAMP_FORMAT=iso_utc
# 高级填法:如果金蝶环境不是增强型 Token,或 body 结构特殊,可直接填写完整 JSON。
# 填了 KINGDEE_TOKEN_BODY_JSON 后,会覆盖上面的官方默认 body。
# 变量:KINGDEE_TOKEN_BODY_JSON
# 用途:调用登录/token 接口时发送的完整 JSON 请求体。
# 是否必填:可选;只有不用拆分变量时才填写,必须是一行合法 JSON。
KINGDEE_TOKEN_BODY_JSON=
# 变量:KINGDEE_TOKEN_HEADERS_JSON
# 用途:调用登录/token 接口时额外发送的请求头。
# 是否必填:可选;没有额外 header 时保持 {{}}。
KINGDEE_TOKEN_HEADERS_JSON={{}}
# 变量:KINGDEE_TOKEN_FIELD
# 用途:从 token 接口返回 JSON 中读取 access token 的字段路径。
# 是否必填:可选,默认从 data.access_token 读取。
KINGDEE_TOKEN_FIELD=data.access_token
# 变量:KINGDEE_TOKEN_EXPIRES_IN_FIELD
# 用途:从 token 接口返回 JSON 中读取有效期的字段路径。
# 是否必填:可选,默认从 data.expires_in 读取;官方单位是毫秒。
KINGDEE_TOKEN_EXPIRES_IN_FIELD=data.expires_in
# 变量:KINGDEE_TOKEN_EXPIRES_IN_UNIT
# 用途:KINGDEE_TOKEN_EXPIRES_IN_FIELD 读到的单位。
# 是否必填:可选,默认 milliseconds,符合官方文档。
KINGDEE_TOKEN_EXPIRES_IN_UNIT=milliseconds
# 变量:KINGDEE_REFRESH_TOKEN_FIELD
# 用途:从 token 接口返回 JSON 中读取 refresh token 的字段路径。
# 是否必填:可选;只有配置 refresh 流程时才有用。
KINGDEE_REFRESH_TOKEN_FIELD=data.refresh_token
# 变量:KINGDEE_REFRESH_URL
# 用途:refresh token 接口地址。
# 是否必填:可选;不填时 token 过期会重新调用 KINGDEE_TOKEN_URL。
KINGDEE_REFRESH_URL=
# 变量:KINGDEE_REFRESH_BODY_JSON
# 用途:调用 refresh 接口时发送的 JSON 请求体;可使用 {{refresh_token}} 占位符。
# 是否必填:可选;填写 KINGDEE_REFRESH_URL 时通常需要填写。
KINGDEE_REFRESH_BODY_JSON=
# 变量:KINGDEE_ACGW_IDENTITY
# 用途:销售订单 API 要求的 x-acgw-identity 请求头。
# 是否必填:必填;不填无法调用销售订单查询 API。
KINGDEE_ACGW_IDENTITY=
# 变量:KINGDEE_PAGE_SIZE
# 用途:销售订单 API 分页大小,对应请求体 pageSize。
# 是否必填:可选,默认 100。
KINGDEE_PAGE_SIZE={DEFAULT_PAGE_SIZE}
"""
def ensure_kingdee_config(config_path: str | Path | None = None) -> bool:
path = Path(config_path or DEFAULT_CONFIG_PATH).expanduser()
if path.exists():
return False
_ensure_private_parent(path)
path.write_text(KINGDEE_CONFIG_TEMPLATE, encoding="utf-8")
path.chmod(0o600)
return True
def load_kingdee_config(config_path: str | Path | None = None) -> dict[str, str]:
path = Path(config_path or DEFAULT_CONFIG_PATH).expanduser()
values = parse_config_file(path)
aliases = {
"KINGDEE_API_URL": "KINGDEE_SALORDER_API_URL",
"KINGDEE_API_TOKEN": "KINGDEE_ACCESS_TOKEN",
"KINGDEE_LOGIN_URL": "KINGDEE_TOKEN_URL",
"KINGDEE_LOGIN_BODY_JSON": "KINGDEE_TOKEN_BODY_JSON",
"KINGDEE_LOGIN_HEADERS_JSON": "KINGDEE_TOKEN_HEADERS_JSON",
"KINGDEE_APP_KEY": "KINGDEE_CLIENT_ID",
"KINGDEE_APP_SECRET": "KINGDEE_CLIENT_SECRET",
}
for key, value in os.environ.items():
if key.startswith("KINGDEE_") and value:
values[key] = clean_config_value(value)
for old, new in aliases.items():
if values.get(old) and not values.get(new):
values[new] = values[old]
values.setdefault("KINGDEE_SALORDER_API_URL", DEFAULT_SALORDER_API_URL)
values.setdefault("KINGDEE_TOKEN_URL", DEFAULT_TOKEN_API_URL)
values.setdefault("KINGDEE_TOKEN_HEADERS_JSON", "{}")
values.setdefault("KINGDEE_LANGUAGE", "zh_CN")
values.setdefault("KINGDEE_TOKEN_TIMESTAMP_FORMAT", "iso_utc")
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_TOKEN_EXPIRES_IN_UNIT", "milliseconds")
values.setdefault("KINGDEE_REFRESH_TOKEN_FIELD", "data.refresh_token")
values.setdefault("KINGDEE_PAGE_SIZE", str(DEFAULT_PAGE_SIZE))
return values
def token_cache_path_for_config(config_path: str | Path | None = None) -> Path:
if config_path is None:
return DEFAULT_TOKEN_CACHE_PATH
return Path(config_path).expanduser().with_name("kingdee-token.json")
def get_kingdee_access_token(
config: dict[str, str],
*,
cache_path: str | Path = DEFAULT_TOKEN_CACHE_PATH,
urlopen: Callable[..., Any] = urllib.request.urlopen,
now_epoch: float | None = None,
) -> str:
now = time.time() if now_epoch is None else float(now_epoch)
if config.get("KINGDEE_ACCESS_TOKEN"):
return config["KINGDEE_ACCESS_TOKEN"]
cache_path = Path(cache_path).expanduser()
cached = _read_token_cache(cache_path)
if cached.get("access_token") and float(cached.get("expires_at") or 0) > now + TOKEN_SKEW_SECONDS:
return str(cached["access_token"])
token_payload: dict[str, Any] | None = None
refresh_token = str(cached.get("refresh_token") or "")
if refresh_token and config.get("KINGDEE_REFRESH_URL"):
try:
token_payload = _request_token(
config["KINGDEE_REFRESH_URL"],
_render_json_template(
config.get("KINGDEE_REFRESH_BODY_JSON", ""),
_template_values(now, {"refresh_token": refresh_token}),
),
config.get("KINGDEE_TOKEN_HEADERS_JSON", "{}"),
urlopen=urlopen,
)
except Exception:
token_payload = None
if token_payload is None:
token_url = config.get("KINGDEE_TOKEN_URL", "")
if not token_url:
raise RuntimeError("KINGDEE_TOKEN_URL is required when no cached KINGDEE_ACCESS_TOKEN is available")
token_payload = _request_token(
token_url,
_build_token_body_json(config, now),
config.get("KINGDEE_TOKEN_HEADERS_JSON", "{}"),
urlopen=urlopen,
)
access_token = _extract_path(token_payload, config.get("KINGDEE_TOKEN_FIELD", "data.access_token"))
if not access_token:
raise RuntimeError("Kingdee token response did not contain an access token")
expires_in = _expires_in_seconds(
_extract_path(token_payload, config.get("KINGDEE_TOKEN_EXPIRES_IN_FIELD", "data.expires_in")),
config.get("KINGDEE_TOKEN_EXPIRES_IN_UNIT", "milliseconds"),
)
refresh_token = _extract_path(token_payload, config.get("KINGDEE_REFRESH_TOKEN_FIELD", "data.refresh_token")) or refresh_token
cache = {
"access_token": str(access_token),
"refresh_token": str(refresh_token or ""),
"expires_at": now + max(expires_in - TOKEN_SKEW_SECONDS, 1),
"obtained_at": now,
}
_write_private_json(cache_path, cache)
return str(access_token)
def fetch_kingdee_sales_orders(
config: dict[str, str],
*,
filters: dict[str, Any] | None = None,
token_cache_path: str | Path = DEFAULT_TOKEN_CACHE_PATH,
page_size: int | None = None,
max_pages: int = DEFAULT_MAX_PAGES,
urlopen: Callable[..., Any] = urllib.request.urlopen,
now_epoch: float | None = None,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
access_token = get_kingdee_access_token(config, cache_path=token_cache_path, urlopen=urlopen, now_epoch=now_epoch)
identity = config.get("KINGDEE_ACGW_IDENTITY", "")
if not identity:
raise RuntimeError("KINGDEE_ACGW_IDENTITY is required for Kingdee sales order API")
api_url = config.get("KINGDEE_SALORDER_API_URL") or DEFAULT_SALORDER_API_URL
size = page_size or _int_or_default(config.get("KINGDEE_PAGE_SIZE"), DEFAULT_PAGE_SIZE)
rows: list[dict[str, Any]] = []
pages: list[dict[str, Any]] = []
total_count: int | None = None
safe_filters = filters or {}
for page_no in range(1, max_pages + 1):
body = {"data": safe_filters, "pageNo": page_no, "pageSize": size}
request = urllib.request.Request(
api_url,
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
headers={
"Content-Type": "application/json",
"accesstoken": access_token,
"x-acgw-identity": identity,
"Idempotency-Key": str(uuid.uuid4()),
},
method="POST",
)
with urlopen(request, timeout=60) as response:
payload = json.loads(response.read().decode("utf-8"))
if payload.get("status") is False:
raise RuntimeError(
f"Kingdee sales order API failed: {payload.get('errorCode', '')} {payload.get('message', '')}".strip()
)
page_data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
page_rows = page_data.get("rows") if isinstance(page_data.get("rows"), list) else []
pages.append(payload)
rows.extend([row for row in page_rows if isinstance(row, dict)])
total = page_data.get("totalCount")
if isinstance(total, int) and total >= 0:
total_count = total
if not page_rows or len(page_rows) < size:
break
if total_count is not None and len(rows) >= total_count:
break
return rows, {"source": "kingdee-sales-order-api", "pages_fetched": len(pages), "rows": rows, "pages": pages}
def build_sales_order_filters(args: Any) -> dict[str, Any]:
fields = {
"start_bizdate": getattr(args, "start_bizdate", None),
"end_bizdate": getattr(args, "end_bizdate", None),
"start_createtime": getattr(args, "start_createtime", None),
"end_createtime": getattr(args, "end_createtime", None),
"start_modifytime": getattr(args, "start_modifytime", None) or getattr(args, "since", None),
"end_modifytime": getattr(args, "end_modifytime", None),
"start_auditdate": getattr(args, "start_auditdate", None),
"end_auditdate": getattr(args, "end_auditdate", None),
}
filters = {key: value for key, value in fields.items() if value}
array_fields = {
"id": getattr(args, "id", None),
"billno": getattr(args, "billno", None),
"billstatus": getattr(args, "billstatus", None),
"org_number": getattr(args, "org_number", None),
"customer_number": getattr(args, "customer_number", None),
"customer_createorg_number": getattr(args, "customer_createorg_number", None),
}
for key, value in array_fields.items():
values = _split_values(value)
if values:
filters[key] = values
return filters
def _request_token(
url: str,
body_json: str,
headers_json: str,
*,
urlopen: Callable[..., Any],
) -> dict[str, Any]:
body = json.loads(body_json or "{}")
headers = {"Content-Type": "application/json", **json.loads(headers_json or "{}")}
request = urllib.request.Request(
url,
data=json.dumps(body, ensure_ascii=False).encode("utf-8"),
headers=headers,
method="POST",
)
with urlopen(request, timeout=60) as response:
payload = json.loads(response.read().decode("utf-8"))
if isinstance(payload, dict) and payload.get("status") is False:
raise RuntimeError(f"Kingdee token API failed: {payload.get('errorCode', '')} {payload.get('message', '')}".strip())
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] = {
"client_id": config.get("KINGDEE_CLIENT_ID", ""),
"client_secret": config.get("KINGDEE_CLIENT_SECRET", ""),
"username": config.get("KINGDEE_USERNAME", ""),
"accountId": config.get("KINGDEE_ACCOUNT_ID", ""),
"nonce": config.get("KINGDEE_NONCE") or uuid.uuid4().hex,
"timestamp": _format_timestamp(now_epoch, config.get("KINGDEE_TOKEN_TIMESTAMP_FORMAT", "iso_utc")),
}
if config.get("KINGDEE_LANGUAGE"):
body["language"] = config["KINGDEE_LANGUAGE"]
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 "iso_utc").strip().lower()
dt = datetime.fromtimestamp(now_epoch, tz=timezone.utc)
if normalized in {"iso_utc", "iso_utc_ms"}:
return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{int(dt.microsecond / 1000):03d}Z"
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 == "date_utc":
return dt.strftime("%Y-%m-%d")
raise RuntimeError(f"unsupported KINGDEE_TOKEN_TIMESTAMP_FORMAT: {timestamp_format}")
def _expires_in_seconds(value: Any, unit: str) -> int:
normalized = (unit or "milliseconds").strip().lower()
if normalized in {"milliseconds", "millisecond", "ms"}:
return max(int(_int_or_default(value, 7_200_000) / 1000), 1)
if normalized in {"seconds", "second", "s"}:
return _int_or_default(value, 7200)
if normalized == "auto":
parsed = _int_or_default(value, 7_200_000)
return max(int(parsed / 1000), 1) if parsed > 86_400 else parsed
raise RuntimeError(f"unsupported KINGDEE_TOKEN_EXPIRES_IN_UNIT: {unit}")
def _render_json_template(template: str, values: dict[str, str]) -> str:
if not template:
return "{}"
rendered = template
for key, value in values.items():
rendered = rendered.replace("{" + key + "}", value)
return rendered
def _extract_path(payload: Any, path: str | None) -> Any:
if not path:
return None
current = payload
for part in path.split("."):
if not isinstance(current, dict):
return None
current = current.get(part)
return current
def _read_token_cache(path: Path) -> dict[str, Any]:
if not path.is_file():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return data if isinstance(data, dict) else {}
def _write_private_json(path: Path, data: dict[str, Any]) -> None:
_ensure_private_parent(path)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
path.chmod(0o600)
def _ensure_private_parent(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
try:
path.parent.chmod(0o700)
except OSError:
pass
def _int_or_default(value: Any, default: int) -> int:
try:
parsed = int(str(value))
except (TypeError, ValueError):
return default
return parsed if parsed > 0 else default
def _split_values(value: Any) -> list[str]:
if not value:
return []
if isinstance(value, (list, tuple)):
raw = []
for item in value:
raw.extend(str(item).split(","))
else:
raw = str(value).split(",")
return [item.strip() for item in raw if item.strip()]