import { CARGO_LIMITS } from "@/lib/constants/cargo-limits"; import { snapMothershipReadyDateToWeekday } from "@/lib/mothership/logged-in-constraints"; import { z } from "zod"; import { buildCargoHash, buildLoggedInCargoHashExtras, 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 { dimValueToIn, normalizeDimUnit, normalizeWeightUnit, toAxelWholeInches, toAxelWholePounds, weightUnitAmbiguousMessage, weightValueToLb, } 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 weightUnitSchema = z .string({ errorMap: () => ({ message: "请选择有效的重量单位" }) }) .superRefine((s, ctx) => { const ambiguous = weightUnitAmbiguousMessage(s); if (ambiguous) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: ambiguous }); return; } if (normalizeWeightUnit(s) === null) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "请选择有效的重量单位(lb / kg / t)", }); } }) .transform((s) => normalizeWeightUnit(s)!); const dimUnitSchema = z .string({ errorMap: () => ({ message: "请选择有效的尺寸单位" }) }) .refine((s) => normalizeDimUnit(s) !== null, "请选择有效的尺寸单位(in / cm / m)") .transform((s) => normalizeDimUnit(s)!); const positiveFinite = (label: string) => z .number({ invalid_type_error: `请填写${label}` }) .finite(`请填写有效的${label}`) .positive(`请填写有效的${label}`); const weightSchema = z.object({ value: positiveFinite("重量"), unit: weightUnitSchema, }); const dimensionsSchema = z.object({ length: positiveFinite("长度"), width: positiveFinite("宽度"), height: positiveFinite("高度"), unit: dimUnitSchema, }); 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(), pickup_accessorials: z.array(z.string().min(1).max(64)).max(20).optional(), delivery_accessorials: z.array(z.string().min(1).max(64)).max(20).optional(), ready_date: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/, "可提货日期格式无效") .transform((v) => snapMothershipReadyDateToWeekday(v)) .optional(), ready_time: z.string().min(1).max(32).optional(), /** 多行货物字段已为英制(weight_lb / *_in),不做公制换算 */ cargo_lines: z .array( z.object({ cargo_type: z.string().min(1).max(64), quantity: z.number().finite().positive().max(999), weight_lb: z.number().finite().positive().max(100_000), length_in: z.number().finite().positive().max(1_000), width_in: z.number().finite().positive().max(1_000), height_in: z.number().finite().positive().max(1_000), }), ) .min(1) .max(20) .optional(), mothership_details: z .object({ pickup: z .object({ company_name: z.string().max(128).optional(), suite: z.string().max(64).optional(), contact_first: z.string().max(64).optional(), contact_last: z.string().max(64).optional(), contact_email: z.string().max(128).optional(), contact_phone: z.string().max(64).optional(), reference: z.string().max(128).optional(), notes: z.string().max(1000).optional(), opens_at: z.string().max(32).optional(), closes_at: z.string().max(32).optional(), }) .optional(), delivery: z .object({ company_name: z.string().max(128).optional(), suite: z.string().max(64).optional(), contact_first: z.string().max(64).optional(), contact_last: z.string().max(64).optional(), contact_email: z.string().max(128).optional(), contact_phone: z.string().max(64).optional(), reference: z.string().max(128).optional(), notes: z.string().max(1000).optional(), opens_at: z.string().max(32).optional(), closes_at: z.string().max(32).optional(), }) .optional(), request_delivery_appointment: z.boolean().optional(), fba_number: z.string().max(128).optional(), fba_po_number: z.string().max(128).optional(), cargo: z .array( z.object({ piece_count_type: z.string().max(32).optional(), piece_count_qty: z.number().min(0).max(999999).optional(), description: z.string().max(500).optional(), nmfc: z.string().max(64).optional(), hazmat: z.boolean().optional(), alcohol: z.boolean().optional(), tobacco: z.boolean().optional(), }), ) .max(20) .optional(), }) .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 weightLbRaw = weightValueToLb(data.weight.value, data.weight.unit); const dimLInRaw = dimValueToIn(data.dimensions.length, data.dimensions.unit); const dimWInRaw = dimValueToIn(data.dimensions.width, data.dimensions.unit); const dimHInRaw = dimValueToIn(data.dimensions.height, data.dimensions.unit); validateMothershipLimits( weightLbRaw, dimLInRaw, dimWInRaw, dimHInRaw, data.pallet_count, ); const weightLb = toAxelWholePounds(weightLbRaw); const dimLIn = toAxelWholeInches(dimLInRaw); const dimWIn = toAxelWholeInches(dimWInRaw); const dimHIn = toAxelWholeInches(dimHInRaw); 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, loggedInExtras: buildLoggedInCargoHashExtras({ pickupAccessorials: data.pickup_accessorials, deliveryAccessorials: data.delivery_accessorials, readyDate: data.ready_date, readyTime: data.ready_time, cargoLines: data.cargo_lines?.map((row) => ({ cargoType: row.cargo_type, quantity: row.quantity, weightLb: row.weight_lb, lengthIn: row.length_in, widthIn: row.width_in, heightIn: row.height_in, })), }), }); 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, pickupAccessorials: data.pickup_accessorials, deliveryAccessorials: data.delivery_accessorials, readyDate: data.ready_date, readyTime: data.ready_time, cargoLines: data.cargo_lines?.map((row) => ({ cargoType: row.cargo_type, quantity: row.quantity, weightLb: row.weight_lb, lengthIn: row.length_in, widthIn: row.width_in, heightIn: row.height_in, })), mothershipDetails: data.mothership_details, }; } export type { ValidationError };