['msrp']]); // 可选排除 */ class Fingerprint { /** 你的 veh_vehicle_sku 所有列(保持与表结构一致,顺序无关但建议完整) */ public const COLS = [ 'id','sku_code','fingerprint', 'brand_code','brand_name','series_code','series_name', 'model_code','model_name','model_year', 'trim_code','trim_level_code','edition_code', 'trim','trim_level','edition_name', 'body_type_code','drive_type_code','motor_layout_code','fuel_type_code', 'engine_disp_l','engine_turbo','engine_power_kw','battery_kwh', 'transmission_code','gears','range_km','range_std_code', 'doors','seats','weight_kg','dimensions_mm', 'color_exterior_code','color_exterior_text', 'color_interior_code','color_interior_text', 'options_official','features','emission_std_code', 'origin_country','currency','msrp', 'is_active','corp_id','remark', 'created_by','updated_by','created_at','updated_at', ]; /** 默认剔除:技术/循环依赖字段 */ public const EXCLUDE_DEFAULT = [ 'id','sku_code','fingerprint', 'dimensions_mm', 'options_official', 'features', 'origin_country','currency','msrp', 'is_active','corp_id','remark', 'created_by','updated_by','created_at','updated_at' ]; /** 识别为“code类”的字段模式(统一大写) */ private const CODE_FIELD_REGEX = '/(_code|_country|^currency)$/i'; /** 识别为 JSON 字段 */ private const JSON_FIELDS = ['dimensions_mm','options_official','features']; /** * 从 Eloquent 模型生成 */ public static function makeFromModel($model, array $opts = []): string { return self::makeFromArray($model->toArray(), $opts); } /** * 从数组生成指纹 * @param array $row 一般为 $_POST 合并后的业务数据或 $model->toArray() * @param array $opts ['exclude'=>[], 'extra'=>[], 'salt'=>string] * - exclude: 额外排除的字段(默认仅排除 id,fingerprint) * - extra : 额外拼入的键值(例如上下文、租户策略),会参与计算 * - salt : 额外加盐字符串 */ public static function makeFromArray(array $row, array $opts = []): string { $exclude = array_values(array_unique(array_merge(self::EXCLUDE_DEFAULT, $opts['exclude'] ?? []))); $extra = $opts['extra'] ?? []; $salt = $opts['salt'] ?? ''; // 1) 统一抽取“表中定义的字段” + extra,缺失字段也要纳入(值按 NULL 处理) $data = []; foreach (self::COLS as $col) { if (in_array($col, $exclude, true)) continue; $data[$col] = array_key_exists($col, $row) ? $row[$col] : null; } // extra 也参与 foreach ($extra as $k => $v) { if (!is_string($k) || $k === '') continue; $data['__extra__'.$k] = $v; } // 2) 规范化(按字段特征) $canon = []; foreach ($data as $k => $v) { $canon[$k] = self::normalize($k, $v); } // 3) 键名排序,确保顺序无关 ksort($canon, SORT_STRING); // 4) 拼成稳定串: key=value | key=value... $parts = []; foreach ($canon as $k => $v) { // 为空也必须占位,避免 key 消失影响其他键的意义 $parts[] = $k.'='.$v; } if ($salt !== '') { $parts[] = '__salt__='.$salt; } $plain = implode('|', $parts); // 5) sha256 return hash('sha256', $plain); } /** * 规范化单个字段 * - NULL -> 特殊占位 "∅"(避免与空串混淆) * - 布尔/整数/浮点 -> 统一数字字符串 * - code类字段 -> 去首尾空白 & 转大写 * - 普通字符串 -> trim & 压缩连续空白为单空格 * - JSON字段 -> 解析后做“键排序 + 值规范化 + 索引数组可排序”,再无空白编码 */ private static function normalize(string $field, $value): string { if ($value === null) return '∅'; // 数值类型 if (is_int($value)) return (string)$value; if (is_bool($value)) return $value ? '1' : '0'; if (is_float($value) || (is_string($value) && is_numeric($value) && self::looksLikeNumberField($field))) { // 统一浮点格式,最多 6 位小数并去尾零 $num = (float)$value; $s = number_format($num, 6, '.', ''); $s = rtrim(rtrim($s, '0'), '.'); return $s === '' ? '0' : $s; } // JSON 字段 if (in_array($field, self::JSON_FIELDS, true)) { return self::normalizeJson($value); } // 字符串 if (is_string($value)) { $s = trim($value); // code类:统一大写 if (preg_match(self::CODE_FIELD_REGEX, $field)) { $s = mb_strtoupper($s, 'UTF-8'); } else { // 普通文本:压缩连续空白 $s = preg_replace('/\s+/u', ' ', $s); } return $s === '' ? '' : $s; } // 数组(极少出现,按 JSON 处理) if (is_array($value)) { return self::normalizeJson($value); } // 其他类型(对象等):转 JSON 后处理 return self::normalizeJson($value); } /** 判断这个字段是否“看起来像数值字段” */ private static function looksLikeNumberField(string $field): bool { static $numKeys = [ 'engine_disp_l','engine_power_kw','battery_kwh','gears', 'range_km','doors','seats','weight_kg','msrp','model_year', 'created_at','updated_at','corp_id','is_active' ]; return in_array($field, $numKeys, true); } /** * JSON 规范化: * - 字符串:若能 json_decode 则按 JSON;否则当普通字符串再规范化 * - 对象:键名排序;值递归规范化 * - 索引数组:元素递归规范化后,按字符串值排序(视为集合,避免顺序抖动) * - 最终 json_encode(JSON_UNESCAPED_UNICODE|UNESCAPED_SLASHES) */ private static function normalizeJson($val): string { // 已是数组/对象 if (is_array($val)) { $normalized = self::canonArray($val); return json_encode($normalized, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); } // 字符串尝试解析 if (is_string($val)) { $val = trim($val); if ($val === '') return '""'; $decoded = json_decode($val, true); if (json_last_error() === JSON_ERROR_NONE) { $normalized = self::canonArray($decoded); return json_encode($normalized, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); } // 非 JSON 字符串,按普通字符串规范化 $s = preg_replace('/\s+/u', ' ', $val); return json_encode($s, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); } // 其他类型:直接编码 return json_encode($val, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); } /** 递归规范化数组/对象 */ private static function canonArray($arr) { if (!is_array($arr)) return $arr; // 关联数组(对象) if (self::isAssoc($arr)) { $out = []; ksort($arr, SORT_STRING); foreach ($arr as $k => $v) { $out[(string)$k] = self::canonValue($v); } return $out; } // 索引数组(当集合处理:元素字符串化后排序) $tmp = []; foreach ($arr as $v) { $tmp[] = self::canonValue($v); } // 将元素转为字符串再排序,确保稳定 usort($tmp, function($a, $b) { $sa = is_scalar($a) ? (string)$a : json_encode($a, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); $sb = is_scalar($b) ? (string)$b : json_encode($b, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); return strcmp($sa, $sb); }); return $tmp; } private static function canonValue($v) { if (is_array($v)) return self::canonArray($v); if (is_bool($v)) return $v ? 1 : 0; if (is_int($v)) return $v; if (is_float($v)) { $s = number_format($v, 6, '.', ''); $s = rtrim(rtrim($s, '0'), '.'); return $s === '' ? '0' : $s; } if (is_string($v)) { $s = trim($v); $s = preg_replace('/\s+/u', ' ', $s); return $s; } return $v; } private static function isAssoc(array $arr): bool { // 有任意一个键不是 0..n 的递增整数,就视为关联数组 $i = 0; foreach ($arr as $k => $_) { if ($k !== $i++) return true; } return false; } }