Files
fdesk/app/common/logic/Fingerprint.php
T

262 lines
9.5 KiB
PHP
Raw Normal View History

2025-09-01 10:08:51 +08:00
<?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;
}
}