字段解密授权态 + 统一脱敏渲染 + 按作用域控制(PHP版)
设计思路
核心上不要做成“前端自己解密”,而应由服务端统一判断字段权限并返回结果,因为列级权限/数据脱敏本质上应在服务端的数据集或字段访问层生效,这样更安全,也便于审计。[help.aliyun]
同时把你的两种场景拆成两类作用域:列表页用 scope = column,详情页用 scope = field,前者一次放开整列 A,后者只放开某个详情字段如 email,两者共用一套授权记录表和渲染规则即可。[cnblogs]
接口模型
建议至少有 4 类接口:列表查询、详情查询、隐私授权开关、隐私状态查询;其中“开关接口”只负责写入当前用户对某页面某字段的可见授权,不直接返回业务数据,这样就符合你说的“点眼睛后再刷新列表/详情才看到明文”的交互。[cnblogs]
| 接口 | 方法 | 用途 |
|---|---|---|
| /api/customer/list | GET | 返回列表数据,A 列按当前授权态决定脱敏或明文。 |
| /api/customer/detail | GET | 返回详情数据,email 等字段按字段级授权态决定脱敏或明文。 |
| /api/privacy/grant | POST | 点击小眼睛后写入授权,如解锁某列表列或某详情字段。 |
| /api/privacy/revoke | POST | 再次点击小眼睛时取消授权,恢复脱敏显示。 |
推荐 grant 入参统一如下:
{
"bizType": "customer",
"pageCode": "customer_list",
"scope": "column",
"targetKey": "A",
"recordId": null,
"ttl": 300
}详情页字段解锁则是:
{
"bizType": "customer",
"pageCode": "customer_detail",
"scope": "field",
"targetKey": "email",
"recordId": 10001,
"ttl": 300
}这里的差异点是:列表列级通常 recordId 为空,表示对当前页面查询结果中的该列整体放开;详情字段级通常要绑定具体记录 recordId,避免用户先解锁一个详情字段,再串到别的记录上也能看明文。[help.aliyun]
数据表设计
建议至少有三张表:字段配置表、隐私授权表、审计日志表;这种做法接近“列级权限 + 脱敏规则”的通用实现,能把字段规则和用户临时可见状态解耦。[cloud.tencent]
1)字段隐私配置表 privacy_field_config
CREATE TABLE privacy_field_config (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
biz_type VARCHAR(50) NOT NULL,
page_code VARCHAR(50) NOT NULL,
field_key VARCHAR(50) NOT NULL,
field_name VARCHAR(100) NOT NULL,
scope ENUM('column','field') NOT NULL,
is_sensitive TINYINT NOT NULL DEFAULT 1,
mask_type VARCHAR(30) NOT NULL,
mask_rule JSON NULL,
grant_mode ENUM('manual_click','role_direct') NOT NULL DEFAULT 'manual_click',
status TINYINT NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_page_field (biz_type, page_code, field_key)
);示例数据:
- 列表页 customer_list 的 A 字段:scope=column
- 详情页 customer_detail 的 email 字段:scope=field
这类字段可以配置成“默认脱敏,点击后放开”,也可以对部分角色配置成默认明文。[cnblogs]
2)用户隐私授权表 privacy_grant
CREATE TABLE privacy_grant (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
biz_type VARCHAR(50) NOT NULL,
page_code VARCHAR(50) NOT NULL,
scope ENUM('column','field') NOT NULL,
target_key VARCHAR(50) NOT NULL,
record_id BIGINT NULL,
granted TINYINT NOT NULL DEFAULT 1,
expire_at DATETIME NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_grant (user_id, biz_type, page_code, scope, target_key, record_id)
);这张表就是“小眼睛开关状态”。建议加 expire_at,比如 5 分钟或 30 分钟后自动失效,减少敏感数据长时间暴露的风险;很多数据脱敏实践都强调应结合访问规则而不是永久展示。[cnblogs]
3)访问审计表 privacy_audit_log
CREATE TABLE privacy_audit_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
biz_type VARCHAR(50) NOT NULL,
page_code VARCHAR(50) NOT NULL,
scope ENUM('column','field') NOT NULL,
target_key VARCHAR(50) NOT NULL,
record_id BIGINT NULL,
action VARCHAR(30) NOT NULL,
client_ip VARCHAR(64) NULL,
user_agent VARCHAR(255) NULL,
remark VARCHAR(255) NULL,
created_at DATETIME NOT NULL
);敏感字段查看最好留痕,尤其是邮箱、手机号、身份证这类字段;这样后续能追查谁在什么时候打开过明文显示。[blog.csdn]
服务端处理流程
后端不要在 Controller 里到处手写 if/else,建议统一成“查询原始数据 -> 走字段渲染器 -> 输出 DTO”。这种统一脱敏出口更适合后续扩展更多字段。[cloud.tencent]
列表页流程
- 查询列表原始数据。
- 读取当前页的隐私字段配置。
- 判断当前用户对 customer_list.A 是否已有 scope=column 的有效授权。
- 如果有授权,整列 A 返回明文;否则整列 A 统一返回脱敏值。
- 返回时额外带上字段可见状态,前端决定小眼睛是闭眼还是开眼。[cnblogs]
返回示例:
{
"list": [
{ "id": 1, "name": "张三", "A": "138****5678" },
{ "id": 2, "name": "李四", "A": "139****4321" }
],
"privacyMeta": {
"columns": {
"A": {
"granted": false,
"scope": "column",
"canToggle": true
}
}
}
}点击列表头小眼睛后:
textPOST /api/privacy/grant
{
"bizType": "customer",
"pageCode": "customer_list",
"scope": "column",
"targetKey": "A",
"ttl": 300
}成功后前端重新调 /api/customer/list,此时 A 整列显示明文。[cnblogs]
详情页流程
- 查询详情原始记录。
- 读取详情页隐私字段配置。
- 判断当前用户对 customer_detail.email + recordId 是否已有 scope=field 的有效授权。
- 有授权则 email 返回明文,否则返回脱敏值。
- 返回 privacyMeta.fields.email.granted 供前端决定图标状态。[cnblogs]
返回示例:
{
"id": 10001,
"name": "张三",
"email": "zh***@example.com",
"mobile": "138****5678",
"privacyMeta": {
"fields": {
"email": {
"granted": false,
"scope": "field",
"canToggle": true,
"recordId": 10001
}
}
}
}PHP 分层建议
比较稳妥的写法是 5 层:Controller、Application Service、Repository、PrivacyGrantService、MaskingService;数据脱敏应做成独立服务,便于列表和详情共用。[cnblogs]
1)脱敏服务
class MaskingService
{
public function mask(string $type, ?string $value, array $rule = []): ?string
{
if ($value === null || $value === '') {
return $value;
}
return match ($type) {
'mobile' => substr($value, 0, 3) . '****' . substr($value, -4),
'email' => $this->maskEmail($value),
'full' => '******',
'custom' => $this->maskCustom($value, $rule),
default => '******',
};
}
private function maskEmail(string $email): string
{
if (!str_contains($email, '@')) {
return '******';
}
[$name, $domain] = explode('@', $email, 2);
$prefix = mb_substr($name, 0, 2);
return $prefix . '***@' . $domain;
}
private function maskCustom(string $value, array $rule): string
{
$prefix = $rule['prefix'] ?? 0;
$suffix = $rule['suffix'] ?? 0;
$len = mb_strlen($value);
if ($prefix + $suffix >= $len) {
return str_repeat('*', $len);
}
return mb_substr($value, 0, $prefix)
. str_repeat('*', $len - $prefix - $suffix)
. mb_substr($value, $len - $suffix);
}
}邮箱、手机号做局部脱敏是常见方案,而不是一定返回全星号;列级权限文档里也常见“保留前后字符”“自定义脱敏规则”的做法。[cnblogs]
2)授权判断服务
class PrivacyGrantService
{
public function hasGrant(
int $userId,
string $bizType,
string $pageCode,
string $scope,
string $targetKey,
?int $recordId = null
): bool {
// 查询 privacy_grant
// 条件:user_id + biz_type + page_code + scope + target_key + record_id
// granted = 1 且 (expire_at is null or expire_at > now)
return true;
}
public function grant(
int $userId,
string $bizType,
string $pageCode,
string $scope,
string $targetKey,
?int $recordId,
int $ttl = 300
): void {
// upsert 到 privacy_grant
// expire_at = now + ttl
// 写 privacy_audit_log
}
public function revoke(
int $userId,
string $bizType,
string $pageCode,
string $scope,
string $targetKey,
?int $recordId
): void {
// granted = 0 或直接删除
// 写 privacy_audit_log
}
}3)统一字段渲染器
class PrivacyRenderService
{
public function __construct(
private PrivacyGrantService $grantService,
private MaskingService $maskingService
) {}
public function renderList(array $rows, array $fieldConfigs, int $userId): array
{
$privacyMeta = ['columns' => []];
foreach ($fieldConfigs as $config) {
if ($config['scope'] !== 'column') {
continue;
}
$granted = $this->grantService->hasGrant(
$userId,
$config['biz_type'],
$config['page_code'],
'column',
$config['field_key'],
null
);
$privacyMeta['columns'][$config['field_key']] = [
'granted' => $granted,
'scope' => 'column',
'canToggle' => true,
];
if (!$granted) {
foreach ($rows as &$row) {
if (array_key_exists($config['field_key'], $row)) {
$row[$config['field_key']] = $this->maskingService->mask(
$config['mask_type'],
(string)$row[$config['field_key']],
$config['mask_rule'] ?? []
);
}
}
}
}
return ['list' => $rows, 'privacyMeta' => $privacyMeta];
}
public function renderDetail(array $data, array $fieldConfigs, int $userId, int $recordId): array
{
$privacyMeta = ['fields' => []];
foreach ($fieldConfigs as $config) {
if ($config['scope'] !== 'field') {
continue;
}
$granted = $this->grantService->hasGrant(
$userId,
$config['biz_type'],
$config['page_code'],
'field',
$config['field_key'],
$recordId
);
$privacyMeta['fields'][$config['field_key']] = [
'granted' => $granted,
'scope' => 'field',
'canToggle' => true,
'recordId' => $recordId,
];
if (!$granted && array_key_exists($config['field_key'], $data)) {
$data[$config['field_key']] = $this->maskingService->mask(
$config['mask_type'],
(string)$data[$config['field_key']],
$config['mask_rule'] ?? []
);
}
}
$data['privacyMeta'] = $privacyMeta;
return $data;
}
}这个设计的重点是:列表按列批量处理,详情按单字段处理,但代码只在“是否带 recordId”和 scope 上有差异,整体模型一致。[cnblogs]
接口示例
1)列表接口
class CustomerController
{
public function list()
{
$userId = auth()->id();
$rows = $this->customerRepo->getList($_GET);
$fieldConfigs = $this->fieldConfigRepo->getByPage('customer', 'customer_list');
$result = $this->privacyRenderService->renderList($rows, $fieldConfigs, $userId);
return $this->jsonSuccess($result);
}
}2)详情接口
public function detail()
{
$userId = auth()->id();
$id = (int)$_GET['id'];
$data = $this->customerRepo->getDetail($id);
$fieldConfigs = $this->fieldConfigRepo->getByPage('customer', 'customer_detail');
$result = $this->privacyRenderService->renderDetail($data, $fieldConfigs, $userId, $id);
return $this->jsonSuccess($result);
}3)授权开关接口
class PrivacyController
{
public function grant()
{
$userId = auth()->id();
$body = json_decode(file_get_contents('php://input'), true);
$this->validateGrantRequest($body);
$this->privacyGrantService->grant(
$userId,
$body['bizType'],
$body['pageCode'],
$body['scope'],
$body['targetKey'],
$body['recordId'] ?? null,
$body['ttl'] ?? 300
);
return $this->jsonSuccess(['granted' => true]);
}
public function revoke()
{
$userId = auth()->id();
$body = json_decode(file_get_contents('php://input'), true);
$this->privacyGrantService->revoke(
$userId,
$body['bizType'],
$body['pageCode'],
$body['scope'],
$body['targetKey'],
$body['recordId'] ?? null
);
return $this->jsonSuccess(['granted' => false]);
}
}前后端协作规则
前端只负责展示和触发,不负责判断是否可看明文;因为真正的列级/字段级权限应该由后端根据授权对象和规则生效。[cnblogs]
建议前端遵循下面规则:
- 列表头小眼睛点击 -> POST /api/privacy/grant,参数 scope=column,targetKey=A。
- 成功后刷新列表 -> /api/customer/list。
- 详情字段旁小眼睛点击 -> POST /api/privacy/grant,参数 scope=field,targetKey=email,recordId=10001。
- 成功后刷新详情 -> /api/customer/detail?id=10001。
- 页面根据 privacyMeta 决定图标开闭状态,不靠本地缓存硬编码。
这样即便用户刷新页面、切分页、切路由,状态也能以服务端授权记录为准。[cnblogs]
安全细节
有几点建议最好一开始就加上:
- 不要把明文和脱敏字段同时返回,比如不要返回 email_masked 和 email_raw 给前端二选一,否则前端其实已经拿到明文了。[blog.csdn]
- grant 接口除了登录校验,还应校验用户是否本来就有“查看敏感字段”的基础资格,比如角色、部门、数据范围,不应任何登录用户点一下就能看。[cnblogs]
- 详情字段建议绑定 recordId,避免字段授权被横向复用到别的记录。[cnblogs]
- 建议设置短期 TTL、二次验证或操作审计,尤其是邮箱、手机、身份证、银行卡等字段。[blog.csdn]
- 如果安全要求更高,小眼睛点击前可增加短信验证码、密码确认或一次性口令;数据脱敏实践里常见“本人验证后再看明文”的模式。[cnblogs]
推荐落地方案
最适合你的不是“给每个页面单独写一套逻辑”,而是做成一个通用框架:
- privacy_field_config 负责定义哪些字段敏感、按列还是按字段控制。[cnblogs]
- privacy_grant 负责记录某用户当前是否已点开小眼睛。[cnblogs]
- PrivacyRenderService 负责统一脱敏输出。[cnblogs]
- 列表页和详情页都只调用各自查询接口,永远不直接请求“解密后的业务字段”。
这样后续你再加手机号、身份证、地址、银行卡,只是加配置,不需要重新改一堆业务代码。[help.aliyun]
如果你要,我下一步可以直接给你一版可落地的 PHP 代码骨架,我可以按这 3 种风格任选一种继续写:
- 原生 PHP + PDO,
- Laravel 版本,
- ThinkPHP 版本。