262 lines
9.5 KiB
PHP
262 lines
9.5 KiB
PHP
<?php
|
||
|
||
namespace app\common\logic;
|
||
|
||
/**
|
||
* Fingerprint
|
||
*
|
||
* 目标:
|
||
* - 默认“强制所有字段参与计算”,保证相同记录生成同一指纹
|
||
* - 通过统一规范化策略,避免无意义差异(空白、大小写、JSON键序)导致指纹抖动
|
||
* - 仅排除技术字段:id、fingerprint(可通过参数自行增删)
|
||
*
|
||
* 用法:
|
||
* $hash = Fingerprint::makeFromArray($payload); // 创建前
|
||
* $hash = Fingerprint::makeFromModel($skuModel); // 已有模型
|
||
* $hash = Fingerprint::makeFromArray($payload, ['exclude'=>['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;
|
||
}
|
||
} |