資安法規 (GDPR、個資法) 要求前端顯示時對敏感資料進行去識別化。本文提供用戶名、身份證、IP、手機號、銀行卡、郵箱等 6 類常見敏感資料的遮罩函數,含正則與非正則兩種實作,可直接投入生產環境。
| 資料類型 | 遮罩規則 | 範例 |
|---|---|---|
| 中文用戶名 | 保留首尾字,中間 * | 張*三、王**五 |
| 英文用戶名 | 首字母 + * + 尾字母 | j***n、a***k |
| 身份證字號 (台) | 顯示首碼 + 後 4 碼 | A123******5678 |
| 身份證號碼 (陸) | 隱藏出生日期段 (7-14 位) | 110101********1234 |
| IPv4 | 隱藏最後一段 | 192.168.1.* |
| IPv6 | 隱藏後 64 位 | 2001:db8::/64 |
| 手機號 (台) | 09XX-XXX-XXX → 09**-XXX-XXX | 0912-345-678 → 09**-345-678 |
| 手機號 (陸) | 隱藏中間 4 碼 | 138****1234 |
| 銀行卡 | 顯示首 6 尾 4 (BIN + 校驗碼) | 6225 88****** 1234 |
| 本地部首字母 + * + @domain | j***@example.com |
<?php
class DataMasker {
/**
* 中文用戶名遮罩 (支援多字節)
*/
public static function chineseName($name, $maskChar = '*') {
$len = mb_strlen($name, 'UTF-8');
if ($len <= 1) return $name;
if ($len === 2) return mb_substr($name, 0, 1) . $maskChar;
return mb_substr($name, 0, 1, 'UTF-8')
. str_repeat($maskChar, $len - 2)
. mb_substr($name, -1, 1, 'UTF-8');
}
/**
* 英文用戶名遮罩
*/
public static function englishName($name, $maskChar = '*') {
$len = strlen($name);
if ($len <= 2) return $name[0] . $maskChar;
return $name[0] . str_repeat($maskChar, $len - 2) . $name[$len - 1];
}
/**
* 通用:保留首 N 尾 M 字元
*/
public static function keepStartEnd($str, $start = 1, $end = 1, $maskChar = '*') {
$len = strlen($str);
if ($len <= $start + $end) return $str;
return substr($str, 0, $start)
. str_repeat($maskChar, $len - $start - $end)
. substr($str, -$end);
}
// === 身份證遮罩 ===
public static function taiwanId($id) {
// 格式: A123456789 (1 字母 + 9 數字)
if (!preg_match('/^[A-Z][12]\d{8}$/i', $id)) return $id;
return substr($id, 0, 1) . str_repeat('*', 6) . substr($id, -3);
}
public static function chinaId($id) {
// 18 位: 6地址 + 8出生 + 3順序 + 1校驗
if (strlen($id) === 18) {
return substr($id, 0, 6) . '********' . substr($id, -4);
}
// 15 位舊版
if (strlen($id) === 15) {
return substr($id, 0, 6) . '*****' . substr($id, -4);
}
return $id;
}
// === IP 遮罩 ===
public static function ipv4($ip) {
$parts = explode('.', $ip);
if (count($parts) !== 4) return $ip;
$parts[3] = '*';
return implode('.', $parts);
}
public static function ipv6($ip) {
// 簡化:顯示前綴 /64
if (strpos($ip, ':') === false) return $ip;
return preg_replace('/(:[0-9a-fA-F]{1,4}){4}$/', '::/64', $ip);
}
// === 手機遮罩 ===
public static function taiwanMobile($mobile) {
// 09XXXXXXXX 或 09XX-XXX-XXX
$mobile = preg_replace('/\D/', '', $mobile);
if (preg_match('/^09\d{8}$/', $mobile)) {
return '09**' . substr($mobile, 4); // 09**123456
}
return $mobile;
}
public static function chinaMobile($mobile) {
// 11 位: 138****1234
if (preg_match('/^1[3-9]\d{9}$/', $mobile)) {
return substr($mobile, 0, 3) . '****' . substr($mobile, -4);
}
return $mobile;
}
// === 銀行卡遮罩 ===
public static function bankCard($card) {
$card = preg_replace('/\s+/', '', $card);
$len = strlen($card);
if ($len < 10) return $card;
// 顯示首 6 (BIN) + 尾 4
return substr($card, 0, 6) . str_repeat('*', $len - 10) . substr($card, -4);
}
// === Email 遮罩 ===
public static function email($email) {
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) return $email;
list($local, $domain) = explode('@', $email);
$masked_local = strlen($local) > 2
? $local[0] . '***' . $local[strlen($local)-1]
: $local[0] . '*';
return $masked_local . '@' . $domain;
}
}
// === 使用範例 ===
$test_data = [
'chinese_name' => '張小明',
'english_name' => 'johnsmith',
'taiwan_id' => 'A123456789',
'china_id' => '110101199003071234',
'ipv4' => '192.168.1.100',
'ipv6' => '2001:0db8:85a3:0000:0000:8a2e:0370:7334',
'taiwan_mobile' => '0912-345-678',
'china_mobile' => '13812345678',
'bank_card' => '6225 8800 1234 5678',
'email' => 'john.smith@example.com',
];
foreach ($test_data as $type => $value) {
$method = $type;
if (method_exists('DataMasker', $method)) {
echo "$type: " . DataMasker::$method($value) . "\n";
}
}
/* 輸出:
chinese_name: 張*明
english_name: j*******h
taiwan_id: A******789
china_id: 110101********1234
ipv4: 192.168.1.*
ipv6: 2001:db8::/64
taiwan_mobile: 09**345678
china_mobile: 138****5678
bank_card: 622588******5678
email: j***@example.com
*/
<?php
// 身份證 (陸 18 位)
function maskChinaId($id) {
return strlen($id) === 18
? substr($id, 0, 6) . '********' . substr($id, -4)
: $id;
}
// IP 最後一段
function maskIp($ip) {
return preg_replace('/(\d+\.\d+\.\d+)\.\d+/', '$1.*', $ip);
}
// 手機號 (陸) 中間 4 碼
function maskMobile($mobile) {
return preg_replace('/(\d{3})\d{4}(\d{4})/', '$1****$2', $mobile);
}
// 手機號 (陸) 通用版 (支援 +86、0086、區碼)
function maskMobileUniversal($phone) {
// 固話: 010-88889999 → 010-****9999
if (preg_match('/^0\d{2,3}-?\d{7,8}$/', $phone)) {
return preg_replace('/(0\d{2,3}-?\d{3,4})\d{3,4}(\d{3,4})/', '$1****$2', $phone);
}
// 手機: 13812345678 → 138****5678
return preg_replace('/(^|\D)1[3-9]\d(\d{4})(\d{4})($|\D)/', '$11$2****$3$4', $phone);
}
// 銀行卡 (每 4 位分組顯示)
function maskBankCard($card) {
$card = preg_replace('/\s+/', '', $card);
return preg_replace('/(\d{6})\d+(\d{4})/', '$1******$2', $card);
}
-- 建立遮罩視圖,應用層直接查詢 view 無需額外處理
CREATE VIEW v_users_masked AS
SELECT
id,
CONCAT(SUBSTRING(username, 1, 1), REPEAT('*', CHAR_LENGTH(username)-2), SUBSTRING(username, -1)) AS username,
CONCAT(SUBSTRING(real_name, 1, 1), REPEAT('*', CHAR_LENGTH(real_name)-1)) AS real_name,
CONCAT(SUBSTRING(id_card, 1, 6), '********', SUBSTRING(id_card, -4)) AS id_card,
CONCAT(SUBSTRING(mobile, 1, 3), '****', SUBSTRING(mobile, -4)) AS mobile,
CONCAT(SUBSTRING(email, 1, 1), '***', SUBSTRING(email, LOCATE('@', email))) AS email,
CONCAT(SUBSTRING(bank_card, 1, 6), REPEAT('*', LENGTH(bank_card)-10), SUBSTRING(bank_card, -4)) AS bank_card,
created_at
FROM users;
-- 權限控制:一般帳號只能查 view,管理員可查 base table
GRANT SELECT ON db.v_users_masked TO 'app_user'@'%';
GRANT SELECT ON db.users TO 'admin'@'%';
<!-- Vue 3 範例 -->
{{ field.label }}:
{{ hasPermission ? field.raw : field.masked }}
遮罩 ≠ 加密。遮罩是為了「顯示安全」,資料庫儲存仍需加密 (AES-256) 或雜湊 (bcrypt/Argon2)。兩者層級不同,缺一不可。