Files
zyt/server/app/mcp/service/FieldPolicy.php
T
2026-09-24 09:45:44 +08:00

203 lines
7.9 KiB
PHP

<?php
declare(strict_types=1);
namespace app\mcp\service;
/**
* 字段策略:凭据类字段一律删除;手机号、身份证号、住址、银行卡、附件地址按权限脱敏;
* 所有文本里夹带的手机号、身份证号同样脱敏。后台列表接口本身返回明文,这里在服务端补上。
*/
class FieldPolicy
{
private const SECRET = '/(^|_)(password|passwd|pwd|salt|secret|secret_key|app_secret|appsecret|token|access_token|refresh_token|api_key|apikey|private_key|access_key|aes_key|encoding_aes_key|session_key|sign_key|mch_key|signature|cert_path|key_path|cipher|ciphertext)(_|$)/i';
private const PHONE = '/(^|_)(phone|mobile|tel|telephone)(_|$)/i';
private const ID_CARD = '/(^|_)(id_card|idcard|id_no|idno|id_number|identity_card|license_no)(_|$)/i';
private const ADDRESS = '/(^|_)(address|addr)(_|$)/i';
private const BANK = '/(^|_)(bank_card|bank_account|card_no|account_no)(_|$)/i';
private const IP = '/(^|_)ip(_|$)/i';
private const ATTACHMENT = '/(^|_)(images?|imgs?|photos?|pics?|files?|urls?|avatar|attachments?|audio|video|voice|report_files|tongue_images|qualification_images)(_|$)/i';
private const TEXT_PHONE = '/(?<!\d)(1[3-9]\d)\d{4}(\d{4})(?!\d)/';
private const TEXT_ID = '/(?<![0-9A-Za-z])([1-9]\d{5})(?:19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])(\d{3}[0-9Xx])(?![0-9A-Za-z])/';
/** 被脱敏或删除的字段名(去重),在结果里告诉模型 */
public array $masked = [];
private bool $phone;
private bool $sensitive;
private int $maxText;
public function __construct(bool $seesPhone, bool $seesSensitive, int $maxText = 20000)
{
$this->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;
}
}