fix(analysis): 统一方案名与接口错误提示
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
import { getApiErrorMessage } from "./apiError";
|
||||
|
||||
describe("getApiErrorMessage", () => {
|
||||
it("uses a business detail from Problem Details", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
detail: "ACTIVE 状态的阀门 V1 必须提供设置值",
|
||||
errors: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe("ACTIVE 状态的阀门 V1 必须提供设置值");
|
||||
});
|
||||
|
||||
it("formats field-level validation errors in Chinese", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
detail: "Request validation failed",
|
||||
errors: [
|
||||
{
|
||||
type: "missing",
|
||||
loc: ["query", "drainage_node_id"],
|
||||
msg: "Field required",
|
||||
},
|
||||
{
|
||||
type: "literal_error",
|
||||
loc: ["query", "valve_statuses", 1],
|
||||
msg: "Input should be 'OPEN', 'CLOSED' or 'ACTIVE'",
|
||||
ctx: { expected: "'OPEN', 'CLOSED' or 'ACTIVE'" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(
|
||||
"排水节点:不能为空;阀门开关状态[2]:可选值为 'OPEN', 'CLOSED' 或 'ACTIVE'",
|
||||
);
|
||||
});
|
||||
|
||||
it("supports legacy FastAPI validation details", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
detail: [
|
||||
{
|
||||
type: "greater_than_equal",
|
||||
loc: ["body", "sensor_count"],
|
||||
msg: "Input should be greater than or equal to 1",
|
||||
ctx: { ge: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe("监测点数量:必须大于或等于 1");
|
||||
});
|
||||
|
||||
it("limits long validation responses while preserving the remaining count", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
errors: Array.from({ length: 5 }, (_, index) => ({
|
||||
type: "missing",
|
||||
loc: ["body", `field_${index}`],
|
||||
msg: "Field required",
|
||||
})),
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(
|
||||
"field_0:不能为空;field_1:不能为空;field_2:不能为空;另有 2 项参数错误",
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds hostile validation payloads before formatting", () => {
|
||||
const message = getApiErrorMessage({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
errors: Array.from({ length: 100_000 }, () => ({
|
||||
type: "custom_error",
|
||||
loc: ["body", "x".repeat(1_000)],
|
||||
msg: "y".repeat(10_000),
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(message.length).toBeLessThan(600);
|
||||
expect(message).toContain("另有 99997 项参数错误");
|
||||
});
|
||||
|
||||
it("bounds validation constraint values", () => {
|
||||
const message = getApiErrorMessage({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
errors: [
|
||||
{
|
||||
type: "greater_than",
|
||||
loc: ["body", "duration"],
|
||||
ctx: { gt: "9".repeat(10_000) },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(message.length).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it("distinguishes network and timeout failures", () => {
|
||||
expect(getApiErrorMessage({ code: "ERR_NETWORK" })).toContain("无法连接服务");
|
||||
expect(getApiErrorMessage({ code: "ECONNABORTED" })).toContain("请求超时");
|
||||
});
|
||||
|
||||
it("uses a localized message and trace id for server failures", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 503,
|
||||
data: {
|
||||
detail: "仿真服务暂时不可用",
|
||||
trace_id: "trace-123",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe("服务暂时不可用,请稍后重试(追踪 ID:trace-123)");
|
||||
});
|
||||
|
||||
it("does not expose authentication details from the response", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 401,
|
||||
data: { detail: "Not authenticated" },
|
||||
},
|
||||
}),
|
||||
).toBe("登录状态已失效,请重新登录");
|
||||
});
|
||||
|
||||
it("preserves a clear Chinese permission detail", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 403,
|
||||
data: { detail: "当前项目角色为只读,不能创建方案" },
|
||||
},
|
||||
}),
|
||||
).toBe("当前项目角色为只读,不能创建方案");
|
||||
});
|
||||
|
||||
it("localizes plain-text server failures", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 500,
|
||||
data: "Internal Server Error",
|
||||
},
|
||||
}),
|
||||
).toBe("服务处理失败,请稍后重试");
|
||||
});
|
||||
|
||||
it("does not misreport server error arrays as validation failures", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 500,
|
||||
data: {
|
||||
errors: [{ loc: ["body", "scheme_name"], msg: "failed" }],
|
||||
trace_id: "trace-500",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe("服务处理失败,请稍后重试(追踪 ID:trace-500)");
|
||||
});
|
||||
|
||||
it("does not display HTML gateway responses", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 502,
|
||||
data: "<!doctype html><html><body>Bad Gateway</body></html>",
|
||||
},
|
||||
}),
|
||||
).toBe("上游服务暂时不可用,请稍后重试");
|
||||
});
|
||||
|
||||
it("uses a localized fallback for an unknown HTTP status", () => {
|
||||
expect(
|
||||
getApiErrorMessage({
|
||||
response: {
|
||||
status: 418,
|
||||
},
|
||||
message: "Request failed with status code 418",
|
||||
}),
|
||||
).toBe("请求失败(HTTP 418)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
type ErrorResponse = {
|
||||
status?: number;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
scheme_name: "方案名称",
|
||||
start_time: "开始时间",
|
||||
modify_pattern_start_time: "开始时间",
|
||||
target_time: "目标时间",
|
||||
sampling_interval_minutes: "采样间隔",
|
||||
duration: "持续时长",
|
||||
modify_total_duration: "持续时长",
|
||||
valves: "参与阀门",
|
||||
valve_statuses: "阀门开关状态",
|
||||
valve_settings: "阀门设置值",
|
||||
valves_k: "阀门开度",
|
||||
drainage_node_id: "排水节点",
|
||||
drainage_node_ID: "排水节点",
|
||||
burst_id: "爆管点",
|
||||
burst_ID: "爆管点",
|
||||
burst_size: "爆管流量",
|
||||
burst_leakage: "爆管流量",
|
||||
min_dpressure: "最小压降",
|
||||
basic_pressure: "基准压力",
|
||||
data_source: "数据来源",
|
||||
scada_burst_start: "爆管开始时间",
|
||||
scada_burst_end: "爆管结束时间",
|
||||
use_scada_flow: "使用流量监测数据",
|
||||
simulation_scheme_name: "模拟方案名称",
|
||||
simulation_scheme_type: "模拟方案类型",
|
||||
source: "污染源节点",
|
||||
concentration: "污染物浓度",
|
||||
pattern: "污染物注入模式",
|
||||
sensor_count: "监测点数量",
|
||||
sensor_type: "监测点类型",
|
||||
method: "优化方法",
|
||||
min_diameter: "最小管径",
|
||||
dma_count: "DMA 数量",
|
||||
scada_start: "监测开始时间",
|
||||
scada_end: "监测结束时间",
|
||||
q_sum: "总漏损流量",
|
||||
pop_size: "种群规模",
|
||||
max_gen: "最大迭代次数",
|
||||
flush_flow: "冲洗流量",
|
||||
};
|
||||
|
||||
const STATUS_MESSAGES: Record<number, string> = {
|
||||
400: "请求参数不正确",
|
||||
401: "登录状态已失效,请重新登录",
|
||||
403: "当前账号无权执行此操作",
|
||||
404: "请求的数据不存在",
|
||||
409: "当前数据已发生变化,请刷新后重试",
|
||||
422: "请求参数校验失败",
|
||||
429: "请求过于频繁,请稍后重试",
|
||||
500: "服务处理失败,请稍后重试",
|
||||
502: "上游服务暂时不可用,请稍后重试",
|
||||
503: "服务暂时不可用,请稍后重试",
|
||||
504: "服务响应超时,请稍后重试",
|
||||
};
|
||||
|
||||
const asRecord = (value: unknown): UnknownRecord | null =>
|
||||
value !== null && typeof value === "object"
|
||||
? (value as UnknownRecord)
|
||||
: null;
|
||||
|
||||
const nonEmptyString = (value: unknown): string | null =>
|
||||
typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
|
||||
const safeServerMessage = (value: unknown): string | null => {
|
||||
const message = nonEmptyString(value);
|
||||
if (!message || message.length > 300) return null;
|
||||
if (/<!doctype|<html|<body|<script|<style|<[^>]+>/i.test(message)) {
|
||||
return null;
|
||||
}
|
||||
return message;
|
||||
};
|
||||
|
||||
const boundedText = (value: unknown, maxLength: number): string | null => {
|
||||
const text = nonEmptyString(value);
|
||||
if (!text) return null;
|
||||
return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
|
||||
};
|
||||
|
||||
const boundedConstraint = (value: unknown, fallback: string) =>
|
||||
value === null || value === undefined
|
||||
? fallback
|
||||
: (boundedText(String(value), 40) ?? fallback);
|
||||
|
||||
const getErrorResponse = (error: unknown): ErrorResponse | null => {
|
||||
const candidate = asRecord(error);
|
||||
const response = asRecord(candidate?.response);
|
||||
if (!response) return null;
|
||||
|
||||
return {
|
||||
status:
|
||||
typeof response.status === "number" ? response.status : undefined,
|
||||
data: response.data,
|
||||
};
|
||||
};
|
||||
|
||||
const formatValidationLocation = (location: unknown): string => {
|
||||
if (!Array.isArray(location)) return "请求参数";
|
||||
|
||||
const segments = location.slice(0, 5).filter(
|
||||
(segment, index) =>
|
||||
!(
|
||||
index === 0 &&
|
||||
["body", "query", "path", "header"].includes(String(segment))
|
||||
),
|
||||
);
|
||||
if (segments.length === 0) return "请求参数";
|
||||
|
||||
return segments.reduce<string>((result, segment, index) => {
|
||||
if (typeof segment === "number") {
|
||||
return `${result}[${segment + 1}]`;
|
||||
}
|
||||
const rawSegment = String(segment);
|
||||
const label = FIELD_LABELS[rawSegment] ?? boundedText(rawSegment, 40) ?? "参数";
|
||||
return index === 0 ? label : `${result}.${label}`;
|
||||
}, "");
|
||||
};
|
||||
|
||||
const translateValidationMessage = (error: UnknownRecord): string => {
|
||||
const type = nonEmptyString(error.type);
|
||||
const message = nonEmptyString(error.msg);
|
||||
const context = asRecord(error.ctx);
|
||||
|
||||
switch (type) {
|
||||
case "missing":
|
||||
return "不能为空";
|
||||
case "literal_error": {
|
||||
const expected = boundedText(context?.expected, 100);
|
||||
return expected
|
||||
? `可选值为 ${expected.replace(/\s+or\s+/g, " 或 ")}`
|
||||
: "取值不在允许范围内";
|
||||
}
|
||||
case "greater_than":
|
||||
return `必须大于 ${boundedConstraint(context?.gt, "限定值")}`;
|
||||
case "greater_than_equal":
|
||||
return `必须大于或等于 ${boundedConstraint(context?.ge, "限定值")}`;
|
||||
case "less_than":
|
||||
return `必须小于 ${boundedConstraint(context?.lt, "限定值")}`;
|
||||
case "less_than_equal":
|
||||
return `必须小于或等于 ${boundedConstraint(context?.le, "限定值")}`;
|
||||
case "int_parsing":
|
||||
return "必须是整数";
|
||||
case "float_parsing":
|
||||
case "decimal_parsing":
|
||||
return "必须是数字";
|
||||
case "datetime_from_date_parsing":
|
||||
case "datetime_parsing":
|
||||
return "日期时间格式不正确";
|
||||
case "string_too_short":
|
||||
return `长度不能少于 ${boundedConstraint(context?.min_length, "要求的")} 个字符`;
|
||||
case "string_too_long":
|
||||
return `长度不能超过 ${boundedConstraint(context?.max_length, "允许的")} 个字符`;
|
||||
default:
|
||||
if (message === "Field required") return "不能为空";
|
||||
if (message?.startsWith("Input should be ")) {
|
||||
return boundedText(
|
||||
message.replace("Input should be ", "可选值为 "),
|
||||
120,
|
||||
)!;
|
||||
}
|
||||
return boundedText(message, 120) ?? "参数无效";
|
||||
}
|
||||
};
|
||||
|
||||
const formatValidationErrors = (value: unknown): string | null => {
|
||||
if (!Array.isArray(value) || value.length === 0) return null;
|
||||
|
||||
const messages = value
|
||||
.slice(0, 3)
|
||||
.map(asRecord)
|
||||
.filter((item): item is UnknownRecord => item !== null)
|
||||
.map(
|
||||
(item) =>
|
||||
`${formatValidationLocation(item.loc)}:${translateValidationMessage(item)}`,
|
||||
);
|
||||
|
||||
if (messages.length === 0) return null;
|
||||
const visibleMessages = messages.join(";");
|
||||
const remainingCount = value.length - messages.length;
|
||||
return remainingCount > 0
|
||||
? `${visibleMessages};另有 ${remainingCount} 项参数错误`
|
||||
: visibleMessages;
|
||||
};
|
||||
|
||||
const appendTraceId = (message: string, traceId: string | null) =>
|
||||
traceId ? `${message}(追踪 ID:${traceId})` : message;
|
||||
|
||||
export const getApiErrorMessage = (
|
||||
error: unknown,
|
||||
fallback = "请求失败,请稍后重试",
|
||||
): string => {
|
||||
const candidate = asRecord(error);
|
||||
const response = getErrorResponse(error);
|
||||
const payload = asRecord(response?.data);
|
||||
const status = response?.status;
|
||||
const traceId = safeServerMessage(payload?.trace_id);
|
||||
const isValidationResponse =
|
||||
status === 400 ||
|
||||
status === 422 ||
|
||||
nonEmptyString(payload?.code) === "validation_error";
|
||||
const validationErrors = isValidationResponse
|
||||
? formatValidationErrors(
|
||||
payload?.errors ??
|
||||
(Array.isArray(payload?.detail) ? payload.detail : null),
|
||||
)
|
||||
: null;
|
||||
|
||||
if (validationErrors) {
|
||||
return validationErrors;
|
||||
}
|
||||
|
||||
const responseText = safeServerMessage(response?.data);
|
||||
const detail = safeServerMessage(payload?.detail);
|
||||
const responseMessage = safeServerMessage(payload?.message);
|
||||
const statusMessage = status ? STATUS_MESSAGES[status] : null;
|
||||
const unknownStatusMessage = status ? `请求失败(HTTP ${status})` : null;
|
||||
|
||||
if (status === 403) {
|
||||
const permissionDetail = detail ?? responseMessage;
|
||||
return permissionDetail && /[\u3400-\u9fff]/u.test(permissionDetail)
|
||||
? permissionDetail
|
||||
: STATUS_MESSAGES[403];
|
||||
}
|
||||
|
||||
if (status && (status === 401 || status >= 500)) {
|
||||
return appendTraceId(
|
||||
statusMessage ?? unknownStatusMessage ?? fallback,
|
||||
status >= 500 ? traceId : null,
|
||||
);
|
||||
}
|
||||
|
||||
const message =
|
||||
detail ?? responseMessage ?? responseText ?? statusMessage ?? unknownStatusMessage;
|
||||
|
||||
if (message) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const code = nonEmptyString(candidate?.code);
|
||||
if (code === "ECONNABORTED" || code === "ETIMEDOUT") {
|
||||
return "请求超时,请稍后重试";
|
||||
}
|
||||
if (
|
||||
code === "ERR_NETWORK" ||
|
||||
(candidate?.isAxiosError === true && !response)
|
||||
) {
|
||||
return "无法连接服务,请检查网络连接或服务状态";
|
||||
}
|
||||
|
||||
return nonEmptyString(candidate?.message) ?? fallback;
|
||||
};
|
||||
Reference in New Issue
Block a user