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.

407 lines
12 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 { 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,
/(<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 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<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 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 };