fix: repair XFT expense type diagnostics
This commit is contained in:
+149
-25
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user