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.

467 lines
14 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 {
findMothershipCargoWeightBlockMessage,
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_display_label: z.string().min(1, "请确认精确地址"),
selected_from_mothership: z.literal(true, {
errorMap: () => ({
message: "提货/派送地址须在候选列表中确认后再询价",
}),
}),
});
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, "请填写客户标识"),
business_customer_id: z.string().min(1, "请填写业务客户标识").optional(),
/** ccnew LR_Base_User.F_Account;无 business_customer_id 时用于反查加价客户 */
business_user_account: z.string().min(1).max(64).optional(),
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 { dimIn, weightLbPerPallet, totalWeightLbMax } = MOTHERSHIP_LIMITS;
// 托盘数:仅要求正整数,取消历史 1–25 硬限(登录态/匿名均不卡上限)
if (!Number.isInteger(palletCount) || palletCount < 1) {
throw new ValidationErrorClass("托盘数须为正整数");
}
if (weightLb < weightLbPerPallet.min || weightLb > weightLbPerPallet.max) {
throw new ValidationErrorClass("重量超出允许范围");
}
if (
dimLIn < dimIn.min ||
dimLIn > dimIn.lengthMax ||
dimWIn < dimIn.min ||
dimWIn > dimIn.widthMax ||
dimHIn < dimIn.min ||
dimHIn > dimIn.heightMax
) {
throw new ValidationErrorClass("尺寸超出允许范围");
}
const totalWeight = weightLb * palletCount;
if (totalWeight > totalWeightLbMax) {
throw new ValidationErrorClass(
`整票总重超出允许范围(≤${totalWeightLbMax} lb)`,
);
}
}
/**
* 登录态多行货物:单件均重 ≤5000 + 整票总重 ≤53ft 载重 + 尺寸合理。
*/
function validateMothershipLoggedInCargoLines(
lines: Array<{
weight_lb: number;
length_in: number;
width_in: number;
height_in: number;
quantity: number;
}>,
fallbackDims: { l: number; w: number; h: number },
): void {
const block = findMothershipCargoWeightBlockMessage(
lines.map((row) => ({
weightLb: row.weight_lb,
quantity: row.quantity,
})),
);
if (block) {
throw new ValidationErrorClass(block);
}
const { dimIn } = MOTHERSHIP_LIMITS;
const checkDim = (l: number, w: number, h: number) => {
if (
l < dimIn.min ||
l > dimIn.lengthMax ||
w < dimIn.min ||
w > dimIn.widthMax ||
h < dimIn.min ||
h > dimIn.heightMax
) {
throw new ValidationErrorClass("尺寸超出允许范围");
}
};
for (const row of lines) {
checkDim(row.length_in, row.width_in, row.height_in);
}
checkDim(fallbackDims.l, fallbackDims.w, fallbackDims.h);
}
/** 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);
// 登录态:一级带 cargo_lines;二级刷价/结账常只带 mothership_details(库内无 cargo_lines 列)
// 二者均不得再走匿名托数 1–25 硬拒
const isLoggedInMsPath =
Boolean(data.cargo_lines && data.cargo_lines.length > 0) ||
Boolean(data.mothership_details);
if (isLoggedInMsPath) {
if (data.cargo_lines && data.cargo_lines.length > 0) {
validateMothershipLoggedInCargoLines(data.cargo_lines, {
l: dimLInRaw,
w: dimWInRaw,
h: dimHInRaw,
});
}
} else {
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,
businessCustomerId: data.business_customer_id?.trim() || undefined,
businessUserAccount: data.business_user_account?.trim() || undefined,
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 };