fix: repair XFT expense type diagnostics

This commit is contained in:
2026-08-26 05:05:59 +00:00
parent 2c60c35048
commit 76a4e0592c
4 changed files with 339 additions and 52 deletions
+149 -25
View File
@@ -456,6 +456,7 @@ export class XftOpenApiClient implements XftApprovalProvider {
identity: XftIdentity,
template: XftSubmissionTemplate,
): Promise<XftSubmissionOption[]> {
let customFieldError: unknown;
try {
const rawFields = await this.request(
`/itrip/xft-api/v1/form/customFields/eventSeqId/${encodeURIComponent(template.eventSeqId)}`,
@@ -465,34 +466,46 @@ export class XftOpenApiClient implements XftApprovalProvider {
);
const embedded = extractExpenseTypeOptions(rawFields);
if (embedded.length) return embedded;
} catch {
} catch (error) {
customFieldError = error;
// Some templates do not expose standard expense dictionaries in custom fields.
// Fall through to the enterprise multilevel data-source APIs.
}
const candidates: Array<{ id: string; raw: JsonObject }> = [];
let sourceCount = 0;
for (let page = 0; page < EXPENSE_TYPE_SOURCE_MAX_PAGES; page += 1) {
let response: unknown;
try {
response = await this.request(
"/itrip/xft-api/v1/dataSource/findMultilevelSourcePage",
{ start: page * EXPENSE_TYPE_SOURCE_PAGE_SIZE, limit: EXPENSE_TYPE_SOURCE_PAGE_SIZE },
"POST",
identity,
);
} catch {
return [];
}
const start = page * EXPENSE_TYPE_SOURCE_PAGE_SIZE;
const response = await this.request(
`/itrip/xft-api/v1/dataSource/findMultilevelSourcePage?start=${start}&limit=${EXPENSE_TYPE_SOURCE_PAGE_SIZE}`,
{},
"POST",
identity,
);
const records = pagedRecords(response);
sourceCount += records.length;
for (const record of records) {
const name = stringAt(record, ["sourceName", "dataSourceName", "multilevelSourceName", "displayName", "name", "title"]);
const id = stringAt(record, ["sourceSeqId", "dataSourceSeqId", "multilevelSourceSeqId", "seqId", "id"]);
const name = stringAt(record, [
"selectSourceName", "sourceName", "dataSourceName", "multilevelSourceName", "displayName", "name", "title",
]);
const id = stringAt(record, ["seqId", "sourceSeqId", "dataSourceSeqId", "multilevelSourceSeqId"]);
if (id && name && EXPENSE_TYPE_HINT.test(name)) candidates.push({ id, raw: record });
}
if (records.length < EXPENSE_TYPE_SOURCE_PAGE_SIZE) break;
}
if (!candidates.length) {
if (customFieldError) throw customFieldError;
const sourceResult = sourceCount === 0
? "企业多级数据源列表为空"
: `企业多级数据源共返回 ${sourceCount} 项,但没有费用类型数据源`;
throw new ApprovalSafetyError(
`薪福通费用类型查询没有返回可用选项:提单实时字段未提供内嵌选项,且${sourceResult}。`
+ "薪福通正式 OpenAPI 未提供独立的费用类型目录查询,请在费用管理页面确认配置或使用薪福通客户端完成",
);
}
const output: XftSubmissionOption[] = [];
const detailErrors: unknown[] = [];
for (const candidate of candidates.slice(0, 10)) {
output.push(...extractExpenseTypeOptions(candidate.raw, true));
try {
@@ -503,10 +516,17 @@ export class XftOpenApiClient implements XftApprovalProvider {
identity,
);
output.push(...extractExpenseTypeOptions(detail, true));
} catch {
} catch (error) {
detailErrors.push(error);
// Continue with other matching sources. Empty output fails closed below.
}
}
if (!output.length && detailErrors.length) throw detailErrors[0];
if (!output.length) {
throw new ApprovalSafetyError(
`薪福通费用类型查询没有返回可用选项:已找到 ${candidates.length} 个费用类型数据源,但详情中没有可选择的名称与编码`,
);
}
return uniqueOptions(output);
}
@@ -1078,30 +1098,31 @@ export class XftOpenApiClient implements XftApprovalProvider {
signal: AbortSignal.timeout(timeoutMs),
});
} catch (error) {
throw new XftApiError(`请求薪福通正式接口失败:${errorMessage(error)}`, 0, "TRANSPORT_ERROR");
throw xftTransportError(error, pathname, "request", timeoutMs);
}
let text: string;
try { text = await response.text(); }
catch (error) {
throw new XftApiError(`读取薪福通响应失败:${errorMessage(error)}`, 0, "RESPONSE_STREAM_ERROR");
throw xftTransportError(error, pathname, "response", timeoutMs);
}
const operation = xftOperationLabel(pathname);
let parsed: unknown;
try {
const plaintext = this.api.encrypted && /^[0-9a-fA-F]+$/.test(text) && text.length % 2 === 0
? String(sm4.decrypt(text, this.api.authoritySecret.slice(0, 32)))
: text;
parsed = plaintext ? JSON.parse(plaintext) : {};
} catch { throw new XftApiError(`薪福通正式接口返回解密或 JSON 解析失败(HTTP ${response.status})`, response.status); }
parsed = this.decryptEnvelope(parsed);
if (!response.ok) throw new XftApiError(apiMessage(parsed) ?? `薪福通接口 HTTP ${response.status}`, response.status, apiCode(parsed));
} catch { throw new XftApiError(`薪福通${operation}返回解密或 JSON 解析失败(HTTP ${response.status})`, response.status); }
parsed = this.decryptEnvelope(parsed, operation);
if (!response.ok) throw xftResponseError(parsed, response.status, operation);
const code = apiCode(parsed);
if (!isXftSuccessCode(code)) {
throw new XftApiError(apiMessage(parsed) ?? `薪福通接口错误 ${code}`, response.status, code);
throw xftResponseError(parsed, response.status, operation);
}
return unwrapData(parsed);
}
private decryptEnvelope(value: unknown): unknown {
private decryptEnvelope(value: unknown, operation: string): unknown {
if (!this.api.encrypted || !isObject(value)) return value;
const encrypted = typeof value.secretMsg === "string" ? value.secretMsg
: isObject(value.data) && typeof value.data.secretMsg === "string" ? value.data.secretMsg
@@ -1111,11 +1132,114 @@ export class XftOpenApiClient implements XftApprovalProvider {
const plaintext = sm4.decrypt(encrypted, this.api.authoritySecret.slice(0, 32));
return JSON.parse(String(plaintext));
} catch {
throw new XftApiError("薪福通响应解密失败,请核对应用加密配置和 AuthoritySecret", 502, "DECRYPT_FAILED");
throw new XftApiError(`薪福通${operation}响应解密失败,请核对应用加密配置和 AuthoritySecret`, 502, "DECRYPT_FAILED");
}
}
}
type XftTransportPhase = "request" | "response";
interface XftTransportDiagnostic {
operation: string;
phase: XftTransportPhase;
category: string;
causeName?: string;
causeCode?: string;
timeoutMs: number;
}
function xftTransportError(error: unknown, path: string, phase: XftTransportPhase, timeoutMs: number): XftApiError {
const operation = xftOperationLabel(path);
const diagnostic = xftTransportDiagnostic(error, operation, phase, timeoutMs);
// Keep logs useful without recording URLs, query parameters, identities, request bodies, or upstream payloads.
console.error("[dingtalk-approval-mcp] XFT transport failure", diagnostic);
const technical = [
diagnostic.causeCode ? `底层错误码 ${diagnostic.causeCode}` : undefined,
diagnostic.causeName && diagnostic.causeName !== "TypeError" ? `错误类型 ${diagnostic.causeName}` : undefined,
`超时上限 ${timeoutMs}ms`,
].filter(Boolean).join(";");
const prefix = phase === "request" ? "请求" : "读取响应";
const code = phase === "request" ? "TRANSPORT_ERROR" : "RESPONSE_STREAM_ERROR";
return new XftApiError(`薪福通${operation}${prefix}失败:${diagnostic.category}(${technical})`, 0, code);
}
function xftTransportDiagnostic(
error: unknown,
operation: string,
phase: XftTransportPhase,
timeoutMs: number,
): XftTransportDiagnostic {
const chain = errorDiagnosticChain(error);
const causeCode = chain.map((item) => safeDiagnosticCode(item.code)).find(Boolean);
const names = chain.map((item) => safeDiagnosticName(item.name)).filter((name): name is string => Boolean(name));
const causeName = names.find((name) => name !== "Error" && name !== "TypeError")
?? names.find((name) => name !== "Error");
const markers = `${causeCode ?? ""} ${causeName ?? ""}`.toUpperCase();
let category = phase === "response" ? "响应流在读取过程中中断" : "网络传输失败";
if (/TIMEOUT|ETIMEDOUT/.test(markers)) category = phase === "response" ? "读取响应超时" : "连接或等待响应超时";
else if (/ENOTFOUND|EAI_AGAIN|DNS/.test(markers)) category = "DNS 解析失败";
else if (/ECONNREFUSED/.test(markers)) category = "连接被拒绝";
else if (/ECONNRESET|UND_ERR_SOCKET|EPIPE/.test(markers)) category = "网络连接被重置或中断";
else if (/ENETUNREACH|EHOSTUNREACH/.test(markers)) category = "目标网络或主机不可达";
else if (/CERT|TLS|SSL|UNABLE_TO_VERIFY|SELF_SIGNED/.test(markers)) category = "TLS 或证书校验失败";
else if (/ABORT/.test(markers)) category = "请求被取消或超过等待时间";
return { operation, phase, category, ...(causeName ? { causeName } : {}), ...(causeCode ? { causeCode } : {}), timeoutMs };
}
function errorDiagnosticChain(error: unknown): Array<Record<string, unknown>> {
const output: Array<Record<string, unknown>> = [];
const pending: unknown[] = [error];
const seen = new Set<object>();
while (pending.length && output.length < 8) {
const current = pending.shift();
if ((!current || typeof current !== "object") && typeof current !== "function") continue;
if (seen.has(current as object)) continue;
seen.add(current as object);
const record = current as Record<string, unknown>;
output.push(record);
if (record.cause) pending.push(record.cause);
if (Array.isArray(record.errors)) pending.push(...record.errors);
}
return output;
}
function safeDiagnosticCode(value: unknown): string | undefined {
return typeof value === "string" && /^[A-Z][A-Z0-9_]{1,63}$/.test(value) ? value : undefined;
}
function safeDiagnosticName(value: unknown): string | undefined {
return typeof value === "string" && /^[A-Za-z][A-Za-z0-9]{0,63}(?:Error|Exception)$/.test(value) ? value : undefined;
}
function xftResponseError(value: unknown, status: number, operation: string): XftApiError {
const code = apiCode(value);
const visibleCode = safeXftApiCode(code);
const metadata = [`操作:${operation}`, `HTTP ${status}`, ...(visibleCode ? [`薪福通错误码 ${visibleCode}`] : [])].join(";");
return new XftApiError(`${apiMessage(value) ?? "薪福通接口返回业务错误"}(${metadata})`, status, code);
}
function safeXftApiCode(value: string | undefined): string | undefined {
return value && /^[A-Za-z0-9_.:-]{1,64}$/.test(value) ? value : undefined;
}
function xftOperationLabel(path: string): string {
if (path.includes("user-mapping")) return "用户身份映射";
if (path.includes("member/get/by-id")) return "当前用户成员档案";
if (path.includes("todo-task/page/by-user")) return "待办或已处理列表";
if (path.includes("todo-task/detail/by-ids")) return "待办任务详情";
if (path.endsWith("/bill-list")) return "单据摘要";
if (path.includes("/form/query-event-list")) return "提单模板列表";
if (path.includes("/form/customFields/eventSeqId/")) return "提单实时字段";
if (path.includes("/dataSource/findMultilevelSourcePage")) return "费用类型数据源列表";
if (path.includes("/dataSource/getMultiLevelDetail/")) return "费用类型数据源详情";
if (path.includes("/common/city/getAllCity")) return "城市选项";
if (path.includes("/approve/")) return "审批写操作";
if (path.includes("/create")) return "审批发起";
if (path.includes("/detail") || path.includes("/taskHistory/")) return "审批详情";
if (path.includes("/queryBillByCondition")) return "本人发起记录";
return "业务接口";
}
function isUncertainXftWriteError(error: unknown): boolean {
return !(error instanceof ApprovalSafetyError)
&& (!(error instanceof XftApiError)
@@ -1394,11 +1518,11 @@ function extractExpenseTypeOptions(value: unknown, trustedExpenseSource = false)
]);
const code = explicitCode ?? (path.length > 0 ? stringAt(record, [
"optionCode", "optionNumber", "itemCode", "itemNumber", "nodeCode", "nodeNumber", "dataCode", "dataNumber",
"sourceOptionCode", "sourceOptionNumber", "code", "number", "value", "key",
"sourceOptionCode", "sourceOptionNumber", "code", "number", "value", "key", "seqId",
]) : undefined);
const label = stringAt(record, [
"expenseName", "expenseTypeName", "expTypeName", "optionName", "itemName", "nodeName", "dataName",
"sourceOptionName", "label", "text", "displayName", "name", "title",
"sourceOptionName", "selectOptionName", "label", "text", "displayName", "name", "title",
]);
if (code && label) candidates.push({ code, label });
}