phone = $seesPhone; $this->sensitive = $seesSensitive; $this->maxText = $maxText; } public static function forIdentity(Identity $identity, int $maxText = 20000): self { return new self($identity->seesPhone(), $identity->seesSensitive(), $maxText); } public function apply($value, string $key = '') { if (is_array($value)) { if ($key !== '' && !$this->sensitive && preg_match(self::ATTACHMENT, $key) && self::isUrlList($value)) { return $this->attachment($key, count($value)); } $out = []; foreach ($value as $k => $v) { if (is_string($k) && preg_match(self::SECRET, $k)) { $this->masked[$k] = true; continue; } $out[$k] = $this->apply($v, is_string($k) ? $k : $key); } return $out; } if (is_int($value) && $value > 999999 && $key !== '' && (preg_match(self::PHONE, $key) || preg_match(self::ID_CARD, $key))) { $value = (string) $value; } if (!is_string($value) || $value === '') { return $value; } if ($key !== '') { // 只对像号码的值脱敏,is_phone 之类的标志位原样保留 if (!$this->phone && preg_match(self::PHONE, $key) && preg_match_all('/\d/', $value) >= 7) { return $this->mark($key, self::maskPhone($value)); } if (!$this->sensitive && preg_match(self::ID_CARD, $key) && mb_strlen($value) >= 8) { return $this->mark($key, self::maskMiddle($value, 4, 4)); } if (!$this->sensitive && preg_match(self::ADDRESS, $key) && mb_strlen($value) > 6) { return $this->mark($key, mb_substr($value, 0, 6) . '***'); } if (!$this->sensitive && preg_match(self::BANK, $key) && mb_strlen($value) >= 8) { return $this->mark($key, self::maskMiddle($value, 0, 4)); } if (!$this->sensitive && preg_match(self::IP, $key) && preg_match('/^(\d{1,3}\.\d{1,3}\.\d{1,3})\.\d{1,3}$/', $value, $m)) { return $this->mark($key, $m[1] . '.*'); } if (!$this->sensitive && preg_match(self::ATTACHMENT, $key) && self::looksLikeUrls($value)) { return $this->attachment($key, self::urlCount($value)); } // 字段名不像附件、但值是本系统存储路径的(如 examination_report),同样按附件处理 if (!$this->sensitive && self::isStoragePath($value)) { return $this->attachment($key, self::urlCount($value)); } } $text = $this->maskFreeText($value); if (mb_strlen($text) > $this->maxText) { $text = mb_substr($text, 0, $this->maxText) . '…(已截断,原文共 ' . mb_strlen($value) . ' 字,请用 zyt_get 查看单条详情)'; } return $text; } /** 文本中夹带的手机号、身份证号 */ public function maskFreeText(string $text): string { if (strlen($text) < 11) { return $text; } if (!$this->phone) { $text = preg_replace(self::TEXT_PHONE, '$1****$2', $text) ?? $text; } if (!$this->sensitive) { $text = preg_replace(self::TEXT_ID, '$1********$2', $text) ?? $text; } return $text; } /** 写审计日志用:无论权限,一律脱敏 */ public static function maskText($value) { return (new self(false, false, 500))->apply($value); } public static function maskPhone(string $value): string { // 可能是 "138****1234" 这种已脱敏的值,或 "0371-12345678" 这种座机;只保留前 3 位和后 4 位数字 $digits = preg_replace('/\D/', '', $value); return strlen($digits) >= 7 ? substr($digits, 0, 3) . '****' . substr($digits, -4) : $value; } public static function maskMiddle(string $value, int $head, int $tail): string { $len = mb_strlen($value); if ($len <= $head + $tail) { return str_repeat('*', $len); } return mb_substr($value, 0, $head) . str_repeat('*', $len - $head - $tail) . ($tail ? mb_substr($value, -$tail) : ''); } public function maskedFields(): array { return array_keys($this->masked); } private function mark(string $key, string $value): string { $this->masked[$key] = true; return $value; } private function attachment(string $key, int $count): string { $this->masked[$key] = true; return '[附件×' . $count . ',如需查看请用 zyt_file 读取]'; } private static function looksLikeUrls(string $value): bool { $value = trim($value); if ($value !== '' && $value[0] === '[') { $decoded = json_decode($value, true); return is_array($decoded) && self::isUrlList($decoded); } return (bool) preg_match('#^(https?://|/?uploads/|/?storage/|/?static/)#i', $value); } private static function isStoragePath(string $value): bool { $value = trim($value); if ($value !== '' && $value[0] === '[') { $decoded = json_decode($value, true); $value = is_array($decoded) && is_string($decoded[0] ?? null) ? $decoded[0] : ''; } return (bool) preg_match('#^(https?://[^/\s]+)?/?(uploads|storage)/[^\s]+\.[a-z0-9]{2,5}(,|$)#i', $value); } private static function urlCount(string $value): int { $value = trim($value); if ($value !== '' && $value[0] === '[') { $decoded = json_decode($value, true); return is_array($decoded) ? count($decoded) : 1; } return count(array_filter(explode(',', $value))); } private static function isUrlList(array $value): bool { if ($value === []) { return false; } foreach ($value as $item) { $url = is_array($item) ? ($item['url'] ?? $item['uri'] ?? null) : $item; if (!is_string($url) || !preg_match('#^(https?://|/?uploads/|/?storage/|/?static/)#i', trim($url))) { return false; } } return true; } }