fix(analysis): 统一方案名与接口错误提示
Generic Container CI/CD / test-build-publish (push) Successful in 1m2s
Frontend CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m2s

This commit is contained in:
2026-08-17 18:54:29 +08:00
parent 9e79d52dc8
commit 45def5bba3
12 changed files with 720 additions and 55 deletions
+209
View File
@@ -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)");
});
});
+258
View File
@@ -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;
};