You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

277 lines
7.6 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { CARGO_LIMITS } from "@/lib/constants/cargo-limits";
import { z } from "zod";
import {
buildCargoHash,
serializeAddress,
} from "@/modules/quote/cargo-hash";
import type {
NormalizedCargo,
QuoteRequestBody,
ValidationError,
} from "@/modules/quote/types";
import {
SecurityValidationError,
ValidationError as ValidationErrorClass,
} from "@/modules/quote/types";
import { cmToIn, kgToLb } from "@/modules/quote/unit-converter";
/** 查价货物硬限PRD §4.2.8 */
export const MOTHERSHIP_LIMITS = CARGO_LIMITS;
export const CARGO_TYPES = [
"general_freight",
"machinery",
"furniture",
"electronics",
"building_materials",
"auto_parts",
"food_nonperishable",
"apparel",
"other",
] as const;
const UUID_V4_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const US_ZIP_REGEX = /^\d{5}(-\d{4})?$/;
const SQL_INJECTION_PATTERNS = [
/(\bunion\b.*\bselect\b)/i,
/(\bdrop\b.*\btable\b)/i,
/(\binsert\b.*\binto\b)/i,
/(\bdelete\b.*\bfrom\b)/i,
/(--|\/\*|\*\/)/,
/(\bor\b\s+\d+\s*=\s*\d+)/i,
/(<script|javascript:)/i,
/(\bexec\b|\bxp_)/i,
];
const addressSchema = z.object({
street: z.string().min(1, "请填写街道地址"),
city: z.string().min(1, "请填写城市"),
state: z.string().min(2, "请填写州/省").max(2, "请填写州/省"),
zip: z
.string()
.refine((v) => v === "" || US_ZIP_REGEX.test(v), "邮编格式无效")
.default(""),
place_id: z.string().min(1, "请从地址列表中选择有效地址"),
formatted_address: z.string().min(1, "请填写完整地址"),
selected_from_suggestions: z.literal(true, {
errorMap: () => ({ message: "请从地址列表中选择有效地址" }),
}),
mothership_option_id: z.string().min(1, "请确认 MotherShip 精确地址"),
mothership_display_label: z.string().min(1, "请确认 MotherShip 精确地址"),
selected_from_mothership: z.literal(true, {
errorMap: () => ({
message: "提货/派送地址须在 MotherShip 候选列表中确认后再询价",
}),
}),
});
const weightSchema = z.object({
value: z.number({ invalid_type_error: "请填写重量" }),
unit: z.enum(["lb", "kg"], { errorMap: () => ({ message: "请选择有效的单位" }) }),
});
const dimensionsSchema = z.object({
length: z.number({ invalid_type_error: "请填写长度" }),
width: z.number({ invalid_type_error: "请填写宽度" }),
height: z.number({ invalid_type_error: "请填写高度" }),
unit: z.enum(["in", "cm"], {
errorMap: () => ({ message: "请选择有效的单位" }),
}),
});
const quoteBodySchema = z.object({
request_id: z.string().min(1, "请填写请求标识"),
customer_id: z.string().min(1, "请填写客户标识"),
pickup_address: addressSchema,
delivery_address: addressSchema,
weight: weightSchema,
dimensions: dimensionsSchema,
pallet_count: z.number({ invalid_type_error: "请填写托盘数" }),
cargo_type: z.enum(CARGO_TYPES, {
errorMap: () => ({ message: "请选择有效的货物类型" }),
}),
service_level: z.enum(["standard", "guaranteed"]).optional(),
rate_option: z.enum(["lowest", "fastest"]).optional(),
display_unit: z.enum(["imperial", "metric"]).optional(),
quote_session_id: z.string().uuid("会话标识无效").optional(),
});
function containsSqlInjection(value: string): boolean {
return SQL_INJECTION_PATTERNS.some((pattern) => pattern.test(value));
}
function checkAddressInjection(
addr: {
street: string;
city: string;
state: string;
zip: string;
place_id: string;
formatted_address: string;
},
label: string,
): void {
const fields = [
addr.street,
addr.city,
addr.state,
addr.zip,
addr.formatted_address,
addr.place_id,
];
if (fields.some(containsSqlInjection)) {
throw new SecurityValidationError(`${label}无效,请检查`);
}
}
function validateZip(zip: string, label: string): void {
if (zip.trim() === "") {
return;
}
if (!US_ZIP_REGEX.test(zip)) {
throw new ValidationErrorClass(`${label}邮编无效`);
}
}
function validateMothershipLimits(
weightLb: number,
dimLIn: number,
dimWIn: number,
dimHIn: number,
palletCount: number,
): void {
const { palletCount: pc, dimIn, weightLbPerPallet, totalWeightLbMax } =
MOTHERSHIP_LIMITS;
if (
!Number.isInteger(palletCount) ||
palletCount < pc.min ||
palletCount > pc.max
) {
throw new ValidationErrorClass(
`托盘数超出 Mothership 允许范围(${pc.min}${pc.max}`,
);
}
if (weightLb < weightLbPerPallet.min || weightLb > weightLbPerPallet.max) {
throw new ValidationErrorClass("重量超出 Mothership 允许范围");
}
if (
dimLIn < dimIn.min ||
dimLIn > dimIn.lengthMax ||
dimWIn < dimIn.min ||
dimWIn > dimIn.widthMax ||
dimHIn < dimIn.min ||
dimHIn > dimIn.heightMax
) {
throw new ValidationErrorClass("尺寸超出 Mothership 允许范围");
}
const totalWeight = weightLb * palletCount;
if (totalWeight > totalWeightLbMax) {
throw new ValidationErrorClass(
`整票总重超出 Mothership 允许范围(≤${totalWeightLbMax} lb`,
);
}
}
/** E4.7:拒绝 quantity 件数字段 */
export function rejectQuantityField(body: Record<string, unknown>): void {
if ("quantity" in body) {
throw new ValidationErrorClass("请使用托盘数 pallet_count");
}
}
/**
* 校验 + 单位换算 + cargo_hash 生成
* @throws ValidationError
*/
export function validateQuoteInput(
body: unknown,
): NormalizedCargo {
if (typeof body !== "object" || body === null) {
throw new ValidationErrorClass("请求体无效");
}
rejectQuantityField(body as Record<string, unknown>);
const parsed = quoteBodySchema.safeParse(body);
if (!parsed.success) {
const first = parsed.error.errors[0];
throw new ValidationErrorClass(first?.message ?? "请求参数无效");
}
const data = parsed.data as QuoteRequestBody;
if (!UUID_V4_REGEX.test(data.request_id)) {
throw new ValidationErrorClass("请求标识无效");
}
checkAddressInjection(data.pickup_address, "发货地址");
checkAddressInjection(data.delivery_address, "收货地址");
validateZip(data.pickup_address.zip, "发货地址");
validateZip(data.delivery_address.zip, "收货地址");
const weightLb =
data.weight.unit === "kg" ? kgToLb(data.weight.value) : data.weight.value;
const dimLIn =
data.dimensions.unit === "cm"
? cmToIn(data.dimensions.length)
: data.dimensions.length;
const dimWIn =
data.dimensions.unit === "cm"
? cmToIn(data.dimensions.width)
: data.dimensions.width;
const dimHIn =
data.dimensions.unit === "cm"
? cmToIn(data.dimensions.height)
: data.dimensions.height;
validateMothershipLimits(
weightLb,
dimLIn,
dimWIn,
dimHIn,
data.pallet_count,
);
const pickupSerialized = serializeAddress(data.pickup_address);
const deliverySerialized = serializeAddress(data.delivery_address);
const cargoHash = buildCargoHash({
pickupSerialized,
deliverySerialized,
weightLb,
dimLIn,
dimWIn,
dimHIn,
palletCount: data.pallet_count,
cargoType: data.cargo_type,
});
return {
requestId: data.request_id,
customerId: data.customer_id,
pickupAddress: data.pickup_address,
deliveryAddress: data.delivery_address,
weightLb,
dimLIn,
dimWIn,
dimHIn,
palletCount: data.pallet_count,
cargoType: data.cargo_type,
serviceLevel: data.service_level ?? "standard",
rateOption: data.rate_option ?? "lowest",
cargoHash,
pickupSerialized,
deliverySerialized,
quoteSessionId: data.quote_session_id,
};
}
export type { ValidationError };