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, /( 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): 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); 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 };